package main import ( "context" "log/slog" "os" "os/signal" "syscall" "github.com/joho/godotenv" "github.com/leolionad58/ticketbot/internal/claim" "github.com/leolionad58/ticketbot/internal/config" "github.com/leolionad58/ticketbot/internal/db" discordbot "github.com/leolionad58/ticketbot/internal/discord" "github.com/leolionad58/ticketbot/internal/discord/commands" "github.com/leolionad58/ticketbot/internal/discord/components" "github.com/leolionad58/ticketbot/internal/discord/events" "github.com/leolionad58/ticketbot/internal/logger" "github.com/leolionad58/ticketbot/internal/panel" "github.com/leolionad58/ticketbot/internal/tickets" "github.com/leolionad58/ticketbot/internal/transcript" ) func main() { setupLogger() godotenv.Load(".env") //nolint — optional file cfg, err := config.Load("config.yaml") must(err, "load config") cfgProvider := config.NewProvider(cfg) must(os.MkdirAll("data", 0755), "create data dir") must(os.MkdirAll("transcripts", 0755), "create transcripts dir") sqldb, err := db.Open("data/tickets.db") must(err, "open db") defer sqldb.Close() // CLI admin subcommand — handle and exit before starting the bot if handleAdminCLI(sqldb) { return } // Migrate YAML panels to DB on first boot panelRepo := db.NewPanelConfigRepo(sqldb) if err := panel.MigrateYAMLPanels(context.Background(), panelRepo, cfg, os.Getenv("GUILD_ID")); err != nil { slog.Warn("YAML panel migration failed", "err", err) } ticketRepo := db.NewTicketRepo(sqldb) claimRepo := db.NewClaimMessageRepo(sqldb) convocRepo := db.NewConvocationConfigRepo(sqldb) convocPanelRepo := db.NewConvocationPanelRepo(sqldb) adminRepo := db.NewPanelAdminRepo(sqldb) sessionRepo := db.NewPanelSessionRepo(sqldb) auditRepo := db.NewAuditLogRepo(sqldb) token := os.Getenv("DISCORD_TOKEN") if token == "" { slog.Error("DISCORD_TOKEN not set") os.Exit(1) } appID := os.Getenv("DISCORD_APP_ID") guildID := os.Getenv("GUILD_ID") // Build guild allowlist: config yaml takes priority, env GUILD_ID is always included allowedGuilds := append([]string{}, cfg.Bot.AllowedGuilds...) if guildID != "" { found := false for _, id := range allowedGuilds { if id == guildID { found = true break } } if !found { allowedGuilds = append(allowedGuilds, guildID) } } bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID, allowedGuilds) must(err, "create bot") // Services auth := tickets.NewAuthService(cfgProvider) ticketSvc := tickets.NewService(ticketRepo, panelRepo, auth, bot.Session, cfgProvider) claimMgr := claim.NewManager(cfgProvider, ticketRepo, claimRepo) logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider) transcriptGen := transcript.NewGenerator("transcripts", bot.Session) // Commands panelCmd := &commands.PanelCommand{Config: cfgProvider, Auth: auth} ticketCmd := &commands.TicketCommand{ TicketSvc: ticketSvc, TicketRepo: ticketRepo, ClaimMgr: claimMgr, LogSvc: logSvc, Transcript: transcriptGen, Auth: auth, } convocCmd := &commands.ConvocationCommand{ Config: cfgProvider, TicketRepo: ticketRepo, Auth: auth, LogSvc: logSvc, } // Components panelComp := &components.PanelComponent{ TicketSvc: ticketSvc, TicketRepo: ticketRepo, PanelRepo: panelRepo, ClaimMgr: claimMgr, LogSvc: logSvc, Config: cfgProvider, } ticketComp := &components.TicketComponent{ TicketSvc: ticketSvc, TicketRepo: ticketRepo, ClaimMgr: claimMgr, LogSvc: logSvc, Transcript: transcriptGen, Auth: auth, } convocComp := &components.ConvocationComponent{ ConvocRepo: convocRepo, TicketRepo: ticketRepo, Auth: auth, LogSvc: logSvc, } // Router router := &discordbot.Router{ PanelCmd: panelCmd, TicketCmd: ticketCmd, ConvocCmd: convocCmd, PanelComp: panelComp, TicketComp: ticketComp, ConvocComp: convocComp, IsGuildAllowed: bot.IsGuildAllowed, } bot.Session.AddHandler(router.Handle) // Events readyHandler := &events.ReadyEvent{ TicketRepo: ticketRepo, ClaimRepo: claimRepo, ClaimMgr: claimMgr, } bot.Session.AddHandler(readyHandler.Handle) cdHandler := &events.ChannelDeleteHandler{Tickets: ticketRepo} bot.Session.AddHandler(cdHandler.Handle) must(bot.Open(), "open discord session") defer bot.Close() if appID != "" { if err := bot.RegisterCommands(appID); err != nil { slog.Error("register commands", "err", err) } } if err := config.Watch("config.yaml", cfgProvider, nil); err != nil { slog.Warn("config watcher failed to start", "err", err) } // Start panel HTTP server if configured if cfg.Panel.BaseURL != "" || cfg.Panel.Port > 0 { botSvc := discordbot.NewBotService(bot.Session, panelRepo, ticketRepo, convocRepo, convocPanelRepo, guildID, allowedGuilds) panelSrv := panel.NewServer( cfg.Panel, ticketRepo, adminRepo, sessionRepo, auditRepo, panelRepo, convocRepo, convocPanelRepo, guildID, botSvc, ) ctx, cancelPanel := context.WithCancel(context.Background()) go func() { slog.Info("panel server starting", "port", cfg.Panel.Port) if err := panelSrv.Start(ctx); err != nil { slog.Error("panel server stopped", "err", err) } }() defer cancelPanel() } slog.Info("ticketbot ready, Ctrl+C to stop") stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop slog.Info("shutting down") } func setupLogger() { level := slog.LevelInfo switch os.Getenv("LOG_LEVEL") { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError } var handler slog.Handler if os.Getenv("LOG_FORMAT") == "json" { handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) } else { handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) } slog.SetDefault(slog.New(handler)) } func must(err error, msg string) { if err != nil { slog.Error(msg, "err", err) os.Exit(1) } }