Compare commits

..
Author SHA1 Message Date
FernandoJVideira 177c820821 Merge pull request 'v3.5.0' (#20) from dev into master
Reviewed-on: #20
2026-10-20 22:39:57 +00:00
FernandoJVideira ffb8b963fa Merge pull request 'v3.4.0' (#17) from dev into master
Reviewed-on: #17
2026-08-29 18:53:28 +00:00
8 changed files with 49 additions and 177 deletions

No files matched your search

+1 -1
View File
@@ -102,7 +102,7 @@ just down # stop postgres
- `/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 entry in the panel's dropdown per category, each with its own staff role (**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.
+3 -31
View File
@@ -60,7 +60,7 @@ var Tickets = &discordgo.ApplicationCommand{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add",
Description: "Add a category (an option in the panel's dropdown)",
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"},
@@ -424,7 +424,7 @@ func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason st
edit(s, i, "Ticket closed.")
}
// HandleComponent handles the panel dropdown and Create ticket button and the Claim and
// 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 {
@@ -434,12 +434,9 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.MessageComponentData()
switch {
case strings.HasPrefix(data.CustomID, "tickets:open:"):
// Panels posted before the dropdown had one button per category.
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
case data.CustomID == "tickets:select" && len(data.Values) > 0:
rememberPick(s, i, data.Values[0])
case data.CustomID == "tickets:create":
createFromPick(s, i)
showOpenForm(s, i, data.Values[0])
case data.CustomID == "tickets:claim":
claim(s, i)
case data.CustomID == "tickets:close":
@@ -453,31 +450,6 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
}
}
// rememberPick stores the dropdown choice for the Create ticket button and
// acknowledges it without changing the panel, so the choice stays visible.
func rememberPick(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
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
tickets.RememberPick(guildID(i), userID, catID)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate})
}
func createFromPick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
catID, ok := tickets.PickedCategory(guildID(i), userID)
if !ok {
reply(s, i, "Pick a category from the menu first, then press **Create ticket**.")
return
}
showOpenForm(s, i, strconv.FormatInt(catID, 10))
}
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 {
+1 -1
View File
@@ -182,7 +182,7 @@ func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, tra
embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
Description: "**" + clip(t.Subject, maxSubject) + "**",
Color: accent,
Color: 0x6b7280,
Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true},
+1
View File
@@ -86,6 +86,7 @@ func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
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
+27 -16
View File
@@ -15,9 +15,8 @@ import (
// Custom IDs. Everything starts with "tickets:" so the interaction router can
// hand it to this package.
const (
idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
idSelect = "tickets:select" // panel dropdown
idCreate = "tickets:create" // panel "Create ticket" button
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"
@@ -43,8 +42,8 @@ func parseEmoji(s string) *discordgo.ComponentEmoji {
}
// BuildPanel is the message members open tickets from: an embed with the
// guild's text and each category's description, a dropdown to pick the
// category, and a Create ticket button that opens the form for that pick.
// 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),
@@ -55,7 +54,27 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
cats = cats[:maxCategories]
}
opts := make([]discordgo.SelectMenuOption, 0, len(cats))
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),
@@ -63,18 +82,10 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
Description: clip(c.Description, 100),
Emoji: parseEmoji(c.Emoji),
})
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: []discordgo.MessageComponent{
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Create ticket", Style: discordgo.PrimaryButton, CustomID: idCreate, Emoji: &discordgo.ComponentEmoji{Name: "🎫"}},
}},
}
}}}
}
// PostPanel posts the panel in the ticket channel, or updates the one it
-59
View File
@@ -1,59 +0,0 @@
package tickets
import (
"sync"
"time"
)
// Discord doesn't send a dropdown's value along with a button click, so the
// panel's category pick is remembered here between the two interactions:
// per guild and member, for a short while, in memory. A bot restart forgets
// it, which only means picking the category again.
const (
pickTTL = 15 * time.Minute
maxPicks = 10000 // a ceiling, so a flood of picks can't grow this forever
)
type pickKey struct{ guild, user int64 }
type pick struct {
category int64
at time.Time
}
var (
picksMu sync.Mutex
picks = map[pickKey]pick{}
now = time.Now
)
// RememberPick records the category a member chose in the panel dropdown.
func RememberPick(guildID, userID, categoryID int64) {
picksMu.Lock()
defer picksMu.Unlock()
t := now()
k := pickKey{guildID, userID}
if _, ok := picks[k]; !ok && len(picks) >= maxPicks {
for k, p := range picks {
if t.Sub(p.at) > pickTTL {
delete(picks, k)
}
}
if len(picks) >= maxPicks {
return
}
}
picks[k] = pick{categoryID, t}
}
// PickedCategory returns the category a member chose, if they did recently.
func PickedCategory(guildID, userID int64) (int64, bool) {
picksMu.Lock()
defer picksMu.Unlock()
p, ok := picks[pickKey{guildID, userID}]
if !ok || now().Sub(p.at) > pickTTL {
delete(picks, pickKey{guildID, userID})
return 0, false
}
return p.category, true
}
+6 -8
View File
@@ -1,8 +1,7 @@
// Package tickets is the support ticket system: a panel message with a
// category dropdown and a Create ticket button, 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.
// 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
@@ -68,9 +67,7 @@ const (
var (
defaultStore Store
// Every ticket embed (panel, ticket, log) is orange, so support stands
// apart from the bot's other messages.
accent = 0xf97316
accent = 0x2b7bfa
)
func Init(s Store) { defaultStore = s }
@@ -83,6 +80,7 @@ const (
maxSubject = 100
maxDetails = 1000
maxReason = 300
maxPanelButtons = 5 // more categories than this switch the panel to a dropdown
maxCategories = 25
autoArchiveWeek = 10080
)
+10 -61
View File
@@ -74,77 +74,26 @@ func TestBuildPanel(t *testing.T) {
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
}
embed, comps := BuildPanel(s, cats)
if embed.Title != "Help" || embed.Color != 0xf97316 {
t.Errorf("panel embed wrong: %+v", embed)
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 len(comps) != 2 {
t.Fatalf("want a dropdown row and a button row, got %d rows", len(comps))
}
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
if menu.CustomID != "tickets:select" || len(menu.Options) != 2 || menu.Options[0].Value != "1" || menu.Options[0].Emoji.Name != "🛟" {
t.Errorf("dropdown wrong: %+v", menu)
}
if e := menu.Options[1].Emoji; e.ID != "123" || e.Name != "party" || !e.Animated {
t.Errorf("custom emoji not parsed: %+v", e)
}
button := comps[1].(discordgo.ActionsRow).Components[0].(discordgo.Button)
if button.CustomID != "tickets:create" || button.Label != "Create ticket" {
t.Errorf("create button wrong: %+v", button)
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 <= 30; i++ {
for i := 1; i <= 7; i++ {
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
}
_, comps = BuildPanel(s, many)
if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
t.Errorf("a dropdown holds at most 25 options, got %d", n)
}
}
func TestPicks(t *testing.T) {
clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return clock }
t.Cleanup(func() { now = time.Now; picks = map[pickKey]pick{} })
if _, ok := PickedCategory(guild, 5); ok {
t.Error("nothing picked yet")
}
RememberPick(guild, 5, 3)
RememberPick(guild, 5, 4)
RememberPick(guild, 6, 9)
if c, ok := PickedCategory(guild, 5); !ok || c != 4 {
t.Errorf("the latest pick wins: %d %v", c, ok)
}
if _, ok := PickedCategory(guild+1, 5); ok {
t.Error("picks are per guild")
}
clock = clock.Add(pickTTL + time.Second)
if _, ok := PickedCategory(guild, 5); ok {
t.Error("a pick expires")
}
// Full: new members can't add more, but expired ones make room.
picks = map[pickKey]pick{}
for i := 0; i < maxPicks; i++ {
picks[pickKey{guild + 100, int64(i)}] = pick{1, clock}
}
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); ok {
t.Error("a full map takes no new members")
}
RememberPick(guild+100, 0, 2)
if c, _ := PickedCategory(guild+100, 0); c != 2 {
t.Error("someone already in the map can still change their pick")
}
clock = clock.Add(pickTTL + time.Second)
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); !ok {
t.Error("expired picks should be cleared to make room")
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)
}
}