WIP fluxer bot
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add guild_actor and manager which handle per-guild interactions and keeping state for each guild

nnuuvv 2a519f8e f7080a29

+834 -381
+1
.gitignore
··· 1 1 data/ 2 2 .envrc 3 3 .env 4 + */logs/*
+13 -6
README.md
··· 9 9 10 10 ## Deployment 11 11 `Dockerfile` and example `docker-compose.yml` to be added 12 + Also prebuilt images 12 13 13 14 ## TODO 14 15 #### features 15 16 - [x] add roles 16 17 - [x] `@bot add-reaction-role :emote: @role <message-link>` 17 18 - [ ] respond to message with `@bot add-reaction-role :emote: @role` 19 + - [ ] `@bot add-reaction-role` step by step process 18 20 - [x] same role different emotes 21 + - [ ] handle new user reactions on startup 19 22 - [ ] handle duplicate emotes 20 23 - [ ] way to remove `emote -> role` mappings 21 24 - [ ] `@bot remove-reaction-role :emote: <message-link>` 22 25 - [ ] respond to message with `emote -> role` mappings with `@bot remove-reaction-role :emote:` 26 + - [ ] `@bot remove-reaction-role :emote:` -> remove if only one exists & prompt for choice when multiple exist 27 + - [ ] `@bot remove-reaction-role` -> list all, allow deletion via reactions | when theres multiple with the same emote on different messages, make it a 2 step process 23 28 - [x] clean up 24 29 - [x] notify success/failure 25 30 - [x] fix-reaction-roles command 26 - - [ ] spread on_event over multiple actors (per guild) 27 - - [ ] hold guild roles in actor state 28 - - [ ] hold `emote -> role` mappings in actor state 31 + - [x] spread on_event over multiple actors (per guild) 32 + - [x] hold guild roles in actor state 33 + - [x] hold `emote -> role` mappings in actor state 29 34 - [ ] handle gateway role changes 30 - - [ ] GUILD_ROLE_CREATE 31 - - [ ] GUILD_ROLE_UPDATE 35 + - [x] GUILD_ROLE_CREATE 36 + - [x] GUILD_ROLE_UPDATE 32 37 - [ ] GUILD_ROLE_UPDATE_BULK 33 - - [ ] GUILD_ROLE_DELETE 38 + - [x] GUILD_ROLE_DELETE 34 39 - [ ] handle bot leaving/getting removed from guild 40 + - [ ] proper grom gateway supervision 35 41 36 42 #### bug-fixes 37 43 - [x] restart after crash / supervision 38 44 - [x] bot giving itself all roles 39 45 - [ ] occasional ssl issue 40 46 WARN #(charlist.from_string("Actor discarding unexpected message: ~s"), [charlist.from_string("Ssl(Sslsocket(//erl(#Port<0.17>), //erl(<0.159.0>), //erl(<0.158.0>), GenTcp, TlsGenConnection, //erl(#Ref<0.3355478690.2935619588.73249>), Undefined), <<129, 42, 123, 34, 100, 34, 58, 123, 34, 104, 101, 97, 114, 116, 98, 101, 97, 116, 95, 105, 110, 116, 101, 114, 118, 97, 108, 34, 58, 52, 49, 50, 53, 48, 125, 44, 34, 111, 112, 34, 58, 49, 48, 125>>)")]) 47 + - [ ] CouldNotDecode(UnableToDecode([DecodeError("Bool", "Dict", [])]))
+2 -1
server/gleam.toml
··· 20 20 gleam_erlang = ">= 1.3.0 and < 2.0.0" 21 21 pog = ">= 4.1.0 and < 5.0.0" 22 22 splitter = ">= 1.2.0 and < 2.0.0" 23 - crew = ">= 2.0.0 and < 3.0.0" 24 23 gleam_otp = ">= 1.2.0 and < 2.0.0" 24 + glight = ">= 2.2.0 and < 3.0.0" 25 + gleam_json = ">= 3.1.0 and < 4.0.0" 25 26 26 27 [dev-dependencies] 27 28 gleeunit = ">= 1.0.0 and < 2.0.0"
+7 -3
server/manifest.toml
··· 4 4 packages = [ 5 5 { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 6 { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, 7 - { name = "crew", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_deque", "gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "crew", source = "hex", outer_checksum = "C6A81CDF01E4413E1EFBFBEF54CA6581D9C243A70912D43CF277BB54FE479725" }, 7 + { name = "birl", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "gleam_time", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "9924A2C3EECD33A5C94CD6859570261F2620435C54DAC5F2B00E5E6C806EEC6F" }, 8 8 { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 9 9 { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, 10 10 { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, ··· 13 13 { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, 14 14 { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, 15 15 { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 16 - { name = "gleam_deque", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_deque", source = "hex", outer_checksum = "64D77068931338CF0D0CB5D37522C3E3CCA7CB7D6C5BACB41648B519CC0133C7" }, 17 16 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 18 17 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 19 18 { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, ··· 22 21 { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 23 22 { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, 24 23 { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 24 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 25 25 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 26 26 { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, 27 + { name = "glight", version = "2.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_otp", "gleam_stdlib", "jsx", "logging"], otp_app = "glight", source = "hex", outer_checksum = "DB1FC2B770C86437A3932851AC8D7F8C32646E7CD4A8730A2B0AAA9B74E4AD90" }, 27 28 { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 28 29 { name = "grom", version = "5.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "multipart_form", "operating_system", "splitter", "status_code", "stratus"], source = "local", path = "../grom" }, 30 + { name = "jsx", version = "3.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "jsx", source = "hex", outer_checksum = "0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3" }, 29 31 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 30 32 { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 31 33 { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, ··· 36 38 { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, 37 39 { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, 38 40 { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, 41 + { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 39 42 { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 40 43 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 41 44 { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, ··· 48 51 ] 49 52 50 53 [requirements] 51 - crew = { version = ">= 2.0.0 and < 3.0.0" } 52 54 envoy = { version = ">= 1.1.0 and < 2.0.0" } 53 55 gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 56 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 54 57 gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } 55 58 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 56 59 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 60 + glight = { version = ">= 2.2.0 and < 3.0.0" } 57 61 grom = { path = "../grom/" } 58 62 logging = { version = ">= 1.3.0 and < 2.0.0" } 59 63 pog = { version = ">= 4.1.0 and < 5.0.0" }
+87 -350
server/src/server.gleam
··· 7 7 import gleam/otp/static_supervisor as supervisor 8 8 import gleam/result 9 9 import gleam/string 10 + import glight 10 11 import grom 11 12 import grom/gateway 12 13 import grom/gateway/intent 13 - import grom/guild 14 - import grom/guild/role 15 14 import grom/guild_member 16 15 import grom/message 17 - import grom/message/message_reference 18 16 import grom/message/reaction 19 - import grom/permission 20 17 import grom/user 21 18 import grom/user/current_user 22 19 import logging 23 20 import pog 24 - import server/command 21 + import server/guild_actor.{GuildId} 25 22 import server/message_ref 26 - import server/reaction_role 27 23 import server/reaction_role/database 28 - import splitter 29 24 30 25 type State { 31 26 State( 32 27 client: grom.Client, 33 28 db_pool_name: process.Name(pog.Message), 34 29 self: user.User, 35 - command_splitter: splitter.Splitter, 30 + guild_manager: process.Name(guild_actor.ManagerMessage), 36 31 ) 37 32 } 38 33 39 34 pub fn main() -> Nil { 40 - logging.configure() 35 + configure_logging() 36 + 37 + let guild_factory = process.new_name("guild_factory") 38 + let guild_manager = process.new_name("guild_manager") 41 39 42 40 let assert Ok(db_config) = database_config() 43 41 44 - let assert Ok(bot_config) = bot_config(db_config.pool_name) 42 + let assert Ok(#(bot_state, bot_config)) = 43 + bot_config(db_config.pool_name, guild_manager) 45 44 45 + logging.log(logging.Info, "Starting main supervision tree") 46 46 let assert Ok(_) = 47 47 supervisor.new(supervisor.OneForOne) 48 48 |> supervisor.add(pog.supervised(db_config)) 49 + |> supervisor.add(guild_actor.supervised( 50 + guild_factory:, 51 + manager_name: guild_manager, 52 + client: bot_state.client, 53 + bot_user: bot_state.self, 54 + db_name: db_config.pool_name, 55 + )) 49 56 |> supervisor.add(gateway.supervised(bot_config)) 50 57 |> supervisor.start() 51 58 ··· 54 61 process.sleep_forever() 55 62 } 56 63 64 + fn configure_logging() -> Nil { 65 + glight.configure([glight.Console, glight.File("logs/server.log")]) 66 + glight.set_is_color(True) 67 + glight.set_log_level(glight.Debug) 68 + } 69 + 57 70 fn on_event(state: State, event: gateway.Event) -> gateway.Next(State) { 71 + let send = fn(message) { 72 + let guild_manager = process.named_subject(state.guild_manager) 73 + process.send(guild_manager, message) 74 + gateway.continue(state) 75 + } 76 + 58 77 case event { 59 78 gateway.ErrorEvent(error) -> { 60 79 logging.log(logging.Warning, string.inspect(error)) 61 80 gateway.continue(state) 62 81 } 82 + gateway.AllShardsReadyEvent(ready) -> on_ready(state, ready) 63 83 64 - gateway.AllShardsReadyEvent(ready) -> on_ready(state, ready) 65 - gateway.MessageCreatedEvent(message) -> on_message(state, message) 84 + // messages 85 + gateway.MessageCreatedEvent(gateway.MessageCreatedMessage( 86 + message:, 87 + guild_id: Some(guild_id), 88 + member: Some(member), 89 + mentions:, 90 + )) -> 91 + send(guild_actor.Forward( 92 + guild_actor.NewMessage(guild_actor.NewMessageData( 93 + message:, 94 + member:, 95 + mentions:, 96 + )), 97 + to: GuildId(guild_id), 98 + )) 99 + 100 + // role updates 101 + gateway.RoleCreatedEvent(created) -> 102 + send(guild_actor.Forward( 103 + guild_actor.RoleCreated(created), 104 + to: GuildId(created.guild_id), 105 + )) 106 + gateway.RoleUpdatedEvent(updated) -> 107 + send(guild_actor.Forward( 108 + guild_actor.RoleUpdated(updated), 109 + to: GuildId(updated.guild_id), 110 + )) 111 + // TODO: RoleUpdatedBulkEvent 112 + gateway.RoleDeletedEvent(deleted) -> 113 + send(guild_actor.Forward( 114 + guild_actor.RoleDeleted(deleted), 115 + to: GuildId(deleted.guild_id), 116 + )) 117 + 66 118 gateway.MessageReactionCreatedEvent(reaction) -> 67 119 on_new_reaction(state, reaction) 68 120 ··· 172 224 let channel_id = reaction.channel_id 173 225 let message_id = reaction.message_id 174 226 175 - use reactioin_roles <- result.try( 227 + use reaction_roles <- result.try( 176 228 database.find( 177 229 state.db_pool_name, 178 230 message_ref.MessageRef(guild_id:, channel_id:, message_id:), ··· 180 232 |> result.replace_error(NoReactionRoleFound), 181 233 ) 182 234 183 - use reactioin_role <- result.try( 184 - reactioin_roles 235 + use reaction_roles <- result.try( 236 + reaction_roles 185 237 |> list.find(fn(role) { Some(role.emote) == reaction.emoji.name }) 186 238 |> result.replace_error(InvalidReaction), 187 239 ) ··· 191 243 state.client, 192 244 in: guild_id, 193 245 to: reaction.user_id, 194 - id: reactioin_role.target_role_id, 246 + id: reaction_roles.target_role_id, 195 247 because: Some("reaction role"), 196 248 ) 197 249 |> result.map_error(fn(error) { ··· 210 262 gateway.continue(state) 211 263 } 212 264 213 - type CommandError { 214 - // pre-parsing 215 - UserIsntAdmin 216 - FailedToGetRoles 217 - IsDirectMessage 218 - 219 - // parsing 220 - ParseError(command.ParseError) 221 - 222 - // execution 223 - FailedToPersist 224 - HelpError 225 - FailedToGetFromDatabase 226 - FailedToAddReaction(reaction_role.ReactionRole) 227 - FailedToRemoveReactions(message_ref.MessageRef) 228 - InsufficientPermissions 229 - NetworkIssue 230 - } 231 - 232 - fn on_message( 233 - state: State, 234 - message: gateway.MessageCreatedMessage, 235 - ) -> gateway.Next(State) { 236 - let _execute_command = 237 - { 238 - // first check if the message is a guild message since we currently only do stuff in guilds 239 - use #(guild_id, member) <- is_guild_message(message) 240 - 241 - do_call_and_response(state.client, message.message, guild_id) 242 - 243 - // attempt to parse a command from it 244 - use command <- result.try( 245 - command.parse(state.self, message) 246 - |> result.map_error(ParseError), 247 - ) 248 - 249 - // find out if the user is an admin 250 - use roles <- get_roles(client: state.client, guild_id:) 251 - use <- member_is_admin(member:, roles:) 252 - 253 - // if so, try to execute the command 254 - execute_command(state, message, command) 255 - } 256 - |> result.map_error(recover_failed_command_execution( 257 - _, 258 - state.client, 259 - message, 260 - )) 261 - 262 - gateway.continue(state) 263 - } 264 - 265 - fn recover_failed_command_execution( 266 - command_error: CommandError, 267 - client: grom.Client, 268 - message: gateway.MessageCreatedMessage, 269 - ) -> option.Option(a) { 270 - let parse_error = fn(error: command.ParseError) { 271 - case error { 272 - command.DoesntMentionBot -> None 273 - command.MessageHasNoCommand -> Some(command.help_text(command.Help)) 274 - command.UnknownCommand(invalid_command) -> 275 - Some("I don't know the command '" <> invalid_command <> "'. 276 - Try `help` to see a list of available commands") 277 - command.InvalidArguments(argument) -> 278 - Some("There was a problem with the command argument '" <> argument <> "' 279 - Have a look at `help` if you don't know whats wrong") 280 - } 281 - } 282 - 283 - let response = case command_error { 284 - ParseError(error) -> parse_error(error) 285 - IsDirectMessage -> 286 - Some("There is nothing i can do for you here at the moment.") 287 - UserIsntAdmin -> Some("You don't have permissions to do this.") 288 - FailedToGetRoles -> Some("I failed to get the roles from fluxer.") 289 - FailedToAddReaction(reaction_role) -> 290 - Some( 291 - "I ran in to an issue adding the reaction '" 292 - <> reaction_role.emote 293 - <> "' to " 294 - <> reaction_role.message_ref |> message_ref.to_link, 295 - ) 296 - FailedToRemoveReactions(message_ref) -> 297 - Some( 298 - "I ran in to an issue removing the reactions from from " 299 - <> message_ref.to_link(message_ref), 300 - ) 301 - FailedToPersist | FailedToGetFromDatabase -> 302 - Some("There seems to be an issue with my database") 303 - HelpError -> 304 - Some( 305 - "So... The help message didn't send, but this might o.o (try again later)", 306 - ) 307 - InsufficientPermissions -> 308 - Some( 309 - "I lack the permissions to do that. Make sure i have the relevant permissions and that my role is above the roles you want me to manage.", 310 - ) 311 - NetworkIssue -> Some("There was some network related issue.") 312 - } 313 - 314 - use response <- option.then(response) 315 - 316 - let _welp_we_tried = 317 - send_ephemeral_reply( 318 - client:, 319 - guild_id: message.guild_id, 320 - channel_id: message.message.channel_id, 321 - message_id: message.message.id, 322 - with_message: response, 323 - delete_after_ms: 30_000, 324 - ) 325 - 326 - None 327 - } 328 - 329 - /// check if a message is a guild message 330 - /// 331 - fn is_guild_message( 332 - message: gateway.MessageCreatedMessage, 333 - inner: fn(#(String, guild_member.GuildMember)) -> Result(a, CommandError), 334 - ) -> Result(a, CommandError) { 335 - case message.guild_id, message.member { 336 - Some(guild_id), Some(member) -> inner(#(guild_id, member)) 337 - _, _ -> Error(IsDirectMessage) 338 - } 339 - } 340 - 341 - /// get all roles for a specific guild by `guild_id` 342 - /// 343 - fn get_roles( 344 - client client: grom.Client, 345 - guild_id guild_id: String, 346 - inner inner: fn(List(role.Role)) -> Result(a, CommandError), 347 - ) -> Result(a, CommandError) { 348 - use roles <- result.try( 349 - guild.get_roles(client, guild_id) 350 - |> result.map_error(grommon_error_or(_, FailedToGetRoles)), 351 - ) 352 - 353 - inner(roles) 354 - } 355 - 356 - /// check if any of a users roles have the `Administrator` permission 357 - /// 358 - fn member_is_admin( 359 - member member: guild_member.GuildMember, 360 - roles roles: List(role.Role), 361 - inner inner: fn() -> Result(a, CommandError), 362 - ) -> Result(a, CommandError) { 363 - let is_admin = 364 - roles 365 - |> list.any(fn(role) { 366 - list.contains(member.roles, role.id) 367 - && list.contains(role.permissions, permission.Administrator) 368 - }) 369 - 370 - case is_admin { 371 - True -> inner() 372 - False -> Error(UserIsntAdmin) 373 - } 374 - } 375 - 376 - fn execute_command( 377 - state: State, 378 - message_created: gateway.MessageCreatedMessage, 379 - command: command.Command, 380 - ) -> Result(Nil, CommandError) { 381 - case command { 382 - command.AddReactionRole(reaction_role:) -> { 383 - // save to database 384 - use _ <- result.try( 385 - database.insert(state.db_pool_name, reaction_role) 386 - |> log_error( 387 - log_level: logging.Warning, 388 - message: "Ran into error while persisting reaction_role:", 389 - ) 390 - |> result.replace_error(FailedToPersist), 391 - ) 392 - 393 - // add reaction to message 394 - add_reaction(state.client, reaction_role) 395 - |> result.map_error(grommon_error_or( 396 - _, 397 - FailedToAddReaction(reaction_role), 398 - )) 399 - } 400 - command.FixReactionRoles -> 401 - fix_reaction_roles(state.client, state.db_pool_name, message_created) 402 - command.Help -> 403 - send_ephemeral_reply( 404 - client: state.client, 405 - guild_id: None, 406 - channel_id: message_created.message.channel_id, 407 - message_id: message_created.message.id, 408 - with_message: command.help_text(command.Help), 409 - delete_after_ms: 30_000, 410 - ) 411 - |> result.map_error(grommon_error_or(_, HelpError)) 412 - } 413 - } 414 - 415 - fn fix_reaction_roles( 416 - client: grom.Client, 417 - db_pool_name: process.Name(pog.Message), 418 - message_created: gateway.MessageCreatedMessage, 419 - ) -> Result(Nil, CommandError) { 420 - use guild_id <- result.try( 421 - message_created.guild_id |> option.to_result(IsDirectMessage), 422 - ) 423 - 424 - use reaction_roles <- result.try( 425 - database.find_by_guild_id(db_pool_name, guild_id) 426 - |> log_error(log_level: logging.Warning, message: "") 427 - |> result.replace_error(FailedToGetFromDatabase), 428 - ) 429 - 430 - use _ <- result.try( 431 - reaction_roles 432 - |> list.map(fn(reaction_role) { reaction_role.message_ref }) 433 - |> list.unique() 434 - |> list.try_fold(Nil, fn(_, message_ref) { 435 - message.delete_all_reactions( 436 - client, 437 - in: message_ref.channel_id, 438 - from: message_ref.message_id, 439 - ) 440 - |> result.map_error(grommon_error_or( 441 - _, 442 - FailedToRemoveReactions(message_ref), 443 - )) 444 - }), 445 - ) 446 - 447 - reaction_roles 448 - |> list.try_fold(Nil, fn(_, reaction_role) { 449 - add_reaction(client, reaction_role) 450 - |> result.replace_error(FailedToAddReaction(reaction_role)) 451 - }) 452 - } 453 - 454 - fn grommon_error_or(error: grom.Error, or: CommandError) -> CommandError { 455 - case error { 456 - grom.HttpError(_) -> NetworkIssue 457 - grom.InsufficientPermissions -> InsufficientPermissions 458 - _ -> or 459 - } 460 - } 461 - 462 - fn add_reaction( 463 - client client: grom.Client, 464 - reaction_role reaction_role: reaction_role.ReactionRole, 465 - ) -> Result(Nil, grom.Error) { 466 - reaction.create( 467 - client, 468 - in: reaction_role.message_ref.channel_id, 469 - on: reaction_role.message_ref.message_id, 470 - emoji: reaction_role.emote, 471 - ) 472 - |> log_error( 473 - log_level: logging.Warning, 474 - message: "Ran into error while adding '" 475 - <> reaction_role.emote 476 - <> "' to " 477 - <> reaction_role.message_ref |> message_ref.to_link(), 478 - ) 479 - } 480 - 481 - fn do_call_and_response( 482 - client: grom.Client, 483 - message: message.Message, 484 - guild_id: String, 485 - ) -> Nil { 486 - case message.content { 487 - "hello little stylus" -> { 488 - let _response = 489 - message.create( 490 - client, 491 - in: message.channel_id, 492 - using: message.Create( 493 - ..message.new_create(), 494 - content: Some("hello mario"), 495 - message_reference: Some(message_reference.MessageReference( 496 - type_: message_reference.Default, 497 - message_id: Some(message.id), 498 - channel_id: Some(message.channel_id), 499 - guild_id: Some(guild_id), 500 - fail_if_not_exists: Some(False), 501 - )), 502 - ), 503 - ) 504 - Nil 505 - } 506 - _ -> Nil 507 - } 508 - } 509 - 510 265 fn on_ready( 511 266 state: State, 512 267 ready: gateway.AllShardsReadyMessage, ··· 518 273 gateway.continue(State(..state, self: ready.user)) 519 274 } 520 275 521 - fn send_ephemeral_reply( 522 - client client: grom.Client, 523 - guild_id guild_id: option.Option(String), 524 - channel_id channel_id: String, 525 - message_id message_id: String, 526 - with_message response_text: String, 527 - delete_after_ms delete_after_ms: Int, 528 - ) -> Result(Nil, grom.Error) { 529 - send_ephemeral( 530 - client:, 531 - channel_id: channel_id, 532 - message: message.Create( 533 - ..message.new_create(), 534 - content: Some(response_text), 535 - message_reference: Some(message_reference.MessageReference( 536 - type_: message_reference.Default, 537 - message_id: Some(message_id), 538 - channel_id: Some(channel_id), 539 - guild_id: guild_id, 540 - fail_if_not_exists: Some(False), 541 - )), 542 - ), 543 - delete_after_ms:, 544 - ) 545 - } 546 - 547 276 fn send_ephemeral_message( 548 277 client client: grom.Client, 549 278 channel_id channel_id: String, ··· 589 318 // setup ------------------------------------------------------------------------ 590 319 591 320 fn database_config() -> Result(pog.Config, String) { 321 + logging.log(logging.Debug, "Getting DATABASE_URL variable") 592 322 use database_url <- result.try( 593 323 envoy.get("DATABASE_URL") |> result.replace_error("Missing DATABASE_URL"), 594 324 ) 595 325 596 326 let pool_name = process.new_name("db_pool") 597 327 328 + logging.log(logging.Debug, "Parsing pog config from DATABASE_URL") 598 329 use config <- result.try( 599 330 pog.url_config(pool_name, database_url) 600 331 |> result.replace_error("Invalid DATABASE_URL"), 601 332 ) 333 + logging.log(logging.Debug, "Adjusting pog pool size") 602 334 603 335 config 604 336 |> pog.pool_size(10) ··· 613 345 614 346 fn bot_config( 615 347 db_pool_name: process.Name(pog.Message), 616 - ) -> Result(gateway.Builder(State), actor.StartError) { 348 + guild_manager_name: process.Name(_), 349 + ) { 350 + logging.log(logging.Debug, "Getting FLUXER_BOT_TOKEN variable") 617 351 let assert Ok(bot_token) = envoy.get("FLUXER_BOT_TOKEN") 618 352 619 353 let client = grom.Client(bot_token) 620 354 621 355 let identify = gateway.identify(client, intents: intent.all) 622 356 357 + logging.log(logging.Debug, "Getting gateway data") 623 358 use data <- result.try( 624 359 gateway.get_data(client) 625 360 |> log_error(logging.Critical, "Failed to get gateway data") 626 361 |> result.replace_error(actor.InitFailed("Failed to get gateway data")), 627 362 ) 363 + logging.log(logging.Debug, "Getting self") 628 364 use self <- result.try( 629 365 current_user.get(client) 630 366 |> log_error(logging.Critical, "Failed to get self") 631 367 |> result.replace_error(actor.InitFailed("Failed to get self")), 632 368 ) 633 369 634 - gateway.new( 635 - State(client:, db_pool_name:, self:, command_splitter: splitter.new([" "])), 636 - identify, 637 - data, 638 - ) 639 - |> gateway.on_event(do: on_event) 640 - |> gateway.on_close(fn(_state) { 641 - logging.log(logging.Alert, "Gateway closed") 642 - }) 643 - |> Ok 370 + let state = 371 + State(client:, db_pool_name:, self:, guild_manager: guild_manager_name) 372 + 373 + Ok(#( 374 + state, 375 + gateway.new(state, identify, data) 376 + |> gateway.on_event(do: on_event) 377 + |> gateway.on_close(fn(_state) { 378 + logging.log(logging.Alert, "Gateway closed") 379 + }), 380 + )) 644 381 } 645 382 646 383 /// `log_level` -> to log with
+29 -21
server/src/server/command.gleam
··· 2 2 import gleam/option.{Some} 3 3 import gleam/result 4 4 import gleam/string 5 - import grom 6 - import grom/gateway 7 - import grom/guild 8 - import grom/guild/role 9 - import grom/guild_member 10 - import grom/message 11 - import grom/permission 12 5 import grom/user 6 + import logging 13 7 import server/emoji 14 8 import server/message_ref 15 9 import server/reaction_role ··· 20 14 AddReactionRole(reaction_role: reaction_role.ReactionRole) 21 15 FixReactionRoles 22 16 Help 17 + ShowReactionRoles 23 18 } 24 19 25 20 // help texts ------------------------------------------------------------------- ··· 28 23 HelpTexts(add_reaction_role: String, fix_reaction_roles: String) 29 24 } 30 25 31 - const help_texts = HelpTexts( 26 + const help_texts: HelpTexts = HelpTexts( 32 27 add_reaction_role: "`add-reaction-role :emote: @role <link-to-message>` 33 28 add a new `reaction -> role` mapping to a specific message", 34 29 fix_reaction_roles: "`fix-reaction-roles` ··· 37 32 38 33 /// return the help text for a given command 39 34 /// 40 - pub fn help_text(command: Command) { 35 + pub fn help_text(command: Command) -> String { 41 36 case command { 42 37 AddReactionRole(_) -> help_texts.add_reaction_role 38 + ShowReactionRoles -> todo as "implement show-reaction-roles help text" 43 39 FixReactionRoles -> help_texts.fix_reaction_roles 44 40 Help -> 45 41 "every command needs to be prefaced with @bot-name ··· 68 64 /// 69 65 pub fn parse( 70 66 self self: user.User, 71 - message message: gateway.MessageCreatedMessage, 67 + content content: String, 68 + mentions mentions: List(user.User), 72 69 ) -> Result(Command, ParseError) { 73 - let gateway.MessageCreatedMessage( 74 - message: message.Message(content:, ..), 75 - mentions:, 76 - .., 77 - ) = message 78 - 79 70 // check that we are being addressed, in a guild 80 71 use <- mentions_bot(mentions, self) 81 72 ··· 84 75 85 76 // parse commands 86 77 case content { 87 - "add-reaction-role" <> rest -> 88 - parse_reaction_role_arguments(rest) |> result.map(AddReactionRole) 78 + "add-reaction-role" <> rest -> parse_reaction_role_arguments(rest) 79 + "remove-reaction-role" <> rest -> parse_remove_reaction_role_arguments(rest) 89 80 81 + "show-reaction-roles" -> Ok(ShowReactionRoles) 90 82 "fix-reaction-roles" -> Ok(FixReactionRoles) 91 83 "help" -> Ok(Help) 92 84 ··· 94 86 } 95 87 } 96 88 89 + fn parse_remove_reaction_role_arguments( 90 + arguments: String, 91 + ) -> Result(Command, ParseError) { 92 + todo 93 + } 94 + 95 + // TODO: implement alternative command(s) 96 + // respond to message with `@bot-name add-reaction-role :emote: @role` 97 97 fn parse_reaction_role_arguments( 98 98 arguments: String, 99 - ) -> Result(reaction_role.ReactionRole, ParseError) { 100 - case string.split(arguments, " ") { 99 + ) -> Result(Command, ParseError) { 100 + logging.log(logging.Debug, "Reaction role arguments: " <> arguments) 101 + 102 + case string.split(arguments, " ") |> remove_empty() |> echo { 101 103 [emote, target_role_id, message_link] -> { 102 104 use emote <- result.try( 103 105 emoji.from_string(emote) ··· 109 111 |> result.replace_error(InvalidArguments("message_link")), 110 112 ) 111 113 112 - reaction_role.ReactionRole(message_ref:, emote:, target_role_id:) |> Ok 114 + reaction_role.ReactionRole(message_ref:, emote:, target_role_id:) 115 + |> AddReactionRole 116 + |> Ok 113 117 } 114 118 _ -> InvalidArguments("count") |> Error 115 119 } 120 + } 121 + 122 + fn remove_empty(list: List(String)) -> List(String) { 123 + list.filter(list, fn(item) { !{ item == "" || item == " " } }) 116 124 } 117 125 118 126 fn command_content(
+695
server/src/server/guild_actor.gleam
··· 1 + import gleam/dict 2 + import gleam/erlang/process 3 + import gleam/list 4 + import gleam/option.{None, Some} 5 + import gleam/otp/actor 6 + import gleam/otp/factory_supervisor as factory 7 + import gleam/otp/static_supervisor 8 + import gleam/otp/supervision 9 + import gleam/result 10 + import gleam/string 11 + import grom 12 + import grom/gateway 13 + import grom/guild 14 + import grom/guild/role 15 + import grom/guild_member 16 + import grom/message 17 + import grom/message/message_reference 18 + import grom/message/reaction 19 + import grom/permission 20 + import grom/user 21 + import logging 22 + import pog 23 + import server/command 24 + import server/message_ref 25 + import server/reaction_role 26 + import server/reaction_role/database 27 + 28 + pub type GuildId { 29 + GuildId(String) 30 + } 31 + 32 + pub type RoleId { 33 + RoleId(String) 34 + } 35 + 36 + pub fn supervised( 37 + guild_factory guild_factory: process.Name(_), 38 + manager_name manager_name: process.Name(_), 39 + client client: grom.Client, 40 + bot_user bot_user: user.User, 41 + db_name db_name: process.Name(pog.Message), 42 + ) -> supervision.ChildSpecification(static_supervisor.Supervisor) { 43 + static_supervisor.new(static_supervisor.RestForOne) 44 + |> static_supervisor.add(factory(guild_factory)) 45 + |> static_supervisor.add(manager( 46 + manager_name, 47 + guild_factory, 48 + client, 49 + bot_user, 50 + db_name, 51 + )) 52 + |> static_supervisor.supervised() 53 + } 54 + 55 + // factory ---------------------------------------------------------------------- factory 56 + 57 + fn factory(guild_factory: process.Name(_)) -> supervision.ChildSpecification(_) { 58 + factory.worker_child(guild_actor) 59 + |> factory.named(guild_factory) 60 + |> factory.supervised() 61 + } 62 + 63 + // guild_actor ------------------------------------------------------------------ guild_actor 64 + 65 + pub type GuildActorState { 66 + GuildActorState( 67 + guild_id: GuildId, 68 + client: grom.Client, 69 + bot_user: user.User, 70 + db_name: process.Name(pog.Message), 71 + data: GuildActorData, 72 + ) 73 + } 74 + 75 + pub type GuildActorData { 76 + GuildActorData( 77 + roles: dict.Dict(RoleId, role.Role), 78 + reaction_roles: List(reaction_role.ReactionRole), 79 + ) 80 + } 81 + 82 + pub type GuildActorMessage { 83 + // role tracking ---------------------------------------------------------------- 84 + RoleDeleted(gateway.RoleDeletedMessage) 85 + RoleUpdated(gateway.RoleUpdatedMessage) 86 + RoleCreated(gateway.RoleCreatedMessage) 87 + // messages --------------------------------------------------------------------- 88 + NewMessage(NewMessage) 89 + } 90 + 91 + pub type NewMessage { 92 + NewMessageData( 93 + message: message.Message, 94 + member: guild_member.GuildMember, 95 + mentions: List(user.User), 96 + ) 97 + } 98 + 99 + pub type StartGuildActor { 100 + StartGuildActor( 101 + client: grom.Client, 102 + bot_user: user.User, 103 + guild_id: GuildId, 104 + register_with: process.Name(ManagerMessage), 105 + db_name: process.Name(pog.Message), 106 + ) 107 + } 108 + 109 + fn guild_actor( 110 + args: StartGuildActor, 111 + ) -> Result(actor.Started(process.Subject(GuildActorMessage)), actor.StartError) { 112 + let GuildId(guild_id) = args.guild_id 113 + logging.log(logging.Debug, "Starting guild_actor for guild " <> guild_id) 114 + 115 + actor.new_with_initialiser(5000, fn(subj) { 116 + use roles <- result.try( 117 + guild.get_roles(args.client, guild_id) 118 + |> result.map_error(fn(error) { 119 + "Failed to get roles for guild " 120 + <> guild_id 121 + <> " from fluxer; with grom error " 122 + <> string.inspect(error) 123 + }), 124 + ) 125 + 126 + let roles = 127 + roles 128 + |> list.fold(dict.new(), fn(dict, role) { 129 + dict.insert(dict, RoleId(role.id), role) 130 + }) 131 + 132 + use reaction_roles <- result.try( 133 + database.find_by_guild_id(args.db_name, guild_id) 134 + |> result.map_error(fn(error) { 135 + "Failed to get reaction_roles for guild " 136 + <> guild_id 137 + <> " from database; with pog error " 138 + <> string.inspect(error) 139 + }), 140 + ) 141 + 142 + actor.send( 143 + process.named_subject(args.register_with), 144 + RegisterNewGuildActor(args.guild_id, subj), 145 + ) 146 + 147 + actor.initialised(GuildActorState( 148 + guild_id: args.guild_id, 149 + client: args.client, 150 + bot_user: args.bot_user, 151 + db_name: args.db_name, 152 + data: GuildActorData(roles:, reaction_roles:), 153 + )) 154 + |> actor.returning(subj) 155 + |> Ok 156 + }) 157 + |> actor.on_message(on_guild_actor_message) 158 + |> actor.start() 159 + } 160 + 161 + fn on_guild_actor_message(state: GuildActorState, message: GuildActorMessage) { 162 + let state = case message { 163 + // TODO: Exit normally when getting removed from guild 164 + // TODO: RoleUpdatedBulkEvent 165 + RoleDeleted(gateway.RoleDeletedMessage(role_id:, ..)) -> { 166 + let roles = dict.delete(state.data.roles, RoleId(role_id)) 167 + GuildActorState(..state, data: GuildActorData(..state.data, roles:)) 168 + } 169 + RoleUpdated(gateway.RoleUpdatedMessage(role:, ..)) 170 + | RoleCreated(gateway.RoleCreatedMessage(role:, ..)) -> { 171 + let roles = dict.insert(state.data.roles, RoleId(role.id), role) 172 + GuildActorState(..state, data: GuildActorData(..state.data, roles:)) 173 + } 174 + NewMessage(message) -> { 175 + on_guild_message(state, message) 176 + } 177 + } 178 + 179 + actor.continue(state) 180 + } 181 + 182 + // manager ---------------------------------------------------------------------- manager 183 + 184 + type ManagerState { 185 + ManagerState( 186 + guild_actors: dict.Dict(GuildId, process.Subject(GuildActorMessage)), 187 + factory_name: process.Name( 188 + factory.Message(StartGuildActor, process.Subject(GuildActorMessage)), 189 + ), 190 + client: grom.Client, 191 + bot_user: user.User, 192 + self: process.Name(ManagerMessage), 193 + db_name: process.Name(pog.Message), 194 + ) 195 + } 196 + 197 + pub type ManagerMessage { 198 + // GetByGuildId( 199 + // guild_id: GuildId, 200 + // send_to: process.Subject(process.Subject(GuildActorMessage)), 201 + // ) 202 + Forward(message: GuildActorMessage, to: GuildId) 203 + RegisterNewGuildActor( 204 + guild_id: GuildId, 205 + subject: process.Subject(GuildActorMessage), 206 + ) 207 + GuildActorDown(process.Down) 208 + } 209 + 210 + fn manager( 211 + manager: process.Name(ManagerMessage), 212 + guild_factory: process.Name( 213 + factory.Message(StartGuildActor, process.Subject(GuildActorMessage)), 214 + ), 215 + client: grom.Client, 216 + bot_user: user.User, 217 + db_name: process.Name(pog.Message), 218 + ) -> supervision.ChildSpecification(process.Subject(ManagerMessage)) { 219 + supervision.worker(fn() { 220 + logging.log(logging.Debug, "Starting manager") 221 + actor.new_with_initialiser(1000, fn(subject) { 222 + let selector = 223 + process.new_selector() 224 + |> process.select_monitors(GuildActorDown) 225 + |> process.merge_selector(process.select( 226 + process.new_selector(), 227 + subject, 228 + )) 229 + 230 + actor.initialised(ManagerState( 231 + dict.new(), 232 + guild_factory, 233 + client:, 234 + bot_user:, 235 + self: manager, 236 + db_name:, 237 + )) 238 + |> actor.selecting(selector) 239 + |> actor.returning(subject) 240 + |> Ok 241 + }) 242 + |> actor.named(manager) 243 + |> actor.on_message(manager_handle_message) 244 + |> actor.start() 245 + }) 246 + } 247 + 248 + fn manager_handle_message( 249 + state: ManagerState, 250 + message: ManagerMessage, 251 + ) -> actor.Next(ManagerState, ManagerMessage) { 252 + let make_new_guild_actor = fn( 253 + factory_name: process.Name(_), 254 + guild_id: GuildId, 255 + ) { 256 + factory.get_by_name(factory_name) 257 + |> factory.start_child(StartGuildActor( 258 + client: state.client, 259 + guild_id:, 260 + register_with: state.self, 261 + bot_user: state.bot_user, 262 + db_name: state.db_name, 263 + )) 264 + } 265 + 266 + // TODO: handle chat messages 267 + // TODO: handle reactions 268 + case message { 269 + RegisterNewGuildActor(guild_id:, subject:) -> { 270 + logging.log(logging.Info, "New guild actor registered") 271 + 272 + let guild_actors = state.guild_actors |> dict.insert(guild_id, subject) 273 + let _monitor = 274 + process.subject_owner(subject) |> result.map(process.monitor) 275 + 276 + actor.continue(ManagerState(..state, guild_actors:)) 277 + } 278 + Forward(message:, to: guild_id) -> { 279 + { 280 + let actor = 281 + state.guild_actors 282 + |> dict.get(guild_id) 283 + 284 + case actor { 285 + Ok(actor) -> { 286 + actor.send(actor, message) 287 + actor.continue(state) 288 + } 289 + Error(_) -> { 290 + let actor = make_new_guild_actor(state.factory_name, guild_id) 291 + case actor { 292 + Ok(started) -> { 293 + process.monitor(started.pid) 294 + 295 + let guild_actors = 296 + state.guild_actors |> dict.insert(guild_id, started.data) 297 + 298 + actor.send(started.data, message) 299 + 300 + actor.continue(ManagerState(..state, guild_actors:)) 301 + } 302 + Error(start_error) -> { 303 + logging.log( 304 + logging.Warning, 305 + "Failed to start " 306 + <> string.inspect(guild_id) 307 + <> " with error " 308 + <> string.inspect(start_error) 309 + <> ". Message " 310 + <> string.inspect(message) 311 + <> "was dropped", 312 + ) 313 + 314 + actor.continue(state) 315 + } 316 + } 317 + } 318 + } 319 + } 320 + } 321 + GuildActorDown(process.ProcessDown(monitor: _, pid:, reason: _)) -> { 322 + logging.log(logging.Info, "Guild actor went down") 323 + let guild_actors = 324 + state.guild_actors 325 + |> dict.filter(keeping: fn(_key, value) { 326 + process.subject_owner(value) 327 + |> result.map(fn(owner) { owner != pid }) 328 + |> result.unwrap(True) 329 + }) 330 + 331 + actor.continue(ManagerState(..state, guild_actors:)) 332 + } 333 + GuildActorDown(process.PortDown(..)) -> panic as "we dont have ports" 334 + } 335 + } 336 + 337 + // message handling ------------------------------------------------------------- 338 + 339 + type CommandError { 340 + // pre-parsing 341 + UserIsntAdmin 342 + FailedToGetRoles 343 + 344 + // parsing 345 + ParseError(command.ParseError) 346 + 347 + // execution 348 + FailedToPersist 349 + HelpError 350 + FailedToGetFromDatabase 351 + FailedToAddReaction(reaction_role.ReactionRole) 352 + FailedToRemoveReactions(message_ref.MessageRef) 353 + InsufficientPermissions 354 + NetworkIssue 355 + } 356 + 357 + fn on_guild_message( 358 + state: GuildActorState, 359 + message: NewMessage, 360 + ) -> GuildActorState { 361 + let _execute_command = 362 + { 363 + do_call_and_response(state.client, message, state.guild_id) 364 + 365 + // attempt to parse a command from it 366 + use command <- result.try( 367 + command.parse(state.bot_user, message.message.content, message.mentions) 368 + |> result.map_error(ParseError), 369 + ) 370 + 371 + use <- member_is_admin(member: message.member, roles: state.data.roles) 372 + 373 + // if so, try to execute the command 374 + execute_command(state, message.message, state.guild_id, command) 375 + } 376 + |> result.map_error(recover_failed_command_execution( 377 + _, 378 + state.client, 379 + message.message, 380 + state.guild_id, 381 + )) 382 + 383 + state 384 + } 385 + 386 + fn recover_failed_command_execution( 387 + command_error: CommandError, 388 + client: grom.Client, 389 + message: message.Message, 390 + guild_id: GuildId, 391 + ) -> option.Option(a) { 392 + let GuildId(guild_id) = guild_id 393 + let parse_error = fn(error: command.ParseError) { 394 + case error { 395 + command.DoesntMentionBot -> None 396 + command.MessageHasNoCommand -> Some(command.help_text(command.Help)) 397 + command.UnknownCommand(invalid_command) -> 398 + Some("I don't know the command '" <> invalid_command <> "'. 399 + Try `help` to see a list of available commands") 400 + command.InvalidArguments(argument) -> 401 + Some("There was a problem with the command argument '" <> argument <> "' 402 + Have a look at `help` if you don't know whats wrong") 403 + } 404 + } 405 + 406 + let response = case command_error { 407 + ParseError(error) -> parse_error(error) 408 + UserIsntAdmin -> Some("You don't have permissions to do this.") 409 + FailedToGetRoles -> Some("I failed to get the roles from fluxer.") 410 + FailedToAddReaction(reaction_role) -> 411 + Some( 412 + "I ran in to an issue adding the reaction '" 413 + <> reaction_role.emote 414 + <> "' to " 415 + <> reaction_role.message_ref |> message_ref.to_link, 416 + ) 417 + FailedToRemoveReactions(message_ref) -> 418 + Some( 419 + "I ran in to an issue removing the reactions from from " 420 + <> message_ref.to_link(message_ref), 421 + ) 422 + FailedToPersist | FailedToGetFromDatabase -> 423 + Some("There seems to be an issue with my database") 424 + HelpError -> 425 + Some( 426 + "So... The help message didn't send, but this might o.o (try again later)", 427 + ) 428 + InsufficientPermissions -> 429 + Some( 430 + "I lack the permissions to do that. Make sure i have the relevant permissions and that my role is above the roles you want me to manage.", 431 + ) 432 + NetworkIssue -> Some("There was some network related issue.") 433 + } 434 + 435 + use response <- option.then(response) 436 + 437 + let _welp_we_tried = 438 + send_ephemeral_reply( 439 + client:, 440 + guild_id: Some(guild_id), 441 + channel_id: message.channel_id, 442 + message_id: message.id, 443 + with_message: response, 444 + delete_after_ms: 30_000, 445 + ) 446 + 447 + None 448 + } 449 + 450 + /// check if any of a users roles have the `Administrator` permission 451 + /// 452 + fn member_is_admin( 453 + member member: guild_member.GuildMember, 454 + roles roles: dict.Dict(RoleId, role.Role), 455 + inner inner: fn() -> Result(a, CommandError), 456 + ) -> Result(a, CommandError) { 457 + let is_admin = 458 + roles 459 + |> dict.values() 460 + |> list.any(fn(role) { 461 + list.contains(member.roles, role.id) 462 + && list.contains(role.permissions, permission.Administrator) 463 + }) 464 + 465 + case is_admin { 466 + True -> inner() 467 + False -> Error(UserIsntAdmin) 468 + } 469 + } 470 + 471 + fn execute_command( 472 + state: GuildActorState, 473 + message: message.Message, 474 + guild_id: GuildId, 475 + command: command.Command, 476 + ) -> Result(Nil, CommandError) { 477 + let GuildId(guild_id) = guild_id 478 + case command { 479 + command.AddReactionRole(reaction_role:) -> { 480 + // save to database 481 + use _ <- result.try( 482 + database.insert(state.db_name, reaction_role) 483 + |> log_error( 484 + log_level: logging.Warning, 485 + message: "Ran into error while persisting reaction_role:", 486 + ) 487 + |> result.replace_error(FailedToPersist), 488 + ) 489 + 490 + // add reaction to message 491 + add_reaction(state.client, reaction_role) 492 + |> result.map_error(grommon_error_or( 493 + _, 494 + FailedToAddReaction(reaction_role), 495 + )) 496 + } 497 + command.FixReactionRoles -> 498 + fix_reaction_roles(state.client, state.db_name, guild_id) 499 + command.Help -> 500 + send_ephemeral_reply( 501 + client: state.client, 502 + guild_id: None, 503 + channel_id: message.channel_id, 504 + message_id: message.id, 505 + with_message: command.help_text(command.Help), 506 + delete_after_ms: 30_000, 507 + ) 508 + |> result.map_error(grommon_error_or(_, HelpError)) 509 + } 510 + } 511 + 512 + fn fix_reaction_roles( 513 + client: grom.Client, 514 + db_pool_name: process.Name(pog.Message), 515 + guild_id: String, 516 + ) -> Result(Nil, CommandError) { 517 + use reaction_roles <- result.try( 518 + database.find_by_guild_id(db_pool_name, guild_id) 519 + |> log_error(log_level: logging.Warning, message: "") 520 + |> result.replace_error(FailedToGetFromDatabase), 521 + ) 522 + 523 + use _ <- result.try( 524 + reaction_roles 525 + |> list.map(fn(reaction_role) { reaction_role.message_ref }) 526 + |> list.unique() 527 + |> list.try_fold(Nil, fn(_, message_ref) { 528 + message.delete_all_reactions( 529 + client, 530 + in: message_ref.channel_id, 531 + from: message_ref.message_id, 532 + ) 533 + |> result.map_error(grommon_error_or( 534 + _, 535 + FailedToRemoveReactions(message_ref), 536 + )) 537 + }), 538 + ) 539 + 540 + reaction_roles 541 + |> list.try_fold(Nil, fn(_, reaction_role) { 542 + add_reaction(client, reaction_role) 543 + |> result.replace_error(FailedToAddReaction(reaction_role)) 544 + }) 545 + } 546 + 547 + fn grommon_error_or(error: grom.Error, or: CommandError) -> CommandError { 548 + case error { 549 + grom.HttpError(_) -> NetworkIssue 550 + grom.InsufficientPermissions -> InsufficientPermissions 551 + _ -> or 552 + } 553 + } 554 + 555 + fn add_reaction( 556 + client client: grom.Client, 557 + reaction_role reaction_role: reaction_role.ReactionRole, 558 + ) -> Result(Nil, grom.Error) { 559 + reaction.create( 560 + client, 561 + in: reaction_role.message_ref.channel_id, 562 + on: reaction_role.message_ref.message_id, 563 + emoji: reaction_role.emote, 564 + ) 565 + |> log_error( 566 + log_level: logging.Warning, 567 + message: "Ran into error while adding '" 568 + <> reaction_role.emote 569 + <> "' to " 570 + <> reaction_role.message_ref |> message_ref.to_link(), 571 + ) 572 + } 573 + 574 + fn do_call_and_response( 575 + client: grom.Client, 576 + message: NewMessage, 577 + guild_id: GuildId, 578 + ) -> Nil { 579 + let GuildId(guild_id) = guild_id 580 + let NewMessageData(message:, member: _, mentions: _) = message 581 + let response = case message.content { 582 + "hello little stylus" -> { 583 + Some("hello mario") 584 + } 585 + "trans rights are human rights" <> _ -> Some("you got that right!") 586 + _ -> None 587 + } 588 + 589 + case response { 590 + None -> Nil 591 + Some(response) -> { 592 + // this is low stakes call and response so we dont care about failing 593 + let _send_response = 594 + message.create( 595 + client, 596 + in: message.channel_id, 597 + using: message.Create( 598 + ..message.new_create(), 599 + content: Some(response), 600 + message_reference: option.Some(message_reference.MessageReference( 601 + type_: message_reference.Default, 602 + message_id: option.Some(message.id), 603 + channel_id: option.Some(message.channel_id), 604 + guild_id: option.Some(guild_id), 605 + fail_if_not_exists: option.Some(False), 606 + )), 607 + ), 608 + ) 609 + Nil 610 + } 611 + } 612 + } 613 + 614 + fn send_ephemeral_reply( 615 + client client: grom.Client, 616 + guild_id guild_id: option.Option(String), 617 + channel_id channel_id: String, 618 + message_id message_id: String, 619 + with_message response_text: String, 620 + delete_after_ms delete_after_ms: Int, 621 + ) -> Result(Nil, grom.Error) { 622 + send_ephemeral( 623 + client:, 624 + channel_id: channel_id, 625 + message: message.Create( 626 + ..message.new_create(), 627 + content: Some(response_text), 628 + message_reference: Some(message_reference.MessageReference( 629 + type_: message_reference.Default, 630 + message_id: Some(message_id), 631 + channel_id: Some(channel_id), 632 + guild_id: guild_id, 633 + fail_if_not_exists: Some(False), 634 + )), 635 + ), 636 + delete_after_ms:, 637 + ) 638 + } 639 + 640 + fn send_ephemeral_message( 641 + client client: grom.Client, 642 + channel_id channel_id: String, 643 + with_message response_text: String, 644 + delete_after_ms delete_after_ms: Int, 645 + ) -> Result(Nil, grom.Error) { 646 + send_ephemeral( 647 + client:, 648 + channel_id: channel_id, 649 + message: message.Create( 650 + ..message.new_create(), 651 + content: Some(response_text), 652 + ), 653 + delete_after_ms:, 654 + ) 655 + } 656 + 657 + /// attempts to send a `message`, if that succeeds spawns an unlinked process that will attempt to delete the message after the supplied cooldown `delete_after_ms` 658 + /// 659 + fn send_ephemeral( 660 + client client: grom.Client, 661 + channel_id in: String, 662 + message message: message.Create, 663 + delete_after_ms delete_after_ms: Int, 664 + ) -> Result(Nil, grom.Error) { 665 + use sent <- result.try(message.create(client, in:, using: message)) 666 + 667 + process.spawn_unlinked(fn() { 668 + process.sleep(delete_after_ms) 669 + 670 + use _ <- result.try(message.delete( 671 + client, 672 + in: sent.channel_id, 673 + id: sent.id, 674 + because: Some("Ephemeral message"), 675 + )) 676 + Ok(Nil) 677 + }) 678 + 679 + Ok(Nil) 680 + } 681 + 682 + /// `log_level` -> to log with 683 + /// `message` -> to prepend to `string.inspect(error)` 684 + /// 685 + fn log_error( 686 + result: Result(a, b), 687 + log_level log_level: logging.LogLevel, 688 + message message: String, 689 + ) -> Result(a, b) { 690 + let _ = 691 + result.map_error(result, fn(error) { 692 + logging.log(log_level, message <> " " <> string.inspect(error)) 693 + }) 694 + result 695 + }