Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df656ca12d | ||
|
|
076e616b9c | ||
|
|
891f3f6ad2 | ||
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
db5d23c2ac | ||
|
|
a85ac23a6b |
@@ -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).
|
||||
+15
-1
@@ -31,7 +31,13 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
||||
// - InteractionCreate (slash commands): Guilds
|
||||
// - MessageCreate (leveling): GuildMessages
|
||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
||||
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates
|
||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||
discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsGuildVoiceStates |
|
||||
discordgo.IntentsGuildMessageReactions |
|
||||
discordgo.IntentsDirectMessages |
|
||||
discordgo.IntentsDirectMessageReactions
|
||||
|
||||
return &Bot{
|
||||
Session: session,
|
||||
@@ -75,6 +81,14 @@ func (b *Bot) Start() error {
|
||||
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
|
||||
music.OnVoiceServerUpdate(ev)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
|
||||
events.HandleMessageReactionAdd(s, r, b.Services)
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/config"
|
||||
"velox-bot/internal/commands/fun"
|
||||
"velox-bot/internal/commands/help"
|
||||
cmdlevel "velox-bot/internal/commands/level"
|
||||
"velox-bot/internal/commands/meeting"
|
||||
cmdmusic "velox-bot/internal/commands/music"
|
||||
"velox-bot/internal/commands/projects"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -29,27 +32,33 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
||||
cmdmusic.Queue,
|
||||
cmdmusic.Volume,
|
||||
meeting.Meeting,
|
||||
timezone.Timezone,
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
config.Config,
|
||||
}
|
||||
|
||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||
"ping": fun.PingHandler,
|
||||
"joke": fun.JokeHandler,
|
||||
"coinflip": fun.CoinflipHandler,
|
||||
"dice": fun.DiceHandler,
|
||||
"rps": fun.RpsHandler,
|
||||
"rpsstats": fun.RpsStatsHandler,
|
||||
"ping": fun.PingHandler,
|
||||
"joke": fun.JokeHandler,
|
||||
"coinflip": fun.CoinflipHandler,
|
||||
"dice": fun.DiceHandler,
|
||||
"rps": fun.RpsHandler,
|
||||
"rpsstats": fun.RpsStatsHandler,
|
||||
"rpsleaderboard": fun.RpsLeaderboardHandler,
|
||||
"help": help.HelpHandler,
|
||||
"slvl": cmdlevel.SlvlHandler,
|
||||
"rank": cmdlevel.RankHandler,
|
||||
"leaderboard": cmdlevel.LeaderboardHandler,
|
||||
"rewards": cmdlevel.RewardsHandler,
|
||||
"meeting": meeting.MeetingHandler,
|
||||
"projects": projects.ProjectsHandler,
|
||||
"play": cmdmusic.PlayHandler,
|
||||
"queue": cmdmusic.QueueHandler,
|
||||
"volume": cmdmusic.VolumeHandler,
|
||||
"help": help.HelpHandler,
|
||||
"slvl": cmdlevel.SlvlHandler,
|
||||
"rank": cmdlevel.RankHandler,
|
||||
"leaderboard": cmdlevel.LeaderboardHandler,
|
||||
"rewards": cmdlevel.RewardsHandler,
|
||||
"meeting": meeting.MeetingHandler,
|
||||
"timezone": timezone.TimezoneHandler,
|
||||
"schedule": schedule.ScheduleHandler,
|
||||
"projects": projects.ProjectsHandler,
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Schedule = &discordgo.ApplicationCommand{
|
||||
Name: "schedule",
|
||||
Description: "Schedule 1:1 sessions",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new session with a user",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to invite",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "When (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "What is this session about? (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "List your upcoming sessions",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "reschedule",
|
||||
Description: "Reschedule an existing session",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "id",
|
||||
Description: "ID of the session (from /schedule list)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "New datetime (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleList(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "create":
|
||||
handleCreate(s, i)
|
||||
case "list":
|
||||
handleList(s, i)
|
||||
case "reschedule":
|
||||
handleReschedule(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var (
|
||||
targetUser *discordgo.User
|
||||
whenStr string
|
||||
desc string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "user":
|
||||
targetUser = o.UserValue(s)
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
case "description":
|
||||
desc = o.StringValue()
|
||||
}
|
||||
}
|
||||
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.ID == i.Member.User.ID {
|
||||
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid target user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve requester's timezone and parse input using explicit layout in that zone.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect layout "2006-01-02 15:04" in the user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
ctx := context.Background()
|
||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create schedule.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send DM to invitee.
|
||||
dmCh, err := s.UserChannelCreate(targetUser.ID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "New session request",
|
||||
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
utcStr := when.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: utcStr,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if desc != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add reactions for interaction.
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
|
||||
}
|
||||
|
||||
// Store linkage between message and schedule.
|
||||
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your sessions.")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
shared.RespondEphemeral(s, i, "You have no upcoming sessions.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, sch := range items {
|
||||
role := "Requester"
|
||||
otherID := sch.InviteeID
|
||||
if sch.InviteeID == userID {
|
||||
role = "Invitee"
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
status := string(sch.Status)
|
||||
whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status)
|
||||
if sch.Description != "" {
|
||||
line += " " + sch.Description + "\n"
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, b.String())
|
||||
}
|
||||
|
||||
func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var (
|
||||
idVal int64
|
||||
whenStr string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "id":
|
||||
idVal = o.IntValue()
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
}
|
||||
}
|
||||
if idVal <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid session ID.")
|
||||
return
|
||||
}
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := services.Global.Schedule.GetByID(ctx, idVal)
|
||||
if err != nil || sch == nil {
|
||||
shared.RespondEphemeral(s, i, "Session not found.")
|
||||
return
|
||||
}
|
||||
if sch.GuildID != guildID {
|
||||
shared.RespondEphemeral(s, i, "This session belongs to another server.")
|
||||
return
|
||||
}
|
||||
if userID != sch.RequesterID && userID != sch.InviteeID {
|
||||
shared.RespondEphemeral(s, i, "You are not part of this session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Use rescheduler's timezone for parsing.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify participants via DM and send new request DM to invitee.
|
||||
whenFmt := when.Format("2006-01-02 15:04")
|
||||
|
||||
otherID := sch.InviteeID
|
||||
if userID == sch.InviteeID {
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
|
||||
// DM both about the change, including their local times if available.
|
||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
||||
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
|
||||
if err != nil {
|
||||
locU = time.UTC
|
||||
zoneU = "UTC"
|
||||
}
|
||||
localU := when.In(locU).Format("2006-01-02 15:04")
|
||||
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
|
||||
if sch.Description != "" {
|
||||
msg += "\nTopic: " + sch.Description
|
||||
}
|
||||
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = s.ChannelMessageSend(ch.ID, msg)
|
||||
}
|
||||
|
||||
// Send new "request" DM to invitee with reactions again.
|
||||
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
|
||||
inviteeUser, err := s.User(inviteeStr)
|
||||
if err == nil && inviteeUser != nil {
|
||||
dmCh, err := s.UserChannelCreate(inviteeStr)
|
||||
if err == nil {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Rescheduled session",
|
||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
||||
Color: 0xffc107,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if errTZ != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err == nil && msgObj != nil {
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
|
||||
}
|
||||
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/schedule/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Schedule *discordgo.ApplicationCommand = public.Schedule
|
||||
)
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if i.GuildID == "" {
|
||||
RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Schedule == nil {
|
||||
RespondEphemeral(s, i, "Scheduling service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) {
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
RespondEphemeral(s, i, "Invalid guild ID.")
|
||||
return 0, false
|
||||
}
|
||||
return guildID, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/timezone/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Timezone = &discordgo.ApplicationCommand{
|
||||
Name: "timezone",
|
||||
Description: "Configure and view your timezone",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set",
|
||||
Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "zone",
|
||||
Description: "Timezone (e.g., Europe/Lisbon, America/New_York)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "show",
|
||||
Description: "Show your current timezone",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireUserSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleShow(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "set":
|
||||
handleSet(s, i)
|
||||
case "show":
|
||||
handleShow(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var zone string
|
||||
for _, o := range opt.Options {
|
||||
if o.Name == "zone" {
|
||||
zone = o.StringValue()
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
shared.RespondEphemeral(s, i, "Timezone cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate via service (which uses time.LoadLocation).
|
||||
if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").")
|
||||
}
|
||||
|
||||
func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your timezone.")
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
|
||||
if userDefined {
|
||||
shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").")
|
||||
} else {
|
||||
shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package timezone
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/timezone/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Timezone *discordgo.ApplicationCommand = public.Timezone
|
||||
)
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.UserSettings == nil {
|
||||
RespondEphemeral(s, i, "User settings service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) {
|
||||
var idStr string
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
idStr = i.Member.User.ID
|
||||
} else if i.User != nil {
|
||||
idStr = i.User.ID
|
||||
}
|
||||
if idStr == "" {
|
||||
return 0, false
|
||||
}
|
||||
uid, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
@@ -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,189 @@
|
||||
package schedulerepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type ScheduleStatus string
|
||||
|
||||
const (
|
||||
StatusPending ScheduleStatus = "pending"
|
||||
StatusAccepted ScheduleStatus = "accepted"
|
||||
StatusDeclined ScheduleStatus = "declined"
|
||||
StatusRescheduleRequested ScheduleStatus = "reschedule_requested"
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
RequesterID int64
|
||||
InviteeID int64
|
||||
ScheduledAt time.Time
|
||||
Status ScheduleStatus
|
||||
Description string
|
||||
ReminderSent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
const q = `
|
||||
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (schedule_id, message_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
|
||||
FROM schedules s
|
||||
JOIN schedule_messages m ON m.schedule_id = s.id
|
||||
WHERE m.message_id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, messageID)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, status, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE guild_id = $1
|
||||
AND (requester_id = $2 OR invitee_id = $2)
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET scheduled_at = $1,
|
||||
status = 'pending',
|
||||
reminder_sent = FALSE
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, when, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded.
|
||||
func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE reminder_sent = FALSE
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
AND scheduled_at > $1
|
||||
AND scheduled_at <= $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET reminder_sent = TRUE
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package usersettingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
const q = `
|
||||
INSERT INTO user_settings (user_id, timezone)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET timezone = EXCLUDED.timezone
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, userID, zone)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) {
|
||||
const q = `
|
||||
SELECT timezone
|
||||
FROM user_settings
|
||||
WHERE user_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, userID)
|
||||
var zone string
|
||||
switch err := row.Scan(&zone); err {
|
||||
case nil:
|
||||
return zone, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return "", false, nil
|
||||
default:
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *schedulerepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *schedulerepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
|
||||
}
|
||||
|
||||
func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
|
||||
}
|
||||
|
||||
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByMessageID(ctx, messageID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListForUser(ctx, guildID, userID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListDueForReminder(ctx, now, ahead)
|
||||
}
|
||||
|
||||
func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
return s.repo.MarkReminded(ctx, scheduleID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
return s.repo.Reschedule(ctx, id, when)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,34 @@ import (
|
||||
"velox-bot/internal/db/services/meeting"
|
||||
"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 {
|
||||
Level *level.Service
|
||||
LevelSettings *levelsettings.Service
|
||||
Meeting *meeting.Service
|
||||
Schedule *schedule.Service
|
||||
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, 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,
|
||||
Meeting: meeting,
|
||||
Schedule: schedule,
|
||||
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,42 @@
|
||||
package usersettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *usersettingsrepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *usersettingsrepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
// validate zone before storing
|
||||
if _, err := time.LoadLocation(zone); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.SetTimezone(ctx, userID, zone)
|
||||
}
|
||||
|
||||
// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined.
|
||||
func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) {
|
||||
zone, ok, err := s.repo.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
return time.UTC, "UTC", false, err
|
||||
}
|
||||
if !ok || zone == "" {
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
// fallback to UTC but indicate not user-defined
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
return loc, zone, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
|
||||
// ✅ accept, ❌ decline, 🔁 request reschedule.
|
||||
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil {
|
||||
return
|
||||
}
|
||||
if r == nil || r.UserID == "" || r.MessageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore bot reactions.
|
||||
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
|
||||
return
|
||||
}
|
||||
|
||||
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
|
||||
if err != nil || msgID64 == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
|
||||
if err != nil || sch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only invitee can react to change status.
|
||||
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending can be transitioned by reaction.
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
var newStatus schedulerepo.ScheduleStatus
|
||||
switch r.Emoji.Name {
|
||||
case "✅":
|
||||
newStatus = schedulerepo.StatusAccepted
|
||||
case "❌":
|
||||
newStatus = schedulerepo.StatusDeclined
|
||||
case "🔁":
|
||||
newStatus = schedulerepo.StatusRescheduleRequested
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
|
||||
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||
schedulerepo.StatusAccepted: "accepted ✅",
|
||||
schedulerepo.StatusDeclined: "declined ❌",
|
||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||
}[newStatus]
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
// Notify requester with local time if configured.
|
||||
if svc.UserSettings != nil {
|
||||
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
|
||||
|
||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
||||
dmCh, err := s.UserChannelCreate(requesterID)
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// StartScheduleReminderLoop runs a background loop that periodically checks for
|
||||
// upcoming sessions and sends DM reminders to all participants.
|
||||
func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
interval = time.Minute // check every minute
|
||||
remindBefore = 10 * time.Minute // remind 10 minutes before
|
||||
initialDelay = 10 * time.Second
|
||||
)
|
||||
|
||||
go func() {
|
||||
// small delay so the bot can fully start
|
||||
time.Sleep(initialDelay)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
ctx := context.Background()
|
||||
|
||||
// Find all sessions that start in the next `remindBefore` window and haven't been reminded.
|
||||
schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore)
|
||||
if err != nil {
|
||||
log.Printf("schedule: reminder query failed: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, sch := range schedules {
|
||||
if sch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
desc := sch.Description
|
||||
|
||||
// Notify requester with local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
locReq = sch.ScheduledAt.UTC().Location()
|
||||
zoneReq = "UTC"
|
||||
}
|
||||
localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04")
|
||||
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID)
|
||||
if desc != "" {
|
||||
base += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.RequesterID, base); err != nil {
|
||||
log.Printf("schedule: failed to DM requester reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify invitee with their local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if err != nil {
|
||||
locInv = sch.ScheduledAt.UTC().Location()
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04")
|
||||
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID)
|
||||
if desc != "" {
|
||||
baseInvitee += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil {
|
||||
log.Printf("schedule: failed to DM invitee reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil {
|
||||
log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func dmUser(s *discordgo.Session, userID int64, content string) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
uid := strconv.FormatInt(userID, 10)
|
||||
ch, err := s.UserChannelCreate(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.ChannelMessageSend(ch.ID, content)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -13,13 +13,19 @@ import (
|
||||
"velox-bot/internal/db/repos/levelrepo"
|
||||
"velox-bot/internal/db/repos/projectsrepo"
|
||||
"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/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() {
|
||||
@@ -40,12 +46,18 @@ func main() {
|
||||
settingsRepo := settingsrepo.NewRepo(db)
|
||||
rpsRepo := rpsrepo.NewRepo(db)
|
||||
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)
|
||||
scheduleService := schedule.New(scheduleRepo)
|
||||
userSettingsService := usersettings.New(userSettingsRepo)
|
||||
projectsService := projects.New(projectsRepo)
|
||||
rpsService := rps.New(rpsRepo)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, 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 {
|
||||
|
||||
+27
-1
@@ -100,4 +100,30 @@ CREATE TABLE IF NOT EXISTS project_members (
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
--
|
||||
--
|
||||
-- Scheduling (one-to-one sessions)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
requester_id BIGINT NOT NULL,
|
||||
invitee_id BIGINT NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
||||
description TEXT,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_messages (
|
||||
schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
|
||||
message_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (schedule_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
timezone TEXT NOT NULL
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user