Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4080ce8aec | ||
|
|
e6aa5def96 | ||
|
|
6410619662 | ||
|
|
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).
|
||||||
@@ -30,9 +30,11 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
// Intents needed for:
|
// Intents needed for:
|
||||||
// - InteractionCreate (slash commands): Guilds
|
// - InteractionCreate (slash commands): Guilds
|
||||||
// - MessageCreate (leveling): GuildMessages
|
// - MessageCreate (leveling): GuildMessages
|
||||||
|
// - GuildMemberAdd (welcome messages): GuildMembers
|
||||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
||||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
||||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||||
|
discordgo.IntentsGuildMembers |
|
||||||
discordgo.IntentsGuildMessages |
|
discordgo.IntentsGuildMessages |
|
||||||
discordgo.IntentsGuildVoiceStates |
|
discordgo.IntentsGuildVoiceStates |
|
||||||
discordgo.IntentsGuildMessageReactions |
|
discordgo.IntentsGuildMessageReactions |
|
||||||
@@ -73,6 +75,10 @@ func (b *Bot) Start() error {
|
|||||||
events.HandleMessageCreate(s, m, b.Services)
|
events.HandleMessageCreate(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
||||||
|
events.HandleGuildMemberAdd(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||||
music.OnVoiceStateUpdate(vs)
|
music.OnVoiceStateUpdate(vs)
|
||||||
@@ -87,6 +93,7 @@ func (b *Bot) Start() error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||||
|
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"velox-bot/internal/commands/config"
|
||||||
"velox-bot/internal/commands/fun"
|
"velox-bot/internal/commands/fun"
|
||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
cmdlevel "velox-bot/internal/commands/level"
|
cmdlevel "velox-bot/internal/commands/level"
|
||||||
@@ -34,6 +35,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
timezone.Timezone,
|
timezone.Timezone,
|
||||||
schedule.Schedule,
|
schedule.Schedule,
|
||||||
projects.Projects,
|
projects.Projects,
|
||||||
|
config.Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
@@ -56,6 +58,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
|||||||
"play": cmdmusic.PlayHandler,
|
"play": cmdmusic.PlayHandler,
|
||||||
"queue": cmdmusic.QueueHandler,
|
"queue": cmdmusic.QueueHandler,
|
||||||
"volume": cmdmusic.VolumeHandler,
|
"volume": cmdmusic.VolumeHandler,
|
||||||
|
"config": config.ConfigHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setwelcomechannel",
|
||||||
|
Description: "Set the channel for welcome messages",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionChannel,
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Text channel for welcomes",
|
||||||
|
Required: true,
|
||||||
|
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setwelcomemessage",
|
||||||
|
Description: "Set the welcome message template shown in the server",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "message",
|
||||||
|
Description: "Use {user} where the new member mention should appear",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setwelcomedm",
|
||||||
|
Description: "Set the welcome DM template sent to new members",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "message",
|
||||||
|
Description: "Use {user} where the new member mention should appear",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setwelcomegif",
|
||||||
|
Description: "Set the GIF/image URL used in welcome messages",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "url",
|
||||||
|
Description: "Direct image or GIF URL",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setdefaultrole",
|
||||||
|
Description: "Set the default role given to new members",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionRole,
|
||||||
|
Name: "role",
|
||||||
|
Description: "Role to assign on join",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
respondEphemeral(s, i, "Configuration services are 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)
|
||||||
|
case "setwelcomechannel":
|
||||||
|
log.Printf("welcome: /config setwelcomechannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetWelcomeChannel(s, i)
|
||||||
|
case "setwelcomemessage":
|
||||||
|
log.Printf("welcome: /config setwelcomemessage invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetWelcomeMessage(s, i)
|
||||||
|
case "setwelcomedm":
|
||||||
|
log.Printf("welcome: /config setwelcomedm invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetWelcomeDM(s, i)
|
||||||
|
case "setwelcomegif":
|
||||||
|
log.Printf("welcome: /config setwelcomegif invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetWelcomeGIF(s, i)
|
||||||
|
case "setdefaultrole":
|
||||||
|
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetDefaultRole(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 handleSetWelcomeChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.Welcome == nil {
|
||||||
|
respondEphemeral(s, i, "Welcome configuration is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Welcome.SetChannel(context.Background(), guildID, chID); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set welcome channel.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Welcome messages will be sent to "+ch.Mention()+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetWelcomeMessage(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.Welcome == nil {
|
||||||
|
respondEphemeral(s, i, "Welcome configuration is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid guild ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := i.ApplicationCommandData().Options[0]
|
||||||
|
var msg string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
if o.Name == "message" {
|
||||||
|
msg = strings.TrimSpace(o.StringValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
respondEphemeral(s, i, "Message cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.Welcome.SetMessage(context.Background(), guildID, msg); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set welcome message.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Welcome message updated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetWelcomeDM(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.Welcome == nil {
|
||||||
|
respondEphemeral(s, i, "Welcome configuration is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid guild ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := i.ApplicationCommandData().Options[0]
|
||||||
|
var msg string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
if o.Name == "message" {
|
||||||
|
msg = strings.TrimSpace(o.StringValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
respondEphemeral(s, i, "DM message cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.Welcome.SetDM(context.Background(), guildID, msg); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set welcome DM.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Welcome DM updated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetWelcomeGIF(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.Welcome == nil {
|
||||||
|
respondEphemeral(s, i, "Welcome configuration is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid guild ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := i.ApplicationCommandData().Options[0]
|
||||||
|
var url string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
if o.Name == "url" {
|
||||||
|
url = strings.TrimSpace(o.StringValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
respondEphemeral(s, i, "URL cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.Welcome.SetGIF(context.Background(), guildID, url); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set welcome GIF.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Welcome GIF updated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.DefaultRole == nil {
|
||||||
|
respondEphemeral(s, i, "Default role configuration is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid guild ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := i.ApplicationCommandData().Options[0]
|
||||||
|
var roleOpt *discordgo.ApplicationCommandInteractionDataOption
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
if o.Name == "role" {
|
||||||
|
roleOpt = o
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roleOpt == nil {
|
||||||
|
respondEphemeral(s, i, "Missing role option.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := roleOpt.RoleValue(s, i.GuildID)
|
||||||
|
if role == nil {
|
||||||
|
respondEphemeral(s, i, "Invalid role.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roleID, err := strconv.ParseInt(role.ID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid role ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.DefaultRole.SetRole(context.Background(), guildID, roleID); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set default role.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Default role set to "+role.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{
|
embed := &discordgo.MessageEmbed{
|
||||||
Title: "Velox Bot Help",
|
Title: "Velox Bot Help",
|
||||||
Description: "Here is the help for the bot.",
|
Description: "Command reference. Some commands require server permissions or roles.",
|
||||||
Color: 0xFFA500,
|
Color: 0xFFA500,
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
{
|
{
|
||||||
Name: "Fun Commands",
|
Name: "Fun",
|
||||||
Value: "Small utility + games.",
|
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",
|
Name: "Music (requires **DJ** role)",
|
||||||
Value: "Pong!",
|
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]",
|
Name: "Scheduling (1:1 sessions)",
|
||||||
Value: "Get a random joke (optional category).",
|
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",
|
Name: "Timezone",
|
||||||
Value: "Flip a coin.",
|
Value: "" +
|
||||||
|
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
|
||||||
|
"`/timezone show` — Show your timezone",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "/dice <expr>",
|
Name: "Projects",
|
||||||
Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).",
|
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>",
|
Name: "Meetings (**Manage Server**)",
|
||||||
Value: "Play Rock Paper Scissors (server only).",
|
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",
|
Name: "Leveling",
|
||||||
Value: "Show your RPS score (server only).",
|
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",
|
Name: "Other",
|
||||||
Value: "Show top RPS scores (server only).",
|
Value: "`/help` — Show this help in DMs",
|
||||||
},
|
|
||||||
{
|
|
||||||
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).",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Author: &author,
|
Author: &author,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Config struct {
|
|||||||
DBHost string
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass string
|
LavalinkPass string
|
||||||
|
TwitchClientID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
@@ -44,6 +45,11 @@ func LoadConfig() (*Config, error) {
|
|||||||
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
||||||
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
||||||
|
|
||||||
|
twitchClientID := os.Getenv("TWITCH_CLIENT_ID")
|
||||||
|
if twitchClientID == "" {
|
||||||
|
return nil, fmt.Errorf("TWITCH_CLIENT_ID is not set")
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
@@ -51,5 +57,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
LavalinkHost: lavalinkHost,
|
LavalinkHost: lavalinkHost,
|
||||||
LavalinkPass: lavalinkPass,
|
LavalinkPass: lavalinkPass,
|
||||||
|
TwitchClientID: twitchClientID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package defaultrolerepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRole returns the configured default role ID for a guild, or 0 if none.
|
||||||
|
func (r *Repo) GetRole(ctx context.Context, guildID int64) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT role_id
|
||||||
|
FROM defaultrole
|
||||||
|
WHERE guild_id = $1
|
||||||
|
`
|
||||||
|
var roleID sql.NullInt64
|
||||||
|
if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&roleID); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !roleID.Valid {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return roleID.Int64, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRole upserts the default role for a guild.
|
||||||
|
func (r *Repo) SetRole(ctx context.Context, guildID, roleID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO defaultrole (guild_id, role_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET role_id = EXCLUDED.role_id
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, roleID)
|
||||||
|
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,111 @@
|
|||||||
|
package welcomerepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
GuildID int64
|
||||||
|
ChannelID int64
|
||||||
|
WelcomeMessage string
|
||||||
|
WelcomeDM string
|
||||||
|
WelcomeGIFURL string
|
||||||
|
HasConfiguration bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT welcome_channel_id, welcome_message, welcome_dm, welcome_gif_url
|
||||||
|
FROM welcome
|
||||||
|
WHERE guild_id = $1
|
||||||
|
`
|
||||||
|
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||||
|
var ch sql.NullInt64
|
||||||
|
var msg, dm, gif sql.NullString
|
||||||
|
|
||||||
|
switch err := row.Scan(&ch, &msg, &dm, &gif); err {
|
||||||
|
case nil:
|
||||||
|
s := &Settings{
|
||||||
|
GuildID: guildID,
|
||||||
|
ChannelID: 0,
|
||||||
|
WelcomeMessage: "",
|
||||||
|
WelcomeDM: "",
|
||||||
|
WelcomeGIFURL: "",
|
||||||
|
HasConfiguration: true,
|
||||||
|
}
|
||||||
|
if ch.Valid {
|
||||||
|
s.ChannelID = ch.Int64
|
||||||
|
}
|
||||||
|
if msg.Valid {
|
||||||
|
s.WelcomeMessage = msg.String
|
||||||
|
}
|
||||||
|
if dm.Valid {
|
||||||
|
s.WelcomeDM = dm.String
|
||||||
|
}
|
||||||
|
if gif.Valid {
|
||||||
|
s.WelcomeGIFURL = gif.String
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
case sql.ErrNoRows:
|
||||||
|
return &Settings{
|
||||||
|
GuildID: guildID,
|
||||||
|
HasConfiguration: false,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpsertChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO welcome (guild_id, welcome_channel_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET welcome_channel_id = EXCLUDED.welcome_channel_id
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpsertMessage(ctx context.Context, guildID int64, message string) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO welcome (guild_id, welcome_message)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET welcome_message = EXCLUDED.welcome_message
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpsertDM(ctx context.Context, guildID int64, dm string) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO welcome (guild_id, welcome_dm)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET welcome_dm = EXCLUDED.welcome_dm
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, dm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpsertGIF(ctx context.Context, guildID int64, gifURL string) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO welcome (guild_id, welcome_gif_url)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET welcome_gif_url = EXCLUDED.welcome_gif_url
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, gifURL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package defaultrole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *defaultrolerepo.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *defaultrolerepo.Repo) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetRole(ctx context.Context, guildID int64) (int64, error) {
|
||||||
|
return s.repo.GetRole(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetRole(ctx context.Context, guildID, roleID int64) error {
|
||||||
|
return s.repo.SetRole(ctx, guildID, roleID)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/schedule"
|
|
||||||
"velox-bot/internal/db/services/usersettings"
|
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
|
"velox-bot/internal/db/services/schedule"
|
||||||
|
"velox-bot/internal/db/services/twitch"
|
||||||
|
"velox-bot/internal/db/services/usersettings"
|
||||||
|
"velox-bot/internal/db/services/welcome"
|
||||||
|
"velox-bot/internal/db/services/defaultrole"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
@@ -18,11 +21,14 @@ type Services struct {
|
|||||||
UserSettings *usersettings.Service
|
UserSettings *usersettings.Service
|
||||||
Projects *projects.Service
|
Projects *projects.Service
|
||||||
RPS *rps.Service
|
RPS *rps.Service
|
||||||
|
Twitch *twitch.Service
|
||||||
|
Welcome *welcome.Service
|
||||||
|
DefaultRole *defaultrole.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Services
|
var Global *Services
|
||||||
|
|
||||||
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services {
|
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service) *Services {
|
||||||
s := &Services{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
@@ -31,6 +37,9 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
|||||||
UserSettings: userSettings,
|
UserSettings: userSettings,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
RPS: rps,
|
RPS: rps,
|
||||||
|
Twitch: twitchSvc,
|
||||||
|
Welcome: welcomeSvc,
|
||||||
|
DefaultRole: defaultRoleSvc,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/twitchrepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *twitchrepo.Repo
|
||||||
|
client *http.Client
|
||||||
|
twitchClient string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *twitchrepo.Repo, twitchClientID string) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
twitchClient: twitchClientID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListGuilds(ctx context.Context) ([]int64, error) {
|
||||||
|
return s.repo.ListGuilds(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) {
|
||||||
|
return s.repo.ListUsersForGuild(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) {
|
||||||
|
return s.repo.GetStatus(ctx, guildID, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateStatus(ctx context.Context, guildID int64, username, status string) error {
|
||||||
|
return s.repo.UpdateStatus(ctx, guildID, username, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpsertStreamer(ctx context.Context, guildID int64, username string) error {
|
||||||
|
return s.repo.UpsertStreamer(ctx, guildID, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RemoveStreamer(ctx context.Context, guildID int64, username string) error {
|
||||||
|
return s.repo.RemoveStreamer(ctx, guildID, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) {
|
||||||
|
return s.repo.GetNotificationChannel(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
|
return s.repo.SetNotificationChannel(ctx, guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) {
|
||||||
|
type gqlRequest struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Variables map[string]any `json:"variables"`
|
||||||
|
}
|
||||||
|
type gqlResponse struct {
|
||||||
|
Data struct {
|
||||||
|
User *struct {
|
||||||
|
Stream *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"stream"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
q := fmt.Sprintf(`query { user(login: "%s") { stream { id } } }`, username)
|
||||||
|
body, err := json.Marshal(gqlRequest{
|
||||||
|
Query: q,
|
||||||
|
Variables: map[string]any{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://gql.twitch.tv/gql", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Client-Id", s.twitchClient)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var respBody gqlResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if respBody.Data.User == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return respBody.Data.User.Stream != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package welcome
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/welcomerepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *welcomerepo.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *welcomerepo.Repo) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*welcomerepo.Settings, error) {
|
||||||
|
return s.repo.GetSettings(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
|
return s.repo.UpsertChannel(ctx, guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetMessage(ctx context.Context, guildID int64, message string) error {
|
||||||
|
return s.repo.UpsertMessage(ctx, guildID, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetDM(ctx context.Context, guildID int64, dm string) error {
|
||||||
|
return s.repo.UpsertDM(ctx, guildID, dm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetGIF(ctx context.Context, guildID int64, gifURL string) error {
|
||||||
|
return s.repo.UpsertGIF(ctx, guildID, gifURL)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGuildMemberAdd sends a configurable welcome message in a guild channel
|
||||||
|
// and an optional configurable DM to the joining user.
|
||||||
|
func HandleGuildMemberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || svc == nil || svc.Welcome == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
guildID := mustParseInt64(m.GuildID)
|
||||||
|
|
||||||
|
settings, err := svc.Welcome.GetSettings(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("welcome: failed to load settings for guild %s: %v", m.GuildID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasWelcomeChannel := settings != nil && settings.ChannelID != 0
|
||||||
|
|
||||||
|
memberMention := m.User.Mention()
|
||||||
|
|
||||||
|
// Resolve guild/server name and user avatar for placeholders / visuals.
|
||||||
|
guildName := ""
|
||||||
|
userAvatarURL := ""
|
||||||
|
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil {
|
||||||
|
guildName = g.Name
|
||||||
|
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil {
|
||||||
|
guildName = g.Name
|
||||||
|
}
|
||||||
|
if m.User.Avatar != "" {
|
||||||
|
userAvatarURL = discordgo.EndpointUserAvatar(m.User.ID, m.User.Avatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasWelcomeChannel {
|
||||||
|
// Build guild welcome message.
|
||||||
|
channelID := strconv.FormatInt(settings.ChannelID, 10)
|
||||||
|
welcomeMsg := strings.TrimSpace(settings.WelcomeMessage)
|
||||||
|
if welcomeMsg == "" {
|
||||||
|
// Default message when none configured.
|
||||||
|
// "<user>! Welcome to <server>! Have fun!"
|
||||||
|
welcomeMsg = "<user>! Welcome to <server>! Have fun!"
|
||||||
|
}
|
||||||
|
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{user}", memberMention)
|
||||||
|
if guildName != "" {
|
||||||
|
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{server}", guildName)
|
||||||
|
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<server>", guildName)
|
||||||
|
}
|
||||||
|
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<user>", memberMention)
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "👋 Welcome!",
|
||||||
|
Description: welcomeMsg,
|
||||||
|
Color: 0xFFA500,
|
||||||
|
Footer: &discordgo.MessageEmbedFooter{
|
||||||
|
Text: "ID: " + m.User.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if userAvatarURL != "" {
|
||||||
|
embed.Author = &discordgo.MessageEmbedAuthor{
|
||||||
|
Name: m.User.Username,
|
||||||
|
IconURL: userAvatarURL,
|
||||||
|
}
|
||||||
|
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{
|
||||||
|
URL: userAvatarURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gif := strings.TrimSpace(settings.WelcomeGIFURL)
|
||||||
|
if gif == "" {
|
||||||
|
// Default GIF when none configured.
|
||||||
|
gif = "https://images-ext-1.discordapp.net/external/uJ6XfdK2WwDnei3RmNWUqSiOVboC4mK9r78TtgVE_9g/https/media.giphy.com/media/XD9o33QG9BoMis7iM4/giphy.gif"
|
||||||
|
}
|
||||||
|
embed.Image = &discordgo.MessageEmbedImage{URL: gif}
|
||||||
|
|
||||||
|
if _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
||||||
|
Content: memberMention,
|
||||||
|
Embed: embed,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("welcome: failed to send channel welcome in guild %s: %v", m.GuildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional DM.
|
||||||
|
dmText := ""
|
||||||
|
if settings != nil {
|
||||||
|
dmText = strings.TrimSpace(settings.WelcomeDM)
|
||||||
|
}
|
||||||
|
if dmText == "" {
|
||||||
|
// Default DM when none configured.
|
||||||
|
// "Welcome to <server>! Have fun!"
|
||||||
|
dmText = "Welcome to <server>! Have fun!"
|
||||||
|
}
|
||||||
|
dmText = strings.ReplaceAll(dmText, "{user}", memberMention)
|
||||||
|
if guildName != "" {
|
||||||
|
dmText = strings.ReplaceAll(dmText, "{server}", guildName)
|
||||||
|
dmText = strings.ReplaceAll(dmText, "<server>", guildName)
|
||||||
|
}
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(m.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("welcome: failed to create DM channel for user %s: %v", m.User.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ChannelMessageSend(dmCh.ID, dmText); err != nil {
|
||||||
|
log.Printf("welcome: failed to send DM welcome to user %s: %v", m.User.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default role assignment.
|
||||||
|
if svc.DefaultRole != nil {
|
||||||
|
roleID, err := svc.DefaultRole.GetRole(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("defaultrole: failed to get default role for guild %s: %v", m.GuildID, err)
|
||||||
|
} else if roleID != 0 {
|
||||||
|
if err := s.GuildMemberRoleAdd(m.GuildID, m.User.ID, strconv.FormatInt(roleID, 10)); err != nil {
|
||||||
|
log.Printf("defaultrole: failed to assign role %d to user %s in guild %s: %v", roleID, m.User.ID, m.GuildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,15 +15,21 @@ import (
|
|||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/schedulerepo"
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
|
"velox-bot/internal/db/repos/welcomerepo"
|
||||||
|
"velox-bot/internal/db/repos/twitchrepo"
|
||||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||||
|
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/schedule"
|
|
||||||
"velox-bot/internal/db/services/usersettings"
|
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
|
"velox-bot/internal/db/services/schedule"
|
||||||
|
"velox-bot/internal/db/services/twitch"
|
||||||
|
"velox-bot/internal/db/services/usersettings"
|
||||||
|
"velox-bot/internal/db/services/welcome"
|
||||||
|
"velox-bot/internal/db/services/defaultrole"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -46,6 +52,9 @@ func main() {
|
|||||||
projectsRepo := projectsrepo.NewRepo(db)
|
projectsRepo := projectsrepo.NewRepo(db)
|
||||||
scheduleRepo := schedulerepo.NewRepo(db)
|
scheduleRepo := schedulerepo.NewRepo(db)
|
||||||
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||||
|
welcomeRepo := welcomerepo.NewRepo(db)
|
||||||
|
twitchRepo := twitchrepo.NewRepo(db)
|
||||||
|
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
meetingService := meeting.New(settingsRepo)
|
meetingService := meeting.New(settingsRepo)
|
||||||
@@ -53,7 +62,10 @@ func main() {
|
|||||||
userSettingsService := usersettings.New(userSettingsRepo)
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
projectsService := projects.New(projectsRepo)
|
projectsService := projects.New(projectsRepo)
|
||||||
rpsService := rps.New(rpsRepo)
|
rpsService := rps.New(rpsRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
|
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
||||||
|
welcomeService := welcome.New(welcomeRepo)
|
||||||
|
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
||||||
|
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService)
|
||||||
|
|
||||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS twitch (
|
|||||||
CREATE TABLE IF NOT EXISTS levelsettings (
|
CREATE TABLE IF NOT EXISTS levelsettings (
|
||||||
guild_id BIGINT PRIMARY KEY,
|
guild_id BIGINT PRIMARY KEY,
|
||||||
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
|
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
role BIGINT,
|
|
||||||
levelreq INT,
|
|
||||||
message TEXT
|
message TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user