Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136377d81a | ||
|
|
0218676e2d | ||
|
|
f285367511 | ||
|
|
e10e1f7a86 | ||
|
|
e453a9ba94 | ||
|
|
95072fcd53 |
No files matched your search
@@ -102,7 +102,7 @@ just down # stop postgres
|
|||||||
- `/projects list`, `/projects create`, `/projects add-helper`
|
- `/projects list`, `/projects create`, `/projects add-helper`
|
||||||
- **Tickets**
|
- **Tickets**
|
||||||
- `/tickets setup channel:#tickets [log-channel] [staff-role]` — Where tickets open and closed ones are logged (**Manage Server**)
|
- `/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 category add|remove|list` — One entry in the panel's dropdown per category, each with its own staff role (**Manage Server**)
|
||||||
- `/tickets panel` — Post or refresh the panel (**Manage Server**)
|
- `/tickets panel` — Post or refresh the panel (**Manage Server**)
|
||||||
- `/ticket add`, `/ticket close [reason]` — Opener or staff; `/ticket claim`, `/ticket remove` — staff
|
- `/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.
|
- 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.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ var Tickets = &discordgo.ApplicationCommand{
|
|||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "add",
|
Name: "add",
|
||||||
Description: "Add a category (one button on the panel)",
|
Description: "Add a category (an option in the panel's dropdown)",
|
||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
|
{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.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.")
|
edit(s, i, "Ticket closed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleComponent handles the panel buttons and dropdown and the Claim and
|
// HandleComponent handles the panel dropdown and Create ticket button and the Claim and
|
||||||
// Close buttons in a ticket.
|
// Close buttons in a ticket.
|
||||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
if Repo == nil {
|
if Repo == nil {
|
||||||
@@ -434,9 +434,12 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
data := i.MessageComponentData()
|
data := i.MessageComponentData()
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(data.CustomID, "tickets:open:"):
|
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:"))
|
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
|
||||||
case data.CustomID == "tickets:select" && len(data.Values) > 0:
|
case data.CustomID == "tickets:select" && len(data.Values) > 0:
|
||||||
showOpenForm(s, i, data.Values[0])
|
rememberPick(s, i, data.Values[0])
|
||||||
|
case data.CustomID == "tickets:create":
|
||||||
|
createFromPick(s, i)
|
||||||
case data.CustomID == "tickets:claim":
|
case data.CustomID == "tickets:claim":
|
||||||
claim(s, i)
|
claim(s, i)
|
||||||
case data.CustomID == "tickets:close":
|
case data.CustomID == "tickets:close":
|
||||||
@@ -450,6 +453,31 @@ 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) {
|
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
|
||||||
catID, err := strconv.ParseInt(rawCategory, 10, 64)
|
catID, err := strconv.ParseInt(rawCategory, 10, 64)
|
||||||
if err != nil || i.Member == nil || i.Member.User == nil {
|
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||||
|
|||||||
@@ -93,9 +93,10 @@ func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error
|
|||||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// Modules start off until the server sets them up.
|
||||||
return &Settings{
|
return &Settings{
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
Enabled: true,
|
Enabled: false,
|
||||||
PanelTitle: DefaultPanelTitle,
|
PanelTitle: DefaultPanelTitle,
|
||||||
PanelDescription: DefaultPanelDescription,
|
PanelDescription: DefaultPanelDescription,
|
||||||
MaxOpenPerUser: DefaultMaxOpenPerUser,
|
MaxOpenPerUser: DefaultMaxOpenPerUser,
|
||||||
@@ -109,11 +110,12 @@ func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetChannels saves where tickets live and where transcripts go. A zero log
|
// SetChannels saves where tickets live and where transcripts go. A zero log
|
||||||
// channel clears it.
|
// channel clears it. Setting tickets up for the first time turns them on
|
||||||
|
// (they start off); after that, the on/off switch is left alone.
|
||||||
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
|
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id)
|
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id, enabled)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, TRUE)
|
||||||
ON CONFLICT (guild_id) DO UPDATE SET
|
ON CONFLICT (guild_id) DO UPDATE SET
|
||||||
channel_id = EXCLUDED.channel_id,
|
channel_id = EXCLUDED.channel_id,
|
||||||
log_channel_id = EXCLUDED.log_channel_id`
|
log_channel_id = EXCLUDED.log_channel_id`
|
||||||
|
|||||||
@@ -102,10 +102,13 @@ func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64
|
|||||||
return chID.Int64, true, nil
|
return chID.Int64, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNotificationChannel sets where live alerts go. Setting it for the first
|
||||||
|
// time turns alerts on (they start off); after that, the on/off switch is
|
||||||
|
// left alone.
|
||||||
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO twitch_config (guild_id, twitch_channel_id)
|
INSERT INTO twitch_config (guild_id, twitch_channel_id, enabled)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, TRUE)
|
||||||
ON CONFLICT (guild_id)
|
ON CONFLICT (guild_id)
|
||||||
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
|
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
|
||||||
`
|
`
|
||||||
@@ -114,15 +117,14 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled reports whether Twitch notifications are enabled for guildID.
|
// IsEnabled reports whether Twitch notifications are enabled for guildID.
|
||||||
// A guild with no twitch_config row yet defaults to enabled, matching the
|
// A guild with no twitch_config row yet is off: modules start off until
|
||||||
// column's own DEFAULT TRUE - "never configured" and "explicitly on" are
|
// the server sets them up.
|
||||||
// the same state until someone actually flips it off.
|
|
||||||
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||||
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
|
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
|
||||||
var enabled bool
|
var enabled bool
|
||||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return true, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, tra
|
|||||||
embed := &discordgo.MessageEmbed{
|
embed := &discordgo.MessageEmbed{
|
||||||
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
|
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
|
||||||
Description: "**" + clip(t.Subject, maxSubject) + "**",
|
Description: "**" + clip(t.Subject, maxSubject) + "**",
|
||||||
Color: 0x6b7280,
|
Color: accent,
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
||||||
{Name: "Claimed by", Value: claimed, Inline: true},
|
{Name: "Claimed by", Value: claimed, Inline: true},
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ 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."},
|
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
|
||||||
}
|
}
|
||||||
if t.Status == ticketrepo.StatusClosed {
|
if t.Status == ticketrepo.StatusClosed {
|
||||||
e.Color = 0x6b7280
|
|
||||||
e.Footer.Text = "This ticket is closed."
|
e.Footer.Text = "This ticket is closed."
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
|
|||||||
+17
-28
@@ -15,8 +15,9 @@ import (
|
|||||||
// Custom IDs. Everything starts with "tickets:" so the interaction router can
|
// Custom IDs. Everything starts with "tickets:" so the interaction router can
|
||||||
// hand it to this package.
|
// hand it to this package.
|
||||||
const (
|
const (
|
||||||
idOpenPrefix = "tickets:open:" // panel button, + category ID
|
idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
|
||||||
idSelect = "tickets:select" // panel dropdown (many categories)
|
idSelect = "tickets:select" // panel dropdown
|
||||||
|
idCreate = "tickets:create" // panel "Create ticket" button
|
||||||
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
|
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
|
||||||
idClaim = "tickets:claim"
|
idClaim = "tickets:claim"
|
||||||
idClose = "tickets:close"
|
idClose = "tickets:close"
|
||||||
@@ -42,8 +43,8 @@ func parseEmoji(s string) *discordgo.ComponentEmoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildPanel is the message members open tickets from: an embed with the
|
// 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
|
// guild's text and each category's description, a dropdown to pick the
|
||||||
// more categories than fit comfortably in a row.
|
// category, and a Create ticket button that opens the form for that pick.
|
||||||
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
|
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
|
||||||
embed := &discordgo.MessageEmbed{
|
embed := &discordgo.MessageEmbed{
|
||||||
Title: clip(s.PanelTitle, 256),
|
Title: clip(s.PanelTitle, 256),
|
||||||
@@ -54,27 +55,7 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
|
|||||||
cats = cats[:maxCategories]
|
cats = cats[:maxCategories]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cats) <= maxPanelButtons {
|
opts := make([]discordgo.SelectMenuOption, 0, len(cats))
|
||||||
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 {
|
for _, c := range cats {
|
||||||
opts = append(opts, discordgo.SelectMenuOption{
|
opts = append(opts, discordgo.SelectMenuOption{
|
||||||
Label: clip(c.Name, 100),
|
Label: clip(c.Name, 100),
|
||||||
@@ -82,10 +63,18 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
|
|||||||
Description: clip(c.Description, 100),
|
Description: clip(c.Description, 100),
|
||||||
Emoji: parseEmoji(c.Emoji),
|
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{
|
||||||
|
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: "🎫"}},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
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
|
// PostPanel posts the panel in the ticket channel, or updates the one it
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package tickets is the support ticket system: a panel message with one
|
// Package tickets is the support ticket system: a panel message with a
|
||||||
// button per category, a short form, and a private thread per ticket that
|
// category dropdown and a Create ticket button, a short form, and a private
|
||||||
// staff can claim and anyone involved can close. Closing locks and archives
|
// thread per ticket that staff can claim and anyone involved can close.
|
||||||
// the thread and posts a transcript to the log channel.
|
// 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
|
// The Discord and database calls go through small interfaces so the flows
|
||||||
// can be tested without either; the slash command and button handlers in
|
// can be tested without either; the slash command and button handlers in
|
||||||
@@ -67,7 +68,9 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
defaultStore Store
|
defaultStore Store
|
||||||
accent = 0x2b7bfa
|
// Every ticket embed (panel, ticket, log) is orange, so support stands
|
||||||
|
// apart from the bot's other messages.
|
||||||
|
accent = 0xf97316
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init(s Store) { defaultStore = s }
|
func Init(s Store) { defaultStore = s }
|
||||||
@@ -80,7 +83,6 @@ const (
|
|||||||
maxSubject = 100
|
maxSubject = 100
|
||||||
maxDetails = 1000
|
maxDetails = 1000
|
||||||
maxReason = 300
|
maxReason = 300
|
||||||
maxPanelButtons = 5 // more categories than this switch the panel to a dropdown
|
|
||||||
maxCategories = 25
|
maxCategories = 25
|
||||||
autoArchiveWeek = 10080
|
autoArchiveWeek = 10080
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,26 +74,77 @@ func TestBuildPanel(t *testing.T) {
|
|||||||
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
|
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
|
||||||
}
|
}
|
||||||
embed, comps := BuildPanel(s, cats)
|
embed, comps := BuildPanel(s, cats)
|
||||||
row := comps[0].(discordgo.ActionsRow)
|
if embed.Title != "Help" || embed.Color != 0xf97316 {
|
||||||
b1, b2 := row.Components[0].(discordgo.Button), row.Components[1].(discordgo.Button)
|
t.Errorf("panel embed wrong: %+v", embed)
|
||||||
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 {
|
if len(comps) != 2 {
|
||||||
t.Errorf("custom emoji not parsed: %+v", b2.Emoji)
|
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 len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
|
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
|
||||||
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
|
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
var many []ticketrepo.Category
|
var many []ticketrepo.Category
|
||||||
for i := 1; i <= 7; i++ {
|
for i := 1; i <= 30; i++ {
|
||||||
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
|
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
|
||||||
}
|
}
|
||||||
_, comps = BuildPanel(s, many)
|
_, comps = BuildPanel(s, many)
|
||||||
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
|
if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
|
||||||
if menu.CustomID != "tickets:select" || len(menu.Options) != 7 || menu.Options[6].Value != "7" {
|
t.Errorf("a dropdown holds at most 25 options, got %d", n)
|
||||||
t.Errorf("more than 5 categories should use a dropdown: %+v", menu)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS levelup (
|
|||||||
CREATE TABLE IF NOT EXISTS twitch_config (
|
CREATE TABLE IF NOT EXISTS twitch_config (
|
||||||
guild_id BIGINT PRIMARY KEY,
|
guild_id BIGINT PRIMARY KEY,
|
||||||
twitch_channel_id BIGINT,
|
twitch_channel_id BIGINT,
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rps (
|
CREATE TABLE IF NOT EXISTS rps (
|
||||||
@@ -180,7 +180,7 @@ CREATE TABLE IF NOT EXISTS music_commands (
|
|||||||
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
|
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
|
||||||
CREATE TABLE IF NOT EXISTS ticket_settings (
|
CREATE TABLE IF NOT EXISTS ticket_settings (
|
||||||
guild_id BIGINT PRIMARY KEY,
|
guild_id BIGINT PRIMARY KEY,
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
channel_id BIGINT,
|
channel_id BIGINT,
|
||||||
log_channel_id BIGINT,
|
log_channel_id BIGINT,
|
||||||
panel_message_id BIGINT,
|
panel_message_id BIGINT,
|
||||||
|
|||||||
Reference in new issue
Block a user