From e6aa5def96c513f8e9a5bd994ab783557d7f330a Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Wed, 18 Mar 2026 05:07:11 +0000 Subject: [PATCH] feat: add welcome message functionality - Introduced a new welcome service and repository for managing welcome messages, channels, and GIFs. - Added commands to set welcome channel, message, DM, and GIF through the bot's configuration. - Implemented event handling for new guild members to send welcome messages and DMs based on configuration. - Updated main application to include the new welcome service in the bot's services. --- internal/bot/bot.go | 6 + internal/commands/config/public/config.go | 210 +++++++++++++++++++++- internal/db/repos/welcomerepo/repo.go | 111 ++++++++++++ internal/db/services/services.go | 5 +- internal/db/services/welcome/service.go | 36 ++++ internal/events/welcome.go | 119 ++++++++++++ main.go | 6 +- schema.sql | 2 - 8 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 internal/db/repos/welcomerepo/repo.go create mode 100644 internal/db/services/welcome/service.go create mode 100644 internal/events/welcome.go diff --git a/internal/bot/bot.go b/internal/bot/bot.go index deba2f3..6288d90 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -30,9 +30,11 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di // Intents needed for: // - InteractionCreate (slash commands): Guilds // - MessageCreate (leveling): GuildMessages + // - GuildMemberAdd (welcome messages): GuildMembers // - VoiceStateUpdate (meeting lobby): GuildVoiceStates // - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions session.Identify.Intents = discordgo.IntentsGuilds | + discordgo.IntentsGuildMembers | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildMessageReactions | @@ -73,6 +75,10 @@ func (b *Bot) Start() error { 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) { events.HandleVoiceStateUpdate(s, vs, b.Services) music.OnVoiceStateUpdate(vs) diff --git a/internal/commands/config/public/config.go b/internal/commands/config/public/config.go index ac8f0bb..d5c640a 100644 --- a/internal/commands/config/public/config.go +++ b/internal/commands/config/public/config.go @@ -55,6 +55,59 @@ var Config = &discordgo.ApplicationCommand{ }, }, }, + { + 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, + }, + }, + }, }, } @@ -67,8 +120,8 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { 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.") + if services.Global == nil { + respondEphemeral(s, i, "Configuration services are not available.") return } @@ -88,6 +141,18 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { 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) default: respondEphemeral(s, i, "Unknown subcommand.") } @@ -193,6 +258,147 @@ func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { 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 respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, diff --git a/internal/db/repos/welcomerepo/repo.go b/internal/db/repos/welcomerepo/repo.go new file mode 100644 index 0000000..a141e53 --- /dev/null +++ b/internal/db/repos/welcomerepo/repo.go @@ -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 +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index fc33ed9..820ecab 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -9,6 +9,7 @@ import ( "velox-bot/internal/db/services/schedule" "velox-bot/internal/db/services/twitch" "velox-bot/internal/db/services/usersettings" + "velox-bot/internal/db/services/welcome" ) type Services struct { @@ -20,11 +21,12 @@ type Services struct { Projects *projects.Service RPS *rps.Service Twitch *twitch.Service + Welcome *welcome.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.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) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, @@ -34,6 +36,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee Projects: projects, RPS: rps, Twitch: twitchSvc, + Welcome: welcomeSvc, } Global = s return s diff --git a/internal/db/services/welcome/service.go b/internal/db/services/welcome/service.go new file mode 100644 index 0000000..a3ef775 --- /dev/null +++ b/internal/db/services/welcome/service.go @@ -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) +} + diff --git a/internal/events/welcome.go b/internal/events/welcome.go new file mode 100644 index 0000000..9c23b8f --- /dev/null +++ b/internal/events/welcome.go @@ -0,0 +1,119 @@ +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 + } + if settings == nil || settings.ChannelID == 0 { + // No welcome channel configured; nothing to do. + return + } + + 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) + } + + // Build guild welcome message. + channelID := strconv.FormatInt(settings.ChannelID, 10) + welcomeMsg := strings.TrimSpace(settings.WelcomeMessage) + if welcomeMsg == "" { + // Default message when none configured. + // "! Welcome to ! Have fun!" + welcomeMsg = "! Welcome to ! Have fun!" + } + welcomeMsg = strings.ReplaceAll(welcomeMsg, "{user}", memberMention) + if guildName != "" { + welcomeMsg = strings.ReplaceAll(welcomeMsg, "{server}", guildName) + welcomeMsg = strings.ReplaceAll(welcomeMsg, "", guildName) + } + welcomeMsg = strings.ReplaceAll(welcomeMsg, "", 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 := strings.TrimSpace(settings.WelcomeDM) + if dmText == "" { + // Default DM when none configured. + // "Welcome to ! Have fun!" + dmText = "Welcome to ! Have fun!" + } + dmText = strings.ReplaceAll(dmText, "{user}", memberMention) + if guildName != "" { + dmText = strings.ReplaceAll(dmText, "{server}", guildName) + dmText = strings.ReplaceAll(dmText, "", 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) + } +} + diff --git a/main.go b/main.go index 2e31265..5c5f499 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/schedulerepo" "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/services" @@ -26,6 +27,7 @@ import ( "velox-bot/internal/db/services/schedule" "velox-bot/internal/db/services/twitch" "velox-bot/internal/db/services/usersettings" + "velox-bot/internal/db/services/welcome" ) func main() { @@ -48,6 +50,7 @@ func main() { projectsRepo := projectsrepo.NewRepo(db) scheduleRepo := schedulerepo.NewRepo(db) userSettingsRepo := usersettingsrepo.NewRepo(db) + welcomeRepo := welcomerepo.NewRepo(db) twitchRepo := twitchrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) @@ -57,7 +60,8 @@ func main() { projectsService := projects.New(projectsRepo) rpsService := rps.New(rpsRepo) twitchService := twitch.New(twitchRepo, config.TwitchClientID) - services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService) + welcomeService := welcome.New(welcomeRepo) + services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services) if err != nil { diff --git a/schema.sql b/schema.sql index 89b5597..e5dfcde 100644 --- a/schema.sql +++ b/schema.sql @@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS twitch ( CREATE TABLE IF NOT EXISTS levelsettings ( guild_id BIGINT PRIMARY KEY, levelsys BOOLEAN NOT NULL DEFAULT FALSE, - role BIGINT, - levelreq INT, message TEXT );