Compare commits

...
2 Commits
Author SHA1 Message Date
FernandoJVideira 95072fcd53 Merge pull request 'Add support tickets with private threads' (#18) from ft/tickets into dev
Reviewed-on: #18
2026-10-20 21:03:04 +00:00
FernandoJVideiraandClaude Opus 5.5 d294b3463b Add support tickets that work with private threads
Members open a ticket from a panel in the ticket channel: one button per
category (a dropdown past five), then a short form. Each ticket is a private
thread named after the category, number and opener, with a first message
that mentions the category's staff role so its members join, and Claim and
Close buttons.

- Claim: staff (the category's role, or Manage Threads / Manage Server)
  take a ticket; the first message shows who has it.
- Close: the opener or staff close it, with an optional reason. The thread
  is locked and archived, and the log channel gets a summary and a .txt
  transcript of the whole thread.
- /ticket add|remove|claim|close inside a ticket; /tickets setup,
  /tickets category add|remove|list and /tickets panel for admins.
- Per-member limit on open tickets (default 1), per-server ticket numbers
  handed out atomically, a deleted thread closes its ticket.
- The dashboard asks the bot to post or refresh the panel through
  ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if
  the database connection drops.
- Setup warns about missing bot permissions in the ticket and log channels.

The flows live in internal/tickets behind small Discord and store
interfaces, with unit tests on fakes; the repo and the listener have
integration tests (-tags integration, TICKETS_TEST_DB).

Needs the ticket tables (velox-dashboard-api migration 000016_tickets).
.gitignore gains the /tldr benchmark rules so private chat exports and
benchmark results on disk can't be committed from this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:31:52 +01:00
20 changed files with 2800 additions and 0 deletions

No files matched your search

+7
View File
@@ -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/
+7
View File
@@ -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)
+20
View File
@@ -3,11 +3,13 @@ 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"
)
@@ -70,6 +72,18 @@ func (b *Bot) Start() error {
}
}()
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)
@@ -122,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)
+9
View File
@@ -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)
}
}
+9
View File
@@ -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: "" +
+514
View File
@@ -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"))
}
}
+29
View File
@@ -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)
}
+301
View File
@@ -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")
}
}
}
+247
View File
@@ -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
}
+63
View File
@@ -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")
}
+271
View File
@@ -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)
}
}
+171
View File
@@ -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
}
+187
View File
@@ -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,
}}},
},
},
}
}
+197
View File
@@ -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
}
+348
View File
@@ -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)
}
}
+88
View File
@@ -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")
}
+3
View File
@@ -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)
+52
View File
@@ -176,3 +176,55 @@ CREATE TABLE IF NOT EXISTS music_commands (
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
);