Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1be60e201 | ||
|
|
461f8435c0 | ||
|
|
1b47bd13a3 | ||
|
|
035e383db2 | ||
|
|
2b14079e50 | ||
|
|
a92c4131db | ||
|
|
397a8a459a | ||
|
|
4080ce8aec | ||
|
|
e6aa5def96 | ||
|
|
6410619662 | ||
|
|
df656ca12d | ||
|
|
076e616b9c | ||
|
|
891f3f6ad2 | ||
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
d7edfea45e |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
+21
@@ -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"]
|
||||
@@ -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 <expr>`
|
||||
- `/rps <hand>`, `/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:<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:<twitch-name>` — Add a streamer for notifications
|
||||
- `/config removestreamer username:<twitch-name>` — 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:<image-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).
|
||||
+16
-17
@@ -1,23 +1,22 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: velox-postgres
|
||||
bot:
|
||||
build: .
|
||||
container_name: velox-bot
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: velox
|
||||
POSTGRES_PASSWORD: velox_pwd
|
||||
POSTGRES_DB: velox
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- velox_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U velox -d velox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
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
|
||||
|
||||
volumes:
|
||||
velox_pgdata:
|
||||
networks:
|
||||
lavalink:
|
||||
external: true
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -11,8 +13,9 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/disgoorg/json v1.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // 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
|
||||
|
||||
@@ -3,12 +3,19 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0
|
||||
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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
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=
|
||||
|
||||
+59
-6
@@ -5,6 +5,7 @@ import (
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -16,9 +17,11 @@ type Bot struct {
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*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
|
||||
@@ -27,21 +30,27 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
|
||||
// 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,
|
||||
Services: services,
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -50,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)
|
||||
@@ -66,8 +77,49 @@ func (b *Bot) Start() error {
|
||||
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) {
|
||||
@@ -75,6 +127,7 @@ func (b *Bot) Start() error {
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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/timezone"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
"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"
|
||||
)
|
||||
@@ -25,33 +29,51 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
||||
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.PingHandler,
|
||||
"joke": fun.JokeHandler,
|
||||
"coinflip": fun.CoinflipHandler,
|
||||
"dice": fun.DiceHandler,
|
||||
"rps": fun.RpsHandler,
|
||||
"rpsstats": fun.RpsStatsHandler,
|
||||
"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,
|
||||
"timezone": timezone.TimezoneHandler,
|
||||
"schedule": schedule.ScheduleHandler,
|
||||
"projects": projects.ProjectsHandler,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -42,80 +42,107 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Velox Bot Help",
|
||||
Description: "Here is the help for the bot.",
|
||||
Description: "Command reference. Some commands require server permissions or roles.",
|
||||
Color: 0xFFA500,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Fun Commands",
|
||||
Value: "Small utility + games.",
|
||||
Name: "Fun",
|
||||
Value: "" +
|
||||
"`/ping` — Pong!\n" +
|
||||
"`/joke [type]` — Random joke (optional category)\n" +
|
||||
"`/coinflip` — Flip a coin\n" +
|
||||
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
|
||||
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
|
||||
"`/rpsstats` — Your RPS score (server only)\n" +
|
||||
"`/rpsleaderboard` — Top RPS scores (server only)",
|
||||
},
|
||||
{
|
||||
Name: "/ping",
|
||||
Value: "Pong!",
|
||||
Name: "Music (requires **DJ** role)",
|
||||
Value: "" +
|
||||
"`/play <query>` — Play a song (search or URL)\n" +
|
||||
"`/queue` — Show the current queue\n" +
|
||||
"`/volume <0-150>` — Set playback volume",
|
||||
},
|
||||
{
|
||||
Name: "/joke [type]",
|
||||
Value: "Get a random joke (optional category).",
|
||||
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:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
|
||||
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
|
||||
},
|
||||
{
|
||||
Name: "/coinflip",
|
||||
Value: "Flip a coin.",
|
||||
Name: "Timezone",
|
||||
Value: "" +
|
||||
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
|
||||
"`/timezone show` — Show your timezone",
|
||||
},
|
||||
{
|
||||
Name: "/dice <expr>",
|
||||
Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).",
|
||||
Name: "Projects",
|
||||
Value: "" +
|
||||
"`/projects list` — List active projects\n" +
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "/rps <hand>",
|
||||
Value: "Play Rock Paper Scissors (server only).",
|
||||
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: "/rpsstats",
|
||||
Value: "Show your RPS score (server only).",
|
||||
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:<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: "/rpsleaderboard",
|
||||
Value: "Show top RPS scores (server only).",
|
||||
Name: "Leveling",
|
||||
Value: "" +
|
||||
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
|
||||
"`/slvl set user:@someone level:<n>` — 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:<n> 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: "/help",
|
||||
Value: "Get help with the bot.",
|
||||
Name: "Twitch live notifications (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config addstreamer username:<twitch-name>` — Add a streamer\n" +
|
||||
"`/config removestreamer username:<twitch-name>` — Remove a streamer\n" +
|
||||
"`/config settwitchnotificationchannel channel:#channel` — Set notification channel",
|
||||
},
|
||||
{
|
||||
Name: "Leveling System",
|
||||
Value: `The leveling system is a system that allows users to gain XP and level up. The system is based on the amount of messages sent in a server. The more messages you send, the more XP you gain. The more XP you gain, the higher your level will be. The leveling system is disabled by default, however you can enable/disable it by using the /slvl toggle command. You can also configure the leveling system by using the /slvl command.`,
|
||||
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:<image-url>` — GIF/image used in welcome embed\n" +
|
||||
"`/config setdefaultrole role:@role` — Role granted to new members",
|
||||
},
|
||||
{
|
||||
Name: "/slvl toggle",
|
||||
Value: "Toggle the leveling system.",
|
||||
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: "/slvl set",
|
||||
Value: "Set the level of a user.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-channel",
|
||||
Value: "Set the channel for level-up messages.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-message",
|
||||
Value: "Set the custom level-up message.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-reward",
|
||||
Value: "Set a role reward for a level.",
|
||||
},
|
||||
{
|
||||
Name: "/rank [user]",
|
||||
Value: "Show a rank card (server only).",
|
||||
},
|
||||
{
|
||||
Name: "/leaderboard",
|
||||
Value: "Show top levels (server only).",
|
||||
},
|
||||
{
|
||||
Name: "/rewards",
|
||||
Value: "List configured level rewards (server only).",
|
||||
Name: "Other",
|
||||
Value: "`/help` — Show this help in DMs",
|
||||
},
|
||||
},
|
||||
Author: &author,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
+22
-11
@@ -8,10 +8,13 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
TwitchClientID string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
@@ -30,19 +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,
|
||||
DBHost: dbHost,
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
TwitchClientID: twitchClientID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ type LevelReward struct {
|
||||
RoleID int64
|
||||
}
|
||||
|
||||
type LogSettings struct {
|
||||
GuildID int64
|
||||
ChannelID int64
|
||||
IsEnabled bool
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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/schedule"
|
||||
"velox-bot/internal/db/services/usersettings"
|
||||
"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 {
|
||||
@@ -18,11 +22,15 @@ type Services struct {
|
||||
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) *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,
|
||||
@@ -31,6 +39,10 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
||||
UserSettings: userSettings,
|
||||
Projects: projects,
|
||||
RPS: rps,
|
||||
Twitch: twitchSvc,
|
||||
Welcome: welcomeSvc,
|
||||
DefaultRole: defaultRoleSvc,
|
||||
LogSettings: logSettingsSvc,
|
||||
}
|
||||
Global = s
|
||||
return s
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
||||
CacheMessageFromCreate(m)
|
||||
|
||||
if m.Author.Bot || m.GuildID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// "<user>! Welcome to <server>! Have fun!"
|
||||
welcomeMsg = "<user>! Welcome to <server>! Have fun!"
|
||||
}
|
||||
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{user}", memberMention)
|
||||
if guildName != "" {
|
||||
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{server}", guildName)
|
||||
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<server>", guildName)
|
||||
}
|
||||
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<user>", 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 <server>! Have fun!"
|
||||
dmText = "Welcome to <server>! Have fun!"
|
||||
}
|
||||
dmText = strings.ReplaceAll(dmText, "{user}", memberMention)
|
||||
if guildName != "" {
|
||||
dmText = strings.ReplaceAll(dmText, "{server}", guildName)
|
||||
dmText = strings.ReplaceAll(dmText, "<server>", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,15 +15,22 @@ import (
|
||||
"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/schedule"
|
||||
"velox-bot/internal/db/services/usersettings"
|
||||
"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() {
|
||||
@@ -46,16 +53,23 @@ func main() {
|
||||
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)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
|
||||
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, commands.AllCommands, services)
|
||||
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
|
||||
|
||||
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS twitch (
|
||||
CREATE TABLE IF NOT EXISTS levelsettings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
role BIGINT,
|
||||
levelreq INT,
|
||||
message TEXT
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user