feat: twitch notifications
This commit is contained in:
@@ -87,6 +87,7 @@ func (b *Bot) Start() error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||||
|
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"velox-bot/internal/commands/config"
|
||||||
"velox-bot/internal/commands/fun"
|
"velox-bot/internal/commands/fun"
|
||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
cmdlevel "velox-bot/internal/commands/level"
|
cmdlevel "velox-bot/internal/commands/level"
|
||||||
@@ -34,6 +35,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
timezone.Timezone,
|
timezone.Timezone,
|
||||||
schedule.Schedule,
|
schedule.Schedule,
|
||||||
projects.Projects,
|
projects.Projects,
|
||||||
|
config.Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
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,
|
"play": cmdmusic.PlayHandler,
|
||||||
"queue": cmdmusic.QueueHandler,
|
"queue": cmdmusic.QueueHandler,
|
||||||
"volume": cmdmusic.VolumeHandler,
|
"volume": cmdmusic.VolumeHandler,
|
||||||
|
"config": config.ConfigHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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) }
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ type Config struct {
|
|||||||
DBHost string
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass string
|
LavalinkPass string
|
||||||
|
TwitchClientID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
@@ -44,12 +45,18 @@ func LoadConfig() (*Config, error) {
|
|||||||
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
||||||
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
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{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
LavalinkHost: lavalinkHost,
|
LavalinkHost: lavalinkHost,
|
||||||
LavalinkPass: lavalinkPass,
|
LavalinkPass: lavalinkPass,
|
||||||
|
TwitchClientID: twitchClientID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"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/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"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 {
|
type Services struct {
|
||||||
@@ -18,11 +19,12 @@ type Services struct {
|
|||||||
UserSettings *usersettings.Service
|
UserSettings *usersettings.Service
|
||||||
Projects *projects.Service
|
Projects *projects.Service
|
||||||
RPS *rps.Service
|
RPS *rps.Service
|
||||||
|
Twitch *twitch.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Services
|
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{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
@@ -31,6 +33,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
|||||||
UserSettings: userSettings,
|
UserSettings: userSettings,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
RPS: rps,
|
RPS: rps,
|
||||||
|
Twitch: twitchSvc,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,15 +15,17 @@ import (
|
|||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/schedulerepo"
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
|
"velox-bot/internal/db/repos/twitchrepo"
|
||||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"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/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"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() {
|
func main() {
|
||||||
@@ -46,6 +48,7 @@ func main() {
|
|||||||
projectsRepo := projectsrepo.NewRepo(db)
|
projectsRepo := projectsrepo.NewRepo(db)
|
||||||
scheduleRepo := schedulerepo.NewRepo(db)
|
scheduleRepo := schedulerepo.NewRepo(db)
|
||||||
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||||
|
twitchRepo := twitchrepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
meetingService := meeting.New(settingsRepo)
|
meetingService := meeting.New(settingsRepo)
|
||||||
@@ -53,7 +56,8 @@ func main() {
|
|||||||
userSettingsService := usersettings.New(userSettingsRepo)
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
projectsService := projects.New(projectsRepo)
|
projectsService := projects.New(projectsRepo)
|
||||||
rpsService := rps.New(rpsRepo)
|
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)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user