diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 41fec0f..deba2f3 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -87,6 +87,7 @@ func (b *Bot) Start() error { }) events.StartScheduleReminderLoop(b.Session, b.Services) + events.StartTwitchLiveLoop(b.Session, b.Services) return nil } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index cab5ab2..ee8f9fe 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,6 +1,7 @@ package commands import ( + "velox-bot/internal/commands/config" "velox-bot/internal/commands/fun" "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" @@ -34,6 +35,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ timezone.Timezone, schedule.Schedule, projects.Projects, + config.Config, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ @@ -56,6 +58,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "play": cmdmusic.PlayHandler, "queue": cmdmusic.QueueHandler, "volume": cmdmusic.VolumeHandler, + "config": config.ConfigHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/commands/config/public/config.go b/internal/commands/config/public/config.go new file mode 100644 index 0000000..ac8f0bb --- /dev/null +++ b/internal/commands/config/public/config.go @@ -0,0 +1,205 @@ +package public + +import ( + "context" + "log" + "strconv" + "strings" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Config = &discordgo.ApplicationCommand{ + Name: "config", + Description: "Server configuration", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "addstreamer", + Description: "Add a Twitch streamer for live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "username", + Description: "Twitch username (without https://)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "removestreamer", + Description: "Remove a Twitch streamer from live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "username", + Description: "Twitch username to remove", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "settwitchnotificationchannel", + Description: "Set the channel for Twitch live notifications", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel for notifications", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + }, + }, + }, +} + +func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + respondEphemeral(s, i, "This command can only be used in a server.") + return + } + if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this.") + return + } + if services.Global == nil || services.Global.Twitch == nil { + respondEphemeral(s, i, "Twitch configuration is not available.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + respondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "addstreamer": + log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleAddStreamer(s, i) + case "removestreamer": + log.Printf("twitch: /config removestreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleRemoveStreamer(s, i) + case "settwitchnotificationchannel": + log.Printf("twitch: /config settwitchnotificationchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID) + handleSetChannel(s, i) + default: + respondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleAddStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var username string + for _, o := range opt.Options { + if o.Name == "username" { + username = strings.TrimSpace(o.StringValue()) + } + } + username = strings.TrimPrefix(username, "https://www.twitch.tv/") + username = strings.TrimPrefix(username, "http://www.twitch.tv/") + username = strings.TrimPrefix(username, "twitch.tv/") + username = strings.TrimSpace(username) + + if username == "" { + respondEphemeral(s, i, "Username cannot be empty.") + return + } + + if err := services.Global.Twitch.UpsertStreamer(context.Background(), guildID, username); err != nil { + respondEphemeral(s, i, "Failed to add streamer.") + return + } + + respondEphemeral(s, i, "Added `"+username+"` for Twitch live notifications.") +} + +func handleRemoveStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var username string + for _, o := range opt.Options { + if o.Name == "username" { + username = strings.TrimSpace(o.StringValue()) + } + } + username = strings.TrimSpace(username) + if username == "" { + respondEphemeral(s, i, "Username cannot be empty.") + return + } + + if err := services.Global.Twitch.RemoveStreamer(context.Background(), guildID, username); err != nil { + respondEphemeral(s, i, "Failed to remove streamer.") + return + } + + respondEphemeral(s, i, "Removed `"+username+"` from Twitch live notifications.") +} + +func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid guild ID.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + for _, o := range opt.Options { + if o.Name == "channel" { + chOpt = o + break + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + chID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + respondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Twitch.SetNotificationChannel(context.Background(), guildID, chID); err != nil { + respondEphemeral(s, i, "Failed to set notification channel.") + return + } + + respondEphemeral(s, i, "Twitch notifications will be sent to "+ch.Mention()+".") +} + +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + diff --git a/internal/commands/config/registry.go b/internal/commands/config/registry.go new file mode 100644 index 0000000..248a458 --- /dev/null +++ b/internal/commands/config/registry.go @@ -0,0 +1,14 @@ +package config + +import ( + "velox-bot/internal/commands/config/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Config *discordgo.ApplicationCommand = public.Config +) + +func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ConfigHandler(s, i) } + diff --git a/internal/config/config.go b/internal/config/config.go index c6b3766..fcab283 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { DBHost string LavalinkHost string LavalinkPass string + TwitchClientID string } func LoadConfig() (*Config, error) { @@ -44,12 +45,18 @@ func LoadConfig() (*Config, error) { lavalinkHost := os.Getenv("LAVALINK_HOST") lavalinkPass := os.Getenv("LAVALINK_PASSWORD") + twitchClientID := os.Getenv("TWITCH_CLIENT_ID") + if twitchClientID == "" { + return nil, fmt.Errorf("TWITCH_CLIENT_ID is not set") + } + return &Config{ - BotToken: token, - AppID: appID, - GuildID: guildID, - DBHost: dbHost, - LavalinkHost: lavalinkHost, - LavalinkPass: lavalinkPass, + BotToken: token, + AppID: appID, + GuildID: guildID, + DBHost: dbHost, + LavalinkHost: lavalinkHost, + LavalinkPass: lavalinkPass, + TwitchClientID: twitchClientID, }, nil } diff --git a/internal/db/repos/twitchrepo/repo.go b/internal/db/repos/twitchrepo/repo.go new file mode 100644 index 0000000..f68e441 --- /dev/null +++ b/internal/db/repos/twitchrepo/repo.go @@ -0,0 +1,115 @@ +package twitchrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) ListGuilds(ctx context.Context) ([]int64, error) { + const q = `SELECT DISTINCT guild_id FROM twitch` + rows, err := r.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []int64 + for rows.Next() { + var gid int64 + if err := rows.Scan(&gid); err != nil { + return nil, err + } + ids = append(ids, gid) + } + return ids, rows.Err() +} + +func (r *Repo) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) { + const q = `SELECT twitch_user FROM twitch WHERE guild_id = $1` + rows, err := r.db.QueryContext(ctx, q, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []string + for rows.Next() { + var u string + if err := rows.Scan(&u); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +func (r *Repo) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) { + const q = `SELECT status FROM twitch WHERE guild_id = $1 AND twitch_user = $2` + var status string + err := r.db.QueryRowContext(ctx, q, guildID, username).Scan(&status) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return status, true, nil +} + +func (r *Repo) UpsertStreamer(ctx context.Context, guildID int64, username string) error { + const q = ` + INSERT INTO twitch (twitch_user, guild_id, status) + VALUES ($1, $2, 'not live') + ON CONFLICT (twitch_user, guild_id) + DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, username, guildID) + return err +} + +func (r *Repo) RemoveStreamer(ctx context.Context, guildID int64, username string) error { + const q = `DELETE FROM twitch WHERE guild_id = $1 AND twitch_user = $2` + _, err := r.db.ExecContext(ctx, q, guildID, username) + return err +} + +func (r *Repo) UpdateStatus(ctx context.Context, guildID int64, username, status string) error { + const q = `UPDATE twitch SET status = $1 WHERE guild_id = $2 AND twitch_user = $3` + _, err := r.db.ExecContext(ctx, q, status, guildID, username) + return err +} + +func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) { + const q = `SELECT twitch_channel_id FROM twitch_config WHERE guild_id = $1` + var chID sql.NullInt64 + if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&chID); err != nil { + if err == sql.ErrNoRows { + return 0, false, nil + } + return 0, false, err + } + if !chID.Valid { + return 0, false, nil + } + return chID.Int64, true, nil +} + +func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + INSERT INTO twitch_config (guild_id, twitch_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index 62ce89d..fc33ed9 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -4,10 +4,11 @@ import ( "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/meeting" - "velox-bot/internal/db/services/schedule" - "velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" + "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/twitch" + "velox-bot/internal/db/services/usersettings" ) type Services struct { @@ -18,11 +19,12 @@ type Services struct { UserSettings *usersettings.Service Projects *projects.Service RPS *rps.Service + Twitch *twitch.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, @@ -31,6 +33,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee UserSettings: userSettings, Projects: projects, RPS: rps, + Twitch: twitchSvc, } Global = s return s diff --git a/internal/db/services/twitch/service.go b/internal/db/services/twitch/service.go new file mode 100644 index 0000000..8a2024c --- /dev/null +++ b/internal/db/services/twitch/service.go @@ -0,0 +1,109 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "velox-bot/internal/db/repos/twitchrepo" +) + +type Service struct { + repo *twitchrepo.Repo + client *http.Client + twitchClient string +} + +func New(repo *twitchrepo.Repo, twitchClientID string) *Service { + return &Service{ + repo: repo, + client: &http.Client{ + Timeout: 5 * time.Second, + }, + twitchClient: twitchClientID, + } +} + +func (s *Service) ListGuilds(ctx context.Context) ([]int64, error) { + return s.repo.ListGuilds(ctx) +} + +func (s *Service) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) { + return s.repo.ListUsersForGuild(ctx, guildID) +} + +func (s *Service) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) { + return s.repo.GetStatus(ctx, guildID, username) +} + +func (s *Service) UpdateStatus(ctx context.Context, guildID int64, username, status string) error { + return s.repo.UpdateStatus(ctx, guildID, username, status) +} + +func (s *Service) UpsertStreamer(ctx context.Context, guildID int64, username string) error { + return s.repo.UpsertStreamer(ctx, guildID, username) +} + +func (s *Service) RemoveStreamer(ctx context.Context, guildID int64, username string) error { + return s.repo.RemoveStreamer(ctx, guildID, username) +} + +func (s *Service) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) { + return s.repo.GetNotificationChannel(ctx, guildID) +} + +func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error { + return s.repo.SetNotificationChannel(ctx, guildID, channelID) +} + +func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) { + type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + type gqlResponse struct { + Data struct { + User *struct { + Stream *struct { + ID string `json:"id"` + } `json:"stream"` + } `json:"user"` + } `json:"data"` + } + + q := fmt.Sprintf(`query { user(login: "%s") { stream { id } } }`, username) + body, err := json.Marshal(gqlRequest{ + Query: q, + Variables: map[string]any{}, + }) + if err != nil { + return false, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://gql.twitch.tv/gql", bytes.NewReader(body)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", s.twitchClient) + + resp, err := s.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var respBody gqlResponse + if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { + return false, err + } + + if respBody.Data.User == nil { + return false, nil + } + return respBody.Data.User.Stream != nil, nil +} + diff --git a/internal/events/twitch_live.go b/internal/events/twitch_live.go new file mode 100644 index 0000000..9f3ae7a --- /dev/null +++ b/internal/events/twitch_live.go @@ -0,0 +1,134 @@ +package events + +import ( + "context" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// StartTwitchLiveLoop periodically checks configured Twitch streamers and +// sends live notifications to the configured channel. +func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) { + if svc == nil || svc.Twitch == nil || s == nil { + return + } + + const ( + interval = 30 * time.Second + initialDelay = 10 * time.Second + ) + + go func() { + time.Sleep(initialDelay) + + log.Printf("twitch: starting live notification loop (interval=%s)", interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + ctx := context.Background() + + guildIDs, err := svc.Twitch.ListGuilds(ctx) + if err != nil { + log.Printf("twitch: failed to list guilds: %v", err) + continue + } + + if len(guildIDs) == 0 { + log.Printf("twitch: no guilds with configured streamers") + } + + for _, guildID := range guildIDs { + log.Printf("twitch: processing guild %d", guildID) + + channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID) + if err != nil { + log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err) + continue + } + if !ok || channelID == 0 { + log.Printf("twitch: no notification channel configured for guild %d", guildID) + continue + } + + users, err := svc.Twitch.ListUsersForGuild(ctx, guildID) + if err != nil { + log.Printf("twitch: failed to list users for guild %d: %v", guildID, err) + continue + } + + if len(users) == 0 { + log.Printf("twitch: no streamers configured for guild %d", guildID) + continue + } + + chIDStr := strconv.FormatInt(channelID, 10) + + for _, username := range users { + log.Printf("twitch: checking live status for guild %d, user %s", guildID, username) + + isLive, err := svc.Twitch.IsUserStreaming(ctx, username) + if err != nil { + log.Printf("twitch: failed to check stream for %s: %v", username, err) + continue + } + + status, ok, err := svc.Twitch.GetStatus(ctx, guildID, username) + if err != nil { + log.Printf("twitch: failed to get status for %s: %v", username, err) + continue + } + if !ok { + status = "not live" + } + + if isLive { + if status == "not live" { + log.Printf("twitch: %s went live in guild %d, sending notification to channel %s", username, guildID, chIDStr) + + liveURL := "https://www.twitch.tv/" + username + content := "@everyone" + + previewURL := "https://static-cdn.jtvnw.net/previews-ttv/live_user_" + username + "-640x360.jpg" + + embed := &discordgo.MessageEmbed{ + Title: ":red_circle: " + username + " is now LIVE on Twitch!", + Description: "Click the link below to watch the stream.", + URL: liveURL, + Color: 0x9146FF, // Twitch purple + Image: &discordgo.MessageEmbedImage{ + URL: previewURL, + }, + } + + if _, err := s.ChannelMessageSendComplex(chIDStr, &discordgo.MessageSend{ + Content: content, + Embed: embed, + }); err != nil { + log.Printf("twitch: failed to send notification for %s: %v", username, err) + continue + } + if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "live"); err != nil { + log.Printf("twitch: failed to update status to live for %s in guild %d: %v", username, guildID, err) + } + } + } else { + if status != "not live" { + log.Printf("twitch: %s is no longer live in guild %d, updating status", username, guildID) + if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "not live"); err != nil { + log.Printf("twitch: failed to update status to not live for %s in guild %d: %v", username, guildID, err) + } + } + } + } + } + } + }() +} + diff --git a/main.go b/main.go index 87078d5..2e31265 100644 --- a/main.go +++ b/main.go @@ -15,15 +15,17 @@ import ( "velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/schedulerepo" "velox-bot/internal/db/repos/settingsrepo" + "velox-bot/internal/db/repos/twitchrepo" "velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/services" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/meeting" - "velox-bot/internal/db/services/schedule" - "velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" + "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/twitch" + "velox-bot/internal/db/services/usersettings" ) func main() { @@ -46,6 +48,7 @@ func main() { projectsRepo := projectsrepo.NewRepo(db) scheduleRepo := schedulerepo.NewRepo(db) userSettingsRepo := usersettingsrepo.NewRepo(db) + twitchRepo := twitchrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) meetingService := meeting.New(settingsRepo) @@ -53,7 +56,8 @@ func main() { userSettingsService := usersettings.New(userSettingsRepo) projectsService := projects.New(projectsRepo) rpsService := rps.New(rpsRepo) - services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService) + twitchService := twitch.New(twitchRepo, config.TwitchClientID) + services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services) if err != nil {