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