Release: open source, and a command for every setting #31
No files matched your search
+1
-16
@@ -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.
|
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:
|
`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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ var Tickets = &discordgo.ApplicationCommand{
|
|||||||
Description: "Set up support tickets",
|
Description: "Set up support tickets",
|
||||||
DefaultMemberPermissions: &manageGuild,
|
DefaultMemberPermissions: &manageGuild,
|
||||||
Contexts: guildOnly,
|
Contexts: guildOnly,
|
||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: append([]*discordgo.ApplicationCommandOption{
|
||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "setup",
|
Name: "setup",
|
||||||
@@ -77,6 +77,7 @@ var Tickets = &discordgo.ApplicationCommand{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
|
||||||
|
categoryEditOption(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,7 +85,7 @@ var Tickets = &discordgo.ApplicationCommand{
|
|||||||
Name: "panel",
|
Name: "panel",
|
||||||
Description: "Post the ticket panel, or update it after changing categories",
|
Description: "Post the ticket panel, or update it after changing categories",
|
||||||
},
|
},
|
||||||
},
|
}, ticketSettingsOptions()...),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket is used inside a ticket thread.
|
// Ticket is used inside a ticket thread.
|
||||||
@@ -166,6 +167,10 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
handleSetup(s, i, sub.Options)
|
handleSetup(s, i, sub.Options)
|
||||||
case "panel":
|
case "panel":
|
||||||
handlePanel(s, i)
|
handlePanel(s, i)
|
||||||
|
case "settings":
|
||||||
|
handleSettings(s, i, sub.Options)
|
||||||
|
case "list":
|
||||||
|
handleList(s, i, sub.Options)
|
||||||
case "category":
|
case "category":
|
||||||
if len(sub.Options) == 0 {
|
if len(sub.Options) == 0 {
|
||||||
return
|
return
|
||||||
@@ -177,6 +182,8 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
handleCategoryRemove(s, i, sub.Options[0].Options)
|
handleCategoryRemove(s, i, sub.Options[0].Options)
|
||||||
case "list":
|
case "list":
|
||||||
handleCategoryList(s, i)
|
handleCategoryList(s, i)
|
||||||
|
case "edit":
|
||||||
|
handleCategoryEdit(s, i, sub.Options[0].Options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"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)
|
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
|
||||||
return err
|
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()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build integration
|
//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
|
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
|
||||||
package ticketrepo
|
package ticketrepo
|
||||||
@@ -46,7 +46,7 @@ func TestSettingsDefaultsAndChannels(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
s, err := r.GetSettings(ctx, g)
|
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)
|
t.Fatalf("defaults wrong: %+v %v", s, err)
|
||||||
}
|
}
|
||||||
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -445,3 +445,37 @@ CREATE TABLE IF NOT EXISTS security_commands (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
processed_at TIMESTAMPTZ
|
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();
|
||||||
Reference in new issue
Block a user