feat: implement server logging functionality
- Added a logging service to manage server event logs including message edits, deletions, member joins/leaves, and moderation actions. - Introduced commands to configure logging settings, including setting the log channel and enabling/disabling logging. - Updated the README to document the new logging features and commands. - Enhanced moderation commands to log actions taken on members.
This commit is contained in:
@@ -13,6 +13,9 @@ It currently includes:
|
|||||||
- Twitch live notifications (per-guild `/config` setup)
|
- Twitch live notifications (per-guild `/config` setup)
|
||||||
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
|
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
|
||||||
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
|
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
|
||||||
|
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
|
||||||
|
|
||||||
|
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -115,6 +118,10 @@ just down # stop postgres
|
|||||||
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
||||||
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
||||||
- `/config setdefaultrole role:@role` — Role automatically granted on join
|
- `/config setdefaultrole role:@role` — Role automatically granted on join
|
||||||
|
- **Logging** (**Manage Server**)
|
||||||
|
- `/config setlogchannel channel:#channel` — Set where logs are sent
|
||||||
|
- `/config enablelogging` — Enable event/moderation logs
|
||||||
|
- `/config disablelogging` — Disable event/moderation logs
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
// - 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.IntentsGuildBans |
|
||||||
discordgo.IntentsGuildMembers |
|
discordgo.IntentsGuildMembers |
|
||||||
discordgo.IntentsGuildMessages |
|
discordgo.IntentsGuildMessages |
|
||||||
|
discordgo.IntentsMessageContent |
|
||||||
discordgo.IntentsGuildVoiceStates |
|
discordgo.IntentsGuildVoiceStates |
|
||||||
discordgo.IntentsGuildMessageReactions |
|
discordgo.IntentsGuildMessageReactions |
|
||||||
discordgo.IntentsDirectMessages |
|
discordgo.IntentsDirectMessages |
|
||||||
@@ -76,7 +78,39 @@ func (b *Bot) Start() error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
||||||
|
events.HandleBotAddedOnGuild(s, m, b.Services)
|
||||||
|
|
||||||
|
// Ignore bot members for welcome/log onboarding of normal users.
|
||||||
|
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
events.HandleGuildMemberAdd(s, m, b.Services)
|
events.HandleGuildMemberAdd(s, m, b.Services)
|
||||||
|
events.HandleGuildMemberAddLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
|
||||||
|
events.HandleGuildMemberRemoveLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
|
||||||
|
events.HandleGuildMemberUpdateLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) {
|
||||||
|
events.HandleMessageUpdateLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
|
||||||
|
events.HandleMessageDeleteLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) {
|
||||||
|
events.HandleGuildBanAddLog(s, m, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) {
|
||||||
|
events.HandleGuildBanRemoveLog(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
|
|||||||
@@ -121,6 +121,30 @@ var Config = &discordgo.ApplicationCommand{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setlogchannel",
|
||||||
|
Description: "Set the moderation log channel",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionChannel,
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Text channel for logs",
|
||||||
|
Required: true,
|
||||||
|
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "enablelogging",
|
||||||
|
Description: "Enable server logging events",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "disablelogging",
|
||||||
|
Description: "Disable server logging events",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +193,15 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
case "setdefaultrole":
|
case "setdefaultrole":
|
||||||
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
handleSetDefaultRole(s, i)
|
handleSetDefaultRole(s, i)
|
||||||
|
case "setlogchannel":
|
||||||
|
log.Printf("log: /config setlogchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetLogChannel(s, i)
|
||||||
|
case "enablelogging":
|
||||||
|
log.Printf("log: /config enablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetLoggingEnabled(s, i, true)
|
||||||
|
case "disablelogging":
|
||||||
|
log.Printf("log: /config disablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
|
handleSetLoggingEnabled(s, i, false)
|
||||||
default:
|
default:
|
||||||
respondEphemeral(s, i, "Unknown subcommand.")
|
respondEphemeral(s, i, "Unknown subcommand.")
|
||||||
}
|
}
|
||||||
@@ -460,6 +493,79 @@ func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate)
|
|||||||
respondEphemeral(s, i, "Default role set to "+role.Mention()+".")
|
respondEphemeral(s, i, "Default role set to "+role.Mention()+".")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleSetLogChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if services.Global.LogSettings == nil {
|
||||||
|
respondEphemeral(s, i, "Log settings service 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
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid channel ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.LogSettings.SetChannel(context.Background(), guildID, channelID); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to set log channel.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, true); err != nil {
|
||||||
|
respondEphemeral(s, i, "Log channel set, but failed to enable logging.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondEphemeral(s, i, "Logging enabled in "+ch.Mention()+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetLoggingEnabled(s *discordgo.Session, i *discordgo.InteractionCreate, enabled bool) {
|
||||||
|
if services.Global.LogSettings == nil {
|
||||||
|
respondEphemeral(s, i, "Log settings service is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respondEphemeral(s, i, "Invalid guild ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, enabled); err != nil {
|
||||||
|
respondEphemeral(s, i, "Failed to update logging state.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
respondEphemeral(s, i, "Logging is now enabled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondEphemeral(s, i, "Logging is now disabled.")
|
||||||
|
}
|
||||||
|
|
||||||
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
||||||
"`/config setdefaultrole role:@role` — Role granted to new members",
|
"`/config setdefaultrole role:@role` — Role granted to new members",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "Logging (**Manage Server**)",
|
||||||
|
Value: "" +
|
||||||
|
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
|
||||||
|
"`/config enablelogging` — Enable event/moderation logging\n" +
|
||||||
|
"`/config disablelogging` — Disable event/moderation logging",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "Other",
|
Name: "Other",
|
||||||
Value: "`/help` — Show this help in DMs",
|
Value: "`/help` — Show this help in DMs",
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
"velox-bot/internal/modlog"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,6 +241,7 @@ func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, "Deleted 1 message.")
|
respondEphemeral(s, i, "Deleted 1 message.")
|
||||||
|
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +251,7 @@ func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids)))
|
respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids)))
|
||||||
|
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -298,9 +303,11 @@ func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String()))
|
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String()))
|
||||||
|
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason))
|
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason))
|
||||||
|
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -345,9 +352,11 @@ func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
respondEphemeral(s, i, "Kicked "+target.Mention()+".")
|
respondEphemeral(s, i, "Kicked "+target.Mention()+".")
|
||||||
|
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason)
|
respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason)
|
||||||
|
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -399,9 +408,11 @@ func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays))
|
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays))
|
||||||
|
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason))
|
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason))
|
||||||
|
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -433,9 +444,11 @@ func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.")
|
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.")
|
||||||
|
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason)
|
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason)
|
||||||
|
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -472,9 +485,11 @@ func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".")
|
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".")
|
||||||
|
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason)
|
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason)
|
||||||
|
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -519,9 +534,11 @@ func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
if seconds == 0 {
|
if seconds == 0 {
|
||||||
respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".")
|
respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".")
|
||||||
|
sendModerationActionLog(s, i, "Slowmode", "Channel: "+ch.Mention()+"\nSet to: `0s`", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds))
|
respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds))
|
||||||
|
sendModerationActionLog(s, i, "Slowmode", fmt.Sprintf("Channel: %s\nSet to: `%ds`", ch.Mention(), seconds), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPerm(i *discordgo.InteractionCreate, perm int64) bool {
|
func hasPerm(i *discordgo.InteractionCreate, perm int64) bool {
|
||||||
@@ -538,3 +555,24 @@ func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendModerationActionLog(s *discordgo.Session, i *discordgo.InteractionCreate, action, details, reason string) {
|
||||||
|
if services.Global == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moderator := "Unknown"
|
||||||
|
if i != nil && i.Member != nil && i.Member.User != nil {
|
||||||
|
moderator = i.Member.User.Mention()
|
||||||
|
}
|
||||||
|
desc := "Moderator: " + moderator + "\n" + details
|
||||||
|
if strings.TrimSpace(reason) != "" {
|
||||||
|
desc += "\nReason: " + reason
|
||||||
|
}
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Moderation Action: " + action,
|
||||||
|
Color: 0x5865F2,
|
||||||
|
Description: desc,
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
_ = modlog.Send(s, services.Global, i.GuildID, embed)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type LevelReward struct {
|
|||||||
RoleID int64
|
RoleID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogSettings struct {
|
||||||
|
GuildID int64
|
||||||
|
ChannelID int64
|
||||||
|
IsEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo {
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
return &Repo{db: db}
|
return &Repo{db: db}
|
||||||
}
|
}
|
||||||
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetLogChannel(ctx context.Context, guildID int64, channelID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO logsettings (guild_id, log_channel_id, is_enabled)
|
||||||
|
VALUES ($1, $2, TRUE)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET log_channel_id = EXCLUDED.log_channel_id
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetLogEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO logsettings (guild_id, is_enabled)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id)
|
||||||
|
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetLogSettings(ctx context.Context, guildID int64) (*LogSettings, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT log_channel_id, is_enabled
|
||||||
|
FROM logsettings
|
||||||
|
WHERE guild_id = $1
|
||||||
|
`
|
||||||
|
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||||
|
var channelID sql.NullInt64
|
||||||
|
var enabled sql.NullBool
|
||||||
|
switch err := row.Scan(&channelID, &enabled); err {
|
||||||
|
case nil:
|
||||||
|
out := &LogSettings{
|
||||||
|
GuildID: guildID,
|
||||||
|
ChannelID: 0,
|
||||||
|
IsEnabled: false,
|
||||||
|
}
|
||||||
|
if channelID.Valid {
|
||||||
|
out.ChannelID = channelID.Int64
|
||||||
|
}
|
||||||
|
if enabled.Valid {
|
||||||
|
out.IsEnabled = enabled.Bool
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
case sql.ErrNoRows:
|
||||||
|
return &LogSettings{
|
||||||
|
GuildID: guildID,
|
||||||
|
ChannelID: 0,
|
||||||
|
IsEnabled: false,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) HasLogSettings(ctx context.Context, guildID int64) (bool, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM logsettings
|
||||||
|
WHERE guild_id = $1
|
||||||
|
`
|
||||||
|
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||||
|
var one int
|
||||||
|
switch err := row.Scan(&one); err {
|
||||||
|
case nil:
|
||||||
|
return true, nil
|
||||||
|
case sql.ErrNoRows:
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT message
|
SELECT message
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package logsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
settings *settingsrepo.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(settings *settingsrepo.Repo) *Service {
|
||||||
|
return &Service{settings: settings}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
|
return s.settings.SetLogChannel(ctx, guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||||
|
return s.settings.SetLogEnabled(ctx, guildID, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*settingsrepo.LogSettings, error) {
|
||||||
|
return s.settings.GetLogSettings(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HasSettings(ctx context.Context, guildID int64) (bool, error) {
|
||||||
|
return s.settings.HasLogSettings(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"velox-bot/internal/db/services/defaultrole"
|
||||||
"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/logsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
@@ -10,7 +12,6 @@ import (
|
|||||||
"velox-bot/internal/db/services/twitch"
|
"velox-bot/internal/db/services/twitch"
|
||||||
"velox-bot/internal/db/services/usersettings"
|
"velox-bot/internal/db/services/usersettings"
|
||||||
"velox-bot/internal/db/services/welcome"
|
"velox-bot/internal/db/services/welcome"
|
||||||
"velox-bot/internal/db/services/defaultrole"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
@@ -24,11 +25,12 @@ type Services struct {
|
|||||||
Twitch *twitch.Service
|
Twitch *twitch.Service
|
||||||
Welcome *welcome.Service
|
Welcome *welcome.Service
|
||||||
DefaultRole *defaultrole.Service
|
DefaultRole *defaultrole.Service
|
||||||
|
LogSettings *logsettings.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, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.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, logSettingsSvc *logsettings.Service) *Services {
|
||||||
s := &Services{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
@@ -40,6 +42,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
|||||||
Twitch: twitchSvc,
|
Twitch: twitchSvc,
|
||||||
Welcome: welcomeSvc,
|
Welcome: welcomeSvc,
|
||||||
DefaultRole: defaultRoleSvc,
|
DefaultRole: defaultRoleSvc,
|
||||||
|
LogSettings: logSettingsSvc,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
||||||
|
CacheMessageFromCreate(m)
|
||||||
|
|
||||||
if m.Author.Bot || m.GuildID == "" {
|
if m.Author.Bot || m.GuildID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
"velox-bot/internal/modlog"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cachedMessage struct {
|
||||||
|
Content string
|
||||||
|
AuthorID string
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageCache sync.Map // map[messageID]cachedMessage
|
||||||
|
|
||||||
|
func HandleMessageUpdateLog(s *discordgo.Session, m *discordgo.MessageUpdate, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Author != nil && m.Author.Bot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
before := ""
|
||||||
|
if m.BeforeUpdate != nil {
|
||||||
|
before = strings.TrimSpace(m.BeforeUpdate.Content)
|
||||||
|
}
|
||||||
|
if before == "" {
|
||||||
|
if prev, ok := getCachedMessage(m.ID); ok {
|
||||||
|
before = strings.TrimSpace(prev.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
after := strings.TrimSpace(m.Content)
|
||||||
|
if before == after {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID)
|
||||||
|
authorMention := "Unknown"
|
||||||
|
if m.Author != nil {
|
||||||
|
authorMention = m.Author.Mention()
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Message Edited",
|
||||||
|
Color: 0xF1C40F,
|
||||||
|
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\n[Jump to message](%s)", authorMention, m.ChannelID, jumpURL),
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Before", Value: truncateOrPlaceholder(before), Inline: false},
|
||||||
|
{Name: "After", Value: truncateOrPlaceholder(after), Inline: false},
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send message edit log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMessage(m.ID, after, authorIDOrEmpty(m.Author))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleMessageDeleteLog(s *discordgo.Session, m *discordgo.MessageDelete, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authorID := ""
|
||||||
|
authorLabel := "Unknown"
|
||||||
|
content := ""
|
||||||
|
if m.BeforeDelete != nil {
|
||||||
|
if m.BeforeDelete.Author != nil {
|
||||||
|
authorID = m.BeforeDelete.Author.ID
|
||||||
|
authorLabel = m.BeforeDelete.Author.Mention()
|
||||||
|
}
|
||||||
|
content = strings.TrimSpace(m.BeforeDelete.Content)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
if prev, ok := getCachedMessage(m.ID); ok {
|
||||||
|
content = strings.TrimSpace(prev.Content)
|
||||||
|
if authorID == "" && prev.AuthorID != "" {
|
||||||
|
authorID = prev.AuthorID
|
||||||
|
authorLabel = "<@" + prev.AuthorID + ">"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if authorID == "" && m.Author != nil {
|
||||||
|
authorID = m.Author.ID
|
||||||
|
authorLabel = m.Author.Mention()
|
||||||
|
}
|
||||||
|
if authorID == "" {
|
||||||
|
authorLabel = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Message Deleted",
|
||||||
|
Color: 0xE74C3C,
|
||||||
|
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\nMessage ID: `%s`", authorLabel, m.ChannelID, m.ID),
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Content", Value: truncateOrPlaceholder(content), Inline: false},
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send message delete log: %v", err)
|
||||||
|
}
|
||||||
|
deleteCachedMessage(m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGuildMemberAddLog(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Member Joined",
|
||||||
|
Color: 0x2ECC71,
|
||||||
|
Description: fmt.Sprintf("%s joined the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID),
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send member join log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGuildMemberRemoveLog(s *discordgo.Session, m *discordgo.GuildMemberRemove, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to classify this leave as a kick via recent audit log.
|
||||||
|
kickEntry, kickMod := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberKick)
|
||||||
|
title := "Member Left"
|
||||||
|
color := 0x95A5A6
|
||||||
|
description := fmt.Sprintf("%s left the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
|
||||||
|
if kickEntry != nil {
|
||||||
|
title = "Member Kicked"
|
||||||
|
color = 0xE67E22
|
||||||
|
description = fmt.Sprintf("%s was kicked.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
|
||||||
|
if kickMod != nil {
|
||||||
|
description += "\nModerator: " + kickMod.Mention()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(kickEntry.Reason) != "" {
|
||||||
|
description += "\nReason: " + kickEntry.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: title,
|
||||||
|
Color: color,
|
||||||
|
Description: description,
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send member remove log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGuildMemberUpdateLog(s *discordgo.Session, m *discordgo.GuildMemberUpdate, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil || m.BeforeUpdate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
added := diffRoleIDs(m.BeforeUpdate.Roles, m.Roles)
|
||||||
|
removed := diffRoleIDs(m.Roles, m.BeforeUpdate.Roles)
|
||||||
|
if len(added) == 0 && len(removed) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]*discordgo.MessageEmbedField, 0, 2)
|
||||||
|
if len(added) > 0 {
|
||||||
|
fields = append(fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: "Roles Added",
|
||||||
|
Value: joinRoleMentions(added),
|
||||||
|
Inline: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
fields = append(fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: "Roles Removed",
|
||||||
|
Value: joinRoleMentions(removed),
|
||||||
|
Inline: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Member Roles Updated",
|
||||||
|
Color: 0x3498DB,
|
||||||
|
Description: fmt.Sprintf("Member: %s (`%s`)", m.User.Mention(), m.User.ID),
|
||||||
|
Fields: fields,
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send member update log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGuildBanAddLog(s *discordgo.Session, m *discordgo.GuildBanAdd, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanAdd)
|
||||||
|
desc := fmt.Sprintf("%s was banned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
|
||||||
|
if moderator != nil {
|
||||||
|
desc += "\nModerator: " + moderator.Mention()
|
||||||
|
}
|
||||||
|
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
|
||||||
|
desc += "\nReason: " + entry.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Member Banned",
|
||||||
|
Color: 0xC0392B,
|
||||||
|
Description: desc,
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send ban add log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGuildBanRemoveLog(s *discordgo.Session, m *discordgo.GuildBanRemove, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanRemove)
|
||||||
|
desc := fmt.Sprintf("%s was unbanned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
|
||||||
|
if moderator != nil {
|
||||||
|
desc += "\nModerator: " + moderator.Mention()
|
||||||
|
}
|
||||||
|
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
|
||||||
|
desc += "\nReason: " + entry.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Member Unbanned",
|
||||||
|
Color: 0x27AE60,
|
||||||
|
Description: desc,
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
|
||||||
|
log.Printf("log: failed to send ban remove log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRecentAuditEntryForTarget(s *discordgo.Session, guildID, targetID string, action discordgo.AuditLogAction) (*discordgo.AuditLogEntry, *discordgo.User) {
|
||||||
|
if s == nil || guildID == "" || targetID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
logs, err := s.GuildAuditLog(guildID, "", "", int(action), 10)
|
||||||
|
if err != nil || logs == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosen *discordgo.AuditLogEntry
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, e := range logs.AuditLogEntries {
|
||||||
|
if e == nil || e.TargetID != targetID || e.ActionType == nil || *e.ActionType != action {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := discordgo.SnowflakeTimestamp(e.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Keep only very recent entries to reduce false positives.
|
||||||
|
if now.Sub(ts) > 2*time.Minute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chosen = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range logs.Users {
|
||||||
|
if u != nil && u.ID == chosen.UserID {
|
||||||
|
return chosen, u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chosen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateOrPlaceholder(s string) string {
|
||||||
|
v := strings.TrimSpace(s)
|
||||||
|
if v == "" {
|
||||||
|
return "*empty*"
|
||||||
|
}
|
||||||
|
if len(v) <= 1000 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return v[:997] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffRoleIDs(base, compare []string) []string {
|
||||||
|
out := make([]string, 0)
|
||||||
|
for _, id := range base {
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(compare, id) {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinRoleMentions(roleIDs []string) string {
|
||||||
|
if len(roleIDs) == 0 {
|
||||||
|
return "*none*"
|
||||||
|
}
|
||||||
|
const max = 12
|
||||||
|
parts := make([]string, 0, len(roleIDs))
|
||||||
|
for idx, id := range roleIDs {
|
||||||
|
if idx >= max {
|
||||||
|
parts = append(parts, fmt.Sprintf("... and %d more", len(roleIDs)-max))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, "<@&"+id+">")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheMessageFromCreate(m *discordgo.MessageCreate) {
|
||||||
|
if m == nil || m.Message == nil || m.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cacheMessage(m.ID, strings.TrimSpace(m.Content), authorIDOrEmpty(m.Author))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheMessage(messageID, content, authorID string) {
|
||||||
|
if messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageCache.Store(messageID, cachedMessage{
|
||||||
|
Content: content,
|
||||||
|
AuthorID: authorID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedMessage(messageID string) (cachedMessage, bool) {
|
||||||
|
if messageID == "" {
|
||||||
|
return cachedMessage{}, false
|
||||||
|
}
|
||||||
|
raw, ok := messageCache.Load(messageID)
|
||||||
|
if !ok {
|
||||||
|
return cachedMessage{}, false
|
||||||
|
}
|
||||||
|
msg, ok := raw.(cachedMessage)
|
||||||
|
if !ok {
|
||||||
|
return cachedMessage{}, false
|
||||||
|
}
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCachedMessage(messageID string) {
|
||||||
|
if messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageCache.Delete(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorIDOrEmpty(u *discordgo.User) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleBotAddedOnGuild applies safe defaults when this bot is added to a guild
|
||||||
|
// and DMs setup instructions to the inviter (or guild owner fallback).
|
||||||
|
func HandleBotAddedOnGuild(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
|
||||||
|
if s == nil || m == nil || m.Member == nil || m.User == nil || svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.State == nil || s.State.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.User.ID != s.State.User.ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID64, err := strconv.ParseInt(m.GuildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat an existing logsettings row as "already onboarded once".
|
||||||
|
alreadyOnboarded := false
|
||||||
|
if svc.LogSettings != nil {
|
||||||
|
exists, err := svc.LogSettings.HasSettings(context.Background(), guildID64)
|
||||||
|
if err == nil {
|
||||||
|
alreadyOnboarded = exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadyOnboarded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly disable toggleable systems by default.
|
||||||
|
if svc.LevelSettings != nil {
|
||||||
|
if err := svc.LevelSettings.SetLevelSystemEnabled(context.Background(), guildID64, false); err != nil {
|
||||||
|
log.Printf("onboarding: failed to set default level state for guild %s: %v", m.GuildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.LogSettings != nil {
|
||||||
|
if err := svc.LogSettings.SetEnabled(context.Background(), guildID64, false); err != nil {
|
||||||
|
log.Printf("onboarding: failed to set default log state for guild %s: %v", m.GuildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientID := findRecentBotAdderID(s, m.GuildID)
|
||||||
|
if recipientID == "" {
|
||||||
|
recipientID = guildOwnerID(s, m.GuildID)
|
||||||
|
}
|
||||||
|
if recipientID == "" {
|
||||||
|
log.Printf("onboarding: unable to resolve DM recipient for guild %s", m.GuildID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(recipientID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("onboarding: failed creating DM for user %s: %v", recipientID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildName := m.GuildID
|
||||||
|
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
|
||||||
|
guildName = g.Name
|
||||||
|
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
|
||||||
|
guildName = g.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Thanks for adding Velox Bot!",
|
||||||
|
Color: 0x5865F2,
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
"Server: **%s**\n\nFor safety, toggleable systems start **disabled by default**.\nUse these commands in your server to enable features:",
|
||||||
|
guildName,
|
||||||
|
),
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{
|
||||||
|
Name: "Level System",
|
||||||
|
Value: "`/slvl toggle`",
|
||||||
|
Inline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Logging System",
|
||||||
|
Value: "" +
|
||||||
|
"`/config setlogchannel channel:#channel`\n" +
|
||||||
|
"`/config enablelogging`",
|
||||||
|
Inline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Welcome System",
|
||||||
|
Value: "" +
|
||||||
|
"`/config setwelcomechannel channel:#channel`\n" +
|
||||||
|
"`/config setwelcomemessage message:\"...\"`\n" +
|
||||||
|
"`/config setwelcomedm message:\"...\"`",
|
||||||
|
Inline: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Footer: &discordgo.MessageEmbedFooter{
|
||||||
|
Text: "Tip: run /help in any server channel for the full command reference.",
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.ChannelMessageSendEmbed(dmCh.ID, embed); err != nil {
|
||||||
|
log.Printf("onboarding: failed sending setup DM to user %s: %v", recipientID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRecentBotAdderID(s *discordgo.Session, guildID string) string {
|
||||||
|
if s == nil || s.State == nil || s.State.User == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
logs, err := s.GuildAuditLog(guildID, "", "", int(discordgo.AuditLogActionBotAdd), 10)
|
||||||
|
if err != nil || logs == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
botID := s.State.User.ID
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, entry := range logs.AuditLogEntries {
|
||||||
|
if entry == nil || entry.TargetID != botID || entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionBotAdd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := discordgo.SnowflakeTimestamp(entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(ts) > 10*time.Minute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.UserID != "" {
|
||||||
|
return entry.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func guildOwnerID(s *discordgo.Session, guildID string) string {
|
||||||
|
if s == nil || guildID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s.State != nil {
|
||||||
|
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
|
||||||
|
return g.OwnerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, err := s.Guild(guildID); err == nil && g != nil {
|
||||||
|
return g.OwnerID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package modlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Send(s *discordgo.Session, svc *services.Services, guildID string, embed *discordgo.MessageEmbed) error {
|
||||||
|
if s == nil || svc == nil || svc.LogSettings == nil || embed == nil || guildID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gid, err := strconv.ParseInt(guildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := svc.LogSettings.GetSettings(context.Background(), gid)
|
||||||
|
if err != nil || cfg == nil || !cfg.IsEnabled || cfg.ChannelID == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.ChannelMessageSendEmbed(strconv.FormatInt(cfg.ChannelID, 10), embed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"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/logsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
@@ -57,6 +58,7 @@ func main() {
|
|||||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
|
logSettingsService := logsettings.New(settingsRepo)
|
||||||
meetingService := meeting.New(settingsRepo)
|
meetingService := meeting.New(settingsRepo)
|
||||||
scheduleService := schedule.New(scheduleRepo)
|
scheduleService := schedule.New(scheduleRepo)
|
||||||
userSettingsService := usersettings.New(userSettingsRepo)
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
@@ -65,7 +67,7 @@ func main() {
|
|||||||
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
||||||
welcomeService := welcome.New(welcomeRepo)
|
welcomeService := welcome.New(welcomeRepo)
|
||||||
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService)
|
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService)
|
||||||
|
|
||||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user