Compare commits
9
Commits
539c0a69af
...
ft/tickets
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d294b3463b | ||
|
|
b1ea5f17e3 | ||
|
|
23d417ef27 | ||
|
|
52dfe859ee | ||
|
|
da66eb8dd5 | ||
|
|
fcc7c9c66a | ||
|
|
7a17c55774 | ||
|
|
39962457b3 | ||
|
|
e507225cae |
No files matched your search
@@ -1,3 +1,10 @@
|
||||
*.env
|
||||
.cursor
|
||||
tmp/
|
||||
|
||||
# Local /tldr benchmark: chat dumps and results never get committed.
|
||||
bench-out/
|
||||
internal/tldr/tldrbench/private/
|
||||
|
||||
# Real chat dumps used by the benchmark never get committed.
|
||||
internal/tldr/tldrbench/testdata/private/
|
||||
@@ -9,6 +9,7 @@ It currently includes:
|
||||
- Meeting rooms (voice lobby → auto-created temporary voice channels)
|
||||
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
|
||||
- Projects (list/create projects + helpers)
|
||||
- Support tickets (a panel with categories, a private thread per ticket, claim/close, transcripts to a log channel)
|
||||
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
|
||||
- Twitch live notifications (per-guild `/config` setup)
|
||||
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
|
||||
@@ -98,6 +99,12 @@ just down # stop postgres
|
||||
- `/schedule create`, `/schedule list`, `/schedule reschedule`
|
||||
- **Projects**
|
||||
- `/projects list`, `/projects create`, `/projects add-helper`
|
||||
- **Tickets**
|
||||
- `/tickets setup channel:#tickets [log-channel] [staff-role]` — Where tickets open and closed ones are logged (**Manage Server**)
|
||||
- `/tickets category add|remove|list` — One panel button per category, each with its own staff role (**Manage Server**)
|
||||
- `/tickets panel` — Post or refresh the panel (**Manage Server**)
|
||||
- `/ticket add`, `/ticket close [reason]` — Opener or staff; `/ticket claim`, `/ticket remove` — staff
|
||||
- The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
|
||||
- **Music** (requires **DJ** role)
|
||||
- `/play`, `/queue`, `/volume`
|
||||
- **Moderation** (focused + practical)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+30
-1
@@ -1,12 +1,15 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
"velox-bot/internal/music"
|
||||
"velox-bot/internal/tickets"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -19,11 +22,12 @@ type Bot struct {
|
||||
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, musicRepo *musicrepo.Repo) (*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
|
||||
@@ -49,6 +53,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
MusicRepo: musicRepo,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
@@ -61,6 +66,24 @@ func (b *Bot) Start() error {
|
||||
|
||||
_ = 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)
|
||||
}
|
||||
}()
|
||||
|
||||
if st := tickets.Default(); st != nil {
|
||||
go func() {
|
||||
// The connection can drop (database restart, network blip):
|
||||
// reconnect instead of silently ignoring the dashboard from then on.
|
||||
for {
|
||||
err := tickets.StartCommandListener(context.Background(), b.DBHost, b.Session, st)
|
||||
log.Printf("tickets: command listener stopped: %v (retrying in 10s)", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||
for _, cmd := range b.Commands {
|
||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||
@@ -113,6 +136,12 @@ func (b *Bot) Start() error {
|
||||
events.HandleGuildBanRemoveLog(s, m, b.Services)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, t *discordgo.ThreadDelete) {
|
||||
if st := tickets.Default(); st != nil && t.Channel != nil {
|
||||
tickets.ThreadDeleted(context.Background(), st, t.ID)
|
||||
}
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||
music.OnVoiceStateUpdate(vs)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
cmdmusic "velox-bot/internal/commands/music"
|
||||
"velox-bot/internal/commands/projects"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
cmdtickets "velox-bot/internal/commands/tickets"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
@@ -40,6 +41,8 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
config.Config,
|
||||
cmdtickets.Tickets,
|
||||
cmdtickets.Ticket,
|
||||
}
|
||||
|
||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||
@@ -65,6 +68,8 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
||||
"volume": cmdmusic.VolumeHandler,
|
||||
"music": cmdmusic.MusicHandler,
|
||||
"config": config.ConfigHandler,
|
||||
"tickets": cmdtickets.TicketsHandler,
|
||||
"ticket": cmdtickets.TicketHandler,
|
||||
}
|
||||
|
||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
@@ -82,6 +87,8 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
schedule.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||
cmdtickets.HandleComponent(s, i)
|
||||
}
|
||||
case discordgo.InteractionModalSubmit:
|
||||
data := i.ModalSubmitData()
|
||||
@@ -90,6 +97,8 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
schedule.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||
cmdtickets.HandleModalSubmit(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Tickets",
|
||||
Value: "" +
|
||||
"Open a ticket from the panel: you get a private thread with the staff.\n" +
|
||||
"`/ticket add user:@someone` — Add someone to your ticket\n" +
|
||||
"`/ticket close [reason]` — Close the ticket (opener or staff)\n" +
|
||||
"`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" +
|
||||
"`/tickets setup`, `/tickets category add|remove|list`, `/tickets panel` — Set up (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Meetings (**Manage Server**)",
|
||||
Value: "" +
|
||||
|
||||
@@ -2,8 +2,6 @@ package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
@@ -49,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{
|
||||
@@ -90,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 {
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
"velox-bot/internal/tickets"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// The session must provide everything the ticket flows call.
|
||||
var _ tickets.Discord = (*discordgo.Session)(nil)
|
||||
|
||||
// Repo is the full ticket store, including the admin calls that only the
|
||||
// slash commands make. Set by Init.
|
||||
var Repo *ticketrepo.Repo
|
||||
|
||||
func Init(r *ticketrepo.Repo) {
|
||||
Repo = r
|
||||
tickets.Init(r)
|
||||
}
|
||||
|
||||
var (
|
||||
manageGuild = int64(discordgo.PermissionManageGuild)
|
||||
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
|
||||
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText}
|
||||
opTimeout = 2 * time.Minute
|
||||
genericFailed = "Something went wrong. Try again in a moment, and tell an admin if it keeps happening."
|
||||
)
|
||||
|
||||
// Tickets is the admin command for setting the system up.
|
||||
var Tickets = &discordgo.ApplicationCommand{
|
||||
Name: "tickets",
|
||||
Description: "Set up support tickets",
|
||||
DefaultMemberPermissions: &manageGuild,
|
||||
Contexts: guildOnly,
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "setup",
|
||||
Description: "Choose where tickets are opened and where closed tickets are logged",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Where the panel goes and ticket threads are created", Required: true, ChannelTypes: textChannels},
|
||||
{Type: discordgo.ApplicationCommandOptionChannel, Name: "log-channel", Description: "Where closed tickets and their transcripts are posted", ChannelTypes: textChannels},
|
||||
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Creates a Support category handled by this role, if there are no categories yet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
|
||||
Name: "category",
|
||||
Description: "Manage ticket categories",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add",
|
||||
Description: "Add a category (one button on the panel)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
|
||||
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button", MaxLength: 64},
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel", MaxLength: 200},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "remove",
|
||||
Description: "Remove a category",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category's name", Required: true},
|
||||
},
|
||||
},
|
||||
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "panel",
|
||||
Description: "Post the ticket panel, or update it after changing categories",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Ticket is used inside a ticket thread.
|
||||
var Ticket = &discordgo.ApplicationCommand{
|
||||
Name: "ticket",
|
||||
Description: "Manage the ticket you're in",
|
||||
Contexts: guildOnly,
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Add someone to this ticket",
|
||||
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to add", Required: true}},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Remove someone from this ticket (staff only)",
|
||||
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to remove", Required: true}},
|
||||
},
|
||||
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "claim", Description: "Take this ticket (staff only)"},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", Description: "Close this ticket",
|
||||
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Why it's being closed", MaxLength: 300}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func reply(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, AllowedMentions: &discordgo.MessageAllowedMentions{}},
|
||||
})
|
||||
}
|
||||
|
||||
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
|
||||
}
|
||||
|
||||
// message turns an error into what the user sees; unexpected ones are logged.
|
||||
func message(where string, err error) string {
|
||||
var ue tickets.UserError
|
||||
if errors.As(err, &ue) {
|
||||
return string(ue)
|
||||
}
|
||||
log.Printf("tickets: %s: %v", where, err)
|
||||
return genericFailed
|
||||
}
|
||||
|
||||
func actor(i *discordgo.InteractionCreate) tickets.Actor {
|
||||
a := tickets.Actor{Member: i.Member}
|
||||
if i.Member != nil {
|
||||
a.Perms = i.Member.Permissions
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func guildID(i *discordgo.InteractionCreate) int64 {
|
||||
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if Repo == nil {
|
||||
reply(s, i, "Tickets aren't available right now.")
|
||||
return
|
||||
}
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
sub := data.Options[0]
|
||||
switch sub.Name {
|
||||
case "setup":
|
||||
handleSetup(s, i, sub.Options)
|
||||
case "panel":
|
||||
handlePanel(s, i)
|
||||
case "category":
|
||||
if len(sub.Options) == 0 {
|
||||
return
|
||||
}
|
||||
switch sub.Options[0].Name {
|
||||
case "add":
|
||||
handleCategoryAdd(s, i, sub.Options[0].Options)
|
||||
case "remove":
|
||||
handleCategoryRemove(s, i, sub.Options[0].Options)
|
||||
case "list":
|
||||
handleCategoryList(s, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
|
||||
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
|
||||
for _, x := range o {
|
||||
m[x.Name] = x
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// permissionWarning names what the bot is missing in a channel, or "".
|
||||
func permissionWarning(s *discordgo.Session, channelID string, need int64, what string) string {
|
||||
have, err := s.UserChannelPermissions(s.State.User.ID, channelID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if missing := tickets.MissingPerms(have, need); len(missing) > 0 {
|
||||
return fmt.Sprintf("\n⚠️ I'm missing these permissions in <#%s> (%s): %s.", channelID, what, strings.Join(missing, ", "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleSetup(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
args := opts(o)
|
||||
g := guildID(i)
|
||||
|
||||
ch := args["channel"].ChannelValue(nil).ID
|
||||
var logID int64
|
||||
logMention := "none"
|
||||
if l, ok := args["log-channel"]; ok {
|
||||
lc := l.ChannelValue(nil).ID
|
||||
logID, _ = strconv.ParseInt(lc, 10, 64)
|
||||
logMention = "<#" + lc + ">"
|
||||
}
|
||||
chID, _ := strconv.ParseInt(ch, 10, 64)
|
||||
if err := Repo.SetChannels(ctx, g, chID, logID); err != nil {
|
||||
reply(s, i, message("setup", err))
|
||||
return
|
||||
}
|
||||
|
||||
cats, err := Repo.ListCategories(ctx, g)
|
||||
if err != nil {
|
||||
reply(s, i, message("setup", err))
|
||||
return
|
||||
}
|
||||
created := ""
|
||||
if r, ok := args["staff-role"]; ok && len(cats) == 0 {
|
||||
role := r.RoleValue(nil, i.GuildID)
|
||||
roleID, _ := strconv.ParseInt(role.ID, 10, 64)
|
||||
if _, err := Repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support", Emoji: "🎫", StaffRoleID: roleID}); err != nil {
|
||||
reply(s, i, message("setup", err))
|
||||
return
|
||||
}
|
||||
created = fmt.Sprintf("\nCreated a **Support** category handled by <@&%s>.", role.ID)
|
||||
cats = append(cats, ticketrepo.Category{Name: "Support"})
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Tickets will open as private threads in <#%s>. Closed tickets are logged to %s.%s", ch, logMention, created)
|
||||
msg += permissionWarning(s, ch, tickets.NeedTicketChannel, "ticket channel")
|
||||
if logID != 0 {
|
||||
msg += permissionWarning(s, strconv.FormatInt(logID, 10), tickets.NeedLogChannel, "log channel")
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
msg += "\nNext: add a category with `/tickets category add`, then post the panel with `/tickets panel`."
|
||||
} else {
|
||||
msg += "\nNext: post the panel with `/tickets panel`."
|
||||
}
|
||||
msg += "\nTip: give your staff role **Manage Threads** in that channel so they can see every ticket, even ones they weren't pinged in."
|
||||
reply(s, i, msg)
|
||||
}
|
||||
|
||||
func handlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !deferReply(s, i) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
msg, err := tickets.PostPanel(ctx, s, Repo, guildID(i))
|
||||
if err != nil {
|
||||
edit(s, i, message("panel", err))
|
||||
return
|
||||
}
|
||||
edit(s, i, fmt.Sprintf("The ticket panel is up: https://discord.com/channels/%s/%s/%s", i.GuildID, msg.ChannelID, msg.ID))
|
||||
}
|
||||
|
||||
func handleCategoryAdd(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
args := opts(o)
|
||||
g := guildID(i)
|
||||
|
||||
cats, err := Repo.ListCategories(ctx, g)
|
||||
if err != nil {
|
||||
reply(s, i, message("category add", err))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(args["name"].StringValue())
|
||||
for _, c := range cats {
|
||||
if strings.EqualFold(c.Name, name) {
|
||||
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(cats) >= 25 {
|
||||
reply(s, i, "A panel can hold at most 25 categories.")
|
||||
return
|
||||
}
|
||||
|
||||
c := ticketrepo.Category{GuildID: g, Name: name}
|
||||
if r, ok := args["staff-role"]; ok {
|
||||
c.StaffRoleID, _ = strconv.ParseInt(r.RoleValue(nil, i.GuildID).ID, 10, 64)
|
||||
}
|
||||
if e, ok := args["emoji"]; ok {
|
||||
c.Emoji = strings.TrimSpace(e.StringValue())
|
||||
}
|
||||
if d, ok := args["description"]; ok {
|
||||
c.Description = strings.TrimSpace(d.StringValue())
|
||||
}
|
||||
if _, err := Repo.AddCategory(ctx, c); err != nil {
|
||||
reply(s, i, message("category add", err))
|
||||
return
|
||||
}
|
||||
reply(s, i, fmt.Sprintf("Added **%s**. Run `/tickets panel` to show it on the panel.", name))
|
||||
}
|
||||
|
||||
func handleCategoryRemove(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
name := strings.TrimSpace(opts(o)["name"].StringValue())
|
||||
ok, err := Repo.DeleteCategoryByName(ctx, guildID(i), name)
|
||||
switch {
|
||||
case err != nil:
|
||||
reply(s, i, message("category remove", err))
|
||||
case !ok:
|
||||
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
|
||||
default:
|
||||
reply(s, i, fmt.Sprintf("Removed **%s**. Open tickets keep working. Run `/tickets panel` to update the panel.", name))
|
||||
}
|
||||
}
|
||||
|
||||
func handleCategoryList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
cats, err := Repo.ListCategories(ctx, guildID(i))
|
||||
if err != nil {
|
||||
reply(s, i, message("category list", err))
|
||||
return
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
reply(s, i, "No categories yet. Add one with `/tickets category add`.")
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, c := range cats {
|
||||
line := "• "
|
||||
if c.Emoji != "" {
|
||||
line += c.Emoji + " "
|
||||
}
|
||||
line += "**" + c.Name + "**"
|
||||
if c.StaffRoleID != 0 {
|
||||
line += fmt.Sprintf(" → <@&%d>", c.StaffRoleID)
|
||||
} else {
|
||||
line += " → no staff role (only Manage Threads)"
|
||||
}
|
||||
if c.Description != "" {
|
||||
line += ": " + c.Description
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
reply(s, i, b.String())
|
||||
}
|
||||
|
||||
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if Repo == nil {
|
||||
reply(s, i, "Tickets aren't available right now.")
|
||||
return
|
||||
}
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
sub := data.Options[0]
|
||||
args := opts(sub.Options)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch sub.Name {
|
||||
case "add", "remove":
|
||||
user := args["user"].UserValue(nil)
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
if !deferReply(s, i) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if sub.Name == "add" {
|
||||
err = tickets.AddMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
|
||||
} else {
|
||||
err = tickets.RemoveMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
|
||||
}
|
||||
if err != nil {
|
||||
edit(s, i, message("ticket "+sub.Name, err))
|
||||
return
|
||||
}
|
||||
edit(s, i, "Done.")
|
||||
case "claim":
|
||||
claim(s, i)
|
||||
case "close":
|
||||
reason := ""
|
||||
if r, ok := args["reason"]; ok {
|
||||
reason = r.StringValue()
|
||||
}
|
||||
closeTicket(s, i, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func claim(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !deferReply(s, i) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
if err := tickets.Claim(ctx, s, Repo, i.ChannelID, actor(i)); err != nil {
|
||||
edit(s, i, message("claim", err))
|
||||
return
|
||||
}
|
||||
edit(s, i, "You're handling this ticket now.")
|
||||
}
|
||||
|
||||
func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason string) {
|
||||
if !deferReply(s, i) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
if err := tickets.Close(ctx, s, Repo, i.ChannelID, actor(i), reason); err != nil {
|
||||
edit(s, i, message("close", err))
|
||||
return
|
||||
}
|
||||
edit(s, i, "Ticket closed.")
|
||||
}
|
||||
|
||||
// HandleComponent handles the panel buttons and dropdown and the Claim and
|
||||
// Close buttons in a ticket.
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if Repo == nil {
|
||||
reply(s, i, "Tickets aren't available right now.")
|
||||
return
|
||||
}
|
||||
data := i.MessageComponentData()
|
||||
switch {
|
||||
case strings.HasPrefix(data.CustomID, "tickets:open:"):
|
||||
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
|
||||
case data.CustomID == "tickets:select" && len(data.Values) > 0:
|
||||
showOpenForm(s, i, data.Values[0])
|
||||
case data.CustomID == "tickets:claim":
|
||||
claim(s, i)
|
||||
case data.CustomID == "tickets:close":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
if err := tickets.CheckCanClose(ctx, Repo, i.ChannelID, actor(i)); err != nil {
|
||||
reply(s, i, message("close", err))
|
||||
return
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, tickets.CloseForm())
|
||||
}
|
||||
}
|
||||
|
||||
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
|
||||
catID, err := strconv.ParseInt(rawCategory, 10, 64)
|
||||
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
_, cat, err := tickets.CheckCanOpen(ctx, Repo, guildID(i), userID, catID)
|
||||
if err != nil {
|
||||
reply(s, i, message("open", err))
|
||||
return
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, tickets.OpenForm(cat))
|
||||
}
|
||||
|
||||
func field(data discordgo.ModalSubmitInteractionData, name string) string {
|
||||
for _, row := range data.Components {
|
||||
r, ok := row.(*discordgo.ActionsRow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, c := range r.Components {
|
||||
if in, ok := c.(*discordgo.TextInput); ok && in.CustomID == name {
|
||||
return strings.TrimSpace(in.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleModalSubmit handles the opening and closing forms.
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if Repo == nil {
|
||||
reply(s, i, "Tickets aren't available right now.")
|
||||
return
|
||||
}
|
||||
data := i.ModalSubmitData()
|
||||
switch {
|
||||
case strings.HasPrefix(data.CustomID, "tickets:openform:"):
|
||||
catID, err := strconv.ParseInt(strings.TrimPrefix(data.CustomID, "tickets:openform:"), 10, 64)
|
||||
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||
return
|
||||
}
|
||||
if !deferReply(s, i) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
thread, err := tickets.Open(ctx, s, Repo, tickets.OpenRequest{
|
||||
GuildID: guildID(i), CategoryID: catID, User: i.Member.User,
|
||||
Subject: field(data, "subject"), Details: field(data, "details"),
|
||||
})
|
||||
if err != nil {
|
||||
edit(s, i, message("open", err))
|
||||
return
|
||||
}
|
||||
edit(s, i, fmt.Sprintf("Your ticket is open: <#%s>. The staff will be with you there.", thread.ID))
|
||||
case data.CustomID == "tickets:closeform":
|
||||
closeTicket(s, i, field(data, "reason"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/tickets/public"
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Tickets *discordgo.ApplicationCommand = public.Tickets
|
||||
Ticket *discordgo.ApplicationCommand = public.Ticket
|
||||
)
|
||||
|
||||
func Init(r *ticketrepo.Repo) { public.Init(r) }
|
||||
|
||||
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.TicketsHandler(s, i)
|
||||
}
|
||||
|
||||
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TicketHandler(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)
|
||||
}
|
||||
@@ -10,6 +10,11 @@ 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 {
|
||||
@@ -20,9 +25,15 @@ 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) error {
|
||||
const q = `INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (guild_id) DO UPDATE SET track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5, paused = $6, volume = $7`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,6 +43,12 @@ func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
|
||||
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 {
|
||||
@@ -39,19 +56,89 @@ func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueE
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
const q = `DELETE FROM music_queue WHERE guild_id = $1`
|
||||
_, err = tx.ExecContext(ctx, q, guildID)
|
||||
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 {
|
||||
const q = `INSERT INTO music_queue (guild_id, track_title, track_url, requested_by, position) VALUES ($1, $2, $3, $4, $5)`
|
||||
_, err = tx.ExecContext(ctx, q, guildID, entry.Title, entry.URL, entry.RequestedBy, i)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Package ticketrepo stores the ticket system's settings, categories, tickets
|
||||
// and the dashboard's requests to the bot. Discord IDs are int64; 0 means
|
||||
// "not set" for optional ones.
|
||||
package ticketrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultPanelTitle = "Need help?"
|
||||
DefaultPanelDescription = "Pick a category below to open a private ticket with the staff team."
|
||||
DefaultMaxOpenPerUser = 1
|
||||
|
||||
StatusOpen = "open"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
GuildID int64
|
||||
Enabled bool
|
||||
ChannelID int64
|
||||
LogChannelID int64
|
||||
PanelMessageID int64
|
||||
PanelTitle string
|
||||
PanelDescription string
|
||||
MaxOpenPerUser int
|
||||
// Configured is false when the guild has no settings row yet; the other
|
||||
// fields then hold the defaults.
|
||||
Configured bool
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Name string
|
||||
Emoji string
|
||||
Description string
|
||||
StaffRoleID int64
|
||||
Position int
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Number int
|
||||
CategoryID int64
|
||||
CategoryName string
|
||||
ThreadID int64
|
||||
IntroMessage int64
|
||||
OpenerID int64
|
||||
Subject string
|
||||
Status string
|
||||
ClaimedBy int64
|
||||
ClosedBy int64
|
||||
CloseReason string
|
||||
CreatedAt time.Time
|
||||
ClosedAt time.Time // zero while open
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
CommandType string
|
||||
RequestedBy int64
|
||||
}
|
||||
|
||||
func nullID(v int64) sql.NullInt64 {
|
||||
return sql.NullInt64{Int64: v, Valid: v != 0}
|
||||
}
|
||||
|
||||
func nullText(v string) sql.NullString {
|
||||
return sql.NullString{String: v, Valid: v != ""}
|
||||
}
|
||||
|
||||
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||
const q = `
|
||||
SELECT enabled, channel_id, log_channel_id, panel_message_id, panel_title, panel_description, max_open_per_user
|
||||
FROM ticket_settings WHERE guild_id = $1`
|
||||
var ch, logCh, panel sql.NullInt64
|
||||
s := &Settings{GuildID: guildID}
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return &Settings{
|
||||
GuildID: guildID,
|
||||
Enabled: true,
|
||||
PanelTitle: DefaultPanelTitle,
|
||||
PanelDescription: DefaultPanelDescription,
|
||||
MaxOpenPerUser: DefaultMaxOpenPerUser,
|
||||
}, nil
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
s.ChannelID, s.LogChannelID, s.PanelMessageID = ch.Int64, logCh.Int64, panel.Int64
|
||||
s.Configured = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetChannels saves where tickets live and where transcripts go. A zero log
|
||||
// channel clears it.
|
||||
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
|
||||
const q = `
|
||||
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET
|
||||
channel_id = EXCLUDED.channel_id,
|
||||
log_channel_id = EXCLUDED.log_channel_id`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, nullID(channelID), nullID(logChannelID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) SetPanelMessage(ctx context.Context, guildID, messageID int64) error {
|
||||
const q = `
|
||||
INSERT INTO ticket_settings (guild_id, panel_message_id) VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET panel_message_id = EXCLUDED.panel_message_id`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, nullID(messageID))
|
||||
return err
|
||||
}
|
||||
|
||||
// NextNumber hands out the guild's next ticket number. It is a single
|
||||
// statement, so two tickets opened at the same moment never share a number.
|
||||
func (r *Repo) NextNumber(ctx context.Context, guildID int64) (int, error) {
|
||||
const q = `
|
||||
INSERT INTO ticket_settings (guild_id, next_number) VALUES ($1, 2)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET next_number = ticket_settings.next_number + 1
|
||||
RETURNING next_number - 1`
|
||||
var n int
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const categoryCols = `id, guild_id, name, COALESCE(emoji, ''), description, COALESCE(staff_role_id, 0), position`
|
||||
|
||||
func scanCategory(row interface{ Scan(...any) error }) (Category, error) {
|
||||
var c Category
|
||||
err := row.Scan(&c.ID, &c.GuildID, &c.Name, &c.Emoji, &c.Description, &c.StaffRoleID, &c.Position)
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (r *Repo) ListCategories(ctx context.Context, guildID int64) ([]Category, error) {
|
||||
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 ORDER BY position, id`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Category
|
||||
for rows.Next() {
|
||||
c, err := scanCategory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCategory returns nil when the category does not exist in that guild.
|
||||
func (r *Repo) GetCategory(ctx context.Context, guildID, id int64) (*Category, error) {
|
||||
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 AND id = $2`
|
||||
c, err := scanCategory(r.db.QueryRowContext(ctx, q, guildID, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// AddCategory appends a category after the existing ones.
|
||||
func (r *Repo) AddCategory(ctx context.Context, c Category) (*Category, error) {
|
||||
q := `
|
||||
INSERT INTO ticket_categories (guild_id, name, emoji, description, staff_role_id, position)
|
||||
VALUES ($1, $2, $3, $4, $5,
|
||||
(SELECT COALESCE(MAX(position) + 1, 0) FROM ticket_categories WHERE guild_id = $1))
|
||||
RETURNING ` + categoryCols
|
||||
out, err := scanCategory(r.db.QueryRowContext(ctx, q, c.GuildID, c.Name, nullText(c.Emoji), c.Description, nullID(c.StaffRoleID)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteCategoryByName removes a category (case-insensitively) and reports
|
||||
// whether one was found. Tickets keep their copied category name.
|
||||
func (r *Repo) DeleteCategoryByName(ctx context.Context, guildID int64, name string) (bool, error) {
|
||||
const q = `DELETE FROM ticket_categories WHERE guild_id = $1 AND lower(name) = lower($2)`
|
||||
res, err := r.db.ExecContext(ctx, q, guildID, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *Repo) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) {
|
||||
const q = `SELECT COUNT(*) FROM tickets WHERE guild_id = $1 AND opener_id = $2 AND status = 'open'`
|
||||
var n int
|
||||
err := r.db.QueryRowContext(ctx, q, guildID, openerID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *Repo) CreateTicket(ctx context.Context, t Ticket) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO tickets (guild_id, number, category_id, category_name, thread_id, intro_message_id, opener_id, subject)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`
|
||||
var id int64
|
||||
err := r.db.QueryRowContext(ctx, q, t.GuildID, t.Number, nullID(t.CategoryID), t.CategoryName, t.ThreadID, nullID(t.IntroMessage), t.OpenerID, t.Subject).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const ticketCols = `id, guild_id, number, COALESCE(category_id, 0), category_name, thread_id, COALESCE(intro_message_id, 0), opener_id, subject, status,
|
||||
COALESCE(claimed_by, 0), COALESCE(closed_by, 0), COALESCE(close_reason, ''), created_at, closed_at`
|
||||
|
||||
func scanTicket(row interface{ Scan(...any) error }) (Ticket, error) {
|
||||
var t Ticket
|
||||
var closedAt sql.NullTime
|
||||
err := row.Scan(&t.ID, &t.GuildID, &t.Number, &t.CategoryID, &t.CategoryName, &t.ThreadID, &t.IntroMessage, &t.OpenerID, &t.Subject, &t.Status,
|
||||
&t.ClaimedBy, &t.ClosedBy, &t.CloseReason, &t.CreatedAt, &closedAt)
|
||||
t.ClosedAt = closedAt.Time
|
||||
return t, err
|
||||
}
|
||||
|
||||
// GetTicketByThread returns nil when the thread is not a ticket.
|
||||
func (r *Repo) GetTicketByThread(ctx context.Context, threadID int64) (*Ticket, error) {
|
||||
q := `SELECT ` + ticketCols + ` FROM tickets WHERE thread_id = $1`
|
||||
t, err := scanTicket(r.db.QueryRowContext(ctx, q, threadID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Claim sets the ticket's handler unless someone already has it, and returns
|
||||
// whoever holds it afterwards (the caller when the claim worked).
|
||||
func (r *Repo) Claim(ctx context.Context, threadID, staffID int64) (int64, error) {
|
||||
const q = `
|
||||
UPDATE tickets SET claimed_by = COALESCE(claimed_by, $2)
|
||||
WHERE thread_id = $1 AND status = 'open'
|
||||
RETURNING claimed_by`
|
||||
var holder int64
|
||||
err := r.db.QueryRowContext(ctx, q, threadID, staffID).Scan(&holder)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return holder, err
|
||||
}
|
||||
|
||||
// Close marks an open ticket closed and reports whether this call closed it,
|
||||
// so two people pressing Close at once only produce one transcript.
|
||||
func (r *Repo) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) {
|
||||
const q = `
|
||||
UPDATE tickets SET status = 'closed', closed_by = $2, close_reason = $3, closed_at = NOW()
|
||||
WHERE thread_id = $1 AND status = 'open'`
|
||||
res, err := r.db.ExecContext(ctx, q, threadID, nullID(closedBy), nullText(reason))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
|
||||
const q = `SELECT id, guild_id, command_type, requested_by FROM ticket_commands WHERE processed_at IS NULL ORDER BY id`
|
||||
rows, err := r.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Command
|
||||
for rows.Next() {
|
||||
var c Command
|
||||
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.RequestedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//go:build integration
|
||||
|
||||
// Runs against a real Postgres with the dashboard API's migrations applied:
|
||||
//
|
||||
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
|
||||
package ticketrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func testRepo(t *testing.T) (*Repo, int64) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TICKETS_TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("TICKETS_TEST_DB not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
const guild = 424242
|
||||
for _, q := range []string{
|
||||
`DELETE FROM tickets WHERE guild_id = $1`,
|
||||
`DELETE FROM ticket_categories WHERE guild_id = $1`,
|
||||
`DELETE FROM ticket_settings WHERE guild_id = $1`,
|
||||
`DELETE FROM ticket_commands WHERE guild_id = $1`,
|
||||
} {
|
||||
if _, err := db.Exec(q, guild); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return NewRepo(db), guild
|
||||
}
|
||||
|
||||
func TestSettingsDefaultsAndChannels(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := r.GetSettings(ctx, g)
|
||||
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
|
||||
t.Fatalf("defaults wrong: %+v %v", s, err)
|
||||
}
|
||||
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ = r.GetSettings(ctx, g)
|
||||
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
|
||||
t.Fatalf("saved settings wrong: %+v", s)
|
||||
}
|
||||
// Clearing the log channel.
|
||||
_ = r.SetChannels(ctx, g, 111, 0)
|
||||
s, _ = r.GetSettings(ctx, g)
|
||||
if s.LogChannelID != 0 {
|
||||
t.Errorf("log channel not cleared: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var mu sync.Mutex
|
||||
seen := map[int]bool{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
n, err := r.NextNumber(ctx, g)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if seen[n] {
|
||||
t.Errorf("number %d handed out twice", n)
|
||||
}
|
||||
seen[n] = true
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for n := 1; n <= 20; n++ {
|
||||
if !seen[n] {
|
||||
t.Errorf("number %d missing: numbers should be 1..20", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategories(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
|
||||
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
|
||||
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
|
||||
}
|
||||
list, _ := r.ListCategories(ctx, g)
|
||||
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
|
||||
t.Fatalf("list wrong: %+v", list)
|
||||
}
|
||||
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
|
||||
t.Errorf("get wrong: %+v", c)
|
||||
}
|
||||
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
|
||||
t.Error("a category must not be readable from another guild")
|
||||
}
|
||||
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
|
||||
t.Error("delete should match case-insensitively")
|
||||
}
|
||||
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
|
||||
t.Error("deleting a missing category should report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketLifecycle(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
|
||||
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
|
||||
if err != nil || id == 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
|
||||
t.Errorf("open count = %d", n)
|
||||
}
|
||||
|
||||
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
|
||||
t.Errorf("first claim should win, holder = %d", holder)
|
||||
}
|
||||
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
|
||||
t.Errorf("second claim must not take over, holder = %d", holder)
|
||||
}
|
||||
|
||||
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
|
||||
t.Error("first close should succeed")
|
||||
}
|
||||
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
|
||||
t.Error("closing twice must report false")
|
||||
}
|
||||
tk, _ := r.GetTicketByThread(ctx, 5001)
|
||||
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
|
||||
t.Fatalf("closed ticket wrong: %+v", tk)
|
||||
}
|
||||
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
|
||||
t.Errorf("closed ticket still counted as open")
|
||||
}
|
||||
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
|
||||
t.Error("a closed ticket cannot be claimed")
|
||||
}
|
||||
|
||||
// Deleting the category keeps the ticket and its copied name.
|
||||
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
|
||||
tk, _ = r.GetTicketByThread(ctx, 5001)
|
||||
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
|
||||
t.Errorf("ticket after category delete: %+v", tk)
|
||||
}
|
||||
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
|
||||
t.Error("unknown thread should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds, err := r.ListUnprocessedCommands(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mine *Command
|
||||
for i := range cmds {
|
||||
if cmds[i].GuildID == g {
|
||||
mine = &cmds[i]
|
||||
}
|
||||
}
|
||||
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
|
||||
t.Fatalf("command not listed: %+v", cmds)
|
||||
}
|
||||
_ = r.MarkCommandProcessed(ctx, mine.ID)
|
||||
cmds, _ = r.ListUnprocessedCommands(ctx)
|
||||
for _, c := range cmds {
|
||||
if c.ID == mine.ID {
|
||||
t.Error("processed command still listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+196
-17
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"velox-bot/internal/db/repos/musicrepo"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TrackEntry struct {
|
||||
@@ -21,6 +23,14 @@ type TrackEntry struct {
|
||||
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 {
|
||||
@@ -213,6 +223,8 @@ func syncState(guidID string) {
|
||||
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)
|
||||
@@ -245,7 +257,7 @@ func syncState(guidID string) {
|
||||
length := int(current.Track.Info.Length / 1000)
|
||||
startedAt := current.StartedAt
|
||||
|
||||
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume)
|
||||
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -262,6 +274,7 @@ func syncState(guidID string) {
|
||||
Title: entry.Track.Info.Title,
|
||||
URL: uri,
|
||||
RequestedBy: reqID,
|
||||
ClientID: entry.QueueID,
|
||||
})
|
||||
}
|
||||
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
||||
@@ -271,23 +284,50 @@ func syncState(guidID string) {
|
||||
|
||||
}
|
||||
|
||||
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) {
|
||||
if manager == nil {
|
||||
return nil, false, fmt.Errorf("music manager not initialized")
|
||||
// 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
|
||||
}
|
||||
|
||||
// Join voice channel
|
||||
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
||||
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
||||
u, err := url.Parse(query)
|
||||
if err != nil || u == nil {
|
||||
return query
|
||||
}
|
||||
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
if textChannelID != "" {
|
||||
manager.mu.Lock()
|
||||
gp.TextChannelID = textChannelID
|
||||
manager.mu.Unlock()
|
||||
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(
|
||||
@@ -325,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
||||
)
|
||||
|
||||
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.
|
||||
@@ -340,12 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
||||
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]
|
||||
@@ -372,6 +438,47 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
||||
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
|
||||
@@ -551,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) {
|
||||
@@ -565,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) {
|
||||
@@ -630,6 +743,72 @@ func SetVolume(guildID string, volume int) error {
|
||||
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
|
||||
}
|
||||
|
||||
func CurrentNowPlayingMessageID(guildID string) string {
|
||||
if manager == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory ticketrepo with the same rules as the SQL.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
settings map[int64]*ticketrepo.Settings
|
||||
cats []ticketrepo.Category
|
||||
tickets []*ticketrepo.Ticket
|
||||
next map[int64]int
|
||||
cmds []ticketrepo.Command
|
||||
done []int64
|
||||
}
|
||||
|
||||
func newStore() *fakeStore {
|
||||
return &fakeStore{settings: map[int64]*ticketrepo.Settings{}, next: map[int64]int{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) configure(guild, channel, logCh int64) {
|
||||
f.settings[guild] = &ticketrepo.Settings{
|
||||
GuildID: guild, Enabled: true, ChannelID: channel, LogChannelID: logCh,
|
||||
PanelTitle: ticketrepo.DefaultPanelTitle, PanelDescription: ticketrepo.DefaultPanelDescription,
|
||||
MaxOpenPerUser: 1, Configured: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) addCat(c ticketrepo.Category) ticketrepo.Category {
|
||||
c.ID = int64(len(f.cats) + 1)
|
||||
f.cats = append(f.cats, c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*ticketrepo.Settings, error) {
|
||||
if s, ok := f.settings[g]; ok {
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
return &ticketrepo.Settings{GuildID: g, Enabled: true, PanelTitle: ticketrepo.DefaultPanelTitle, MaxOpenPerUser: 1}, nil
|
||||
}
|
||||
func (f *fakeStore) SetPanelMessage(_ context.Context, g, m int64) error {
|
||||
f.settings[g].PanelMessageID = m
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) NextNumber(_ context.Context, g int64) (int, error) {
|
||||
f.next[g]++
|
||||
return f.next[g], nil
|
||||
}
|
||||
func (f *fakeStore) ListCategories(_ context.Context, g int64) ([]ticketrepo.Category, error) {
|
||||
var out []ticketrepo.Category
|
||||
for _, c := range f.cats {
|
||||
if c.GuildID == g {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) GetCategory(_ context.Context, g, id int64) (*ticketrepo.Category, error) {
|
||||
for _, c := range f.cats {
|
||||
if c.GuildID == g && c.ID == id {
|
||||
cp := c
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CountOpenByOpener(_ context.Context, g, u int64) (int, error) {
|
||||
n := 0
|
||||
for _, t := range f.tickets {
|
||||
if t.GuildID == g && t.OpenerID == u && t.Status == ticketrepo.StatusOpen {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func (f *fakeStore) CreateTicket(_ context.Context, t ticketrepo.Ticket) (int64, error) {
|
||||
t.ID = int64(len(f.tickets) + 1)
|
||||
t.Status = ticketrepo.StatusOpen
|
||||
t.CreatedAt = time.Now().Add(-90 * time.Minute)
|
||||
f.tickets = append(f.tickets, &t)
|
||||
return t.ID, nil
|
||||
}
|
||||
func (f *fakeStore) byThread(th int64) *ticketrepo.Ticket {
|
||||
for _, t := range f.tickets {
|
||||
if t.ThreadID == th {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) GetTicketByThread(_ context.Context, th int64) (*ticketrepo.Ticket, error) {
|
||||
if t := f.byThread(th); t != nil {
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) Claim(_ context.Context, th, staff int64) (int64, error) {
|
||||
t := f.byThread(th)
|
||||
if t == nil || t.Status != ticketrepo.StatusOpen {
|
||||
return 0, nil
|
||||
}
|
||||
if t.ClaimedBy == 0 {
|
||||
t.ClaimedBy = staff
|
||||
}
|
||||
return t.ClaimedBy, nil
|
||||
}
|
||||
func (f *fakeStore) Close(_ context.Context, th, by int64, reason string) (bool, error) {
|
||||
t := f.byThread(th)
|
||||
if t == nil || t.Status != ticketrepo.StatusOpen {
|
||||
return false, nil
|
||||
}
|
||||
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, by, reason, time.Now()
|
||||
return true, nil
|
||||
}
|
||||
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]ticketrepo.Command, error) {
|
||||
var out []ticketrepo.Command
|
||||
for _, c := range f.cmds {
|
||||
done := false
|
||||
for _, d := range f.done {
|
||||
done = done || d == c.ID
|
||||
}
|
||||
if !done {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
|
||||
f.done = append(f.done, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeDiscord records what the flows ask Discord to do.
|
||||
type fakeDiscord struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
threads []*discordgo.ThreadStart
|
||||
members map[string][]string // thread -> user IDs
|
||||
sent map[string][]*discordgo.MessageSend
|
||||
edits []*discordgo.MessageEdit
|
||||
chEdits map[string]*discordgo.ChannelEdit
|
||||
deleted []string
|
||||
files map[string]string // file name -> contents
|
||||
history map[string][]*discordgo.Message
|
||||
messages map[string]*discordgo.Message // by "channel/message"
|
||||
failSend error
|
||||
failEdit error
|
||||
failFetch error
|
||||
}
|
||||
|
||||
func newDiscord() *fakeDiscord {
|
||||
return &fakeDiscord{
|
||||
nextID: 1000, members: map[string][]string{}, sent: map[string][]*discordgo.MessageSend{},
|
||||
chEdits: map[string]*discordgo.ChannelEdit{}, files: map[string]string{},
|
||||
history: map[string][]*discordgo.Message{}, messages: map[string]*discordgo.Message{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDiscord) newID() string {
|
||||
f.nextID++
|
||||
return fmt.Sprint(f.nextID)
|
||||
}
|
||||
|
||||
func (f *fakeDiscord) ThreadStartComplex(ch string, data *discordgo.ThreadStart, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||
f.threads = append(f.threads, data)
|
||||
return &discordgo.Channel{ID: f.newID(), ParentID: ch, Name: data.Name, Type: data.Type}, nil
|
||||
}
|
||||
func (f *fakeDiscord) ThreadMemberAdd(th, u string, _ ...discordgo.RequestOption) error {
|
||||
f.members[th] = append(f.members[th], u)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDiscord) ThreadMemberRemove(th, u string, _ ...discordgo.RequestOption) error {
|
||||
var keep []string
|
||||
for _, m := range f.members[th] {
|
||||
if m != u {
|
||||
keep = append(keep, m)
|
||||
}
|
||||
}
|
||||
f.members[th] = keep
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||
if f.failSend != nil {
|
||||
return nil, f.failSend
|
||||
}
|
||||
for _, file := range data.Files {
|
||||
raw, _ := io.ReadAll(file.Reader)
|
||||
f.files[file.Name] = string(raw)
|
||||
}
|
||||
f.sent[ch] = append(f.sent[ch], data)
|
||||
m := &discordgo.Message{ID: f.newID(), ChannelID: ch, Content: data.Content, Embeds: data.Embeds, Components: data.Components}
|
||||
f.messages[ch+"/"+m.ID] = m
|
||||
return m, nil
|
||||
}
|
||||
func (f *fakeDiscord) ChannelMessageEditComplex(e *discordgo.MessageEdit, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||
if f.failEdit != nil {
|
||||
return nil, f.failEdit
|
||||
}
|
||||
f.edits = append(f.edits, e)
|
||||
return &discordgo.Message{ID: e.ID, ChannelID: e.Channel}, nil
|
||||
}
|
||||
func (f *fakeDiscord) ChannelMessage(ch, m string, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||
if msg, ok := f.messages[ch+"/"+m]; ok {
|
||||
return msg, nil
|
||||
}
|
||||
return nil, errors.New("unknown message")
|
||||
}
|
||||
func (f *fakeDiscord) ChannelMessages(ch string, limit int, before, _, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
|
||||
if f.failFetch != nil {
|
||||
return nil, f.failFetch
|
||||
}
|
||||
// history is stored newest first, like Discord returns it.
|
||||
all := f.history[ch]
|
||||
start := 0
|
||||
if before != "" {
|
||||
for i, m := range all {
|
||||
if m.ID == before {
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[start:end], nil
|
||||
}
|
||||
func (f *fakeDiscord) ChannelEditComplex(ch string, data *discordgo.ChannelEdit, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||
f.chEdits[ch] = data
|
||||
return &discordgo.Channel{ID: ch}, nil
|
||||
}
|
||||
func (f *fakeDiscord) ChannelDelete(ch string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||
f.deleted = append(f.deleted, ch)
|
||||
return &discordgo.Channel{ID: ch}, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// StartCommandListener waits for the dashboard's requests (ticket_commands
|
||||
// rows, announced with NOTIFY) and carries them out. It returns when the
|
||||
// connection fails or ctx ends.
|
||||
func StartCommandListener(ctx context.Context, connString string, d Discord, st Store) error {
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
if _, err := conn.Exec(ctx, "LISTEN ticket_commands"); err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
// Anything queued while the bot was down.
|
||||
ProcessCommands(ctx, d, st)
|
||||
|
||||
for {
|
||||
if _, err := conn.WaitForNotification(ctx); err != nil {
|
||||
return fmt.Errorf("waiting for notifications: %w", err)
|
||||
}
|
||||
ProcessCommands(ctx, d, st)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessCommands carries out every pending command once. A command that
|
||||
// fails is still marked done: retrying a panel post that failed for missing
|
||||
// permissions would only fail again, and the error is logged.
|
||||
func ProcessCommands(ctx context.Context, d Discord, st Store) {
|
||||
cmds, err := st.ListUnprocessedCommands(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tickets: listing dashboard commands: %v", err)
|
||||
return
|
||||
}
|
||||
for _, c := range cmds {
|
||||
switch c.CommandType {
|
||||
case "post_panel":
|
||||
if _, err := PostPanel(ctx, d, st, c.GuildID); err != nil {
|
||||
var ue UserError
|
||||
if errors.As(err, &ue) {
|
||||
log.Printf("tickets: posting the panel for guild %d from the dashboard: %s", c.GuildID, ue)
|
||||
} else {
|
||||
log.Printf("tickets: posting the panel for guild %d from the dashboard: %v", c.GuildID, err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Printf("tickets: unknown dashboard command %q (%d)", c.CommandType, c.ID)
|
||||
}
|
||||
if err := st.MarkCommandProcessed(ctx, c.ID); err != nil {
|
||||
log.Printf("tickets: marking command %d done: %v", c.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build integration
|
||||
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// The dashboard's request reaches the bot through the real trigger and
|
||||
// LISTEN/NOTIFY:
|
||||
//
|
||||
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/tickets
|
||||
func TestListenerPostsPanelOnNotify(t *testing.T) {
|
||||
dsn := os.Getenv("TICKETS_TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("TICKETS_TEST_DB not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
const g = 515151
|
||||
for _, q := range []string{`DELETE FROM ticket_commands`, `DELETE FROM ticket_categories WHERE guild_id = $1`, `DELETE FROM ticket_settings WHERE guild_id = $1`} {
|
||||
if q == `DELETE FROM ticket_commands` {
|
||||
_, err = db.Exec(q)
|
||||
} else {
|
||||
_, err = db.Exec(q, g)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := ticketrepo.NewRepo(db)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := repo.SetChannels(ctx, g, 10, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := newDiscord()
|
||||
go func() { _ = StartCommandListener(ctx, dsn, d, repo) }()
|
||||
time.Sleep(500 * time.Millisecond) // let LISTEN start
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 1)`, g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
s, _ := repo.GetSettings(ctx, g)
|
||||
if s.PanelMessageID != 0 {
|
||||
cmds, _ := repo.ListUnprocessedCommands(ctx)
|
||||
if len(cmds) != 0 {
|
||||
t.Errorf("command not marked processed: %+v", cmds)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the panel was never posted")
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Actor is whoever pressed the button or ran the command, with their
|
||||
// permissions in the ticket thread.
|
||||
type Actor struct {
|
||||
Member *discordgo.Member
|
||||
Perms int64
|
||||
}
|
||||
|
||||
func (a Actor) userID() string {
|
||||
if a.Member == nil || a.Member.User == nil {
|
||||
return ""
|
||||
}
|
||||
return a.Member.User.ID
|
||||
}
|
||||
|
||||
// load finds the open ticket behind a thread and its category (nil when the
|
||||
// category was deleted since).
|
||||
func load(ctx context.Context, st Store, threadID string) (*ticketrepo.Ticket, *ticketrepo.Category, error) {
|
||||
t, err := st.GetTicketByThread(ctx, mustID(threadID))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, nil, ErrNotATicket
|
||||
}
|
||||
if t.Status != ticketrepo.StatusOpen {
|
||||
return nil, nil, ErrClosed
|
||||
}
|
||||
var cat *ticketrepo.Category
|
||||
if t.CategoryID != 0 {
|
||||
if cat, err = st.GetCategory(ctx, t.GuildID, t.CategoryID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return t, cat, nil
|
||||
}
|
||||
|
||||
// updateIntro edits the ticket's first message to show who claimed it and
|
||||
// whether it is closed. It keeps the original text (the details are only in
|
||||
// that message) and is best effort: a deleted first message doesn't stop the
|
||||
// ticket from being claimed or closed.
|
||||
func updateIntro(d Discord, t *ticketrepo.Ticket) {
|
||||
if t.IntroMessage == 0 {
|
||||
return
|
||||
}
|
||||
msg, err := d.ChannelMessage(id(t.ThreadID), id(t.IntroMessage))
|
||||
if err != nil || len(msg.Embeds) == 0 {
|
||||
return
|
||||
}
|
||||
fresh := IntroEmbed(t, "")
|
||||
e := *msg.Embeds[0]
|
||||
e.Color, e.Footer = fresh.Color, fresh.Footer
|
||||
e.Fields = fresh.Fields
|
||||
embeds := []*discordgo.MessageEmbed{&e}
|
||||
comps := IntroComponents(t.ClaimedBy != 0, t.Status == ticketrepo.StatusClosed)
|
||||
if _, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: id(t.ThreadID), ID: id(t.IntroMessage), Embeds: &embeds, Components: &comps,
|
||||
}); err != nil {
|
||||
log.Printf("tickets: updating the first message of ticket %d: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim marks the actor as the ticket's handler. Only staff can claim, and a
|
||||
// claimed ticket stays with its first claimer.
|
||||
func Claim(ctx context.Context, d Discord, st Store, threadID string, a Actor) error {
|
||||
t, cat, err := load(ctx, st, threadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !IsStaff(a.Member, a.Perms, cat) {
|
||||
return ErrNotStaff
|
||||
}
|
||||
me := mustID(a.userID())
|
||||
holder, err := st.Claim(ctx, t.ThreadID, me)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if holder == 0 {
|
||||
return ErrClosed
|
||||
}
|
||||
if holder != me {
|
||||
return UserError(fmt.Sprintf("<@%d> is already handling this ticket.", holder))
|
||||
}
|
||||
t.ClaimedBy = me
|
||||
updateIntro(d, t)
|
||||
_, err = d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
|
||||
Content: fmt.Sprintf("<@%d> is handling this ticket.", me),
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckCanClose runs before the close form is shown.
|
||||
func CheckCanClose(ctx context.Context, st Store, threadID string, a Actor) error {
|
||||
t, cat, err := load(ctx, st, threadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||
return ErrNotInvolved
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the ticket: a note in the thread, a summary with the
|
||||
// transcript in the log channel, the first message's buttons disabled, and
|
||||
// the thread locked and archived. The opener or staff can close.
|
||||
func Close(ctx context.Context, d Discord, st Store, threadID string, a Actor, reason string) error {
|
||||
t, cat, err := load(ctx, st, threadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
me := mustID(a.userID())
|
||||
if me != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||
return ErrNotInvolved
|
||||
}
|
||||
reason = clip(reason, maxReason)
|
||||
|
||||
// The transcript is taken before anything else changes in the thread.
|
||||
transcript, count, terr := Transcript(d, t)
|
||||
|
||||
closed, err := st.Close(ctx, t.ThreadID, me, reason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !closed {
|
||||
return ErrClosed // someone else closed it a moment ago
|
||||
}
|
||||
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, me, reason, time.Now()
|
||||
|
||||
note := fmt.Sprintf("🔒 Closed by <@%d>.", me)
|
||||
if reason != "" {
|
||||
note += " Reason: " + reason
|
||||
}
|
||||
if _, err := d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
|
||||
Content: note, AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
}); err != nil {
|
||||
log.Printf("tickets: posting the close note in ticket %d: %v", t.ID, err)
|
||||
}
|
||||
|
||||
if terr != nil {
|
||||
log.Printf("tickets: transcript of ticket %d: %v", t.ID, terr)
|
||||
}
|
||||
postLog(ctx, d, st, t, transcript, count)
|
||||
updateIntro(d, t)
|
||||
|
||||
yes := true
|
||||
if _, err := d.ChannelEditComplex(id(t.ThreadID), &discordgo.ChannelEdit{Locked: &yes, Archived: &yes}); err != nil {
|
||||
return fmt.Errorf("locking the thread: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// postLog sends the closed ticket's summary and transcript to the log
|
||||
// channel, if the guild has one.
|
||||
func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, transcript []byte, count int) {
|
||||
s, err := st.GetSettings(ctx, t.GuildID)
|
||||
if err != nil || s.LogChannelID == 0 {
|
||||
return
|
||||
}
|
||||
claimed := "Nobody"
|
||||
if t.ClaimedBy != 0 {
|
||||
claimed = "<@" + id(t.ClaimedBy) + ">"
|
||||
}
|
||||
reason := t.CloseReason
|
||||
if reason == "" {
|
||||
reason = "No reason given"
|
||||
}
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
|
||||
Description: "**" + clip(t.Subject, maxSubject) + "**",
|
||||
Color: 0x6b7280,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
||||
{Name: "Claimed by", Value: claimed, Inline: true},
|
||||
{Name: "Closed by", Value: "<@" + id(t.ClosedBy) + ">", Inline: true},
|
||||
{Name: "Reason", Value: clip(reason, 1024)},
|
||||
{Name: "Thread", Value: "<#" + id(t.ThreadID) + ">", Inline: true},
|
||||
{Name: "Messages", Value: fmt.Sprint(count), Inline: true},
|
||||
{Name: "Open for", Value: humanDuration(t.ClosedAt.Sub(t.CreatedAt)), Inline: true},
|
||||
},
|
||||
Timestamp: t.ClosedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
send := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}}
|
||||
if len(transcript) > 0 {
|
||||
send.Files = []*discordgo.File{{
|
||||
Name: fmt.Sprintf("ticket-%04d.txt", t.Number),
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
Reader: bytes.NewReader(transcript),
|
||||
}}
|
||||
}
|
||||
if _, err := d.ChannelMessageSendComplex(id(s.LogChannelID), send); err != nil {
|
||||
log.Printf("tickets: posting ticket %d to the log channel: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func humanDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return "-"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d min", int(d.Minutes()+0.5))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%.1f h", d.Hours())
|
||||
default:
|
||||
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
||||
}
|
||||
}
|
||||
|
||||
// AddMember brings someone into a ticket. The opener and staff can add.
|
||||
func AddMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
|
||||
t, cat, err := load(ctx, st, threadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||
return ErrNotInvolved
|
||||
}
|
||||
if err := d.ThreadMemberAdd(threadID, userID); err != nil {
|
||||
return fmt.Errorf("adding to the thread: %w", err)
|
||||
}
|
||||
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
|
||||
Content: fmt.Sprintf("<@%s> added <@%s> to this ticket.", a.userID(), userID),
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember takes someone out of a ticket. Only staff can, and never the
|
||||
// opener.
|
||||
func RemoveMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
|
||||
t, cat, err := load(ctx, st, threadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !IsStaff(a.Member, a.Perms, cat) {
|
||||
return ErrNotStaff
|
||||
}
|
||||
if mustID(userID) == t.OpenerID {
|
||||
return UserError("The person who opened the ticket can't be removed. Close the ticket instead.")
|
||||
}
|
||||
if err := d.ThreadMemberRemove(threadID, userID); err != nil {
|
||||
return fmt.Errorf("removing from the thread: %w", err)
|
||||
}
|
||||
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
|
||||
Content: fmt.Sprintf("<@%s> removed <@%s> from this ticket.", a.userID(), userID),
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ThreadDeleted closes the ticket of a thread someone deleted by hand, so it
|
||||
// stops counting towards the opener's limit.
|
||||
func ThreadDeleted(ctx context.Context, st Store, threadID string) {
|
||||
if _, err := st.Close(ctx, mustID(threadID), 0, "Thread deleted"); err != nil {
|
||||
log.Printf("tickets: closing the ticket of deleted thread %s: %v", threadID, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// CheckCanOpen runs before the form is shown, so a member who can't open a
|
||||
// ticket finds out before typing anything.
|
||||
func CheckCanOpen(ctx context.Context, st Store, guildID, userID, categoryID int64) (*ticketrepo.Settings, *ticketrepo.Category, error) {
|
||||
s, err := st.GetSettings(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !s.Configured || s.ChannelID == 0 {
|
||||
return nil, nil, ErrNotSetUp
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, nil, ErrDisabled
|
||||
}
|
||||
cat, err := st.GetCategory(ctx, guildID, categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if cat == nil {
|
||||
return nil, nil, ErrUnknownCategory
|
||||
}
|
||||
open, err := st.CountOpenByOpener(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if open >= s.MaxOpenPerUser {
|
||||
if s.MaxOpenPerUser == 1 {
|
||||
return nil, nil, UserError("You already have an open ticket. Close it before opening another one.")
|
||||
}
|
||||
return nil, nil, UserError(fmt.Sprintf("You already have %d open tickets, the most this server allows. Close one before opening another.", open))
|
||||
}
|
||||
return s, cat, nil
|
||||
}
|
||||
|
||||
type OpenRequest struct {
|
||||
GuildID int64
|
||||
CategoryID int64
|
||||
User *discordgo.User
|
||||
Subject string
|
||||
Details string
|
||||
}
|
||||
|
||||
// IntroComponents are the Claim and Close buttons under a ticket's first
|
||||
// message, disabled as the ticket moves on.
|
||||
func IntroComponents(claimed, closed bool) []discordgo.MessageComponent {
|
||||
claimLabel := "Claim"
|
||||
if claimed {
|
||||
claimLabel = "Claimed"
|
||||
}
|
||||
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{Label: claimLabel, Style: discordgo.SuccessButton, CustomID: idClaim, Disabled: claimed || closed, Emoji: &discordgo.ComponentEmoji{Name: "🙋"}},
|
||||
discordgo.Button{Label: "Close", Style: discordgo.DangerButton, CustomID: idClose, Disabled: closed, Emoji: &discordgo.ComponentEmoji{Name: "🔒"}},
|
||||
}}}
|
||||
}
|
||||
|
||||
// IntroEmbed describes the ticket at the top of its thread.
|
||||
func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
|
||||
desc := "**" + clip(t.Subject, maxSubject) + "**"
|
||||
if details != "" {
|
||||
desc += "\n\n" + clip(details, maxDetails)
|
||||
}
|
||||
claimed := "Nobody yet"
|
||||
if t.ClaimedBy != 0 {
|
||||
claimed = "<@" + id(t.ClaimedBy) + ">"
|
||||
}
|
||||
e := &discordgo.MessageEmbed{
|
||||
Title: fmt.Sprintf("Ticket #%04d · %s", t.Number, clip(t.CategoryName, 60)),
|
||||
Description: desc,
|
||||
Color: accent,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
||||
{Name: "Claimed by", Value: claimed, Inline: true},
|
||||
},
|
||||
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
|
||||
}
|
||||
if t.Status == ticketrepo.StatusClosed {
|
||||
e.Color = 0x6b7280
|
||||
e.Footer.Text = "This ticket is closed."
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Open creates the ticket: a private thread in the ticket channel with the
|
||||
// opener added, a first message that mentions the category's staff role
|
||||
// (which adds that role's members to the thread), and the database row.
|
||||
func Open(ctx context.Context, d Discord, st Store, req OpenRequest) (*discordgo.Channel, error) {
|
||||
s, cat, err := CheckCanOpen(ctx, st, req.GuildID, mustID(req.User.ID), req.CategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
number, err := st.NextNumber(ctx, req.GuildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := req.User.GlobalName
|
||||
if name == "" {
|
||||
name = req.User.Username
|
||||
}
|
||||
thread, err := d.ThreadStartComplex(id(s.ChannelID), &discordgo.ThreadStart{
|
||||
Name: ThreadName(cat.Name, number, name),
|
||||
Type: discordgo.ChannelTypeGuildPrivateThread,
|
||||
AutoArchiveDuration: autoArchiveWeek,
|
||||
Invitable: false, // only staff can add people
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating the thread: %w", err)
|
||||
}
|
||||
// From here on a failure leaves an empty thread behind unless it's removed.
|
||||
fail := func(err error) (*discordgo.Channel, error) {
|
||||
if _, derr := d.ChannelDelete(thread.ID); derr != nil {
|
||||
log.Printf("tickets: removing thread %s after a failed open: %v", thread.ID, derr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := d.ThreadMemberAdd(thread.ID, req.User.ID); err != nil {
|
||||
return fail(fmt.Errorf("adding the opener: %w", err))
|
||||
}
|
||||
|
||||
t := &ticketrepo.Ticket{
|
||||
GuildID: req.GuildID,
|
||||
Number: number,
|
||||
CategoryID: cat.ID,
|
||||
CategoryName: cat.Name,
|
||||
OpenerID: mustID(req.User.ID),
|
||||
Subject: clip(req.Subject, maxSubject),
|
||||
Status: ticketrepo.StatusOpen,
|
||||
}
|
||||
content := "<@" + req.User.ID + ">"
|
||||
mentions := &discordgo.MessageAllowedMentions{Users: []string{req.User.ID}}
|
||||
if cat.StaffRoleID != 0 {
|
||||
content += " <@&" + id(cat.StaffRoleID) + ">"
|
||||
mentions.Roles = []string{id(cat.StaffRoleID)}
|
||||
}
|
||||
intro, err := d.ChannelMessageSendComplex(thread.ID, &discordgo.MessageSend{
|
||||
Content: content,
|
||||
Embeds: []*discordgo.MessageEmbed{IntroEmbed(t, req.Details)},
|
||||
Components: IntroComponents(false, false),
|
||||
AllowedMentions: mentions,
|
||||
})
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("posting the first message: %w", err))
|
||||
}
|
||||
|
||||
t.ThreadID = mustID(thread.ID)
|
||||
t.IntroMessage = mustID(intro.ID)
|
||||
if _, err := st.CreateTicket(ctx, *t); err != nil {
|
||||
return fail(fmt.Errorf("saving the ticket: %w", err))
|
||||
}
|
||||
return thread, nil
|
||||
}
|
||||
|
||||
// mustID parses a snowflake that came from Discord; those are always valid.
|
||||
func mustID(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Custom IDs. Everything starts with "tickets:" so the interaction router can
|
||||
// hand it to this package.
|
||||
const (
|
||||
idOpenPrefix = "tickets:open:" // panel button, + category ID
|
||||
idSelect = "tickets:select" // panel dropdown (many categories)
|
||||
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
|
||||
idClaim = "tickets:claim"
|
||||
idClose = "tickets:close"
|
||||
idCloseForm = "tickets:closeform"
|
||||
|
||||
fieldSubject = "subject"
|
||||
fieldDetails = "details"
|
||||
fieldReason = "reason"
|
||||
)
|
||||
|
||||
var customEmojiRe = regexp.MustCompile(`^<(a?):(\w+):(\d+)>$`)
|
||||
|
||||
// parseEmoji accepts a Unicode emoji or a custom one written as <:name:id>.
|
||||
func parseEmoji(s string) *discordgo.ComponentEmoji {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if m := customEmojiRe.FindStringSubmatch(s); m != nil {
|
||||
return &discordgo.ComponentEmoji{Name: m[2], ID: m[3], Animated: m[1] == "a"}
|
||||
}
|
||||
return &discordgo.ComponentEmoji{Name: s}
|
||||
}
|
||||
|
||||
// BuildPanel is the message members open tickets from: an embed with the
|
||||
// guild's text and one button per category, or a dropdown once there are
|
||||
// more categories than fit comfortably in a row.
|
||||
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: clip(s.PanelTitle, 256),
|
||||
Description: clip(s.PanelDescription, 4000),
|
||||
Color: accent,
|
||||
}
|
||||
if len(cats) > maxCategories {
|
||||
cats = cats[:maxCategories]
|
||||
}
|
||||
|
||||
if len(cats) <= maxPanelButtons {
|
||||
var buttons []discordgo.MessageComponent
|
||||
for _, c := range cats {
|
||||
buttons = append(buttons, discordgo.Button{
|
||||
Label: clip(c.Name, 80),
|
||||
Style: discordgo.PrimaryButton,
|
||||
CustomID: idOpenPrefix + id(c.ID),
|
||||
Emoji: parseEmoji(c.Emoji),
|
||||
})
|
||||
}
|
||||
// With few categories their descriptions go in the embed, since a
|
||||
// button has no room for one.
|
||||
for _, c := range cats {
|
||||
if c.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
|
||||
}
|
||||
}
|
||||
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: buttons}}
|
||||
}
|
||||
|
||||
var opts []discordgo.SelectMenuOption
|
||||
for _, c := range cats {
|
||||
opts = append(opts, discordgo.SelectMenuOption{
|
||||
Label: clip(c.Name, 100),
|
||||
Value: id(c.ID),
|
||||
Description: clip(c.Description, 100),
|
||||
Emoji: parseEmoji(c.Emoji),
|
||||
})
|
||||
}
|
||||
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
|
||||
}}}
|
||||
}
|
||||
|
||||
// PostPanel posts the panel in the ticket channel, or updates the one it
|
||||
// posted before, and returns where it ended up.
|
||||
func PostPanel(ctx context.Context, d Discord, st Store, guildID int64) (*discordgo.Message, error) {
|
||||
s, err := st.GetSettings(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.ChannelID == 0 {
|
||||
return nil, ErrNotSetUp
|
||||
}
|
||||
cats, err := st.ListCategories(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
return nil, ErrNoCategories
|
||||
}
|
||||
embed, comps := BuildPanel(s, cats)
|
||||
|
||||
if s.PanelMessageID != 0 {
|
||||
embeds := []*discordgo.MessageEmbed{embed}
|
||||
msg, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: id(s.ChannelID),
|
||||
ID: id(s.PanelMessageID),
|
||||
Embeds: &embeds,
|
||||
Components: &comps,
|
||||
})
|
||||
if err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
// The old panel was deleted or the channel changed: post a new one.
|
||||
}
|
||||
|
||||
msg, err := d.ChannelMessageSendComplex(id(s.ChannelID), &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
Components: comps,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("posting the panel: %w", err)
|
||||
}
|
||||
if mid, err := strconv.ParseInt(msg.ID, 10, 64); err == nil {
|
||||
if err := st.SetPanelMessage(ctx, guildID, mid); err != nil {
|
||||
return msg, fmt.Errorf("saving the panel message: %w", err)
|
||||
}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// OpenForm is the short form shown after a member picks a category.
|
||||
func OpenForm(cat *ticketrepo.Category) *discordgo.InteractionResponse {
|
||||
return &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: idOpenFormPrefix + id(cat.ID),
|
||||
Title: clip("Open a ticket: "+cat.Name, 45),
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||
CustomID: fieldSubject,
|
||||
Label: "What do you need help with?",
|
||||
Style: discordgo.TextInputShort,
|
||||
Placeholder: "A short summary",
|
||||
Required: true,
|
||||
MinLength: 3,
|
||||
MaxLength: maxSubject,
|
||||
}}},
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||
CustomID: fieldDetails,
|
||||
Label: "Details",
|
||||
Style: discordgo.TextInputParagraph,
|
||||
Placeholder: "Anything the staff should know (optional)",
|
||||
Required: false,
|
||||
MaxLength: maxDetails,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CloseForm asks for an optional reason before closing.
|
||||
func CloseForm() *discordgo.InteractionResponse {
|
||||
return &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: idCloseForm,
|
||||
Title: "Close this ticket",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||
CustomID: fieldReason,
|
||||
Label: "Reason (optional)",
|
||||
Style: discordgo.TextInputParagraph,
|
||||
Required: false,
|
||||
MaxLength: maxReason,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package tickets is the support ticket system: a panel message with one
|
||||
// button per category, a short form, and a private thread per ticket that
|
||||
// staff can claim and anyone involved can close. Closing locks and archives
|
||||
// the thread and posts a transcript to the log channel.
|
||||
//
|
||||
// The Discord and database calls go through small interfaces so the flows
|
||||
// can be tested without either; the slash command and button handlers in
|
||||
// internal/commands/tickets only translate interactions into these calls.
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Store is the subset of ticketrepo.Repo the flows use.
|
||||
type Store interface {
|
||||
GetSettings(ctx context.Context, guildID int64) (*ticketrepo.Settings, error)
|
||||
SetPanelMessage(ctx context.Context, guildID, messageID int64) error
|
||||
NextNumber(ctx context.Context, guildID int64) (int, error)
|
||||
ListCategories(ctx context.Context, guildID int64) ([]ticketrepo.Category, error)
|
||||
GetCategory(ctx context.Context, guildID, id int64) (*ticketrepo.Category, error)
|
||||
CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error)
|
||||
CreateTicket(ctx context.Context, t ticketrepo.Ticket) (int64, error)
|
||||
GetTicketByThread(ctx context.Context, threadID int64) (*ticketrepo.Ticket, error)
|
||||
Claim(ctx context.Context, threadID, staffID int64) (int64, error)
|
||||
Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error)
|
||||
ListUnprocessedCommands(ctx context.Context) ([]ticketrepo.Command, error)
|
||||
MarkCommandProcessed(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// Discord is the subset of *discordgo.Session the flows use.
|
||||
type Discord interface {
|
||||
ThreadStartComplex(channelID string, data *discordgo.ThreadStart, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||
ThreadMemberAdd(threadID, memberID string, options ...discordgo.RequestOption) error
|
||||
ThreadMemberRemove(threadID, memberID string, options ...discordgo.RequestOption) error
|
||||
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||
ChannelMessageEditComplex(m *discordgo.MessageEdit, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||
ChannelMessage(channelID, messageID string, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||
ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...discordgo.RequestOption) ([]*discordgo.Message, error)
|
||||
ChannelEditComplex(channelID string, data *discordgo.ChannelEdit, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||
ChannelDelete(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||
}
|
||||
|
||||
// UserError is a problem worth showing the person who clicked, as written.
|
||||
// Anything else is logged and shown as a generic failure.
|
||||
type UserError string
|
||||
|
||||
func (e UserError) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
ErrNotSetUp = UserError("Tickets aren't set up on this server yet. An admin can run `/tickets setup`.")
|
||||
ErrDisabled = UserError("Tickets are turned off on this server right now.")
|
||||
ErrNoCategories = UserError("There are no ticket categories yet. Add one with `/tickets category add` or on the dashboard.")
|
||||
ErrUnknownCategory = UserError("That ticket category no longer exists. Ask an admin to repost the ticket panel.")
|
||||
ErrNotATicket = UserError("This isn't a ticket thread.")
|
||||
ErrClosed = UserError("This ticket is already closed.")
|
||||
ErrNotStaff = UserError("Only the staff handling this kind of ticket can do that.")
|
||||
ErrNotInvolved = UserError("Only the person who opened this ticket or the staff can do that.")
|
||||
)
|
||||
|
||||
var (
|
||||
defaultStore Store
|
||||
accent = 0x2b7bfa
|
||||
)
|
||||
|
||||
func Init(s Store) { defaultStore = s }
|
||||
|
||||
func Default() Store { return defaultStore }
|
||||
|
||||
// Limits Discord puts on what the bot sends.
|
||||
const (
|
||||
maxThreadName = 100
|
||||
maxSubject = 100
|
||||
maxDetails = 1000
|
||||
maxReason = 300
|
||||
maxPanelButtons = 5 // more categories than this switch the panel to a dropdown
|
||||
maxCategories = 25
|
||||
autoArchiveWeek = 10080
|
||||
)
|
||||
|
||||
func id(v int64) string { return fmt.Sprintf("%d", v) }
|
||||
|
||||
// IsStaff reports whether a member may claim, close and manage tickets of
|
||||
// this category: anyone with the category's staff role, or with Manage
|
||||
// Threads / Manage Server / Administrator (perms is the member's permission
|
||||
// set in the ticket's channel, as Discord sends it with the interaction).
|
||||
func IsStaff(member *discordgo.Member, perms int64, cat *ticketrepo.Category) bool {
|
||||
const staffPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild | discordgo.PermissionManageThreads
|
||||
if perms&staffPerms != 0 {
|
||||
return true
|
||||
}
|
||||
if member == nil || cat == nil || cat.StaffRoleID == 0 {
|
||||
return false
|
||||
}
|
||||
want := id(cat.StaffRoleID)
|
||||
for _, r := range member.Roles {
|
||||
if r == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slug turns a name into lowercase ASCII words joined by dashes, for thread
|
||||
// names that read well in the channel's thread list.
|
||||
func slug(s string, max int) string {
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
|
||||
if dash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
default:
|
||||
dash = true
|
||||
}
|
||||
if b.Len() >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if len(out) > max {
|
||||
out = out[:max]
|
||||
}
|
||||
return strings.Trim(out, "-")
|
||||
}
|
||||
|
||||
// ThreadName is "support-0042-ana": the category, the ticket number and who
|
||||
// opened it.
|
||||
func ThreadName(category string, number int, opener string) string {
|
||||
cat := slug(category, 30)
|
||||
if cat == "" {
|
||||
cat = "ticket"
|
||||
}
|
||||
name := fmt.Sprintf("%s-%04d", cat, number)
|
||||
if u := slug(opener, 32); u != "" {
|
||||
name += "-" + u
|
||||
}
|
||||
if len(name) > maxThreadName {
|
||||
name = name[:maxThreadName]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Permissions the bot needs, by channel.
|
||||
const (
|
||||
NeedTicketChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
|
||||
discordgo.PermissionCreatePrivateThreads | discordgo.PermissionSendMessagesInThreads | discordgo.PermissionManageThreads
|
||||
NeedLogChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
|
||||
discordgo.PermissionAttachFiles
|
||||
)
|
||||
|
||||
var permNames = []struct {
|
||||
bit int64
|
||||
name string
|
||||
}{
|
||||
{discordgo.PermissionViewChannel, "View Channel"},
|
||||
{discordgo.PermissionSendMessages, "Send Messages"},
|
||||
{discordgo.PermissionEmbedLinks, "Embed Links"},
|
||||
{discordgo.PermissionAttachFiles, "Attach Files"},
|
||||
{discordgo.PermissionCreatePrivateThreads, "Create Private Threads"},
|
||||
{discordgo.PermissionSendMessagesInThreads, "Send Messages in Threads"},
|
||||
{discordgo.PermissionManageThreads, "Manage Threads"},
|
||||
}
|
||||
|
||||
// MissingPerms names the permissions in need that have lacks. Administrator
|
||||
// counts as having everything.
|
||||
func MissingPerms(have, need int64) []string {
|
||||
if have&discordgo.PermissionAdministrator != 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range permNames {
|
||||
if need&p.bit != 0 && have&p.bit == 0 {
|
||||
out = append(out, p.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
const (
|
||||
guild = int64(1)
|
||||
channel = int64(10)
|
||||
logCh = int64(20)
|
||||
staffRl = int64(30)
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func member(userID string, roles ...string) *discordgo.Member {
|
||||
return &discordgo.Member{User: &discordgo.User{ID: userID, Username: "user" + userID}, Roles: roles}
|
||||
}
|
||||
|
||||
func TestThreadName(t *testing.T) {
|
||||
for _, c := range []struct{ cat, user, want string }{
|
||||
{"Support", "Ana", "support-0042-ana"},
|
||||
{"Ban Appeal!", "João Silva", "ban-appeal-0042-jo-o-silva"},
|
||||
{"🎫", "〈ぬべ〉", "ticket-0042"},
|
||||
{strings.Repeat("x", 80), strings.Repeat("y", 80), strings.Repeat("x", 30) + "-0042-" + strings.Repeat("y", 32)},
|
||||
} {
|
||||
if got := ThreadName(c.cat, 42, c.user); got != c.want {
|
||||
t.Errorf("ThreadName(%q, %q) = %q, want %q", c.cat, c.user, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStaff(t *testing.T) {
|
||||
cat := &ticketrepo.Category{StaffRoleID: staffRl}
|
||||
if !IsStaff(member("1", "30"), 0, cat) {
|
||||
t.Error("the staff role should count")
|
||||
}
|
||||
if IsStaff(member("1", "31"), 0, cat) {
|
||||
t.Error("another role must not count")
|
||||
}
|
||||
for _, p := range []int64{discordgo.PermissionManageThreads, discordgo.PermissionManageGuild, discordgo.PermissionAdministrator} {
|
||||
if !IsStaff(member("1"), p, nil) {
|
||||
t.Errorf("permission %d should count, even with the category deleted", p)
|
||||
}
|
||||
}
|
||||
if IsStaff(member("1"), discordgo.PermissionSendMessages, &ticketrepo.Category{}) {
|
||||
t.Error("a category without a staff role must not make everyone staff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPerms(t *testing.T) {
|
||||
got := MissingPerms(discordgo.PermissionViewChannel|discordgo.PermissionSendMessages, NeedLogChannel)
|
||||
if strings.Join(got, ",") != "Embed Links,Attach Files" {
|
||||
t.Errorf("missing = %v", got)
|
||||
}
|
||||
if MissingPerms(discordgo.PermissionAdministrator, NeedTicketChannel) != nil {
|
||||
t.Error("Administrator has everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPanel(t *testing.T) {
|
||||
s := &ticketrepo.Settings{PanelTitle: "Help", PanelDescription: "Pick one"}
|
||||
cats := []ticketrepo.Category{
|
||||
{ID: 1, Name: "Support", Emoji: "🛟", Description: "General help"},
|
||||
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
|
||||
}
|
||||
embed, comps := BuildPanel(s, cats)
|
||||
row := comps[0].(discordgo.ActionsRow)
|
||||
b1, b2 := row.Components[0].(discordgo.Button), row.Components[1].(discordgo.Button)
|
||||
if embed.Title != "Help" || b1.CustomID != "tickets:open:1" || b1.Emoji.Name != "🛟" {
|
||||
t.Errorf("first button wrong: %+v", b1)
|
||||
}
|
||||
if b2.Emoji.ID != "123" || b2.Emoji.Name != "party" || !b2.Emoji.Animated {
|
||||
t.Errorf("custom emoji not parsed: %+v", b2.Emoji)
|
||||
}
|
||||
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
|
||||
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
|
||||
}
|
||||
|
||||
var many []ticketrepo.Category
|
||||
for i := 1; i <= 7; i++ {
|
||||
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
|
||||
}
|
||||
_, comps = BuildPanel(s, many)
|
||||
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
|
||||
if menu.CustomID != "tickets:select" || len(menu.Options) != 7 || menu.Options[6].Value != "7" {
|
||||
t.Errorf("more than 5 categories should use a dropdown: %+v", menu)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPanel(t *testing.T) {
|
||||
st, d := newStore(), newDiscord()
|
||||
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNotSetUp) {
|
||||
t.Errorf("err = %v, want not set up", err)
|
||||
}
|
||||
st.configure(guild, channel, logCh)
|
||||
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNoCategories) {
|
||||
t.Errorf("err = %v, want no categories", err)
|
||||
}
|
||||
st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support"})
|
||||
|
||||
msg, err := PostPanel(ctx, d, st, guild)
|
||||
if err != nil || len(d.sent["10"]) != 1 || st.settings[guild].PanelMessageID != mustID(msg.ID) {
|
||||
t.Fatalf("first post: err=%v sent=%d saved=%d", err, len(d.sent["10"]), st.settings[guild].PanelMessageID)
|
||||
}
|
||||
|
||||
// A second post updates the same message.
|
||||
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.edits) != 1 || len(d.sent["10"]) != 1 {
|
||||
t.Errorf("repost should edit: err=%v edits=%d sent=%d", err, len(d.edits), len(d.sent["10"]))
|
||||
}
|
||||
|
||||
// If the old panel is gone, a new one is posted and remembered.
|
||||
d.failEdit = errors.New("unknown message")
|
||||
old := st.settings[guild].PanelMessageID
|
||||
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.sent["10"]) != 2 || st.settings[guild].PanelMessageID == old {
|
||||
t.Errorf("deleted panel should be replaced: err=%v sent=%d", err, len(d.sent["10"]))
|
||||
}
|
||||
}
|
||||
|
||||
func setup() (*fakeStore, *fakeDiscord, ticketrepo.Category) {
|
||||
st, d := newStore(), newDiscord()
|
||||
st.configure(guild, channel, logCh)
|
||||
cat := st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support", StaffRoleID: staffRl})
|
||||
return st, d, cat
|
||||
}
|
||||
|
||||
func open(t *testing.T, st *fakeStore, d *fakeDiscord, cat ticketrepo.Category, userID string) *discordgo.Channel {
|
||||
t.Helper()
|
||||
th, err := Open(ctx, d, st, OpenRequest{
|
||||
GuildID: guild, CategoryID: cat.ID,
|
||||
User: &discordgo.User{ID: userID, Username: "ana", GlobalName: "Ana"},
|
||||
Subject: "Can't join voice", Details: "It says no permission",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return th
|
||||
}
|
||||
|
||||
func TestCheckCanOpen(t *testing.T) {
|
||||
st := newStore()
|
||||
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrNotSetUp) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
st.configure(guild, channel, logCh)
|
||||
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 99); !errors.Is(err, ErrUnknownCategory) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
st.settings[guild].Enabled = false
|
||||
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
th := open(t, st, d, cat, "500")
|
||||
|
||||
start := d.threads[0]
|
||||
if start.Type != discordgo.ChannelTypeGuildPrivateThread || start.Invitable || start.Name != "support-0001-ana" {
|
||||
t.Errorf("thread wrong: %+v", start)
|
||||
}
|
||||
if strings.Join(d.members[th.ID], ",") != "500" {
|
||||
t.Errorf("opener not added: %v", d.members[th.ID])
|
||||
}
|
||||
intro := d.sent[th.ID][0]
|
||||
if intro.Content != "<@500> <@&30>" || strings.Join(intro.AllowedMentions.Roles, ",") != "30" {
|
||||
t.Errorf("the staff role must be mentioned so its members join: %+v", intro)
|
||||
}
|
||||
if !strings.Contains(intro.Embeds[0].Description, "It says no permission") || intro.Embeds[0].Title != "Ticket #0001 · Support" {
|
||||
t.Errorf("intro embed wrong: %+v", intro.Embeds[0])
|
||||
}
|
||||
tk := st.tickets[0]
|
||||
if tk.Number != 1 || tk.ThreadID != mustID(th.ID) || tk.OpenerID != 500 || tk.IntroMessage == 0 || tk.Subject != "Can't join voice" {
|
||||
t.Errorf("ticket row wrong: %+v", tk)
|
||||
}
|
||||
|
||||
// The limit applies to the second ticket.
|
||||
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "again"})
|
||||
var ue UserError
|
||||
if !errors.As(err, &ue) || !strings.Contains(string(ue), "already have an open ticket") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCleansUpOnFailure(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
d.failSend = errors.New("missing access")
|
||||
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "help"})
|
||||
if err == nil || len(d.deleted) != 1 || len(st.tickets) != 0 {
|
||||
t.Errorf("a half-made ticket must be removed: err=%v deleted=%v tickets=%d", err, d.deleted, len(st.tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaim(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
th := open(t, st, d, cat, "500")
|
||||
|
||||
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("600")}); !errors.Is(err, ErrNotStaff) {
|
||||
t.Errorf("non-staff claim: %v", err)
|
||||
}
|
||||
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("700", "30")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.tickets[0].ClaimedBy != 700 {
|
||||
t.Errorf("claim not saved")
|
||||
}
|
||||
if len(d.edits) != 1 {
|
||||
t.Fatalf("the first message should be updated")
|
||||
}
|
||||
e := (*d.edits[0].Embeds)[0]
|
||||
if e.Fields[1].Value != "<@700>" || !strings.Contains(e.Description, "It says no permission") {
|
||||
t.Errorf("updated intro lost details or claim: %+v", e)
|
||||
}
|
||||
if b := (*d.edits[0].Components)[0].(discordgo.ActionsRow).Components[0].(discordgo.Button); !b.Disabled || b.Label != "Claimed" {
|
||||
t.Errorf("claim button should be disabled: %+v", b)
|
||||
}
|
||||
|
||||
err := Claim(ctx, d, st, th.ID, Actor{Member: member("701"), Perms: discordgo.PermissionManageThreads})
|
||||
if err == nil || !strings.Contains(err.Error(), "<@700> is already handling") {
|
||||
t.Errorf("second claim: %v", err)
|
||||
}
|
||||
if err := Claim(ctx, d, st, "999", Actor{Member: member("700", "30")}); !errors.Is(err, ErrNotATicket) {
|
||||
t.Errorf("claim outside a ticket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
th := open(t, st, d, cat, "500")
|
||||
ts := time.Date(2026, 10, 20, 18, 0, 0, 0, time.UTC)
|
||||
d.history[th.ID] = []*discordgo.Message{ // newest first
|
||||
{ID: "3", Content: "thanks, works now", Author: &discordgo.User{Username: "ana"}, Timestamp: ts.Add(2 * time.Minute)},
|
||||
{ID: "2", Content: "try rejoining", Author: &discordgo.User{Username: "mod", GlobalName: "Mod"}, Timestamp: ts.Add(time.Minute),
|
||||
Attachments: []*discordgo.MessageAttachment{{Filename: "steps.png", URL: "https://cdn/x.png"}}},
|
||||
{ID: "1", Author: &discordgo.User{Username: "velox", Bot: true}, Embeds: []*discordgo.MessageEmbed{{Title: "Ticket #0001 · Support"}}, Timestamp: ts},
|
||||
}
|
||||
|
||||
if err := Close(ctx, d, st, th.ID, Actor{Member: member("600")}, ""); !errors.Is(err, ErrNotInvolved) {
|
||||
t.Errorf("an outsider must not close: %v", err)
|
||||
}
|
||||
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, "Solved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tk := st.tickets[0]
|
||||
if tk.Status != ticketrepo.StatusClosed || tk.ClosedBy != 500 || tk.CloseReason != "Solved" {
|
||||
t.Errorf("ticket not closed: %+v", tk)
|
||||
}
|
||||
if e := d.chEdits[th.ID]; e == nil || !*e.Locked || !*e.Archived {
|
||||
t.Errorf("thread should be locked and archived: %+v", e)
|
||||
}
|
||||
logged := d.sent["20"]
|
||||
if len(logged) != 1 || !strings.Contains(logged[0].Embeds[0].Title, "Ticket #0001 closed") {
|
||||
t.Fatalf("log channel post missing: %+v", logged)
|
||||
}
|
||||
transcript := d.files["ticket-0001.txt"]
|
||||
i1, i2, i3 := strings.Index(transcript, "[embed] Ticket #0001"), strings.Index(transcript, "try rejoining"), strings.Index(transcript, "thanks, works now")
|
||||
if i1 < 0 || i2 < i1 || i3 < i2 {
|
||||
t.Errorf("transcript must be oldest first:\n%s", transcript)
|
||||
}
|
||||
if !strings.Contains(transcript, "Mod (mod)") || !strings.Contains(transcript, "[file] steps.png https://cdn/x.png") || !strings.Contains(transcript, "velox [bot]") {
|
||||
t.Errorf("transcript details missing:\n%s", transcript)
|
||||
}
|
||||
|
||||
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, ""); !errors.Is(err, ErrClosed) {
|
||||
t.Errorf("closing twice: %v", err)
|
||||
}
|
||||
// A closed ticket no longer counts towards the limit.
|
||||
open(t, st, d, cat, "500")
|
||||
}
|
||||
|
||||
func TestTranscriptPages(t *testing.T) {
|
||||
d := newDiscord()
|
||||
var hist []*discordgo.Message
|
||||
for i := 250; i >= 1; i-- {
|
||||
hist = append(hist, &discordgo.Message{ID: fmt.Sprint(i), Content: fmt.Sprint("msg ", i), Author: &discordgo.User{Username: "u"}})
|
||||
}
|
||||
d.history["5"] = hist
|
||||
out, n, err := Transcript(d, &ticketrepo.Ticket{ThreadID: 5, Number: 1})
|
||||
if err != nil || n != 250 {
|
||||
t.Fatalf("n=%d err=%v", n, err)
|
||||
}
|
||||
if strings.Index(string(out), "msg 1\n") > strings.Index(string(out), "msg 250\n") {
|
||||
t.Error("pages must be stitched oldest first")
|
||||
}
|
||||
|
||||
d.failFetch = errors.New("boom")
|
||||
out, _, err = Transcript(d, &ticketrepo.Ticket{ThreadID: 5})
|
||||
if err == nil || !strings.Contains(string(out), "only part of the thread") {
|
||||
t.Error("a failed read should still produce a transcript that says so")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAndRemoveMember(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
th := open(t, st, d, cat, "500")
|
||||
|
||||
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("600")}, "601"); !errors.Is(err, ErrNotInvolved) {
|
||||
t.Errorf("outsider add: %v", err)
|
||||
}
|
||||
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); err != nil {
|
||||
t.Errorf("the opener can add: %v", err)
|
||||
}
|
||||
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); !errors.Is(err, ErrNotStaff) {
|
||||
t.Errorf("only staff remove: %v", err)
|
||||
}
|
||||
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "500"); err == nil {
|
||||
t.Error("the opener can't be removed")
|
||||
}
|
||||
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "601"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if strings.Join(d.members[th.ID], ",") != "500" {
|
||||
t.Errorf("members = %v", d.members[th.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadDeletedFreesTheLimit(t *testing.T) {
|
||||
st, d, cat := setup()
|
||||
th := open(t, st, d, cat, "500")
|
||||
ThreadDeleted(ctx, st, th.ID)
|
||||
if st.tickets[0].Status != ticketrepo.StatusClosed || st.tickets[0].CloseReason != "Thread deleted" {
|
||||
t.Errorf("ticket = %+v", st.tickets[0])
|
||||
}
|
||||
open(t, st, d, cat, "500")
|
||||
}
|
||||
|
||||
func TestProcessCommands(t *testing.T) {
|
||||
st, d, _ := setup()
|
||||
st.cmds = []ticketrepo.Command{{ID: 1, GuildID: guild, CommandType: "post_panel"}, {ID: 2, GuildID: 99, CommandType: "post_panel"}, {ID: 3, CommandType: "nope"}}
|
||||
ProcessCommands(ctx, d, st)
|
||||
if len(d.sent["10"]) != 1 {
|
||||
t.Errorf("panel not posted")
|
||||
}
|
||||
if len(st.done) != 3 {
|
||||
t.Errorf("every command must be marked done, even failed ones: %v", st.done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// maxTranscriptMessages bounds how far back a transcript reads: 50 pages of
|
||||
// 100, far beyond any normal ticket.
|
||||
const maxTranscriptMessages = 5000
|
||||
|
||||
// Transcript renders the whole thread as plain text, oldest first, and
|
||||
// reports how many messages it holds. Attachments and embeds are listed so
|
||||
// the log keeps their links and titles.
|
||||
func Transcript(d Discord, t *ticketrepo.Ticket) ([]byte, int, error) {
|
||||
var msgs []*discordgo.Message
|
||||
before := ""
|
||||
var fetchErr error
|
||||
for len(msgs) < maxTranscriptMessages {
|
||||
page, err := d.ChannelMessages(id(t.ThreadID), 100, before, "", "")
|
||||
if err != nil {
|
||||
fetchErr = err
|
||||
break
|
||||
}
|
||||
msgs = append(msgs, page...)
|
||||
if len(page) < 100 {
|
||||
break
|
||||
}
|
||||
before = page[len(page)-1].ID
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Ticket #%04d · %s\n", t.Number, t.CategoryName)
|
||||
fmt.Fprintf(&b, "Subject: %s\n", t.Subject)
|
||||
fmt.Fprintf(&b, "Opened by user %d on %s\n", t.OpenerID, t.CreatedAt.UTC().Format("2006-01-02 15:04 MST"))
|
||||
if fetchErr != nil {
|
||||
fmt.Fprintf(&b, "Note: only part of the thread could be read (%v)\n", fetchErr)
|
||||
}
|
||||
b.WriteString(strings.Repeat("-", 60) + "\n\n")
|
||||
|
||||
// Discord returns newest first.
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
writeMessage(&b, msgs[i])
|
||||
}
|
||||
return []byte(b.String()), len(msgs), fetchErr
|
||||
}
|
||||
|
||||
func writeMessage(b *strings.Builder, m *discordgo.Message) {
|
||||
name := "unknown"
|
||||
if m.Author != nil {
|
||||
name = m.Author.Username
|
||||
if m.Author.GlobalName != "" {
|
||||
name = m.Author.GlobalName + " (" + m.Author.Username + ")"
|
||||
}
|
||||
if m.Author.Bot {
|
||||
name += " [bot]"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(b, "[%s] %s\n", m.Timestamp.UTC().Format("2006-01-02 15:04"), name)
|
||||
|
||||
if content := strings.TrimSpace(m.Content); content != "" {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
for _, e := range m.Embeds {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
title := e.Title
|
||||
if title == "" {
|
||||
title = clip(e.Description, 120)
|
||||
}
|
||||
if title != "" {
|
||||
b.WriteString(" [embed] " + strings.ReplaceAll(title, "\n", " ") + "\n")
|
||||
}
|
||||
}
|
||||
for _, a := range m.Attachments {
|
||||
if a != nil {
|
||||
b.WriteString(" [file] " + a.Filename + " " + a.URL + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"syscall"
|
||||
"velox-bot/internal/bot"
|
||||
"velox-bot/internal/commands"
|
||||
cmdtickets "velox-bot/internal/commands/tickets"
|
||||
"velox-bot/internal/config"
|
||||
"velox-bot/internal/db"
|
||||
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"velox-bot/internal/db/repos/rpsrepo"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/repos/settingsrepo"
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
"velox-bot/internal/db/repos/twitchrepo"
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
"velox-bot/internal/db/repos/welcomerepo"
|
||||
@@ -59,6 +61,7 @@ func main() {
|
||||
welcomeRepo := welcomerepo.NewRepo(db)
|
||||
twitchRepo := twitchrepo.NewRepo(db)
|
||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||
cmdtickets.Init(ticketrepo.NewRepo(db))
|
||||
levelService := level.New(levelRepo, settingsRepo)
|
||||
levelSettingsService := levelsettings.New(settingsRepo)
|
||||
logSettingsService := logsettings.New(settingsRepo)
|
||||
@@ -73,7 +76,7 @@ func main() {
|
||||
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, musicRepo)
|
||||
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
|
||||
|
||||
+59
-3
@@ -150,7 +150,9 @@ CREATE TABLE IF NOT EXISTS music_now_playing (
|
||||
duration_seconds INT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
volume INT NOT NULL DEFAULT 100
|
||||
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 (
|
||||
@@ -160,15 +162,69 @@ CREATE TABLE IF NOT EXISTS music_queue (
|
||||
track_title TEXT NOT NULL,
|
||||
track_url TEXT NOT NULL,
|
||||
requested_by BIGINT NOT NULL,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||
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')),
|
||||
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
|
||||
);
|
||||
|
||||
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
|
||||
CREATE TABLE IF NOT EXISTS ticket_settings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
channel_id BIGINT,
|
||||
log_channel_id BIGINT,
|
||||
panel_message_id BIGINT,
|
||||
panel_title TEXT NOT NULL DEFAULT 'Need help?',
|
||||
panel_description TEXT NOT NULL DEFAULT 'Pick a category below to open a private ticket with the staff team.',
|
||||
max_open_per_user INT NOT NULL DEFAULT 1 CHECK (max_open_per_user BETWEEN 1 AND 10),
|
||||
next_number INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
|
||||
emoji TEXT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
staff_role_id BIGINT,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
number INT NOT NULL,
|
||||
category_id BIGINT REFERENCES ticket_categories (id) ON DELETE SET NULL,
|
||||
category_name TEXT NOT NULL,
|
||||
thread_id BIGINT NOT NULL UNIQUE,
|
||||
intro_message_id BIGINT,
|
||||
opener_id BIGINT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
|
||||
claimed_by BIGINT,
|
||||
closed_by BIGINT,
|
||||
close_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
UNIQUE (guild_id, number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_commands (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
command_type TEXT NOT NULL CHECK (command_type IN ('post_panel')),
|
||||
requested_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
Reference in new issue
Block a user