Members open a ticket from a panel in the ticket channel: one button per category (a dropdown past five), then a short form. Each ticket is a private thread named after the category, number and opener, with a first message that mentions the category's staff role so its members join, and Claim and Close buttons. - Claim: staff (the category's role, or Manage Threads / Manage Server) take a ticket; the first message shows who has it. - Close: the opener or staff close it, with an optional reason. The thread is locked and archived, and the log channel gets a summary and a .txt transcript of the whole thread. - /ticket add|remove|claim|close inside a ticket; /tickets setup, /tickets category add|remove|list and /tickets panel for admins. - Per-member limit on open tickets (default 1), per-server ticket numbers handed out atomically, a deleted thread closes its ticket. - The dashboard asks the bot to post or refresh the panel through ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if the database connection drops. - Setup warns about missing bot permissions in the ticket and log channels. The flows live in internal/tickets behind small Discord and store interfaces, with unit tests on fakes; the repo and the listener have integration tests (-tags integration, TICKETS_TEST_DB). Needs the ticket tables (velox-dashboard-api migration 000016_tickets). .gitignore gains the /tldr benchmark rules so private chat exports and benchmark results on disk can't be committed from this branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
168 lines
4.9 KiB
Go
168 lines
4.9 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"time"
|
|
"velox-bot/internal/commands"
|
|
"velox-bot/internal/db/repos/musicrepo"
|
|
"velox-bot/internal/db/services"
|
|
"velox-bot/internal/events"
|
|
"velox-bot/internal/music"
|
|
"velox-bot/internal/tickets"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
type Bot struct {
|
|
Session *discordgo.Session
|
|
AppID string
|
|
GuildID string
|
|
Commands []*discordgo.ApplicationCommand
|
|
registeredCommands []*discordgo.ApplicationCommand
|
|
Services *services.Services
|
|
MusicRepo *musicrepo.Repo
|
|
DBHost string
|
|
LavalinkHost string
|
|
LavalinkPass string
|
|
}
|
|
|
|
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
|
session, err := discordgo.New("Bot " + token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Intents needed for:
|
|
// - InteractionCreate (slash commands): Guilds
|
|
// - MessageCreate (leveling): GuildMessages
|
|
// - GuildMemberAdd (welcome messages): GuildMembers
|
|
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
|
session.Identify.Intents = discordgo.IntentsGuilds |
|
|
discordgo.IntentsGuildBans |
|
|
discordgo.IntentsGuildMembers |
|
|
discordgo.IntentsGuildMessages |
|
|
discordgo.IntentsMessageContent |
|
|
discordgo.IntentsGuildVoiceStates |
|
|
discordgo.IntentsDirectMessages
|
|
|
|
return &Bot{
|
|
Session: session,
|
|
AppID: appID,
|
|
GuildID: guildID,
|
|
Commands: cmds,
|
|
Services: services,
|
|
MusicRepo: musicRepo,
|
|
DBHost: dbHost,
|
|
LavalinkHost: lavalinkHost,
|
|
LavalinkPass: lavalinkPass,
|
|
}, nil
|
|
}
|
|
|
|
func (b *Bot) Start() error {
|
|
if err := b.Session.Open(); err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
|
|
|
go func() {
|
|
if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil {
|
|
log.Printf("music: command listener stopped: %v", err)
|
|
}
|
|
}()
|
|
|
|
if st := tickets.Default(); st != nil {
|
|
go func() {
|
|
// The connection can drop (database restart, network blip):
|
|
// reconnect instead of silently ignoring the dashboard from then on.
|
|
for {
|
|
err := tickets.StartCommandListener(context.Background(), b.DBHost, b.Session, st)
|
|
log.Printf("tickets: command listener stopped: %v (retrying in 10s)", err)
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
}()
|
|
}
|
|
|
|
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
|
for _, cmd := range b.Commands {
|
|
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
|
if err != nil {
|
|
log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
|
|
continue
|
|
}
|
|
b.registeredCommands = append(b.registeredCommands, created)
|
|
}
|
|
|
|
b.Session.AddHandler(commands.HandleInteraction)
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
|
events.HandleMessageCreate(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
|
events.HandleBotAddedOnGuild(s, m, b.Services)
|
|
|
|
// Ignore bot members for welcome/log onboarding of normal users.
|
|
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
|
|
return
|
|
}
|
|
|
|
events.HandleGuildMemberAdd(s, m, b.Services)
|
|
events.HandleGuildMemberAddLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
|
|
events.HandleGuildMemberRemoveLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
|
|
events.HandleGuildMemberUpdateLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) {
|
|
events.HandleMessageUpdateLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
|
|
events.HandleMessageDeleteLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) {
|
|
events.HandleGuildBanAddLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) {
|
|
events.HandleGuildBanRemoveLog(s, m, b.Services)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, t *discordgo.ThreadDelete) {
|
|
if st := tickets.Default(); st != nil && t.Channel != nil {
|
|
tickets.ThreadDeleted(context.Background(), st, t.ID)
|
|
}
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
|
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
|
music.OnVoiceStateUpdate(vs)
|
|
})
|
|
|
|
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
|
|
music.OnVoiceServerUpdate(ev)
|
|
})
|
|
|
|
events.StartScheduleReminderLoop(b.Session, b.Services)
|
|
events.StartTwitchLiveLoop(b.Session, b.Services)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) Close() error {
|
|
for _, cmd := range b.registeredCommands {
|
|
if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil {
|
|
log.Printf("cannot delete '%s' command: %v", cmd.Name, err)
|
|
}
|
|
}
|
|
return b.Session.Close()
|
|
}
|