diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..855ad89 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [".env"] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/bot" + cmd = "go build -o ./tmp/bot ." + delay = 1000 + entrypoint = ["./tmp/bot"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "2s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c012acc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.env +*.md +Dockerfile +docker-compose*.yml +.dockerignore diff --git a/.gitignore b/.gitignore index 7d10f7d..88f1524 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env -.cursor \ No newline at end of file +.cursor +tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d934204 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /velox-bot . + +# Run stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /velox-bot . + +ENTRYPOINT ["./velox-bot"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..bba4fbf --- /dev/null +++ b/Justfile @@ -0,0 +1,8 @@ +up: + docker compose up -d + +down: + docker compose down + +start: up + air \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..880e267 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Velox Bot + +A Discord bot written in Go with a PostgreSQL backing store. + +It currently includes: + +- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …) +- Leveling system (opt-in per server via `/slvl toggle`) +- Meeting rooms (voice lobby → auto-created temporary voice channels) +- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule) +- Projects (list/create projects + helpers) +- Music (Lavalink-powered `/play`, `/queue`, `/volume`) +- Twitch live notifications (per-guild `/config` setup) +- Welcome messages, DMs, and default role assignment (per-guild `/config` setup) +- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`) +- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions) + +When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions. + +## Requirements + +- Go (see `go.mod`) +- PostgreSQL 16+ (or use the provided `docker-compose.yml`) +- (Optional) [Air](https://github.com/air-verse/air) for live reload during development +- (Optional) Lavalink if you want music commands + +## Configuration + +The app loads environment variables from `.env` (see `internal/config/config.go`). + +Required: + +- `BOT_TOKEN`: Discord bot token +- `APP_ID`: Discord application ID +- `GUILD_ID`: Target guild/server ID (commands are registered per-guild) +- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable` + +Optional (music): + +- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup) +- `LAVALINK_PASSWORD`: Lavalink password + +Optional (Twitch live notifications): + +- `TWITCH_CLIENT_ID`: Twitch Client ID used to query live status + +## Database setup + +If you use the compose file: + +```bash +docker compose up -d +``` + +Then apply the schema: + +```bash +psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql +``` + +## Run + +Plain Go: + +```bash +go run . +``` + +With live reload (Air): + +```bash +air +``` + +If you use `just`, a small workflow is included: + +```bash +just up # start postgres +just start # start postgres + run air +just down # stop postgres +``` + +## Commands (high-level) + +- **Fun** + - `/ping`, `/joke [type]`, `/coinflip`, `/dice ` + - `/rps `, `/rpsstats`, `/rpsleaderboard` (server only) +- **Help** + - `/help` (sends a DM with command reference) +- **Leveling** + - `/slvl ...` admin/config + - `/rank`, `/leaderboard`, `/rewards` +- **Meetings** (**Manage Server**) + - `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite` +- **Timezone** + - `/timezone set`, `/timezone show` +- **Scheduling** + - `/schedule create`, `/schedule list`, `/schedule reschedule` +- **Projects** + - `/projects list`, `/projects create`, `/projects add-helper` +- **Music** (requires **DJ** role) + - `/play`, `/queue`, `/volume` +- **Moderation** (focused + practical) + - `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**) + - `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**) + - `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**) + - `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**) + - `/moderation unban user_id: [reason]` — Unban by user ID (**Ban Members**) + - `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**) + - `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**) +- **Twitch live notifications** (**Manage Server**) + - `/config addstreamer username:` — Add a streamer for notifications + - `/config removestreamer username:` — Remove a streamer + - `/config settwitchnotificationchannel channel:#channel` — Channel for live pings +- **Welcome / onboarding** (**Manage Server**) + - `/config setwelcomechannel channel:#channel` — Channel for welcome messages + - `/config setwelcomemessage message:"..."` — Server welcome template (supports `{user}`, `{server}`) + - `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`) + - `/config setwelcomegif url:` — GIF/image used in welcome embed + - `/config setdefaultrole role:@role` — Role automatically granted on join +- **Logging** (**Manage Server**) + - `/config setlogchannel channel:#channel` — Set where logs are sent + - `/config enablelogging` — Enable event/moderation logs + - `/config disablelogging` — Disable event/moderation logs + +## Notes + +- Slash commands are registered to the configured `GUILD_ID` on startup. +- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a42b76c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.9" + +services: + bot: + build: . + container_name: velox-bot + restart: unless-stopped + env_file: .env + environment: + DB_HOST: postgres://velox:velox_pwd@db:5432/velox?sslmode=disable + LAVALINK_HOST: ${LAVALINK_HOST:-ws://lavalink:2333} + LAVALINK_PASSWORD: ${LAVALINK_PASSWORD:-youshallnotpass} + depends_on: + db: + condition: service_healthy + networks: + - default + - lavalink + +networks: + lavalink: + external: true \ No newline at end of file diff --git a/go.mod b/go.mod index bb28788..82844e6 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,23 @@ go 1.26.1 require ( github.com/bwmarrin/discordgo v0.29.0 + github.com/disgoorg/disgolink/v3 v3.1.0 + github.com/disgoorg/snowflake/v2 v2.0.3 + github.com/fogleman/gg v1.3.0 + github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 + golang.org/x/image v0.37.0 ) require ( - github.com/gorilla/websocket v1.4.2 // indirect + github.com/disgoorg/json v1.2.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 04715b6..a38ba93 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,53 @@ 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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +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/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8= +github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8= +github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y= +github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA= +github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro= +github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 5ed2147..bedea5d 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -3,6 +3,9 @@ package bot import ( "log" "velox-bot/internal/commands" + "velox-bot/internal/db/services" + "velox-bot/internal/events" + "velox-bot/internal/music" "github.com/bwmarrin/discordgo" ) @@ -13,19 +16,41 @@ type Bot struct { GuildID string Commands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand + Services *services.Services + LavalinkHost string + LavalinkPass string } -func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand) (*Bot, error) { +func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) { session, err := discordgo.New("Bot " + token) if err != nil { return nil, err } + // Intents needed for: + // - InteractionCreate (slash commands): Guilds + // - MessageCreate (leveling): GuildMessages + // - GuildMemberAdd (welcome messages): GuildMembers + // - VoiceStateUpdate (meeting lobby): GuildVoiceStates + // - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions + session.Identify.Intents = discordgo.IntentsGuilds | + discordgo.IntentsGuildBans | + discordgo.IntentsGuildMembers | + discordgo.IntentsGuildMessages | + discordgo.IntentsMessageContent | + discordgo.IntentsGuildVoiceStates | + discordgo.IntentsGuildMessageReactions | + discordgo.IntentsDirectMessages | + discordgo.IntentsDirectMessageReactions + return &Bot{ - Session: session, - AppID: appID, - GuildID: guildID, - Commands: cmds, + Session: session, + AppID: appID, + GuildID: guildID, + Commands: cmds, + Services: services, + LavalinkHost: lavalinkHost, + LavalinkPass: lavalinkPass, }, nil } @@ -34,6 +59,8 @@ func (b *Bot) Start() error { return err } + _ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass) + b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) for _, cmd := range b.Commands { created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) @@ -45,8 +72,64 @@ func (b *Bot) Start() error { } b.Session.AddHandler(commands.HandleInteraction) - return nil + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + events.HandleMessageCreate(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) { + events.HandleBotAddedOnGuild(s, m, b.Services) + + // Ignore bot members for welcome/log onboarding of normal users. + if m != nil && m.Member != nil && m.User != nil && m.User.Bot { + return + } + + events.HandleGuildMemberAdd(s, m, b.Services) + events.HandleGuildMemberAddLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) { + events.HandleGuildMemberRemoveLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) { + events.HandleGuildMemberUpdateLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) { + events.HandleMessageUpdateLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) { + events.HandleMessageDeleteLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) { + events.HandleGuildBanAddLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) { + events.HandleGuildBanRemoveLog(s, m, b.Services) + }) + + b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { + events.HandleVoiceStateUpdate(s, vs, b.Services) + music.OnVoiceStateUpdate(vs) + }) + + b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) { + music.OnVoiceServerUpdate(ev) + }) + + b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) { + events.HandleMessageReactionAdd(s, r, b.Services) + }) + + events.StartScheduleReminderLoop(b.Session, b.Services) + events.StartTwitchLiveLoop(b.Session, b.Services) + + return nil } func (b *Bot) Close() error { diff --git a/internal/commands/commands.go b/internal/commands/commands.go index fdafb6a..16a972d 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,21 +1,79 @@ package commands import ( + "velox-bot/internal/commands/config" "velox-bot/internal/commands/fun" + "velox-bot/internal/commands/help" + cmdlevel "velox-bot/internal/commands/level" + "velox-bot/internal/commands/meeting" + "velox-bot/internal/commands/moderation" + cmdmusic "velox-bot/internal/commands/music" + "velox-bot/internal/commands/projects" + "velox-bot/internal/commands/schedule" + "velox-bot/internal/commands/timezone" + "velox-bot/internal/music" "github.com/bwmarrin/discordgo" ) var AllCommands = []*discordgo.ApplicationCommand{ - fun.PingCommand, + fun.Ping, + fun.Joke, + fun.Coinflip, + fun.Dice, + fun.Rps, + fun.RpsStats, + fun.RpsLeaderboard, + help.Help, + cmdlevel.Slvl, + cmdlevel.Rank, + cmdlevel.Leaderboard, + cmdlevel.Rewards, + cmdmusic.Play, + cmdmusic.Queue, + cmdmusic.Volume, + meeting.Meeting, + moderation.Moderation, + timezone.Timezone, + schedule.Schedule, + projects.Projects, + config.Config, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "ping": fun.HandlePing, + "ping": fun.PingHandler, + "joke": fun.JokeHandler, + "coinflip": fun.CoinflipHandler, + "dice": fun.DiceHandler, + "rps": fun.RpsHandler, + "rpsstats": fun.RpsStatsHandler, + "rpsleaderboard": fun.RpsLeaderboardHandler, + "help": help.HelpHandler, + "slvl": cmdlevel.SlvlHandler, + "rank": cmdlevel.RankHandler, + "leaderboard": cmdlevel.LeaderboardHandler, + "rewards": cmdlevel.RewardsHandler, + "meeting": meeting.MeetingHandler, + "moderation": moderation.ModerationHandler, + "timezone": timezone.TimezoneHandler, + "schedule": schedule.ScheduleHandler, + "projects": projects.ProjectsHandler, + "play": cmdmusic.PlayHandler, + "queue": cmdmusic.QueueHandler, + "volume": cmdmusic.VolumeHandler, + "config": config.ConfigHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := handlers[i.ApplicationCommandData().Name]; ok { - h(s, i) + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := handlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + case discordgo.InteractionMessageComponent: + data := i.MessageComponentData() + if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" { + music.HandleComponent(s, i) + } } } diff --git a/internal/commands/config/public/config.go b/internal/commands/config/public/config.go new file mode 100644 index 0000000..9adbd9e --- /dev/null +++ b/internal/commands/config/public/config.go @@ -0,0 +1,578 @@ +package public + +import ( + "context" + "log" + "strconv" + "strings" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Config = &discordgo.ApplicationCommand{ + Name: "config", + Description: "Server configuration", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "addstreamer", + Description: "Add a Twitch streamer for live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "username", + Description: "Twitch username (without https://)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "removestreamer", + Description: "Remove a Twitch streamer from live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "username", + Description: "Twitch username to remove", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "settwitchnotificationchannel", + Description: "Set the channel for Twitch live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel for notifications", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setwelcomechannel", + Description: "Set the channel for welcome messages", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel for welcomes", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setwelcomemessage", + Description: "Set the welcome message template shown in the server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "message", + Description: "Use {user} where the new member mention should appear", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setwelcomedm", + Description: "Set the welcome DM template sent to new members", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "message", + Description: "Use {user} where the new member mention should appear", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setwelcomegif", + Description: "Set the GIF/image URL used in welcome messages", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "url", + Description: "Direct image or GIF URL", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setdefaultrole", + Description: "Set the default role given to new members", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionRole, + Name: "role", + Description: "Role to assign on join", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "setlogchannel", + Description: "Set the moderation log channel", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel for logs", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "enablelogging", + Description: "Enable server logging events", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "disablelogging", + Description: "Disable server logging events", + }, + }, +} + +func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + respondEphemeral(s, i, "This command can only be used in a server.") + return + } + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this.") + return + } + if services.Global == nil { + respondEphemeral(s, i, "Configuration services are not available.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + respondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "addstreamer": + log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleAddStreamer(s, i) + case "removestreamer": + log.Printf("twitch: /config removestreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleRemoveStreamer(s, i) + case "settwitchnotificationchannel": + log.Printf("twitch: /config settwitchnotificationchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetChannel(s, i) + case "setwelcomechannel": + log.Printf("welcome: /config setwelcomechannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetWelcomeChannel(s, i) + case "setwelcomemessage": + log.Printf("welcome: /config setwelcomemessage invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetWelcomeMessage(s, i) + case "setwelcomedm": + log.Printf("welcome: /config setwelcomedm invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetWelcomeDM(s, i) + case "setwelcomegif": + log.Printf("welcome: /config setwelcomegif invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetWelcomeGIF(s, i) + case "setdefaultrole": + log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetDefaultRole(s, i) + case "setlogchannel": + log.Printf("log: /config setlogchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetLogChannel(s, i) + case "enablelogging": + log.Printf("log: /config enablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetLoggingEnabled(s, i, true) + case "disablelogging": + log.Printf("log: /config disablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetLoggingEnabled(s, i, false) + default: + respondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleAddStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var username string + for _, o := range opt.Options { + if o.Name == "username" { + username = strings.TrimSpace(o.StringValue()) + } + } + username = strings.TrimPrefix(username, "https://www.twitch.tv/") + username = strings.TrimPrefix(username, "http://www.twitch.tv/") + username = strings.TrimPrefix(username, "twitch.tv/") + username = strings.TrimSpace(username) + + if username == "" { + respondEphemeral(s, i, "Username cannot be empty.") + return + } + + if err := services.Global.Twitch.UpsertStreamer(context.Background(), guildID, username); err != nil { + respondEphemeral(s, i, "Failed to add streamer.") + return + } + + respondEphemeral(s, i, "Added `"+username+"` for Twitch live notifications.") +} + +func handleRemoveStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var username string + for _, o := range opt.Options { + if o.Name == "username" { + username = strings.TrimSpace(o.StringValue()) + } + } + username = strings.TrimSpace(username) + if username == "" { + respondEphemeral(s, i, "Username cannot be empty.") + return + } + + if err := services.Global.Twitch.RemoveStreamer(context.Background(), guildID, username); err != nil { + respondEphemeral(s, i, "Failed to remove streamer.") + return + } + + respondEphemeral(s, i, "Removed `"+username+"` from Twitch live notifications.") +} + +func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + for _, o := range opt.Options { + if o.Name == "channel" { + chOpt = o + break + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + chID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Twitch.SetNotificationChannel(context.Background(), guildID, chID); err != nil { + respondEphemeral(s, i, "Failed to set notification channel.") + return + } + + respondEphemeral(s, i, "Twitch notifications will be sent to "+ch.Mention()+".") +} + +func handleSetWelcomeChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.Welcome == nil { + respondEphemeral(s, i, "Welcome configuration is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + for _, o := range opt.Options { + if o.Name == "channel" { + chOpt = o + break + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + chID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Welcome.SetChannel(context.Background(), guildID, chID); err != nil { + respondEphemeral(s, i, "Failed to set welcome channel.") + return + } + + respondEphemeral(s, i, "Welcome messages will be sent to "+ch.Mention()+".") +} + +func handleSetWelcomeMessage(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.Welcome == nil { + respondEphemeral(s, i, "Welcome configuration is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var msg string + for _, o := range opt.Options { + if o.Name == "message" { + msg = strings.TrimSpace(o.StringValue()) + } + } + if msg == "" { + respondEphemeral(s, i, "Message cannot be empty.") + return + } + + if err := services.Global.Welcome.SetMessage(context.Background(), guildID, msg); err != nil { + respondEphemeral(s, i, "Failed to set welcome message.") + return + } + + respondEphemeral(s, i, "Welcome message updated.") +} + +func handleSetWelcomeDM(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.Welcome == nil { + respondEphemeral(s, i, "Welcome configuration is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var msg string + for _, o := range opt.Options { + if o.Name == "message" { + msg = strings.TrimSpace(o.StringValue()) + } + } + if msg == "" { + respondEphemeral(s, i, "DM message cannot be empty.") + return + } + + if err := services.Global.Welcome.SetDM(context.Background(), guildID, msg); err != nil { + respondEphemeral(s, i, "Failed to set welcome DM.") + return + } + + respondEphemeral(s, i, "Welcome DM updated.") +} + +func handleSetWelcomeGIF(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.Welcome == nil { + respondEphemeral(s, i, "Welcome configuration is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var url string + for _, o := range opt.Options { + if o.Name == "url" { + url = strings.TrimSpace(o.StringValue()) + } + } + if url == "" { + respondEphemeral(s, i, "URL cannot be empty.") + return + } + + if err := services.Global.Welcome.SetGIF(context.Background(), guildID, url); err != nil { + respondEphemeral(s, i, "Failed to set welcome GIF.") + return + } + + respondEphemeral(s, i, "Welcome GIF updated.") +} + +func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.DefaultRole == nil { + respondEphemeral(s, i, "Default role configuration is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var roleOpt *discordgo.ApplicationCommandInteractionDataOption + for _, o := range opt.Options { + if o.Name == "role" { + roleOpt = o + break + } + } + if roleOpt == nil { + respondEphemeral(s, i, "Missing role option.") + return + } + + role := roleOpt.RoleValue(s, i.GuildID) + if role == nil { + respondEphemeral(s, i, "Invalid role.") + return + } + + roleID, err := strconv.ParseInt(role.ID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid role ID.") + return + } + + if err := services.Global.DefaultRole.SetRole(context.Background(), guildID, roleID); err != nil { + respondEphemeral(s, i, "Failed to set default role.") + return + } + + respondEphemeral(s, i, "Default role set to "+role.Mention()+".") +} + +func handleSetLogChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { + if services.Global.LogSettings == nil { + respondEphemeral(s, i, "Log settings service is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + for _, o := range opt.Options { + if o.Name == "channel" { + chOpt = o + break + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.LogSettings.SetChannel(context.Background(), guildID, channelID); err != nil { + respondEphemeral(s, i, "Failed to set log channel.") + return + } + if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, true); err != nil { + respondEphemeral(s, i, "Log channel set, but failed to enable logging.") + return + } + + respondEphemeral(s, i, "Logging enabled in "+ch.Mention()+".") +} + +func handleSetLoggingEnabled(s *discordgo.Session, i *discordgo.InteractionCreate, enabled bool) { + if services.Global.LogSettings == nil { + respondEphemeral(s, i, "Log settings service is not available.") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, enabled); err != nil { + respondEphemeral(s, i, "Failed to update logging state.") + return + } + + if enabled { + respondEphemeral(s, i, "Logging is now enabled.") + return + } + respondEphemeral(s, i, "Logging is now disabled.") +} + +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + diff --git a/internal/commands/config/registry.go b/internal/commands/config/registry.go new file mode 100644 index 0000000..248a458 --- /dev/null +++ b/internal/commands/config/registry.go @@ -0,0 +1,14 @@ +package config + +import ( + "velox-bot/internal/commands/config/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Config *discordgo.ApplicationCommand = public.Config +) + +func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ConfigHandler(s, i) } + diff --git a/internal/commands/fun/public/coinflip.go b/internal/commands/fun/public/coinflip.go new file mode 100644 index 0000000..c0f67f3 --- /dev/null +++ b/internal/commands/fun/public/coinflip.go @@ -0,0 +1,22 @@ +package public + +import ( + "math/rand" + "velox-bot/internal/commands/fun/shared" + + "github.com/bwmarrin/discordgo" +) + +var Coinflip = &discordgo.ApplicationCommand{ + Name: "coinflip", + Description: "Flip a coin", +} + +func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + num := rand.Intn(2) + if num == 0 { + shared.Respond(s, i, "Heads!") + } else { + shared.Respond(s, i, "Tails!") + } +} diff --git a/internal/commands/fun/public/dice.go b/internal/commands/fun/public/dice.go new file mode 100644 index 0000000..3015718 --- /dev/null +++ b/internal/commands/fun/public/dice.go @@ -0,0 +1,62 @@ +package public + +import ( + "strings" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/diceexpr" + + "github.com/bwmarrin/discordgo" +) + +var Dice = &discordgo.ApplicationCommand{ + Name: "dice", + Description: "Roll dice expressions (e.g. d20, 2d20+1, 2#d20+1)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "expr", + Description: "Dice expression to roll", + Required: true, + }, + }, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + discordgo.InteractionContextBotDM, + discordgo.InteractionContextPrivateChannel, + }, +} + +func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + expr := "" + if opts := i.ApplicationCommandData().Options; len(opts) > 0 { + expr = opts[0].StringValue() + } + expr = strings.TrimSpace(expr) + if expr == "" { + respondDiceFailure(s, i) + return + } + + res, err := diceexpr.EvalMany(expr, diceexpr.DefaultLimits()) + if err != nil { + respondDiceFailure(s, i) + return + } + + out := diceexpr.FormatResult(res, diceexpr.DefaultFormatOptions()) + if len(out) > 1900 { + // Keep within Discord message limits; trim safely. + out = out[:1900] + "…" + } + shared.Respond(s, i, out) +} + +func respondDiceFailure(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID != "" { + shared.RespondEphemeral(s, i, "Failure!") + return + } + shared.Respond(s, i, "Failure!") +} + diff --git a/internal/commands/fun/public/joke.go b/internal/commands/fun/public/joke.go new file mode 100644 index 0000000..6bb9a47 --- /dev/null +++ b/internal/commands/fun/public/joke.go @@ -0,0 +1,153 @@ +package public + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "velox-bot/internal/commands/fun/shared" + + "github.com/bwmarrin/discordgo" +) + +var Joke = &discordgo.ApplicationCommand{ + Name: "joke", + Description: "Get a random joke", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + discordgo.InteractionContextBotDM, + discordgo.InteractionContextPrivateChannel, + }, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "type", + Description: "The type of joke to get", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Programming", + Value: "Programming", + }, + { + Name: "Misc", + Value: "Miscellaneous", + }, + { + Name: "Dark", + Value: "Dark", + }, + { + Name: "Pun", + Value: "Pun", + }, + { + Name: "Spooky", + Value: "Spooky", + }, + { + Name: "Christmas", + Value: "Christmas", + }, + }, + }, + }, +} + +func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + jokeType := "Any" + if opts := i.ApplicationCommandData().Options; len(opts) > 0 { + if v := opts[0].StringValue(); v != "" { + jokeType = v + } + } + if !isValidJokeType(jokeType) { + jokeType = "Any" + } + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + content, err := fetchJoke(ctx, jokeType) + if err != nil { + if i.GuildID != "" { + shared.RespondEphemeral(s, i, "Failed to get joke.") + } else { + shared.Respond(s, i, "Failed to get joke.") + } + return + } + + shared.Respond(s, i, content) +} + +func isValidJokeType(t string) bool { + switch t { + case "Any", "Programming", "Misc", "Dark", "Pun", "Spooky", "Christmas": + return true + default: + return false + } +} + +type jokeAPIResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + Type string `json:"type"` + Joke string `json:"joke"` + Setup string `json:"setup"` + Delivery string `json:"delivery"` +} + +func fetchJoke(ctx context.Context, jokeType string) (string, error) { + const baseURL = "https://v2.jokeapi.dev/joke" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", baseURL, jokeType), nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("joke api status: %s", resp.Status) + } + + var data jokeAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if data.Error { + if data.Message != "" { + return "", errors.New(data.Message) + } + return "", errors.New("joke api returned error") + } + + switch data.Type { + case "single": + if data.Joke == "" { + return "", errors.New("empty joke") + } + return data.Joke, nil + case "twopart": + if data.Setup == "" && data.Delivery == "" { + return "", errors.New("empty joke") + } + if data.Setup == "" { + return data.Delivery, nil + } + if data.Delivery == "" { + return data.Setup, nil + } + return data.Setup + "\n" + data.Delivery, nil + default: + return "", fmt.Errorf("unknown joke type: %q", data.Type) + } +} diff --git a/internal/commands/fun/ping.go b/internal/commands/fun/public/ping.go similarity index 69% rename from internal/commands/fun/ping.go rename to internal/commands/fun/public/ping.go index 1567bfd..f227c8d 100644 --- a/internal/commands/fun/ping.go +++ b/internal/commands/fun/public/ping.go @@ -1,13 +1,13 @@ -package fun +package public import "github.com/bwmarrin/discordgo" -var PingCommand = &discordgo.ApplicationCommand{ +var Ping = &discordgo.ApplicationCommand{ Name: "ping", Description: "Ping the bot", } -func HandlePing(s *discordgo.Session, i *discordgo.InteractionCreate) { +func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ diff --git a/internal/commands/fun/public/rps.go b/internal/commands/fun/public/rps.go new file mode 100644 index 0000000..a3ae1df --- /dev/null +++ b/internal/commands/fun/public/rps.go @@ -0,0 +1,106 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + rpssvc "velox-bot/internal/db/services/rps" + + "github.com/bwmarrin/discordgo" +) + +var Rps = &discordgo.ApplicationCommand{ + Name: "rps", + Description: "Plays Rock Paper Scissors with you", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "hand", + Description: "Choose between ✌️, ✋ or 🤜", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "✌️ - Scissors", Value: string(rpssvc.HandScissors)}, + {Name: "✋ - Paper", Value: string(rpssvc.HandPaper)}, + {Name: "🤜 - Rock", Value: string(rpssvc.HandRock)}, + }, + }, + }, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + handStr := data.Options[0].StringValue() + userHand, ok := rpssvc.ParseHand(handStr) + if !ok { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + out, err := services.Global.RPS.Play(context.Background(), guildID, userID, userHand) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to play RPS.") + return + } + + color := 0xEF4444 + switch out.Result { + case rpssvc.ResultWin: + color = 0x22C55E + case rpssvc.ResultTie: + color = 0xF59E0B + } + + embed := &discordgo.MessageEmbed{ + Title: string(out.Result), + Description: user.Mention() + " vs bot", + Color: color, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Your hand", Value: out.UserHand.String(), Inline: true}, + {Name: "Bot hand", Value: out.BotHand.String(), Inline: true}, + {Name: "Score", Value: strconv.Itoa(out.Score), Inline: true}, + }, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} diff --git a/internal/commands/fun/public/rpsleaderboard.go b/internal/commands/fun/public/rpsleaderboard.go new file mode 100644 index 0000000..40c27c5 --- /dev/null +++ b/internal/commands/fun/public/rpsleaderboard.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsLeaderboard = &discordgo.ApplicationCommand{ + Name: "rpsleaderboard", + Description: "Shows the RPS leaderboard", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + top, err := services.Global.RPS.TopScores(context.Background(), guildID, 10) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load leaderboard.") + return + } + if len(top) == 0 { + shared.RespondEphemeral(s, i, "No leaderboard data yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(top)) + for idx, entry := range top { + display := fmt.Sprintf("<@%d>", entry.UserID) + if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { + display = u.Username + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("%d. %s", idx+1, display), + Value: fmt.Sprintf("Score: **%d**", entry.Score), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Leaderboard", + Description: fmt.Sprintf("Top %d players in this server", len(top)), + Color: 0xA855F7, + Fields: fields, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/public/rpsstats.go b/internal/commands/fun/public/rpsstats.go new file mode 100644 index 0000000..9eac696 --- /dev/null +++ b/internal/commands/fun/public/rpsstats.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsStats = &discordgo.ApplicationCommand{ + Name: "rpsstats", + Description: "Shows your RPS stats", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + score, ok, err := services.Global.RPS.GetScore(context.Background(), guildID, userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load stats.") + return + } + if !ok { + shared.RespondEphemeral(s, i, "You haven't played RPS yet!") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Stats", + Description: user.Mention(), + Color: 0x3B82F6, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Score", Value: strconv.Itoa(score), Inline: true}, + }, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/registry.go b/internal/commands/fun/registry.go new file mode 100644 index 0000000..410efa9 --- /dev/null +++ b/internal/commands/fun/registry.go @@ -0,0 +1,33 @@ +package fun + +import ( + "velox-bot/internal/commands/fun/public" + + "github.com/bwmarrin/discordgo" +) + +// Commands +var ( + Ping *discordgo.ApplicationCommand = public.Ping + Joke *discordgo.ApplicationCommand = public.Joke + Coinflip *discordgo.ApplicationCommand = public.Coinflip + Dice *discordgo.ApplicationCommand = public.Dice + Rps *discordgo.ApplicationCommand = public.Rps + RpsStats *discordgo.ApplicationCommand = public.RpsStats + RpsLeaderboard *discordgo.ApplicationCommand = public.RpsLeaderboard +) + +// Handlers +func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PingHandler(s, i) } +func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.JokeHandler(s, i) } +func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.CoinflipHandler(s, i) +} +func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.DiceHandler(s, i) } +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RpsHandler(s, i) } +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsStatsHandler(s, i) +} +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsLeaderboardHandler(s, i) +} diff --git a/internal/commands/fun/shared/shared.go b/internal/commands/fun/shared/shared.go new file mode 100644 index 0000000..34be049 --- /dev/null +++ b/internal/commands/fun/shared/shared.go @@ -0,0 +1,24 @@ +package shared + +import ( + "github.com/bwmarrin/discordgo" +) + +func Respond(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + }, + }) +} + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go new file mode 100644 index 0000000..9450cdd --- /dev/null +++ b/internal/commands/help/help.go @@ -0,0 +1,152 @@ +package help + +import ( + "log" + + "github.com/bwmarrin/discordgo" +) + +var Help = &discordgo.ApplicationCommand{ + Name: "help", + Description: "Get help with the bot", +} + +func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Check your DMs for help.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + userID := "" + + // check if command is run in a guild + if i.Member != nil { + userID = i.Member.User.ID + } else { + userID = i.Interaction.User.ID + } + + dmChannel, err := s.UserChannelCreate(userID) + if err != nil { + log.Printf("Error creating DM channel: %v", err) + return + } + + author := discordgo.MessageEmbedAuthor{ + Name: "Velox Bot", + URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot", + } + + embed := &discordgo.MessageEmbed{ + Title: "Velox Bot Help", + Description: "Command reference. Some commands require server permissions or roles.", + Color: 0xFFA500, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Fun", + Value: "" + + "`/ping` — Pong!\n" + + "`/joke [type]` — Random joke (optional category)\n" + + "`/coinflip` — Flip a coin\n" + + "`/dice ` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" + + "`/rps ` — Rock Paper Scissors (server only)\n" + + "`/rpsstats` — Your RPS score (server only)\n" + + "`/rpsleaderboard` — Top RPS scores (server only)", + }, + { + Name: "Music (requires **DJ** role)", + Value: "" + + "`/play ` — Play a song (search or URL)\n" + + "`/queue` — Show the current queue\n" + + "`/volume <0-150>` — Set playback volume", + }, + { + Name: "Scheduling (1:1 sessions)", + Value: "" + + "`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" + + "`/schedule list` — List your upcoming sessions\n" + + "`/schedule reschedule id: datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" + + "Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.", + }, + { + Name: "Timezone", + Value: "" + + "`/timezone set ` — Set your timezone (e.g. `Europe/Lisbon`)\n" + + "`/timezone show` — Show your timezone", + }, + { + Name: "Projects", + Value: "" + + "`/projects list` — List active projects\n" + + "`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" + + "`/projects add-helper project-id: user:@someone` — Add helper (**Manage Server**)", + }, + { + Name: "Meetings (**Manage Server**)", + Value: "" + + "`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" + + "`/meeting lock` — Lock your meeting room\n" + + "`/meeting lock-private` — Lock + hide your meeting room\n" + + "`/meeting unlock` — Unlock your meeting room\n" + + "`/meeting invite user:@someone` — Allow a user to join\n" + + "`/meeting uninvite user:@someone` — Remove a user's permission", + }, + { + Name: "Moderation (focused tools)", + Value: "" + + "`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" + + "`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" + + "`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" + + "`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" + + "`/moderation unban user_id: [reason]` — Unban by user ID (**Ban Members**)\n" + + "`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" + + "`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)", + }, + { + Name: "Leveling", + Value: "" + + "`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" + + "`/slvl set user:@someone level:` — Set user level (**Manage Server**)\n" + + "`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" + + "`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" + + "`/slvl set-reward level: role:@role` — Role rewards (**Manage Server**)\n" + + "`/rank [user]` — Your rank card (server only)\n" + + "`/leaderboard` — Top levels (server only)\n" + + "`/rewards` — List configured rewards (server only)", + }, + { + Name: "Twitch live notifications (**Manage Server**)", + Value: "" + + "`/config addstreamer username:` — Add a streamer\n" + + "`/config removestreamer username:` — Remove a streamer\n" + + "`/config settwitchnotificationchannel channel:#channel` — Set notification channel", + }, + { + Name: "Welcome / onboarding (**Manage Server**)", + Value: "" + + "`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" + + "`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" + + "`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" + + "`/config setwelcomegif url:` — GIF/image used in welcome embed\n" + + "`/config setdefaultrole role:@role` — Role granted to new members", + }, + { + Name: "Logging (**Manage Server**)", + Value: "" + + "`/config setlogchannel channel:#channel` — Set log output channel\n" + + "`/config enablelogging` — Enable event/moderation logging\n" + + "`/config disablelogging` — Disable event/moderation logging", + }, + { + Name: "Other", + Value: "`/help` — Show this help in DMs", + }, + }, + Author: &author, + } + + s.ChannelMessageSendEmbed(dmChannel.ID, embed) +} diff --git a/internal/commands/level/config/command.go b/internal/commands/level/config/command.go new file mode 100644 index 0000000..0a6afed --- /dev/null +++ b/internal/commands/level/config/command.go @@ -0,0 +1,41 @@ +package config + +import ( + "velox-bot/internal/commands/level/shared" + + "github.com/bwmarrin/discordgo" +) + +var Slvl = &discordgo.ApplicationCommand{ + Name: "slvl", + Description: "Leveling System", + Options: []*discordgo.ApplicationCommandOption{ + slvlToggleOption(), + slvlSetLevelOption(), + slvlSetChannelOption(), + slvlSetMessageOption(), + slvlSetRewardOption(), + }, +} + +func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return + } + switch data.Options[0].Name { + case "toggle": + slvlToggleHandler(s, i) + case "set": + slvlSetLevelHandler(s, i) + case "set-channel": + slvlSetChannelHandler(s, i) + case "set-message": + slvlSetMessageHandler(s, i) + case "set-reward": + slvlSetRewardHandler(s, i) + default: + shared.RespondEphemeral(s, i, "Invalid subcommand.") + } +} + diff --git a/internal/commands/level/config/set_channel.go b/internal/commands/level/config/set_channel.go new file mode 100644 index 0000000..e2994e4 --- /dev/null +++ b/internal/commands/level/config/set_channel.go @@ -0,0 +1,68 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetChannelOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-channel", + Description: "Set the level up channel", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Channel for level-up messages", + Required: true, + }, + }, + } +} + +func slvlSetChannelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + channelOpt, ok := optMap["channel"] + if !ok { + shared.RespondEphemeral(s, i, "Missing channel option.") + return + } + + ch := channelOpt.ChannelValue(s) + if ch == nil { + shared.RespondEphemeral(s, i, "Invalid channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.LevelSettings.SetLevelUpChannel(context.Background(), guildID, channelID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level up channel.") + return + } + + shared.RespondEphemeral(s, i, "Level up channel set to "+ch.Mention()+".") +} + diff --git a/internal/commands/level/config/set_level.go b/internal/commands/level/config/set_level.go new file mode 100644 index 0000000..b3e5c43 --- /dev/null +++ b/internal/commands/level/config/set_level.go @@ -0,0 +1,84 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetLevelOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set", + Description: "Set the level of a user", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "The user to set the level of", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "level", + Description: "The level to set the user to", + Required: true, + }, + }, + } +} + +func slvlSetLevelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if services.Global == nil || services.Global.Level == nil { + shared.RespondEphemeral(s, i, "Leveling service is not available.") + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + levelOpt, ok := optMap["level"] + if !ok { + shared.RespondEphemeral(s, i, "Missing level option.") + return + } + + targetUser := userOpt.UserValue(s) + if targetUser == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + if targetUser.Bot { + shared.RespondEphemeral(s, i, "You can't set a bot's level.") + return + } + + newLevel := int(levelOpt.IntValue()) + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + userID, err := strconv.ParseInt(targetUser.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + if err := services.Global.Level.SetLevel(context.Background(), guildID, userID, newLevel); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level.") + return + } + + shared.RespondEphemeral(s, i, "Level set to "+strconv.Itoa(newLevel)+" for "+targetUser.Mention()+".") +} + diff --git a/internal/commands/level/config/set_message.go b/internal/commands/level/config/set_message.go new file mode 100644 index 0000000..839532e --- /dev/null +++ b/internal/commands/level/config/set_message.go @@ -0,0 +1,60 @@ +package config + +import ( + "context" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetMessageOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-message", + Description: "Set the level up message", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "message", + Description: "Level-up message. Variables: {user}, {level}", + Required: true, + }, + }, + } +} + +func slvlSetMessageHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + messageOpt, ok := optMap["message"] + if !ok { + shared.RespondEphemeral(s, i, "Missing message option.") + return + } + + message := messageOpt.StringValue() + if message == "" { + message = "GG {user}, you reached level {level}!" + } + + if err := services.Global.LevelSettings.SetLevelUpMessage(context.Background(), guildID, message); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level up message.") + return + } + + shared.RespondEphemeral(s, i, "Level up message set.") +} + diff --git a/internal/commands/level/config/set_reward.go b/internal/commands/level/config/set_reward.go new file mode 100644 index 0000000..1940121 --- /dev/null +++ b/internal/commands/level/config/set_reward.go @@ -0,0 +1,79 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetRewardOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-reward", + Description: "Set role reward for a level", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "level", + Description: "Level to reward", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionRole, + Name: "role", + Description: "Role to grant", + Required: true, + }, + }, + } +} + +func slvlSetRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + levelOpt, ok := optMap["level"] + if !ok { + shared.RespondEphemeral(s, i, "Missing level option.") + return + } + roleOpt, ok := optMap["role"] + if !ok { + shared.RespondEphemeral(s, i, "Missing role option.") + return + } + + level := int(levelOpt.IntValue()) + role := roleOpt.RoleValue(s, i.GuildID) + if role == nil { + shared.RespondEphemeral(s, i, "Invalid role.") + return + } + roleID, err := strconv.ParseInt(role.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid role ID.") + return + } + + if err := services.Global.LevelSettings.SetRoleRewardForLevel(context.Background(), guildID, level, roleID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set reward.") + return + } + + shared.RespondEphemeral(s, i, "Reward set: level "+strconv.Itoa(level)+" -> "+role.Mention()) +} + diff --git a/internal/commands/level/config/toggle.go b/internal/commands/level/config/toggle.go new file mode 100644 index 0000000..3ffb9ad --- /dev/null +++ b/internal/commands/level/config/toggle.go @@ -0,0 +1,43 @@ +package config + +import ( + "context" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlToggleOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "toggle", + Description: "Toggle the leveling system", + } +} + +func slvlToggleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + enabled, err := services.Global.LevelSettings.ToggleLevelSystem(context.Background(), guildID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to toggle leveling system.") + return + } + status := "disabled" + if enabled { + status = "enabled" + } + shared.RespondEphemeral(s, i, "Leveling system "+status+" for this server.") +} + diff --git a/internal/commands/level/public/leaderboard.go b/internal/commands/level/public/leaderboard.go new file mode 100644 index 0000000..3c4ca1e --- /dev/null +++ b/internal/commands/level/public/leaderboard.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Leaderboard = &discordgo.ApplicationCommand{ + Name: "leaderboard", + Description: "Top 10 levels in this server", +} + +func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) { + return + } + if !shared.RequireLevelServices(s, i) { + return + } + if !shared.RequireLevelSystemEnabled(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + ctx := context.Background() + top, err := services.Global.Level.TopLevels(ctx, guildID, 10) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load leaderboard.") + return + } + if len(top) == 0 { + shared.RespondEphemeral(s, i, "No leaderboard data yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(top)) + for idx, entry := range top { + display := fmt.Sprintf("<@%d>", entry.UserID) + if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { + display = u.Username + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("%d. %s", idx+1, display), + Value: fmt.Sprintf("Level: **%d** | XP: **%d**", entry.Level, entry.XP), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "Leaderboard", + Description: fmt.Sprintf("These are the top %d users in the server", len(top)), + Fields: fields, + Color: 0xF59E0B, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/level/public/rank.go b/internal/commands/level/public/rank.go new file mode 100644 index 0000000..4866681 --- /dev/null +++ b/internal/commands/level/public/rank.go @@ -0,0 +1,94 @@ +package public + +import ( + "bytes" + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + "velox-bot/internal/rankcard" + + "github.com/bwmarrin/discordgo" +) + +var Rank = &discordgo.ApplicationCommand{ + Name: "rank", + Description: "Get your level card", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "The user to get the rank card of", + Required: false, + }, + }, +} + +func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireLevelServices(s, i) { + return + } + if i.GuildID != "" && !shared.RequireLevelSystemEnabled(s, i) { + return + } + + // Determine target user: option or caller. + target := i.User + if i.Member != nil && i.Member.User != nil { + target = i.Member.User + } + if len(i.ApplicationCommandData().Options) > 0 { + if u := i.ApplicationCommandData().Options[0].UserValue(s); u != nil { + target = u + } + } + if target == nil { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + userID, err := strconv.ParseInt(target.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + ctx := context.Background() + lvl, err := services.Global.Level.GetLevel(ctx, guildID, userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load level.") + return + } + + percent := float64(lvl.XP) / 100.0 + avatarURL := target.AvatarURL("256") + + pngBytes, err := rankcard.RenderPNG(ctx, rankcard.Data{ + Name: target.Username, + Level: lvl.Level, + XP: lvl.XP, + Percent: percent, + Avatar: avatarURL, + }) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to render rank card.") + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Files: []*discordgo.File{ + { + Name: "levelcard.png", + Reader: bytes.NewReader(pngBytes), + }, + }, + }, + }) +} + diff --git a/internal/commands/level/public/rewards.go b/internal/commands/level/public/rewards.go new file mode 100644 index 0000000..bc1ae1a --- /dev/null +++ b/internal/commands/level/public/rewards.go @@ -0,0 +1,68 @@ +package public + +import ( + "context" + "fmt" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Rewards = &discordgo.ApplicationCommand{ + Name: "rewards", + Description: "View level rewards in this server", +} + +func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + if !shared.RequireLevelSystemEnabled(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + ctx := context.Background() + rewards, err := services.Global.LevelSettings.ListRoleRewards(ctx, guildID, 25) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load rewards.") + return + } + if len(rewards) == 0 { + shared.RespondEphemeral(s, i, "No rewards configured yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(rewards)) + for _, r := range rewards { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("Level %d", r.Level), + Value: fmt.Sprintf("<@&%d>", r.RoleID), + Inline: true, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "Level Rewards", + Description: "Roles granted when you reach a level.", + Fields: fields, + Color: 0xF59E0B, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/level/registry.go b/internal/commands/level/registry.go new file mode 100644 index 0000000..1ba2da2 --- /dev/null +++ b/internal/commands/level/registry.go @@ -0,0 +1,26 @@ +package level + +import ( + "velox-bot/internal/commands/level/config" + "velox-bot/internal/commands/level/public" + + "github.com/bwmarrin/discordgo" +) + +// Commands +var ( + Rank *discordgo.ApplicationCommand = public.Rank + Leaderboard *discordgo.ApplicationCommand = public.Leaderboard + Rewards *discordgo.ApplicationCommand = public.Rewards + Slvl *discordgo.ApplicationCommand = config.Slvl +) + +// Handlers +func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(s, i) } +func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.LeaderboardHandler(s, i) +} +func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RewardsHandler(s, i) +} +func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) } diff --git a/internal/commands/level/shared/shared.go b/internal/commands/level/shared/shared.go new file mode 100644 index 0000000..d158d10 --- /dev/null +++ b/internal/commands/level/shared/shared.go @@ -0,0 +1,95 @@ +package shared + +import ( + "context" + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + RespondEphemeral(s, i, "You don't have permission to manage leveling settings.") + return false + } + return true +} + +func RequireLevelServices(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Level == nil || services.Global.LevelSettings == nil { + RespondEphemeral(s, i, "Leveling service is not available.") + return false + } + return true +} + +func RequireLevelSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.LevelSettings == nil { + RespondEphemeral(s, i, "Leveling service is not available.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + +func RequireLevelSystemEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if !RequireLevelSettingsService(s, i) { + return false + } + guildID, ok := ParseGuildID(s, i) + if !ok { + return false + } + enabled, err := services.Global.LevelSettings.IsLevelSystemEnabled(context.Background(), guildID) + if err != nil { + RespondEphemeral(s, i, "Failed to load leveling settings.") + return false + } + if !enabled { + RespondEphemeral(s, i, "Leveling system is disabled on this server. Use /slvl toggle to enable it.") + return false + } + return true +} + +func SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return map[string]*discordgo.ApplicationCommandInteractionDataOption{} + } + opts := data.Options[0].Options + m := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(opts)) + for _, opt := range opts { + m[opt.Name] = opt + } + return m +} + diff --git a/internal/commands/meeting/public/meeting.go b/internal/commands/meeting/public/meeting.go new file mode 100644 index 0000000..8c26065 --- /dev/null +++ b/internal/commands/meeting/public/meeting.go @@ -0,0 +1,332 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/meeting/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Meeting = &discordgo.ApplicationCommand{ + Name: "meeting", + Description: "Meeting room settings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-lobby", + Description: "Set the voice lobby channel used to create meetings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Voice channel lobby", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock", + Description: "Lock your current meeting room (block others from joining)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock-private", + Description: "Lock and hide your meeting room (block joining and visibility)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "unlock", + Description: "Unlock your current meeting room (allow others to join)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "invite", + Description: "Allow a user to join your locked meeting room", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to allow", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "uninvite", + Description: "Remove a previously invited user's permission to join", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to remove", + Required: true, + }, + }, + }, + }, +} + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireManageGuild(s, i) || !shared.RequireMeetingService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "set-lobby": + handleSetLobby(s, i) + case "lock": + handleLock(s, i) + case "lock-private": + handleLockPrivate(s, i) + case "unlock": + handleUnlock(s, i) + case "invite": + handleInvite(s, i) + case "uninvite": + handleUninvite(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + channelOpt, ok := optMap["channel"] + if !ok { + shared.RespondEphemeral(s, i, "Missing channel option.") + return + } + + ch := channelOpt.ChannelValue(s) + if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "Invalid voice channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") + return + } + + shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") +} + +func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, chID64, guildID64, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT for @everyone (guild ID). + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + discordgo.PermissionVoiceConnect, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") + + _ = chID64 + _ = guildID64 +} + +func handleLockPrivate(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT and VIEW_CHANNEL for @everyone (guild ID). + deny := int64(discordgo.PermissionVoiceConnect | discordgo.PermissionViewChannel) + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + deny, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked (private) "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") +} + +func handleUnlock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Remove the @everyone overwrite so defaults apply again. + if err := s.ChannelPermissionDelete(ch.ID, i.GuildID); err != nil { + shared.RespondEphemeral(s, i, "Failed to unlock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Unlocked "+ch.Mention()+".") +} + +func handleInvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Allow CONNECT for the invited user. + if err := s.ChannelPermissionSet( + ch.ID, + u.ID, + discordgo.PermissionOverwriteTypeMember, + discordgo.PermissionVoiceConnect, + 0, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to invite user.") + return + } + + shared.RespondEphemeral(s, i, "Invited "+u.Mention()+" to "+ch.Mention()+".") +} + +func handleUninvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Remove any member-specific overwrite for this user. + if err := s.ChannelPermissionDelete(ch.ID, u.ID); err != nil { + shared.RespondEphemeral(s, i, "Failed to remove invite.") + return + } + + shared.RespondEphemeral(s, i, "Removed "+u.Mention()+" from "+ch.Mention()+".") +} + +func requireOwnTempVoiceChannel(s *discordgo.Session, i *discordgo.InteractionCreate) (channel *discordgo.Channel, channelID64 int64, guildID64 int64, ok bool) { + guildID64, ok = shared.ParseGuildID(s, i) + if !ok { + return nil, 0, 0, false + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return nil, 0, 0, false + } + + voiceChannelID, found := findMemberVoiceChannelID(s, i.GuildID, i.Member.User.ID) + if !found || voiceChannelID == "" { + shared.RespondEphemeral(s, i, "You must be in your meeting voice channel to use this.") + return nil, 0, 0, false + } + + ch, err := s.Channel(voiceChannelID) + if err != nil || ch == nil { + shared.RespondEphemeral(s, i, "Failed to load your voice channel.") + return nil, 0, 0, false + } + if ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "This only works in a voice channel.") + return nil, 0, 0, false + } + + channelID64, err = strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return nil, 0, 0, false + } + + isTemp, err := services.Global.Meeting.IsTempChannel(context.Background(), guildID64, channelID64) + if err != nil || !isTemp { + shared.RespondEphemeral(s, i, "This is not a bot-created meeting room.") + return nil, 0, 0, false + } + + ownerID, ownerOK, err := services.Global.Meeting.GetTempChannelOwner(context.Background(), guildID64, channelID64) + if err != nil || !ownerOK { + shared.RespondEphemeral(s, i, "Failed to verify meeting room owner.") + return nil, 0, 0, false + } + + invokerID64, _ := strconv.ParseInt(i.Member.User.ID, 10, 64) + if invokerID64 == 0 || ownerID != invokerID64 { + shared.RespondEphemeral(s, i, fmt.Sprintf("Only the room creator can do this. (creator: <@%d>)", ownerID)) + return nil, 0, 0, false + } + + return ch, channelID64, guildID64, true +} + +func findMemberVoiceChannelID(s *discordgo.Session, guildID, userID string) (string, bool) { + // Prefer session state cache (discordgo tracks voice states there when IntentsGuildVoiceStates is enabled). + if s != nil && s.State != nil { + if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil { + if vs.ChannelID != "" { + return vs.ChannelID, true + } + return "", false + } + // Fallback to cached guild object (State, not REST). + if g, err := s.State.Guild(guildID); err == nil && g != nil { + for _, st := range g.VoiceStates { + if st != nil && st.UserID == userID { + return st.ChannelID, st.ChannelID != "" + } + } + } + } + return "", false +} + diff --git a/internal/commands/meeting/registry.go b/internal/commands/meeting/registry.go new file mode 100644 index 0000000..f5c630a --- /dev/null +++ b/internal/commands/meeting/registry.go @@ -0,0 +1,14 @@ +package meeting + +import ( + "velox-bot/internal/commands/meeting/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Meeting *discordgo.ApplicationCommand = public.Meeting +) + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MeetingHandler(s, i) } + diff --git a/internal/commands/meeting/shared/shared.go b/internal/commands/meeting/shared/shared.go new file mode 100644 index 0000000..791055f --- /dev/null +++ b/internal/commands/meeting/shared/shared.go @@ -0,0 +1,66 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + RespondEphemeral(s, i, "You don't have permission to manage meeting settings.") + return false + } + return true +} + +func RequireMeetingService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Meeting == nil { + RespondEphemeral(s, i, "Meeting service is not available.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + +func SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return map[string]*discordgo.ApplicationCommandInteractionDataOption{} + } + opts := data.Options[0].Options + m := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(opts)) + for _, opt := range opts { + m[opt.Name] = opt + } + return m +} + diff --git a/internal/commands/moderation/public/moderation.go b/internal/commands/moderation/public/moderation.go new file mode 100644 index 0000000..3565a97 --- /dev/null +++ b/internal/commands/moderation/public/moderation.go @@ -0,0 +1,578 @@ +package public + +import ( + "fmt" + "strings" + "time" + + "velox-bot/internal/db/services" + "velox-bot/internal/modlog" + + "github.com/bwmarrin/discordgo" +) + +var Moderation = &discordgo.ApplicationCommand{ + Name: "moderation", + Description: "High-value moderation tools", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "purge", + Description: "Delete recent messages in this channel (max 100)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "amount", + Description: "How many recent messages to delete (1-100)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "timeout", + Description: "Temporarily timeout a member", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to timeout", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "duration", + Description: "Duration like 10m, 1h, 2h30m (max 28d)", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "kick", + Description: "Kick a member from the server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to kick", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "ban", + Description: "Ban a member from the server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to ban", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "delete_days", + Description: "Delete message history (0-7 days)", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "unban", + Description: "Unban a user by ID", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "user_id", + Description: "User ID to unban", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "untimeout", + Description: "Remove timeout from a member", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to untimeout", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "slowmode", + Description: "Set channel slowmode (0 disables)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel to update", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "seconds", + Description: "Slowmode delay in seconds (0-21600)", + Required: true, + }, + }, + }, + }, +} + +func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + respondEphemeral(s, i, "This command can only be used in a server.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + respondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "purge": + handlePurge(s, i) + case "timeout": + handleTimeout(s, i) + case "kick": + handleKick(s, i) + case "ban": + handleBan(s, i) + case "unban": + handleUnban(s, i) + case "untimeout": + handleUntimeout(s, i) + case "slowmode": + handleSlowmode(s, i) + default: + respondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionManageMessages) { + respondEphemeral(s, i, "You need **Manage Messages** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var amount int64 + for _, o := range opt.Options { + if o.Name == "amount" { + amount = o.IntValue() + break + } + } + if amount < 1 || amount > 100 { + respondEphemeral(s, i, "Amount must be between 1 and 100.") + return + } + + msgs, err := s.ChannelMessages(i.ChannelID, int(amount), "", "", "") + if err != nil { + respondEphemeral(s, i, "Failed to fetch messages.") + return + } + if len(msgs) == 0 { + respondEphemeral(s, i, "No messages found to delete.") + return + } + + tooOldCutoff := time.Now().Add(-14 * 24 * time.Hour) + ids := make([]string, 0, len(msgs)) + for _, m := range msgs { + if m == nil { + continue + } + if m.Timestamp.Before(tooOldCutoff) { + continue + } + ids = append(ids, m.ID) + } + if len(ids) == 0 { + respondEphemeral(s, i, "No messages can be bulk deleted (older than 14 days).") + return + } + + if len(ids) == 1 { + if err := s.ChannelMessageDelete(i.ChannelID, ids[0]); err != nil { + respondEphemeral(s, i, "Failed to delete message.") + return + } + respondEphemeral(s, i, "Deleted 1 message.") + sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "") + return + } + + if err := s.ChannelMessagesBulkDelete(i.ChannelID, ids); err != nil { + respondEphemeral(s, i, "Failed to bulk delete messages.") + return + } + + respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids))) + sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "") +} + +func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionModerateMembers) { + respondEphemeral(s, i, "You need **Moderate Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var durationInput string + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "duration": + durationInput = strings.TrimSpace(o.StringValue()) + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + + dur, err := time.ParseDuration(durationInput) + if err != nil || dur <= 0 { + respondEphemeral(s, i, "Invalid duration. Examples: `10m`, `1h`, `2h30m`.") + return + } + maxTimeout := 28 * 24 * time.Hour + if dur > maxTimeout { + respondEphemeral(s, i, "Duration cannot exceed 28 days.") + return + } + + until := time.Now().UTC().Add(dur) + if err := s.GuildMemberTimeout(i.GuildID, target.ID, &until); err != nil { + respondEphemeral(s, i, "Failed to timeout user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String())) + sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), "") + return + } + respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason)) + sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), reason) +} + +func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionKickMembers) { + respondEphemeral(s, i, "You need **Kick Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID { + respondEphemeral(s, i, "You cannot kick yourself.") + return + } + if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID { + respondEphemeral(s, i, "I can't kick myself.") + return + } + + if err := s.GuildMemberDeleteWithReason(i.GuildID, target.ID, reason); err != nil { + respondEphemeral(s, i, "Failed to kick user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Kicked "+target.Mention()+".") + sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), "") + return + } + respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason) + sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), reason) +} + +func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionBanMembers) { + respondEphemeral(s, i, "You need **Ban Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var deleteDays int64 + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "delete_days": + deleteDays = o.IntValue() + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + if deleteDays < 0 || deleteDays > 7 { + respondEphemeral(s, i, "delete_days must be between 0 and 7.") + return + } + if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID { + respondEphemeral(s, i, "You cannot ban yourself.") + return + } + if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID { + respondEphemeral(s, i, "I can't ban myself.") + return + } + + if err := s.GuildBanCreateWithReason(i.GuildID, target.ID, reason, int(deleteDays)); err != nil { + respondEphemeral(s, i, "Failed to ban user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays)) + sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), "") + return + } + respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason)) + sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), reason) +} + +func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionBanMembers) { + respondEphemeral(s, i, "You need **Ban Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userID string + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user_id": + userID = strings.TrimSpace(o.StringValue()) + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userID == "" { + respondEphemeral(s, i, "Missing user ID.") + return + } + + if err := s.GuildBanDelete(i.GuildID, userID); err != nil { + respondEphemeral(s, i, "Failed to unban user. Make sure the user ID is valid and banned.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.") + sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", "") + return + } + respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason) + sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", reason) +} + +func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionModerateMembers) { + respondEphemeral(s, i, "You need **Moderate Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + + if err := s.GuildMemberTimeout(i.GuildID, target.ID, nil); err != nil { + respondEphemeral(s, i, "Failed to remove timeout. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".") + sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), "") + return + } + respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason) + sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), reason) +} + +func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionManageChannels) { + respondEphemeral(s, i, "You need **Manage Channels** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + var seconds int64 + for _, o := range opt.Options { + switch o.Name { + case "channel": + chOpt = o + case "seconds": + seconds = o.IntValue() + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + if seconds < 0 || seconds > 21600 { + respondEphemeral(s, i, "Seconds must be between 0 and 21600.") + return + } + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + secondsInt := int(seconds) + _, err := s.ChannelEditComplex(ch.ID, &discordgo.ChannelEdit{ + RateLimitPerUser: &secondsInt, + }) + if err != nil { + respondEphemeral(s, i, "Failed to update slowmode.") + return + } + + if seconds == 0 { + respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".") + sendModerationActionLog(s, i, "Slowmode", "Channel: "+ch.Mention()+"\nSet to: `0s`", "") + return + } + respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds)) + sendModerationActionLog(s, i, "Slowmode", fmt.Sprintf("Channel: %s\nSet to: `%ds`", ch.Mention(), seconds), "") +} + +func hasPerm(i *discordgo.InteractionCreate, perm int64) bool { + return i.Member != nil && (i.Member.Permissions&perm) == perm +} + +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func sendModerationActionLog(s *discordgo.Session, i *discordgo.InteractionCreate, action, details, reason string) { + if services.Global == nil { + return + } + moderator := "Unknown" + if i != nil && i.Member != nil && i.Member.User != nil { + moderator = i.Member.User.Mention() + } + desc := "Moderator: " + moderator + "\n" + details + if strings.TrimSpace(reason) != "" { + desc += "\nReason: " + reason + } + embed := &discordgo.MessageEmbed{ + Title: "Moderation Action: " + action, + Color: 0x5865F2, + Description: desc, + Timestamp: time.Now().Format(time.RFC3339), + } + _ = modlog.Send(s, services.Global, i.GuildID, embed) +} + diff --git a/internal/commands/moderation/registry.go b/internal/commands/moderation/registry.go new file mode 100644 index 0000000..57794b4 --- /dev/null +++ b/internal/commands/moderation/registry.go @@ -0,0 +1,16 @@ +package moderation + +import ( + "velox-bot/internal/commands/moderation/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Moderation *discordgo.ApplicationCommand = public.Moderation +) + +func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.ModerationHandler(s, i) +} + diff --git a/internal/commands/music/public/permissions.go b/internal/commands/music/public/permissions.go new file mode 100644 index 0000000..b88d124 --- /dev/null +++ b/internal/commands/music/public/permissions.go @@ -0,0 +1,36 @@ +package public + +import "github.com/bwmarrin/discordgo" + +func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool { + if s == nil || guildID == "" || m == nil { + return false + } + if len(m.Roles) == 0 { + return false + } + + roles, err := s.GuildRoles(guildID) + if err != nil { + return false + } + + djRoleID := "" + for _, r := range roles { + if r != nil && r.Name == "DJ" { + djRoleID = r.ID + break + } + } + if djRoleID == "" { + return false + } + + for _, rid := range m.Roles { + if rid == djRoleID { + return true + } + } + return false +} + diff --git a/internal/commands/music/public/play.go b/internal/commands/music/public/play.go new file mode 100644 index 0000000..d4abaf4 --- /dev/null +++ b/internal/commands/music/public/play.go @@ -0,0 +1,130 @@ +package public + +import ( + "fmt" + "net/url" + "strings" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Play = &discordgo.ApplicationCommand{ + Name: "play", + Description: "Play a song via Lavalink", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "query", + Description: "Song name or URL", + Required: true, + }, + }, +} + +func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return + } + query := data.Options[0].StringValue() + + // Always acknowledge quickly to avoid "application did not respond". + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Searching...", + }, + }) + + if !memberHasDJRole(s, i.GuildID, i.Member) { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("You need the **DJ** role to use music commands."), + }) + return + } + + // If user provided plain text, turn it into a YouTube search + if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") { + query = "ytsearch:" + query + } else { + query = normalizeYouTubeRadioURL(query) + } + + vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID) + if err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username) + if err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + title := entry.Track.Info.Title + author := entry.Track.Info.Author + length := entry.Track.Info.Length + + if started { + // manager will post now-playing message & manage invalidating old buttons + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)), + }) + } else { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)), + }) + } +} + +func ptr[T any](v T) *T { return new(v) } + +// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like: +// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID +// It intentionally does NOT strip normal playlist URLs. +func normalizeYouTubeRadioURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u == nil { + return raw + } + + host := strings.ToLower(u.Host) + if !strings.Contains(host, "youtube.com") || u.Path != "/watch" { + return raw + } + + q := u.Query() + v := q.Get("v") + if v == "" { + return raw + } + + list := q.Get("list") + _, hasStartRadio := q["start_radio"] + if !hasStartRadio && !strings.HasPrefix(list, "RD") { + return raw + } + + u.RawQuery = url.Values{"v": []string{v}}.Encode() + u.Fragment = "" + return u.String() +} + +func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) { + g, err := s.State.Guild(guildID) + if err != nil { + return nil, fmt.Errorf("cannot find guild voice state") + } + for _, vs := range g.VoiceStates { + if vs.UserID == userID { + return vs, nil + } + } + return nil, fmt.Errorf("you must be in a voice channel") +} diff --git a/internal/commands/music/public/queue.go b/internal/commands/music/public/queue.go new file mode 100644 index 0000000..0281bd3 --- /dev/null +++ b/internal/commands/music/public/queue.go @@ -0,0 +1,77 @@ +package public + +import ( + "fmt" + "strings" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Queue = &discordgo.ApplicationCommand{ + Name: "queue", + Description: "Show the current music queue", +} + +func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !memberHasDJRole(s, i.GuildID, i.Member) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You need the **DJ** role to use music commands.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + current, rest := music.GetQueue(i.GuildID) + + if current == nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "The queue is currently empty.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author) + + var descBuilder strings.Builder + if len(rest) > 0 { + limit := len(rest) + if limit > 10 { + limit = 10 + } + for idx, entry := range rest[:limit] { + fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy) + } + if len(rest) > limit { + fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit) + } + } else { + descBuilder.WriteString("Nothing else in the queue.") + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Music Queue", + Description: descBuilder.String(), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Now Playing", + Value: nowPlaying, + }, + }, + }, + }, + }, + }) +} + diff --git a/internal/commands/music/public/volume.go b/internal/commands/music/public/volume.go new file mode 100644 index 0000000..4221b17 --- /dev/null +++ b/internal/commands/music/public/volume.go @@ -0,0 +1,63 @@ +package public + +import ( + "fmt" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Volume = &discordgo.ApplicationCommand{ + Name: "volume", + Description: "Set playback volume (0-150)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "value", + Description: "Volume percent (0-150)", + Required: true, + MinValue: ptrFloat(0), + MaxValue: 150, + }, + }, +} + +func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Updating volume...", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + if !memberHasDJRole(s, i.GuildID, i.Member) { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("You need the **DJ** role to use music commands."), + }) + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("Missing volume value."), + }) + return + } + + vol := int(data.Options[0].IntValue()) + if err := music.SetVolume(i.GuildID, vol); err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)), + }) +} + +func ptrFloat(v float64) *float64 { return &v } + diff --git a/internal/commands/music/registry.go b/internal/commands/music/registry.go new file mode 100644 index 0000000..fd22966 --- /dev/null +++ b/internal/commands/music/registry.go @@ -0,0 +1,20 @@ +package music + +import ( + "velox-bot/internal/commands/music/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Play *discordgo.ApplicationCommand = public.Play + Queue *discordgo.ApplicationCommand = public.Queue + Volume *discordgo.ApplicationCommand = public.Volume +) + +func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) } +func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.QueueHandler(s, i) +} +func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) } + diff --git a/internal/commands/projects/public/projects.go b/internal/commands/projects/public/projects.go new file mode 100644 index 0000000..cc2bc43 --- /dev/null +++ b/internal/commands/projects/public/projects.go @@ -0,0 +1,268 @@ +package public + +import ( + "context" + "strconv" + "strings" + + "velox-bot/internal/commands/projects/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Projects = &discordgo.ApplicationCommand{ + Name: "projects", + Description: "Manage and list ongoing projects", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "list", + Description: "List active projects with creators and helpers", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "create", + Description: "Create a new active project", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "name", + Description: "Project name", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "description", + Description: "Short description (optional)", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "add-helper", + Description: "Add a helper to an existing project", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "project-id", + Description: "ID of the project", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to add as helper", + Required: true, + }, + }, + }, + }, +} + +func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireProjectsService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + handleList(s, i) + return + } + + switch data.Options[0].Name { + case "list": + handleList(s, i) + case "create": + handleCreate(s, i) + case "add-helper": + handleAddHelper(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + const limit = 25 + items, err := services.Global.Projects.ListActiveWithMembers(context.Background(), guildID, limit) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load projects.") + return + } + if len(items) == 0 { + shared.RespondEphemeral(s, i, "There are no active projects at the moment.") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "Active projects", + Description: "", + Color: 0x00bcd4, + } + + for _, item := range items { + if item == nil || item.Project == nil { + continue + } + p := item.Project + name := p.Name + if name == "" { + name = "Unnamed project" + } + creatorMention := mentionUser(item.Creator) + + var helpersMentions []string + for _, h := range item.Helpers { + helpersMentions = append(helpersMentions, mentionUser(h)) + } + helpersText := "None" + if len(helpersMentions) > 0 { + helpersText = strings.Join(helpersMentions, ", ") + } + + desc := "**Creator:** " + creatorMention + "\n" + + "**Helpers:** " + helpersText + if p.Description != "" { + desc += "\n" + p.Description + } + + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: name, + Value: desc, + }) + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + +func mentionUser(id int64) string { + if id == 0 { + return "Unknown" + } + return "<@" + strconv.FormatInt(id, 10) + ">" +} + +func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var name, description string + for _, o := range opt.Options { + switch o.Name { + case "name": + name = o.StringValue() + case "description": + description = o.StringValue() + } + } + if strings.TrimSpace(name) == "" { + shared.RespondEphemeral(s, i, "Project name cannot be empty.") + return + } + + creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to create project.") + return + } + + shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.") +} + +func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var projectID int64 + var userID string + for _, o := range opt.Options { + switch o.Name { + case "project-id": + projectID = o.IntValue() + case "user": + if u := o.UserValue(s); u != nil { + userID = u.ID + } + } + } + if projectID <= 0 { + shared.RespondEphemeral(s, i, "Invalid project ID.") + return + } + if userID == "" { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load project.") + return + } + if !exists { + shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.") + return + } + + userID64, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil { + shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")") + return + } + + shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.") +} + diff --git a/internal/commands/projects/registry.go b/internal/commands/projects/registry.go new file mode 100644 index 0000000..ff3f81f --- /dev/null +++ b/internal/commands/projects/registry.go @@ -0,0 +1,14 @@ +package projects + +import ( + "velox-bot/internal/commands/projects/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Projects *discordgo.ApplicationCommand = public.Projects +) + +func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) } + diff --git a/internal/commands/projects/shared/shared.go b/internal/commands/projects/shared/shared.go new file mode 100644 index 0000000..fac7325 --- /dev/null +++ b/internal/commands/projects/shared/shared.go @@ -0,0 +1,53 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireProjectsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Projects == nil { + RespondEphemeral(s, i, "Projects service is not available.") + return false + } + return true +} + +func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + RespondEphemeral(s, i, "You don't have permission to manage projects.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + diff --git a/internal/commands/schedule/public/schedule.go b/internal/commands/schedule/public/schedule.go new file mode 100644 index 0000000..e8a6c9f --- /dev/null +++ b/internal/commands/schedule/public/schedule.go @@ -0,0 +1,439 @@ +package public + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "velox-bot/internal/commands/schedule/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Schedule = &discordgo.ApplicationCommand{ + Name: "schedule", + Description: "Schedule 1:1 sessions", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "create", + Description: "Create a new session with a user", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to invite", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "datetime", + Description: "When (UTC), format: 2006-01-02 15:04", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "description", + Description: "What is this session about? (optional)", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "list", + Description: "List your upcoming sessions", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "reschedule", + Description: "Reschedule an existing session", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "id", + Description: "ID of the session (from /schedule list)", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "datetime", + Description: "New datetime (UTC), format: 2006-01-02 15:04", + Required: true, + }, + }, + }, + }, +} + +func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + handleList(s, i) + return + } + + switch data.Options[0].Name { + case "create": + handleCreate(s, i) + case "list": + handleList(s, i) + case "reschedule": + handleReschedule(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var ( + targetUser *discordgo.User + whenStr string + desc string + ) + for _, o := range opt.Options { + switch o.Name { + case "user": + targetUser = o.UserValue(s) + case "datetime": + whenStr = o.StringValue() + case "description": + desc = o.StringValue() + } + } + + if targetUser == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + if targetUser.ID == i.Member.User.ID { + shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.") + return + } + + requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid target user ID.") + return + } + + whenStr = strings.TrimSpace(whenStr) + if whenStr == "" { + shared.RespondEphemeral(s, i, "Datetime cannot be empty.") + return + } + + // Resolve requester's timezone and parse input using explicit layout in that zone. + loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID) + if err != nil { + loc = time.UTC + zone = "UTC" + } + // Expect layout "2006-01-02 15:04" in the user's timezone. + when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") + return + } + when = when.In(time.UTC) + + ctx := context.Background() + scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to create schedule.") + return + } + + // Send DM to invitee. + dmCh, err := s.UserChannelCreate(targetUser.ID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "New session request", + Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID), + Color: 0x4caf50, + } + // Show both UTC and invitee-local time if available. + utcStr := when.Format("2006-01-02 15:04") + locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID) + if err != nil { + locInv = time.UTC + zoneInv = "UTC" + } + localInvStr := when.In(locInv).Format("2006-01-02 15:04") + embed.Fields = []*discordgo.MessageEmbedField{ + { + Name: "When (UTC)", + Value: utcStr, + }, + { + Name: "When (" + zoneInv + ")", + Value: localInvStr, + }, + } + if desc != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: desc, + }) + } + + msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ + Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.", + Embed: embed, + }) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to DM the invited user.") + return + } + + // Add reactions for interaction. + for _, emoji := range []string{"✅", "❌", "🔁"} { + _ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji) + } + + // Store linkage between message and schedule. + msgID64, _ := strconv.ParseInt(msg.ID, 10, 64) + chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) + _ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true) + + shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".") +} + +func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + const limit = 10 + items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load your sessions.") + return + } + if len(items) == 0 { + shared.RespondEphemeral(s, i, "You have no upcoming sessions.") + return + } + + var b strings.Builder + for _, sch := range items { + role := "Requester" + otherID := sch.InviteeID + if sch.InviteeID == userID { + role = "Invitee" + otherID = sch.RequesterID + } + status := string(sch.Status) + whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status) + if sch.Description != "" { + line += " " + sch.Description + "\n" + } + b.WriteString(line) + } + + shared.RespondEphemeral(s, i, b.String()) +} + +func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var ( + idVal int64 + whenStr string + ) + for _, o := range opt.Options { + switch o.Name { + case "id": + idVal = o.IntValue() + case "datetime": + whenStr = o.StringValue() + } + } + if idVal <= 0 { + shared.RespondEphemeral(s, i, "Invalid session ID.") + return + } + whenStr = strings.TrimSpace(whenStr) + if whenStr == "" { + shared.RespondEphemeral(s, i, "Datetime cannot be empty.") + return + } + + userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + ctx := context.Background() + sch, err := services.Global.Schedule.GetByID(ctx, idVal) + if err != nil || sch == nil { + shared.RespondEphemeral(s, i, "Session not found.") + return + } + if sch.GuildID != guildID { + shared.RespondEphemeral(s, i, "This session belongs to another server.") + return + } + if userID != sch.RequesterID && userID != sch.InviteeID { + shared.RespondEphemeral(s, i, "You are not part of this session.") + return + } + + // Use rescheduler's timezone for parsing. + loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID) + if err != nil { + loc = time.UTC + zone = "UTC" + } + // Expect explicit layout "2006-01-02 15:04" in user's timezone. + when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") + return + } + when = when.In(time.UTC) + + if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil { + shared.RespondEphemeral(s, i, "Failed to reschedule session.") + return + } + + // Notify participants via DM and send new request DM to invitee. + whenFmt := when.Format("2006-01-02 15:04") + + otherID := sch.InviteeID + if userID == sch.InviteeID { + otherID = sch.RequesterID + } + + // DM both about the change, including their local times if available. + for _, uid := range []int64{sch.RequesterID, sch.InviteeID} { + locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid) + if err != nil { + locU = time.UTC + zoneU = "UTC" + } + localU := when.In(locU).Format("2006-01-02 15:04") + msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID) + if sch.Description != "" { + msg += "\nTopic: " + sch.Description + } + ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10)) + if err != nil { + continue + } + _, _ = s.ChannelMessageSend(ch.ID, msg) + } + + // Send new "request" DM to invitee with reactions again. + inviteeStr := strconv.FormatInt(sch.InviteeID, 10) + inviteeUser, err := s.User(inviteeStr) + if err == nil && inviteeUser != nil { + dmCh, err := s.UserChannelCreate(inviteeStr) + if err == nil { + embed := &discordgo.MessageEmbed{ + Title: "Rescheduled session", + Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), + Color: 0xffc107, + } + // Show both UTC and invitee-local time if available. + locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID) + if errTZ != nil { + locInv = time.UTC + zoneInv = "UTC" + } + localInvStr := when.In(locInv).Format("2006-01-02 15:04") + embed.Fields = []*discordgo.MessageEmbedField{ + { + Name: "When (UTC)", + Value: whenFmt, + }, + { + Name: "When (" + zoneInv + ")", + Value: localInvStr, + }, + } + if sch.Description != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: sch.Description, + }) + } + msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ + Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.", + Embed: embed, + }) + if err == nil && msgObj != nil { + for _, emoji := range []string{"✅", "❌", "🔁"} { + _ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji) + } + msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64) + chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) + _ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true) + } + } + } + + shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.") +} + + diff --git a/internal/commands/schedule/registry.go b/internal/commands/schedule/registry.go new file mode 100644 index 0000000..63d7d13 --- /dev/null +++ b/internal/commands/schedule/registry.go @@ -0,0 +1,14 @@ +package schedule + +import ( + "velox-bot/internal/commands/schedule/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Schedule *discordgo.ApplicationCommand = public.Schedule +) + +func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) } + diff --git a/internal/commands/schedule/shared/shared.go b/internal/commands/schedule/shared/shared.go new file mode 100644 index 0000000..ffc667e --- /dev/null +++ b/internal/commands/schedule/shared/shared.go @@ -0,0 +1,45 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Schedule == nil { + RespondEphemeral(s, i, "Scheduling service is not available.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + diff --git a/internal/commands/timezone/public/timezone.go b/internal/commands/timezone/public/timezone.go new file mode 100644 index 0000000..e5d44d8 --- /dev/null +++ b/internal/commands/timezone/public/timezone.go @@ -0,0 +1,115 @@ +package public + +import ( + "context" + "time" + + "velox-bot/internal/commands/timezone/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Timezone = &discordgo.ApplicationCommand{ + Name: "timezone", + Description: "Configure and view your timezone", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set", + Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "zone", + Description: "Timezone (e.g., Europe/Lisbon, America/New_York)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "show", + Description: "Show your current timezone", + }, + }, +} + +func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireUserSettingsService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + handleShow(s, i) + return + } + + switch data.Options[0].Name { + case "set": + handleSet(s, i) + case "show": + handleShow(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID, ok := shared.CurrentUserID(i) + if !ok { + shared.RespondEphemeral(s, i, "Could not determine your user ID.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var zone string + for _, o := range opt.Options { + if o.Name == "zone" { + zone = o.StringValue() + } + } + if zone == "" { + shared.RespondEphemeral(s, i, "Timezone cannot be empty.") + return + } + + // Validate via service (which uses time.LoadLocation). + if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil { + shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.") + return + } + + loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID) + now := time.Now().In(loc).Format("2006-01-02 15:04") + shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").") +} + +func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID, ok := shared.CurrentUserID(i) + if !ok { + shared.RespondEphemeral(s, i, "Could not determine your user ID.") + return + } + + loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load your timezone.") + return + } + now := time.Now().In(loc).Format("2006-01-02 15:04") + + if userDefined { + shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").") + } else { + shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.") + } +} + diff --git a/internal/commands/timezone/registry.go b/internal/commands/timezone/registry.go new file mode 100644 index 0000000..7bdedd7 --- /dev/null +++ b/internal/commands/timezone/registry.go @@ -0,0 +1,14 @@ +package timezone + +import ( + "velox-bot/internal/commands/timezone/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Timezone *discordgo.ApplicationCommand = public.Timezone +) + +func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) } + diff --git a/internal/commands/timezone/shared/shared.go b/internal/commands/timezone/shared/shared.go new file mode 100644 index 0000000..257e915 --- /dev/null +++ b/internal/commands/timezone/shared/shared.go @@ -0,0 +1,45 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.UserSettings == nil { + RespondEphemeral(s, i, "User settings service is not available.") + return false + } + return true +} + +func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) { + var idStr string + if i.Member != nil && i.Member.User != nil { + idStr = i.Member.User.ID + } else if i.User != nil { + idStr = i.User.ID + } + if idStr == "" { + return 0, false + } + uid, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, false + } + return uid, true +} + diff --git a/internal/config/config.go b/internal/config/config.go index aa58118..2fd96fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,9 +8,13 @@ import ( ) type Config struct { - BotToken string - AppID string - GuildID string + BotToken string + AppID string + GuildID string + DBHost string + LavalinkHost string + LavalinkPass string + TwitchClientID string } func LoadConfig() (*Config, error) { @@ -29,13 +33,27 @@ func LoadConfig() (*Config, error) { } guildID := os.Getenv("GUILD_ID") - if guildID == "" { - return nil, fmt.Errorf("GUILD_ID is not set") + + dbHost := os.Getenv("DB_HOST") + if dbHost == "" { + return nil, fmt.Errorf("DB_HOST is not set") + } + + lavalinkHost := os.Getenv("LAVALINK_HOST") + lavalinkPass := os.Getenv("LAVALINK_PASSWORD") + + twitchClientID := os.Getenv("TWITCH_CLIENT_ID") + if twitchClientID == "" { + return nil, fmt.Errorf("TWITCH_CLIENT_ID is not set") } return &Config{ - BotToken: token, - AppID: appID, - GuildID: guildID, + BotToken: token, + AppID: appID, + GuildID: guildID, + DBHost: dbHost, + LavalinkHost: lavalinkHost, + LavalinkPass: lavalinkPass, + TwitchClientID: twitchClientID, }, nil } diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..c12e0a4 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,26 @@ +package db + +import ( + "database/sql" + "time" + "velox-bot/internal/config" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func NewDB(config *config.Config) (*sql.DB, error) { + pool, err := sql.Open("pgx", config.DBHost) + if err != nil { + return nil, err + } + + pool.SetMaxIdleConns(10) + pool.SetMaxOpenConns(100) + pool.SetConnMaxLifetime(time.Hour) + + if err := pool.Ping(); err != nil { + return nil, err + } + + return pool, nil +} diff --git a/internal/db/repos/defaultrolerepo/repo.go b/internal/db/repos/defaultrolerepo/repo.go new file mode 100644 index 0000000..5886e94 --- /dev/null +++ b/internal/db/repos/defaultrolerepo/repo.go @@ -0,0 +1,47 @@ +package defaultrolerepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +// GetRole returns the configured default role ID for a guild, or 0 if none. +func (r *Repo) GetRole(ctx context.Context, guildID int64) (int64, error) { + const q = ` + SELECT role_id + FROM defaultrole + WHERE guild_id = $1 + ` + var roleID sql.NullInt64 + if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&roleID); err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + if !roleID.Valid { + return 0, nil + } + return roleID.Int64, nil +} + +// SetRole upserts the default role for a guild. +func (r *Repo) SetRole(ctx context.Context, guildID, roleID int64) error { + const q = ` + INSERT INTO defaultrole (guild_id, role_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET role_id = EXCLUDED.role_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, roleID) + return err +} + diff --git a/internal/db/repos/levelrepo/repo.go b/internal/db/repos/levelrepo/repo.go new file mode 100644 index 0000000..acf7ccd --- /dev/null +++ b/internal/db/repos/levelrepo/repo.go @@ -0,0 +1,94 @@ +package levelrepo + +import ( + "context" + "database/sql" +) + +type Level struct { + GuildID int64 + UserID int64 + Level int + XP int64 +} + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) GetLevel(ctx context.Context, guildID, userID int64) (*Level, error) { + const q = ` + SELECT level, xp + FROM levels + WHERE guild = $1 AND user_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, userID) + var lvl Level + lvl.GuildID = guildID + lvl.UserID = userID + switch err := row.Scan(&lvl.Level, &lvl.XP); err { + case sql.ErrNoRows: + // default new user + lvl.Level = 0 + lvl.XP = 0 + return &lvl, nil + case nil: + return &lvl, nil + default: + return nil, err + } +} +func (r *Repo) UpsertLevel(ctx context.Context, lvl *Level) error { + const q = ` + INSERT INTO levels (guild, user_id, level, xp) + VALUES ($1, $2, $3, $4) + ON CONFLICT (guild, user_id) + DO UPDATE SET level = EXCLUDED.level, xp = EXCLUDED.xp + ` + _, err := r.db.ExecContext(ctx, q, lvl.GuildID, lvl.UserID, lvl.Level, lvl.XP) + return err +} + +func (r *Repo) SetLevel(ctx context.Context, guildID, userID int64, level int) error { + const q = ` + INSERT INTO levels (guild, user_id, level, xp) + VALUES ($1, $2, $3, 0) + ON CONFLICT (guild, user_id) + DO UPDATE SET level = EXCLUDED.level, xp = 0 + ` + _, err := r.db.ExecContext(ctx, q, guildID, userID, level) + return err +} + +func (r *Repo) TopLevels(ctx context.Context, guildID int64, limit int) ([]*Level, error) { + const q = ` + SELECT user_id, level, xp + FROM levels + WHERE guild = $1 + ORDER BY level DESC, xp DESC, user_id ASC + LIMIT $2 + ` + + rows, err := r.db.QueryContext(ctx, q, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]*Level, 0, limit) + for rows.Next() { + lvl := &Level{GuildID: guildID} + if err := rows.Scan(&lvl.UserID, &lvl.Level, &lvl.XP); err != nil { + return nil, err + } + out = append(out, lvl) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/db/repos/projectsrepo/repo.go b/internal/db/repos/projectsrepo/repo.go new file mode 100644 index 0000000..54383d2 --- /dev/null +++ b/internal/db/repos/projectsrepo/repo.go @@ -0,0 +1,158 @@ +package projectsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +type Project struct { + ID int64 + GuildID int64 + Name string + Description string + IsActive bool + CreatedBy int64 +} + +type ProjectWithMembers struct { + Project *Project + Creator int64 + Helpers []int64 +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) { + const q = ` + INSERT INTO projects (guild_id, name, description, created_by) + VALUES ($1, $2, $3, $4) + RETURNING id + ` + var id int64 + if err := r.db.QueryRowContext(ctx, q, guildID, name, description, creatorID).Scan(&id); err != nil { + return 0, err + } + + const qMember = ` + INSERT INTO project_members (project_id, user_id, is_creator) + VALUES ($1, $2, TRUE) + ON CONFLICT (project_id, user_id) DO NOTHING + ` + if _, err := r.db.ExecContext(ctx, qMember, id, creatorID); err != nil { + return 0, err + } + + return id, nil +} + +func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error { + const q = ` + INSERT INTO project_members (project_id, user_id, is_creator) + VALUES ($1, $2, FALSE) + ON CONFLICT (project_id, user_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, projectID, userID) + return err +} + +func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { + const q = ` + SELECT 1 + FROM projects + WHERE guild_id = $1 AND id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, projectID) + var one int + switch err := row.Scan(&one); err { + case nil: + return true, nil + case sql.ErrNoRows: + return false, nil + default: + return false, err + } +} + +func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) { + const qProjects = ` + SELECT id, name, description, created_by + FROM projects + WHERE guild_id = $1 AND is_active = TRUE + ORDER BY created_at DESC + LIMIT $2 + ` + rows, err := r.db.QueryContext(ctx, qProjects, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*ProjectWithMembers + for rows.Next() { + p := &Project{GuildID: guildID, IsActive: true} + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil { + return nil, err + } + out = append(out, &ProjectWithMembers{ + Project: p, + Creator: p.CreatedBy, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(out) == 0 { + return out, nil + } + + // Load members per project. + const qMembers = ` + SELECT project_id, user_id, is_creator + FROM project_members + WHERE project_id = ANY($1) + ` + ids := make([]int64, len(out)) + for i, p := range out { + ids[i] = p.Project.ID + } + + rowsM, err := r.db.QueryContext(ctx, qMembers, ids) + if err != nil { + return nil, err + } + defer rowsM.Close() + + index := make(map[int64]*ProjectWithMembers, len(out)) + for _, p := range out { + index[p.Project.ID] = p + } + + for rowsM.Next() { + var pid, uid int64 + var isCreator bool + if err := rowsM.Scan(&pid, &uid, &isCreator); err != nil { + return nil, err + } + item, ok := index[pid] + if !ok { + continue + } + if isCreator { + item.Creator = uid + } else { + item.Helpers = append(item.Helpers, uid) + } + } + if err := rowsM.Err(); err != nil { + return nil, err + } + + return out, nil +} + diff --git a/internal/db/repos/rpsrepo/repo.go b/internal/db/repos/rpsrepo/repo.go new file mode 100644 index 0000000..47156c4 --- /dev/null +++ b/internal/db/repos/rpsrepo/repo.go @@ -0,0 +1,76 @@ +package rpsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + var score int + err := r.db.QueryRowContext(ctx, ` + SELECT score + FROM rps + WHERE guild_id = $1 AND user_id = $2 + `, guildID, userID).Scan(&score) + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return score, true, nil +} + +func (r *Repo) SetScore(ctx context.Context, guildID int64, userID int64, score int) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO rps (guild_id, user_id, score) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET score = EXCLUDED.score + `, guildID, userID, score) + return err +} + +type ScoreEntry struct { + UserID int64 + Score int +} + +func (r *Repo) TopScores(ctx context.Context, guildID int64, limit int) ([]ScoreEntry, error) { + if limit <= 0 { + limit = 10 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT user_id, score + FROM rps + WHERE guild_id = $1 + ORDER BY score DESC, user_id ASC + LIMIT $2 + `, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]ScoreEntry, 0, limit) + for rows.Next() { + var e ScoreEntry + if err := rows.Scan(&e.UserID, &e.Score); err != nil { + return nil, err + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + diff --git a/internal/db/repos/schedulerepo/repo.go b/internal/db/repos/schedulerepo/repo.go new file mode 100644 index 0000000..6b628fb --- /dev/null +++ b/internal/db/repos/schedulerepo/repo.go @@ -0,0 +1,189 @@ +package schedulerepo + +import ( + "context" + "database/sql" + "time" +) + +type Repo struct { + db *sql.DB +} + +type ScheduleStatus string + +const ( + StatusPending ScheduleStatus = "pending" + StatusAccepted ScheduleStatus = "accepted" + StatusDeclined ScheduleStatus = "declined" + StatusRescheduleRequested ScheduleStatus = "reschedule_requested" +) + +type Schedule struct { + ID int64 + GuildID int64 + RequesterID int64 + InviteeID int64 + ScheduledAt time.Time + Status ScheduleStatus + Description string + ReminderSent bool + CreatedAt time.Time +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) { + const q = ` + INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ` + var id int64 + if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { + const q = ` + INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm) + VALUES ($1, $2, $3, $4) + ON CONFLICT (schedule_id, message_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM) + return err +} + +func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) { + const q = ` + SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at + FROM schedules s + JOIN schedule_messages m ON m.schedule_id = s.id + WHERE m.message_id = $1 + LIMIT 1 + ` + row := r.db.QueryRowContext(ctx, q, messageID) + var sch Schedule + if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &sch, nil +} + +func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error { + const q = ` + UPDATE schedules + SET status = $1 + WHERE id = $2 + ` + _, err := r.db.ExecContext(ctx, q, status, scheduleID) + return err +} + +func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE guild_id = $1 + AND (requester_id = $2 OR invitee_id = $2) + AND status IN ('pending', 'accepted', 'reschedule_requested') + ORDER BY scheduled_at ASC + LIMIT $3 + ` + rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*Schedule + for rows.Next() { + var sch Schedule + if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + return nil, err + } + out = append(out, &sch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE id = $1 + ` + row := r.db.QueryRowContext(ctx, q, id) + var sch Schedule + if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &sch, nil +} + +func (r *Repo) Reschedule(ctx context.Context, id int64, when time.Time) error { + const q = ` + UPDATE schedules + SET scheduled_at = $1, + status = 'pending', + reminder_sent = FALSE + WHERE id = $2 + ` + _, err := r.db.ExecContext(ctx, q, when, id) + return err +} + +// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded. +func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE reminder_sent = FALSE + AND status IN ('pending', 'accepted', 'reschedule_requested') + AND scheduled_at > $1 + AND scheduled_at <= $2 + ` + rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead)) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*Schedule + for rows.Next() { + var sch Schedule + if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + return nil, err + } + out = append(out, &sch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error { + const q = ` + UPDATE schedules + SET reminder_sent = TRUE + WHERE id = $1 + ` + _, err := r.db.ExecContext(ctx, q, scheduleID) + return err +} + + diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go new file mode 100644 index 0000000..e895604 --- /dev/null +++ b/internal/db/repos/settingsrepo/repo.go @@ -0,0 +1,342 @@ +package settingsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +type LevelReward struct { + GuildID int64 + Level int + RoleID int64 +} + +type LogSettings struct { + GuildID int64 + ChannelID int64 + IsEnabled bool +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + const q = ` + INSERT INTO meeting_settings (guild_id, lobby_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) + return err +} + +func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + const q = ` + SELECT lobby_channel_id + FROM meeting_settings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var ch sql.NullInt64 + switch err := row.Scan(&ch); err { + case nil: + if !ch.Valid { + return 0, false, nil + } + return ch.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + const q = ` + INSERT INTO meeting_temp_channels (guild_id, channel_id, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, channel_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID, createdBy) + return err +} + +func (r *Repo) RemoveMeetingTempChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + DELETE FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) IsMeetingTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + const q = ` + SELECT 1 + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + var one int + switch err := row.Scan(&one); err { + case nil: + return true, nil + case sql.ErrNoRows: + return false, nil + default: + return false, err + } +} + +func (r *Repo) GetMeetingTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + const q = ` + SELECT created_by + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + var cb sql.NullInt64 + switch err := row.Scan(&cb); err { + case nil: + if !cb.Valid { + return 0, false, nil + } + return cb.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error { + const q = ` + INSERT INTO levelsettings (guild_id, levelsys) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET levelsys = EXCLUDED.levelsys + ` + _, err := r.db.ExecContext(ctx, q, guildID, enabled) + return err +} + +func (r *Repo) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) { + const q = ` + SELECT levelsys + FROM levelsettings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var enabled bool + switch err := row.Scan(&enabled); err { + case nil: + return enabled, nil + case sql.ErrNoRows: + return false, nil + default: + return false, err + } +} + +func (r *Repo) GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error) { + const q = ` + SELECT levelup_channel_id + FROM levelup + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var channelID sql.NullInt64 + switch err := row.Scan(&channelID); err { + case nil: + if !channelID.Valid { + return 0, false, nil + } + return channelID.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error) { + const q = ` + SELECT role + FROM levelrewards + WHERE guild_id = $1 AND levelreq = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, level) + var role sql.NullInt64 + switch err := row.Scan(&role); err { + case nil: + if !role.Valid { + return 0, false, nil + } + return role.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error { + const q = ` + INSERT INTO levelrewards (guild_id, levelreq, role) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, levelreq) + DO UPDATE SET role = EXCLUDED.role + ` + _, err := r.db.ExecContext(ctx, q, guildID, level, roleID) + return err +} + +func (r *Repo) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*LevelReward, error) { + const q = ` + SELECT levelreq, role + FROM levelrewards + WHERE guild_id = $1 + ORDER BY levelreq ASC + LIMIT $2 + ` + rows, err := r.db.QueryContext(ctx, q, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]*LevelReward, 0, limit) + for rows.Next() { + rw := &LevelReward{GuildID: guildID} + if err := rows.Scan(&rw.Level, &rw.RoleID); err != nil { + return nil, err + } + out = append(out, rw) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *Repo) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error { + const q = ` + INSERT INTO levelup (guild_id, levelup_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET levelup_channel_id = EXCLUDED.levelup_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error { + const q = ` + UPDATE levelsettings + SET message = $1 + WHERE guild_id = $2 + ` + _, err := r.db.ExecContext(ctx, q, message, guildID) + return err +} + +func (r *Repo) SetLogChannel(ctx context.Context, guildID int64, channelID int64) error { + const q = ` + INSERT INTO logsettings (guild_id, log_channel_id, is_enabled) + VALUES ($1, $2, TRUE) + ON CONFLICT (guild_id) + DO UPDATE SET log_channel_id = EXCLUDED.log_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) SetLogEnabled(ctx context.Context, guildID int64, enabled bool) error { + const q = ` + INSERT INTO logsettings (guild_id, is_enabled) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET is_enabled = EXCLUDED.is_enabled + ` + _, err := r.db.ExecContext(ctx, q, guildID, enabled) + return err +} + +func (r *Repo) GetLogSettings(ctx context.Context, guildID int64) (*LogSettings, error) { + const q = ` + SELECT log_channel_id, is_enabled + FROM logsettings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var channelID sql.NullInt64 + var enabled sql.NullBool + switch err := row.Scan(&channelID, &enabled); err { + case nil: + out := &LogSettings{ + GuildID: guildID, + ChannelID: 0, + IsEnabled: false, + } + if channelID.Valid { + out.ChannelID = channelID.Int64 + } + if enabled.Valid { + out.IsEnabled = enabled.Bool + } + return out, nil + case sql.ErrNoRows: + return &LogSettings{ + GuildID: guildID, + ChannelID: 0, + IsEnabled: false, + }, nil + default: + return nil, err + } +} + +func (r *Repo) HasLogSettings(ctx context.Context, guildID int64) (bool, error) { + const q = ` + SELECT 1 + FROM logsettings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var one int + switch err := row.Scan(&one); err { + case nil: + return true, nil + case sql.ErrNoRows: + return false, nil + default: + return false, err + } +} + +func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) { + const q = ` + SELECT message + FROM levelsettings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var message sql.NullString + switch err := row.Scan(&message); err { + case nil: + if !message.Valid { + return "GG {user}, you reached level {level}!", nil + } + return message.String, nil + case sql.ErrNoRows: + return "GG {user}, you reached level {level}!", nil + default: + return "", err + } +} diff --git a/internal/db/repos/twitchrepo/repo.go b/internal/db/repos/twitchrepo/repo.go new file mode 100644 index 0000000..f68e441 --- /dev/null +++ b/internal/db/repos/twitchrepo/repo.go @@ -0,0 +1,115 @@ +package twitchrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) ListGuilds(ctx context.Context) ([]int64, error) { + const q = `SELECT DISTINCT guild_id FROM twitch` + rows, err := r.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []int64 + for rows.Next() { + var gid int64 + if err := rows.Scan(&gid); err != nil { + return nil, err + } + ids = append(ids, gid) + } + return ids, rows.Err() +} + +func (r *Repo) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) { + const q = `SELECT twitch_user FROM twitch WHERE guild_id = $1` + rows, err := r.db.QueryContext(ctx, q, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []string + for rows.Next() { + var u string + if err := rows.Scan(&u); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +func (r *Repo) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) { + const q = `SELECT status FROM twitch WHERE guild_id = $1 AND twitch_user = $2` + var status string + err := r.db.QueryRowContext(ctx, q, guildID, username).Scan(&status) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return status, true, nil +} + +func (r *Repo) UpsertStreamer(ctx context.Context, guildID int64, username string) error { + const q = ` + INSERT INTO twitch (twitch_user, guild_id, status) + VALUES ($1, $2, 'not live') + ON CONFLICT (twitch_user, guild_id) + DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, username, guildID) + return err +} + +func (r *Repo) RemoveStreamer(ctx context.Context, guildID int64, username string) error { + const q = `DELETE FROM twitch WHERE guild_id = $1 AND twitch_user = $2` + _, err := r.db.ExecContext(ctx, q, guildID, username) + return err +} + +func (r *Repo) UpdateStatus(ctx context.Context, guildID int64, username, status string) error { + const q = `UPDATE twitch SET status = $1 WHERE guild_id = $2 AND twitch_user = $3` + _, err := r.db.ExecContext(ctx, q, status, guildID, username) + return err +} + +func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) { + const q = `SELECT twitch_channel_id FROM twitch_config WHERE guild_id = $1` + var chID sql.NullInt64 + if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&chID); err != nil { + if err == sql.ErrNoRows { + return 0, false, nil + } + return 0, false, err + } + if !chID.Valid { + return 0, false, nil + } + return chID.Int64, true, nil +} + +func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + INSERT INTO twitch_config (guild_id, twitch_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + diff --git a/internal/db/repos/usersettingsrepo/repo.go b/internal/db/repos/usersettingsrepo/repo.go new file mode 100644 index 0000000..364f77f --- /dev/null +++ b/internal/db/repos/usersettingsrepo/repo.go @@ -0,0 +1,44 @@ +package usersettingsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error { + const q = ` + INSERT INTO user_settings (user_id, timezone) + VALUES ($1, $2) + ON CONFLICT (user_id) + DO UPDATE SET timezone = EXCLUDED.timezone + ` + _, err := r.db.ExecContext(ctx, q, userID, zone) + return err +} + +func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) { + const q = ` + SELECT timezone + FROM user_settings + WHERE user_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, userID) + var zone string + switch err := row.Scan(&zone); err { + case nil: + return zone, true, nil + case sql.ErrNoRows: + return "", false, nil + default: + return "", false, err + } +} + diff --git a/internal/db/repos/welcomerepo/repo.go b/internal/db/repos/welcomerepo/repo.go new file mode 100644 index 0000000..a141e53 --- /dev/null +++ b/internal/db/repos/welcomerepo/repo.go @@ -0,0 +1,111 @@ +package welcomerepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +type Settings struct { + GuildID int64 + ChannelID int64 + WelcomeMessage string + WelcomeDM string + WelcomeGIFURL string + HasConfiguration bool +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) { + const q = ` + SELECT welcome_channel_id, welcome_message, welcome_dm, welcome_gif_url + FROM welcome + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var ch sql.NullInt64 + var msg, dm, gif sql.NullString + + switch err := row.Scan(&ch, &msg, &dm, &gif); err { + case nil: + s := &Settings{ + GuildID: guildID, + ChannelID: 0, + WelcomeMessage: "", + WelcomeDM: "", + WelcomeGIFURL: "", + HasConfiguration: true, + } + if ch.Valid { + s.ChannelID = ch.Int64 + } + if msg.Valid { + s.WelcomeMessage = msg.String + } + if dm.Valid { + s.WelcomeDM = dm.String + } + if gif.Valid { + s.WelcomeGIFURL = gif.String + } + return s, nil + case sql.ErrNoRows: + return &Settings{ + GuildID: guildID, + HasConfiguration: false, + }, nil + default: + return nil, err + } +} + +func (r *Repo) UpsertChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + INSERT INTO welcome (guild_id, welcome_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET welcome_channel_id = EXCLUDED.welcome_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) UpsertMessage(ctx context.Context, guildID int64, message string) error { + const q = ` + INSERT INTO welcome (guild_id, welcome_message) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET welcome_message = EXCLUDED.welcome_message + ` + _, err := r.db.ExecContext(ctx, q, guildID, message) + return err +} + +func (r *Repo) UpsertDM(ctx context.Context, guildID int64, dm string) error { + const q = ` + INSERT INTO welcome (guild_id, welcome_dm) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET welcome_dm = EXCLUDED.welcome_dm + ` + _, err := r.db.ExecContext(ctx, q, guildID, dm) + return err +} + +func (r *Repo) UpsertGIF(ctx context.Context, guildID int64, gifURL string) error { + const q = ` + INSERT INTO welcome (guild_id, welcome_gif_url) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET welcome_gif_url = EXCLUDED.welcome_gif_url + ` + _, err := r.db.ExecContext(ctx, q, guildID, gifURL) + return err +} + diff --git a/internal/db/services/defaultrole/service.go b/internal/db/services/defaultrole/service.go new file mode 100644 index 0000000..60e9cdb --- /dev/null +++ b/internal/db/services/defaultrole/service.go @@ -0,0 +1,24 @@ +package defaultrole + +import ( + "context" + + "velox-bot/internal/db/repos/defaultrolerepo" +) + +type Service struct { + repo *defaultrolerepo.Repo +} + +func New(repo *defaultrolerepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetRole(ctx context.Context, guildID int64) (int64, error) { + return s.repo.GetRole(ctx, guildID) +} + +func (s *Service) SetRole(ctx context.Context, guildID, roleID int64) error { + return s.repo.SetRole(ctx, guildID, roleID) +} + diff --git a/internal/db/services/level/service.go b/internal/db/services/level/service.go new file mode 100644 index 0000000..22af857 --- /dev/null +++ b/internal/db/services/level/service.go @@ -0,0 +1,134 @@ +package level + +import ( + "context" + "math/rand" + "time" + + "velox-bot/internal/db/repos/levelrepo" +) + +type SettingsProvider interface { + IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) + GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error) + GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error) + SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error + SetLevelUpMessage(ctx context.Context, guildID int64, message string) error + GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) +} + +type Result struct { + LeveledUp bool + NewLevel int + RoleRewardID int64 // 0 if none + LevelupMessage string // you can fill this later + LevelupChannel int64 // 0 means "use current channel" + UpdatedXP int64 + UpdatedLevel int +} + +type Service struct { + levels *levelrepo.Repo + settings SettingsProvider + rand *rand.Rand +} + +func New(levels *levelrepo.Repo, settings SettingsProvider) *Service { + return &Service{ + levels: levels, + settings: settings, + rand: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// HandleMessage mirrors your Python gain_xp behavior, minus Discord specifics. +func (s *Service) HandleMessage(ctx context.Context, guildID, userID int64) (*Result, error) { + enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID) + if err != nil || !enabled { + return &Result{LeveledUp: false}, err + } + + lvl, err := s.levels.GetLevel(ctx, guildID, userID) + if err != nil { + return nil, err + } + + // XP gain rules + shouldGainXP := false + if lvl.Level < 5 { + shouldGainXP = true + } else { + // random.randint(1, level//4) == 1 + div := lvl.Level / 4 + if div < 1 { + div = 1 + } + if s.rand.Intn(div)+1 == 1 { + shouldGainXP = true + } + } + + if !shouldGainXP { + return &Result{ + LeveledUp: false, + UpdatedXP: lvl.XP, + UpdatedLevel: lvl.Level, + }, nil + } + + // This assumes your setXp() adds some fixed XP per hit; adapt amount as needed. + xpPerMessage := s.rand.Intn(3) + 1 + lvl.XP += int64(xpPerMessage) + + res := &Result{ + LeveledUp: false, + UpdatedXP: lvl.XP, + UpdatedLevel: lvl.Level, + } + + // Level-up rule: if xp >= 100 -> level++ + if lvl.XP >= 100 { + lvl.Level++ + lvl.XP = 0 // or keep overflow, depending on Python behavior + + res.LeveledUp = true + res.NewLevel = lvl.Level + res.UpdatedXP = lvl.XP + res.UpdatedLevel = lvl.Level + + roleID, ok, err := s.settings.GetRoleRewardForLevel(ctx, guildID, lvl.Level) + if err != nil { + return nil, err + } + if ok { + res.RoleRewardID = roleID + } + + chID, ok, err := s.settings.GetLevelupChannelID(ctx, guildID) + if err != nil { + return nil, err + } + if ok { + res.LevelupChannel = chID + } + } + + // Persist + if err := s.levels.UpsertLevel(ctx, lvl); err != nil { + return nil, err + } + + return res, nil +} + +func (s *Service) SetLevel(ctx context.Context, guildID, userID int64, level int) error { + return s.levels.SetLevel(ctx, guildID, userID, level) +} + +func (s *Service) GetLevel(ctx context.Context, guildID, userID int64) (*levelrepo.Level, error) { + return s.levels.GetLevel(ctx, guildID, userID) +} + +func (s *Service) TopLevels(ctx context.Context, guildID int64, limit int) ([]*levelrepo.Level, error) { + return s.levels.TopLevels(ctx, guildID, limit) +} diff --git a/internal/db/services/levelsettings/service.go b/internal/db/services/levelsettings/service.go new file mode 100644 index 0000000..a97eb00 --- /dev/null +++ b/internal/db/services/levelsettings/service.go @@ -0,0 +1,57 @@ +package levelsettings + +import ( + "context" + + "velox-bot/internal/db/repos/settingsrepo" +) + +type Service struct { + settings *settingsrepo.Repo +} + +func New(settings *settingsrepo.Repo) *Service { + return &Service{settings: settings} +} + +func (s *Service) ToggleLevelSystem(ctx context.Context, guildID int64) (bool, error) { + enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID) + if err != nil { + return false, err + } + + newEnabled := !enabled + if err := s.settings.SetLevelSystemEnabled(ctx, guildID, newEnabled); err != nil { + return false, err + } + + return newEnabled, nil +} + +func (s *Service) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error { + return s.settings.SetLevelSystemEnabled(ctx, guildID, enabled) +} + +func (s *Service) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error { + return s.settings.SetLevelUpChannel(ctx, guildID, channelID) +} + +func (s *Service) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error { + return s.settings.SetLevelUpMessage(ctx, guildID, message) +} + +func (s *Service) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) { + return s.settings.GetLevelUpMessage(ctx, guildID) +} + +func (s *Service) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error { + return s.settings.SetRoleRewardForLevel(ctx, guildID, level, roleID) +} + +func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*settingsrepo.LevelReward, error) { + return s.settings.ListRoleRewards(ctx, guildID, limit) +} + +func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) { + return s.settings.IsLevelSystemEnabled(ctx, guildID) +} diff --git a/internal/db/services/logsettings/service.go b/internal/db/services/logsettings/service.go new file mode 100644 index 0000000..14046e4 --- /dev/null +++ b/internal/db/services/logsettings/service.go @@ -0,0 +1,32 @@ +package logsettings + +import ( + "context" + + "velox-bot/internal/db/repos/settingsrepo" +) + +type Service struct { + settings *settingsrepo.Repo +} + +func New(settings *settingsrepo.Repo) *Service { + return &Service{settings: settings} +} + +func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error { + return s.settings.SetLogChannel(ctx, guildID, channelID) +} + +func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error { + return s.settings.SetLogEnabled(ctx, guildID, enabled) +} + +func (s *Service) GetSettings(ctx context.Context, guildID int64) (*settingsrepo.LogSettings, error) { + return s.settings.GetLogSettings(ctx, guildID) +} + +func (s *Service) HasSettings(ctx context.Context, guildID int64) (bool, error) { + return s.settings.HasLogSettings(ctx, guildID) +} + diff --git a/internal/db/services/meeting/service.go b/internal/db/services/meeting/service.go new file mode 100644 index 0000000..7c2b585 --- /dev/null +++ b/internal/db/services/meeting/service.go @@ -0,0 +1,40 @@ +package meeting + +import ( + "context" + + "velox-bot/internal/db/repos/settingsrepo" +) + +type Service struct { + settings *settingsrepo.Repo +} + +func New(settings *settingsrepo.Repo) *Service { + return &Service{settings: settings} +} + +func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) +} + +func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + return s.settings.GetMeetingLobbyChannel(ctx, guildID) +} + +func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + return s.settings.AddMeetingTempChannel(ctx, guildID, channelID, createdBy) +} + +func (s *Service) RemoveTempChannel(ctx context.Context, guildID, channelID int64) error { + return s.settings.RemoveMeetingTempChannel(ctx, guildID, channelID) +} + +func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + return s.settings.IsMeetingTempChannel(ctx, guildID, channelID) +} + +func (s *Service) GetTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + return s.settings.GetMeetingTempChannelOwner(ctx, guildID, channelID) +} + diff --git a/internal/db/services/projects/service.go b/internal/db/services/projects/service.go new file mode 100644 index 0000000..e1687c8 --- /dev/null +++ b/internal/db/services/projects/service.go @@ -0,0 +1,32 @@ +package projects + +import ( + "context" + + "velox-bot/internal/db/repos/projectsrepo" +) + +type Service struct { + repo *projectsrepo.Repo +} + +func New(repo *projectsrepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) { + return s.repo.CreateProject(ctx, guildID, creatorID, name, description) +} + +func (s *Service) AddHelper(ctx context.Context, projectID, userID int64) error { + return s.repo.AddHelper(ctx, projectID, userID) +} + +func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.ProjectWithMembers, error) { + return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit) +} + +func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { + return s.repo.ProjectExistsInGuild(ctx, guildID, projectID) +} + diff --git a/internal/db/services/rps/service.go b/internal/db/services/rps/service.go new file mode 100644 index 0000000..9bf9f09 --- /dev/null +++ b/internal/db/services/rps/service.go @@ -0,0 +1,136 @@ +package rps + +import ( + "context" + "crypto/rand" + "errors" + "math/big" + "strings" + + "velox-bot/internal/db/repos/rpsrepo" +) + +type Repo interface { + GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) + SetScore(ctx context.Context, guildID int64, userID int64, score int) error + TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) +} + +type Service struct { + repo Repo +} + +func New(repo Repo) *Service { + return &Service{repo: repo} +} + +type Hand string + +const ( + HandScissors Hand = "✌️" + HandPaper Hand = "✋" + HandRock Hand = "🤜" +) + +func (h Hand) String() string { return string(h) } + +func ParseHand(s string) (Hand, bool) { + switch strings.TrimSpace(s) { + case string(HandScissors): + return HandScissors, true + case string(HandPaper): + return HandPaper, true + case string(HandRock): + return HandRock, true + default: + return "", false + } +} + +func RandomHand() (Hand, error) { + n, err := rand.Int(rand.Reader, big.NewInt(3)) + if err != nil { + return "", err + } + switch n.Int64() { + case 0: + return HandScissors, nil + case 1: + return HandPaper, nil + default: + return HandRock, nil + } +} + +type GameResult string + +const ( + ResultWin GameResult = "You won!" + ResultLose GameResult = "You lost!" + ResultTie GameResult = "It's a tie!" +) + +func DetermineResult(user Hand, bot Hand) GameResult { + if user == bot { + return ResultTie + } + switch user { + case HandRock: + if bot == HandScissors { + return ResultWin + } + case HandPaper: + if bot == HandRock { + return ResultWin + } + case HandScissors: + if bot == HandPaper { + return ResultWin + } + } + return ResultLose +} + +type PlayOutput struct { + UserHand Hand + BotHand Hand + Result GameResult + Score int +} + +func (s *Service) Play(ctx context.Context, guildID int64, userID int64, userHand Hand) (PlayOutput, error) { + botHand, err := RandomHand() + if err != nil { + return PlayOutput{}, err + } + result := DetermineResult(userHand, botHand) + + score, _, err := s.repo.GetScore(ctx, guildID, userID) + if err != nil { + return PlayOutput{}, err + } + if result == ResultWin { + score++ + if err := s.repo.SetScore(ctx, guildID, userID, score); err != nil { + return PlayOutput{}, err + } + } + + return PlayOutput{ + UserHand: userHand, + BotHand: botHand, + Result: result, + Score: score, + }, nil +} + +func (s *Service) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + return s.repo.GetScore(ctx, guildID, userID) +} + +func (s *Service) TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) { + return s.repo.TopScores(ctx, guildID, limit) +} + +var ErrGuildOnly = errors.New("guild only") + diff --git a/internal/db/services/schedule/service.go b/internal/db/services/schedule/service.go new file mode 100644 index 0000000..6e74277 --- /dev/null +++ b/internal/db/services/schedule/service.go @@ -0,0 +1,53 @@ +package schedule + +import ( + "context" + "time" + + "velox-bot/internal/db/repos/schedulerepo" +) + +type Service struct { + repo *schedulerepo.Repo +} + +func New(repo *schedulerepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) { + return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description) +} + +func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { + return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM) +} + +func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) { + return s.repo.GetByMessageID(ctx, messageID) +} + +func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error { + return s.repo.UpdateStatus(ctx, scheduleID, status) +} + +func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) { + return s.repo.ListForUser(ctx, guildID, userID, limit) +} + +func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) { + return s.repo.ListDueForReminder(ctx, now, ahead) +} + +func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error { + return s.repo.MarkReminded(ctx, scheduleID) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error { + return s.repo.Reschedule(ctx, id, when) +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go new file mode 100644 index 0000000..d4b21b7 --- /dev/null +++ b/internal/db/services/services.go @@ -0,0 +1,49 @@ +package services + +import ( + "velox-bot/internal/db/services/defaultrole" + "velox-bot/internal/db/services/level" + "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/logsettings" + "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/projects" + "velox-bot/internal/db/services/rps" + "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/twitch" + "velox-bot/internal/db/services/usersettings" + "velox-bot/internal/db/services/welcome" +) + +type Services struct { + Level *level.Service + LevelSettings *levelsettings.Service + Meeting *meeting.Service + Schedule *schedule.Service + UserSettings *usersettings.Service + Projects *projects.Service + RPS *rps.Service + Twitch *twitch.Service + Welcome *welcome.Service + DefaultRole *defaultrole.Service + LogSettings *logsettings.Service +} + +var Global *Services + +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service) *Services { + s := &Services{ + Level: level, + LevelSettings: levelSettings, + Meeting: meeting, + Schedule: schedule, + UserSettings: userSettings, + Projects: projects, + RPS: rps, + Twitch: twitchSvc, + Welcome: welcomeSvc, + DefaultRole: defaultRoleSvc, + LogSettings: logSettingsSvc, + } + Global = s + return s +} diff --git a/internal/db/services/twitch/service.go b/internal/db/services/twitch/service.go new file mode 100644 index 0000000..8a2024c --- /dev/null +++ b/internal/db/services/twitch/service.go @@ -0,0 +1,109 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "velox-bot/internal/db/repos/twitchrepo" +) + +type Service struct { + repo *twitchrepo.Repo + client *http.Client + twitchClient string +} + +func New(repo *twitchrepo.Repo, twitchClientID string) *Service { + return &Service{ + repo: repo, + client: &http.Client{ + Timeout: 5 * time.Second, + }, + twitchClient: twitchClientID, + } +} + +func (s *Service) ListGuilds(ctx context.Context) ([]int64, error) { + return s.repo.ListGuilds(ctx) +} + +func (s *Service) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) { + return s.repo.ListUsersForGuild(ctx, guildID) +} + +func (s *Service) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) { + return s.repo.GetStatus(ctx, guildID, username) +} + +func (s *Service) UpdateStatus(ctx context.Context, guildID int64, username, status string) error { + return s.repo.UpdateStatus(ctx, guildID, username, status) +} + +func (s *Service) UpsertStreamer(ctx context.Context, guildID int64, username string) error { + return s.repo.UpsertStreamer(ctx, guildID, username) +} + +func (s *Service) RemoveStreamer(ctx context.Context, guildID int64, username string) error { + return s.repo.RemoveStreamer(ctx, guildID, username) +} + +func (s *Service) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) { + return s.repo.GetNotificationChannel(ctx, guildID) +} + +func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error { + return s.repo.SetNotificationChannel(ctx, guildID, channelID) +} + +func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) { + type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + type gqlResponse struct { + Data struct { + User *struct { + Stream *struct { + ID string `json:"id"` + } `json:"stream"` + } `json:"user"` + } `json:"data"` + } + + q := fmt.Sprintf(`query { user(login: "%s") { stream { id } } }`, username) + body, err := json.Marshal(gqlRequest{ + Query: q, + Variables: map[string]any{}, + }) + if err != nil { + return false, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://gql.twitch.tv/gql", bytes.NewReader(body)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", s.twitchClient) + + resp, err := s.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var respBody gqlResponse + if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { + return false, err + } + + if respBody.Data.User == nil { + return false, nil + } + return respBody.Data.User.Stream != nil, nil +} + diff --git a/internal/db/services/usersettings/service.go b/internal/db/services/usersettings/service.go new file mode 100644 index 0000000..a4b80b9 --- /dev/null +++ b/internal/db/services/usersettings/service.go @@ -0,0 +1,42 @@ +package usersettings + +import ( + "context" + "time" + + "velox-bot/internal/db/repos/usersettingsrepo" +) + +type Service struct { + repo *usersettingsrepo.Repo +} + +func New(repo *usersettingsrepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error { + // validate zone before storing + if _, err := time.LoadLocation(zone); err != nil { + return err + } + return s.repo.SetTimezone(ctx, userID, zone) +} + +// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined. +func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) { + zone, ok, err := s.repo.GetTimezone(ctx, userID) + if err != nil { + return time.UTC, "UTC", false, err + } + if !ok || zone == "" { + return time.UTC, "UTC", false, nil + } + loc, err := time.LoadLocation(zone) + if err != nil { + // fallback to UTC but indicate not user-defined + return time.UTC, "UTC", false, nil + } + return loc, zone, true, nil +} + diff --git a/internal/db/services/welcome/service.go b/internal/db/services/welcome/service.go new file mode 100644 index 0000000..a3ef775 --- /dev/null +++ b/internal/db/services/welcome/service.go @@ -0,0 +1,36 @@ +package welcome + +import ( + "context" + + "velox-bot/internal/db/repos/welcomerepo" +) + +type Service struct { + repo *welcomerepo.Repo +} + +func New(repo *welcomerepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetSettings(ctx context.Context, guildID int64) (*welcomerepo.Settings, error) { + return s.repo.GetSettings(ctx, guildID) +} + +func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error { + return s.repo.UpsertChannel(ctx, guildID, channelID) +} + +func (s *Service) SetMessage(ctx context.Context, guildID int64, message string) error { + return s.repo.UpsertMessage(ctx, guildID, message) +} + +func (s *Service) SetDM(ctx context.Context, guildID int64, dm string) error { + return s.repo.UpsertDM(ctx, guildID, dm) +} + +func (s *Service) SetGIF(ctx context.Context, guildID int64, gifURL string) error { + return s.repo.UpsertGIF(ctx, guildID, gifURL) +} + diff --git a/internal/diceexpr/diceexpr.go b/internal/diceexpr/diceexpr.go new file mode 100644 index 0000000..3452520 --- /dev/null +++ b/internal/diceexpr/diceexpr.go @@ -0,0 +1,744 @@ +package diceexpr + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "unicode" +) + +type Limits struct { + MaxExprLen int + MaxRepeat int + MaxDice int + MaxSides int + MaxAstDepth int + MaxExplosionDepth int +} + +func DefaultLimits() Limits { + return Limits{ + MaxExprLen: 200, + MaxRepeat: 25, + MaxDice: 300, + MaxSides: 1000000, + MaxAstDepth: 64, + MaxExplosionDepth: 100, + } +} + +type EvalResult struct { + Total int + Trace *Trace +} + +type Trace struct { + // Text is a normalized display of the (non-repeat) expression. + Text string + // Rolls includes all dice rolls (including dropped ones) in encounter order. + Rolls []RollGroup +} + +type RollGroup struct { + Count int + Sides int + // Dice is the per-die roll chain; each die may have multiple faces due to explosions. + Dice []Die + // KeepDrop is the modifier used (kh/kl/dh/dl). Empty if none. + KeepDrop string + KeepDropN int +} + +type Die struct { + Faces []int + Kept bool +} + +type MultiResult struct { + Repeat int + Items []EvalResult +} + +func EvalMany(input string, limits Limits) (MultiResult, error) { + input = strings.TrimSpace(input) + if input == "" { + return MultiResult{}, errors.New("empty") + } + if limits.MaxExprLen > 0 && len(input) > limits.MaxExprLen { + return MultiResult{}, errors.New("too long") + } + + repeat, expr := splitRepeat(input) + if repeat < 1 { + repeat = 1 + } + if limits.MaxRepeat > 0 && repeat > limits.MaxRepeat { + return MultiResult{}, fmt.Errorf("repeat too large") + } + + // Parse once, evaluate many times. + p := newParser(expr, limits) + ast, err := p.parse() + if err != nil { + return MultiResult{}, err + } + norm := formatExpr(ast) + + out := MultiResult{Repeat: repeat, Items: make([]EvalResult, 0, repeat)} + for range repeat { + e := evaluator{limits: limits} + total, trace, err := e.eval(ast) + if err != nil { + return MultiResult{}, err + } + trace.Text = norm + out.Items = append(out.Items, EvalResult{Total: total, Trace: trace}) + } + return out, nil +} + +func splitRepeat(s string) (int, string) { + // Accept "R#expr" where R is positive integer. + // We only treat the first '#' as repeat separator if it occurs before any dice operator. + // This keeps "d#" or other oddities from being interpreted as repeat. + i := strings.IndexByte(s, '#') + if i <= 0 { + return 1, s + } + prefix := strings.TrimSpace(s[:i]) + if prefix == "" { + return 1, s + } + for _, r := range prefix { + if !unicode.IsDigit(r) { + return 1, s + } + } + n, err := strconv.Atoi(prefix) + if err != nil || n <= 0 { + return 1, s + } + return n, strings.TrimSpace(s[i+1:]) +} + +// ---- Formatting ---- + +type FormatOptions struct { + BoldMinMax bool +} + +func DefaultFormatOptions() FormatOptions { + return FormatOptions{BoldMinMax: true} +} + +func FormatResult(r MultiResult, opts FormatOptions) string { + lines := make([]string, 0, len(r.Items)) + for _, item := range r.Items { + lines = append(lines, formatOne(item, opts)) + } + return strings.Join(lines, "\n") +} + +func formatOne(item EvalResult, opts FormatOptions) string { + // If there were multiple roll groups, we still show them in sequence, e.g. "[...][...] expr". + parts := make([]string, 0, len(item.Trace.Rolls)) + for _, g := range item.Trace.Rolls { + parts = append(parts, formatRollGroup(g, opts)) + } + breakdown := strings.Join(parts, " ") + if breakdown == "" { + breakdown = "[]" + } + return fmt.Sprintf("%d \u2190 %s %s", item.Total, breakdown, item.Trace.Text) +} + +func formatRollGroup(g RollGroup, opts FormatOptions) string { + items := make([]string, 0, len(g.Dice)) + for _, d := range g.Dice { + items = append(items, formatDie(d, g.Sides, opts)) + } + return "[" + strings.Join(items, ", ") + "]" +} + +func formatDie(d Die, sides int, opts FormatOptions) string { + // Chain faces with '+' if exploded. + faceParts := make([]string, 0, len(d.Faces)) + for _, v := range d.Faces { + faceParts = append(faceParts, formatFace(v, sides, opts)) + } + txt := strings.Join(faceParts, "+") + if len(d.Faces) > 1 { + // denote explosion occurred (kept compact) + txt = txt + "!" + } + if !d.Kept { + return "~~" + txt + "~~" + } + return txt +} + +func formatFace(v, sides int, opts FormatOptions) string { + if !opts.BoldMinMax { + return strconv.Itoa(v) + } + if v == 1 || v == sides { + return "**" + strconv.Itoa(v) + "**" + } + return strconv.Itoa(v) +} + +// ---- AST / Parser / Evaluator ---- + +type nodeKind int + +const ( + kindNumber nodeKind = iota + kindUnary + kindBinary + kindDice +) + +type node struct { + kind nodeKind + + // number + num int + + // unary + unOp byte + unA *node + + // binary + binOp byte + binL *node + binR *node + + // dice + diceCount *node + diceSides *node + explode bool + keepDrop string // kh/kl/dh/dl + keepN *node +} + +type parser struct { + s string + i int + limits Limits + depth int +} + +func newParser(s string, limits Limits) *parser { + return &parser{s: strings.TrimSpace(s), limits: limits} +} + +func (p *parser) parse() (*node, error) { + p.skipSpaces() + n, err := p.parseExpr() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.i != len(p.s) { + return nil, errors.New("trailing input") + } + return n, nil +} + +func (p *parser) parseExpr() (*node, error) { + // expr = term {( + | - ) term} + left, err := p.parseTerm() + if err != nil { + return nil, err + } + for { + p.skipSpaces() + if p.peek() != '+' && p.peek() != '-' { + return left, nil + } + op := p.next() + right, err := p.parseTerm() + if err != nil { + return nil, err + } + left = &node{kind: kindBinary, binOp: op, binL: left, binR: right} + } +} + +func (p *parser) parseTerm() (*node, error) { + // term = factor {( * | / ) factor} + left, err := p.parseFactor() + if err != nil { + return nil, err + } + for { + p.skipSpaces() + if p.peek() != '*' && p.peek() != '/' { + return left, nil + } + op := p.next() + right, err := p.parseFactor() + if err != nil { + return nil, err + } + left = &node{kind: kindBinary, binOp: op, binL: left, binR: right} + } +} + +func (p *parser) parseFactor() (*node, error) { + // factor = unary + return p.parseUnary() +} + +func (p *parser) parseUnary() (*node, error) { + p.skipSpaces() + if p.peek() == '+' || p.peek() == '-' { + op := p.next() + a, err := p.parseUnary() + if err != nil { + return nil, err + } + return &node{kind: kindUnary, unOp: op, unA: a}, nil + } + return p.parsePrimaryOrDice() +} + +func (p *parser) parsePrimaryOrDice() (*node, error) { + p.skipSpaces() + if p.peek() == '(' { + p.next() + p.depth++ + if p.limits.MaxAstDepth > 0 && p.depth > p.limits.MaxAstDepth { + return nil, errors.New("expression too deep") + } + n, err := p.parseExpr() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.peek() != ')' { + return nil, errors.New("missing )") + } + p.next() + p.depth-- + return n, nil + } + + // Try parse leading number or dice with omitted count. + start := p.i + num, hasNum, err := p.tryParseInt() + if err != nil { + return nil, err + } + p.skipSpaces() + if p.peekLower() == 'd' { + // dice: [count] d sides [keep/drop] [explode] + var countNode *node + if hasNum { + countNode = &node{kind: kindNumber, num: num} + } else { + countNode = &node{kind: kindNumber, num: 1} + } + p.next() // d + sidesNode, err := p.parseSides() + if err != nil { + return nil, err + } + keepDrop, keepN, err := p.parseKeepDrop() + if err != nil { + return nil, err + } + explode := false + p.skipSpaces() + if p.peek() == '!' { + explode = true + p.next() + } + return &node{ + kind: kindDice, + diceCount: countNode, + diceSides: sidesNode, + explode: explode, + keepDrop: keepDrop, + keepN: keepN, + }, nil + } + + // Not a dice; if we had a number, return number; else error. + if hasNum { + return &node{kind: kindNumber, num: num}, nil + } + p.i = start + return nil, errors.New("expected number, dice, or (") +} + +func (p *parser) parseSides() (*node, error) { + p.skipSpaces() + if p.peek() == '%' { + p.next() + return &node{kind: kindNumber, num: 100}, nil + } + n, ok, err := p.tryParseInt() + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("expected sides") + } + return &node{kind: kindNumber, num: n}, nil +} + +func (p *parser) parseKeepDrop() (string, *node, error) { + p.skipSpaces() + // khN klN dhN dlN + if p.peekLower() != 'k' && p.peekLower() != 'd' { + return "", nil, nil + } + c1 := p.peekLower() + if c1 != 'k' && c1 != 'd' { + return "", nil, nil + } + if p.i+1 >= len(p.s) { + return "", nil, nil + } + c2 := byte(unicode.ToLower(rune(p.s[p.i+1]))) + if c2 != 'h' && c2 != 'l' { + return "", nil, nil + } + op := string([]byte{c1, c2}) + p.i += 2 + n, ok, err := p.tryParseInt() + if err != nil { + return "", nil, err + } + if !ok || n <= 0 { + return "", nil, errors.New("expected keep/drop count") + } + return op, &node{kind: kindNumber, num: n}, nil +} + +func (p *parser) tryParseInt() (int, bool, error) { + p.skipSpaces() + if p.i >= len(p.s) || !unicode.IsDigit(rune(p.s[p.i])) { + return 0, false, nil + } + start := p.i + for p.i < len(p.s) && unicode.IsDigit(rune(p.s[p.i])) { + p.i++ + } + v, err := strconv.Atoi(p.s[start:p.i]) + if err != nil { + return 0, false, err + } + return v, true, nil +} + +func (p *parser) skipSpaces() { + for p.i < len(p.s) && unicode.IsSpace(rune(p.s[p.i])) { + p.i++ + } +} + +func (p *parser) peek() byte { + if p.i >= len(p.s) { + return 0 + } + return p.s[p.i] +} + +func (p *parser) peekLower() byte { + if p.i >= len(p.s) { + return 0 + } + return byte(unicode.ToLower(rune(p.s[p.i]))) +} + +func (p *parser) next() byte { + if p.i >= len(p.s) { + return 0 + } + b := p.s[p.i] + p.i++ + return b +} + +func normalizeSpaces(s string) string { + // Keep compact: remove spaces entirely, but preserve leading repeat already stripped outside. + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if !unicode.IsSpace(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// formatExpr renders a canonical expression with explicit dice counts and +// spaces around binary operators. +func formatExpr(n *node) string { + return formatExprPrec(n, 0) +} + +func formatExprPrec(n *node, parentPrec int) string { + switch n.kind { + case kindNumber: + return strconv.Itoa(n.num) + case kindUnary: + inner := formatExprPrec(n.unA, 3) + if n.unOp == '+' { + return inner + } + return string(n.unOp) + inner + case kindDice: + // count and sides are currently numbers, but we keep it generic. + count := formatExprPrec(n.diceCount, 4) + sides := formatExprPrec(n.diceSides, 4) + out := count + "d" + sides + if n.keepDrop != "" && n.keepN != nil { + out += n.keepDrop + formatExprPrec(n.keepN, 4) + } + if n.explode { + out += "!" + } + return out + case kindBinary: + prec := binPrec(n.binOp) + l := formatExprPrec(n.binL, prec) + r := formatExprPrec(n.binR, prec+1) + out := l + " " + string(n.binOp) + " " + r + if prec < parentPrec { + return "(" + out + ")" + } + return out + default: + return "" + } +} + +func binPrec(op byte) int { + switch op { + case '+', '-': + return 1 + case '*', '/': + return 2 + default: + return 0 + } +} + +type evaluator struct { + limits Limits + dice int +} + +func (e *evaluator) eval(n *node) (int, *Trace, error) { + t := &Trace{} + v, err := e.evalNode(n, t) + if err != nil { + return 0, nil, err + } + return v, t, nil +} + +func (e *evaluator) evalNode(n *node, t *Trace) (int, error) { + switch n.kind { + case kindNumber: + return n.num, nil + case kindUnary: + v, err := e.evalNode(n.unA, t) + if err != nil { + return 0, err + } + switch n.unOp { + case '+': + return v, nil + case '-': + return -v, nil + default: + return 0, errors.New("bad unary op") + } + case kindBinary: + l, err := e.evalNode(n.binL, t) + if err != nil { + return 0, err + } + r, err := e.evalNode(n.binR, t) + if err != nil { + return 0, err + } + switch n.binOp { + case '+': + return l + r, nil + case '-': + return l - r, nil + case '*': + return l * r, nil + case '/': + if r == 0 { + return 0, errors.New("division by zero") + } + return l / r, nil + default: + return 0, errors.New("bad binary op") + } + case kindDice: + return e.evalDice(n, t) + default: + return 0, errors.New("unknown node") + } +} + +func (e *evaluator) evalDice(n *node, t *Trace) (int, error) { + count, err := e.evalNode(n.diceCount, t) + if err != nil { + return 0, err + } + sides, err := e.evalNode(n.diceSides, t) + if err != nil { + return 0, err + } + if count <= 0 || sides <= 0 { + return 0, errors.New("invalid dice") + } + if e.limits.MaxDice > 0 && e.dice+count > e.limits.MaxDice { + return 0, errors.New("too many dice") + } + if e.limits.MaxSides > 0 && sides > e.limits.MaxSides { + return 0, errors.New("sides too large") + } + e.dice += count + + keepDrop := n.keepDrop + keepN := 0 + if n.keepN != nil { + keepN, err = e.evalNode(n.keepN, t) + if err != nil { + return 0, err + } + } + if keepDrop != "" && keepN <= 0 { + return 0, errors.New("invalid keep/drop") + } + if keepDrop != "" && keepN > count { + keepN = count + } + + g := RollGroup{ + Count: count, + Sides: sides, + Dice: make([]Die, 0, count), + KeepDrop: keepDrop, + KeepDropN: keepN, + } + + type dieScore struct { + idx int + score int + } + scores := make([]dieScore, 0, count) + + // Roll base dice. + for i := 0; i < count; i++ { + faces, err := rollExploding(sides, n.explode, e.limits.MaxExplosionDepth) + if err != nil { + return 0, err + } + sum := 0 + for _, v := range faces { + sum += v + } + g.Dice = append(g.Dice, Die{Faces: faces, Kept: true}) + scores = append(scores, dieScore{idx: i, score: sum}) + } + + // Apply keep/drop to base dice by comparing chain sums. + if keepDrop != "" { + sort.SliceStable(scores, func(i, j int) bool { return scores[i].score < scores[j].score }) + + kept := make(map[int]bool, count) + switch keepDrop { + case "kh": + for i := len(scores) - keepN; i < len(scores); i++ { + if i >= 0 && i < len(scores) { + kept[scores[i].idx] = true + } + } + case "kl": + for i := 0; i < keepN && i < len(scores); i++ { + kept[scores[i].idx] = true + } + case "dh": + // drop highest N => keep all except highest N + for i := 0; i < len(scores)-keepN; i++ { + if i >= 0 && i < len(scores) { + kept[scores[i].idx] = true + } + } + case "dl": + // drop lowest N => keep highest count-N + for i := keepN; i < len(scores); i++ { + kept[scores[i].idx] = true + } + default: + return 0, errors.New("unknown keep/drop") + } + for i := range g.Dice { + g.Dice[i].Kept = kept[i] + } + } + + // Sum kept dice. + total := 0 + for _, d := range g.Dice { + if !d.Kept { + continue + } + for _, v := range d.Faces { + total += v + } + } + + t.Rolls = append(t.Rolls, g) + return total, nil +} + +func rollExploding(sides int, explode bool, maxDepth int) ([]int, error) { + v, err := roll1(sides) + if err != nil { + return nil, err + } + out := []int{v} + if !explode { + return out, nil + } + depth := 0 + for v == sides { + depth++ + if maxDepth > 0 && depth > maxDepth { + return nil, errors.New("explosion depth limit") + } + v, err = roll1(sides) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + +func roll1(sides int) (int, error) { + if sides <= 0 { + return 0, errors.New("bad sides") + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(sides))) + if err != nil { + return 0, err + } + return int(n.Int64()) + 1, nil +} + diff --git a/internal/events/event_handlers.go b/internal/events/event_handlers.go new file mode 100644 index 0000000..ea12615 --- /dev/null +++ b/internal/events/event_handlers.go @@ -0,0 +1,51 @@ +package events + +import ( + "context" + "log" + "strconv" + "strings" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) { + CacheMessageFromCreate(m) + + if m.Author.Bot || m.GuildID == "" { + return + } + ctx := context.Background() + guildID := mustParseInt64(m.GuildID) + userID := mustParseInt64(m.Author.ID) + res, err := services.Level.HandleMessage(ctx, guildID, userID) + if err != nil || !res.LeveledUp { + return + } + // Decide where to send the level-up message + targetChannelID := m.ChannelID + if res.LevelupChannel != 0 { + targetChannelID = strconv.FormatInt(res.LevelupChannel, 10) + } + content, err := services.LevelSettings.GetLevelUpMessage(ctx, guildID) + if err != nil { + return + } + content = strings.ReplaceAll(content, "{user}", m.Author.Mention()) + content = strings.ReplaceAll(content, "{level}", strconv.Itoa(res.NewLevel)) + + s.ChannelMessageSend(targetChannelID, content) + // Apply role reward if any + if res.RoleRewardID != 0 { + roleID := strconv.FormatInt(res.RoleRewardID, 10) + if err := s.GuildMemberRoleAdd(m.GuildID, m.Author.ID, roleID); err != nil { + log.Printf("Failed to add role reward: guild=%s user=%s role=%s err=%v", m.GuildID, m.Author.ID, roleID, err) + } + } +} + +func mustParseInt64(s string) int64 { + id, _ := strconv.ParseInt(s, 10, 64) + return id +} diff --git a/internal/events/logging.go b/internal/events/logging.go new file mode 100644 index 0000000..40057c2 --- /dev/null +++ b/internal/events/logging.go @@ -0,0 +1,376 @@ +package events + +import ( + "fmt" + "log" + "slices" + "strings" + "sync" + "time" + + "velox-bot/internal/db/services" + "velox-bot/internal/modlog" + + "github.com/bwmarrin/discordgo" +) + +type cachedMessage struct { + Content string + AuthorID string +} + +var messageCache sync.Map // map[messageID]cachedMessage + +func HandleMessageUpdateLog(s *discordgo.Session, m *discordgo.MessageUpdate, svc *services.Services) { + if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil { + return + } + if m.Author != nil && m.Author.Bot { + return + } + + before := "" + if m.BeforeUpdate != nil { + before = strings.TrimSpace(m.BeforeUpdate.Content) + } + if before == "" { + if prev, ok := getCachedMessage(m.ID); ok { + before = strings.TrimSpace(prev.Content) + } + } + after := strings.TrimSpace(m.Content) + if before == after { + return + } + + jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID) + authorMention := "Unknown" + if m.Author != nil { + authorMention = m.Author.Mention() + } + + embed := &discordgo.MessageEmbed{ + Title: "Message Edited", + Color: 0xF1C40F, + Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\n[Jump to message](%s)", authorMention, m.ChannelID, jumpURL), + Fields: []*discordgo.MessageEmbedField{ + {Name: "Before", Value: truncateOrPlaceholder(before), Inline: false}, + {Name: "After", Value: truncateOrPlaceholder(after), Inline: false}, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send message edit log: %v", err) + } + + cacheMessage(m.ID, after, authorIDOrEmpty(m.Author)) +} + +func HandleMessageDeleteLog(s *discordgo.Session, m *discordgo.MessageDelete, svc *services.Services) { + if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil { + return + } + + authorID := "" + authorLabel := "Unknown" + content := "" + if m.BeforeDelete != nil { + if m.BeforeDelete.Author != nil { + authorID = m.BeforeDelete.Author.ID + authorLabel = m.BeforeDelete.Author.Mention() + } + content = strings.TrimSpace(m.BeforeDelete.Content) + } + if content == "" { + if prev, ok := getCachedMessage(m.ID); ok { + content = strings.TrimSpace(prev.Content) + if authorID == "" && prev.AuthorID != "" { + authorID = prev.AuthorID + authorLabel = "<@" + prev.AuthorID + ">" + } + } + } + if authorID == "" && m.Author != nil { + authorID = m.Author.ID + authorLabel = m.Author.Mention() + } + if authorID == "" { + authorLabel = "Unknown" + } + + embed := &discordgo.MessageEmbed{ + Title: "Message Deleted", + Color: 0xE74C3C, + Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\nMessage ID: `%s`", authorLabel, m.ChannelID, m.ID), + Fields: []*discordgo.MessageEmbedField{ + {Name: "Content", Value: truncateOrPlaceholder(content), Inline: false}, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send message delete log: %v", err) + } + deleteCachedMessage(m.ID) +} + +func HandleGuildMemberAddLog(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) { + if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil { + return + } + embed := &discordgo.MessageEmbed{ + Title: "Member Joined", + Color: 0x2ECC71, + Description: fmt.Sprintf("%s joined the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID), + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send member join log: %v", err) + } +} + +func HandleGuildMemberRemoveLog(s *discordgo.Session, m *discordgo.GuildMemberRemove, svc *services.Services) { + if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil { + return + } + + // Try to classify this leave as a kick via recent audit log. + kickEntry, kickMod := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberKick) + title := "Member Left" + color := 0x95A5A6 + description := fmt.Sprintf("%s left the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID) + if kickEntry != nil { + title = "Member Kicked" + color = 0xE67E22 + description = fmt.Sprintf("%s was kicked.\nUser ID: `%s`", m.User.Mention(), m.User.ID) + if kickMod != nil { + description += "\nModerator: " + kickMod.Mention() + } + if strings.TrimSpace(kickEntry.Reason) != "" { + description += "\nReason: " + kickEntry.Reason + } + } + + embed := &discordgo.MessageEmbed{ + Title: title, + Color: color, + Description: description, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send member remove log: %v", err) + } +} + +func HandleGuildMemberUpdateLog(s *discordgo.Session, m *discordgo.GuildMemberUpdate, svc *services.Services) { + if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil || m.BeforeUpdate == nil { + return + } + + added := diffRoleIDs(m.BeforeUpdate.Roles, m.Roles) + removed := diffRoleIDs(m.Roles, m.BeforeUpdate.Roles) + if len(added) == 0 && len(removed) == 0 { + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, 2) + if len(added) > 0 { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Roles Added", + Value: joinRoleMentions(added), + Inline: false, + }) + } + if len(removed) > 0 { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Roles Removed", + Value: joinRoleMentions(removed), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "Member Roles Updated", + Color: 0x3498DB, + Description: fmt.Sprintf("Member: %s (`%s`)", m.User.Mention(), m.User.ID), + Fields: fields, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send member update log: %v", err) + } +} + +func HandleGuildBanAddLog(s *discordgo.Session, m *discordgo.GuildBanAdd, svc *services.Services) { + if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil { + return + } + + entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanAdd) + desc := fmt.Sprintf("%s was banned.\nUser ID: `%s`", m.User.Mention(), m.User.ID) + if moderator != nil { + desc += "\nModerator: " + moderator.Mention() + } + if entry != nil && strings.TrimSpace(entry.Reason) != "" { + desc += "\nReason: " + entry.Reason + } + + embed := &discordgo.MessageEmbed{ + Title: "Member Banned", + Color: 0xC0392B, + Description: desc, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send ban add log: %v", err) + } +} + +func HandleGuildBanRemoveLog(s *discordgo.Session, m *discordgo.GuildBanRemove, svc *services.Services) { + if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil { + return + } + + entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanRemove) + desc := fmt.Sprintf("%s was unbanned.\nUser ID: `%s`", m.User.Mention(), m.User.ID) + if moderator != nil { + desc += "\nModerator: " + moderator.Mention() + } + if entry != nil && strings.TrimSpace(entry.Reason) != "" { + desc += "\nReason: " + entry.Reason + } + + embed := &discordgo.MessageEmbed{ + Title: "Member Unbanned", + Color: 0x27AE60, + Description: desc, + Timestamp: time.Now().Format(time.RFC3339), + } + if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { + log.Printf("log: failed to send ban remove log: %v", err) + } +} + +func findRecentAuditEntryForTarget(s *discordgo.Session, guildID, targetID string, action discordgo.AuditLogAction) (*discordgo.AuditLogEntry, *discordgo.User) { + if s == nil || guildID == "" || targetID == "" { + return nil, nil + } + logs, err := s.GuildAuditLog(guildID, "", "", int(action), 10) + if err != nil || logs == nil { + return nil, nil + } + + var chosen *discordgo.AuditLogEntry + now := time.Now().UTC() + for _, e := range logs.AuditLogEntries { + if e == nil || e.TargetID != targetID || e.ActionType == nil || *e.ActionType != action { + continue + } + ts, err := discordgo.SnowflakeTimestamp(e.ID) + if err != nil { + continue + } + // Keep only very recent entries to reduce false positives. + if now.Sub(ts) > 2*time.Minute { + continue + } + chosen = e + break + } + if chosen == nil { + return nil, nil + } + + for _, u := range logs.Users { + if u != nil && u.ID == chosen.UserID { + return chosen, u + } + } + return chosen, nil +} + +func truncateOrPlaceholder(s string) string { + v := strings.TrimSpace(s) + if v == "" { + return "*empty*" + } + if len(v) <= 1000 { + return v + } + return v[:997] + "..." +} + +func diffRoleIDs(base, compare []string) []string { + out := make([]string, 0) + for _, id := range base { + if id == "" { + continue + } + if !slices.Contains(compare, id) { + out = append(out, id) + } + } + return out +} + +func joinRoleMentions(roleIDs []string) string { + if len(roleIDs) == 0 { + return "*none*" + } + const max = 12 + parts := make([]string, 0, len(roleIDs)) + for idx, id := range roleIDs { + if idx >= max { + parts = append(parts, fmt.Sprintf("... and %d more", len(roleIDs)-max)) + break + } + parts = append(parts, "<@&"+id+">") + } + return strings.Join(parts, ", ") +} + +func CacheMessageFromCreate(m *discordgo.MessageCreate) { + if m == nil || m.Message == nil || m.ID == "" { + return + } + cacheMessage(m.ID, strings.TrimSpace(m.Content), authorIDOrEmpty(m.Author)) +} + +func cacheMessage(messageID, content, authorID string) { + if messageID == "" { + return + } + messageCache.Store(messageID, cachedMessage{ + Content: content, + AuthorID: authorID, + }) +} + +func getCachedMessage(messageID string) (cachedMessage, bool) { + if messageID == "" { + return cachedMessage{}, false + } + raw, ok := messageCache.Load(messageID) + if !ok { + return cachedMessage{}, false + } + msg, ok := raw.(cachedMessage) + if !ok { + return cachedMessage{}, false + } + return msg, true +} + +func deleteCachedMessage(messageID string) { + if messageID == "" { + return + } + messageCache.Delete(messageID) +} + +func authorIDOrEmpty(u *discordgo.User) string { + if u == nil { + return "" + } + return u.ID +} + diff --git a/internal/events/meeting_voice.go b/internal/events/meeting_voice.go new file mode 100644 index 0000000..d63314a --- /dev/null +++ b/internal/events/meeting_voice.go @@ -0,0 +1,116 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleVoiceStateUpdate implements the "meeting lobby" behavior: +// - When a user joins the configured lobby voice channel, create a temporary voice channel +// with the same UserLimit and move the user into it. +// - When a temporary channel becomes empty, delete it. +func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { + if svc == nil || svc.Meeting == nil { + return + } + if vs == nil || vs.VoiceState == nil { + return + } + if vs.GuildID == "" || vs.UserID == "" { + return + } + + ctx := context.Background() + guildID64, _ := strconv.ParseInt(vs.GuildID, 10, 64) + + // Clean up the channel the user left (if any) + if vs.BeforeUpdate != nil && vs.BeforeUpdate.ChannelID != "" { + tryCleanupTempChannel(s, ctx, svc, vs.GuildID, guildID64, vs.BeforeUpdate.ChannelID) + } + + // If not joining a channel (disconnect), nothing else to do. + if vs.ChannelID == "" { + return + } + + lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) + if err != nil || !ok || lobbyID == 0 { + return + } + if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { + return + } + + lobbyCh, err := s.Channel(vs.ChannelID) + if err != nil || lobbyCh == nil { + return + } + + // Create the new channel in the same category as the lobby, with same user limit. + newName := fmt.Sprintf("%s • %s", lobbyCh.Name, time.Now().Format("15:04")) + newCh, err := s.GuildChannelCreateComplex(vs.GuildID, discordgo.GuildChannelCreateData{ + Name: newName, + Type: discordgo.ChannelTypeGuildVoice, + ParentID: lobbyCh.ParentID, + UserLimit: lobbyCh.UserLimit, + Bitrate: lobbyCh.Bitrate, + }) + if err != nil || newCh == nil { + log.Printf("meeting: failed creating temp channel guild=%s user=%s err=%v", vs.GuildID, vs.UserID, err) + return + } + + newChID64, _ := strconv.ParseInt(newCh.ID, 10, 64) + _ = svc.Meeting.AddTempChannel(ctx, guildID64, newChID64, mustParseInt64(vs.UserID)) + + // Move the user into the new channel. + targetID := newCh.ID + if err := s.GuildMemberMove(vs.GuildID, vs.UserID, &targetID); err != nil { + log.Printf("meeting: failed moving member guild=%s user=%s channel=%s err=%v", vs.GuildID, vs.UserID, newCh.ID, err) + // If move fails, delete the channel to avoid junk. + _, _ = s.ChannelDelete(newCh.ID) + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, newChID64) + return + } +} + +func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *services.Services, guildID string, guildID64 int64, channelID string) { + chID64, err := strconv.ParseInt(channelID, 10, 64) + if err != nil || chID64 == 0 { + return + } + isTemp, err := svc.Meeting.IsTempChannel(ctx, guildID64, chID64) + if err != nil || !isTemp { + return + } + + // Check if anyone is still in the channel. + // IMPORTANT: don't use REST `s.Guild(...)` here; it doesn't include VoiceStates. + // Use the session state cache (requires IntentsGuildVoiceStates). + if s == nil || s.State == nil { + return + } + g, err := s.State.Guild(guildID) + if err != nil || g == nil { + return + } + for _, st := range g.VoiceStates { + if st != nil && st.ChannelID == channelID { + return + } + } + + if _, err := s.ChannelDelete(channelID); err != nil { + // If already deleted, still remove from DB. + log.Printf("meeting: failed deleting empty temp channel guild=%s channel=%s err=%v", guildID, channelID, err) + } + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) +} + diff --git a/internal/events/onboarding.go b/internal/events/onboarding.go new file mode 100644 index 0000000..46ffebf --- /dev/null +++ b/internal/events/onboarding.go @@ -0,0 +1,163 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleBotAddedOnGuild applies safe defaults when this bot is added to a guild +// and DMs setup instructions to the inviter (or guild owner fallback). +func HandleBotAddedOnGuild(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) { + if s == nil || m == nil || m.Member == nil || m.User == nil || svc == nil { + return + } + if s.State == nil || s.State.User == nil { + return + } + if m.User.ID != s.State.User.ID { + return + } + + guildID64, err := strconv.ParseInt(m.GuildID, 10, 64) + if err != nil { + return + } + + // Treat an existing logsettings row as "already onboarded once". + alreadyOnboarded := false + if svc.LogSettings != nil { + exists, err := svc.LogSettings.HasSettings(context.Background(), guildID64) + if err == nil { + alreadyOnboarded = exists + } + } + if alreadyOnboarded { + return + } + + // Explicitly disable toggleable systems by default. + if svc.LevelSettings != nil { + if err := svc.LevelSettings.SetLevelSystemEnabled(context.Background(), guildID64, false); err != nil { + log.Printf("onboarding: failed to set default level state for guild %s: %v", m.GuildID, err) + } + } + if svc.LogSettings != nil { + if err := svc.LogSettings.SetEnabled(context.Background(), guildID64, false); err != nil { + log.Printf("onboarding: failed to set default log state for guild %s: %v", m.GuildID, err) + } + } + + recipientID := findRecentBotAdderID(s, m.GuildID) + if recipientID == "" { + recipientID = guildOwnerID(s, m.GuildID) + } + if recipientID == "" { + log.Printf("onboarding: unable to resolve DM recipient for guild %s", m.GuildID) + return + } + + dmCh, err := s.UserChannelCreate(recipientID) + if err != nil { + log.Printf("onboarding: failed creating DM for user %s: %v", recipientID, err) + return + } + + guildName := m.GuildID + if g, err := s.State.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" { + guildName = g.Name + } else if g, err := s.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" { + guildName = g.Name + } + + embed := &discordgo.MessageEmbed{ + Title: "Thanks for adding Velox Bot!", + Color: 0x5865F2, + Description: fmt.Sprintf( + "Server: **%s**\n\nFor safety, toggleable systems start **disabled by default**.\nUse these commands in your server to enable features:", + guildName, + ), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Level System", + Value: "`/slvl toggle`", + Inline: false, + }, + { + Name: "Logging System", + Value: "" + + "`/config setlogchannel channel:#channel`\n" + + "`/config enablelogging`", + Inline: false, + }, + { + Name: "Welcome System", + Value: "" + + "`/config setwelcomechannel channel:#channel`\n" + + "`/config setwelcomemessage message:\"...\"`\n" + + "`/config setwelcomedm message:\"...\"`", + Inline: false, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Tip: run /help in any server channel for the full command reference.", + }, + Timestamp: time.Now().Format(time.RFC3339), + } + + if _, err := s.ChannelMessageSendEmbed(dmCh.ID, embed); err != nil { + log.Printf("onboarding: failed sending setup DM to user %s: %v", recipientID, err) + } +} + +func findRecentBotAdderID(s *discordgo.Session, guildID string) string { + if s == nil || s.State == nil || s.State.User == nil { + return "" + } + logs, err := s.GuildAuditLog(guildID, "", "", int(discordgo.AuditLogActionBotAdd), 10) + if err != nil || logs == nil { + return "" + } + + botID := s.State.User.ID + now := time.Now().UTC() + for _, entry := range logs.AuditLogEntries { + if entry == nil || entry.TargetID != botID || entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionBotAdd { + continue + } + ts, err := discordgo.SnowflakeTimestamp(entry.ID) + if err != nil { + continue + } + if now.Sub(ts) > 10*time.Minute { + continue + } + if entry.UserID != "" { + return entry.UserID + } + } + return "" +} + +func guildOwnerID(s *discordgo.Session, guildID string) string { + if s == nil || guildID == "" { + return "" + } + if s.State != nil { + if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" { + return g.OwnerID + } + } + if g, err := s.Guild(guildID); err == nil && g != nil { + return g.OwnerID + } + return "" +} + diff --git a/internal/events/schedule_reactions.go b/internal/events/schedule_reactions.go new file mode 100644 index 0000000..f0aca0e --- /dev/null +++ b/internal/events/schedule_reactions.go @@ -0,0 +1,95 @@ +package events + +import ( + "context" + "log" + "strconv" + + "velox-bot/internal/db/repos/schedulerepo" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleMessageReactionAdd reacts to emoji on schedule DM messages: +// ✅ accept, ❌ decline, 🔁 request reschedule. +func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) { + if svc == nil || svc.Schedule == nil { + return + } + if r == nil || r.UserID == "" || r.MessageID == "" { + return + } + + // Ignore bot reactions. + if r.Member != nil && r.Member.User != nil && r.Member.User.Bot { + return + } + + msgID64, err := strconv.ParseInt(r.MessageID, 10, 64) + if err != nil || msgID64 == 0 { + return + } + + ctx := context.Background() + sch, err := svc.Schedule.GetByMessageID(ctx, msgID64) + if err != nil || sch == nil { + return + } + + // Only invitee can react to change status. + if r.UserID != strconv.FormatInt(sch.InviteeID, 10) { + return + } + + // Only pending can be transitioned by reaction. + if sch.Status != schedulerepo.StatusPending { + return + } + + var newStatus schedulerepo.ScheduleStatus + switch r.Emoji.Name { + case "✅": + newStatus = schedulerepo.StatusAccepted + case "❌": + newStatus = schedulerepo.StatusDeclined + case "🔁": + newStatus = schedulerepo.StatusRescheduleRequested + default: + return + } + + if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil { + log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err) + return + } + + statusText := map[schedulerepo.ScheduleStatus]string{ + schedulerepo.StatusAccepted: "accepted ✅", + schedulerepo.StatusDeclined: "declined ❌", + schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁", + }[newStatus] + + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + + // Notify requester with local time if configured. + if svc.UserSettings != nil { + loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID) + if err != nil { + loc = sch.ScheduledAt.UTC().Location() + zone = "UTC" + } + localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04") + content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " + + utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "." + + requesterID := strconv.FormatInt(sch.RequesterID, 10) + dmCh, err := s.UserChannelCreate(requesterID) + if err == nil { + if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil { + log.Printf("schedule: failed to DM requester about status change: %v", err) + } + } + } +} + diff --git a/internal/events/schedule_reminders.go b/internal/events/schedule_reminders.go new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/internal/events/schedule_reminders.go @@ -0,0 +1,107 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// StartScheduleReminderLoop runs a background loop that periodically checks for +// upcoming sessions and sends DM reminders to all participants. +func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) { + if svc == nil || svc.Schedule == nil || s == nil { + return + } + + const ( + interval = time.Minute // check every minute + remindBefore = 10 * time.Minute // remind 10 minutes before + initialDelay = 10 * time.Second + ) + + go func() { + // small delay so the bot can fully start + time.Sleep(initialDelay) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + ctx := context.Background() + + // Find all sessions that start in the next `remindBefore` window and haven't been reminded. + schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore) + if err != nil { + log.Printf("schedule: reminder query failed: %v", err) + continue + } + for _, sch := range schedules { + if sch == nil { + continue + } + + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + desc := sch.Description + + // Notify requester with local time if available. + if svc.UserSettings != nil { + locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID) + if err != nil { + locReq = sch.ScheduledAt.UTC().Location() + zoneReq = "UTC" + } + localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04") + base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID) + if desc != "" { + base += "\nTopic: " + desc + } + if err := dmUser(s, sch.RequesterID, base); err != nil { + log.Printf("schedule: failed to DM requester reminder: %v", err) + } + } + + // Notify invitee with their local time if available. + if svc.UserSettings != nil { + locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID) + if err != nil { + locInv = sch.ScheduledAt.UTC().Location() + zoneInv = "UTC" + } + localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04") + baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID) + if desc != "" { + baseInvitee += "\nTopic: " + desc + } + if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil { + log.Printf("schedule: failed to DM invitee reminder: %v", err) + } + } + + if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil { + log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err) + } + } + } + }() +} + +func dmUser(s *discordgo.Session, userID int64, content string) error { + if s == nil || userID == 0 { + return nil + } + uid := strconv.FormatInt(userID, 10) + ch, err := s.UserChannelCreate(uid) + if err != nil { + return err + } + _, err = s.ChannelMessageSend(ch.ID, content) + return err +} + diff --git a/internal/events/twitch_live.go b/internal/events/twitch_live.go new file mode 100644 index 0000000..9f3ae7a --- /dev/null +++ b/internal/events/twitch_live.go @@ -0,0 +1,134 @@ +package events + +import ( + "context" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// StartTwitchLiveLoop periodically checks configured Twitch streamers and +// sends live notifications to the configured channel. +func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) { + if svc == nil || svc.Twitch == nil || s == nil { + return + } + + const ( + interval = 30 * time.Second + initialDelay = 10 * time.Second + ) + + go func() { + time.Sleep(initialDelay) + + log.Printf("twitch: starting live notification loop (interval=%s)", interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + ctx := context.Background() + + guildIDs, err := svc.Twitch.ListGuilds(ctx) + if err != nil { + log.Printf("twitch: failed to list guilds: %v", err) + continue + } + + if len(guildIDs) == 0 { + log.Printf("twitch: no guilds with configured streamers") + } + + for _, guildID := range guildIDs { + log.Printf("twitch: processing guild %d", guildID) + + channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID) + if err != nil { + log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err) + continue + } + if !ok || channelID == 0 { + log.Printf("twitch: no notification channel configured for guild %d", guildID) + continue + } + + users, err := svc.Twitch.ListUsersForGuild(ctx, guildID) + if err != nil { + log.Printf("twitch: failed to list users for guild %d: %v", guildID, err) + continue + } + + if len(users) == 0 { + log.Printf("twitch: no streamers configured for guild %d", guildID) + continue + } + + chIDStr := strconv.FormatInt(channelID, 10) + + for _, username := range users { + log.Printf("twitch: checking live status for guild %d, user %s", guildID, username) + + isLive, err := svc.Twitch.IsUserStreaming(ctx, username) + if err != nil { + log.Printf("twitch: failed to check stream for %s: %v", username, err) + continue + } + + status, ok, err := svc.Twitch.GetStatus(ctx, guildID, username) + if err != nil { + log.Printf("twitch: failed to get status for %s: %v", username, err) + continue + } + if !ok { + status = "not live" + } + + if isLive { + if status == "not live" { + log.Printf("twitch: %s went live in guild %d, sending notification to channel %s", username, guildID, chIDStr) + + liveURL := "https://www.twitch.tv/" + username + content := "@everyone" + + previewURL := "https://static-cdn.jtvnw.net/previews-ttv/live_user_" + username + "-640x360.jpg" + + embed := &discordgo.MessageEmbed{ + Title: ":red_circle: " + username + " is now LIVE on Twitch!", + Description: "Click the link below to watch the stream.", + URL: liveURL, + Color: 0x9146FF, // Twitch purple + Image: &discordgo.MessageEmbedImage{ + URL: previewURL, + }, + } + + if _, err := s.ChannelMessageSendComplex(chIDStr, &discordgo.MessageSend{ + Content: content, + Embed: embed, + }); err != nil { + log.Printf("twitch: failed to send notification for %s: %v", username, err) + continue + } + if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "live"); err != nil { + log.Printf("twitch: failed to update status to live for %s in guild %d: %v", username, guildID, err) + } + } + } else { + if status != "not live" { + log.Printf("twitch: %s is no longer live in guild %d, updating status", username, guildID) + if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "not live"); err != nil { + log.Printf("twitch: failed to update status to not live for %s in guild %d: %v", username, guildID, err) + } + } + } + } + } + } + }() +} + diff --git a/internal/events/welcome.go b/internal/events/welcome.go new file mode 100644 index 0000000..8f7231d --- /dev/null +++ b/internal/events/welcome.go @@ -0,0 +1,133 @@ +package events + +import ( + "context" + "log" + "strconv" + "strings" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleGuildMemberAdd sends a configurable welcome message in a guild channel +// and an optional configurable DM to the joining user. +func HandleGuildMemberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) { + if s == nil || m == nil || svc == nil || svc.Welcome == nil { + return + } + if m.User == nil { + return + } + + ctx := context.Background() + guildID := mustParseInt64(m.GuildID) + + settings, err := svc.Welcome.GetSettings(ctx, guildID) + if err != nil { + log.Printf("welcome: failed to load settings for guild %s: %v", m.GuildID, err) + return + } + hasWelcomeChannel := settings != nil && settings.ChannelID != 0 + + memberMention := m.User.Mention() + + // Resolve guild/server name and user avatar for placeholders / visuals. + guildName := "" + userAvatarURL := "" + if g, err := s.State.Guild(m.GuildID); err == nil && g != nil { + guildName = g.Name + } else if g, err := s.Guild(m.GuildID); err == nil && g != nil { + guildName = g.Name + } + if m.User.Avatar != "" { + userAvatarURL = discordgo.EndpointUserAvatar(m.User.ID, m.User.Avatar) + } + + if hasWelcomeChannel { + // Build guild welcome message. + channelID := strconv.FormatInt(settings.ChannelID, 10) + welcomeMsg := strings.TrimSpace(settings.WelcomeMessage) + if welcomeMsg == "" { + // Default message when none configured. + // "! Welcome to ! Have fun!" + welcomeMsg = "! Welcome to ! Have fun!" + } + welcomeMsg = strings.ReplaceAll(welcomeMsg, "{user}", memberMention) + if guildName != "" { + welcomeMsg = strings.ReplaceAll(welcomeMsg, "{server}", guildName) + welcomeMsg = strings.ReplaceAll(welcomeMsg, "", guildName) + } + welcomeMsg = strings.ReplaceAll(welcomeMsg, "", memberMention) + + embed := &discordgo.MessageEmbed{ + Title: "👋 Welcome!", + Description: welcomeMsg, + Color: 0xFFA500, + Footer: &discordgo.MessageEmbedFooter{ + Text: "ID: " + m.User.ID, + }, + } + if userAvatarURL != "" { + embed.Author = &discordgo.MessageEmbedAuthor{ + Name: m.User.Username, + IconURL: userAvatarURL, + } + embed.Thumbnail = &discordgo.MessageEmbedThumbnail{ + URL: userAvatarURL, + } + } + gif := strings.TrimSpace(settings.WelcomeGIFURL) + if gif == "" { + // Default GIF when none configured. + gif = "https://images-ext-1.discordapp.net/external/uJ6XfdK2WwDnei3RmNWUqSiOVboC4mK9r78TtgVE_9g/https/media.giphy.com/media/XD9o33QG9BoMis7iM4/giphy.gif" + } + embed.Image = &discordgo.MessageEmbedImage{URL: gif} + + if _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: memberMention, + Embed: embed, + }); err != nil { + log.Printf("welcome: failed to send channel welcome in guild %s: %v", m.GuildID, err) + } + } + + // Optional DM. + dmText := "" + if settings != nil { + dmText = strings.TrimSpace(settings.WelcomeDM) + } + if dmText == "" { + // Default DM when none configured. + // "Welcome to ! Have fun!" + dmText = "Welcome to ! Have fun!" + } + dmText = strings.ReplaceAll(dmText, "{user}", memberMention) + if guildName != "" { + dmText = strings.ReplaceAll(dmText, "{server}", guildName) + dmText = strings.ReplaceAll(dmText, "", guildName) + } + + dmCh, err := s.UserChannelCreate(m.User.ID) + if err != nil { + log.Printf("welcome: failed to create DM channel for user %s: %v", m.User.ID, err) + return + } + if _, err := s.ChannelMessageSend(dmCh.ID, dmText); err != nil { + log.Printf("welcome: failed to send DM welcome to user %s: %v", m.User.ID, err) + } + + // Default role assignment. + if svc.DefaultRole != nil { + roleID, err := svc.DefaultRole.GetRole(ctx, guildID) + if err != nil { + log.Printf("defaultrole: failed to get default role for guild %s: %v", m.GuildID, err) + } else if roleID != 0 { + if err := s.GuildMemberRoleAdd(m.GuildID, m.User.ID, strconv.FormatInt(roleID, 10)); err != nil { + log.Printf("defaultrole: failed to assign role %d to user %s in guild %s: %v", roleID, m.User.ID, m.GuildID, err) + } + } + } +} + diff --git a/internal/modlog/modlog.go b/internal/modlog/modlog.go new file mode 100644 index 0000000..1acf830 --- /dev/null +++ b/internal/modlog/modlog.go @@ -0,0 +1,30 @@ +package modlog + +import ( + "context" + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func Send(s *discordgo.Session, svc *services.Services, guildID string, embed *discordgo.MessageEmbed) error { + if s == nil || svc == nil || svc.LogSettings == nil || embed == nil || guildID == "" { + return nil + } + + gid, err := strconv.ParseInt(guildID, 10, 64) + if err != nil { + return nil + } + + cfg, err := svc.LogSettings.GetSettings(context.Background(), gid) + if err != nil || cfg == nil || !cfg.IsEnabled || cfg.ChannelID == 0 { + return err + } + + _, err = s.ChannelMessageSendEmbed(strconv.FormatInt(cfg.ChannelID, 10), embed) + return err +} + diff --git a/internal/music/components.go b/internal/music/components.go new file mode 100644 index 0000000..214b1e5 --- /dev/null +++ b/internal/music/components.go @@ -0,0 +1,108 @@ +package music + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { + // DJ role is required for all music controls. + if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You need the **DJ** role to use music commands.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + customID := i.MessageComponentData().CustomID + parts := strings.Split(customID, ":") + if len(parts) < 2 { + return + } + + action := parts[1] + guildID := i.GuildID + + // Ignore controls from old now-playing messages + currentMsgID := CurrentNowPlayingMessageID(guildID) + if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "These controls are outdated.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Acknowledge immediately to avoid "This interaction failed". + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }) + + var err error + content := "" + + switch action { + case "pause": + err = Pause(guildID, true) + content = "Paused." + case "resume": + err = Pause(guildID, false) + content = "Resumed." + case "toggle_pause": + paused, _ := pausedAndVolume(guildID) + err = Pause(guildID, !paused) + if paused { + content = "Resumed." + } else { + content = "Paused." + } + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + case "skip": + err = Skip(guildID) + content = "Skipped." + case "stop": + err = Stop(guildID) + content = "Stopped." + case "repeat_song": + rs, rq := ToggleRepeatSong(guildID) + _ = rs + _ = rq + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + if rs { + content = "Repeat song enabled." + } else { + content = "Repeat song disabled." + } + case "repeat_queue": + rs, rq := ToggleRepeatQueue(guildID) + _ = rs + _ = rq + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + if rq { + content = "Repeat queue enabled." + } else { + content = "Repeat queue disabled." + } + } + + if content == "" { + content = "Done." + } + if err != nil { + content = "Error: " + err.Error() + } + + // send ephemeral confirmation + _, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/internal/music/manager.go b/internal/music/manager.go new file mode 100644 index 0000000..1a4fce1 --- /dev/null +++ b/internal/music/manager.go @@ -0,0 +1,538 @@ +package music + +import ( + "context" + "fmt" + "net/url" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/disgoorg/disgolink/v3/disgolink" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/snowflake/v2" +) + +type TrackEntry struct { + Track lavalink.Track + RequestedBy string +} + +type guildPlayer struct { + Player disgolink.Player + Queue []TrackEntry + RepeatSong bool + RepeatQueue bool + Paused bool + Volume int + IdleSince time.Time + TextChannelID string + NowPlayingMsgID string +} + +type Manager struct { + client disgolink.Client + session *discordgo.Session + + mu sync.Mutex + players map[string]*guildPlayer +} + +var manager *Manager + +func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error { + if lavalinkHost == "" { + return nil + } + + userID, err := snowflake.Parse(appID) + if err != nil { + return fmt.Errorf("parse app id for lavalink: %w", err) + } + + m := &Manager{ + client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)), + session: session, + players: make(map[string]*guildPlayer), + } + + u, err := url.Parse(lavalinkHost) + if err != nil { + return fmt.Errorf("parse lavalink host: %w", err) + } + + secure := u.Scheme == "https" || u.Scheme == "wss" + password := lavalinkPass + if password == "" { + password = "youshallnotpass" + } + + _, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{ + Name: "main", + Address: u.Host, + Password: password, + Secure: secure, + }) + if err != nil { + return fmt.Errorf("add lavalink node: %w", err) + } + + manager = m + go idleDisconnectLoop() + return nil +} + +func idleDisconnectLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if manager == nil || manager.session == nil { + continue + } + + now := time.Now() + var toDisconnect []string + + manager.mu.Lock() + for guildID, gp := range manager.players { + if gp == nil { + continue + } + + // If we're paused or have anything queued/playing, clear idle timer. + if gp.Paused || len(gp.Queue) > 0 { + gp.IdleSince = time.Time{} + continue + } + + if gp.IdleSince.IsZero() { + gp.IdleSince = now + continue + } + + if now.Sub(gp.IdleSince) >= time.Minute { + toDisconnect = append(toDisconnect, guildID) + gp.IdleSince = time.Time{} + } + } + manager.mu.Unlock() + + for _, guildID := range toDisconnect { + // Best-effort: disconnect. Safe even if we're already disconnected. + _ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false) + } + } +} + +func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) { + if manager == nil { + return + } + if vs.UserID != manager.session.State.User.ID { + return + } + + guildID := snowflake.MustParse(vs.GuildID) + var channelID *snowflake.ID + if vs.ChannelID != "" { + id := snowflake.MustParse(vs.ChannelID) + channelID = &id + } + + manager.client.OnVoiceStateUpdate( + context.Background(), + guildID, + channelID, + vs.SessionID, + ) +} + +func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) { + if manager == nil { + return + } + endpoint := "" + if ev.Endpoint != "" { + endpoint = ev.Endpoint + } + + manager.client.OnVoiceServerUpdate( + context.Background(), + snowflake.MustParse(ev.GuildID), + ev.Token, + endpoint, + ) +} + +func getOrCreateGuildPlayer(guildID string) *guildPlayer { + manager.mu.Lock() + defer manager.mu.Unlock() + + if gp, ok := manager.players[guildID]; ok { + return gp + } + + player := manager.client.Player(snowflake.MustParse(guildID)) + gp := &guildPlayer{ + Player: player, + Queue: make([]TrackEntry, 0), + Volume: 100, + } + manager.players[guildID] = gp + return gp +} + +func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) { + if manager == nil { + return nil, false, fmt.Errorf("music manager not initialized") + } + + // Join voice channel + if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil { + return nil, false, fmt.Errorf("join voice channel: %w", err) + } + + gp := getOrCreateGuildPlayer(guildID) + if textChannelID != "" { + manager.mu.Lock() + gp.TextChannelID = textChannelID + manager.mu.Unlock() + } + + var loadedTracks []lavalink.Track + + manager.client.BestNode().LoadTracksHandler( + context.Background(), + query, + disgolink.NewResultHandler( + func(track lavalink.Track) { + loadedTracks = append(loadedTracks, track) + }, + func(playlist lavalink.Playlist) { + if len(playlist.Tracks) == 0 { + return + } + + // Respect Lavalink's selectedTrack when present by rotating the playlist. + selected := playlist.Info.SelectedTrack + if selected >= 0 && selected < len(playlist.Tracks) { + loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...) + loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...) + return + } + + loadedTracks = append(loadedTracks, playlist.Tracks...) + }, + func(tracks []lavalink.Track) { + if len(tracks) > 0 { + loadedTracks = append(loadedTracks, tracks[0]) + } + }, + func() { + }, + func(err error) { + }, + ), + ) + + if len(loadedTracks) == 0 { + return nil, false, fmt.Errorf("no tracks found for query") + } + + // Some sources may return unplayable entries; pick the first with an encoded track. + firstPlayableIdx := 0 + for idx := range loadedTracks { + if loadedTracks[idx].Encoded != "" { + firstPlayableIdx = idx + break + } + } + if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) { + loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...) + } + + entries := make([]TrackEntry, len(loadedTracks)) + for idx, t := range loadedTracks { + entries[idx] = TrackEntry{ + Track: t, + RequestedBy: requester, + } + } + firstEntry := entries[0] + + manager.mu.Lock() + shouldStart := len(gp.Queue) == 0 + gp.Queue = append(gp.Queue, entries...) + gp.IdleSince = time.Time{} + manager.mu.Unlock() + + if shouldStart { + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil { + return nil, false, fmt.Errorf("start track: %w", err) + } + if gp.TextChannelID != "" { + postNowPlaying(guildID, gp.TextChannelID, firstEntry) + } + } + + return &firstEntry, shouldStart, nil +} + +func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) { + if manager == nil { + return + } + if !event.Reason.MayStartNext() { + return + } + + guildID := player.GuildID().String() + + manager.mu.Lock() + gp, ok := manager.players[guildID] + if !ok || len(gp.Queue) == 0 { + manager.mu.Unlock() + return + } + + // repeat current track + if gp.RepeatSong { + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } + return + } + + // pop current and optionally cycle it to end + finished := gp.Queue[0] + gp.Queue = gp.Queue[1:] + if gp.RepeatQueue { + gp.Queue = append(gp.Queue, finished) + } + + if len(gp.Queue) == 0 { + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + _ = gp.Player.Update(context.Background(), lavalink.WithNullTrack()) + return + } + + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } +} + +func Pause(guildID string, pause bool) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + gp.Paused = pause + if pause { + gp.IdleSince = time.Time{} + } + manager.mu.Unlock() + return gp.Player.Update(context.Background(), lavalink.WithPaused(pause)) +} + +func Skip(guildID string) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + if len(gp.Queue) == 0 { + manager.mu.Unlock() + return nil + } + // skip always advances regardless of repeat song + skipped := gp.Queue[0] + gp.Queue = gp.Queue[1:] + if gp.RepeatQueue { + gp.Queue = append(gp.Queue, skipped) + } + + if len(gp.Queue) == 0 { + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) + } + + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return err + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } + return nil +} + +func Stop(guildID string) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.Queue = nil + gp.Paused = false + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + + return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) +} + +func GetQueue(guildID string) (*TrackEntry, []TrackEntry) { + if manager == nil { + return nil, nil + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + defer manager.mu.Unlock() + + if len(gp.Queue) == 0 { + return nil, nil + } + + current := gp.Queue[0] + rest := make([]TrackEntry, len(gp.Queue)-1) + copy(rest, gp.Queue[1:]) + return ¤t, rest +} + +func ToggleRepeatSong(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + gp.RepeatSong = !gp.RepeatSong + if gp.RepeatSong { + gp.RepeatQueue = false + } + return gp.RepeatSong, gp.RepeatQueue +} + +func ToggleRepeatQueue(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + gp.RepeatQueue = !gp.RepeatQueue + if gp.RepeatQueue { + gp.RepeatSong = false + } + return gp.RepeatSong, gp.RepeatQueue +} + +func repeatFlags(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + return gp.RepeatSong, gp.RepeatQueue +} + +func pausedAndVolume(guildID string) (bool, int) { + if manager == nil { + return false, 100 + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + vol := gp.Volume + if vol <= 0 { + vol = 100 + } + return gp.Paused, vol +} + +func SetVolume(guildID string, volume int) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + if volume < 0 { + volume = 0 + } + if volume > 150 { + volume = 150 + } + + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + gp.Volume = volume + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil { + return err + } + + // Refresh the embed so the displayed volume matches. + if textChannelID != "" && nowPlayingMsgID != "" { + current, _ := GetQueue(guildID) + if current != nil { + postNowPlaying(guildID, textChannelID, *current) + } + } + return nil +} + +func CurrentNowPlayingMessageID(guildID string) string { + if manager == nil { + return "" + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + return gp.NowPlayingMsgID +} + + diff --git a/internal/music/nowplaying.go b/internal/music/nowplaying.go new file mode 100644 index 0000000..a5067f2 --- /dev/null +++ b/internal/music/nowplaying.go @@ -0,0 +1,179 @@ +package music + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/snowflake/v2" +) + +func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent { + repeatSongStyle := discordgo.SecondaryButton + if repeatSong { + repeatSongStyle = discordgo.SuccessButton + } + repeatQueueStyle := discordgo.SecondaryButton + if repeatQueue { + repeatQueueStyle = discordgo.SuccessButton + } + + return []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Style: discordgo.SecondaryButton, + Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]}, + CustomID: "music:toggle_pause", + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.SecondaryButton, + Emoji: &discordgo.ComponentEmoji{Name: "⏭️"}, + CustomID: "music:skip", + Disabled: disabled, + }, + discordgo.Button{ + Style: repeatSongStyle, + Emoji: &discordgo.ComponentEmoji{Name: "🔂"}, + CustomID: "music:repeat_song", + Disabled: disabled, + }, + discordgo.Button{ + Style: repeatQueueStyle, + Emoji: &discordgo.ComponentEmoji{Name: "🔁"}, + CustomID: "music:repeat_queue", + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.DangerButton, + Emoji: &discordgo.ComponentEmoji{Name: "✖️"}, + CustomID: "music:stop", + Disabled: disabled, + }, + }, + }, + } +} + +func disableNowPlaying(textChannelID, messageID string) { + if manager == nil || textChannelID == "" || messageID == "" { + return + } + disabled := nowPlayingComponents(true, false, false, false) + _, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: textChannelID, + ID: messageID, + Components: &disabled, + }) +} + +func postNowPlaying(guildID, textChannelID string, entry TrackEntry) { + if manager == nil || textChannelID == "" { + return + } + + manager.mu.Lock() + gp, ok := manager.players[guildID] + var oldMsgID string + if ok { + oldMsgID = gp.NowPlayingMsgID + } + manager.mu.Unlock() + + if oldMsgID != "" { + disableNowPlaying(textChannelID, oldMsgID) + } + + repeatSong, repeatQueue := repeatFlags(guildID) + paused, volume := pausedAndVolume(guildID) + + info := entry.Track.Info + title := info.Title + + embed := &discordgo.MessageEmbed{ + Author: &discordgo.MessageEmbedAuthor{ + Name: info.Author, + IconURL: sourceIconURL(info.SourceName), + }, + Title: title, + Color: 0x2B2D31, // matches Discord dark-ish embed accent + Fields: []*discordgo.MessageEmbedField{ + {Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true}, + {Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true}, + {Name: "Requested by", Value: entry.RequestedBy, Inline: true}, + }, + } + + if info.URI != nil && *info.URI != "" { + embed.URL = *info.URI + } + + if art := artworkURL(info); art != "" { + embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art} + } + + msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused), + }) + if err != nil { + return + } + + manager.mu.Lock() + // Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks). + gp = manager.players[guildID] + if gp == nil { + gp = &guildPlayer{ + Player: manager.client.Player(snowflake.MustParse(guildID)), + Queue: make([]TrackEntry, 0), + } + manager.players[guildID] = gp + } + gp.TextChannelID = textChannelID + gp.NowPlayingMsgID = msg.ID + manager.mu.Unlock() +} + +func artworkURL(info lavalink.TrackInfo) string { + if info.ArtworkURL != nil && *info.ArtworkURL != "" { + return *info.ArtworkURL + } + // Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback. + if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" { + return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg" + } + return "" +} + +func formatDuration(d lavalink.Duration, isStream bool) string { + if isStream { + return "Live" + } + secs := d.Seconds() + if secs < 0 { + secs = 0 + } + h := secs / 3600 + m := (secs % 3600) / 60 + s := secs % 60 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%d:%02d", m, s) +} + +func sourceIconURL(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "youtube": + // Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds) + return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com" + case "soundcloud": + return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com" + default: + return "" + } +} + diff --git a/internal/music/permissions.go b/internal/music/permissions.go new file mode 100644 index 0000000..5bd94f9 --- /dev/null +++ b/internal/music/permissions.go @@ -0,0 +1,36 @@ +package music + +import "github.com/bwmarrin/discordgo" + +func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool { + if s == nil || guildID == "" || m == nil { + return false + } + if len(m.Roles) == 0 { + return false + } + + roles, err := s.GuildRoles(guildID) + if err != nil { + return false + } + + djRoleID := "" + for _, r := range roles { + if r != nil && r.Name == "DJ" { + djRoleID = r.ID + break + } + } + if djRoleID == "" { + return false + } + + for _, rid := range m.Roles { + if rid == djRoleID { + return true + } + } + return false +} + diff --git a/internal/music/repeat_buttons.go b/internal/music/repeat_buttons.go new file mode 100644 index 0000000..ff6f05d --- /dev/null +++ b/internal/music/repeat_buttons.go @@ -0,0 +1,18 @@ +package music + +import "github.com/bwmarrin/discordgo" + +func updateNowPlayingButtons(channelID, messageID string, guildID string) { + if manager == nil || channelID == "" || messageID == "" { + return + } + repeatSong, repeatQueue := repeatFlags(guildID) + paused, _ := pausedAndVolume(guildID) + comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused) + _, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: channelID, + ID: messageID, + Components: &comps, + }) +} + diff --git a/internal/rankcard/rankcard.go b/internal/rankcard/rankcard.go new file mode 100644 index 0000000..d0aab3f --- /dev/null +++ b/internal/rankcard/rankcard.go @@ -0,0 +1,178 @@ +package rankcard + +import ( + "bytes" + "context" + "fmt" + "image" + "image/png" + "io" + "math" + "net/http" + "time" + + "github.com/fogleman/gg" + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/opentype" +) + +type Data struct { + Name string + Level int + XP int64 + Percent float64 // 0..1 progress towards next level + Avatar string // URL +} + +func RenderPNG(ctx context.Context, d Data) ([]byte, error) { + const ( + w = 900 + h = 300 + px = 30 + + avatarSize = 150 + barX = 30 + barY = 220 + barW = 650 + barH = 40 + ) + + percent := clamp01(d.Percent) + + dc := gg.NewContext(w, h) + dc.SetHexColor("#141414") + dc.Clear() + + // Right white polygon + dc.SetHexColor("#FFFFFF") + dc.NewSubPath() + dc.MoveTo(600, 0) + dc.LineTo(750, 300) + dc.LineTo(900, 300) + dc.LineTo(900, 0) + dc.ClosePath() + dc.Fill() + + // Avatar (circle clip) + if img, err := fetchImage(ctx, d.Avatar); err == nil && img != nil { + img = resizeImage(img, avatarSize, avatarSize) + dc.Push() + dc.DrawCircle(px+avatarSize/2, px+avatarSize/2, avatarSize/2) + dc.Clip() + dc.DrawImage(img, px, px) + dc.Pop() + // Defensive: ensure no clip leaks past avatar drawing. + dc.ResetClip() + } + + // Progress bar background + dc.SetHexColor("#FFFFFF") + dc.DrawRoundedRectangle(barX, barY, barW, barH, 20) + dc.Fill() + + // Progress bar fill + fillW := barW * percent + if fillW > 0 { + dc.SetHexColor("#282828") + // Keep radius from looking weird on small widths + r := math.Min(20, fillW/2) + dc.DrawRoundedRectangle(barX, barY, fillW, barH, r) + dc.Fill() + } + + // Text + titleFace, smallFace, err := loadFontFaces() + if err != nil { + return nil, err + } + + dc.SetHexColor("#FFFFFF") + dc.SetFontFace(titleFace) + dc.DrawStringAnchored(d.Name, 200, 60, 0, 0.5) + + dc.SetLineWidth(2) + dc.DrawLine(200, 100, 550, 100) + dc.Stroke() + + dc.SetFontFace(smallFace) + dc.DrawStringAnchored( + fmt.Sprintf("Level - %d | XP - %d | %d%%", d.Level, d.XP, int64(math.Round(percent*100))), + 200, + 145, + 0, + 0.5, + ) + + // Watermark to verify latest render is running + dc.SetFontFace(smallFace) + dc.SetHexColor("#FFCC00") + dc.DrawStringAnchored("velox-go rankcard", 890, 290, 1, 1) + + var buf bytes.Buffer + if err := png.Encode(&buf, dc.Image()); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func loadFontFaces() (title font.Face, small font.Face, err error) { + f, err := opentype.Parse(goregular.TTF) + if err != nil { + return nil, nil, err + } + + title, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 40, DPI: 72, Hinting: font.HintingFull}) + if err != nil { + return nil, nil, err + } + small, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 30, DPI: 72, Hinting: font.HintingFull}) + if err != nil { + return nil, nil, err + } + + return title, small, nil +} + +func fetchImage(ctx context.Context, url string) (image.Image, error) { + if url == "" { + return nil, fmt.Errorf("empty avatar url") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode) + } + + img, _, err := image.Decode(resp.Body) + return img, err +} + +func resizeImage(src image.Image, w, h int) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + return dst +} diff --git a/main.go b/main.go index 8d2a608..c009b14 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,28 @@ import ( "velox-bot/internal/bot" "velox-bot/internal/commands" "velox-bot/internal/config" + "velox-bot/internal/db" + "velox-bot/internal/db/repos/levelrepo" + "velox-bot/internal/db/repos/projectsrepo" + "velox-bot/internal/db/repos/rpsrepo" + "velox-bot/internal/db/repos/schedulerepo" + "velox-bot/internal/db/repos/settingsrepo" + "velox-bot/internal/db/repos/welcomerepo" + "velox-bot/internal/db/repos/twitchrepo" + "velox-bot/internal/db/repos/usersettingsrepo" + "velox-bot/internal/db/repos/defaultrolerepo" + "velox-bot/internal/db/services" + "velox-bot/internal/db/services/level" + "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/logsettings" + "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/projects" + "velox-bot/internal/db/services/rps" + "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/twitch" + "velox-bot/internal/db/services/usersettings" + "velox-bot/internal/db/services/welcome" + "velox-bot/internal/db/services/defaultrole" ) func main() { @@ -18,7 +40,36 @@ func main() { return } - bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands) + db, err := db.NewDB(config) + if err != nil { + log.Fatalf("Error creating database: %v", err) + return + } + defer db.Close() + + levelRepo := levelrepo.NewRepo(db) + settingsRepo := settingsrepo.NewRepo(db) + rpsRepo := rpsrepo.NewRepo(db) + projectsRepo := projectsrepo.NewRepo(db) + scheduleRepo := schedulerepo.NewRepo(db) + userSettingsRepo := usersettingsrepo.NewRepo(db) + welcomeRepo := welcomerepo.NewRepo(db) + twitchRepo := twitchrepo.NewRepo(db) + defaultRoleRepo := defaultrolerepo.NewRepo(db) + levelService := level.New(levelRepo, settingsRepo) + levelSettingsService := levelsettings.New(settingsRepo) + logSettingsService := logsettings.New(settingsRepo) + meetingService := meeting.New(settingsRepo) + scheduleService := schedule.New(scheduleRepo) + userSettingsService := usersettings.New(userSettingsRepo) + projectsService := projects.New(projectsRepo) + rpsService := rps.New(rpsRepo) + twitchService := twitch.New(twitchRepo, config.TwitchClientID) + welcomeService := welcome.New(welcomeRepo) + defaultRoleService := defaultrole.New(defaultRoleRepo) + services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService) + + bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services) if err != nil { log.Fatalf("Error creating bot: %v", err) return diff --git a/migration.sql b/migration.sql new file mode 100644 index 0000000..e69d0ca --- /dev/null +++ b/migration.sql @@ -0,0 +1,31 @@ +-- 1) Ensure the new table exists +CREATE TABLE IF NOT EXISTS levelrewards ( + guild_id BIGINT NOT NULL, + levelreq INT NOT NULL, + role BIGINT NOT NULL, + PRIMARY KEY (guild_id, levelreq) +); + +-- 2) Backfill from legacy columns (only rows that have both values) +INSERT INTO levelrewards (guild_id, levelreq, role) +SELECT guild_id, levelreq, role +FROM levelsettings +WHERE role IS NOT NULL + AND levelreq IS NOT NULL +ON CONFLICT (guild_id, levelreq) +DO UPDATE SET role = EXCLUDED.role; + +SELECT + (SELECT COUNT(*) + FROM levelsettings + WHERE role IS NOT NULL AND levelreq IS NOT NULL) AS legacy_reward_rows, + (SELECT COUNT(*) + FROM levelrewards) AS levelrewards_rows; + + SELECT ls.guild_id, ls.levelreq, ls.role +FROM levelsettings ls +LEFT JOIN levelrewards lr + ON lr.guild_id = ls.guild_id AND lr.levelreq = ls.levelreq +WHERE ls.role IS NOT NULL + AND ls.levelreq IS NOT NULL + AND lr.guild_id IS NULL; \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..e5dfcde --- /dev/null +++ b/schema.sql @@ -0,0 +1,127 @@ +-- PostgreSQL schema for Velox +-- Use BIGINT for Discord snowflakes, INT for smaller counters, BOOLEAN properly. + +CREATE TABLE IF NOT EXISTS levels ( + guild BIGINT NOT NULL, + user_id BIGINT NOT NULL, + level INT NOT NULL DEFAULT 0, + xp BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (guild, user_id) +); + +CREATE TABLE IF NOT EXISTS twitch ( + twitch_user TEXT NOT NULL, + guild_id BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'not live', + PRIMARY KEY (twitch_user, guild_id) +); + +CREATE TABLE IF NOT EXISTS levelsettings ( + guild_id BIGINT PRIMARY KEY, + levelsys BOOLEAN NOT NULL DEFAULT FALSE, + message TEXT +); + +-- Role rewards per level (supports multiple rewards per guild) +CREATE TABLE IF NOT EXISTS levelrewards ( + guild_id BIGINT NOT NULL, + levelreq INT NOT NULL, + role BIGINT NOT NULL, + PRIMARY KEY (guild_id, levelreq) +); + +CREATE TABLE IF NOT EXISTS welcome ( + guild_id BIGINT PRIMARY KEY, + welcome_channel_id BIGINT, + welcome_message TEXT, + welcome_dm TEXT, + welcome_gif_url TEXT +); + +CREATE TABLE IF NOT EXISTS levelup ( + guild_id BIGINT PRIMARY KEY, + levelup_channel_id BIGINT +); + +CREATE TABLE IF NOT EXISTS twitch_config ( + guild_id BIGINT PRIMARY KEY, + twitch_channel_id BIGINT +); + +CREATE TABLE IF NOT EXISTS rps ( + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + score INT NOT NULL DEFAULT 0, + PRIMARY KEY (guild_id, user_id) +); + +CREATE TABLE IF NOT EXISTS defaultrole ( + guild_id BIGINT PRIMARY KEY, + role_id BIGINT +); + +CREATE TABLE IF NOT EXISTS logsettings ( + guild_id BIGINT PRIMARY KEY, + log_channel_id BIGINT, + is_enabled BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Meeting rooms (voice lobby -> auto-created temporary voice channels) +CREATE TABLE IF NOT EXISTS meeting_settings ( + guild_id BIGINT PRIMARY KEY, + lobby_channel_id BIGINT +); + +CREATE TABLE IF NOT EXISTS meeting_temp_channels ( + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, channel_id) +); + +-- Projects & members +CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + guild_id BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL, + is_creator BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (project_id, user_id) +); + +-- +-- Scheduling (one-to-one sessions) +-- +CREATE TABLE IF NOT EXISTS schedules ( + id BIGSERIAL PRIMARY KEY, + guild_id BIGINT NOT NULL, + requester_id BIGINT NOT NULL, + invitee_id BIGINT NOT NULL, + scheduled_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested + description TEXT, + reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS schedule_messages ( + schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE, + message_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + is_dm BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (schedule_id, message_id) +); + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id BIGINT PRIMARY KEY, + timezone TEXT NOT NULL +);