Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df656ca12d | ||
|
|
076e616b9c | ||
|
|
891f3f6ad2 |
@@ -0,0 +1,97 @@
|
||||
# Velox Bot
|
||||
|
||||
A Discord bot written in Go with a PostgreSQL backing store.
|
||||
|
||||
It currently includes:
|
||||
|
||||
- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …)
|
||||
- Leveling system (opt-in per server via `/slvl toggle`)
|
||||
- Meeting rooms (voice lobby → auto-created temporary voice channels)
|
||||
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
|
||||
- Projects (list/create projects + helpers)
|
||||
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go (see `go.mod`)
|
||||
- PostgreSQL 16+ (or use the provided `docker-compose.yml`)
|
||||
- (Optional) [Air](https://github.com/air-verse/air) for live reload during development
|
||||
- (Optional) Lavalink if you want music commands
|
||||
|
||||
## Configuration
|
||||
|
||||
The app loads environment variables from `.env` (see `internal/config/config.go`).
|
||||
|
||||
Required:
|
||||
|
||||
- `BOT_TOKEN`: Discord bot token
|
||||
- `APP_ID`: Discord application ID
|
||||
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
|
||||
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
|
||||
|
||||
Optional (music):
|
||||
|
||||
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
|
||||
- `LAVALINK_PASSWORD`: Lavalink password
|
||||
|
||||
## Database setup
|
||||
|
||||
If you use the compose file:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then apply the schema:
|
||||
|
||||
```bash
|
||||
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Plain Go:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
With live reload (Air):
|
||||
|
||||
```bash
|
||||
air
|
||||
```
|
||||
|
||||
If you use `just`, a small workflow is included:
|
||||
|
||||
```bash
|
||||
just up # start postgres
|
||||
just start # start postgres + run air
|
||||
just down # stop postgres
|
||||
```
|
||||
|
||||
## Commands (high-level)
|
||||
|
||||
- **Fun**
|
||||
- `/ping`, `/joke [type]`, `/coinflip`, `/dice <expr>`
|
||||
- `/rps <hand>`, `/rpsstats`, `/rpsleaderboard` (server only)
|
||||
- **Help**
|
||||
- `/help` (sends a DM with command reference)
|
||||
- **Leveling**
|
||||
- `/slvl ...` admin/config
|
||||
- `/rank`, `/leaderboard`, `/rewards`
|
||||
- **Meetings** (**Manage Server**)
|
||||
- `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite`
|
||||
- **Timezone**
|
||||
- `/timezone set`, `/timezone show`
|
||||
- **Scheduling**
|
||||
- `/schedule create`, `/schedule list`, `/schedule reschedule`
|
||||
- **Projects**
|
||||
- `/projects list`, `/projects create`, `/projects add-helper`
|
||||
- **Music** (requires **DJ** role)
|
||||
- `/play`, `/queue`, `/volume`
|
||||
|
||||
## Notes
|
||||
|
||||
- Slash commands are registered to the configured `GUILD_ID` on startup.
|
||||
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
|
||||
@@ -87,6 +87,7 @@ func (b *Bot) Start() error {
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -42,80 +42,73 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Velox Bot Help",
|
||||
Description: "Here is the help for the bot.",
|
||||
Description: "Command reference. Some commands require server permissions or roles.",
|
||||
Color: 0xFFA500,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Fun Commands",
|
||||
Value: "Small utility + games.",
|
||||
Name: "Fun",
|
||||
Value: "" +
|
||||
"`/ping` — Pong!\n" +
|
||||
"`/joke [type]` — Random joke (optional category)\n" +
|
||||
"`/coinflip` — Flip a coin\n" +
|
||||
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
|
||||
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
|
||||
"`/rpsstats` — Your RPS score (server only)\n" +
|
||||
"`/rpsleaderboard` — Top RPS scores (server only)",
|
||||
},
|
||||
{
|
||||
Name: "/ping",
|
||||
Value: "Pong!",
|
||||
Name: "Music (requires **DJ** role)",
|
||||
Value: "" +
|
||||
"`/play <query>` — Play a song (search or URL)\n" +
|
||||
"`/queue` — Show the current queue\n" +
|
||||
"`/volume <0-150>` — Set playback volume",
|
||||
},
|
||||
{
|
||||
Name: "/joke [type]",
|
||||
Value: "Get a random joke (optional category).",
|
||||
Name: "Scheduling (1:1 sessions)",
|
||||
Value: "" +
|
||||
"`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" +
|
||||
"`/schedule list` — List your upcoming sessions\n" +
|
||||
"`/schedule reschedule id:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
|
||||
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
|
||||
},
|
||||
{
|
||||
Name: "/coinflip",
|
||||
Value: "Flip a coin.",
|
||||
Name: "Timezone",
|
||||
Value: "" +
|
||||
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
|
||||
"`/timezone show` — Show your timezone",
|
||||
},
|
||||
{
|
||||
Name: "/dice <expr>",
|
||||
Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).",
|
||||
Name: "Projects",
|
||||
Value: "" +
|
||||
"`/projects list` — List active projects\n" +
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "/rps <hand>",
|
||||
Value: "Play Rock Paper Scissors (server only).",
|
||||
Name: "Meetings (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" +
|
||||
"`/meeting lock` — Lock your meeting room\n" +
|
||||
"`/meeting lock-private` — Lock + hide your meeting room\n" +
|
||||
"`/meeting unlock` — Unlock your meeting room\n" +
|
||||
"`/meeting invite user:@someone` — Allow a user to join\n" +
|
||||
"`/meeting uninvite user:@someone` — Remove a user's permission",
|
||||
},
|
||||
{
|
||||
Name: "/rpsstats",
|
||||
Value: "Show your RPS score (server only).",
|
||||
Name: "Leveling",
|
||||
Value: "" +
|
||||
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
|
||||
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
|
||||
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
|
||||
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
|
||||
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
|
||||
"`/rank [user]` — Your rank card (server only)\n" +
|
||||
"`/leaderboard` — Top levels (server only)\n" +
|
||||
"`/rewards` — List configured rewards (server only)",
|
||||
},
|
||||
{
|
||||
Name: "/rpsleaderboard",
|
||||
Value: "Show top RPS scores (server only).",
|
||||
},
|
||||
{
|
||||
Name: "/help",
|
||||
Value: "Get help with the bot.",
|
||||
},
|
||||
{
|
||||
Name: "Leveling System",
|
||||
Value: `The leveling system is a system that allows users to gain XP and level up. The system is based on the amount of messages sent in a server. The more messages you send, the more XP you gain. The more XP you gain, the higher your level will be. The leveling system is disabled by default, however you can enable/disable it by using the /slvl toggle command. You can also configure the leveling system by using the /slvl command.`,
|
||||
},
|
||||
{
|
||||
Name: "/slvl toggle",
|
||||
Value: "Toggle the leveling system.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set",
|
||||
Value: "Set the level of a user.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-channel",
|
||||
Value: "Set the channel for level-up messages.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-message",
|
||||
Value: "Set the custom level-up message.",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set-reward",
|
||||
Value: "Set a role reward for a level.",
|
||||
},
|
||||
{
|
||||
Name: "/rank [user]",
|
||||
Value: "Show a rank card (server only).",
|
||||
},
|
||||
{
|
||||
Name: "/leaderboard",
|
||||
Value: "Show top levels (server only).",
|
||||
},
|
||||
{
|
||||
Name: "/rewards",
|
||||
Value: "List configured level rewards (server only).",
|
||||
Name: "Other",
|
||||
Value: "`/help` — Show this help in DMs",
|
||||
},
|
||||
},
|
||||
Author: &author,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/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
|
||||
|
||||
@@ -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/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 {
|
||||
|
||||
Reference in New Issue
Block a user