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 // FIXME: if the connect fails we should set a timer and try 752 // again, but if it fails then, quit. at least if it is not a websocket reply 753 // cuz it could be discord went down or something. 754 this.websocket_.connect(); 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 websocket.connect(); 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 945 class DiscordRpcConnection { 946 947 // this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config); 948 // websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`); 949 // writeln(websocket.waitForNextMessage.textData); 950 951 // these would tell me user names and ids when people join/leave but it needs authentication alas 952 953 /+ 954 websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 955 "guild_id": "SSSSSSSSSSSSSSSS", 956 "channel_id": "CCCCCCCCCCCCCCCCC" 957 }, 958 "evt": "VOICE_STATE_CREATE", 959 "cmd": "SUBSCRIBE" 960 }`); 961 writeln(websocket.waitForNextMessage.textData); 962 963 websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": { 964 "guild_id": "SSSSSSSSSSSSSSSS", 965 "channel_id": "CCCCCCCCCCCCCCCCC" 966 }, 967 "evt": "VOICE_STATE_DELETE", 968 "cmd": "SUBSCRIBE" 969 }`); 970 971 websocket.onmessage = delegate(in char[] msg) { 972 writeln(msg); 973 974 import arsd.jsvar; 975 var m = var.fromJson(msg.idup); 976 if(m.cmd == "DISPATCH") { 977 if(m.evt == "SPEAKING_START") { 978 //setSpeaking(m.data.user_id.get!ulong, true); 979 } else if(m.evt == "SPEAKING_STOP") { 980 //setSpeaking(m.data.user_id.get!ulong, false); 981 } 982 } 983 }; 984 985 986 +/ 987 988 989 }