Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ea5f17e3 | ||
|
|
23d417ef27 | ||
|
|
52dfe859ee | ||
|
|
da66eb8dd5 | ||
|
|
fcc7c9c66a | ||
|
|
7a17c55774 | ||
|
|
39962457b3 | ||
|
|
539c0a69af | ||
|
|
d6687879dd | ||
|
|
e507225cae | ||
|
|
c7b960e14a | ||
|
|
691b505175 | ||
|
|
95ba434194 | ||
|
|
5f4e893971 | ||
|
|
01dbb98450 | ||
|
|
5024af2789 | ||
|
|
483dc666d1 | ||
|
|
3585fa0658 | ||
|
|
d1be60e201 | ||
|
|
26e1d9b094 | ||
|
|
461f8435c0 | ||
|
|
1b47bd13a3 | ||
|
|
035e383db2 | ||
|
|
2b14079e50 | ||
|
|
a92c4131db | ||
|
|
397a8a459a | ||
|
|
4080ce8aec | ||
|
|
e6aa5def96 | ||
|
|
6410619662 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
.env
|
||||
*.env
|
||||
.cursor
|
||||
tmp/
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.1-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"]
|
||||
@@ -10,6 +10,12 @@ It currently includes:
|
||||
- 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
|
||||
|
||||
@@ -34,6 +40,10 @@ 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:
|
||||
@@ -90,6 +100,28 @@ just down # stop postgres
|
||||
- `/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
|
||||
|
||||
|
||||
+15
-2
@@ -1,5 +1,3 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
@@ -19,5 +17,20 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
bot:
|
||||
build: .
|
||||
container_name: velox-bot
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_HOST: ${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
|
||||
|
||||
volumes:
|
||||
velox_pgdata:
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
require (
|
||||
github.com/disgoorg/json v1.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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
|
||||
|
||||
@@ -13,6 +13,8 @@ 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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=
|
||||
|
||||
+55
-10
@@ -1,8 +1,10 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
"velox-bot/internal/music"
|
||||
@@ -17,11 +19,13 @@ type Bot struct {
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
MusicRepo *musicrepo.Repo
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
|
||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
||||
session, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -30,14 +34,15 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
||||
// 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
|
||||
discordgo.IntentsDirectMessages
|
||||
|
||||
return &Bot{
|
||||
Session: session,
|
||||
@@ -45,6 +50,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
MusicRepo: musicRepo,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
@@ -55,7 +62,13 @@ func (b *Bot) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
||||
_ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
||||
|
||||
go func() {
|
||||
if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil {
|
||||
log.Printf("music: command listener stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||
for _, cmd := range b.Commands {
|
||||
@@ -73,6 +86,42 @@ 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)
|
||||
@@ -82,10 +131,6 @@ func (b *Bot) Start() error {
|
||||
music.OnVoiceServerUpdate(ev)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
|
||||
events.HandleMessageReactionAdd(s, r, b.Services)
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"velox-bot/internal/commands/config"
|
||||
"velox-bot/internal/commands/fun"
|
||||
"velox-bot/internal/commands/help"
|
||||
cmdlevel "velox-bot/internal/commands/level"
|
||||
"velox-bot/internal/commands/meeting"
|
||||
"velox-bot/internal/commands/moderation"
|
||||
cmdmusic "velox-bot/internal/commands/music"
|
||||
"velox-bot/internal/commands/projects"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
@@ -31,7 +33,9 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
||||
cmdmusic.Play,
|
||||
cmdmusic.Queue,
|
||||
cmdmusic.Volume,
|
||||
cmdmusic.Music,
|
||||
meeting.Meeting,
|
||||
moderation.Moderation,
|
||||
timezone.Timezone,
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
@@ -52,12 +56,14 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
||||
"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,
|
||||
"music": cmdmusic.MusicHandler,
|
||||
"config": config.ConfigHandler,
|
||||
}
|
||||
|
||||
@@ -69,8 +75,22 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
}
|
||||
case discordgo.InteractionMessageComponent:
|
||||
data := i.MessageComponentData()
|
||||
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
|
||||
switch {
|
||||
case strings.HasPrefix(data.CustomID, "music:"):
|
||||
music.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "schedule:"):
|
||||
schedule.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleComponent(s, i)
|
||||
}
|
||||
case discordgo.InteractionModalSubmit:
|
||||
data := i.ModalSubmitData()
|
||||
switch {
|
||||
case strings.HasPrefix(data.CustomID, "schedule:"):
|
||||
schedule.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleModalSubmit(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -55,6 +55,96 @@ var Config = &discordgo.ApplicationCommand{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,8 +157,8 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
respondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.Twitch == nil {
|
||||
respondEphemeral(s, i, "Twitch configuration is not available.")
|
||||
if services.Global == nil {
|
||||
respondEphemeral(s, i, "Configuration services are not available.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,6 +178,30 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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.")
|
||||
}
|
||||
@@ -193,6 +307,265 @@ func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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,
|
||||
|
||||
@@ -94,6 +94,17 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
"`/meeting invite user:@someone` — Allow a user to join\n" +
|
||||
"`/meeting uninvite user:@someone` — Remove a user's permission",
|
||||
},
|
||||
{
|
||||
Name: "Moderation (focused tools)",
|
||||
Value: "" +
|
||||
"`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" +
|
||||
"`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" +
|
||||
"`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" +
|
||||
"`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" +
|
||||
"`/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)\n" +
|
||||
"`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" +
|
||||
"`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)",
|
||||
},
|
||||
{
|
||||
Name: "Leveling",
|
||||
Value: "" +
|
||||
@@ -106,6 +117,29 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
"`/leaderboard` — Top levels (server only)\n" +
|
||||
"`/rewards` — List configured rewards (server only)",
|
||||
},
|
||||
{
|
||||
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: "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: "Logging (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
|
||||
"`/config enablelogging` — Enable event/moderation logging\n" +
|
||||
"`/config disablelogging` — Disable event/moderation logging",
|
||||
},
|
||||
{
|
||||
Name: "Other",
|
||||
Value: "`/help` — Show this help in DMs",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -23,6 +22,10 @@ var Play = &discordgo.ApplicationCommand{
|
||||
}
|
||||
|
||||
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireMusicEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
@@ -44,13 +47,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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{
|
||||
@@ -59,7 +55,7 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
|
||||
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username, i.Member.User.ID)
|
||||
if err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
@@ -85,37 +81,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package public
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -14,6 +15,9 @@ var Queue = &discordgo.ApplicationCommand{
|
||||
}
|
||||
|
||||
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireMusicEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
@@ -74,4 +78,3 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Music = &discordgo.ApplicationCommand{
|
||||
Name: "music",
|
||||
Description: "Music settings",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "toggle",
|
||||
Description: "Turn the music player on or off for this server",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID == "" {
|
||||
shared.RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||
return
|
||||
}
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireMusicService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing subcommand.")
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "toggle":
|
||||
handleToggle(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
enabled, err := services.Global.MusicSettings.ToggleMusic(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to toggle music playback.")
|
||||
return
|
||||
}
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
shared.RespondEphemeral(s, i, "Music playback "+status+" for this server.")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -23,6 +24,9 @@ var Volume = &discordgo.ApplicationCommand{
|
||||
}
|
||||
|
||||
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireMusicEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
@@ -60,4 +64,3 @@ func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
}
|
||||
|
||||
func ptrFloat(v float64) *float64 { return &v }
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Play *discordgo.ApplicationCommand = public.Play
|
||||
Queue *discordgo.ApplicationCommand = public.Queue
|
||||
Play *discordgo.ApplicationCommand = public.Play
|
||||
Queue *discordgo.ApplicationCommand = public.Queue
|
||||
Volume *discordgo.ApplicationCommand = public.Volume
|
||||
Music *discordgo.ApplicationCommand = public.Music
|
||||
)
|
||||
|
||||
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
|
||||
@@ -17,4 +18,4 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.QueueHandler(s, i)
|
||||
}
|
||||
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
|
||||
|
||||
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MusicHandler(s, i) }
|
||||
@@ -0,0 +1,61 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func RequireMusicEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
guildID, ok := ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
enabled, err := services.Global.MusicSettings.IsMusicEnabled(context.Background(), guildID)
|
||||
if err != nil {
|
||||
RespondEphemeral(s, i, "Failed to load music settings.")
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
RespondEphemeral(s, i, "Music is disabled on this server. Use /music toggle to enable it.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 {
|
||||
RespondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireMusicService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.MusicSettings == nil {
|
||||
RespondEphemeral(s, i, "Music service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) {
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
RespondEphemeral(s, i, "Invalid guild ID.")
|
||||
return 0, false
|
||||
}
|
||||
return guildID, true
|
||||
}
|
||||
|
||||
func 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,222 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/commands/projects/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleComponent routes the select menus shown after /projects add-helper
|
||||
// and /projects close.
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
parts := strings.Split(i.MessageComponentData().CustomID, ":")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "add_helper_project_select":
|
||||
handleAddHelperProjectSelect(s, i)
|
||||
case "add_helper_user_select":
|
||||
handleAddHelperUserSelect(s, i, parts)
|
||||
case "close_select":
|
||||
handleCloseSelect(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddHelperProjectSelect is step 1: a project was picked, now show a
|
||||
// native Discord user-select to pick who helps - the actual AddHelper call
|
||||
// happens in handleAddHelperUserSelect once both are known.
|
||||
func handleAddHelperProjectSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
values := i.MessageComponentData().Values
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
projectID := values[0]
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Who should help with this project?",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{
|
||||
MenuType: discordgo.UserSelectMenu,
|
||||
CustomID: "projects:add_helper_user_select:" + projectID,
|
||||
Placeholder: "Select a user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddHelperUserSelect is step 2: a user was picked from the native
|
||||
// select menu, so both the project (carried in the CustomID) and the user
|
||||
// (the selected value) are now known - perform the actual AddHelper call.
|
||||
func handleAddHelperUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate, parts []string) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
values := i.MessageComponentData().Values
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
userID := values[0]
|
||||
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||
return
|
||||
}
|
||||
if project == nil {
|
||||
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Projects.AddHelper(ctx, projectID, userID64); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to add helper to project.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Added <@" + userID + "> as helper to project **" + project.Name + "**.",
|
||||
Components: []discordgo.MessageComponent{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleCloseSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
values := i.MessageComponentData().Values
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseInt(values[0], 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||
return
|
||||
}
|
||||
if project == nil {
|
||||
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Projects.DeactivateProject(ctx, guildID, projectID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to close project.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Closed project **" + project.Name + "**.",
|
||||
Components: []discordgo.MessageComponent{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleModalSubmit handles the /projects create modal.
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
parts := strings.Split(i.ModalSubmitData().CustomID, ":")
|
||||
if len(parts) < 2 || parts[1] != "create_modal" {
|
||||
return
|
||||
}
|
||||
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
values := modalTextInputValues(i.ModalSubmitData().Components)
|
||||
name := strings.TrimSpace(values["name"])
|
||||
description := strings.TrimSpace(values["description"])
|
||||
if name == "" {
|
||||
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||
}
|
||||
|
||||
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, c := range components {
|
||||
row, ok := c.(*discordgo.ActionsRow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, rc := range row.Components {
|
||||
ti, ok := rc.(*discordgo.TextInput)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[ti.CustomID] = ti.Value
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -24,39 +24,16 @@ var Projects = &discordgo.ApplicationCommand{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new active project",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "name",
|
||||
Description: "Project name",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "Short description (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add-helper",
|
||||
Description: "Add a helper to an existing project",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "project-id",
|
||||
Description: "ID of the project",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to add as helper",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "close",
|
||||
Description: "Close (deactivate) a project",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -79,6 +56,8 @@ func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
handleCreate(s, i)
|
||||
case "add-helper":
|
||||
handleAddHelper(s, i)
|
||||
case "close":
|
||||
handleClose(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
@@ -154,56 +133,53 @@ func mentionUser(id int64) string {
|
||||
return "<@" + strconv.FormatInt(id, 10) + ">"
|
||||
}
|
||||
|
||||
// handleCreate just opens the modal - the actual creation happens on
|
||||
// submit, in HandleModalSubmit (components.go).
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var name, description string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "name":
|
||||
name = o.StringValue()
|
||||
case "description":
|
||||
description = o.StringValue()
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: "projects:create_modal",
|
||||
Title: "Create a project",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "name",
|
||||
Label: "Project name",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "description",
|
||||
Label: "Description (optional)",
|
||||
Style: discordgo.TextInputParagraph,
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddHelper collects the user via Discord's native user-option (a
|
||||
// modal can't hold one of these), then responds with a select menu of the
|
||||
// guild's active projects instead of making the caller remember/type a
|
||||
// numeric project ID - the actual add-helper call happens on selection, in
|
||||
// HandleComponent (components.go).
|
||||
// handleAddHelper is the first of two dropdown steps: pick a project here,
|
||||
// then HandleComponent (components.go) follows up with a native Discord
|
||||
// user-select to pick who helps, and performs the actual AddHelper call
|
||||
// once both are known.
|
||||
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
@@ -214,55 +190,110 @@ func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var projectID int64
|
||||
var userID string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "project-id":
|
||||
projectID = o.IntValue()
|
||||
case "user":
|
||||
if u := o.UserValue(s); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid project ID.")
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
|
||||
options, err := activeProjectOptions(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
|
||||
if len(options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.")
|
||||
return
|
||||
}
|
||||
|
||||
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Which project should get a new helper?",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{
|
||||
CustomID: "projects:add_helper_project_select",
|
||||
Placeholder: "Select a project",
|
||||
Options: options,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleClose responds with a select menu of the guild's active projects -
|
||||
// same reasoning as handleAddHelper, picking from a live list beats typing
|
||||
// an ID. The actual deactivation happens on selection, in HandleComponent.
|
||||
func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
options, err := activeProjectOptions(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if len(options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects to close.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Which project should be closed?",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{
|
||||
CustomID: "projects:close_select",
|
||||
Placeholder: "Select a project",
|
||||
Options: options,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// activeProjectOptions builds select-menu options for the guild's active
|
||||
// projects, truncating name/description to Discord's option limits
|
||||
// (label: 100 chars, description: 100 chars).
|
||||
func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) {
|
||||
const limit = 25 // Discord's own cap on select menu options
|
||||
items, err := services.Global.Projects.ListActive(ctx, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := make([]discordgo.SelectMenuOption, 0, len(items))
|
||||
for _, p := range items {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = "Unnamed project"
|
||||
}
|
||||
options = append(options, discordgo.SelectMenuOption{
|
||||
Label: truncate(name, 100),
|
||||
Value: strconv.FormatInt(p.ID, 10),
|
||||
Description: truncate(p.Description, 100),
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
@@ -12,3 +12,9 @@ var (
|
||||
|
||||
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HandleComponent(s, i) }
|
||||
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleModalSubmit(s, i)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func scheduleActionButtons(scheduleID int64) []discordgo.MessageComponent {
|
||||
|
||||
idStr := strconv.FormatInt(scheduleID, 10)
|
||||
|
||||
return []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Accept",
|
||||
Style: discordgo.SuccessButton,
|
||||
CustomID: "schedule:accept:" + idStr,
|
||||
},
|
||||
discordgo.Button{
|
||||
Label: "Decline",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: "schedule:decline:" + idStr,
|
||||
},
|
||||
discordgo.Button{
|
||||
Label: "Reschedule",
|
||||
Style: discordgo.SecondaryButton,
|
||||
CustomID: "schedule:reschedule:" + idStr,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rescheduleProposalButtons(scheduleID int64) []discordgo.MessageComponent {
|
||||
idStr := strconv.FormatInt(scheduleID, 10)
|
||||
|
||||
return []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Accept",
|
||||
Style: discordgo.SuccessButton,
|
||||
CustomID: "schedule:accept_reschedule:" + idStr,
|
||||
},
|
||||
discordgo.Button{
|
||||
Label: "Decline",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: "schedule:decline_reschedule:" + idStr,
|
||||
},
|
||||
discordgo.Button{
|
||||
Label: "Propose different time",
|
||||
Style: discordgo.SecondaryButton,
|
||||
CustomID: "schedule:reschedule:" + idStr,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createUserSelectCustomID = "schedule:create_user_select"
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.MessageComponentData().CustomID == createUserSelectCustomID {
|
||||
handleCreateUserSelect(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
action, scheduleID := parseComponentCustomID(i.MessageComponentData().CustomID)
|
||||
|
||||
userIDStr := interactionUserID(i)
|
||||
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error parsing user ID.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
|
||||
if err != nil || sch == nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error retrieving schedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var status schedulerepo.ScheduleStatus
|
||||
var message string
|
||||
|
||||
switch action {
|
||||
case "accept":
|
||||
if userID != sch.InviteeID {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "This schedule is no longer pending.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status = schedulerepo.StatusAccepted
|
||||
message = "✅ Accepted"
|
||||
case "decline":
|
||||
if userID != sch.InviteeID {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "This schedule is no longer pending.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
status = schedulerepo.StatusDeclined
|
||||
message = "❌ Declined"
|
||||
case "reschedule":
|
||||
if userID != sch.InviteeID && userID != sch.RequesterID {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "This schedule can't be rescheduled right now.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: "schedule:reschedule_modal:" + strconv.FormatInt(scheduleID, 10),
|
||||
Title: "Propose a new time",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "date",
|
||||
Label: "Date (YYYY-MM-DD)",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
Placeholder: "2026-01-02",
|
||||
},
|
||||
},
|
||||
},
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "time",
|
||||
Label: "Time (HH:MM, your timezone)",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
Placeholder: "15:04",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case "accept_reschedule":
|
||||
if sch.ProposedBy == nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "There is no reschedule proposal to accept.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if sch.Status != schedulerepo.StatusRescheduleRequested {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "There is no reschedule request to accept.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = services.Global.Schedule.AcceptReschedule(ctx, scheduleID)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error accepting reschedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "✅ Reschedule accepted",
|
||||
Components: []discordgo.MessageComponent{},
|
||||
},
|
||||
})
|
||||
|
||||
newTime := *sch.ProposedAt
|
||||
utcStr := newTime.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := newTime.In(loc).Format("2006-01-02 15:04")
|
||||
|
||||
content := "Your proposed time for the session was accepted: " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ")."
|
||||
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM proposer about accepted reschedule: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
case "decline_reschedule":
|
||||
if sch.ProposedBy == nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "There is no reschedule proposal to accept.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if sch.Status != schedulerepo.StatusRescheduleRequested {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "There is no reschedule request to accept.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = services.Global.Schedule.DeclineReschedule(ctx, scheduleID)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error declining reschedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "❌ Reschedule declined",
|
||||
Components: []discordgo.MessageComponent{},
|
||||
},
|
||||
})
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
|
||||
content := "Your proposed new time was declined. The session remains scheduled for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ")."
|
||||
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM proposer about declined reschedule: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
default:
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Unknown action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = services.Global.Schedule.UpdateStatus(ctx, scheduleID, status)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error updating schedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseUpdateMessage,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: message,
|
||||
Components: []discordgo.MessageComponent{},
|
||||
},
|
||||
})
|
||||
|
||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||
schedulerepo.StatusAccepted: "accepted ✅",
|
||||
schedulerepo.StatusDeclined: "declined ❌",
|
||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||
}[status]
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
// Notify requester with local time if configured.
|
||||
if services.Global.UserSettings != nil {
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
|
||||
|
||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
||||
dmCh, err := s.UserChannelCreate(requesterID)
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
values := i.MessageComponentData().Values
|
||||
if len(values) == 0 {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "No user selected.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
targetUserID := values[0]
|
||||
|
||||
if targetUserID == interactionUserID(i) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You cannot schedule a session with yourself.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: "schedule:create_modal:" + targetUserID,
|
||||
Title: "Schedule a session",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "date",
|
||||
Label: "Date (YYYY-MM-DD)",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
Placeholder: "2026-01-02",
|
||||
},
|
||||
},
|
||||
},
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "time",
|
||||
Label: "Time (HH:MM, your timezone)",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
Placeholder: "15:04",
|
||||
},
|
||||
},
|
||||
},
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "description",
|
||||
Label: "Description (optional)",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, c := range components {
|
||||
row, ok := c.(*discordgo.ActionsRow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, rc := range row.Components {
|
||||
ti, ok := rc.(*discordgo.TextInput)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[ti.CustomID] = ti.Value
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func handleCreateModalSubmit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, inviteeID int64) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, err := strconv.ParseInt(interactionUserID(i), 10, 64)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Invalid user ID.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if requesterID == inviteeID {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You cannot schedule a session with yourself.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
values := modalTextInputValues(i.ModalSubmitData().Components)
|
||||
dateStr := strings.TrimSpace(values["date"])
|
||||
timeStr := strings.TrimSpace(values["time"])
|
||||
desc := strings.TrimSpace(values["description"])
|
||||
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, requesterID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
|
||||
when, err := shared.ParseScheduleDatetime(dateStr+" "+timeStr, loc)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time, in your timezone (" + zone + ").",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Failed to create schedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(inviteeID, 10))
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Session created, but failed to DM the invited user (are DMs disabled?).",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "New session request",
|
||||
Description: fmt.Sprintf("You have been invited to a session by <@%d>.", requesterID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
utcStr := when.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(ctx, inviteeID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{Name: "When (UTC)", Value: utcStr},
|
||||
{Name: "When (" + zoneInv + ")", Value: localInvStr},
|
||||
}
|
||||
if desc != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "Respond to this session request:",
|
||||
Embed: embed,
|
||||
Components: scheduleActionButtons(scheduleID),
|
||||
})
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Session created, but failed to DM the invited user.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Session request created and sent to <@" + strconv.FormatInt(inviteeID, 10) + ">.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ctx := context.Background()
|
||||
action, idOrUserID := parseComponentCustomID(i.ModalSubmitData().CustomID)
|
||||
|
||||
if action == "create_modal" {
|
||||
handleCreateModalSubmit(ctx, s, i, idOrUserID)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleID := idOrUserID
|
||||
if scheduleID == 0 {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Invalid schedule ID.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modalValues := modalTextInputValues(i.ModalSubmitData().Components)
|
||||
datetimeInput := strings.TrimSpace(modalValues["date"]) + " " + strings.TrimSpace(modalValues["time"])
|
||||
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
|
||||
if err != nil || sch == nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error retrieving schedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr := interactionUserID(i)
|
||||
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error parsing user ID.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userID != sch.InviteeID && userID != sch.RequesterID {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You are not authorized to perform this action.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loc, _, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
}
|
||||
|
||||
newTime, err := shared.ParseScheduleDatetime(datetimeInput, loc)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
//Convert time to UTC before storing in the database
|
||||
newTime = newTime.UTC()
|
||||
|
||||
err = services.Global.Schedule.ProposeReschedule(ctx, scheduleID, newTime, userID)
|
||||
if err != nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error proposing reschedule.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
otherID := sch.RequesterID
|
||||
if userID == sch.RequesterID {
|
||||
otherID = sch.InviteeID
|
||||
}
|
||||
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Reschedule Proposal",
|
||||
Description: fmt.Sprintf("<@%d> has proposed a new time for your session request. Please respond.", userID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
|
||||
// Show both UTC and invitee-local time if available.
|
||||
utcStr := newTime.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), otherID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := newTime.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: utcStr,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "A new time has been proposed for your session:",
|
||||
Embed: embed,
|
||||
Components: rescheduleProposalButtons(scheduleID),
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the other participant.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
|
||||
|
||||
}
|
||||
|
||||
func parseComponentCustomID(customID string) (string, int64) {
|
||||
parts := strings.Split(customID, ":")
|
||||
if len(parts) != 3 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
action := parts[1]
|
||||
scheduleID, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
return action, scheduleID
|
||||
}
|
||||
|
||||
func interactionUserID(i *discordgo.InteractionCreate) string {
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
return i.Member.User.ID
|
||||
}
|
||||
if i.User != nil {
|
||||
return i.User.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -21,26 +22,6 @@ var Schedule = &discordgo.ApplicationCommand{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new session with a user",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to invite",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "When (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "What is this session about? (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
@@ -93,8 +74,7 @@ func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
if _, ok := shared.ParseGuildID(s, i); !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
@@ -102,133 +82,24 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var (
|
||||
targetUser *discordgo.User
|
||||
whenStr string
|
||||
desc string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "user":
|
||||
targetUser = o.UserValue(s)
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
case "description":
|
||||
desc = o.StringValue()
|
||||
}
|
||||
}
|
||||
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.ID == i.Member.User.ID {
|
||||
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid target user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve requester's timezone and parse input using explicit layout in that zone.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect layout "2006-01-02 15:04" in the user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
ctx := context.Background()
|
||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create schedule.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send DM to invitee.
|
||||
dmCh, err := s.UserChannelCreate(targetUser.ID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "New session request",
|
||||
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
utcStr := when.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: utcStr,
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Who do you want to schedule a session with?",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{
|
||||
MenuType: discordgo.UserSelectMenu,
|
||||
CustomID: createUserSelectCustomID,
|
||||
Placeholder: "Select a user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if desc != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add reactions for interaction.
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
|
||||
}
|
||||
|
||||
// Store linkage between message and schedule.
|
||||
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
@@ -338,6 +209,11 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
|
||||
shared.RespondEphemeral(s, i, "This session can't be rescheduled right now.")
|
||||
return
|
||||
}
|
||||
|
||||
// Use rescheduler's timezone for parsing.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -345,19 +221,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
when, err := shared.ParseScheduleDatetime(whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
shared.RespondEphemeral(s, i, err.Error()+" (use `2006-01-02 15:04` in your timezone: "+zone+")")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
||||
if err := services.Global.Schedule.ProposeReschedule(ctx, sch.ID, when, userID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to propose reschedule.")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify participants via DM and send new request DM to invitee.
|
||||
whenFmt := when.Format("2006-01-02 15:04")
|
||||
|
||||
otherID := sch.InviteeID
|
||||
@@ -365,75 +240,50 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
|
||||
// DM both about the change, including their local times if available.
|
||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
||||
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
|
||||
if err != nil {
|
||||
locU = time.UTC
|
||||
zoneU = "UTC"
|
||||
}
|
||||
localU := when.In(locU).Format("2006-01-02 15:04")
|
||||
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
|
||||
if sch.Description != "" {
|
||||
msg += "\nTopic: " + sch.Description
|
||||
}
|
||||
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = s.ChannelMessageSend(ch.ID, msg)
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
// Send new "request" DM to invitee with reactions again.
|
||||
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
|
||||
inviteeUser, err := s.User(inviteeStr)
|
||||
if err == nil && inviteeUser != nil {
|
||||
dmCh, err := s.UserChannelCreate(inviteeStr)
|
||||
if err == nil {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Rescheduled session",
|
||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
||||
Color: 0xffc107,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if errTZ != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err == nil && msgObj != nil {
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
|
||||
}
|
||||
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
|
||||
}
|
||||
}
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Reschedule proposed",
|
||||
Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
// Show both UTC and the other participant's local time if available.
|
||||
locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
|
||||
if errTZ != nil {
|
||||
locOther = time.UTC
|
||||
zoneOther = "UTC"
|
||||
}
|
||||
localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneOther + ")",
|
||||
Value: localOtherStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
|
||||
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "A new time has been proposed for your session:",
|
||||
Embed: embed,
|
||||
Components: rescheduleProposalButtons(sch.ID),
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,13 @@ var (
|
||||
Schedule *discordgo.ApplicationCommand = public.Schedule
|
||||
)
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.ScheduleHandler(s, i)
|
||||
}
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleComponent(s, i)
|
||||
}
|
||||
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleModalSubmit(s, i)
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var scheduleDatetimeLayouts = []string{
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"01/02/2006 15:04",
|
||||
}
|
||||
|
||||
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
@@ -18,6 +27,15 @@ func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
|
||||
})
|
||||
}
|
||||
|
||||
func ParseScheduleDatetime(input string, loc *time.Location) (time.Time, error) {
|
||||
for _, layout := range scheduleDatetimeLayouts {
|
||||
if t, err := time.ParseInLocation(layout, input, loc); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid schedule datetime format")
|
||||
}
|
||||
|
||||
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if i.GuildID == "" {
|
||||
RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||
@@ -42,4 +60,3 @@ func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64,
|
||||
}
|
||||
return guildID, true
|
||||
}
|
||||
|
||||
@@ -8,19 +8,18 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
TwitchClientID string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Load .env if present (e.g. local dev). Ignore if missing (e.g. Docker injects env vars).
|
||||
_ = godotenv.Load()
|
||||
|
||||
token := os.Getenv("BOT_TOKEN")
|
||||
if token == "" {
|
||||
@@ -33,9 +32,6 @@ 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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package musicrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QueueEntry struct {
|
||||
Title string
|
||||
URL string
|
||||
RequestedBy int64
|
||||
|
||||
// ClientID is the track's stable identity (set once by the manager
|
||||
// when a track is first queued), used to upsert this row across
|
||||
// syncs instead of assigning it a fresh DB id every single time.
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int, repeatSong, repeatQueue bool) error {
|
||||
const q = `
|
||||
INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume, repeat_song, repeat_queue)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET
|
||||
track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5,
|
||||
paused = $6, volume = $7, repeat_song = $8, repeat_queue = $9
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume, repeatSong, repeatQueue)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
|
||||
const q = `DELETE FROM music_now_playing WHERE guild_id = $1`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceQueue syncs music_queue with the current in-memory queue by
|
||||
// upserting each entry on its stable ClientID, rather than deleting and
|
||||
// recreating every row on every call. That matters because music_queue.id
|
||||
// is only an internal DB identity, remove/reorder commands target a
|
||||
// track by ClientID, and that has to survive a track just sitting in the
|
||||
// queue unchanged across syncs.
|
||||
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
existing := make(map[string]bool)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var clientID string
|
||||
if err := rows.Scan(&clientID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing[clientID] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
keep := make(map[string]bool, len(entries))
|
||||
for i, entry := range entries {
|
||||
keep[entry.ClientID] = true
|
||||
const q = `
|
||||
INSERT INTO music_queue (guild_id, position, track_title, track_url, requested_by, client_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (guild_id, client_id) DO UPDATE SET
|
||||
position = EXCLUDED.position,
|
||||
track_title = EXCLUDED.track_title,
|
||||
track_url = EXCLUDED.track_url,
|
||||
requested_by = EXCLUDED.requested_by
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, q, guildID, i, entry.Title, entry.URL, entry.RequestedBy, entry.ClientID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for clientID := range existing {
|
||||
if keep[clientID] {
|
||||
continue
|
||||
}
|
||||
const q = `DELETE FROM music_queue WHERE guild_id = $1 AND client_id = $2`
|
||||
if _, err := tx.ExecContext(ctx, q, guildID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type MusicCommand struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
CommandType string
|
||||
Payload []byte
|
||||
RequestedBy int64
|
||||
}
|
||||
|
||||
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]MusicCommand, error) {
|
||||
const q = `SELECT id, guild_id, command_type, payload, requested_by FROM music_commands WHERE processed_at IS NULL ORDER BY id ASC`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []MusicCommand
|
||||
for rows.Next() {
|
||||
var cmd MusicCommand
|
||||
if err := rows.Scan(&cmd.ID, &cmd.GuildID, &cmd.CommandType, &cmd.Payload, &cmd.RequestedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||
const q = `UPDATE music_commands SET processed_at = NOW() WHERE id = $1`
|
||||
_, err := r.db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
@@ -61,6 +61,35 @@ func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
|
||||
const q = `
|
||||
UPDATE projects
|
||||
SET is_active = FALSE
|
||||
WHERE id = $1 AND guild_id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, projectID, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetProject fetches a single project, scoped to guildID so a project ID
|
||||
// from one server can't be used to reach into another's data.
|
||||
func (r *Repo) GetProject(ctx context.Context, guildID, projectID int64) (*Project, error) {
|
||||
const q = `
|
||||
SELECT id, name, description, created_by
|
||||
FROM projects
|
||||
WHERE id = $1 AND guild_id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, projectID, guildID)
|
||||
p := &Project{GuildID: guildID, IsActive: true}
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||
const q = `
|
||||
SELECT 1
|
||||
@@ -79,6 +108,37 @@ func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int6
|
||||
}
|
||||
}
|
||||
|
||||
// ListActiveProjects is a lighter version of ListActiveProjectsWithMembers,
|
||||
// without the member-join query - for callers (like building a select menu
|
||||
// of projects to pick from) that only need name/id, not creator/helpers.
|
||||
func (r *Repo) ListActiveProjects(ctx context.Context, guildID int64, limit int) ([]*Project, error) {
|
||||
const q = `
|
||||
SELECT id, name, description, created_by
|
||||
FROM projects
|
||||
WHERE guild_id = $1 AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Project
|
||||
for rows.Next() {
|
||||
p := &Project{GuildID: guildID, IsActive: true}
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
|
||||
const qProjects = `
|
||||
SELECT id, name, description, created_by
|
||||
|
||||
@@ -20,15 +20,18 @@ const (
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
RequesterID int64
|
||||
InviteeID int64
|
||||
ScheduledAt time.Time
|
||||
Status ScheduleStatus
|
||||
Description string
|
||||
ReminderSent bool
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
GuildID int64
|
||||
RequesterID int64
|
||||
InviteeID int64
|
||||
ScheduledAt time.Time
|
||||
ProposedAt *time.Time
|
||||
ProposedBy *int64
|
||||
PreRescheduleStatus *ScheduleStatus
|
||||
Status ScheduleStatus
|
||||
Description string
|
||||
ReminderSent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
@@ -48,35 +51,6 @@ func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, invitee
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
const q = `
|
||||
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (schedule_id, message_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
|
||||
FROM schedules s
|
||||
JOIN schedule_messages m ON m.schedule_id = s.id
|
||||
WHERE m.message_id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, messageID)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
@@ -119,13 +93,13 @@ func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int
|
||||
|
||||
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, proposed_at, proposed_by, pre_reschedule_status, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -186,4 +160,43 @@ func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET proposed_at = $1,
|
||||
proposed_by = $2,
|
||||
pre_reschedule_status = status,
|
||||
status = 'reschedule_requested'
|
||||
WHERE id = $3
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, newTime, proposedBy, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) AcceptReschedule(ctx context.Context, scheduleID int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET scheduled_at = proposed_at,
|
||||
proposed_at = NULL,
|
||||
proposed_by = NULL,
|
||||
pre_reschedule_status = NULL,
|
||||
status = 'accepted',
|
||||
reminder_sent = FALSE
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) DeclineReschedule(ctx context.Context, scheduleID int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET proposed_at = NULL,
|
||||
proposed_by = NULL,
|
||||
status = pre_reschedule_status,
|
||||
pre_reschedule_status = NULL
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||
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
|
||||
@@ -260,3 +340,22 @@ func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, er
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
const q = `SELECT is_enabled FROM music_settings WHERE guild_id = $1`
|
||||
var enabled bool
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
const q = `INSERT INTO music_settings (guild_id, is_enabled) VALUES ($1, $2) ON CONFLICT (guild_id) DO UPDATE SET is_enabled = $2`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||
return err
|
||||
}
|
||||
@@ -113,3 +113,31 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
|
||||
return err
|
||||
}
|
||||
|
||||
// IsEnabled reports whether Twitch notifications are enabled for guildID.
|
||||
// A guild with no twitch_config row yet defaults to enabled, matching the
|
||||
// column's own DEFAULT TRUE - "never configured" and "explicitly on" are
|
||||
// the same state until someone actually flips it off.
|
||||
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
|
||||
var enabled bool
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
const q = `
|
||||
INSERT INTO twitch_config (guild_id, enabled)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET enabled = EXCLUDED.enabled
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package musicsettings
|
||||
|
||||
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) ToggleMusic(ctx context.Context, guildID int64) (bool, error) {
|
||||
enabled, err := s.settings.IsMusicEnabled(ctx, guildID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
newEnabled := !enabled
|
||||
if err := s.settings.SetMusicEnabled(ctx, guildID, newEnabled); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return newEnabled, nil
|
||||
}
|
||||
|
||||
func (s *Service) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
return s.settings.IsMusicEnabled(ctx, guildID)
|
||||
}
|
||||
@@ -26,7 +26,19 @@ func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limi
|
||||
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ListActive(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.Project, error) {
|
||||
return s.repo.ListActiveProjects(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
|
||||
}
|
||||
|
||||
func (s *Service) GetProject(ctx context.Context, guildID, projectID int64) (*projectsrepo.Project, error) {
|
||||
return s.repo.GetProject(ctx, guildID, projectID)
|
||||
}
|
||||
|
||||
func (s *Service) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
|
||||
return s.repo.DeactivateProject(ctx, guildID, projectID)
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, invi
|
||||
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
|
||||
}
|
||||
|
||||
func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
|
||||
}
|
||||
|
||||
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByMessageID(ctx, messageID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||
}
|
||||
@@ -51,3 +43,14 @@ func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) erro
|
||||
return s.repo.Reschedule(ctx, id, when)
|
||||
}
|
||||
|
||||
func (s *Service) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
|
||||
return s.repo.ProposeReschedule(ctx, scheduleID, newTime, proposedBy)
|
||||
}
|
||||
|
||||
func (s *Service) AcceptReschedule(ctx context.Context, id int64) error {
|
||||
return s.repo.AcceptReschedule(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeclineReschedule(ctx context.Context, id int64) error {
|
||||
return s.repo.DeclineReschedule(ctx, id)
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
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/musicsettings"
|
||||
"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 {
|
||||
@@ -20,11 +24,15 @@ type Services struct {
|
||||
Projects *projects.Service
|
||||
RPS *rps.Service
|
||||
Twitch *twitch.Service
|
||||
Welcome *welcome.Service
|
||||
DefaultRole *defaultrole.Service
|
||||
LogSettings *logsettings.Service
|
||||
MusicSettings *musicsettings.Service
|
||||
}
|
||||
|
||||
var Global *Services
|
||||
|
||||
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service) *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, musicSvc *musicsettings.Service) *Services {
|
||||
s := &Services{
|
||||
Level: level,
|
||||
LevelSettings: levelSettings,
|
||||
@@ -34,6 +42,10 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
||||
Projects: projects,
|
||||
RPS: rps,
|
||||
Twitch: twitchSvc,
|
||||
Welcome: welcomeSvc,
|
||||
DefaultRole: defaultRoleSvc,
|
||||
LogSettings: logSettingsSvc,
|
||||
MusicSettings: musicSvc,
|
||||
}
|
||||
Global = s
|
||||
return s
|
||||
|
||||
@@ -59,6 +59,14 @@ func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID
|
||||
return s.repo.SetNotificationChannel(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
return s.repo.IsEnabled(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
return s.repo.SetEnabled(ctx, guildID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) {
|
||||
type gqlRequest struct {
|
||||
Query string `json:"query"`
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
|
||||
// ✅ accept, ❌ decline, 🔁 request reschedule.
|
||||
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil {
|
||||
return
|
||||
}
|
||||
if r == nil || r.UserID == "" || r.MessageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore bot reactions.
|
||||
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
|
||||
return
|
||||
}
|
||||
|
||||
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
|
||||
if err != nil || msgID64 == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
|
||||
if err != nil || sch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only invitee can react to change status.
|
||||
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending can be transitioned by reaction.
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
var newStatus schedulerepo.ScheduleStatus
|
||||
switch r.Emoji.Name {
|
||||
case "✅":
|
||||
newStatus = schedulerepo.StatusAccepted
|
||||
case "❌":
|
||||
newStatus = schedulerepo.StatusDeclined
|
||||
case "🔁":
|
||||
newStatus = schedulerepo.StatusRescheduleRequested
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
|
||||
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||
schedulerepo.StatusAccepted: "accepted ✅",
|
||||
schedulerepo.StatusDeclined: "declined ❌",
|
||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||
}[newStatus]
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
// Notify requester with local time if configured.
|
||||
if svc.UserSettings != nil {
|
||||
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
|
||||
|
||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
||||
dmCh, err := s.UserChannelCreate(requesterID)
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) {
|
||||
for _, guildID := range guildIDs {
|
||||
log.Printf("twitch: processing guild %d", guildID)
|
||||
|
||||
enabled, err := svc.Twitch.IsEnabled(ctx, guildID)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to check enabled state for guild %d: %v", guildID, err)
|
||||
continue
|
||||
}
|
||||
if !enabled {
|
||||
log.Printf("twitch: notifications disabled for guild %d", guildID)
|
||||
continue
|
||||
}
|
||||
|
||||
channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to get channel for guild %d: %v", 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
|
||||
}
|
||||
|
||||
@@ -97,7 +97,10 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
content = "Done."
|
||||
}
|
||||
if err != nil {
|
||||
content = "Error: " + err.Error()
|
||||
// err can carry a raw Lavalink/network failure here (Player.Update),
|
||||
// not just our own "music manager not initialized" - don't echo
|
||||
// that detail into the ephemeral reply.
|
||||
content = "That didn't work. Please try again."
|
||||
}
|
||||
|
||||
// send ephemeral confirmation
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func StartCommandListener(ctx context.Context, connString string, repo *musicrepo.Repo) error {
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
_, err = conn.Exec(ctx, "LISTEN music_commands")
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, err := conn.WaitForNotification(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("waitfornotification: %w", err)
|
||||
}
|
||||
|
||||
processCommands(ctx, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func processCommands(ctx context.Context, repo *musicrepo.Repo) {
|
||||
cmds, err := repo.ListUnprocessedCommands(ctx)
|
||||
if err != nil {
|
||||
log.Printf("music: failed to list unprocessed commands: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, cmd := range cmds {
|
||||
if err := dispatchCommand(cmd); err != nil {
|
||||
log.Printf("music: command %d (%s) for guild %d failed: %v", cmd.ID, cmd.CommandType, cmd.GuildID, err)
|
||||
}
|
||||
|
||||
if err := repo.MarkCommandProcessed(ctx, cmd.ID); err != nil {
|
||||
log.Printf("music: failed to mark command %d processed: %v", cmd.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchCommand(cmd musicrepo.MusicCommand) error {
|
||||
guildID := strconv.FormatInt(cmd.GuildID, 10)
|
||||
|
||||
switch cmd.CommandType {
|
||||
case "skip":
|
||||
return Skip(guildID)
|
||||
|
||||
case "pause":
|
||||
return Pause(guildID, true)
|
||||
|
||||
case "resume":
|
||||
return Pause(guildID, false)
|
||||
|
||||
case "stop":
|
||||
return Stop(guildID)
|
||||
|
||||
case "set_volume":
|
||||
var payload struct {
|
||||
Volume int `json:"volume"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
return SetVolume(guildID, payload.Volume)
|
||||
|
||||
case "remove_track":
|
||||
var payload struct {
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
return RemoveFromQueue(guildID, payload.QueueID)
|
||||
|
||||
case "reorder":
|
||||
var payload struct {
|
||||
QueueID string `json:"queue_id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
return Reorder(guildID, payload.QueueID, payload.Position)
|
||||
|
||||
case "repeat_song":
|
||||
ToggleRepeatSong(guildID)
|
||||
return nil
|
||||
|
||||
case "repeat_queue":
|
||||
ToggleRepeatQueue(guildID)
|
||||
return nil
|
||||
|
||||
case "add_song":
|
||||
var payload struct {
|
||||
Query string `json:"query"`
|
||||
VoiceChannelID string `json:"voice_channel_id"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
requesterID := strconv.FormatInt(cmd.RequestedBy, 10)
|
||||
|
||||
// A voice channel means "start fresh" (nothing was playing, the
|
||||
// dashboard had the user pick one), otherwise this just adds to
|
||||
// whatever's already going.
|
||||
if payload.VoiceChannelID != "" {
|
||||
// Voice channels have their own built-in text chat at the same
|
||||
// channel ID, post the now-playing embed there so starting
|
||||
// playback from the dashboard still shows up somewhere in
|
||||
// Discord, same as starting it with /play would.
|
||||
_, _, err := EnqueueAndPlay(guildID, payload.VoiceChannelID, payload.VoiceChannelID, payload.Query, "Dashboard", requesterID)
|
||||
return err
|
||||
}
|
||||
_, err := AddToQueue(guildID, payload.Query, requesterID)
|
||||
return err
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown command type %q", cmd.CommandType)
|
||||
}
|
||||
}
|
||||
+323
-41
@@ -3,36 +3,52 @@ package music
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TrackEntry struct {
|
||||
Track lavalink.Track
|
||||
RequestedBy string
|
||||
RequesterID string
|
||||
StartedAt time.Time
|
||||
|
||||
// QueueID is generated once, when the track is first added to the
|
||||
// queue, and never changes for the rest of its life there - it's
|
||||
// what lets syncState upsert this track's row across syncs instead
|
||||
// of deleting and recreating it (and its DB id) every single time,
|
||||
// which is what remove/reorder commands need to reliably target a
|
||||
// specific track.
|
||||
QueueID string
|
||||
}
|
||||
|
||||
type guildPlayer struct {
|
||||
Player disgolink.Player
|
||||
Queue []TrackEntry
|
||||
RepeatSong bool
|
||||
RepeatQueue bool
|
||||
Paused bool
|
||||
Volume int
|
||||
IdleSince time.Time
|
||||
TextChannelID string
|
||||
NowPlayingMsgID string
|
||||
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
|
||||
repo *musicrepo.Repo
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]*guildPlayer
|
||||
@@ -40,7 +56,7 @@ type Manager struct {
|
||||
|
||||
var manager *Manager
|
||||
|
||||
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
|
||||
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
|
||||
if lavalinkHost == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -53,6 +69,7 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
|
||||
m := &Manager{
|
||||
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
||||
session: session,
|
||||
repo: repo,
|
||||
players: make(map[string]*guildPlayer),
|
||||
}
|
||||
|
||||
@@ -67,18 +84,26 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
|
||||
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)
|
||||
}
|
||||
// Connecting to the Lavalink node is a blocking network call (and, on
|
||||
// failure, disgolink retries indefinitely with backoff) — do it in the
|
||||
// background so bot startup isn't held hostage by it. Music commands
|
||||
// simply fail with "not initialized" via the manager==nil checks until
|
||||
// this completes.
|
||||
go func() {
|
||||
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||
Name: "main",
|
||||
Address: u.Host,
|
||||
Password: password,
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("music: failed to connect to lavalink node: %v", err)
|
||||
return
|
||||
}
|
||||
manager = m
|
||||
go idleDisconnectLoop()
|
||||
}()
|
||||
|
||||
manager = m
|
||||
go idleDisconnectLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -183,23 +208,126 @@ func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
||||
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")
|
||||
func syncState(guidID string) {
|
||||
if manager == nil || manager.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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.Lock()
|
||||
gp, ok := manager.players[guidID]
|
||||
if !ok {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
queue := make([]TrackEntry, len(gp.Queue))
|
||||
copy(queue, gp.Queue)
|
||||
paused := gp.Paused
|
||||
volume := gp.Volume
|
||||
repeatSong := gp.RepeatSong
|
||||
repeatQueue := gp.RepeatQueue
|
||||
manager.mu.Unlock()
|
||||
|
||||
gID, err := strconv.ParseInt(guidID, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(queue) == 0 {
|
||||
err := manager.repo.ClearNowPlaying(context.Background(), gID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = manager.repo.ReplaceQueue(context.Background(), gID, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
current := queue[0]
|
||||
title := current.Track.Info.Title
|
||||
|
||||
var uri string
|
||||
if current.Track.Info.URI != nil {
|
||||
uri = *current.Track.Info.URI
|
||||
}
|
||||
|
||||
length := int(current.Track.Info.Length / 1000)
|
||||
startedAt := current.StartedAt
|
||||
|
||||
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rest := queue[1:]
|
||||
var queueEntries []musicrepo.QueueEntry
|
||||
for _, entry := range rest {
|
||||
reqID, _ := strconv.ParseInt(entry.RequesterID, 10, 64)
|
||||
var uri string
|
||||
if entry.Track.Info.URI != nil {
|
||||
uri = *entry.Track.Info.URI
|
||||
}
|
||||
queueEntries = append(queueEntries, musicrepo.QueueEntry{
|
||||
Title: entry.Track.Info.Title,
|
||||
URL: uri,
|
||||
RequestedBy: reqID,
|
||||
ClientID: entry.QueueID,
|
||||
})
|
||||
}
|
||||
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// NormalizeQuery turns plain text into a YouTube search and strips
|
||||
// auto-generated "radio/mix" params off a pasted YouTube URL
|
||||
// (https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 becomes
|
||||
// .../watch?v=ID), while leaving normal playlist URLs and non-YouTube
|
||||
// links (SoundCloud, etc.) untouched. Every entry point that accepts a
|
||||
// user-supplied query - /play and the dashboard's "add song" - should
|
||||
// normalize through this before handing the query to Lavalink.
|
||||
func NormalizeQuery(query string) string {
|
||||
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
|
||||
return "ytsearch:" + query
|
||||
}
|
||||
|
||||
u, err := url.Parse(query)
|
||||
if err != nil || u == nil {
|
||||
return query
|
||||
}
|
||||
|
||||
host := strings.ToLower(u.Host)
|
||||
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||
return query
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
v := q.Get("v")
|
||||
if v == "" {
|
||||
return query
|
||||
}
|
||||
|
||||
list := q.Get("list")
|
||||
_, hasStartRadio := q["start_radio"]
|
||||
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
|
||||
return query
|
||||
}
|
||||
|
||||
u.RawQuery = url.Values{"v": []string{v}}.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// resolveTracks searches Lavalink for query (already normalized) and
|
||||
// returns a playable track list - a single track, a search result's
|
||||
// first hit, or a whole playlist (rotated to respect Lavalink's own
|
||||
// selectedTrack when present).
|
||||
func resolveTracks(query string) ([]lavalink.Track, error) {
|
||||
var loadedTracks []lavalink.Track
|
||||
|
||||
manager.client.BestNode().LoadTracksHandler(
|
||||
@@ -237,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
|
||||
)
|
||||
|
||||
if len(loadedTracks) == 0 {
|
||||
return nil, false, fmt.Errorf("no tracks found for query")
|
||||
return nil, fmt.Errorf("no tracks found for query")
|
||||
}
|
||||
|
||||
// Some sources may return unplayable entries; pick the first with an encoded track.
|
||||
@@ -252,11 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
|
||||
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
|
||||
}
|
||||
|
||||
return loadedTracks, nil
|
||||
}
|
||||
|
||||
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID 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()
|
||||
}
|
||||
|
||||
loadedTracks, err := resolveTracks(NormalizeQuery(query))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
entries := make([]TrackEntry, len(loadedTracks))
|
||||
for idx, t := range loadedTracks {
|
||||
entries[idx] = TrackEntry{
|
||||
Track: t,
|
||||
RequestedBy: requester,
|
||||
RequesterID: requesterID,
|
||||
QueueID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
firstEntry := entries[0]
|
||||
@@ -268,6 +423,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
|
||||
manager.mu.Unlock()
|
||||
|
||||
if shouldStart {
|
||||
gp.Queue[0].StartedAt = time.Now()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
|
||||
return nil, false, fmt.Errorf("start track: %w", err)
|
||||
}
|
||||
@@ -276,9 +433,52 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
|
||||
}
|
||||
}
|
||||
|
||||
syncState(guildID)
|
||||
|
||||
return &firstEntry, shouldStart, nil
|
||||
}
|
||||
|
||||
// AddToQueue adds a track to a guild's existing queue without joining a
|
||||
// voice channel, unlike EnqueueAndPlay it can't start a brand new
|
||||
// session (that needs a voice channel to join, which this has no way to
|
||||
// pick), so it requires one to already be active.
|
||||
func AddToQueue(guildID, query, requesterID string) (*TrackEntry, error) {
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
alreadyActive := len(gp.Queue) > 0
|
||||
manager.mu.Unlock()
|
||||
if !alreadyActive {
|
||||
return nil, fmt.Errorf("nothing is currently playing in this server")
|
||||
}
|
||||
|
||||
loadedTracks, err := resolveTracks(NormalizeQuery(query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]TrackEntry, len(loadedTracks))
|
||||
for idx, t := range loadedTracks {
|
||||
entries[idx] = TrackEntry{
|
||||
Track: t,
|
||||
RequestedBy: "Dashboard",
|
||||
RequesterID: requesterID,
|
||||
QueueID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
gp.Queue = append(gp.Queue, entries...)
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
|
||||
return &entries[0], nil
|
||||
}
|
||||
|
||||
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
if manager == nil {
|
||||
return
|
||||
@@ -298,6 +498,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
|
||||
// repeat current track
|
||||
if gp.RepeatSong {
|
||||
gp.Queue[0].StartedAt = time.Now()
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
@@ -308,12 +509,14 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
syncState(guildID)
|
||||
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)
|
||||
}
|
||||
@@ -328,9 +531,11 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
syncState(guildID)
|
||||
return
|
||||
}
|
||||
|
||||
gp.Queue[0].StartedAt = time.Now()
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
@@ -341,6 +546,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
syncState(guildID)
|
||||
}
|
||||
|
||||
func Pause(guildID string, pause bool) error {
|
||||
@@ -354,6 +560,7 @@ func Pause(guildID string, pause bool) error {
|
||||
gp.IdleSince = time.Time{}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
syncState(guildID)
|
||||
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
||||
}
|
||||
|
||||
@@ -384,9 +591,11 @@ func Skip(guildID string) error {
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
syncState(guildID)
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
|
||||
gp.Queue[0].StartedAt = time.Now()
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
@@ -397,6 +606,7 @@ func Skip(guildID string) error {
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
syncState(guildID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -418,6 +628,7 @@ func Stop(guildID string) error {
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
syncState(guildID)
|
||||
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
@@ -447,12 +658,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) {
|
||||
}
|
||||
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
|
||||
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return repeatSong, repeatQueue
|
||||
}
|
||||
|
||||
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||
@@ -461,12 +675,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||
}
|
||||
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
|
||||
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return repeatSong, repeatQueue
|
||||
}
|
||||
|
||||
func repeatFlags(guildID string) (bool, bool) {
|
||||
@@ -522,6 +739,73 @@ func SetVolume(guildID string, volume int) error {
|
||||
postNowPlaying(guildID, textChannelID, *current)
|
||||
}
|
||||
}
|
||||
syncState(guildID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveFromQueue(guildID, queueID string) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
idx := -1
|
||||
for i, q := range gp.Queue {
|
||||
if q.QueueID == queueID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
manager.mu.Unlock()
|
||||
return fmt.Errorf("queue not found")
|
||||
}
|
||||
|
||||
if idx == 0 {
|
||||
manager.mu.Unlock()
|
||||
return Skip(guildID)
|
||||
}
|
||||
|
||||
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Reorder(guildID, queueID string, newPosition int) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
idx := -1
|
||||
for i, q := range gp.Queue {
|
||||
if q.QueueID == queueID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
manager.mu.Unlock()
|
||||
return fmt.Errorf("queue not found")
|
||||
}
|
||||
|
||||
if idx == 0 {
|
||||
manager.mu.Unlock()
|
||||
return Skip(guildID)
|
||||
}
|
||||
|
||||
entry := gp.Queue[idx]
|
||||
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
|
||||
|
||||
pos := max(1, min(newPosition, len(gp.Queue)))
|
||||
gp.Queue = append(gp.Queue[:pos], append([]TrackEntry{entry}, gp.Queue[pos:]...)...)
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -534,5 +818,3 @@ func CurrentNowPlayingMessageID(guildID string) string {
|
||||
defer manager.mu.Unlock()
|
||||
return gp.NowPlayingMsgID
|
||||
}
|
||||
|
||||
|
||||
@@ -10,22 +10,29 @@ import (
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/config"
|
||||
"velox-bot/internal/db"
|
||||
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||
"velox-bot/internal/db/repos/levelrepo"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
"velox-bot/internal/db/repos/projectsrepo"
|
||||
"velox-bot/internal/db/repos/rpsrepo"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/repos/settingsrepo"
|
||||
"velox-bot/internal/db/repos/twitchrepo"
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
"velox-bot/internal/db/repos/welcomerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
"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/musicsettings"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -44,22 +51,29 @@ func main() {
|
||||
|
||||
levelRepo := levelrepo.NewRepo(db)
|
||||
settingsRepo := settingsrepo.NewRepo(db)
|
||||
musicRepo := musicrepo.NewRepo(db)
|
||||
rpsRepo := rpsrepo.NewRepo(db)
|
||||
projectsRepo := projectsrepo.NewRepo(db)
|
||||
scheduleRepo := schedulerepo.NewRepo(db)
|
||||
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||
welcomeRepo := welcomerepo.NewRepo(db)
|
||||
twitchRepo := twitchrepo.NewRepo(db)
|
||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||
levelService := level.New(levelRepo, settingsRepo)
|
||||
levelSettingsService := levelsettings.New(settingsRepo)
|
||||
logSettingsService := logsettings.New(settingsRepo)
|
||||
meetingService := meeting.New(settingsRepo)
|
||||
scheduleService := schedule.New(scheduleRepo)
|
||||
userSettingsService := usersettings.New(userSettingsRepo)
|
||||
projectsService := projects.New(projectsRepo)
|
||||
rpsService := rps.New(rpsRepo)
|
||||
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService)
|
||||
welcomeService := welcome.New(welcomeRepo)
|
||||
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
||||
musicSettingsService := musicsettings.New(settingsRepo)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService)
|
||||
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating bot: %v", err)
|
||||
return
|
||||
|
||||
+53
-4
@@ -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
|
||||
);
|
||||
|
||||
@@ -47,7 +45,8 @@ CREATE TABLE IF NOT EXISTS levelup (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS twitch_config (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
twitch_channel_id BIGINT
|
||||
twitch_channel_id BIGINT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rps (
|
||||
@@ -112,7 +111,14 @@ CREATE TABLE IF NOT EXISTS schedules (
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
||||
description TEXT,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Reschedule negotiation: a proposed new time awaiting the counterpart's
|
||||
-- response, kept separate from scheduled_at until accepted.
|
||||
proposed_at TIMESTAMPTZ,
|
||||
proposed_by BIGINT,
|
||||
-- Snapshot of status before entering reschedule_requested, so a decline
|
||||
-- knows whether to revert to 'pending' or 'accepted'.
|
||||
pre_reschedule_status TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_messages (
|
||||
@@ -127,3 +133,46 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
timezone TEXT NOT NULL
|
||||
);
|
||||
|
||||
--
|
||||
-- Music (dashboard queue view/control - see velox-dashboard-api's
|
||||
-- 000010_music migration, this file mirrors it)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS music_settings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_now_playing (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
track_title TEXT NOT NULL,
|
||||
track_url TEXT NOT NULL,
|
||||
duration_seconds INT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
volume INT NOT NULL DEFAULT 100,
|
||||
repeat_song BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
repeat_queue BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
track_title TEXT NOT NULL,
|
||||
track_url TEXT NOT NULL,
|
||||
requested_by BIGINT NOT NULL,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
client_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
UNIQUE (guild_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_commands (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue', 'add_song', 'stop')),
|
||||
payload JSONB,
|
||||
requested_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
Reference in new issue
Block a user