From e1038a15cd8df45ca7a647cff0bec3a79954d665 Mon Sep 17 00:00:00 2001 From: Layla Manley Date: Sat, 1 Apr 2023 01:23:37 -0400 Subject: [PATCH] Discord Components & Role Selection (#5) --- ReadMe.md | 3 +- app/bot.go | 7 +- app/component_loader.go | 4 +- app/plugins.go | 12 +-- common/{game_plugin.go => chat_sync.go} | 2 +- common/{component.go => module.go} | 10 +-- core/color.go | 34 +++++++ core/color_test.go | 75 ++++++++++++++++ core/configuration.go | 16 ++++ core/pointers.go | 5 ++ discord/component.go | 29 ++++++ discord/component_action_row.go | 38 ++++++++ discord/component_button.go | 45 ++++++++++ discord/event.go | 1 + discord/role.go | 72 +++++++++++++++ discord/user.go | 36 ++++++++ {components => events}/announce_events.go | 8 +- .../manage_event_channels.go | 6 +- {components => events}/recurring_events.go | 6 +- main.go | 14 ++- modules/announce_events.go | 61 +++++++++++++ modules/manage_event_channels.go | 89 ++++++++++++++++++ modules/recurring_events.go | 43 +++++++++ modules/role_selection.go | 90 +++++++++++++++++++ sample_config.yaml | 21 ++++- 25 files changed, 696 insertions(+), 31 deletions(-) rename common/{game_plugin.go => chat_sync.go} (76%) rename common/{component.go => module.go} (61%) create mode 100644 core/color.go create mode 100644 core/color_test.go create mode 100644 discord/component.go create mode 100644 discord/component_action_row.go create mode 100644 discord/component_button.go create mode 100644 discord/role.go rename {components => events}/announce_events.go (90%) rename {components => events}/manage_event_channels.go (95%) rename {components => events}/recurring_events.go (85%) create mode 100644 modules/announce_events.go create mode 100644 modules/manage_event_channels.go create mode 100644 modules/recurring_events.go create mode 100644 modules/role_selection.go diff --git a/ReadMe.md b/ReadMe.md index 630a3a5..12ac8b5 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -9,6 +9,7 @@ Bird Bot is a discord bot for managing and organizing events for a small discord - Delete text channels after events - Archive text channels after events - Create recurring weekly events +- Role selection ## Usage @@ -16,7 +17,7 @@ To get up and running, install go and you can run `make run`! ## Using Docker -The container is expecting the config file to be located at `/etc/birdbot/birdbot.yaml`. The easily solution here is to mount the conifg with a volume. +The container is expecting the config file to be located at `/etc/birdbot/birdbot.yaml`. The easily solution here is to mount the config with a volume. Example: ```bash diff --git a/app/bot.go b/app/bot.go index 28eb839..6e6b83c 100644 --- a/app/bot.go +++ b/app/bot.go @@ -31,7 +31,7 @@ type Bot struct { onEventUpdatedHandlers [](func(common.Event) error) onEventCompletedHandlers [](func(common.Event) error) - gameModules []common.GameModule + gameModules []common.ChatSyncModule } // Initalize creates the discord session and registers handlers @@ -62,6 +62,11 @@ func (app *Bot) Initialize(cfg *core.Config) error { app.Session.OnEventDelete(app.onEventDelete) app.Session.OnEventUpdate(app.onEventUpdate) + btn := app.Session.NewButton("test", "Click Me") + btn.OnClick(func(user common.User) { + print("clicked") + }) + return nil } diff --git a/app/component_loader.go b/app/component_loader.go index 76b19c3..5263995 100644 --- a/app/component_loader.go +++ b/app/component_loader.go @@ -17,7 +17,7 @@ func NewComponentLoader(bot *Bot) *ComponentLoader { } } -func (loader *ComponentLoader) LoadComponent(component common.Component) { +func (loader *ComponentLoader) LoadComponent(component common.Module) { if err := component.Initialize(loader); err != nil { log.Print("Failed to load component: ", err) } @@ -50,7 +50,7 @@ func (loader *ComponentLoader) OnEventComplete(handler func(common.Event) error) return nil } -func (loader *ComponentLoader) RegisterGameModule(ID string, plugin common.GameModule) error { +func (loader *ComponentLoader) RegisterChatSyncModule(ID string, plugin common.ChatSyncModule) error { return fmt.Errorf("unimplemented") } diff --git a/app/plugins.go b/app/plugins.go index 8193273..e728962 100644 --- a/app/plugins.go +++ b/app/plugins.go @@ -9,7 +9,7 @@ import ( ) // LoadPlugin loads a plugin and returns its component if successful -func LoadPlugin(pluginPath string) common.Component { +func LoadPlugin(pluginPath string) common.Module { plug, err := plugin.Open(pluginPath) if err != nil { @@ -25,8 +25,8 @@ func LoadPlugin(pluginPath string) common.Component { } // Validate component type - var component common.Component - component, ok := sym.(common.Component) + var component common.Module + component, ok := sym.(common.Module) if !ok { log.Printf("Failed to load plugin '%s': Plugin component does not properly implement interface!", pluginPath) } @@ -35,15 +35,15 @@ func LoadPlugin(pluginPath string) common.Component { } // LoadPlugins loads all plugins and componenets in a directory -func LoadPlugins(directory string) []common.Component { +func LoadPlugins(directory string) []common.Module { paths, err := os.ReadDir(directory) if err != nil { log.Printf("Failed to load plugins: %s", err) - return []common.Component{} + return []common.Module{} } - var components []common.Component = make([]common.Component, 0) + var components []common.Module = make([]common.Module, 0) for _, path := range paths { if path.IsDir() { continue diff --git a/common/game_plugin.go b/common/chat_sync.go similarity index 76% rename from common/game_plugin.go rename to common/chat_sync.go index 2e9c805..e51f897 100644 --- a/common/game_plugin.go +++ b/common/chat_sync.go @@ -1,6 +1,6 @@ package common -type GameModule interface { +type ChatSyncModule interface { SendMessage(user string, message string) RecieveMessage(user User, message string) } diff --git a/common/component.go b/common/module.go similarity index 61% rename from common/component.go rename to common/module.go index 0b4223e..a2de761 100644 --- a/common/component.go +++ b/common/module.go @@ -1,12 +1,12 @@ package common -type Component interface { - Initialize(birdbot ComponentManager) error +type Module interface { + Initialize(birdbot ModuleManager) error } -// ComponentManager is the primary way for a component to interact with BirdBot +// ModuleManager is the primary way for a module to interact with BirdBot // by listening to events and committing actions -type ComponentManager interface { +type ModuleManager interface { OnReady(func() error) error OnNotify(func(string) error) error @@ -21,5 +21,5 @@ type ComponentManager interface { CreateEvent(event Event) error Notify(message string) error - RegisterGameModule(ID string, plugin GameModule) error + RegisterChatSyncModule(ID string, plugin ChatSyncModule) error } diff --git a/core/color.go b/core/color.go new file mode 100644 index 0000000..fbdce90 --- /dev/null +++ b/core/color.go @@ -0,0 +1,34 @@ +package core + +import ( + "image/color" + "strconv" + "strings" +) + +// IntToColor converts a hex int to a Go Color +func IntToColor(hex int) color.Color { + r := uint8(hex >> 16 & 0xFF) + g := uint8(hex >> 8 & 0xFF) + b := uint8(hex & 0xFF) + return color.RGBA{r, g, b, 255} +} + +// ColorToInt converts a Go Color to a hex int +func ColorToInt(c color.Color) int { + rgba := color.RGBAModel.Convert(c).(color.RGBA) + hex := int(rgba.R)<<16 | int(rgba.G)<<8 | int(rgba.B) + return hex +} + +// HexToColor coverts hex string to color +func HexToColor(s string) (color.Color, error) { + s = strings.ReplaceAll(s, "#", "") + + hex, err := strconv.ParseInt(s, 16, 32) + if err != nil { + return nil, err + } + return IntToColor(int(hex)), nil + +} diff --git a/core/color_test.go b/core/color_test.go new file mode 100644 index 0000000..ab45843 --- /dev/null +++ b/core/color_test.go @@ -0,0 +1,75 @@ +package core + +import ( + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIntToColor(t *testing.T) { + + // green + hex := 0x00FF00 + expected := color.RGBA{0, 255, 0, 255} + got := IntToColor(hex) + require.Equal(t, expected, got) + + // black + hex = 0x000000 + expected = color.RGBA{0, 0, 0, 255} + got = IntToColor(hex) + require.Equal(t, expected, got) + + // white + hex = 0xFFFFFF + expected = color.RGBA{255, 255, 255, 255} + got = IntToColor(hex) + require.Equal(t, expected, got) + +} + +func TestColorToHex(t *testing.T) { + + // magenta + col := color.RGBA{255, 0, 255, 255} + hex := 0xFF00FF + require.Equal(t, hex, ColorToInt(col)) + + // black + col = color.RGBA{0, 0, 0, 255} + hex = 0x000000 + require.Equal(t, hex, ColorToInt(col)) + + // white + col = color.RGBA{255, 255, 255, 255} + hex = 0xFFFFFF + require.Equal(t, hex, ColorToInt(col)) +} + +func TestHexToColor(t *testing.T) { + + // magenta + hex := "#ff00ff" + col := color.RGBA{255, 0, 255, 255} + + c, err := HexToColor(hex) + require.Nil(t, err) + require.Equal(t, col, c) + + // black + hex = "000000" + col = color.RGBA{0, 0, 0, 255} + + c, err = HexToColor(hex) + require.Nil(t, err) + require.Equal(t, col, c) + + // white + hex = "ffffff" + col = color.RGBA{255, 255, 255, 255} + + c, err = HexToColor(hex) + require.Nil(t, err) + require.Equal(t, col, c) +} diff --git a/core/configuration.go b/core/configuration.go index 897fec7..2bb2bc0 100644 --- a/core/configuration.go +++ b/core/configuration.go @@ -17,6 +17,21 @@ type DiscordConfig struct { EventCategory string `yaml:"event_category" env:"DISCORD_EVENT_CATEGORY"` ArchiveCategory string `yaml:"archive_category" env:"DISCORD_ARCHIVE_CATEGORY"` NotificationChannel string `yaml:"notification_channel" env:"DISCORD_NOTIFICATION_CHANNEL"` + + RoleSelections []RoleSelectionConfig `yaml:"role_selection"` +} + +type RoleSelectionConfig struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + + SelectionChannel string `yaml:"discord_channel"` + Roles []RoleConfig `yaml:"roles"` +} + +type RoleConfig struct { + RoleName string `yaml:"name"` + Color string `yaml:"color"` } // MastodonConfig contains mastodon specific configuration @@ -33,6 +48,7 @@ type Features struct { ManageEventChannels Feature `yaml:"manage_event_channels" env:"BIRD_EVENT_CHANNELS"` AnnounceEvents Feature `yaml:"announce_events" env:"BIRD_ANNOUNCE_EVENTS"` ReccurringEvents Feature `yaml:"recurring_events" env:"BIRD_RECURRING_EVENTS"` + RoleSelection Feature `yaml:"role_selection" env:"BIRD_ROLE_SELECTION"` LoadGamePlugins Feature `yaml:"load_game_plugins" env:"BIRD_LOAD_GAME_PLUGINS"` } diff --git a/core/pointers.go b/core/pointers.go index e27fdd1..1d46de4 100644 --- a/core/pointers.go +++ b/core/pointers.go @@ -4,3 +4,8 @@ package core func Bool(v bool) *bool { return &v } + +// Int returns a pointer to an int +func Int(v int) *int { + return &v +} diff --git a/discord/component.go b/discord/component.go new file mode 100644 index 0000000..48540f4 --- /dev/null +++ b/discord/component.go @@ -0,0 +1,29 @@ +package discord + +import ( + "log" + + "github.com/bwmarrin/discordgo" +) + +// Component is an object that can be formatted as a discord component +type Component interface { + toMessageComponent() discordgo.MessageComponent +} + +// CreateMessageComponent creates a discord component +func (discord *Discord) CreateMessageComponent(channelID string, content string, components []Component) { + + dComponents := make([]discordgo.MessageComponent, len(components)) + for i, v := range components { + dComponents[i] = v.toMessageComponent() + } + + if _, err := discord.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Components: dComponents, + Content: content, + }); err != nil { + log.Print(err) + } + +} diff --git a/discord/component_action_row.go b/discord/component_action_row.go new file mode 100644 index 0000000..fbe4c4f --- /dev/null +++ b/discord/component_action_row.go @@ -0,0 +1,38 @@ +package discord + +import "github.com/bwmarrin/discordgo" + +type ActionRow struct { + components []Component +} + +// NewActionRow creates an empty action row component +func (discord *Discord) NewActionRow() *ActionRow { + return &ActionRow{ + components: []Component{}, + } +} + +// NewActionRowWith creates an action row with a set of components +func (discord *Discord) NewActionRowWith(comp []Component) *ActionRow { + return &ActionRow{ + components: comp, + } +} + +// AddComponent adds a component to the action row +func (row *ActionRow) AddComponent(comp Component) { + row.components = append(row.components, comp) +} + +func (row *ActionRow) toMessageComponent() discordgo.MessageComponent { + + comps := make([]discordgo.MessageComponent, len(row.components)) + for i, v := range row.components { + comps[i] = v.toMessageComponent() + } + + return discordgo.ActionsRow{ + Components: comps, + } +} diff --git a/discord/component_button.go b/discord/component_button.go new file mode 100644 index 0000000..8cff66b --- /dev/null +++ b/discord/component_button.go @@ -0,0 +1,45 @@ +package discord + +import ( + "github.com/bwmarrin/discordgo" + "github.com/yeslayla/birdbot/common" +) + +type Button struct { + Label string + ID string + + discord *Discord +} + +// NewButton creates a new button component +func (discord *Discord) NewButton(id string, label string) *Button { + return &Button{ + discord: discord, + ID: id, + Label: label, + } +} + +// OnClick registers an event when the button is clicked +func (button *Button) OnClick(action func(user common.User)) { + button.discord.session.AddHandler(func(s *discordgo.Session, r *discordgo.InteractionCreate) { + if r.MessageComponentData().CustomID == button.ID { + + action(NewUser(r.Member.User)) + + s.InteractionRespond(r.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + }) + } + }) + +} + +func (button *Button) toMessageComponent() discordgo.MessageComponent { + return discordgo.Button{ + Label: button.Label, + CustomID: button.ID, + Style: discordgo.PrimaryButton, + } +} diff --git a/discord/event.go b/discord/event.go index 6cad111..45d6169 100644 --- a/discord/event.go +++ b/discord/event.go @@ -39,6 +39,7 @@ func NewEvent(guildEvent *discordgo.GuildScheduledEvent) common.Event { return event } +// CreateEvent creates a new discord event func (discord *Discord) CreateEvent(event common.Event) error { params := &discordgo.GuildScheduledEventParams{ diff --git a/discord/role.go b/discord/role.go new file mode 100644 index 0000000..baf5448 --- /dev/null +++ b/discord/role.go @@ -0,0 +1,72 @@ +package discord + +import ( + "image/color" + "log" + + "github.com/bwmarrin/discordgo" + "github.com/yeslayla/birdbot/core" +) + +type Role struct { + discord *Discord + ID string + + Name string + Color color.Color +} + +// GetRole returns a role that exists on Discord +func (discord *Discord) GetRole(name string) *Role { + + roles, err := discord.session.GuildRoles(discord.guildID) + if err != nil { + log.Printf("Error occured listing roles: %s", err) + return nil + } + + for _, role := range roles { + if role.Managed { + continue + } + if role.Name == name { + + return &Role{ + Name: role.Name, + Color: core.IntToColor(role.Color), + discord: discord, + ID: role.ID, + } + } + } + + return nil +} + +// GetRoleAndCreate gets a role and creates it if it doesn't exist +func (discord *Discord) GetRoleAndCreate(name string) *Role { + role := discord.GetRole(name) + if role != nil { + return role + } + + if _, err := discord.session.GuildRoleCreate(discord.guildID, &discordgo.RoleParams{ + Name: name, + Color: core.Int(0), + }); err != nil { + log.Printf("Failed to create role: %s", err) + return nil + } + + return discord.GetRole(name) +} + +// Save updates the role on Discord +func (role *Role) Save() { + if _, err := role.discord.session.GuildRoleEdit(role.discord.guildID, role.ID, &discordgo.RoleParams{ + Name: role.Name, + Color: core.Int(core.ColorToInt(role.Color)), + }); err != nil { + log.Printf("Failed to save role: %s", err) + } +} diff --git a/discord/user.go b/discord/user.go index 6c096a8..eb5776b 100644 --- a/discord/user.go +++ b/discord/user.go @@ -9,6 +9,7 @@ import ( // NewUser creates a new user object from a discordgo.User object func NewUser(user *discordgo.User) common.User { + if user == nil { log.Print("Cannot user object, user is nil!") return common.User{ @@ -20,3 +21,38 @@ func NewUser(user *discordgo.User) common.User { ID: user.ID, } } + +// AssignRole adds a role to a user +func (discord *Discord) AssignRole(user common.User, role *Role) error { + return discord.session.GuildMemberRoleAdd(discord.guildID, user.ID, role.ID) +} + +// UnassignRole removes a role from a user +func (discord *Discord) UnassignRole(user common.User, role *Role) error { + return discord.session.GuildMemberRoleRemove(discord.guildID, user.ID, role.ID) +} + +// HasRole returns true when a user has a given role +func (discord *Discord) HasRole(user common.User, role *Role) bool { + return discord.HasAtLeastOneRole(user, []*Role{role}) +} + +// HasAtLeastOneRole returns true when a user has at one role from a given array +func (discord *Discord) HasAtLeastOneRole(user common.User, roles []*Role) bool { + + member, err := discord.session.GuildMember(discord.guildID, user.ID) + if err != nil { + log.Printf("Failed to get member: %s", err) + return false + } + + for _, v := range member.Roles { + for _, targetRole := range roles { + if v == targetRole.ID { + return true + } + } + } + + return false +} diff --git a/components/announce_events.go b/events/announce_events.go similarity index 90% rename from components/announce_events.go rename to events/announce_events.go index 387db71..792da3b 100644 --- a/components/announce_events.go +++ b/events/announce_events.go @@ -1,4 +1,4 @@ -package components +package events import ( "fmt" @@ -8,13 +8,13 @@ import ( ) type announceEventsComponent struct { - bot common.ComponentManager + bot common.ModuleManager mastodon *mastodon.Mastodon guildID string } // NewAnnounceEventsComponent creates a new component -func NewAnnounceEventsComponent(mastodon *mastodon.Mastodon, guildID string) common.Component { +func NewAnnounceEventsComponent(mastodon *mastodon.Mastodon, guildID string) common.Module { return &announceEventsComponent{ mastodon: mastodon, guildID: guildID, @@ -22,7 +22,7 @@ func NewAnnounceEventsComponent(mastodon *mastodon.Mastodon, guildID string) com } // Initialize registers event listeners -func (c *announceEventsComponent) Initialize(birdbot common.ComponentManager) error { +func (c *announceEventsComponent) Initialize(birdbot common.ModuleManager) error { c.bot = birdbot _ = birdbot.OnEventCreate(c.OnEventCreate) diff --git a/components/manage_event_channels.go b/events/manage_event_channels.go similarity index 95% rename from components/manage_event_channels.go rename to events/manage_event_channels.go index 710bdfe..98b0ece 100644 --- a/components/manage_event_channels.go +++ b/events/manage_event_channels.go @@ -1,4 +1,4 @@ -package components +package events import ( "log" @@ -15,7 +15,7 @@ type manageEventChannelsComponent struct { } // NewManageEventChannelsComponent creates a new component -func NewManageEventChannelsComponent(categoryID string, archiveCategoryID string, session *discord.Discord) common.Component { +func NewManageEventChannelsComponent(categoryID string, archiveCategoryID string, session *discord.Discord) common.Module { return &manageEventChannelsComponent{ session: session, categoryID: categoryID, @@ -24,7 +24,7 @@ func NewManageEventChannelsComponent(categoryID string, archiveCategoryID string } // Initialize registers event listeners -func (c *manageEventChannelsComponent) Initialize(birdbot common.ComponentManager) error { +func (c *manageEventChannelsComponent) Initialize(birdbot common.ModuleManager) error { _ = birdbot.OnEventCreate(c.OnEventCreate) _ = birdbot.OnEventComplete(c.OnEventComplete) _ = birdbot.OnEventDelete(c.OnEventDelete) diff --git a/components/recurring_events.go b/events/recurring_events.go similarity index 85% rename from components/recurring_events.go rename to events/recurring_events.go index cdfffea..bc0fb86 100644 --- a/components/recurring_events.go +++ b/events/recurring_events.go @@ -1,4 +1,4 @@ -package components +package events import ( "log" @@ -13,12 +13,12 @@ type recurringEventsComponent struct { } // NewRecurringEventsComponent creates a new component instance -func NewRecurringEventsComponent() common.Component { +func NewRecurringEventsComponent() common.Module { return &recurringEventsComponent{} } // Initialize registers event listeners -func (c *recurringEventsComponent) Initialize(birdbot common.ComponentManager) error { +func (c *recurringEventsComponent) Initialize(birdbot common.ModuleManager) error { _ = birdbot.OnEventComplete(c.OnEventComplete) return nil diff --git a/main.go b/main.go index 898fd0c..eeb899f 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,8 @@ import ( "github.com/ilyakaznacheev/cleanenv" "github.com/yeslayla/birdbot/app" - "github.com/yeslayla/birdbot/components" "github.com/yeslayla/birdbot/core" + "github.com/yeslayla/birdbot/modules" ) const PluginsDirectory = "./plugins" @@ -59,13 +59,19 @@ func main() { loader := app.NewComponentLoader(bot) if cfg.Features.AnnounceEvents.IsEnabledByDefault() { - loader.LoadComponent(components.NewAnnounceEventsComponent(bot.Mastodon, cfg.Discord.NotificationChannel)) + loader.LoadComponent(modules.NewAnnounceEventsComponent(bot.Mastodon, cfg.Discord.NotificationChannel)) } if cfg.Features.ManageEventChannels.IsEnabledByDefault() { - loader.LoadComponent(components.NewManageEventChannelsComponent(cfg.Discord.EventCategory, cfg.Discord.ArchiveCategory, bot.Session)) + loader.LoadComponent(modules.NewManageEventChannelsComponent(cfg.Discord.EventCategory, cfg.Discord.ArchiveCategory, bot.Session)) } if cfg.Features.ReccurringEvents.IsEnabledByDefault() { - loader.LoadComponent(components.NewRecurringEventsComponent()) + loader.LoadComponent(modules.NewRecurringEventsComponent()) + } + + if cfg.Features.RoleSelection.IsEnabledByDefault() { + for _, v := range cfg.Discord.RoleSelections { + loader.LoadComponent(modules.NewRoleSelectionComponent(bot.Session, v)) + } } if _, err := os.Stat(PluginsDirectory); !os.IsNotExist(err) { diff --git a/modules/announce_events.go b/modules/announce_events.go new file mode 100644 index 0000000..83c2397 --- /dev/null +++ b/modules/announce_events.go @@ -0,0 +1,61 @@ +package modules + +import ( + "fmt" + + "github.com/yeslayla/birdbot/common" + "github.com/yeslayla/birdbot/mastodon" +) + +type announceEventsModule struct { + bot common.ModuleManager + mastodon *mastodon.Mastodon + guildID string +} + +// NewAnnounceEventsComponent creates a new component +func NewAnnounceEventsComponent(mastodon *mastodon.Mastodon, guildID string) common.Module { + return &announceEventsModule{ + mastodon: mastodon, + guildID: guildID, + } +} + +// Initialize registers event listeners +func (c *announceEventsModule) Initialize(birdbot common.ModuleManager) error { + c.bot = birdbot + + _ = birdbot.OnEventCreate(c.OnEventCreate) + _ = birdbot.OnEventDelete(c.OnEventDelete) + + return nil +} + +// OnEventCreate notifies about the event creation to given providers +func (c *announceEventsModule) OnEventCreate(e common.Event) error { + eventURL := fmt.Sprintf("https://discordapp.com/events/%s/%s", c.guildID, e.ID) + c.bot.Notify(fmt.Sprintf("%s is organizing an event '%s': %s", e.Organizer.DiscordMention(), e.Name, eventURL)) + + // Toot an announcement if Mastodon is configured + if c.mastodon != nil { + err := c.mastodon.Toot(fmt.Sprintf("A new event has been organized '%s': %s", e.Name, eventURL)) + if err != nil { + fmt.Println("Failed to send Mastodon Toot:", err) + } + } + + return nil +} + +func (c *announceEventsModule) OnEventDelete(e common.Event) error { + _ = c.bot.Notify(fmt.Sprintf("%s cancelled '%s' on %s, %d!", e.Organizer.DiscordMention(), e.Name, e.DateTime.Month().String(), e.DateTime.Day())) + + if c.mastodon != nil { + err := c.mastodon.Toot(fmt.Sprintf("'%s' cancelled on %s, %d!", e.Name, e.DateTime.Month().String(), e.DateTime.Day())) + if err != nil { + fmt.Println("Failed to send Mastodon Toot:", err) + } + } + + return nil +} diff --git a/modules/manage_event_channels.go b/modules/manage_event_channels.go new file mode 100644 index 0000000..9884895 --- /dev/null +++ b/modules/manage_event_channels.go @@ -0,0 +1,89 @@ +package modules + +import ( + "log" + + "github.com/yeslayla/birdbot/common" + "github.com/yeslayla/birdbot/core" + "github.com/yeslayla/birdbot/discord" +) + +type manageEventChannelsModule struct { + session *discord.Discord + categoryID string + archiveCategoryID string +} + +// NewManageEventChannelsComponent creates a new component +func NewManageEventChannelsComponent(categoryID string, archiveCategoryID string, session *discord.Discord) common.Module { + return &manageEventChannelsModule{ + session: session, + categoryID: categoryID, + archiveCategoryID: archiveCategoryID, + } +} + +// Initialize registers event listeners +func (c *manageEventChannelsModule) Initialize(birdbot common.ModuleManager) error { + _ = birdbot.OnEventCreate(c.OnEventCreate) + _ = birdbot.OnEventComplete(c.OnEventComplete) + _ = birdbot.OnEventDelete(c.OnEventDelete) + + return nil +} + +// OnEventCreate creates a new channel for an event and moves it to a given category +func (c *manageEventChannelsModule) OnEventCreate(e common.Event) error { + channel, err := c.session.NewChannelFromName(core.GenerateChannelFromEvent(e).Name) + if err != nil { + log.Print("Failed to create channel for event: ", err) + } + + if c.categoryID != "" { + err = c.session.MoveChannelToCategory(channel, c.categoryID) + if err != nil { + log.Printf("Failed to move channel to events category '%s': %v", channel.Name, err) + } + } + return nil +} + +// OnEventDelete deletes the channel associated with the given event +func (c *manageEventChannelsModule) OnEventDelete(e common.Event) error { + _, err := c.session.DeleteChannel(core.GenerateChannelFromEvent(e)) + if err != nil { + log.Print("Failed to create channel for event: ", err) + } + return nil +} + +// OnEventComplete archives a given event channel if not given +// an archive category will delete the channel instead +func (c *manageEventChannelsModule) OnEventComplete(e common.Event) error { + channel := core.GenerateChannelFromEvent(e) + + if c.archiveCategoryID != "" { + + if err := c.session.MoveChannelToCategory(channel, c.archiveCategoryID); err != nil { + log.Print("Failed to move channel to archive category: ", err) + } + + if err := c.session.ArchiveChannel(channel); err != nil { + log.Print("Failed to archive channel: ", err) + } + + log.Printf("Archived channel: '%s'", channel.Name) + + } else { + + // Delete Channel + _, err := c.session.DeleteChannel(channel) + if err != nil { + log.Print("Failed to delete channel: ", err) + } + + log.Printf("Deleted channel: '%s'", channel.Name) + } + + return nil +} diff --git a/modules/recurring_events.go b/modules/recurring_events.go new file mode 100644 index 0000000..908cb79 --- /dev/null +++ b/modules/recurring_events.go @@ -0,0 +1,43 @@ +package modules + +import ( + "log" + "strings" + + "github.com/yeslayla/birdbot/common" + "github.com/yeslayla/birdbot/discord" +) + +type recurringEventsModule struct { + session *discord.Discord +} + +// NewRecurringEventsComponent creates a new component instance +func NewRecurringEventsComponent() common.Module { + return &recurringEventsModule{} +} + +// Initialize registers event listeners +func (c *recurringEventsModule) Initialize(birdbot common.ModuleManager) error { + _ = birdbot.OnEventComplete(c.OnEventComplete) + + return nil +} + +// OnEventComplete checks for keywords before creating a new event +func (c *recurringEventsModule) OnEventComplete(e common.Event) error { + + if strings.Contains(strings.ToLower(e.Description), "recurring weekly") { + startTime := e.DateTime.AddDate(0, 0, 7) + finishTime := e.CompleteDateTime.AddDate(0, 0, 7) + nextEvent := e + nextEvent.DateTime = startTime + nextEvent.CompleteDateTime = finishTime + + if err := c.session.CreateEvent(nextEvent); err != nil { + log.Print("Failed to create recurring event: ", err) + } + } + + return nil +} diff --git a/modules/role_selection.go b/modules/role_selection.go new file mode 100644 index 0000000..6a85dfa --- /dev/null +++ b/modules/role_selection.go @@ -0,0 +1,90 @@ +package modules + +import ( + "fmt" + "log" + + "github.com/yeslayla/birdbot/common" + "github.com/yeslayla/birdbot/core" + "github.com/yeslayla/birdbot/discord" +) + +type roleSelectionModule struct { + session *discord.Discord + cfg core.RoleSelectionConfig + exlusive bool +} + +// NewRoleSelectionComponent creates a new component +func NewRoleSelectionComponent(discord *discord.Discord, cfg core.RoleSelectionConfig) common.Module { + return &roleSelectionModule{ + session: discord, + cfg: cfg, + exlusive: true, + } +} + +// Initialize setups component on discord and registers handlers +func (c *roleSelectionModule) Initialize(birdbot common.ModuleManager) error { + + roles := []*discord.Role{} + roleButtons := []discord.Component{} + + for _, roleConfig := range c.cfg.Roles { + + // Create & Validate Roles + role := c.session.GetRoleAndCreate(roleConfig.RoleName) + configColor, _ := core.HexToColor(roleConfig.Color) + + if role.Color != configColor { + role.Color = configColor + role.Save() + } + + // Create button + btn := c.session.NewButton(fmt.Sprint(c.cfg.Title, role.Name), role.Name) + btn.OnClick(func(user common.User) { + + // Remove other roles if exclusive + if c.exlusive { + for _, r := range roles { + if r.ID == role.ID { + continue + } + + if c.session.HasRole(user, r) { + c.session.UnassignRole(user, r) + } + } + } + + // Toggle role + if c.session.HasRole(user, role) { + if err := c.session.UnassignRole(user, role); err != nil { + log.Printf("Failed to unassign role: %s", err) + } + } else if err := c.session.AssignRole(user, role); err != nil { + log.Printf("Failed to assign role: %s", err) + } + + }) + + roles = append(roles, role) + roleButtons = append(roleButtons, btn) + } + + components := []discord.Component{} + var actionRow *discord.ActionRow + for i, btn := range roleButtons { + if i%5 == 0 { + actionRow = c.session.NewActionRow() + components = append(components, actionRow) + } + + actionRow.AddComponent(btn) + } + + c.session.CreateMessageComponent(c.cfg.SelectionChannel, fmt.Sprintf("**%s**\n%s", c.cfg.Title, c.cfg.Description), components) + + return nil +} diff --git a/sample_config.yaml b/sample_config.yaml index b4d8619..be5a47c 100644 --- a/sample_config.yaml +++ b/sample_config.yaml @@ -10,10 +10,29 @@ discord: archive_category: "" notification_channel: "" + # # Configure role selection + # role_selection: + # - title: "SELECTION TITLE" + # description: "SELECTION DESCRIPTION" + # discord_channel: "" + # roles: + # - name: Red + # color: "#f64c38" + # - name: Blue + # color: "#1a88ff" + # mastodon: # server: https://mastodon.social # username: my_user # password: secret # client_id: 1234 -# client_secret: secret2 \ No newline at end of file +# client_secret: secret2 + +# # Feature flags can be used to +# # disable specific features +# features: +# manage_event_channels: true +# announce_events: true +# recurring_events: true +# role_selection: true \ No newline at end of file