1 /++ 2 Module for interacting with the Discord chat service. I use it to run a discord bot providing some slash commands. 3 4 5 $(LIST 6 * Real time gateway 7 See [DiscordGatewayConnection] 8 9 You can use [SlashCommandHandler] subclasses registered with a gateway connection to easily add slash commands to your app. 10 * REST api 11 See [DiscordRestApi] 12 * Local RPC server 13 See [DiscordRpcConnection] (not implemented) 14 * Voice connections 15 not implemented 16 * Login with Discord 17 OAuth2 is easy enough without the lib, see bingo.d line 340ish-380ish. 18 ) 19 20 History: 21 Started April 20, 2024. 22 +/ 23 module arsd.discord; 24 25 // FIXME: it thought it was still alive but showed as not online and idk why. maybe setPulseCallback stopped triggering? 26 27 // FIXME: Secure Connect Failed sometimes on trying to reconnect, should prolly just try again after a short period, or ditch the whole thing if reconnectAndResume and try fresh 28 29 // FIXME: User-Agent: DiscordBot ($url, $versionNumber) 30 31 import arsd.http2; 32 import arsd.jsvar; 33 34 import arsd.core; 35 36 import core.time; 37 38 static assert(use_arsd_core); 39 40 /++ 41 Base class to represent some object on Discord, e.g. users, channels, etc., through its subclasses. 42 43 44 Among its implementations are: 45 46 $(LIST 47 * [DiscordChannel] 48 * [DiscordUser] 49 * [DiscordRole] 50 ) 51 +/ 52 abstract class DiscordEntity { 53 private DiscordRestApi api; 54 private string id_; 55 56 protected this(DiscordRestApi api, string id) { 57 this.api = api; 58 this.id_ = id; 59 } 60 61 override string toString() { 62 return restType ~ "/" ~ id; 63 } 64 65 /++ 66 67 +/ 68 abstract string restType(); 69 70 /++ 71 72 +/ 73 final string id() { 74 return id_; 75 } 76 77 /++ 78 Gives easy access to its rest api through [arsd.http2.HttpApiClient]'s dynamic dispatch functions. 79 80 +/ 81 DiscordRestApi.RestBuilder rest() { 82 return api.rest[restType()][id()]; 83 } 84 } 85 86 /++ 87 Represents something mentionable on Discord with `@name` - roles and users. 88 +/ 89 abstract class DiscordMentionable : DiscordEntity { 90 this(DiscordRestApi api, string id) { 91 super(api, id); 92 } 93 } 94 95 /++ 96 https://discord.com/developers/docs/resources/channel 97 +/ 98 class DiscordChannel : DiscordEntity { 99 this(DiscordRestApi api, string id) { 100 super(api, id); 101 } 102 103 override string restType() { 104 return "channels"; 105 } 106 107 void sendMessage(string message) { 108 if(message.length == 0) 109 message = "empty message specified"; 110 var msg = var.emptyObject; 111 msg.content = message; 112 rest.messages.POST(msg).result; 113 } 114 } 115 116 /++ 117 118 +/ 119 class DiscordRole : DiscordMentionable { 120 this(DiscordRestApi api, DiscordGuild guild, string id) { 121 this.guild_ = guild; 122 super(api, id); 123 } 124 125 private DiscordGuild guild_; 126 127 /++ 128 129 +/ 130 DiscordGuild guild() { 131 return guild_; 132 } 133 134 override string restType() { 135 return "roles"; 136 } 137 } 138 139 /++ 140 https://discord.com/developers/docs/resources/user 141 +/ 142 class DiscordUser : DiscordMentionable { 143 this(DiscordRestApi api, string id) { 144 super(api, id); 145 } 146 147 private var cachedData; 148 149 // DiscordGuild selectedGuild; 150 151 override string restType() { 152 return "users"; 153 } 154 155 void addRole(DiscordRole role) { 156 // PUT /guilds/{guild.id}/members/{user.id}/roles/{role.id} 157 158 auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id]; 159 //writeln(thing.toUri); 160 161 auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].PUT().result; 162 } 163 164 void removeRole(DiscordRole role) { 165 // DELETE /guilds/{guild.id}/members/{user.id}/roles/{role.id} 166 167 auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id]; 168 //writeln(thing.toUri); 169 170 auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].DELETE().result; 171 } 172 173 private DiscordChannel dmChannel_; 174 175 DiscordChannel dmChannel() { 176 if(dmChannel_ is null) { 177 var obj = var.emptyObject; 178 obj.recipient_id = this.id; 179 var result = this.api.rest.users["@me"].channels.POST(obj).result; 180 181 dmChannel_ = new DiscordChannel(api, result.id.get!string);//, result); 182 } 183 return dmChannel_; 184 } 185 186 void sendMessage(string what) { 187 dmChannel.sendMessage(what); 188 } 189 } 190 191 /++ 192 193 +/ 194 class DiscordGuild : DiscordEntity { 195 this(DiscordRestApi api, string id) { 196 super(api, id); 197 } 198 199 override string restType() { 200 return "guilds"; 201 } 202 203 } 204 205 206 enum InteractionType { 207 PING = 1, 208 APPLICATION_COMMAND = 2, // the main one 209 MESSAGE_COMPONENT = 3, 210 APPLICATION_COMMAND_AUTOCOMPLETE = 4, 211 MODAL_SUBMIT = 5, 212 } 213 214 215 /++ 216 You can create your own slash command handlers by subclassing this and writing methods like 217 218 It will register for you when you connect and call your function when things come in. 219 220 See_Also: 221 https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands 222 +/ 223 class SlashCommandHandler { 224 enum ApplicationCommandOptionType { 225 INVALID = 0, // my addition 226 SUB_COMMAND = 1, 227 SUB_COMMAND_GROUP = 2, 228 STRING = 3, 229 INTEGER = 4, // double's int part 230 BOOLEAN = 5, 231 USER = 6, 232 CHANNEL = 7, 233 ROLE = 8, 234 MENTIONABLE = 9, 235 NUMBER = 10, // double 236 ATTACHMENT = 11, 237 } 238 239 /++ 240 This takes the child type into the parent so we can reflect over your added methods. 241 to initialize the reflection info to send to Discord. If you subclass your subclass, 242 make sure the grandchild constructor does `super(); registerAll(this);` to add its method 243 to the list too, but if you only have one level of child, the compiler will auto-generate 244 a constructor for you that calls this. 245 +/ 246 protected this(this This)() { 247 registerAll(cast(This) this); 248 } 249 250 /++ 251 252 +/ 253 static class InteractionReplyHelper { 254 private DiscordRestApi api; 255 private CommandArgs commandArgs; 256 257 private this(DiscordRestApi api, CommandArgs commandArgs) { 258 this.api = api; 259 this.commandArgs = commandArgs; 260 261 } 262 263 /++ 264 265 +/ 266 void reply(string message, bool ephemeral = false) scope { 267 replyLowLevel(message, ephemeral); 268 } 269 270 /++ 271 272 +/ 273 void replyWithError(scope const(char)[] message) scope { 274 if(message.length == 0) 275 message = "I am error."; 276 replyLowLevel(message.idup, true); 277 } 278 279 enum MessageFlags : uint { 280 SUPPRESS_EMBEDS = (1 << 2), // skip the embedded content 281 EPHEMERAL = (1 << 6), // only visible to you 282 LOADING = (1 << 7), // the bot is "thinking" 283 SUPPRESS_NOTIFICATIONS = (1 << 12) // skip push/desktop notifications 284 } 285 286 void replyLowLevel(string message, bool ephemeral) scope { 287 if(message.length == 0) 288 message = "empty message"; 289 var reply = var.emptyObject; 290 reply.type = 4; // chat response in message. 5 can be answered quick and edited later if loading, 6 if quick answer, no loading message 291 var replyData = var.emptyObject; 292 replyData.content = message; 293 replyData.flags = ephemeral ? (1 << 6) : 0; 294 reply.data = replyData; 295 try { 296 var result = api.rest. 297 interactions[commandArgs.interactionId][commandArgs.interactionToken].callback 298 .POST(reply).result; 299 // writeln(result.toString); 300 } catch(Exception e) { 301 // import std.stdio; writeln(commandArgs); 302 logSwallowedException(e); 303 } 304 } 305 } 306 307 308 private bool alreadyRegistered; 309 private void register(DiscordRestApi api, string appId) { 310 if(alreadyRegistered) 311 return; 312 auto result = api.rest.applications[appId].commands.PUT(jsonArrayForDiscord).result; 313 alreadyRegistered = true; 314 } 315 316 private static struct CommandArgs { 317 InteractionType interactionType; 318 string interactionToken; 319 string interactionId; 320 string guildId; 321 string channelId; 322 323 var interactionData; 324 325 var member; 326 var channel; 327 } 328 329 private { 330 331 static void validateDiscordSlashCommandName(string name) { 332 foreach(ch; name) { 333 if(ch != '_' && !(ch >= 'a' && ch <= 'z')) 334 throw new InvalidArgumentsException("name", "discord names must be all lower-case with only letters and underscores", LimitedVariant(name)); 335 } 336 } 337 338 static HandlerInfo makeHandler(alias handler, T)(T slashThis) { 339 HandlerInfo info; 340 341 // must be all lower case! 342 info.name = __traits(identifier, handler); 343 344 validateDiscordSlashCommandName(info.name); 345 346 var cmd = var.emptyObject(); 347 cmd.name = info.name; 348 version(D_OpenD) 349 cmd.description = __traits(docComment, handler); 350 else 351 cmd.description = ""; 352 353 if(cmd.description == "") 354 cmd.description = "Can't be blank for CHAT_INPUT"; 355 356 cmd.type = 1; // CHAT_INPUT 357 358 var optionsArray = var.emptyArray; 359 360 static if(is(typeof(handler) Params == __parameters)) {} 361 362 string[] names; 363 364 // extract parameters 365 foreach(idx, param; Params) { 366 var option = var.emptyObject; 367 auto name = __traits(identifier, Params[idx .. idx + 1]); 368 validateDiscordSlashCommandName(name); 369 names ~= name; 370 option.name = name; 371 option.description = "desc"; 372 option.type = cast(int) applicationComandOptionTypeFromDType!(param); 373 // can also add "choices" which limit it to just specific members 374 if(option.type) { 375 optionsArray ~= option; 376 } 377 } 378 379 cmd.options = optionsArray; 380 381 info.jsonObjectForDiscord = cmd; 382 info.handler = (CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api) { 383 // extract args 384 // call the function 385 // send the appropriate reply 386 static if(is(typeof(handler) Return == return)) { 387 static if(is(Return == void)) { 388 __traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof); 389 sendHandlerReply("OK", replyHelper, true); 390 } else { 391 sendHandlerReply(__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof), replyHelper, false); 392 } 393 } else static assert(0); 394 }; 395 396 return info; 397 } 398 399 static auto fargsFromJson(Params...)(DiscordRestApi api, string[] names, var obj, CommandArgs args/*, Params defaults*/) { 400 static struct Holder { 401 // FIXME: default params no work 402 Params params;// = defaults; 403 } 404 405 Holder holder; 406 foreach(idx, ref param; holder.params) { 407 setParamFromJson(param, names[idx], api, obj, args); 408 } 409 410 return holder; 411 412 /+ 413 414 ync def something(interaction:discord.Interaction): 415 await interaction.response.send_message("NOTHING",ephemeral=True) 416 # optional (if you want to edit the response later,delete it, or send a followup) 417 await interaction.edit_original_response(content="Something") 418 await interaction.followup.send("This is a message too.",ephemeral=True) 419 await interaction.delete_original_response() 420 # if you have deleted the original response you can't edit it or send a followup after it 421 +/ 422 423 } 424 425 426 // {"t":"INTERACTION_CREATE","s":7,"op":0,"d":{"version":1,"type":2,"token":"aW50ZXJhY3Rpb246MTIzMzIyNzE0OTU0NTE3NzE2OTp1Sjg5RE0wMzJiWER2UDRURk5XSWRaUTJtMExBeklWNEtpVEZocTQ4a0VZQ3NWUm9ta3g2SG1JbTBzUm1yWmlUNzQ3eWxpc0FnM0RzUzZHaWtENnRXUDBsdUhERElKSWlaYlFWMlNsZlZXTlFkU3VVQUVWU01PNU9TNFQ5cmFQSw", 427 428 // "member":{"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null}, 429 430 // "locale":"en-US","id":"1233227149545177169","guild_locale":"en-US","guild_id":"1011977515109187704", 431 // "guild":{"locale":"en-US","id":"1011977515109187704","features":[]}, 432 // "entitlements":[],"entitlement_sku_ids":[], 433 // "data":{"type":1,"name":"hello","id":"1233221536522174535"},"channel_id":"1011977515109187707", 434 // "channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1233227103844171806","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0}, 435 // "application_id":"1223724819821105283","app_permissions":"1122573558992465"}} 436 437 438 template applicationComandOptionTypeFromDType(T) { 439 static if(is(T == SendingUser) || is(T == SendingChannel)) 440 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INVALID; // telling it to skip sending this to discord, it purely internal 441 else static if(is(T == DiscordRole)) 442 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.ROLE; 443 else static if(is(T == string)) 444 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.STRING; 445 else static if(is(T == bool)) 446 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.BOOLEAN; 447 else static if(is(T : const long)) 448 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INTEGER; 449 else static if(is(T : const double)) 450 enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.NUMBER; 451 else 452 static assert(0, T.stringof); 453 } 454 455 static var getOptionForName(var obj, string name) { 456 foreach(option; obj.options) 457 if(option.name == name) 458 return option; 459 return var.init; 460 } 461 462 static void setParamFromJson(T)(ref T param, string name, DiscordRestApi api, var obj, CommandArgs args) { 463 static if(is(T == SendingUser)) { 464 param = new SendingUser(api, args.member.user.id.get!string, obj.member.user); 465 } else static if(is(T == SendingChannel)) { 466 param = new SendingChannel(api, args.channel.id.get!string, obj.channel); 467 } else static if(is(T == string)) { 468 var option = getOptionForName(obj, name); 469 if(option.type == cast(int) ApplicationCommandOptionType.STRING) 470 param = option.value.get!(typeof(param)); 471 } else static if(is(T == bool)) { 472 var option = getOptionForName(obj, name); 473 if(option.type == cast(int) ApplicationCommandOptionType.BOOLEAN) 474 param = option.value.get!(typeof(param)); 475 } else static if(is(T : const long)) { 476 var option = getOptionForName(obj, name); 477 if(option.type == cast(int) ApplicationCommandOptionType.INTEGER) 478 param = option.value.get!(typeof(param)); 479 } else static if(is(T : const double)) { 480 var option = getOptionForName(obj, name); 481 if(option.type == cast(int) ApplicationCommandOptionType.NUMBER) 482 param = option.value.get!(typeof(param)); 483 } else static if(is(T == DiscordRole)) { 484 485 //"data":{"type":1,"resolved":{"roles":{"1223727548295544865":{"unicode_emoji":null,"tags":{"bot_id":"1223724819821105283"},"position":1,"permissions":"3088","name":"OpenD","mentionable":false,"managed":true,"id":"1223727548295544865","icon":null,"hoist":false,"flags":0,"description":null,"color":0}}},"options":[{"value":"1223727548295544865","type":8,"name":"role"}],"name":"add_role","id":"1234130839315677226"},"channel_id":"1011977515109187707","channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1234249771745804399","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0},"application_id":"1223724819821105283","app_permissions":"1122573558992465"}} 486 487 // resolved gives you some precache info 488 489 var option = getOptionForName(obj, name); 490 if(option.type == cast(int) ApplicationCommandOptionType.ROLE) 491 param = new DiscordRole(api, new DiscordGuild(api, args.guildId), option.value.get!string); 492 else 493 param = null; 494 } else { 495 static assert(0, "Bad type " ~ T.stringof); 496 } 497 } 498 499 static void sendHandlerReply(T)(T ret, scope InteractionReplyHelper replyHelper, bool ephemeral) { 500 replyHelper.reply(toStringInternal(ret), ephemeral); 501 } 502 503 void registerAll(T)(T t) { 504 assert(t !is null); 505 foreach(memberName; __traits(derivedMembers, T)) 506 static if(memberName != "__ctor") { // FIXME 507 HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t); 508 registerFromRuntimeInfo(hi); 509 } 510 } 511 512 void registerFromRuntimeInfo(HandlerInfo info) { 513 handlers[info.name] = info.handler; 514 if(jsonArrayForDiscord is var.init) 515 jsonArrayForDiscord = var.emptyArray; 516 jsonArrayForDiscord ~= info.jsonObjectForDiscord; 517 } 518 519 alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api); 520 struct HandlerInfo { 521 string name; 522 InternalHandler handler; 523 var jsonObjectForDiscord; 524 } 525 InternalHandler[string] handlers; 526 var jsonArrayForDiscord; 527 } 528 } 529 530 /++ 531 A SendingUser is a special DiscordUser type that just represents the person who sent the message. 532 533 It exists so you can use it in a function parameter list that is auto-mapped to a message handler. 534 +/ 535 class SendingUser : DiscordUser { 536 private this(DiscordRestApi api, string id, var initialCache) { 537 super(api, id); 538 } 539 } 540 541 class SendingChannel : DiscordChannel { 542 private this(DiscordRestApi api, string id, var initialCache) { 543 super(api, id); 544 } 545 } 546 547 // SendingChannel 548 // SendingMessage 549 550 /++ 551 Use as a UDA 552 553 A file of choices for the given option. The exact interpretation depends on the type but the general rule is one option per line, id or name. 554 555 FIXME: watch the file for changes for auto-reload and update on the discord side 556 557 FIXME: NOT IMPLEMENTED 558 +/ 559 struct ChoicesFromFile { 560 string filename; 561 } 562 563 /++ 564 Most the magic is inherited from [arsd.http2.HttpApiClient]. 565 +/ 566 class DiscordRestApi : HttpApiClient!() { 567 /++ 568 Creates an API client. 569 570 Params: 571 token = the bot authorization token you got from Discord 572 yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. 573 yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice. 574 +/ 575 this(string botToken, string yourBotUrl, string yourBotVersion) { 576 this.authType = "Bot"; 577 super("https://discord.com/api/v10/", botToken); 578 } 579 } 580 581 /++ 582 583 +/ 584 class DiscordGatewayConnection { 585 private WebSocket websocket_; 586 private long lastSequenceNumberReceived; 587 private string token; 588 private DiscordRestApi api_; 589 590 /++ 591 An instance to the REST api object associated with your connection. 592 +/ 593 public final DiscordRestApi api() { 594 return this.api_; 595 } 596 597 /++ 598 599 +/ 600 protected final WebSocket websocket() { 601 return websocket_; 602 } 603 604 // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes 605 enum OpCode { 606 Dispatch = 0, // recv 607 Heartbeat = 1, // s/r 608 Identify = 2, // s 609 PresenceUpdate = 3, // s 610 VoiceStateUpdate = 4, // s 611 Resume = 6, // s 612 Reconnect = 7, // r 613 RequestGuildMembers = 8, // s 614 InvalidSession = 9, // r - you should reconnect and identify/resume 615 Hello = 10, // r 616 HeartbeatAck = 11, // r 617 } 618 619 enum DisconnectCodes { 620 UnknownError = 4000, // t 621 UnknownOpcode = 4001, // t (user error) 622 DecodeError = 4002, // t (user error) 623 NotAuthenticated = 4003, // t (user error) 624 AuthenticationFailed = 4004, // f (user error) 625 AlreadyAuthenticated = 4005, // t (user error) 626 InvalidSeq = 4007, // t 627 RateLimited = 4008, // t 628 SessionTimedOut = 4009, // t 629 InvalidShard = 4010, // f (user error) 630 ShardingRequired = 4011, // f 631 InvalidApiVersion = 4012, // f 632 InvalidIntents = 4013, // f 633 DisallowedIntents = 4014, // f 634 } 635 636 private string cachedGatewayUrl; 637 638 /++ 639 Prepare a gateway connection. After you construct it, you still need to call [connect]. 640 641 Params: 642 token = the bot authorization token you got from Discord 643 yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. 644 yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice. 645 +/ 646 public this(string token, string yourBotUrl, string yourBotVersion) { 647 this.token = token; 648 this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion); 649 } 650 651 /++ 652 Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands. 653 +/ 654 final void slashCommandHandler(SlashCommandHandler t) { 655 if(slashCommandHandler_ !is null && t !is null) 656 throw ArsdException!"SlashCommandHandler is already set"(); 657 slashCommandHandler_ = t; 658 if(t && applicationId.length) 659 t.register(api, applicationId); 660 } 661 private SlashCommandHandler slashCommandHandler_; 662 663 /++ 664 665 +/ 666 protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) { 667 logger.info(i"$(closeEvent.toString())"); 668 669 if(heartbeatTimer) 670 heartbeatTimer.cancel(); 671 672 if(closeEvent.code == 1006 || closeEvent.code == 1001) { 673 reconnectAndResume(); 674 } else if(closeEvent.code == 4004) { 675 // auth failed 676 throw new Exception("Authentication failed"); 677 } else { 678 // otherwise, unless we were asked by the api user to close, let's try reconnecting 679 // since discord just does discord things. 680 websocket_ = null; 681 connect(); 682 } 683 } 684 685 /++ 686 +/ 687 void close() { 688 close(1000, null); 689 } 690 691 /// ditto 692 void close(int reason, string reasonText) { 693 if(heartbeatTimer) 694 heartbeatTimer.cancel(); 695 696 websocket_.onclose = null; 697 websocket_.ontextmessage = null; 698 websocket_.onbinarymessage = null; 699 websocket.close(reason, reasonText); 700 websocket_ = null; 701 } 702 703 /++ 704 +/ 705 protected void handleWebsocketMessage(in char[] msg) { 706 var m = var.fromJson(msg.idup); 707 708 OpCode op = cast(OpCode) m.op.get!int; 709 var data = m.d; 710 711 switch(op) { 712 case OpCode.Dispatch: 713 // these are null if op != 0 714 string eventName = m.t.get!string; 715 long seqNumber = m.s.get!long; 716 717 if(seqNumber > lastSequenceNumberReceived) 718 lastSequenceNumberReceived = seqNumber; 719 720 eventReceived(eventName, data); 721 break; 722 case OpCode.Hello: 723 // the hello heartbeat_interval is in milliseconds 724 if(slashCommandHandler_ !is null && applicationId.length) 725 slashCommandHandler_.register(api, applicationId); 726 727 setHeartbeatInterval(data.heartbeat_interval.get!int); 728 break; 729 case OpCode.Heartbeat: 730 sendHeartbeat(); 731 break; 732 case OpCode.HeartbeatAck: 733 mostRecentHeartbeatAckRecivedAt = MonoTime.currTime; 734 break; 735 case OpCode.Reconnect: 736 logger.info(i"Reconnecting discord websocket"); 737 738 this.close(4999, "Reconnect requested"); 739 reconnectAndResume(); 740 break; 741 case OpCode.InvalidSession: 742 logger.info(i"Starting new discord session"); 743 744 close(); 745 connect(); // try starting a brand new session 746 break; 747 default: 748 // ignored 749 } 750 } 751 752 protected void reconnectAndResume() { 753 websocketConnectInLoop(Uri(this.resume_gateway_url)); 754 755 var resumeData = var.emptyObject; 756 resumeData.token = this.token; 757 resumeData.session_id = this.session_id; 758 resumeData.seq = lastSequenceNumberReceived; 759 760 sendWebsocketCommand(OpCode.Resume, resumeData); 761 762 // the close event will cancel the heartbeat and thus we need to restart it 763 if(requestedHeartbeat) 764 setHeartbeatInterval(requestedHeartbeat); 765 } 766 767 /++ 768 +/ 769 protected void eventReceived(string eventName, var data) { 770 // FIXME: any time i get an event i could prolly spin it off into an independent async task 771 switch(eventName) { 772 case "INTERACTION_CREATE": 773 var member = data.member; // {"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null} 774 775 SlashCommandHandler.CommandArgs commandArgs; 776 777 commandArgs.interactionType = cast(InteractionType) data.type.get!int; 778 commandArgs.interactionToken = data.token.get!string; 779 commandArgs.interactionId = data.id.get!string; 780 commandArgs.guildId = data.guild_id.get!string; 781 commandArgs.channelId = data.channel_id.get!string; 782 commandArgs.member = member; 783 commandArgs.channel = data.channel; 784 785 commandArgs.interactionData = data.data; 786 // data.data : type/name/id. can use this to determine what function to call. prolly will include other info too 787 // "data":{"type":1,"name":"hello","id":"1233221536522174535"} 788 789 // application_id and app_permissions and some others there too but that doesn't seem important 790 791 /+ 792 replies: 793 https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type 794 +/ 795 796 scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs); 797 798 Exception throwExternally; 799 800 try { 801 if(slashCommandHandler_ is null) 802 throwExternally = ArsdException!"No slash commands registered"(); 803 else { 804 auto cmdName = commandArgs.interactionData.name.get!string; 805 if(auto pHandler = cmdName in slashCommandHandler_.handlers) { 806 (*pHandler)(commandArgs, replyHelper, api); 807 } else { 808 throwExternally = ArsdException!"Unregistered slash command"(cmdName); 809 } 810 } 811 } catch(ArsdExceptionBase e) { 812 const(char)[] msg = e.message; 813 if(msg.length == 0) 814 msg = "I am error."; 815 816 e.getAdditionalPrintableInformation((string name, in char[] value) { 817 msg ~= ("\n"); 818 msg ~= (name); 819 msg ~= (": "); 820 msg ~= (value); 821 }); 822 823 replyHelper.replyWithError(msg); 824 } catch(Exception e) { 825 replyHelper.replyWithError(e.message); 826 } 827 828 if(throwExternally !is null) 829 throw throwExternally; 830 break; 831 case "READY": 832 this.session_id = data.session_id.get!string; 833 this.resume_gateway_url = data.resume_gateway_url.get!string; 834 this.applicationId_ = data.application.id.get!string; 835 836 if(slashCommandHandler_ !is null && applicationId.length) 837 slashCommandHandler_.register(api, applicationId); 838 break; 839 840 default: 841 } 842 } 843 844 private string session_id; 845 private string resume_gateway_url; 846 private string applicationId_; 847 848 /++ 849 Returns your application id. Only available after the connection is established. 850 +/ 851 public string applicationId() { 852 return applicationId_; 853 } 854 855 private arsd.core.Timer heartbeatTimer; 856 private int requestedHeartbeat; 857 private bool requestedHeartbeatSet; 858 //private int heartbeatsSent; 859 //private int heartbeatAcksReceived; 860 private MonoTime mostRecentHeartbeatAckRecivedAt; 861 862 protected void sendHeartbeat() { 863 logger.info(i"heartbeat"); 864 sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived)); 865 } 866 867 private final void sendHeartbeatThunk() { 868 this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat 869 if(requestedHeartbeatSet == false) { 870 heartbeatTimer.changeTime(requestedHeartbeat, true); 871 requestedHeartbeatSet = true; 872 } else { 873 if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) { 874 // throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info? 875 websocket.close(1006, "heartbeat unanswered"); 876 reconnectAndResume(); 877 } 878 } 879 } 880 881 /++ 882 +/ 883 protected void setHeartbeatInterval(int msecs) { 884 requestedHeartbeat = msecs; 885 requestedHeartbeatSet = false; 886 887 if(heartbeatTimer is null) { 888 heartbeatTimer = new arsd.core.Timer; 889 heartbeatTimer.setPulseCallback(&sendHeartbeatThunk); 890 } 891 892 // the first one is supposed to have random jitter 893 // so we'll do that one-off (but with a non-zero time 894 // since my timers don't like being run twice in one loop 895 // iteration) then that first one will set the repeating time 896 import arsd.random; 897 auto firstBeat = arsd.random.uniform(10, msecs); 898 heartbeatTimer.changeTime(firstBeat, false); 899 } 900 901 /++ 902 903 +/ 904 void sendWebsocketCommand(OpCode op, var d) { 905 assert(websocket !is null, "call connect before sending commands"); 906 907 var cmd = var.emptyObject; 908 cmd.d = d; 909 cmd.op = cast(int) op; 910 websocket.send(cmd.toJson()); 911 } 912 913 /++ 914 915 +/ 916 void connect() { 917 assert(websocket is null, "do not call connect twice"); 918 919 if(cachedGatewayUrl is null) { 920 auto obj = api.rest.gateway.bot.GET().result; 921 cachedGatewayUrl = obj.url.get!string; 922 } 923 924 websocketConnectInLoop(Uri(cachedGatewayUrl)); 925 926 var d = var.emptyObject; 927 d.token = token; 928 // FIXME? 929 d.properties = [ 930 "os": "linux", 931 "browser": "arsd.discord", 932 "device": "arsd.discord", 933 ]; 934 935 sendWebsocketCommand(OpCode.Identify, d); 936 } 937 938 /+ 939 SocketOSException needs full reconnect 940 +/ 941 942 void websocketConnectInLoop(Uri uri) { 943 // FIXME: if the connect fails we should set a timer and try 944 // again, but if it fails then, quit. at least if it is not a websocket reply 945 // cuz it could be discord went down or something. 946 947 import core.time; 948 auto d = 1.seconds; 949 int count = 0; 950 951 try_again: 952 953 this.websocket_ = new WebSocket(uri); 954 websocket.onmessage = &handleWebsocketMessage; 955 websocket.onclose = &handleWebsocketClose; 956 957 try { 958 this.websocket_.connect(); 959 } catch(Exception e) { 960 // it disconnects after 30 days rn w/ 961 // std.socket.SocketOSException@std/socket.d(2897): Unable to connect socket: Transport endpoint is already connected 962 // and idk the root cause, it has invalid session at first 963 964 .destroy(this.websocket_); 965 966 logSwallowedException(e); 967 968 import core.thread; 969 Thread.sleep(d); 970 d *= 2; 971 count++; 972 if(count == 10) 973 throw e; 974 975 goto try_again; 976 } 977 } 978 979 980 } 981 982 class DiscordRpcConnection { 983 984 // this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config); 985 // websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`); 986 // writeln(websocket.waitForNextMessage.textData); 987 988 // these would tell me user names and ids when people join/leave but it needs authentication alas 989 990 /+ 991 websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 992 "guild_id": "SSSSSSSSSSSSSSSS", 993 "channel_id": "CCCCCCCCCCCCCCCCC" 994 }, 995 "evt": "VOICE_STATE_CREATE", 996 "cmd": "SUBSCRIBE" 997 }`); 998 writeln(websocket.waitForNextMessage.textData); 999 1000 websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 1001 "guild_id": "SSSSSSSSSSSSSSSS", 1002 "channel_id": "CCCCCCCCCCCCCCCCC" 1003 }, 1004 "evt": "VOICE_STATE_DELETE", 1005 "cmd": "SUBSCRIBE" 1006 }`); 1007 1008 websocket.onmessage = delegate(in char[] msg) { 1009 writeln(msg); 1010 1011 import arsd.jsvar; 1012 var m = var.fromJson(msg.idup); 1013 if(m.cmd == "DISPATCH") { 1014 if(m.evt == "SPEAKING_START") { 1015 //setSpeaking(m.data.user_id.get!ulong, true); 1016 } else if(m.evt == "SPEAKING_STOP") { 1017 //setSpeaking(m.data.user_id.get!ulong, false); 1018 } 1019 } 1020 }; 1021 1022 1023 +/ 1024 1025 1026 }