WIP fluxer bot
0
fork

Configure Feed

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

quite big refactor

nnuuvv 8a81e661 30c263c4

+941 -171
+3 -3
README.md
··· 27 27 bug-fixes: 28 28 - [x] restart after crash / supervision 29 29 - [ ] bot giving itself all roles 30 - 31 - - [ ] o.o 32 - - [x] >.< 30 + 31 + - [ ] occasional ssl issue 32 + 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>>)")])
+1
server/gleam.toml
··· 21 21 pog = ">= 4.1.0 and < 5.0.0" 22 22 splitter = ">= 1.2.0 and < 2.0.0" 23 23 crew = ">= 2.0.0 and < 3.0.0" 24 + gleam_otp = ">= 1.2.0 and < 2.0.0" 24 25 25 26 [dev-dependencies] 26 27 gleeunit = ">= 1.0.0 and < 2.0.0"
+1
server/manifest.toml
··· 51 51 crew = { version = ">= 2.0.0 and < 3.0.0" } 52 52 envoy = { version = ">= 1.1.0 and < 2.0.0" } 53 53 gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 54 + gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } 54 55 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 55 56 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 56 57 grom = { path = "../grom/" }
+353 -157
server/src/server.gleam
··· 1 1 import envoy 2 + import gleam/bool 2 3 import gleam/erlang/process 3 - import gleam/int 4 4 import gleam/list 5 5 import gleam/option.{None, Some} 6 6 import gleam/otp/actor 7 - import gleam/otp/static_supervisor.{type Supervisor} as supervisor 8 - import gleam/otp/supervision 7 + import gleam/otp/static_supervisor as supervisor 9 8 import gleam/result 10 9 import gleam/string 11 - import gramps/http 12 10 import grom 13 - import grom/application 14 11 import grom/gateway 15 12 import grom/gateway/intent 16 13 import grom/guild 14 + import grom/guild/role 17 15 import grom/guild_member 18 16 import grom/message 19 17 import grom/message/message_reference ··· 23 21 import grom/user/current_user 24 22 import logging 25 23 import pog 26 - import server/emoji 24 + import server/command 25 + import server/message_ref 27 26 import server/reaction_role 28 27 import server/reaction_role/database 29 28 import splitter ··· 33 32 client: grom.Client, 34 33 db_pool_name: process.Name(pog.Message), 35 34 self: user.User, 35 + command_splitter: splitter.Splitter, 36 36 ) 37 37 } 38 38 ··· 71 71 } 72 72 73 73 type NewReactionError { 74 - // TODO: once i get this working i should see if that is ever actually the case or if i can just change that in grom 75 - /// this should, in theory, not happen but grom parses it into an `Option(String)` 74 + /// when DMs; we dont do DMs 76 75 GuildIdMissing 76 + 77 + /// This is our own reaction, we can safely discard it 78 + UserIsSelf 79 + 77 80 /// Normal if there is no reaction role associated with a specific message (the vast majority) 78 81 NoReactionRoleFound 79 82 InvalidReaction ··· 90 93 state: State, 91 94 reaction: gateway.MessageReactionCreatedMessage, 92 95 ) -> gateway.Next(State) { 93 - case reaction.user_id { 94 - user_id if user_id == state.self.id -> gateway.continue(state) 96 + let _ = 97 + do_reaction_role(state, reaction) 98 + |> cleanup_reaction_role(state, reaction) 95 99 96 - _ -> { 97 - let _ = 98 - do_reaction_role(state, reaction) 99 - |> cleanup_failed_reaction_role(state, reaction) 100 - 101 - gateway.continue(state) 102 - } 103 - } 100 + gateway.continue(state) 104 101 } 105 102 106 - fn cleanup_failed_reaction_role( 103 + fn cleanup_reaction_role( 107 104 reaction_role: Result(Nil, NewReactionError), 108 105 state: State, 109 106 reaction: gateway.MessageReactionCreatedMessage, 110 - ) -> a { 111 - todo 107 + ) { 108 + let send_message = fn(message) { 109 + send_ephemeral_message( 110 + client: state.client, 111 + channel_id: reaction.channel_id, 112 + with_message: message, 113 + delete_after_ms: 10_000, 114 + ) 115 + } 116 + 117 + let remove_reaction = fn() { 118 + case reaction.emoji.id { 119 + Some(emoji_id) -> { 120 + reaction.delete_users( 121 + state.client, 122 + channel_id: reaction.channel_id, 123 + message_id: reaction.message_id, 124 + emoji_id:, 125 + user_id: reaction.user_id, 126 + ) 127 + } 128 + None -> Ok(Nil) 129 + } 130 + } 131 + 132 + let notify_succes = fn() { 133 + send_ephemeral_message( 134 + client: state.client, 135 + channel_id: reaction.channel_id, 136 + with_message: "Ive added the role.", 137 + delete_after_ms: 5000, 138 + ) 139 + } 140 + 141 + case reaction_role { 142 + Ok(_) -> { 143 + use _ <- result.try(remove_reaction()) 144 + 145 + notify_succes() 146 + } 147 + Error(GuildIdMissing) -> Ok(Nil) 148 + Error(UserIsSelf) -> Ok(Nil) 149 + Error(NoReactionRoleFound) -> remove_reaction() 150 + Error(InvalidReaction) -> remove_reaction() 151 + Error(FailedToAddRole(InsufficientPermission)) -> 152 + send_message( 153 + "I lack the permissions to give you that role. Make sure i have the correct permissions AND the role i am supposed to assign is below my highest role", 154 + ) 155 + Error(FailedToAddRole(Other)) -> 156 + send_message("Something went wrong while adding your role. Try again") 157 + } 112 158 } 113 159 114 160 fn do_reaction_role( 115 161 state: State, 116 162 reaction: gateway.MessageReactionCreatedMessage, 117 163 ) -> Result(Nil, NewReactionError) { 164 + use <- bool.guard( 165 + when: state.self.id == reaction.user_id, 166 + return: Error(UserIsSelf), 167 + ) 168 + 118 169 use guild_id <- result.try( 119 170 reaction.guild_id |> option.to_result(GuildIdMissing), 120 171 ) ··· 124 175 use reactioin_roles <- result.try( 125 176 database.find( 126 177 state.db_pool_name, 127 - database.MessageRef(guild_id:, channel_id:, message_id:), 178 + message_ref.MessageRef(guild_id:, channel_id:, message_id:), 128 179 ) 129 180 |> result.replace_error(NoReactionRoleFound), 130 181 ) ··· 159 210 gateway.continue(state) 160 211 } 161 212 213 + type CommandError { 214 + UserIsntAdmin 215 + FailedToGetRoles 216 + IsDirectMessage 217 + ParseError(command.ParseError) 218 + 219 + // execution 220 + FailedToPersist 221 + HelpError 222 + FailedToGetFromDatabase 223 + FailedToAddReaction(reaction_role.ReactionRole) 224 + FailedToRemoveReactions(message_ref.MessageRef) 225 + InsufficientPermissions 226 + NetworkIssue 227 + } 228 + 162 229 fn on_message( 163 230 state: State, 164 231 message: gateway.MessageCreatedMessage, 165 232 ) -> gateway.Next(State) { 166 - { 167 - let gateway.MessageCreatedMessage(message:, guild_id:, member:, mentions:) = 168 - message 169 - let self = state.self 170 - use guild_id <- option.then(guild_id) 171 - use member <- option.then(member) 233 + let _execute_command = 234 + { 235 + // first check if the message is a guild message since we currently only do stuff in guilds 236 + use #(guild_id, member) <- is_guild_message(message) 172 237 173 - handle_call_and_response(state.client, message, guild_id) 238 + do_call_and_response(state.client, message.message, guild_id) 239 + 240 + // attempt to parse a command from it 241 + use command <- result.try( 242 + command.parse(state.self, message) 243 + |> result.map_error(ParseError), 244 + ) 174 245 175 - let is_admin = member_is_admin(state.client, guild_id:, member:) 246 + // find out if the user is an admin 247 + use roles <- get_roles(client: state.client, guild_id:) 248 + use <- member_is_admin(member:, roles:) 176 249 177 - case mentions, message { 178 - // bot is the first mentioned user *AND* the sender is an admin 179 - [mentioned_user, ..], message.Message(author:, ..) 180 - if mentioned_user.id == self.id && is_admin 181 - -> { 182 - let splitter = splitter.new([" "]) 250 + // if so, try to execute the command 251 + execute_command(state, message, command) 252 + } 253 + |> result.map_error(recover_failed_command_execution( 254 + _, 255 + state.client, 256 + message, 257 + )) 183 258 184 - let #(_at_self, _, rest) = splitter.split(splitter, message.content) 259 + gateway.continue(state) 260 + } 185 261 186 - let _ = case rest |> string.trim() |> echo |> string.split(" ") { 187 - ["add-reaction-role", role_name, emote, add_to_message_link] -> { 188 - use emote <- result.try( 189 - emoji.from_string(emote) |> result.replace_error(InvalidEmote), 190 - ) 262 + fn recover_failed_command_execution( 263 + command_error: CommandError, 264 + client: grom.Client, 265 + message: gateway.MessageCreatedMessage, 266 + ) -> option.Option(a) { 267 + let parse_error = fn(error: command.ParseError) { 268 + case error { 269 + command.DoesntMentionBot -> None 270 + command.MessageHasNoCommand -> Some(command.help_text(command.Help)) 271 + command.UnknownCommand(invalid_command) -> 272 + Some("I don't know the command '" <> invalid_command <> "'. 273 + Try `help` to see a list of available commands") 274 + command.InvalidArguments(argument) -> 275 + Some("There was a problem with the command argument '" <> argument <> "' 276 + Have a look at `help` if you don't know whats wrong") 277 + } 278 + } 191 279 192 - // TODO: respond depending on error 193 - add_reaction_role( 194 - state, 195 - guild_id, 196 - role_name, 197 - emote, 198 - add_to_message_link, 199 - ) 200 - |> echo 201 - } 202 - ["fix-reaction-roles"] -> todo as "fix reaction roles" 203 - ["help"] -> todo as "help text" 280 + let response = case command_error { 281 + ParseError(error) -> parse_error(error) 282 + IsDirectMessage -> 283 + Some("There is nothing i can do for you here at the moment.") 284 + UserIsntAdmin -> Some("You don't have permissions to do this.") 285 + FailedToGetRoles -> Some("I failed to get the roles from fluxer.") 286 + FailedToAddReaction(reaction_role) -> 287 + Some( 288 + "I ran in to an issue adding the reaction '" 289 + <> reaction_role.emote 290 + <> "' to " 291 + <> reaction_role.message_ref |> message_ref.to_link, 292 + ) 293 + FailedToRemoveReactions(message_ref) -> 294 + Some( 295 + "I ran in to an issue removing the reactions from from " 296 + <> message_ref.to_link(message_ref), 297 + ) 298 + FailedToPersist | FailedToGetFromDatabase -> 299 + Some("There seems to be an issue with my database") 300 + HelpError -> 301 + Some( 302 + "So... The help message didn't send, but this might o.o (try again later)", 303 + ) 304 + InsufficientPermissions -> 305 + Some( 306 + "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.", 307 + ) 308 + NetworkIssue -> Some("There was some network related issue.") 309 + } 204 310 205 - _ -> todo as "respond with help text" 206 - } 311 + use response <- option.then(response) 207 312 208 - None 209 - } 313 + let _welp_we_tried = 314 + send_ephemeral_reply( 315 + client:, 316 + guild_id: message.guild_id, 317 + channel_id: message.message.channel_id, 318 + message_id: message.message.id, 319 + with_message: response, 320 + delete_after_ms: 30_000, 321 + ) 210 322 211 - _, _ -> None 212 - } 213 - None 214 - } 215 - gateway.continue(state) 323 + None 216 324 } 217 325 218 - type AddReactionRoleError { 219 - FailedToGetRoles 220 - FailedToFindRole 221 - InvalidEmote 222 - InvalidLink 223 - FailedToPersist 224 - FailedToAddReaction 326 + /// check if a message is a guild message 327 + /// 328 + fn is_guild_message( 329 + message: gateway.MessageCreatedMessage, 330 + inner: fn(#(String, guild_member.GuildMember)) -> Result(a, CommandError), 331 + ) -> Result(a, CommandError) { 332 + case message.guild_id, message.member { 333 + Some(guild_id), Some(member) -> inner(#(guild_id, member)) 334 + _, _ -> Error(IsDirectMessage) 335 + } 225 336 } 226 337 227 - fn add_reaction_role( 228 - state state: State, 338 + /// get all roles for a specific guild by `guild_id` 339 + /// 340 + fn get_roles( 341 + client client: grom.Client, 229 342 guild_id guild_id: String, 230 - role_name role_name: String, 231 - emote emote: String, 232 - to_message add_to_message_link: String, 233 - ) -> Result(Nil, AddReactionRoleError) { 343 + inner inner: fn(List(role.Role)) -> Result(a, CommandError), 344 + ) -> Result(a, CommandError) { 234 345 use roles <- result.try( 235 - guild.get_roles(state.client, guild_id) 236 - |> result.replace_error(FailedToGetRoles), 346 + guild.get_roles(client, guild_id) 347 + |> result.map_error(grommon_error_or(_, FailedToGetRoles)), 237 348 ) 238 349 239 - use target_role <- result.try( 350 + inner(roles) 351 + } 352 + 353 + /// check if any of a users roles have the `Administrator` permission 354 + /// 355 + fn member_is_admin( 356 + member member: guild_member.GuildMember, 357 + roles roles: List(role.Role), 358 + inner inner: fn() -> Result(a, CommandError), 359 + ) -> Result(a, CommandError) { 360 + let is_admin = 240 361 roles 241 - |> list.find(fn(role) { role.name == role_name }) 242 - |> result.replace_error(FailedToFindRole), 243 - ) 362 + |> list.any(fn(role) { 363 + list.contains(member.roles, role.id) 364 + && list.contains(role.permissions, permission.Administrator) 365 + }) 244 366 245 - case string.split(add_to_message_link, "/") |> list.reverse() { 246 - [message_id, channel_id, ..] -> { 367 + case is_admin { 368 + True -> inner() 369 + False -> Error(UserIsntAdmin) 370 + } 371 + } 372 + 373 + fn execute_command( 374 + state: State, 375 + message_created: gateway.MessageCreatedMessage, 376 + command: command.Command, 377 + ) -> Result(Nil, CommandError) { 378 + case command { 379 + command.AddReactionRole(reaction_role:) -> { 380 + // save to database 247 381 use _ <- result.try( 248 - persist_reaction_role( 249 - state, 250 - reaction_role.ReactionRole( 251 - guild_id, 252 - channel_id, 253 - message_id, 254 - emote, 255 - target_role.id, 256 - ), 382 + database.insert(state.db_pool_name, reaction_role) 383 + |> log_error( 384 + log_level: logging.Warning, 385 + message: "Ran into error while persisting reaction_role:", 257 386 ) 258 387 |> result.replace_error(FailedToPersist), 259 388 ) 260 389 261 - use _ <- result.try( 262 - reaction.create( 263 - state.client, 264 - in: channel_id, 265 - on: message_id, 266 - emoji: emote, 267 - ) 268 - |> result.replace_error(FailedToAddReaction), 390 + // add reaction to message 391 + add_reaction(state.client, reaction_role) 392 + |> result.map_error(grommon_error_or( 393 + _, 394 + FailedToAddReaction(reaction_role), 395 + )) 396 + } 397 + command.FixReactionRoles -> 398 + fix_reaction_roles(state.client, state.db_pool_name, message_created) 399 + command.Help -> 400 + send_ephemeral_reply( 401 + client: state.client, 402 + guild_id: None, 403 + channel_id: message_created.message.channel_id, 404 + message_id: message_created.message.id, 405 + with_message: command.help_text(command.Help), 406 + delete_after_ms: 30_000, 269 407 ) 270 - Ok(Nil) 271 - } 408 + |> result.map_error(grommon_error_or(_, HelpError)) 409 + } 410 + } 411 + 412 + fn fix_reaction_roles( 413 + client: grom.Client, 414 + db_pool_name: process.Name(pog.Message), 415 + message_created: gateway.MessageCreatedMessage, 416 + ) -> Result(Nil, CommandError) { 417 + use guild_id <- result.try( 418 + message_created.guild_id |> option.to_result(IsDirectMessage), 419 + ) 420 + 421 + use reaction_roles <- result.try( 422 + database.find_by_guild_id(db_pool_name, guild_id) 423 + |> log_error(log_level: logging.Warning, message: "") 424 + |> result.replace_error(FailedToGetFromDatabase), 425 + ) 426 + 427 + use _ <- result.try( 428 + reaction_roles 429 + |> list.map(fn(reaction_role) { reaction_role.message_ref }) 430 + |> list.unique() 431 + |> list.try_fold(Nil, fn(_, message_ref) { 432 + message.delete_all_reactions( 433 + client, 434 + in: message_ref.channel_id, 435 + from: message_ref.message_id, 436 + ) 437 + |> result.map_error(grommon_error_or( 438 + _, 439 + FailedToRemoveReactions(message_ref), 440 + )) 441 + }), 442 + ) 443 + 444 + reaction_roles 445 + |> list.try_fold(Nil, fn(_, reaction_role) { 446 + add_reaction(client, reaction_role) 447 + |> result.replace_error(FailedToAddReaction(reaction_role)) 448 + }) 449 + } 272 450 273 - _ -> Error(InvalidLink) 451 + fn grommon_error_or(error: grom.Error, or: CommandError) -> CommandError { 452 + case error { 453 + grom.HttpError(_) -> NetworkIssue 454 + grom.InsufficientPermissions -> InsufficientPermissions 455 + _ -> or 274 456 } 275 457 } 276 458 277 - fn persist_reaction_role( 278 - state: State, 279 - reaction_role: reaction_role.ReactionRole, 280 - ) -> Result(pog.Returned(Nil), pog.QueryError) { 281 - database.insert(state.db_pool_name, reaction_role) 459 + fn add_reaction( 460 + client client: grom.Client, 461 + reaction_role reaction_role: reaction_role.ReactionRole, 462 + ) -> Result(Nil, grom.Error) { 463 + reaction.create( 464 + client, 465 + in: reaction_role.message_ref.channel_id, 466 + on: reaction_role.message_ref.message_id, 467 + emoji: reaction_role.emote, 468 + ) 469 + |> log_error( 470 + log_level: logging.Warning, 471 + message: "Ran into error while adding '" 472 + <> reaction_role.emote 473 + <> "' to " 474 + <> reaction_role.message_ref |> message_ref.to_link(), 475 + ) 282 476 } 283 477 284 - fn handle_call_and_response( 478 + fn do_call_and_response( 285 479 client: grom.Client, 286 480 message: message.Message, 287 481 guild_id: String, ··· 304 498 )), 305 499 ), 306 500 ) 307 - let _ = 308 - send_ephemeral_message( 309 - client:, 310 - channel_id: message.channel_id, 311 - message: message.Create( 312 - ..message.new_create(), 313 - content: Some("noone will believe you >:3"), 314 - message_reference: Some(message_reference.MessageReference( 315 - type_: message_reference.Default, 316 - message_id: Some(message.id), 317 - channel_id: Some(message.channel_id), 318 - guild_id: Some(guild_id), 319 - fail_if_not_exists: Some(False), 320 - )), 321 - ), 322 - delete_after_ms: 10_000, 323 - ) 324 - 325 501 Nil 326 502 } 327 503 _ -> Nil ··· 339 515 gateway.continue(State(..state, self: ready.user)) 340 516 } 341 517 518 + fn send_ephemeral_reply( 519 + client client: grom.Client, 520 + guild_id guild_id: option.Option(String), 521 + channel_id channel_id: String, 522 + message_id message_id: String, 523 + with_message response_text: String, 524 + delete_after_ms delete_after_ms: Int, 525 + ) -> Result(Nil, grom.Error) { 526 + send_ephemeral( 527 + client:, 528 + channel_id: channel_id, 529 + message: message.Create( 530 + ..message.new_create(), 531 + content: Some(response_text), 532 + message_reference: Some(message_reference.MessageReference( 533 + type_: message_reference.Default, 534 + message_id: Some(message_id), 535 + channel_id: Some(channel_id), 536 + guild_id: guild_id, 537 + fail_if_not_exists: Some(False), 538 + )), 539 + ), 540 + delete_after_ms:, 541 + ) 542 + } 543 + 544 + fn send_ephemeral_message( 545 + client client: grom.Client, 546 + channel_id channel_id: String, 547 + with_message response_text: String, 548 + delete_after_ms delete_after_ms: Int, 549 + ) -> Result(Nil, grom.Error) { 550 + send_ephemeral( 551 + client:, 552 + channel_id: channel_id, 553 + message: message.Create( 554 + ..message.new_create(), 555 + content: Some(response_text), 556 + ), 557 + delete_after_ms:, 558 + ) 559 + } 560 + 342 561 /// 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` 343 562 /// 344 - fn send_ephemeral_message( 563 + fn send_ephemeral( 345 564 client client: grom.Client, 346 565 channel_id in: String, 347 566 message message: message.Create, ··· 364 583 Ok(Nil) 365 584 } 366 585 367 - fn member_is_admin( 368 - client client: grom.Client, 369 - guild_id guild_id: String, 370 - member member: guild_member.GuildMember, 371 - ) -> Bool { 372 - let roles = guild.get_roles(client, guild_id) 373 - 374 - case roles { 375 - Ok(roles) -> 376 - roles 377 - |> list.any(fn(role) { 378 - member.roles |> list.contains(role.id) 379 - && role.permissions |> list.contains(permission.Administrator) 380 - }) 381 - Error(error) -> { 382 - logging.log( 383 - logging.Warning, 384 - "Failed to get roles for guild: " 385 - <> guild_id 386 - <> " with error: " 387 - <> string.inspect(error), 388 - ) 389 - False 390 - } 391 - } 392 - } 393 - 394 586 // setup ------------------------------------------------------------------------ 395 587 396 588 fn database_config() -> Result(pog.Config, String) { ··· 436 628 |> result.replace_error(actor.InitFailed("Failed to get self")), 437 629 ) 438 630 439 - gateway.new(State(client:, db_pool_name:, self:), identify, data) 631 + gateway.new( 632 + State(client:, db_pool_name:, self:, command_splitter: splitter.new([" "])), 633 + identify, 634 + data, 635 + ) 440 636 |> gateway.on_event(do: on_event) 441 637 |> gateway.on_close(fn(_state) { 442 638 logging.log(logging.Alert, "Gateway closed")
+137
server/src/server/command.gleam
··· 1 + import gleam/list 2 + import gleam/option.{Some} 3 + import gleam/result 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 + import grom/user 13 + import server/emoji 14 + import server/message_ref 15 + import server/reaction_role 16 + 17 + // commands --------------------------------------------------------------------- 18 + 19 + pub type Command { 20 + AddReactionRole(reaction_role: reaction_role.ReactionRole) 21 + FixReactionRoles 22 + Help 23 + } 24 + 25 + // help texts ------------------------------------------------------------------- 26 + 27 + type HelpTexts { 28 + HelpTexts(add_reaction_role: String, fix_reaction_roles: String) 29 + } 30 + 31 + const help_texts = HelpTexts( 32 + add_reaction_role: "`add-reaction-role :emote: @role <link-to-message>` 33 + add a new `reaction -> role` mapping to a specific message", 34 + fix_reaction_roles: "`fix-reaction-roles` 35 + attempt to fix reaction roles by removing and re-adding all of them based on the database", 36 + ) 37 + 38 + /// return the help text for a given command 39 + /// 40 + pub fn help_text(command: Command) { 41 + case command { 42 + AddReactionRole(_) -> help_texts.add_reaction_role 43 + FixReactionRoles -> help_texts.fix_reaction_roles 44 + Help -> 45 + "every command needs to be prefaced with @bot-name 46 + 47 + `help` 48 + print this help message" 49 + |> list.fold( 50 + [help_texts.add_reaction_role, help_texts.fix_reaction_roles], 51 + _, 52 + fn(last, next) { last <> "\n\n" <> next }, 53 + ) 54 + } 55 + } 56 + 57 + // parsing ---------------------------------------------------------------------- 58 + 59 + pub type ParseError { 60 + /// we dont care about messages that dont mention us 61 + DoesntMentionBot 62 + MessageHasNoCommand 63 + UnknownCommand(String) 64 + InvalidArguments(String) 65 + } 66 + 67 + /// parse `Command` from message sent by user 68 + /// 69 + pub fn parse( 70 + self self: user.User, 71 + message message: gateway.MessageCreatedMessage, 72 + ) -> Result(Command, ParseError) { 73 + let gateway.MessageCreatedMessage( 74 + message: message.Message(content:, ..), 75 + mentions:, 76 + .., 77 + ) = message 78 + 79 + // check that we are being addressed, in a guild 80 + use <- mentions_bot(mentions, self) 81 + 82 + // remove @bot-name 83 + use content <- command_content(content) 84 + 85 + // parse commands 86 + case content { 87 + "add-reaction-role" <> rest -> 88 + parse_reaction_role_arguments(rest) |> result.map(AddReactionRole) 89 + 90 + "fix-reaction-roles" -> Ok(FixReactionRoles) 91 + "help" -> Ok(Help) 92 + 93 + other -> Error(UnknownCommand(other)) 94 + } 95 + } 96 + 97 + fn parse_reaction_role_arguments( 98 + arguments: String, 99 + ) -> Result(reaction_role.ReactionRole, ParseError) { 100 + case string.split(arguments, " ") { 101 + [emote, target_role_id, message_link] -> { 102 + use emote <- result.try( 103 + emoji.from_string(emote) 104 + |> result.replace_error(InvalidArguments("emote")), 105 + ) 106 + 107 + use message_ref <- result.try( 108 + message_ref.from_link(message_link) 109 + |> result.replace_error(InvalidArguments("message_link")), 110 + ) 111 + 112 + reaction_role.ReactionRole(message_ref:, emote:, target_role_id:) |> Ok 113 + } 114 + _ -> InvalidArguments("count") |> Error 115 + } 116 + } 117 + 118 + fn command_content( 119 + content: String, 120 + inner: fn(String) -> Result(a, ParseError), 121 + ) -> Result(a, ParseError) { 122 + case string.split_once(content, " ") { 123 + Ok(#(_, content)) -> inner(string.trim(content)) 124 + Error(_) -> Error(MessageHasNoCommand) 125 + } 126 + } 127 + 128 + fn mentions_bot( 129 + mentions: List(user.User), 130 + self: user.User, 131 + inner: fn() -> Result(a, ParseError), 132 + ) -> Result(a, ParseError) { 133 + case mentions { 134 + [first, ..] if first.id == self.id -> inner() 135 + _ -> Error(DoesntMentionBot) 136 + } 137 + }
+35
server/src/server/message_ref.gleam
··· 1 + import gleam/list 2 + import gleam/string 3 + 4 + pub type MessageRef { 5 + MessageRef(guild_id: String, channel_id: String, message_id: String) 6 + } 7 + 8 + /// turns a link to a message like `https://fluxer.app/channels/{guild_id}/{channel_id}/{message_id}` 9 + /// into a ref like: 10 + /// 11 + /// ```gleam 12 + /// MessagRef(guild_id:, channel_id:, message_id:) 13 + /// ``` 14 + /// 15 + /// Does not verify the chunks are valid snowflakes 16 + /// 17 + pub fn from_link(link: String) -> Result(MessageRef, Nil) { 18 + // TODO: validate segments 19 + case string.split(link, "/") |> list.reverse() { 20 + [message_id, channel_id, guild_id, ..] -> 21 + MessageRef(guild_id:, channel_id:, message_id:) |> Ok 22 + _ -> Nil |> Error 23 + } 24 + } 25 + 26 + pub fn to_link(message_ref: MessageRef) -> String { 27 + let MessageRef(guild_id:, channel_id:, message_id:) = message_ref 28 + 29 + "https://fluxer.app/channels/" 30 + <> guild_id 31 + <> "/" 32 + <> channel_id 33 + <> "/" 34 + <> message_id 35 + }
+313 -3
server/src/server/reaction_role.gleam
··· 1 + import gleam/bool 2 + import gleam/erlang/process 3 + import gleam/list 4 + import gleam/option 5 + import gleam/result 6 + import gleam/string 7 + import grom 8 + import grom/guild 9 + import grom/guild/role 10 + import grom/guild_member 11 + import grom/message 12 + import grom/message/message_reference 13 + import grom/message/reaction 14 + import grom/permission 15 + import pog 16 + import server/emoji 17 + import server/message_ref 18 + import server/reaction_role/sql 19 + 1 20 pub type ReactionRole { 2 21 ReactionRole( 3 - guild_id: String, 4 - channel_id: String, 5 - message_id: String, 22 + message_ref: message_ref.MessageRef, 6 23 emote: String, 7 24 target_role_id: String, 8 25 ) 9 26 } 27 + 28 + pub type AddReactionRoleError { 29 + UserHasInsufficientPermissions 30 + FailedToGetRoles 31 + FailedToFindRole 32 + InvalidEmote 33 + InvalidLink 34 + FailedToPersist 35 + FailedToAddReaction 36 + FailedToSendResponse 37 + } 38 + 39 + pub fn add_reaction( 40 + client: grom.Client, 41 + reaction_role: ReactionRole, 42 + ) -> Result(Nil, AddReactionRoleError) { 43 + reaction.create( 44 + client, 45 + in: reaction_role.message_ref.channel_id, 46 + on: reaction_role.message_ref.message_id, 47 + emoji: reaction_role.emote, 48 + ) 49 + |> result.replace_error(FailedToAddReaction) 50 + } 51 + 52 + fn make( 53 + client client: grom.Client, 54 + guild_id guild_id: String, 55 + role_name role_name: String, 56 + emote emote: String, 57 + to_message_link add_to_message_link: String, 58 + sender member: guild_member.GuildMember, 59 + ) -> Result(ReactionRole, AddReactionRoleError) { 60 + use roles <- result.try( 61 + guild.get_roles(client, guild_id) 62 + |> result.replace_error(FailedToGetRoles), 63 + ) 64 + 65 + use <- bool.guard( 66 + member_is_admin(member:, roles:), 67 + Error(UserHasInsufficientPermissions), 68 + ) 69 + 70 + use emote <- result.try( 71 + emoji.from_string(emote) |> result.replace_error(InvalidEmote), 72 + ) 73 + 74 + use target_role <- result.try( 75 + roles 76 + |> list.find(fn(role) { role.name == role_name }) 77 + |> result.replace_error(FailedToFindRole), 78 + ) 79 + case string.split(add_to_message_link, "/") |> list.reverse() { 80 + [message_id, channel_id, ..] -> { 81 + ReactionRole( 82 + message_ref.MessageRef(guild_id, channel_id, message_id), 83 + emote, 84 + target_role.id, 85 + ) 86 + |> Ok 87 + } 88 + 89 + _ -> Error(InvalidLink) 90 + } 91 + } 92 + 93 + fn member_is_admin( 94 + member member: guild_member.GuildMember, 95 + roles roles: List(role.Role), 96 + ) -> Bool { 97 + roles 98 + |> list.any(fn(role) { 99 + list.contains(member.roles, role.id) 100 + && list.contains(role.permissions, permission.Administrator) 101 + }) 102 + } 103 + 104 + // TODO: better api cause wtf 105 + /// 106 + /// 107 + pub fn a_reaction_role( 108 + client client: grom.Client, 109 + db_pool db_pool: process.Name(pog.Message), 110 + guild_id guild_id: String, 111 + role_name role_name: String, 112 + emote emote: String, 113 + to_message to_message_link: String, 114 + sender member: guild_member.GuildMember, 115 + message message: message.Message, 116 + ) -> Result(Nil, grom.Error) { 117 + case 118 + add( 119 + client:, 120 + db_pool:, 121 + guild_id:, 122 + role_name:, 123 + emote:, 124 + to_message_link:, 125 + sender: member, 126 + respond_to: message, 127 + ) 128 + { 129 + Ok(_) -> { 130 + use _ <- result.try(send_ephemeral_reply( 131 + client: client, 132 + guild_id:, 133 + reply_to: message, 134 + with_message: "Added reaction " 135 + <> emote 136 + <> " for role \"" 137 + <> role_name 138 + <> "\" to " 139 + <> to_message_link, 140 + delete_after_ms: 5000, 141 + )) 142 + do_after(ms: 5000, do: fn() { 143 + let _ = 144 + message.delete( 145 + client, 146 + message.channel_id, 147 + message.id, 148 + option.Some("Handled command"), 149 + ) 150 + Nil 151 + }) 152 + Ok(Nil) 153 + } 154 + Error(error) -> { 155 + let with_message = case error { 156 + UserHasInsufficientPermissions -> 157 + "You need to be an admin to use this command" 158 + FailedToGetRoles -> "I ran in to an issue while fetching the roles" 159 + FailedToFindRole -> "I couldn't find the role \" " <> role_name <> "\"" 160 + InvalidEmote -> "I failed to parse the emote \"" <> emote <> "\"" 161 + InvalidLink -> 162 + "The link \"" <> to_message_link <> "\" you supplied is invalid" 163 + FailedToPersist -> 164 + "I ran in to an issue while saving the emote -> role mapping" 165 + FailedToAddReaction -> 166 + "I ran in to an issue while reacting to the message " 167 + <> to_message_link 168 + FailedToSendResponse -> "uuuuhhh" 169 + } 170 + 171 + use _ <- result.try(send_ephemeral_reply( 172 + client:, 173 + guild_id:, 174 + reply_to: message, 175 + with_message:, 176 + delete_after_ms: 5000, 177 + )) 178 + 179 + Ok(Nil) 180 + } 181 + } 182 + } 183 + 184 + /// 185 + /// 186 + pub fn add( 187 + client client: grom.Client, 188 + db_pool db_pool: process.Name(pog.Message), 189 + guild_id guild_id: String, 190 + role_name role_name: String, 191 + emote emote: String, 192 + to_message_link to_message_link: String, 193 + sender member: guild_member.GuildMember, 194 + respond_to message: message.Message, 195 + ) -> Result(ReactionRole, AddReactionRoleError) { 196 + use reaction_role <- result.try(make( 197 + client:, 198 + guild_id:, 199 + role_name:, 200 + emote:, 201 + to_message_link:, 202 + sender: member, 203 + )) 204 + 205 + use _ <- result.try( 206 + insert(db_pool, reaction_role) 207 + |> result.replace_error(FailedToPersist), 208 + ) 209 + use _ <- result.try(add_reaction(client, reaction_role)) 210 + 211 + use _ <- result.try( 212 + send_ephemeral_reply( 213 + client: client, 214 + guild_id:, 215 + reply_to: message, 216 + with_message: "Added reaction " 217 + <> emote 218 + <> " for role \"" 219 + <> role_name 220 + <> "\" to " 221 + <> to_message_link, 222 + delete_after_ms: 5000, 223 + ) 224 + |> result.replace_error(FailedToSendResponse), 225 + ) 226 + 227 + do_after(ms: 5000, do: fn() { 228 + let _ = 229 + message.delete( 230 + client, 231 + message.channel_id, 232 + message.id, 233 + option.Some("Handled command"), 234 + ) 235 + Nil 236 + }) 237 + 238 + Ok(reaction_role) 239 + } 240 + 241 + /// insert a new `ReactionRole` into the reaction_role table 242 + /// 243 + fn insert( 244 + pool_name pool_name: process.Name(pog.Message), 245 + role reaction_role: ReactionRole, 246 + ) -> Result(pog.Returned(Nil), pog.QueryError) { 247 + let ReactionRole( 248 + message_ref: message_ref.MessageRef(guild_id:, channel_id:, message_id:), 249 + emote:, 250 + target_role_id:, 251 + ) = reaction_role 252 + 253 + let connection = pog.named_connection(pool_name) 254 + 255 + sql.insert( 256 + connection, 257 + guild_id, 258 + channel_id, 259 + message_id, 260 + emote, 261 + target_role_id, 262 + ) 263 + } 264 + 265 + fn send_ephemeral_reply( 266 + client client: grom.Client, 267 + guild_id guild_id: String, 268 + reply_to message: message.Message, 269 + with_message response_text: String, 270 + delete_after_ms delete_after_ms: Int, 271 + ) -> Result(Nil, grom.Error) { 272 + send_ephemeral_message( 273 + client:, 274 + channel_id: message.channel_id, 275 + message: message.Create( 276 + ..message.new_create(), 277 + content: option.Some(response_text), 278 + message_reference: option.Some(message_reference.MessageReference( 279 + type_: message_reference.Default, 280 + message_id: option.Some(message.id), 281 + channel_id: option.Some(message.channel_id), 282 + guild_id: option.Some(guild_id), 283 + fail_if_not_exists: option.Some(False), 284 + )), 285 + ), 286 + delete_after_ms:, 287 + ) 288 + } 289 + 290 + /// 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` 291 + /// 292 + fn send_ephemeral_message( 293 + client client: grom.Client, 294 + channel_id in: String, 295 + message message: message.Create, 296 + delete_after_ms delete_after_ms: Int, 297 + ) -> Result(Nil, grom.Error) { 298 + use sent <- result.try(message.create(client, in:, using: message)) 299 + 300 + do_after(delete_after_ms, fn() { 301 + let _ = 302 + message.delete( 303 + client, 304 + in: sent.channel_id, 305 + id: sent.id, 306 + because: option.Some("Ephemeral message"), 307 + ) 308 + Nil 309 + }) 310 + 311 + Ok(Nil) 312 + } 313 + 314 + fn do_after(ms do_after_ms: Int, do inner: fn() -> Nil) -> process.Pid { 315 + process.spawn_unlinked(fn() { 316 + process.sleep(do_after_ms) 317 + inner() 318 + }) 319 + }
+34 -8
server/src/server/reaction_role/database.gleam
··· 2 2 import gleam/list 3 3 import gleam/result 4 4 import pog 5 + import server/message_ref.{MessageRef} 5 6 import server/reaction_role 6 7 import server/reaction_role/sql 7 8 ··· 21 22 role reaction_role: reaction_role.ReactionRole, 22 23 ) -> Result(pog.Returned(Nil), pog.QueryError) { 23 24 let reaction_role.ReactionRole( 24 - guild_id:, 25 - channel_id:, 26 - message_id:, 25 + message_ref: MessageRef(guild_id:, channel_id:, message_id:), 27 26 emote:, 28 27 target_role_id:, 29 28 ) = reaction_role ··· 40 39 ) 41 40 } 42 41 43 - pub type MessageRef { 44 - MessageRef(guild_id: String, channel_id: String, message_id: String) 45 - } 46 - 47 42 /// find specific `ReactionRole`'s based on their associated `MessageRef` 48 43 /// 49 44 pub fn find( 50 45 pool_name pool_name: process.Name(pog.Message), 51 - find find: MessageRef, 46 + find find: message_ref.MessageRef, 52 47 ) { 53 48 let MessageRef(guild_id:, channel_id:, message_id:) = find 54 49 ··· 73 68 find_row 74 69 75 70 reaction_role.ReactionRole( 71 + message_ref: MessageRef(guild_id:, channel_id:, message_id:), 72 + emote:, 73 + target_role_id:, 74 + ) 75 + } 76 + 77 + /// find all `ReactionRole`'s for a specific guild 78 + /// 79 + pub fn find_by_guild_id( 80 + pool_name pool_name: process.Name(pog.Message), 81 + guild_id guild_id: String, 82 + ) { 83 + let connection = pog.named_connection(pool_name) 84 + 85 + use returned <- result.try(sql.find_by_guild_id(connection, guild_id)) 86 + 87 + returned.rows 88 + |> list.map(find_by_guild_id_row_to_reaction_role) 89 + |> Ok 90 + } 91 + 92 + fn find_by_guild_id_row_to_reaction_role( 93 + find_row: sql.FindByGuildIdRow, 94 + ) -> reaction_role.ReactionRole { 95 + let sql.FindByGuildIdRow( 76 96 guild_id:, 77 97 channel_id:, 78 98 message_id:, 99 + emote:, 100 + target_role_id:, 101 + ) = find_row 102 + 103 + reaction_role.ReactionRole( 104 + message_ref: MessageRef(guild_id:, channel_id:, message_id:), 79 105 emote:, 80 106 target_role_id:, 81 107 )
+56
server/src/server/reaction_role/sql.gleam
··· 93 93 |> pog.execute(db) 94 94 } 95 95 96 + /// A row you get from running the `find_by_guild_id` query 97 + /// defined in `./src/server/reaction_role/sql/find_by_guild_id.sql`. 98 + /// 99 + /// > 🐿️ This type definition was generated automatically using v4.6.0 of the 100 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 101 + /// 102 + pub type FindByGuildIdRow { 103 + FindByGuildIdRow( 104 + guild_id: String, 105 + channel_id: String, 106 + message_id: String, 107 + emote: String, 108 + target_role_id: String, 109 + ) 110 + } 111 + 112 + /// finds an entry using guild_id, channel_id and message_id 113 + /// 114 + /// 115 + /// > 🐿️ This function was generated automatically using v4.6.0 of 116 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 117 + /// 118 + pub fn find_by_guild_id( 119 + db: pog.Connection, 120 + arg_1: String, 121 + ) -> Result(pog.Returned(FindByGuildIdRow), pog.QueryError) { 122 + let decoder = { 123 + use guild_id <- decode.field(0, decode.string) 124 + use channel_id <- decode.field(1, decode.string) 125 + use message_id <- decode.field(2, decode.string) 126 + use emote <- decode.field(3, decode.string) 127 + use target_role_id <- decode.field(4, decode.string) 128 + decode.success(FindByGuildIdRow( 129 + guild_id:, 130 + channel_id:, 131 + message_id:, 132 + emote:, 133 + target_role_id:, 134 + )) 135 + } 136 + 137 + "-- finds an entry using guild_id, channel_id and message_id 138 + -- 139 + select 140 + * 141 + from 142 + reaction_roles 143 + where 144 + guild_id = $1 145 + " 146 + |> pog.query 147 + |> pog.parameter(pog.text(arg_1)) 148 + |> pog.returning(decoder) 149 + |> pog.execute(db) 150 + } 151 + 96 152 /// insert a new reaction_role record into the database 97 153 /// 98 154 ///
+8
server/src/server/reaction_role/sql/find_by_guild_id.sql
··· 1 + -- finds an entry using guild_id, channel_id and message_id 2 + -- 3 + select 4 + * 5 + from 6 + reaction_roles 7 + where 8 + guild_id = $1