diff --git a/docs/database.md b/docs/database.md index ee30a51..4147ade 100644 --- a/docs/database.md +++ b/docs/database.md @@ -8,22 +8,7 @@ The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL live The hosted Velox also has a proprietary dashboard API that keeps its own migrations for the same tables. That is the maintainer's concern: if your change adds or alters a table, update `schema.sql` and say so in your pull request, and the maintainer ports it. You never need the API to develop or run the bot. -Two things are not in `schema.sql`, because only a dashboard needs them: the `NOTIFY` triggers on the three `*_commands` tables (see below), and a migration history. If you write a tool that inserts command rows, add the trigger yourself: - -```sql -CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$ -BEGIN - PERFORM pg_notify('music_commands', NEW.id::text); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER music_commands_notify -AFTER INSERT ON music_commands -FOR EACH ROW EXECUTE FUNCTION notify_music_command(); -``` - -The same works for `ticket_commands` and `security_commands`. Without the trigger the bot still picks the rows up, but only the next time it starts or is notified for another reason. +`schema.sql` also creates the `NOTIFY` triggers on the three `*_commands` tables (see [below](#dashboard-to-bot-settings-and-requests)), so anything that inserts a request wakes the bot, with no `NOTIFY` of its own. ## Tables diff --git a/internal/commands/tickets/public/settings.go b/internal/commands/tickets/public/settings.go new file mode 100644 index 0000000..c7e52f6 --- /dev/null +++ b/internal/commands/tickets/public/settings.go @@ -0,0 +1,247 @@ +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)) +} diff --git a/internal/commands/tickets/public/tickets.go b/internal/commands/tickets/public/tickets.go index b4b12be..4fc09ae 100644 --- a/internal/commands/tickets/public/tickets.go +++ b/internal/commands/tickets/public/tickets.go @@ -41,7 +41,7 @@ var Tickets = &discordgo.ApplicationCommand{ Description: "Set up support tickets", DefaultMemberPermissions: &manageGuild, Contexts: guildOnly, - Options: []*discordgo.ApplicationCommandOption{ + Options: append([]*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "setup", @@ -77,6 +77,7 @@ var Tickets = &discordgo.ApplicationCommand{ }, }, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"}, + categoryEditOption(), }, }, { @@ -84,7 +85,7 @@ var Tickets = &discordgo.ApplicationCommand{ Name: "panel", Description: "Post the ticket panel, or update it after changing categories", }, - }, + }, ticketSettingsOptions()...), } // Ticket is used inside a ticket thread. @@ -166,6 +167,10 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { handleSetup(s, i, sub.Options) case "panel": handlePanel(s, i) + case "settings": + handleSettings(s, i, sub.Options) + case "list": + handleList(s, i, sub.Options) case "category": if len(sub.Options) == 0 { return @@ -177,6 +182,8 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { handleCategoryRemove(s, i, sub.Options[0].Options) case "list": handleCategoryList(s, i) + case "edit": + handleCategoryEdit(s, i, sub.Options[0].Options) } } } diff --git a/internal/db/repos/ticketrepo/repo.go b/internal/db/repos/ticketrepo/repo.go index f440b9c..1a3efb0 100644 --- a/internal/db/repos/ticketrepo/repo.go +++ b/internal/db/repos/ticketrepo/repo.go @@ -7,6 +7,8 @@ import ( "context" "database/sql" "errors" + "strconv" + "strings" "time" ) @@ -301,3 +303,110 @@ 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 } + +// SettingsPatch changes ticket settings. A nil field is left as it is. +type SettingsPatch struct { + Enabled *bool + PanelTitle *string + PanelDescription *string + MaxOpenPerUser *int +} + +// UpdateSettings applies the fields that are set. The server gets a settings +// row if it has none yet, with everything else at its default and the system +// still off, until /tickets setup or this turns it on. +func (r *Repo) UpdateSettings(ctx context.Context, guildID int64, p SettingsPatch) error { + if _, err := r.db.ExecContext(ctx, `INSERT INTO ticket_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID); err != nil { + return err + } + var sets []string + args := []any{guildID} + add := func(col string, v any) { + args = append(args, v) + sets = append(sets, col+` = $`+strconv.Itoa(len(args))) + } + if p.Enabled != nil { + add("enabled", *p.Enabled) + } + if p.PanelTitle != nil { + add("panel_title", *p.PanelTitle) + } + if p.PanelDescription != nil { + add("panel_description", *p.PanelDescription) + } + if p.MaxOpenPerUser != nil { + add("max_open_per_user", *p.MaxOpenPerUser) + } + if len(sets) == 0 { + return nil + } + _, err := r.db.ExecContext(ctx, `UPDATE ticket_settings SET `+strings.Join(sets, ", ")+` WHERE guild_id = $1`, args...) + return err +} + +// CategoryPatch changes a category. A nil field is left as it is; an empty +// emoji or description clears it, and a zero StaffRoleID clears the role. +type CategoryPatch struct { + Name *string + Emoji *string + Description *string + StaffRoleID *int64 +} + +// UpdateCategory applies the fields that are set and returns the category as +// saved, or nil when there is none with that ID in the guild. +func (r *Repo) UpdateCategory(ctx context.Context, guildID, id int64, p CategoryPatch) (*Category, error) { + var sets []string + args := []any{guildID, id} + add := func(col string, v any) { + args = append(args, v) + sets = append(sets, col+` = $`+strconv.Itoa(len(args))) + } + if p.Name != nil { + add("name", *p.Name) + } + if p.Emoji != nil { + add("emoji", nullText(*p.Emoji)) + } + if p.Description != nil { + add("description", *p.Description) + } + if p.StaffRoleID != nil { + add("staff_role_id", nullID(*p.StaffRoleID)) + } + if len(sets) == 0 { + return r.GetCategory(ctx, guildID, id) + } + q := `UPDATE ticket_categories SET ` + strings.Join(sets, ", ") + ` WHERE guild_id = $1 AND id = $2 RETURNING ` + categoryCols + c, err := scanCategory(r.db.QueryRowContext(ctx, q, args...)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &c, nil +} + +// ListTickets returns a guild's most recent tickets with the given status +// ("open" or "closed"), newest first. +func (r *Repo) ListTickets(ctx context.Context, guildID int64, status string, limit int) ([]Ticket, error) { + if limit < 1 || limit > 100 { + limit = 25 + } + q := `SELECT ` + ticketCols + ` FROM tickets WHERE guild_id = $1 AND status = $2 ORDER BY number DESC LIMIT $3` + rows, err := r.db.QueryContext(ctx, q, guildID, status, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ticket + for rows.Next() { + t, err := scanTicket(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/db/repos/ticketrepo/repo_integration_test.go b/internal/db/repos/ticketrepo/repo_integration_test.go index 53abf0c..267123b 100644 --- a/internal/db/repos/ticketrepo/repo_integration_test.go +++ b/internal/db/repos/ticketrepo/repo_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -// Runs against a real Postgres with the dashboard API's migrations applied: +// Runs against a real Postgres with schema.sql loaded: // // TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo package ticketrepo @@ -46,7 +46,7 @@ func TestSettingsDefaultsAndChannels(t *testing.T) { ctx := context.Background() s, err := r.GetSettings(ctx, g) - if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 { + 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 { @@ -204,3 +204,84 @@ func TestCommands(t *testing.T) { } } } + +func TestUpdateSettingsCategoriesAndList(t *testing.T) { + r, g := testRepo(t) + ctx := context.Background() + str := func(v string) *string { return &v } + num := func(v int) *int { return &v } + on := true + + // Changing the text of a server that never set tickets up creates its row + // and leaves the system off. + if err := r.UpdateSettings(ctx, g, SettingsPatch{PanelTitle: str("Help desk"), MaxOpenPerUser: num(3)}); err != nil { + t.Fatal(err) + } + st, _ := r.GetSettings(ctx, g) + if st.Enabled || st.PanelTitle != "Help desk" || st.MaxOpenPerUser != 3 || st.PanelDescription != DefaultPanelDescription { + t.Errorf("settings: %+v", st) + } + if err := r.UpdateSettings(ctx, g, SettingsPatch{Enabled: &on}); err != nil { + t.Fatal(err) + } + if st, _ = r.GetSettings(ctx, g); !st.Enabled || st.PanelTitle != "Help desk" { + t.Errorf("enabling keeps the rest: %+v", st) + } + if err := r.UpdateSettings(ctx, g, SettingsPatch{MaxOpenPerUser: num(11)}); err == nil { + t.Error("the database refuses more than 10 open tickets per member") + } + if err := r.UpdateSettings(ctx, g, SettingsPatch{}); err != nil { + t.Errorf("an empty patch is fine: %v", err) + } + + cat, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🎫", Description: "Questions", StaffRoleID: 99}) + if err != nil { + t.Fatal(err) + } + other, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Report"}) + if err != nil { + t.Fatal(err) + } + zero := int64(0) + empty := "" + got, err := r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{Name: str("Help"), Emoji: &empty, StaffRoleID: &zero}) + if err != nil || got == nil { + t.Fatalf("update: %v %v", got, err) + } + if got.Name != "Help" || got.Emoji != "" || got.StaffRoleID != 0 || got.Description != "Questions" { + t.Errorf("only the named fields change, and empty clears: %+v", got) + } + if got, _ = r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{StaffRoleID: func() *int64 { v := int64(5); return &v }()}); got.StaffRoleID != 5 { + t.Errorf("setting the role: %+v", got) + } + if gone, err := r.UpdateCategory(ctx, g+1, cat.ID, CategoryPatch{Name: str("x")}); err != nil || gone != nil { + t.Errorf("another server's category can't be edited: %v %v", gone, err) + } + if _, err := r.UpdateCategory(ctx, g, other.ID, CategoryPatch{Name: str("")}); err == nil { + t.Error("the database refuses an empty name") + } + + for n, status := range []string{"open", "open", "closed"} { + id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: n + 1, CategoryID: cat.ID, CategoryName: "Help", ThreadID: int64(9100 + n), OpenerID: 7, Subject: "x"}) + if err != nil { + t.Fatal(err) + } + if status == "closed" { + if _, err := r.Close(ctx, int64(9100+n), 7, "done"); err != nil { + t.Fatal(err) + } + } + _ = id + } + open, err := r.ListTickets(ctx, g, StatusOpen, 10) + if err != nil || len(open) != 2 || open[0].Number != 2 { + t.Errorf("open tickets, newest first: %v %v", open, err) + } + closed, _ := r.ListTickets(ctx, g, StatusClosed, 10) + if len(closed) != 1 { + t.Errorf("closed tickets: %v", closed) + } + if other, _ := r.ListTickets(ctx, g+1, StatusOpen, 10); len(other) != 0 { + t.Error("another server's tickets must not show") + } +} diff --git a/schema.sql b/schema.sql index ee94ab2..ea2afe5 100644 --- a/schema.sql +++ b/schema.sql @@ -445,3 +445,37 @@ CREATE TABLE IF NOT EXISTS security_commands ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), processed_at TIMESTAMPTZ ); + +-- Wake the bot when a request is added. The bot also processes everything +-- waiting when it starts, so a request is never lost; this only makes it +-- immediate. Whatever writes to these tables (a dashboard, a script) needs no +-- NOTIFY of its own. +CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('music_commands', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS music_commands_notify ON music_commands; +CREATE TRIGGER music_commands_notify AFTER INSERT ON music_commands + FOR EACH ROW EXECUTE FUNCTION notify_music_command(); + +CREATE OR REPLACE FUNCTION notify_ticket_command() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('ticket_commands', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS ticket_commands_notify ON ticket_commands; +CREATE TRIGGER ticket_commands_notify AFTER INSERT ON ticket_commands + FOR EACH ROW EXECUTE FUNCTION notify_ticket_command(); + +CREATE OR REPLACE FUNCTION notify_security_command() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('security_commands', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS security_commands_notify ON security_commands; +CREATE TRIGGER security_commands_notify AFTER INSERT ON security_commands + FOR EACH ROW EXECUTE FUNCTION notify_security_command();