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 writeln(e.toString()); 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 import std.conv; // FIXME 501 replyHelper.reply(to!string(ret), ephemeral); 502 } 503 504 void registerAll(T)(T t) { 505 assert(t !is null); 506 foreach(memberName; __traits(derivedMembers, T)) 507 static if(memberName != "__ctor") { // FIXME 508 HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t); 509 registerFromRuntimeInfo(hi); 510 } 511 } 512 513 void registerFromRuntimeInfo(HandlerInfo info) { 514 handlers[info.name] = info.handler; 515 if(jsonArrayForDiscord is var.init) 516 jsonArrayForDiscord = var.emptyArray; 517 jsonArrayForDiscord ~= info.jsonObjectForDiscord; 518 } 519 520 alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api); 521 struct HandlerInfo { 522 string name; 523 InternalHandler handler; 524 var jsonObjectForDiscord; 525 } 526 InternalHandler[string] handlers; 527 var jsonArrayForDiscord; 528 } 529 } 530 531 /++ 532 A SendingUser is a special DiscordUser type that just represents the person who sent the message. 533 534 It exists so you can use it in a function parameter list that is auto-mapped to a message handler. 535 +/ 536 class SendingUser : DiscordUser { 537 private this(DiscordRestApi api, string id, var initialCache) { 538 super(api, id); 539 } 540 } 541 542 class SendingChannel : DiscordChannel { 543 private this(DiscordRestApi api, string id, var initialCache) { 544 super(api, id); 545 } 546 } 547 548 // SendingChannel 549 // SendingMessage 550 551 /++ 552 Use as a UDA 553 554 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. 555 556 FIXME: watch the file for changes for auto-reload and update on the discord side 557 558 FIXME: NOT IMPLEMENTED 559 +/ 560 struct ChoicesFromFile { 561 string filename; 562 } 563 564 /++ 565 Most the magic is inherited from [arsd.http2.HttpApiClient]. 566 +/ 567 class DiscordRestApi : HttpApiClient!() { 568 /++ 569 Creates an API client. 570 571 Params: 572 token = the bot authorization token you got from Discord 573 yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. 574 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. 575 +/ 576 this(string botToken, string yourBotUrl, string yourBotVersion) { 577 this.authType = "Bot"; 578 super("https://discord.com/api/v10/", botToken); 579 } 580 } 581 582 /++ 583 584 +/ 585 class DiscordGatewayConnection { 586 private WebSocket websocket_; 587 private long lastSequenceNumberReceived; 588 private string token; 589 private DiscordRestApi api_; 590 591 /++ 592 An instance to the REST api object associated with your connection. 593 +/ 594 public final DiscordRestApi api() { 595 return this.api_; 596 } 597 598 /++ 599 600 +/ 601 protected final WebSocket websocket() { 602 return websocket_; 603 } 604 605 // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes 606 enum OpCode { 607 Dispatch = 0, // recv 608 Heartbeat = 1, // s/r 609 Identify = 2, // s 610 PresenceUpdate = 3, // s 611 VoiceStateUpdate = 4, // s 612 Resume = 6, // s 613 Reconnect = 7, // r 614 RequestGuildMembers = 8, // s 615 InvalidSession = 9, // r - you should reconnect and identify/resume 616 Hello = 10, // r 617 HeartbeatAck = 11, // r 618 } 619 620 enum DisconnectCodes { 621 UnknownError = 4000, // t 622 UnknownOpcode = 4001, // t (user error) 623 DecodeError = 4002, // t (user error) 624 NotAuthenticated = 4003, // t (user error) 625 AuthenticationFailed = 4004, // f (user error) 626 AlreadyAuthenticated = 4005, // t (user error) 627 InvalidSeq = 4007, // t 628 RateLimited = 4008, // t 629 SessionTimedOut = 4009, // t 630 InvalidShard = 4010, // f (user error) 631 ShardingRequired = 4011, // f 632 InvalidApiVersion = 4012, // f 633 InvalidIntents = 4013, // f 634 DisallowedIntents = 4014, // f 635 } 636 637 private string cachedGatewayUrl; 638 639 /++ 640 Prepare a gateway connection. After you construct it, you still need to call [connect]. 641 642 Params: 643 token = the bot authorization token you got from Discord 644 yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. 645 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. 646 +/ 647 public this(string token, string yourBotUrl, string yourBotVersion) { 648 this.token = token; 649 this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion); 650 } 651 652 /++ 653 Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands. 654 +/ 655 final void slashCommandHandler(SlashCommandHandler t) { 656 if(slashCommandHandler_ !is null && t !is null) 657 throw ArsdException!"SlashCommandHandler is already set"(); 658 slashCommandHandler_ = t; 659 if(t && applicationId.length) 660 t.register(api, applicationId); 661 } 662 private SlashCommandHandler slashCommandHandler_; 663 664 /++ 665 666 +/ 667 protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) { 668 import std.stdio; writeln(closeEvent); 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 writeln("reconnecting"); 734 this.close(4999, "Reconnect requested"); 735 reconnectAndResume(); 736 break; 737 case OpCode.InvalidSession: 738 writeln("starting new session"); 739 740 close(); 741 connect(); // try starting a brand new session 742 break; 743 default: 744 // ignored 745 } 746 } 747 748 protected void reconnectAndResume() { 749 this.websocket_ = new WebSocket(Uri(this.resume_gateway_url)); 750 751 websocket.onmessage = &handleWebsocketMessage; 752 websocket.onclose = &handleWebsocketClose; 753 754 websocketConnectInLoop(); 755 756 var resumeData = var.emptyObject; 757 resumeData.token = this.token; 758 resumeData.session_id = this.session_id; 759 resumeData.seq = lastSequenceNumberReceived; 760 761 sendWebsocketCommand(OpCode.Resume, resumeData); 762 763 // the close event will cancel the heartbeat and thus we need to restart it 764 if(requestedHeartbeat) 765 setHeartbeatInterval(requestedHeartbeat); 766 } 767 768 /++ 769 +/ 770 protected void eventReceived(string eventName, var data) { 771 // FIXME: any time i get an event i could prolly spin it off into an independent async task 772 switch(eventName) { 773 case "INTERACTION_CREATE": 774 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} 775 776 SlashCommandHandler.CommandArgs commandArgs; 777 778 commandArgs.interactionType = cast(InteractionType) data.type.get!int; 779 commandArgs.interactionToken = data.token.get!string; 780 commandArgs.interactionId = data.id.get!string; 781 commandArgs.guildId = data.guild_id.get!string; 782 commandArgs.channelId = data.channel_id.get!string; 783 commandArgs.member = member; 784 commandArgs.channel = data.channel; 785 786 commandArgs.interactionData = data.data; 787 // data.data : type/name/id. can use this to determine what function to call. prolly will include other info too 788 // "data":{"type":1,"name":"hello","id":"1233221536522174535"} 789 790 // application_id and app_permissions and some others there too but that doesn't seem important 791 792 /+ 793 replies: 794 https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type 795 +/ 796 797 scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs); 798 799 Exception throwExternally; 800 801 try { 802 if(slashCommandHandler_ is null) 803 throwExternally = ArsdException!"No slash commands registered"(); 804 else { 805 auto cmdName = commandArgs.interactionData.name.get!string; 806 if(auto pHandler = cmdName in slashCommandHandler_.handlers) { 807 (*pHandler)(commandArgs, replyHelper, api); 808 } else { 809 throwExternally = ArsdException!"Unregistered slash command"(cmdName); 810 } 811 } 812 } catch(ArsdExceptionBase e) { 813 const(char)[] msg = e.message; 814 if(msg.length == 0) 815 msg = "I am error."; 816 817 e.getAdditionalPrintableInformation((string name, in char[] value) { 818 msg ~= ("\n"); 819 msg ~= (name); 820 msg ~= (": "); 821 msg ~= (value); 822 }); 823 824 replyHelper.replyWithError(msg); 825 } catch(Exception e) { 826 replyHelper.replyWithError(e.message); 827 } 828 829 if(throwExternally !is null) 830 throw throwExternally; 831 break; 832 case "READY": 833 this.session_id = data.session_id.get!string; 834 this.resume_gateway_url = data.resume_gateway_url.get!string; 835 this.applicationId_ = data.application.id.get!string; 836 837 if(slashCommandHandler_ !is null && applicationId.length) 838 slashCommandHandler_.register(api, applicationId); 839 break; 840 841 default: 842 } 843 } 844 845 private string session_id; 846 private string resume_gateway_url; 847 private string applicationId_; 848 849 /++ 850 Returns your application id. Only available after the connection is established. 851 +/ 852 public string applicationId() { 853 return applicationId_; 854 } 855 856 private arsd.core.Timer heartbeatTimer; 857 private int requestedHeartbeat; 858 private bool requestedHeartbeatSet; 859 //private int heartbeatsSent; 860 //private int heartbeatAcksReceived; 861 private MonoTime mostRecentHeartbeatAckRecivedAt; 862 863 protected void sendHeartbeat() { 864 arsd.core.writeln("sendHeartbeat"); 865 sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived)); 866 } 867 868 private final void sendHeartbeatThunk() { 869 this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat 870 if(requestedHeartbeatSet == false) { 871 heartbeatTimer.changeTime(requestedHeartbeat, true); 872 requestedHeartbeatSet = true; 873 } else { 874 if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) { 875 // throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info? 876 websocket.close(1006, "heartbeat unanswered"); 877 reconnectAndResume(); 878 } 879 } 880 } 881 882 /++ 883 +/ 884 protected void setHeartbeatInterval(int msecs) { 885 requestedHeartbeat = msecs; 886 requestedHeartbeatSet = false; 887 888 if(heartbeatTimer is null) { 889 heartbeatTimer = new arsd.core.Timer; 890 heartbeatTimer.setPulseCallback(&sendHeartbeatThunk); 891 } 892 893 // the first one is supposed to have random jitter 894 // so we'll do that one-off (but with a non-zero time 895 // since my timers don't like being run twice in one loop 896 // iteration) then that first one will set the repeating time 897 import std.random; 898 auto firstBeat = std.random.uniform(10, msecs); 899 heartbeatTimer.changeTime(firstBeat, false); 900 } 901 902 /++ 903 904 +/ 905 void sendWebsocketCommand(OpCode op, var d) { 906 assert(websocket !is null, "call connect before sending commands"); 907 908 var cmd = var.emptyObject; 909 cmd.d = d; 910 cmd.op = cast(int) op; 911 websocket.send(cmd.toJson()); 912 } 913 914 /++ 915 916 +/ 917 void connect() { 918 assert(websocket is null, "do not call connect twice"); 919 920 if(cachedGatewayUrl is null) { 921 auto obj = api.rest.gateway.bot.GET().result; 922 cachedGatewayUrl = obj.url.get!string; 923 } 924 925 this.websocket_ = new WebSocket(Uri(cachedGatewayUrl)); 926 927 websocket.onmessage = &handleWebsocketMessage; 928 websocket.onclose = &handleWebsocketClose; 929 930 websocketConnectInLoop(); 931 932 var d = var.emptyObject; 933 d.token = token; 934 // FIXME? 935 d.properties = [ 936 "os": "linux", 937 "browser": "arsd.discord", 938 "device": "arsd.discord", 939 ]; 940 941 sendWebsocketCommand(OpCode.Identify, d); 942 } 943 944 void websocketConnectInLoop() { 945 // FIXME: if the connect fails we should set a timer and try 946 // again, but if it fails then, quit. at least if it is not a websocket reply 947 // cuz it could be discord went down or something. 948 949 import core.time; 950 auto d = 1.seconds; 951 int count = 0; 952 953 try_again: 954 955 try { 956 this.websocket_.connect(); 957 } catch(Exception e) { 958 import core.thread; 959 Thread.sleep(d); 960 d *= 2; 961 count++; 962 if(count == 10) 963 throw e; 964 965 goto try_again; 966 } 967 } 968 969 970 } 971 972 class DiscordRpcConnection { 973 974 // this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config); 975 // websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`); 976 // writeln(websocket.waitForNextMessage.textData); 977 978 // these would tell me user names and ids when people join/leave but it needs authentication alas 979 980 /+ 981 websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 982 "guild_id": "SSSSSSSSSSSSSSSS", 983 "channel_id": "CCCCCCCCCCCCCCCCC" 984 }, 985 "evt": "VOICE_STATE_CREATE", 986 "cmd": "SUBSCRIBE" 987 }`); 988 writeln(websocket.waitForNextMessage.textData); 989 990 websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 991 "guild_id": "SSSSSSSSSSSSSSSS", 992 "channel_id": "CCCCCCCCCCCCCCCCC" 993 }, 994 "evt": "VOICE_STATE_DELETE", 995 "cmd": "SUBSCRIBE" 996 }`); 997 998 websocket.onmessage = delegate(in char[] msg) { 999 writeln(msg); 1000 1001 import arsd.jsvar; 1002 var m = var.fromJson(msg.idup); 1003 if(m.cmd == "DISPATCH") { 1004 if(m.evt == "SPEAKING_START") { 1005 //setSpeaking(m.data.user_id.get!ulong, true); 1006 } else if(m.evt == "SPEAKING_STOP") { 1007 //setSpeaking(m.data.user_id.get!ulong, false); 1008 } 1009 } 1010 }; 1011 1012 1013 +/ 1014 1015 1016 }