diff --git a/discord.d b/discord.d new file mode 100644 index 0000000..2c4cdd3 --- /dev/null +++ b/discord.d @@ -0,0 +1,910 @@ +/++ + Module for interacting with the Discord chat service. I use it to run a discord bot providing some slash commands. + + + $(LIST + * Real time gateway + See [DiscordGatewayConnection] + + You can use [SlashCommandHandler] subclasses registered with a gateway connection to easily add slash commands to your app. + * REST api + See [DiscordRestApi] + * Local RPC server + See [DiscordRpcConnection] (not implemented) + * Voice connections + not implemented + * Login with Discord + OAuth2 is easy enough without the lib, see bingo.d line 340ish-380ish. + ) + + History: + Started April 20, 2024. ++/ +module arsd.discord; + +// FIXME: User-Agent: DiscordBot ($url, $versionNumber) + +import arsd.http2; +import arsd.jsvar; + +import arsd.core; + +import core.time; + +static assert(use_arsd_core); + +/++ + Base class to represent some object on Discord, e.g. users, channels, etc., through its subclasses. + + + Among its implementations are: + + $(LIST + * [DiscordChannel] + * [DiscordUser] + * [DiscordRole] + ) ++/ +abstract class DiscordEntity { + private DiscordRestApi api; + private string id_; + + protected this(DiscordRestApi api, string id) { + this.api = api; + this.id_ = id; + } + + override string toString() { + return restType ~ "/" ~ id; + } + + /++ + + +/ + abstract string restType(); + + /++ + + +/ + final string id() { + return id_; + } + + /++ + Gives easy access to its rest api through [arsd.http2.HttpApiClient]'s dynamic dispatch functions. + + +/ + DiscordRestApi.RestBuilder rest() { + return api.rest[restType()][id()]; + } +} + +/++ + Represents something mentionable on Discord with `@name` - roles and users. ++/ +abstract class DiscordMentionable : DiscordEntity { + this(DiscordRestApi api, string id) { + super(api, id); + } +} + +/++ + https://discord.com/developers/docs/resources/channel ++/ +class DiscordChannel : DiscordEntity { + this(DiscordRestApi api, string id) { + super(api, id); + } + + override string restType() { + return "channels"; + } +} + +/++ + ++/ +class DiscordRole : DiscordMentionable { + this(DiscordRestApi api, DiscordGuild guild, string id) { + this.guild_ = guild; + super(api, id); + } + + private DiscordGuild guild_; + + /++ + + +/ + DiscordGuild guild() { + return guild_; + } + + override string restType() { + return "roles"; + } +} + +/++ + https://discord.com/developers/docs/resources/user ++/ +class DiscordUser : DiscordMentionable { + this(DiscordRestApi api, string id) { + super(api, id); + } + + private var cachedData; + + // DiscordGuild selectedGuild; + + override string restType() { + return "users"; + } + + void addRole(DiscordRole role) { + // PUT /guilds/{guild.id}/members/{user.id}/roles/{role.id} + + auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id]; + writeln(thing.toUri); + + auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].PUT().result; + } + + void removeRole(DiscordRole role) { + // DELETE /guilds/{guild.id}/members/{user.id}/roles/{role.id} + + auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id]; + writeln(thing.toUri); + + auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].DELETE().result; + } + +} + +/++ + ++/ +class DiscordGuild : DiscordEntity { + this(DiscordRestApi api, string id) { + super(api, id); + } + + override string restType() { + return "guilds"; + } + +} + + +enum InteractionType { + PING = 1, + APPLICATION_COMMAND = 2, // the main one + MESSAGE_COMPONENT = 3, + APPLICATION_COMMAND_AUTOCOMPLETE = 4, + MODAL_SUBMIT = 5, +} + + +/++ + You can create your own slash command handlers by subclassing this and writing methods like + + It will register for you when you connect and call your function when things come in. + + See_Also: + https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands ++/ +class SlashCommandHandler { + enum ApplicationCommandOptionType { + INVALID = 0, // my addition + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, // double's int part + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, + MENTIONABLE = 9, + NUMBER = 10, // double + ATTACHMENT = 11, + } + + /++ + I know this signature looks ridiculous, but in your subclass, make: + + --- + this() { + super(this); + } + --- + + To initialize the reflection info to send to Discord. If you subclass your subclass, + make sure the grandchild constructor does `super(); registerAll(this);` to add its method + to the list too. + +/ + protected this(this This)(This this_) { + registerAll(this_); + } + + /++ + + +/ + static class InteractionReplyHelper { + private DiscordRestApi api; + private CommandArgs commandArgs; + + private this(DiscordRestApi api, CommandArgs commandArgs) { + this.api = api; + this.commandArgs = commandArgs; + + } + + /++ + + +/ + void reply(string message) scope { + replyLowLevel(message); + } + + /++ + + +/ + void replyWithError(in char[] message) scope { + replyLowLevel(message.idup); + } + + void replyLowLevel(string message) scope { + var reply = var.emptyObject; + reply.type = 4; // chat response in message. 5 can be answered quick and edited later if loading, 6 if quick answer, no loading message + var replyData = var.emptyObject; + replyData.content = message; + reply.data = replyData; + var result = api.rest. + interactions[commandArgs.interactionId][commandArgs.interactionToken].callback + .POST(reply).result; + } + } + + + private bool alreadyRegistered; + private void register(DiscordRestApi api, string appId) { + if(alreadyRegistered) + return; + auto result = api.rest.applications[appId].commands.PUT(jsonArrayForDiscord).result; + alreadyRegistered = true; + } + + private static struct CommandArgs { + InteractionType interactionType; + string interactionToken; + string interactionId; + string guildId; + string channelId; + + var interactionData; + + var member; + var channel; + } + + private { + static HandlerInfo makeHandler(alias handler, T)(T slashThis) { + HandlerInfo info; + + // must be all lower case! + info.name = __traits(identifier, handler); + + var cmd = var.emptyObject(); + cmd.name = info.name; + version(D_OpenD) + cmd.description = __traits(docComment, handler); + else + cmd.description = ""; + + if(cmd.description == "") + cmd.description = "Can't be blank for CHAT_INPUT"; + + cmd.type = 1; // CHAT_INPUT + + var optionsArray = var.emptyArray; + + static if(is(typeof(handler) Params == __parameters)) {} + + string[] names; + + // extract parameters + foreach(idx, param; Params) { + var option = var.emptyObject; + auto name = __traits(identifier, Params[idx .. idx + 1]); + names ~= name; + option.name = name; + option.description = "desc"; + option.type = cast(int) applicationComandOptionTypeFromDType!(param); + // can also add "choices" which limit it to just specific members + if(option.type) { + optionsArray ~= option; + } + } + + cmd.options = optionsArray; + + info.jsonObjectForDiscord = cmd; + info.handler = (CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api) { + // extract args + // call the function + // send the appropriate reply + static if(is(typeof(handler) Return == return)) { + static if(is(Return == void)) { + __traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof); + sendHandlerReply("OK", replyHelper); + } else { + sendHandlerReply(__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof), replyHelper); + } + } else static assert(0); + }; + + return info; + } + + static auto fargsFromJson(Params...)(DiscordRestApi api, string[] names, var obj, CommandArgs args/*, Params defaults*/) { + static struct Holder { + // FIXME: default params no work + Params params;// = defaults; + } + + Holder holder; + foreach(idx, ref param; holder.params) { + setParamFromJson(param, names[idx], api, obj, args); + } + + return holder; + +/+ + +ync def something(interaction:discord.Interaction): + await interaction.response.send_message("NOTHING",ephemeral=True) + # optional (if you want to edit the response later,delete it, or send a followup) + await interaction.edit_original_response(content="Something") + await interaction.followup.send("This is a message too.",ephemeral=True) + await interaction.delete_original_response() + # if you have deleted the original response you can't edit it or send a followup after it ++/ + + } + + +// {"t":"INTERACTION_CREATE","s":7,"op":0,"d":{"version":1,"type":2,"token":"aW50ZXJhY3Rpb246MTIzMzIyNzE0OTU0NTE3NzE2OTp1Sjg5RE0wMzJiWER2UDRURk5XSWRaUTJtMExBeklWNEtpVEZocTQ4a0VZQ3NWUm9ta3g2SG1JbTBzUm1yWmlUNzQ3eWxpc0FnM0RzUzZHaWtENnRXUDBsdUhERElKSWlaYlFWMlNsZlZXTlFkU3VVQUVWU01PNU9TNFQ5cmFQSw", + +// "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}, + +// "locale":"en-US","id":"1233227149545177169","guild_locale":"en-US","guild_id":"1011977515109187704", +// "guild":{"locale":"en-US","id":"1011977515109187704","features":[]}, +// "entitlements":[],"entitlement_sku_ids":[], +// "data":{"type":1,"name":"hello","id":"1233221536522174535"},"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":"1233227103844171806","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0}, +// "application_id":"1223724819821105283","app_permissions":"1122573558992465"}} + + + template applicationComandOptionTypeFromDType(T) { + static if(is(T == SendingUser) || is(T == SendingChannel)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INVALID; // telling it to skip sending this to discord, it purely internal + else static if(is(T == DiscordRole)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.ROLE; + else static if(is(T == string)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.STRING; + else static if(is(T == bool)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.BOOLEAN; + else static if(is(T : const long)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INTEGER; + else static if(is(T : const double)) + enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.NUMBER; + else + static assert(0, T.stringof); + } + + static var getOptionForName(var obj, string name) { + foreach(option; obj.options) + if(option.name == name) + return option; + return var.init; + } + + static void setParamFromJson(T)(ref T param, string name, DiscordRestApi api, var obj, CommandArgs args) { + static if(is(T == SendingUser)) { + param = new SendingUser(api, args.member.user.id.get!string, obj.member.user); + } else static if(is(T == SendingChannel)) { + param = new SendingChannel(api, args.channel.id.get!string, obj.channel); + } else static if(is(T == string)) { + var option = getOptionForName(obj, name); + if(option.type == cast(int) ApplicationCommandOptionType.STRING) + param = option.value.get!(typeof(param)); + } else static if(is(T == bool)) { + var option = getOptionForName(obj, name); + if(option.type == cast(int) ApplicationCommandOptionType.BOOLEAN) + param = option.value.get!(typeof(param)); + } else static if(is(T : const long)) { + var option = getOptionForName(obj, name); + if(option.type == cast(int) ApplicationCommandOptionType.INTEGER) + param = option.value.get!(typeof(param)); + } else static if(is(T : const double)) { + var option = getOptionForName(obj, name); + if(option.type == cast(int) ApplicationCommandOptionType.NUMBER) + param = option.value.get!(typeof(param)); + } else static if(is(T == DiscordRole)) { + +//"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"}} + +// resolved gives you some precache info + + var option = getOptionForName(obj, name); + if(option.type == cast(int) ApplicationCommandOptionType.ROLE) + param = new DiscordRole(api, new DiscordGuild(api, args.guildId), option.value.get!string); + else + param = null; + } else { + static assert(0, "Bad type " ~ T.stringof); + } + } + + static void sendHandlerReply(T)(T ret, scope InteractionReplyHelper replyHelper) { + import std.conv; // FIXME + replyHelper.reply(to!string(ret)); + } + + void registerAll(T)(T t) { + foreach(memberName; __traits(derivedMembers, T)) + static if(memberName != "__ctor") { // FIXME + HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t); + registerFromRuntimeInfo(hi); + } + } + + void registerFromRuntimeInfo(HandlerInfo info) { + handlers[info.name] = info.handler; + if(jsonArrayForDiscord is var.init) + jsonArrayForDiscord = var.emptyArray; + jsonArrayForDiscord ~= info.jsonObjectForDiscord; + } + + alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api); + struct HandlerInfo { + string name; + InternalHandler handler; + var jsonObjectForDiscord; + } + InternalHandler[string] handlers; + var jsonArrayForDiscord; + } +} + +/++ + A SendingUser is a special DiscordUser type that just represents the person who sent the message. + + It exists so you can use it in a function parameter list that is auto-mapped to a message handler. ++/ +class SendingUser : DiscordUser { + private this(DiscordRestApi api, string id, var initialCache) { + super(api, id); + } +} + +class SendingChannel : DiscordUser { + private this(DiscordRestApi api, string id, var initialCache) { + super(api, id); + } +} + +// SendingChannel +// SendingMessage + +/++ + Use as a UDA + + 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. + + FIXME: watch the file for changes for auto-reload and update on the discord side + + FIXME: NOT IMPLEMENTED ++/ +struct ChoicesFromFile { + string filename; +} + +/++ + Most the magic is inherited from [arsd.http2.HttpApiClient]. ++/ +class DiscordRestApi : HttpApiClient!() { + /++ + Creates an API client. + + Params: + token = the bot authorization token you got from Discord + yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. + 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. + +/ + this(string botToken, string yourBotUrl, string yourBotVersion) { + this.authType = "Bot"; + super("https://discord.com/api/v10/", botToken); + } +} + +/++ + ++/ +class DiscordGatewayConnection { + private WebSocket websocket_; + private long lastSequenceNumberReceived; + private string token; + private DiscordRestApi api_; + + /++ + An instance to the REST api object associated with your connection. + +/ + public final DiscordRestApi api() { + return this.api_; + } + + /++ + + +/ + protected final WebSocket websocket() { + return websocket_; + } + + // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes + enum OpCode { + Dispatch = 0, // recv + Heartbeat = 1, // s/r + Identify = 2, // s + PresenceUpdate = 3, // s + VoiceStateUpdate = 4, // s + Resume = 6, // s + Reconnect = 7, // r + RequestGuildMembers = 8, // s + InvalidSession = 9, // r - you should reconnect and identify/resume + Hello = 10, // r + HeartbeatAck = 11, // r + } + + enum DisconnectCodes { + UnknownError = 4000, // t + UnknownOpcode = 4001, // t (user error) + DecodeError = 4002, // t (user error) + NotAuthenticated = 4003, // t (user error) + AuthenticationFailed = 4004, // f (user error) + AlreadyAuthenticated = 4005, // t (user error) + InvalidSeq = 4007, // t + RateLimited = 4008, // t + SessionTimedOut = 4009, // t + InvalidShard = 4010, // f (user error) + ShardingRequired = 4011, // f + InvalidApiVersion = 4012, // f + InvalidIntents = 4013, // f + DisallowedIntents = 4014, // f + } + + private string cachedGatewayUrl; + + /++ + Prepare a gateway connection. After you construct it, you still need to call [connect]. + + Params: + token = the bot authorization token you got from Discord + yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work. + 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. + +/ + public this(string token, string yourBotUrl, string yourBotVersion) { + this.token = token; + this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion); + } + + /++ + Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands. + +/ + final void slashCommandHandler(SlashCommandHandler t) { + if(slashCommandHandler_ !is null && t !is null) + throw ArsdException!"SlashCommandHandler is already set"(); + slashCommandHandler_ = t; + if(t && applicationId.length) + t.register(api, applicationId); + } + private SlashCommandHandler slashCommandHandler_; + + /++ + + +/ + protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) { + import std.stdio; writeln(closeEvent); + if(heartbeatTimer) + heartbeatTimer.cancel(); + + if(closeEvent.code == 1006 || closeEvent.code == 1001) + reconnectAndResume(); + } + + /++ + +/ + void close() { + close(1000, null); + } + + /// ditto + void close(int reason, string reasonText) { + if(heartbeatTimer) + heartbeatTimer.cancel(); + + websocket_.onclose = null; + websocket_.ontextmessage = null; + websocket_.onbinarymessage = null; + websocket.close(reason, reasonText); + websocket_ = null; + } + + /++ + +/ + protected void handleWebsocketMessage(in char[] msg) { + var m = var.fromJson(msg.idup); + + OpCode op = cast(OpCode) m.op.get!int; + var data = m.d; + + switch(op) { + case OpCode.Dispatch: + // these are null if op != 0 + string eventName = m.t.get!string; + long seqNumber = m.s.get!long; + + if(seqNumber > lastSequenceNumberReceived) + lastSequenceNumberReceived = seqNumber; + + eventReceived(eventName, data); + break; + case OpCode.Hello: + // the hello heartbeat_interval is in milliseconds + if(slashCommandHandler_ !is null && applicationId.length) + slashCommandHandler_.register(api, applicationId); + + setHeartbeatInterval(data.heartbeat_interval.get!int); + break; + case OpCode.Heartbeat: + sendHeartbeat(); + break; + case OpCode.HeartbeatAck: + mostRecentHeartbeatAckRecivedAt = MonoTime.currTime; + break; + case OpCode.Reconnect: + writeln("reconnecting"); + this.close(4999, "Reconnect requested"); + reconnectAndResume(); + break; + case OpCode.InvalidSession: + writeln("starting new session"); + + close(); + connect(); // try starting a brand new session + break; + default: + // ignored + } + } + + protected void reconnectAndResume() { + this.websocket_ = new WebSocket(Uri(this.resume_gateway_url)); + + websocket.onmessage = &handleWebsocketMessage; + websocket.onclose = &handleWebsocketClose; + + this.websocket_.connect(); + + var resumeData = var.emptyObject; + resumeData.token = this.token; + resumeData.session_id = this.session_id; + resumeData.seq = lastSequenceNumberReceived; + + sendWebsocketCommand(OpCode.Resume, resumeData); + + // the close event will cancel the heartbeat and thus we need to restart it + if(requestedHeartbeat) + setHeartbeatInterval(requestedHeartbeat); + } + + /++ + +/ + protected void eventReceived(string eventName, var data) { + // FIXME: any time i get an event i could prolly spin it off into an independent async task + switch(eventName) { + case "INTERACTION_CREATE": + 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} + + SlashCommandHandler.CommandArgs commandArgs; + + commandArgs.interactionType = cast(InteractionType) data.type.get!int; + commandArgs.interactionToken = data.token.get!string; + commandArgs.interactionId = data.id.get!string; + commandArgs.guildId = data.guild_id.get!string; + commandArgs.channelId = data.channel_id.get!string; + commandArgs.member = member; + commandArgs.channel = data.channel; + + commandArgs.interactionData = data.data; + // data.data : type/name/id. can use this to determine what function to call. prolly will include other info too + // "data":{"type":1,"name":"hello","id":"1233221536522174535"} + + // application_id and app_permissions and some others there too but that doesn't seem important + + /+ + replies: + https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type + +/ + + scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs); + + try { + if(slashCommandHandler_ is null) + throw ArsdException!"No slash commands registered"(); + + auto cmdName = commandArgs.interactionData.name.get!string; + if(auto pHandler = cmdName in slashCommandHandler_.handlers) { + (*pHandler)(commandArgs, replyHelper, api); + } else { + throw ArsdException!"Unregistered slash command"(cmdName); + } + } catch(Exception e) { + replyHelper.replyWithError(e.message); + } + break; + case "READY": + this.session_id = data.session_id.get!string; + this.resume_gateway_url = data.resume_gateway_url.get!string; + this.applicationId_ = data.application.id.get!string; + break; + + default: + } + } + + private string session_id; + private string resume_gateway_url; + private string applicationId_; + + /++ + Returns your application id. Only available after the connection is established. + +/ + public string applicationId() { + return applicationId_; + } + + private arsd.core.Timer heartbeatTimer; + private int requestedHeartbeat; + private bool requestedHeartbeatSet; + //private int heartbeatsSent; + //private int heartbeatAcksReceived; + private MonoTime mostRecentHeartbeatAckRecivedAt; + + protected void sendHeartbeat() { + arsd.core.writeln("sendHeartbeat"); + sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived)); + } + + private final void sendHeartbeatThunk() { + this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat + if(requestedHeartbeatSet == false) { + heartbeatTimer.changeTime(requestedHeartbeat, true); + requestedHeartbeatSet = true; + } else { + if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) { + // throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info? + websocket.close(1006, "heartbeat unanswered"); + reconnectAndResume(); + } + } + } + + /++ + +/ + protected void setHeartbeatInterval(int msecs) { + requestedHeartbeat = msecs; + requestedHeartbeatSet = false; + + if(heartbeatTimer is null) { + heartbeatTimer = new arsd.core.Timer; + heartbeatTimer.setPulseCallback(&sendHeartbeatThunk); + } + + // the first one is supposed to have random jitter + // so we'll do that one-off (but with a non-zero time + // since my timers don't like being run twice in one loop + // iteration) then that first one will set the repeating time + import std.random; + auto firstBeat = std.random.uniform(10, msecs); + heartbeatTimer.changeTime(firstBeat, false); + } + + /++ + + +/ + void sendWebsocketCommand(OpCode op, var d) { + assert(websocket !is null, "call connect before sending commands"); + + var cmd = var.emptyObject; + cmd.d = d; + cmd.op = cast(int) op; + websocket.send(cmd.toJson()); + } + + /++ + + +/ + void connect() { + assert(websocket is null, "do not call connect twice"); + + if(cachedGatewayUrl is null) { + auto obj = api.rest.gateway.bot.GET().result; + cachedGatewayUrl = obj.url.get!string; + } + + this.websocket_ = new WebSocket(Uri(cachedGatewayUrl)); + + websocket.onmessage = &handleWebsocketMessage; + websocket.onclose = &handleWebsocketClose; + + + websocket.connect(); + + var d = var.emptyObject; + d.token = token; + // FIXME? + d.properties = [ + "os": "linux", + "browser": "arsd.discord", + "device": "arsd.discord", + ]; + + sendWebsocketCommand(OpCode.Identify, d); + } +} + +class DiscordRpcConnection { + + // this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config); + // websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`); + // writeln(websocket.waitForNextMessage.textData); + + // these would tell me user names and ids when people join/leave but it needs authentication alas + + /+ + websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": { + "guild_id": "SSSSSSSSSSSSSSSS", + "channel_id": "CCCCCCCCCCCCCCCCC" + }, + "evt": "VOICE_STATE_CREATE", + "cmd": "SUBSCRIBE" +}`); + writeln(websocket.waitForNextMessage.textData); + + websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": { + "guild_id": "SSSSSSSSSSSSSSSS", + "channel_id": "CCCCCCCCCCCCCCCCC" + }, + "evt": "VOICE_STATE_DELETE", + "cmd": "SUBSCRIBE" +}`); + + websocket.onmessage = delegate(in char[] msg) { + writeln(msg); + + import arsd.jsvar; + var m = var.fromJson(msg.idup); + if(m.cmd == "DISPATCH") { + if(m.evt == "SPEAKING_START") { + //setSpeaking(m.data.user_id.get!ulong, true); + } else if(m.evt == "SPEAKING_STOP") { + //setSpeaking(m.data.user_id.get!ulong, false); + } + } + }; + + + +/ + + +}