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 { 675 // otherwise, unless we were asked by the api user to close, let's try reconnecting 676 // since discord just does discord things. 677 websocket_ = null; 678 connect(); 679 } 680 } 681 682 /++ 683 +/ 684 void close() { 685 close(1000, null); 686 } 687 688 /// ditto 689 void close(int reason, string reasonText) { 690 if(heartbeatTimer) 691 heartbeatTimer.cancel(); 692 693 websocket_.onclose = null; 694 websocket_.ontextmessage = null; 695 websocket_.onbinarymessage = null; 696 websocket.close(reason, reasonText); 697 websocket_ = null; 698 } 699 700 /++ 701 +/ 702 protected void handleWebsocketMessage(in char[] msg) { 703 var m = var.fromJson(msg.idup); 704 705 OpCode op = cast(OpCode) m.op.get!int; 706 var data = m.d; 707 708 switch(op) { 709 case OpCode.Dispatch: 710 // these are null if op != 0 711 string eventName = m.t.get!string; 712 long seqNumber = m.s.get!long; 713 714 if(seqNumber > lastSequenceNumberReceived) 715 lastSequenceNumberReceived = seqNumber; 716 717 eventReceived(eventName, data); 718 break; 719 case OpCode.Hello: 720 // the hello heartbeat_interval is in milliseconds 721 if(slashCommandHandler_ !is null && applicationId.length) 722 slashCommandHandler_.register(api, applicationId); 723 724 setHeartbeatInterval(data.heartbeat_interval.get!int); 725 break; 726 case OpCode.Heartbeat: 727 sendHeartbeat(); 728 break; 729 case OpCode.HeartbeatAck: 730 mostRecentHeartbeatAckRecivedAt = MonoTime.currTime; 731 break; 732 case OpCode.Reconnect: 733 logger.info(i"Reconnecting discord websocket"); 734 735 this.close(4999, "Reconnect requested"); 736 reconnectAndResume(); 737 break; 738 case OpCode.InvalidSession: 739 logger.info(i"Starting new discord session"); 740 741 close(); 742 connect(); // try starting a brand new session 743 break; 744 default: 745 // ignored 746 } 747 } 748 749 protected void reconnectAndResume() { 750 websocketConnectInLoop(Uri(this.resume_gateway_url)); 751 752 var resumeData = var.emptyObject; 753 resumeData.token = this.token; 754 resumeData.session_id = this.session_id; 755 resumeData.seq = lastSequenceNumberReceived; 756 757 sendWebsocketCommand(OpCode.Resume, resumeData); 758 759 // the close event will cancel the heartbeat and thus we need to restart it 760 if(requestedHeartbeat) 761 setHeartbeatInterval(requestedHeartbeat); 762 } 763 764 /++ 765 +/ 766 protected void eventReceived(string eventName, var data) { 767 // FIXME: any time i get an event i could prolly spin it off into an independent async task 768 switch(eventName) { 769 case "INTERACTION_CREATE": 770 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} 771 772 SlashCommandHandler.CommandArgs commandArgs; 773 774 commandArgs.interactionType = cast(InteractionType) data.type.get!int; 775 commandArgs.interactionToken = data.token.get!string; 776 commandArgs.interactionId = data.id.get!string; 777 commandArgs.guildId = data.guild_id.get!string; 778 commandArgs.channelId = data.channel_id.get!string; 779 commandArgs.member = member; 780 commandArgs.channel = data.channel; 781 782 commandArgs.interactionData = data.data; 783 // data.data : type/name/id. can use this to determine what function to call. prolly will include other info too 784 // "data":{"type":1,"name":"hello","id":"1233221536522174535"} 785 786 // application_id and app_permissions and some others there too but that doesn't seem important 787 788 /+ 789 replies: 790 https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type 791 +/ 792 793 scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs); 794 795 Exception throwExternally; 796 797 try { 798 if(slashCommandHandler_ is null) 799 throwExternally = ArsdException!"No slash commands registered"(); 800 else { 801 auto cmdName = commandArgs.interactionData.name.get!string; 802 if(auto pHandler = cmdName in slashCommandHandler_.handlers) { 803 (*pHandler)(commandArgs, replyHelper, api); 804 } else { 805 throwExternally = ArsdException!"Unregistered slash command"(cmdName); 806 } 807 } 808 } catch(ArsdExceptionBase e) { 809 const(char)[] msg = e.message; 810 if(msg.length == 0) 811 msg = "I am error."; 812 813 e.getAdditionalPrintableInformation((string name, in char[] value) { 814 msg ~= ("\n"); 815 msg ~= (name); 816 msg ~= (": "); 817 msg ~= (value); 818 }); 819 820 replyHelper.replyWithError(msg); 821 } catch(Exception e) { 822 replyHelper.replyWithError(e.message); 823 } 824 825 if(throwExternally !is null) 826 throw throwExternally; 827 break; 828 case "READY": 829 this.session_id = data.session_id.get!string; 830 this.resume_gateway_url = data.resume_gateway_url.get!string; 831 this.applicationId_ = data.application.id.get!string; 832 833 if(slashCommandHandler_ !is null && applicationId.length) 834 slashCommandHandler_.register(api, applicationId); 835 break; 836 837 default: 838 } 839 } 840 841 private string session_id; 842 private string resume_gateway_url; 843 private string applicationId_; 844 845 /++ 846 Returns your application id. Only available after the connection is established. 847 +/ 848 public string applicationId() { 849 return applicationId_; 850 } 851 852 private arsd.core.Timer heartbeatTimer; 853 private int requestedHeartbeat; 854 private bool requestedHeartbeatSet; 855 //private int heartbeatsSent; 856 //private int heartbeatAcksReceived; 857 private MonoTime mostRecentHeartbeatAckRecivedAt; 858 859 protected void sendHeartbeat() { 860 logger.info(i"heartbeat"); 861 sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived)); 862 } 863 864 private final void sendHeartbeatThunk() { 865 this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat 866 if(requestedHeartbeatSet == false) { 867 heartbeatTimer.changeTime(requestedHeartbeat, true); 868 requestedHeartbeatSet = true; 869 } else { 870 if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) { 871 // throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info? 872 websocket.close(1006, "heartbeat unanswered"); 873 reconnectAndResume(); 874 } 875 } 876 } 877 878 /++ 879 +/ 880 protected void setHeartbeatInterval(int msecs) { 881 requestedHeartbeat = msecs; 882 requestedHeartbeatSet = false; 883 884 if(heartbeatTimer is null) { 885 heartbeatTimer = new arsd.core.Timer; 886 heartbeatTimer.setPulseCallback(&sendHeartbeatThunk); 887 } 888 889 // the first one is supposed to have random jitter 890 // so we'll do that one-off (but with a non-zero time 891 // since my timers don't like being run twice in one loop 892 // iteration) then that first one will set the repeating time 893 import arsd.random; 894 auto firstBeat = arsd.random.uniform(10, msecs); 895 heartbeatTimer.changeTime(firstBeat, false); 896 } 897 898 /++ 899 900 +/ 901 void sendWebsocketCommand(OpCode op, var d) { 902 assert(websocket !is null, "call connect before sending commands"); 903 904 var cmd = var.emptyObject; 905 cmd.d = d; 906 cmd.op = cast(int) op; 907 websocket.send(cmd.toJson()); 908 } 909 910 /++ 911 912 +/ 913 void connect() { 914 assert(websocket is null, "do not call connect twice"); 915 916 if(cachedGatewayUrl is null) { 917 auto obj = api.rest.gateway.bot.GET().result; 918 cachedGatewayUrl = obj.url.get!string; 919 } 920 921 websocketConnectInLoop(Uri(cachedGatewayUrl)); 922 923 var d = var.emptyObject; 924 d.token = token; 925 // FIXME? 926 d.properties = [ 927 "os": "linux", 928 "browser": "arsd.discord", 929 "device": "arsd.discord", 930 ]; 931 932 sendWebsocketCommand(OpCode.Identify, d); 933 } 934 935 /+ 936 SocketOSException needs full reconnect 937 +/ 938 939 void websocketConnectInLoop(Uri uri) { 940 // FIXME: if the connect fails we should set a timer and try 941 // again, but if it fails then, quit. at least if it is not a websocket reply 942 // cuz it could be discord went down or something. 943 944 import core.time; 945 auto d = 1.seconds; 946 int count = 0; 947 948 try_again: 949 950 this.websocket_ = new WebSocket(uri); 951 websocket.onmessage = &handleWebsocketMessage; 952 websocket.onclose = &handleWebsocketClose; 953 954 try { 955 this.websocket_.connect(); 956 } catch(Exception e) { 957 // it disconnects after 30 days rn w/ 958 // std.socket.SocketOSException@std/socket.d(2897): Unable to connect socket: Transport endpoint is already connected 959 // and idk the root cause, it has invalid session at first 960 961 .destroy(this.websocket_); 962 963 logSwallowedException(e); 964 965 import core.thread; 966 Thread.sleep(d); 967 d *= 2; 968 count++; 969 if(count == 10) 970 throw e; 971 972 goto try_again; 973 } 974 } 975 976 977 } 978 979 class DiscordRpcConnection { 980 981 // this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config); 982 // websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`); 983 // writeln(websocket.waitForNextMessage.textData); 984 985 // these would tell me user names and ids when people join/leave but it needs authentication alas 986 987 /+ 988 websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 989 "guild_id": "SSSSSSSSSSSSSSSS", 990 "channel_id": "CCCCCCCCCCCCCCCCC" 991 }, 992 "evt": "VOICE_STATE_CREATE", 993 "cmd": "SUBSCRIBE" 994 }`); 995 writeln(websocket.waitForNextMessage.textData); 996 997 websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 998 "guild_id": "SSSSSSSSSSSSSSSS", 999 "channel_id": "CCCCCCCCCCCCCCCCC" 1000 }, 1001 "evt": "VOICE_STATE_DELETE", 1002 "cmd": "SUBSCRIBE" 1003 }`); 1004 1005 websocket.onmessage = delegate(in char[] msg) { 1006 writeln(msg); 1007 1008 import arsd.jsvar; 1009 var m = var.fromJson(msg.idup); 1010 if(m.cmd == "DISPATCH") { 1011 if(m.evt == "SPEAKING_START") { 1012 //setSpeaking(m.data.user_id.get!ulong, true); 1013 } else if(m.evt == "SPEAKING_STOP") { 1014 //setSpeaking(m.data.user_id.get!ulong, false); 1015 } 1016 } 1017 }; 1018 1019 1020 +/ 1021 1022 1023 }