- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list - schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"velox-bot/internal/db/repos/ticketrepo"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
// The rest of the ticket configuration: the on/off switch, the panel's text,
|
|
// the open ticket limit, editing a category, and looking at tickets. All of it
|
|
// is available from Discord, so a dashboard is never needed.
|
|
|
|
func ticketSettingsOptions() []*discordgo.ApplicationCommandOption {
|
|
lo, hi := 1.0, 10.0
|
|
return []*discordgo.ApplicationCommandOption{
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings",
|
|
Description: "Show the ticket settings, or change them. Leave out what you don't change",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "Turn tickets on or off"},
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-title", Description: "The panel's title", MaxLength: 256},
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-description", Description: "The panel's text", MaxLength: 4000},
|
|
{Type: discordgo.ApplicationCommandOptionInteger, Name: "max-open", Description: "Open tickets one member can have, 1 to 10", MinValue: &lo, MaxValue: hi},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list",
|
|
Description: "List recent tickets",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionString, Name: "status", Description: "Open or closed (default: open)",
|
|
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "open", Value: ticketrepo.StatusOpen}, {Name: "closed", Value: ticketrepo.StatusClosed}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func categoryEditOption() *discordgo.ApplicationCommandOption {
|
|
return &discordgo.ApplicationCommandOption{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "edit",
|
|
Description: "Change a category. Leave out what you don't change",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category to change", Required: true},
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "new-name", Description: "A new name", MaxLength: 40},
|
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
|
|
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "clear-staff-role", Description: "Remove the staff role"},
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button. Use - to remove", MaxLength: 64},
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel. Use - to remove", MaxLength: 200},
|
|
},
|
|
}
|
|
}
|
|
|
|
func onOff(b bool) string {
|
|
if b {
|
|
return "on"
|
|
}
|
|
return "off"
|
|
}
|
|
|
|
func handleSettings(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
args := opts(o)
|
|
g := guildID(i)
|
|
|
|
if len(args) == 0 {
|
|
st, err := Repo.GetSettings(ctx, g)
|
|
if err != nil {
|
|
reply(s, i, message("settings", err))
|
|
return
|
|
}
|
|
where := "not set (use `/tickets setup`)"
|
|
if st.ChannelID != 0 {
|
|
where = fmt.Sprintf("<#%d>", st.ChannelID)
|
|
}
|
|
logs := "none"
|
|
if st.LogChannelID != 0 {
|
|
logs = fmt.Sprintf("<#%d>", st.LogChannelID)
|
|
}
|
|
reply(s, i, fmt.Sprintf("**Tickets:** %s\n**Channel:** %s · **Log channel:** %s\n**Open tickets per member:** %d\n**Panel title:** %s\n**Panel text:** %s",
|
|
onOff(st.Enabled), where, logs, st.MaxOpenPerUser, clipText(st.PanelTitle, 200), clipText(st.PanelDescription, 600)))
|
|
return
|
|
}
|
|
|
|
var p ticketrepo.SettingsPatch
|
|
if a, ok := args["enabled"]; ok {
|
|
v := a.BoolValue()
|
|
p.Enabled = &v
|
|
}
|
|
if a, ok := args["panel-title"]; ok {
|
|
v := strings.TrimSpace(a.StringValue())
|
|
if v == "" || utf8.RuneCountInString(v) > 256 {
|
|
reply(s, i, "The panel title must be between 1 and 256 characters.")
|
|
return
|
|
}
|
|
p.PanelTitle = &v
|
|
}
|
|
if a, ok := args["panel-description"]; ok {
|
|
v := strings.TrimSpace(a.StringValue())
|
|
if utf8.RuneCountInString(v) > 4000 {
|
|
reply(s, i, "The panel text can be at most 4000 characters.")
|
|
return
|
|
}
|
|
p.PanelDescription = &v
|
|
}
|
|
if a, ok := args["max-open"]; ok {
|
|
v := int(a.IntValue())
|
|
if v < 1 || v > 10 {
|
|
reply(s, i, "Open tickets per member must be between 1 and 10.")
|
|
return
|
|
}
|
|
p.MaxOpenPerUser = &v
|
|
}
|
|
if err := Repo.UpdateSettings(ctx, g, p); err != nil {
|
|
reply(s, i, message("settings", err))
|
|
return
|
|
}
|
|
msg := "Saved."
|
|
if p.PanelTitle != nil || p.PanelDescription != nil {
|
|
msg += " Run `/tickets panel` to update the panel with the new text."
|
|
}
|
|
if p.Enabled != nil {
|
|
msg += " Tickets are now **" + onOff(*p.Enabled) + "**."
|
|
}
|
|
reply(s, i, msg)
|
|
}
|
|
|
|
func clipText(s string, n int) string {
|
|
if r := []rune(s); len(r) > n {
|
|
return string(r[:n-1]) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func handleCategoryEdit(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
args := opts(o)
|
|
g := guildID(i)
|
|
name := strings.TrimSpace(args["name"].StringValue())
|
|
|
|
cats, err := Repo.ListCategories(ctx, g)
|
|
if err != nil {
|
|
reply(s, i, message("category edit", err))
|
|
return
|
|
}
|
|
var target *ticketrepo.Category
|
|
for idx := range cats {
|
|
if strings.EqualFold(cats[idx].Name, name) {
|
|
target = &cats[idx]
|
|
}
|
|
}
|
|
if target == nil {
|
|
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
|
|
return
|
|
}
|
|
|
|
var p ticketrepo.CategoryPatch
|
|
if a, ok := args["new-name"]; ok {
|
|
v := strings.TrimSpace(a.StringValue())
|
|
if v == "" || utf8.RuneCountInString(v) > 40 {
|
|
reply(s, i, "The category name must be between 1 and 40 characters.")
|
|
return
|
|
}
|
|
for _, c := range cats {
|
|
if c.ID != target.ID && strings.EqualFold(c.Name, v) {
|
|
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
|
|
return
|
|
}
|
|
}
|
|
p.Name = &v
|
|
}
|
|
if a, ok := args["staff-role"]; ok {
|
|
id, _ := strconv.ParseInt(a.RoleValue(nil, i.GuildID).ID, 10, 64)
|
|
p.StaffRoleID = &id
|
|
}
|
|
if a, ok := args["clear-staff-role"]; ok && a.BoolValue() {
|
|
if p.StaffRoleID != nil {
|
|
reply(s, i, "Choose either a staff role or clearing it, not both.")
|
|
return
|
|
}
|
|
zero := int64(0)
|
|
p.StaffRoleID = &zero
|
|
}
|
|
if a, ok := args["emoji"]; ok {
|
|
v := strings.TrimSpace(a.StringValue())
|
|
if v == "-" {
|
|
v = ""
|
|
}
|
|
p.Emoji = &v
|
|
}
|
|
if a, ok := args["description"]; ok {
|
|
v := strings.TrimSpace(a.StringValue())
|
|
if v == "-" {
|
|
v = ""
|
|
}
|
|
if utf8.RuneCountInString(v) > 200 {
|
|
reply(s, i, "The category description can be at most 200 characters.")
|
|
return
|
|
}
|
|
p.Description = &v
|
|
}
|
|
if p == (ticketrepo.CategoryPatch{}) {
|
|
reply(s, i, "Say what to change: new-name, staff-role, clear-staff-role, emoji or description.")
|
|
return
|
|
}
|
|
if _, err := Repo.UpdateCategory(ctx, g, target.ID, p); err != nil {
|
|
reply(s, i, message("category edit", err))
|
|
return
|
|
}
|
|
reply(s, i, fmt.Sprintf("Updated **%s**. Run `/tickets panel` to update the panel.", target.Name))
|
|
}
|
|
|
|
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
status := ticketrepo.StatusOpen
|
|
if a, ok := opts(o)["status"]; ok {
|
|
status = a.StringValue()
|
|
}
|
|
ts, err := Repo.ListTickets(ctx, guildID(i), status, 15)
|
|
if err != nil {
|
|
reply(s, i, message("list", err))
|
|
return
|
|
}
|
|
if len(ts) == 0 {
|
|
reply(s, i, "No "+status+" tickets.")
|
|
return
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "**Latest %s tickets**\n", status)
|
|
for _, t := range ts {
|
|
fmt.Fprintf(&b, "• #%d <#%d> %s, by <@%d>", t.Number, t.ThreadID, clipText(t.CategoryName, 30), t.OpenerID)
|
|
if t.ClaimedBy != 0 {
|
|
fmt.Fprintf(&b, ", claimed by <@%d>", t.ClaimedBy)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
reply(s, i, clipText(b.String(), 1900))
|
|
}
|