diff --git a/README.md b/README.md index 53822b1..0c80f92 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,15 @@ [![](https://travis-ci.org/b1naryth1ef/dscord.svg?branch=master)](https://travis-ci.org/b1naryth1ef/dscord) -dscord is a client library for the Discord chat service, written in D-lang. The core focus of dscord is to provide a clean interface with a highly performant and scalable backing runtime, to support use-cases that contain a large number of user or guilds. Dscord provides both a base client implementation, and various extras that assist in constructing bots out of it. Dscord is still very much a work-in-progress, so beware of sharp edges or missing pieces. + +dscord is a client libray for interacting with [Discord](https://discordapp.com). Currently dscord supports the majority footprint of the Discord API with some newer features and changes lacking. + +## Development + +For the moment dscord is in "maintence mode" which means no new features are being worked on but reported bugs will be investigated and any pull requests will be reviewed. ## Examples + If you're looking for sample code, or example implementations, the following are good places to start: - [this repo's examples](https://github.com/b1naryth1ef/dscord/tree/master/examples) @@ -12,7 +18,9 @@ If you're looking for sample code, or example implementations, the following are ## Compiling/Installing + Dscord is available on [dub](https://code.dlang.org/packages/dscord), and can be easily dropped into new or existing projects. ## Documentation -Documentation is available [here](http://b1naryth1ef.github.io/dscord/). + +Some documentation is available [here](http://b1naryth1ef.github.io/dscord/). diff --git a/dub.json b/dub.json index 66b2034..8540c81 100644 --- a/dub.json +++ b/dub.json @@ -7,8 +7,8 @@ "targetType": "library", "dependencies": { "shaker": "~>0.0.8", - "vibe-d:core": "~>0.8.0-beta.3", - "vibe-d:http": "~>0.8.0-beta.3", + "vibe-d:core": "~>0.8.5", + "vibe-d:http": "~>0.8.5", "dcad": "~>0.0.9" }, "configurations": [ diff --git a/examples/src/basic.d b/examples/src/basic.d index 72a4ae1..8df5f3b 100644 --- a/examples/src/basic.d +++ b/examples/src/basic.d @@ -13,13 +13,11 @@ import std.stdio, import vibe.core.core; import vibe.http.client; -import dcad.types : DCAFile; import dscord.core, dscord.util.process, - dscord.util.emitter, - dscord.voice.youtubedl; + dscord.util.emitter; import core.sys.posix.signal; import etc.linux.memoryerror; @@ -27,12 +25,6 @@ import etc.linux.memoryerror; import dscord.util.string : camelCaseToUnderscores; class BasicPlugin : Plugin { - DCAFile sound; - - this() { - super(); - } - @Listener!(MessageCreate, EmitterOrder.AFTER) void onMessageCreate(MessageCreate event) { this.log.infof("MessageCreate: %s", event.message.content); @@ -88,31 +80,6 @@ class BasicPlugin : Plugin { } - @Command("sound") - void onSound(CommandEvent event) { - auto chan = this.userVoiceChannel(event.msg.guild, event.msg.author); - - if (!chan) { - event.msg.reply("You are not in a voice channel!"); - return; - } - - if (!this.sound) { - this.sound = new DCAFile(File("test.dca", "r")); - } - - auto playable = new DCAPlayable(this.sound); - - auto vc = chan.joinVoice(); - - if (vc.connect()) { - vc.play(playable).disconnect(); - } else { - event.msg.reply("Failed :("); - } - - } - @Command("whereami") void onWhereAmI(CommandEvent event) { auto chan = this.userVoiceChannel(event.msg.guild, event.msg.author); diff --git a/examples/test.dca b/examples/test.dca deleted file mode 100644 index 39e7167..0000000 Binary files a/examples/test.dca and /dev/null differ diff --git a/src/dscord/api/ratelimit.d b/src/dscord/api/ratelimit.d index 448c760..b35ebba 100644 --- a/src/dscord/api/ratelimit.d +++ b/src/dscord/api/ratelimit.d @@ -10,6 +10,7 @@ import std.conv, core.sync.mutex; import vibe.core.core; +import vibe.core.sync : LocalManualEvent, createManualEvent; import dscord.api.routes, dscord.util.time; @@ -50,7 +51,7 @@ struct RateLimitState { RateLimiter provides an interface for rate limiting HTTP Requests. */ class RateLimiter { - ManualEvent[Bucket] cooldowns; + LocalManualEvent[Bucket] cooldowns; RateLimitState[Bucket] states; /// Cooldown a bucket for a given duration. Blocks ALL requests from completing. diff --git a/src/dscord/client.d b/src/dscord/client.d index ab4c0b0..d98b4bd 100644 --- a/src/dscord/client.d +++ b/src/dscord/client.d @@ -12,7 +12,6 @@ import std.algorithm.iteration; import dscord.api, dscord.types, dscord.state, - dscord.voice, dscord.gateway, dscord.util.emitter; @@ -48,9 +47,6 @@ class Client { /** State instance */ State state; - /** Mapping of voice connections */ - VoiceClient[Snowflake] voiceConns; - /** Emitter for gateway events */ Emitter events; @@ -98,7 +94,7 @@ class Client { foreach(message; messages){ msgIDs ~= message.id; } - + return deleteMessages(channelID, msgIDs); } diff --git a/src/dscord/core.d b/src/dscord/core.d index 685ee7d..de65721 100644 --- a/src/dscord/core.d +++ b/src/dscord/core.d @@ -4,5 +4,4 @@ public import dscord.api; public import dscord.bot; public import dscord.gateway; public import dscord.types; -public import dscord.voice; public import dscord.util.storage; diff --git a/src/dscord/gateway/events.d b/src/dscord/gateway/events.d index f05f48e..826eb4f 100644 --- a/src/dscord/gateway/events.d +++ b/src/dscord/gateway/events.d @@ -166,6 +166,7 @@ class GuildDelete { class GuildBanAdd { mixin Event; + Snowflake guildID; User user; } @@ -175,6 +176,7 @@ class GuildBanAdd { class GuildBanRemove { mixin Event; + Snowflake guildID; User user; } diff --git a/src/dscord/state.d b/src/dscord/state.d index 1477391..6be478e 100644 --- a/src/dscord/state.d +++ b/src/dscord/state.d @@ -5,7 +5,7 @@ import std.functional, std.algorithm.iteration, std.experimental.logger; -import vibe.core.sync : createManualEvent, ManualEvent; +import vibe.core.sync : createManualEvent, LocalManualEvent; import std.algorithm.searching : canFind, countUntil; import std.algorithm.mutation : remove; @@ -47,7 +47,7 @@ class State : Emitter { VoiceStateMap voiceStates; /// Event triggered when all guilds are synced - ManualEvent ready; + LocalManualEvent ready; bool requestOfflineMembers = true; diff --git a/src/dscord/types/base.d b/src/dscord/types/base.d index d0351a1..40fcd3a 100644 --- a/src/dscord/types/base.d +++ b/src/dscord/types/base.d @@ -12,7 +12,7 @@ import std.conv, import dscord.client; import vibe.core.core : runTask, sleep; -import vibe.core.sync; +import vibe.core.sync : createManualEvent, LocalManualEvent; // Commonly used public imports public import dscord.util.json; @@ -39,7 +39,7 @@ class AsyncChainer(T) { private { T obj; AsyncChainer!T parent; - ManualEvent resolveEvent; + LocalManualEvent resolveEvent; bool ignoreFailure; } @@ -144,7 +144,7 @@ class IModel { @JSONIgnore Client client; - void init() {}; + void initialize() {}; // void load(JSONDecoder obj) {}; this() {} @@ -181,7 +181,7 @@ mixin template Model() { this.client = client; this.deserializeFromJSON(obj); - this.init(); + this.initialize(); version (TIMING) { this.client.log.tracef("Finished creation of model %s in %sms", this.toString, diff --git a/src/dscord/types/channel.d b/src/dscord/types/channel.d index 7de5107..5d5b30f 100644 --- a/src/dscord/types/channel.d +++ b/src/dscord/types/channel.d @@ -7,7 +7,6 @@ import std.stdio, core.vararg; import dscord.types, - dscord.voice, dscord.client; alias ChannelMap = ModelMap!(Snowflake, Channel); @@ -64,16 +63,11 @@ class Channel : IModel, IPermissible { @JSONSource("permission_overwrites") PermissionOverwriteMap overwrites; - // Voice Connection - // TODO: move? - @JSONIgnore - VoiceClient vc; - @property Guild guild() { return this.client.state.guilds.get(this.guildID); } - override void init() { + override void initialize() { this.overwrites = new PermissionOverwriteMap; } @@ -134,11 +128,6 @@ class Channel : IModel, IPermissible { return this.guild.voiceStates.filter(c => c.channelID == this.id); } - VoiceClient joinVoice() { - this.vc = new VoiceClient(this); - return this.vc; - } - override Permission getPermissions(Snowflake user) { GuildMember member = this.guild.getMember(user); Permission perm = this.guild.getPermissions(user); diff --git a/src/dscord/types/guild.d b/src/dscord/types/guild.d index 9862f08..2ecd2d3 100644 --- a/src/dscord/types/guild.d +++ b/src/dscord/types/guild.d @@ -139,7 +139,7 @@ class Guild : IModel, IPermissible { @JSONListToMap("id") EmojiMap emojis; - override void init() { + override void initialize() { // It's possible these are not created if (!this.members) return; @@ -154,6 +154,14 @@ class Guild : IModel, IPermissible { return format("", this.name, this.id); } + /// Returns a URL to the guild icon + string getIconURL(string fmt = "webp", size_t size = 1024) { + if (this.icon == "") { + return ""; + } + return format("https://cdn.discordapp.com/icons/%s/%s.%s?size=%s", this.id, this.icon, fmt, size); + } + /// Returns a GuildMember for a given user object GuildMember getMember(User obj) { return this.getMember(obj.id); diff --git a/src/dscord/types/message.d b/src/dscord/types/message.d index 0110d9d..47d6168 100644 --- a/src/dscord/types/message.d +++ b/src/dscord/types/message.d @@ -176,13 +176,22 @@ class Message : IModel { Snowflake channelID; User author; string content; - string timestamp; // TODO: timestamps lol - string editedTimestamp; // TODO: timestamps lol bool tts; bool mentionEveryone; - string nonce; bool pinned; + // Nonce is very unpredictable and user-provided, so we don't unpack it into + // a concrete type. + VibeJSON nonce; + + @JSONTimestamp + SysTime timestamp; + + @JSONTimestamp + SysTime editedTimestamp; + + GuildMember member; + // TODO: GuildMemberMap here @JSONListToMap("id") UserMap mentions; diff --git a/src/dscord/types/user.d b/src/dscord/types/user.d index 1d0e400..2cefa4d 100644 --- a/src/dscord/types/user.d +++ b/src/dscord/types/user.d @@ -1,7 +1,10 @@ module dscord.types.user; import std.stdio, - std.format; + std.format, + std.algorithm.searching; + +import std.conv : to; import dscord.types, dscord.client; @@ -11,6 +14,8 @@ alias UserMap = ModelMap!(Snowflake, User); enum GameType : ushort { DEFAULT = 0, STREAMING = 1, + LISTENING = 2, + WATCHING = 3, } enum UserStatus : string { @@ -21,6 +26,13 @@ enum UserStatus : string { OFFLINE = "offline", } +enum DefaultAvatarColor { + BLURPLE = 0, + GREY = 1, + GREEN = 2, + ORANGE = 3, + RED = 4, +} class Game { string name; @@ -69,4 +81,22 @@ class User : IModel { override string toString() { return format("", this.username, this.discriminator, this.id); } + + string getAvatarURL(string fmt = null, size_t size = 1024) { + if (!this.avatar) { + return format("https://cdn.discordapp.com/embed/avatars/%s.png", cast(int)this.defaultAvatarColor); + } + + if (fmt is null) { + fmt = this.avatar.startsWith("a_") ? "gif" : "webp"; + } + + return format("https://cdn.discordapp.com/avatars/%s/%s.%s?size=%s", this.id, this.avatar, fmt, size); + } + + @property DefaultAvatarColor defaultAvatarColor() { + auto discrimNumber = this.discriminator.to!int; + + return cast(DefaultAvatarColor)(discrimNumber % DefaultAvatarColor.sizeof); + } } diff --git a/src/dscord/util/json.d b/src/dscord/util/json.d index bb38111..bd75ca8 100644 --- a/src/dscord/util/json.d +++ b/src/dscord/util/json.d @@ -3,11 +3,10 @@ */ module dscord.util.json; -import std.stdio; - import std.conv, std.meta, - std.traits; + std.traits, + std.stdio; public import vibe.data.json : VibeJSON = Json, parseJsonString; @@ -17,6 +16,7 @@ public import dscord.util.string : camelCaseToUnderscores; enum JSONIgnore; enum JSONFlat; +enum JSONTimestamp; struct JSONSource { string src; @@ -174,9 +174,12 @@ void deserializeFromJSON(T)(T sourceObj, VibeJSON sourceData) { __traits(getMember, sourceObj, fieldName) = typeof(__traits(getMember, sourceObj, fieldName)).fromJSONArray!( getUDAs!(mixin("sourceObj." ~ fieldName), JSONListToMap)[0].field )(sourceObj, fieldData); + } else static if (hasUDA!(mixin("sourceObj." ~ fieldName), JSONTimestamp)) { + version (JSON_DEBUG) pragma(msg, " -= loadTimestampField"); + __traits(getMember, sourceObj, fieldName) = loadTimestampField!(T, FieldType)(sourceObj, fieldData); } else { version (JSON_DEBUG) pragma(msg, " -= loadSingleField"); - loadSingleField!(T, FieldType)(sourceObj, __traits(getMember, sourceObj, fieldName), fieldData); + __traits(getMember, sourceObj, fieldName) = loadSingleField!(T, FieldType)(sourceObj, fieldData); } } } @@ -191,13 +194,26 @@ template AATypes(T) { alias ArrayElementType!(typeof(T.values)) value; } -private bool loadSingleField(T, Z)(T sourceObj, ref Z result, VibeJSON data) { +private DateT loadTimestampField(T, DateT)(T sourceObj, VibeJSON data) { + return DateT.fromISOExtString(data.to!string); +} + +private Z loadSingleField(T, Z)(T sourceObj, VibeJSON data) { version (JSON_DEBUG) { writefln(" -> parsing type %s from %s", fullyQualifiedName!Z, data.type); } - static if (is(Z == struct)) { + // Some deserialization strategies we take require a reference to the type. + Z result; + + static if (is(Z == VibeJSON)) { + return data; + } else static if (is(Z == struct)) { result.deserializeFromJSON(data); + return result; + } else static if (is(Z == enum)) { + // Read the stored type and then cast it to our enum type + return cast(Z)data.to!(OriginalType!Z); } else static if (is(Z == class)) { // If we have a constructor which allows the parent object and the JSON data use it static if (__traits(compiles, { @@ -212,48 +228,46 @@ private bool loadSingleField(T, Z)(T sourceObj, ref Z result, VibeJSON data) { result = new Z; result.deserializeFromJSON(data); } + return result; } else static if (isSomeString!Z) { static if (__traits(compiles, { - result = cast(Z)data.get!string; + return cast(Z)data.get!string; })) { - result = cast(Z)data.get!string; + return cast(Z)data.get!string; } else { - result = data.get!string.to!Z; + return data.get!string.to!Z; } } else static if (isArray!Z) { alias AT = ArrayElementType!(Z); foreach (obj; data) { - AT v; - loadSingleField!(T, AT)(sourceObj, v, obj); + AT v = loadSingleField!(T, AT)(sourceObj, obj); result ~= v; } + return result; } else static if (isAssociativeArray!Z) { alias ArrayElementType!(typeof(result.keys)) Tk; alias ArrayElementType!(typeof(result.values)) Tv; foreach (ref string k, ref v; data) { - Tv val; - - loadSingleField!(T, Tv)(sourceObj, val, v); + Tv val = loadSingleField!(T, Tv)(sourceObj, v); result[k.to!Tk] = val; } + return result; } else static if (isIntegral!Z) { if (data.type == VibeJSON.Type.string) { - result = data.get!string.to!Z; + return data.get!string.to!Z; } else { static if (__traits(compiles, { result = data.to!Z; })) { - result = data.to!Z; + return data.to!Z; } else { - result = data.get!Z; + return data.get!Z; } } } else { - result = data.to!Z; + return data.to!Z; } - - return false; } private void attach(T, Z)(T baseObj, Z parentObj) { @@ -266,7 +280,6 @@ private void attach(T, Z)(T baseObj, Z parentObj) { } } - T deserializeFromJSON(T)(VibeJSON jsonData) { T result = new T; result.deserializeFromJSON(jsonData); @@ -282,3 +295,31 @@ T[] deserializeFromJSONArray(T)(VibeJSON jsonData, T delegate(VibeJSON) cons) { return result; } + +unittest { + // Test Enums + enum TestEnum { + A = "A", + B = "B", + } + + class TestEnumClass { + TestEnum test; + } + + (new TestEnumClass()).deserializeFromJSON( + parseJsonString(q{{"test": "A"}}), + ); + + // Test Timestamp + import std.datetime : SysTime; + + class TestTimestampClass { + @JSONTimestamp + SysTime timestamp; + } + + (new TestTimestampClass()).deserializeFromJSON( + parseJsonString(q{{"timestamp": "2018-11-13T02:51:57.736000+00:00"}}), + ); +} diff --git a/src/dscord/voice/client.d b/src/dscord/voice/client.d deleted file mode 100644 index 12c492e..0000000 --- a/src/dscord/voice/client.d +++ /dev/null @@ -1,582 +0,0 @@ -/** - Manages Discord voice connections. -*/ -module dscord.voice.client; - -import core.time, - core.stdc.time, - std.stdio, - std.zlib, - std.array, - std.stdio, - std.bitmanip, - std.outbuffer, - std.string, - std.algorithm.comparison; - -import vibe.core.core, - vibe.core.net, - vibe.inet.url, - vibe.http.websockets; - -import dcad.types : DCAFile; - -import shaker : crypto_secretbox_easy; - -import dscord.types, - dscord.voice, - dscord.client, - dscord.gateway, - dscord.util.emitter, - dscord.util.ticker; - -/// VoiceClient connection states -enum VoiceStatus { - DISCONNECTED = 0, - CONNECTING = 1, - CONNECTED = 2, - READY = 3, -} - -/// RTPHeader used for sending RTP data -struct RTPHeader { - /// Sequence number of the current frame - ushort seq; - - /// Timestamp of the current frame - uint ts; - - /// Source ID of the current sender - uint ssrc; - - this(ushort seq, uint ts, uint ssrc) { - this.seq = seq; - this.ts = ts; - this.ssrc = ssrc; - } - - /// Returns a packed (in bytes) version of this header - ubyte[] pack() { - OutBuffer b = new OutBuffer(); - b.write('\x80'); - b.write('\x78'); - b.write(nativeToBigEndian(this.seq)); - b.write(nativeToBigEndian(this.ts)); - b.write(nativeToBigEndian(this.ssrc)); - return b.toBytes; - } -} - -/// UDP Connection wrapper for the VoiceClient -class UDPVoiceClient { - /// Parent VoiceClient reference - VoiceClient vc; - - // UDP Connection - UDPConnection conn; - - private { - // Local connection info - string ip; - ushort port; - - // Running state - bool running; - } - - this(VoiceClient vc) { - this.vc = vc; - } - - void run() { - this.running = true; - - while (this.running) { - auto data = this.conn.recv(); - } - } - - void close() { - this.running = false; - - try { - this.conn.close(); - } catch (Error e) {} - } - - bool connect(string hostname, ushort port, Duration timeout=5.seconds) { - this.conn = listenUDP(0); - this.conn.connect(hostname, port); - - // Send IP discovery payload - OutBuffer b = new OutBuffer(); - b.write(nativeToBigEndian(this.vc.ssrc)); - b.fill0(70 - b.toBytes.length); - this.conn.send(b.toBytes); - - // Wait for the IP discovery response, maybe timeout after a bit - string data; - try { - data = cast(string)this.conn.recv(timeout); - } catch (Exception e) { - return false; - } - - // Parse the IP discovery response - this.ip = data[4..(data[4..data.length].indexOf(0x00) + 4)]; - ubyte[2] portBytes = cast(ubyte[])(data)[data.length - 2..data.length]; - this.port = littleEndianToNative!(ushort, 2)(portBytes); - - // Finally actually start running the task - runTask(&this.run); - return true; - } -} - -class VoiceClient { - /// Global client which owns this VoiceClient - Client client; - - /// The channel this VoiceClient is attached to - Channel channel; - - /// Packet emitter - Emitter packetEmitter; - - /// UDP Client connection - UDPVoiceClient udp; - - // Current voice connection state - VoiceStatus state = VoiceStatus.DISCONNECTED; - - // Currently playing item + player task - Playable playable; - - private { - // Logger reference - Logger log; - - // Event triggered when connection is complete - ManualEvent waitForConnected; - - // Player task - Task playerTask; - - // Voice websocket - WebSocket sock; - - // Heartbeater task - Task heartbeater; - - // Secret key + encryption state - ubyte[32] secretKey; - ubyte[12] headerRaw; - ubyte[24] nonceRaw; - - // Various connection attributes - string token; - URL endpoint; - ushort ssrc; - ushort port; - - // Track mute/deaf states - bool mute, deaf; - - // Track the current speaking state - bool speaking = false; - - // Used to track VoiceServerUpdates - EventListener updateListener; - - // Used to control pausing state - ManualEvent pauseEvent; - } - - this(Channel c, bool mute=false, bool deaf=false) { - this.channel = c; - this.client = c.client; - this.log = this.client.log; - - this.mute = mute; - this.deaf = deaf; - - this.packetEmitter = new Emitter; - this.packetEmitter.listen!VoiceReadyPacket(&this.handleVoiceReadyPacket); - this.packetEmitter.listen!VoiceSessionDescriptionPacket( - &this.handleVoiceSessionDescription); - } - - /// Set the speaking state - void setSpeaking(bool value) { - if (this.speaking == value) return; - - this.speaking = value; - this.send(new VoiceSpeakingPacket(value, 0)); - } - - private void handleVoiceReadyPacket(VoiceReadyPacket p) { - this.ssrc = p.ssrc; - this.port = p.port; - - // Spawn the heartbeater - this.heartbeater = runTask(&this.heartbeat, p.heartbeatInterval); - - // If we don't have a UDP connection open (e.g. not reconnecting), open one - // now. - if (!this.udp) { - this.udp = new UDPVoiceClient(this); - } - - // Then actually connect and perform IP discovery - if (!this.udp.connect(this.endpoint.host, this.port)) { - this.log.warning("VoiceClient failed to connect over UDP and perform IP discovery"); - this.disconnect(false); - return; - } - - // TODO: ensure the mode is supported - - // Select the protocol - // TODO: encryption/xsalsa - this.send(new VoiceSelectProtocolPacket("udp", "xsalsa20_poly1305", this.udp.ip, this.udp.port)); - } - - private void handleVoiceSessionDescription(VoiceSessionDescriptionPacket p) { - this.log.tracef("Recieved VoiceSessionDescription, finished connection sequence."); - - this.secretKey = cast(ubyte[32])p.secretKey[0..32]; - this.log.tracef("secret_key %s", this.secretKey); - - // Toggle our voice speaking state so everyone learns our SSRC - this.send(new VoiceSpeakingPacket(true, 0)); - this.send(new VoiceSpeakingPacket(false, 0)); - sleep(250.msecs); - - // Set the state to READY, we can now send voice data - this.state = VoiceStatus.READY; - - // Emit the connected event - this.waitForConnected.emit(); - - // If we where paused (e.g. in the process of reconnecting), unpause now - if (this.paused) { - // For whatever reason, if we don't sleep here sometimes clients won't accept our audio - sleep(1.seconds); - this.resume(); - } - } - - /// Whether the player is currently paused - @property bool paused() { - return (this.pauseEvent !is null); - } - - /// Pause the player - bool pause(bool wait=false) { - if (this.pauseEvent) { - if (!wait) return false; - this.pauseEvent.wait(); - } - - this.pauseEvent = createManualEvent(); - return true; - } - - /// Resume the player - bool resume() { - if (!this.paused) { - return false; - } - - // Avoid race conditions by copying - auto e = this.pauseEvent; - this.pauseEvent = null; - e.emit(); - return true; - } - - private void runPlayer() { - this.playable.start(); - - if (!this.playable.hasMoreFrames()) { - this.log.warning("Playable ran out of frames before playing"); - return; - } - - this.setSpeaking(true); - - // Create a new timing ticker at the frame duration interval - StaticTicker ticker = new StaticTicker(this.playable.getFrameDuration().msecs, true); - - RTPHeader header; - header.ssrc = this.ssrc; - - ubyte[] frame; - - while (this.playable.hasMoreFrames()) { - // If the UDP connection isnt running, this is pointless - if (!this.udp || !this.udp.running) { - this.log.warning("UDPVoiceClient lost connection while playing audio"); - this.setSpeaking(false); - return; - } - - // If we're paused, wait until we unpause to continue playing. Make sure - // to set speaking here in case users connect during this period. - if (this.paused) { - // Only set our speaking status if we're still connected - if (this.sock.connected) this.setSpeaking(false); - this.pauseEvent.wait(); - this.setSpeaking(true); - - // Reset the ticker so we don't fast forward it to catch up - ticker.reset(); - } - - // Get the next frame from the playable, and send it - frame = this.playable.nextFrame(); - header.seq++; - - // Encrypt the packet - this.headerRaw = header.pack(); - this.nonceRaw[0..12] = headerRaw; - - ubyte[] payload; - payload.length = 16 + frame.length; - - assert(crypto_secretbox_easy( - payload.ptr, - frame.ptr, frame.length, - this.nonceRaw, - this.secretKey, - ) == 0); - - // And send the header + encrypted payload - this.udp.conn.send(this.headerRaw ~ payload); - header.ts += this.playable.getFrameSize(); - - // Wait until its time to play the next frame - ticker.sleep(); - } - - this.setSpeaking(false); - } - - /// Whether the player is currently active - @property bool playing() { - return (this.playerTask && this.playerTask.running); - } - - /// Plays a Playable - VoiceClient play(Playable p) { - assert(this.state == VoiceStatus.READY, "Must be connected to play audio"); - - // If we are currently playing something, kill it - if (this.playerTask && this.playerTask.running) { - this.playerTask.terminate(); - } - - this.playable = p; - this.playerTask = runTask(&this.runPlayer); - return this; - } - - private void heartbeat(ushort heartbeatInterval) { - while (this.state >= VoiceStatus.CONNECTED) { - uint unixTime = cast(uint)core.stdc.time.time(null); - this.send(new VoiceHeartbeatPacket(unixTime * 1000)); - sleep(heartbeatInterval.msecs); - } - } - - private void dispatchVoicePacket(T)(VibeJSON obj) { - T packet = deserializeFromJSON!(T)(obj); - this.packetEmitter.emit!T(packet); - } - - private void parse(string rawData) { - VibeJSON json = parseJsonString(rawData); - - VoiceOPCode op = json["op"].get!VoiceOPCode; - - version (DEBUG_GATEWAY_DATA) { - this.log.tracef("VOICE RECV: %s", rawData); - } - - switch (op) { - case VoiceOPCode.VOICE_READY: - this.dispatchVoicePacket!VoiceReadyPacket(json["d"]); - break; - case VoiceOPCode.VOICE_SESSION_DESCRIPTION: - this.dispatchVoicePacket!VoiceSessionDescriptionPacket(json["d"]); - break; - case VoiceOPCode.VOICE_HEARTBEAT: - case VoiceOPCode.VOICE_SPEAKING: - // Ignored - break; - default: - this.log.warningf("Unhandled voice packet: %s", op); - break; - } - } - - /// Sends a payload to the websocket - void send(Serializable p) { - string data = p.serialize().toString; - - version (DEBUG_GATEWAY_DATA) { - this.log.tracef("VOICE SEND: %s", data); - } - - try { - this.sock.send(data); - } catch (Exception e) { - this.log.warningf("ERROR: %s", e.toString); - } - } - - // Runs this voice client - void run() { - string data; - - while (this.sock.waitForData()) { - // Not possible to recv compressed data on the voice ws right now, but lets future guard - try { - ubyte[] rawdata = this.sock.receiveBinary(); - data = cast(string)uncompress(rawdata); - } catch (Exception e) { - data = this.sock.receiveText(); - } - - if (data == "") { - continue; - } - - try { - this.parse(data); - } catch (Exception e) { - this.log.warningf("failed to handle %s (%s)", e, data); - } catch (Error e) { - this.log.warningf("failed to handle %s (%s)", e, data); - } - } - - this.log.warningf("Lost voice websocket connection in state %s", this.state); - - // If we where in state READY, reconnect fully - if (this.state == VoiceStatus.READY) { - this.log.warning("Attempting reconnection of voice connection"); - this.disconnect(false); - this.connect(); - } - } - - private void onVoiceServerUpdate(VoiceServerUpdate event) { - if (this.channel.guild.id != event.guildID || !event.token) { - return; - } - - if (this.token && event.token != this.token) { - return; - } else { - this.token = event.token; - } - - // If we're connected (e.g. have a WS open), close it so we can reconnect - // to the new voice endpoint. - if (this.state >= VoiceStatus.CONNECTED) { - this.log.warningf("Voice server updated while connected to voice, attempting server change"); - - // If we're playing, pause until we finish reconnecting - if (!this.paused && this.playing) { - this.log.tracef("pausing player while we reconnect"); - this.pause(); - } - - // Set state before we close so we don't attempt to reconnect - this.state = VoiceStatus.CONNECTED; - if (this.sock.connected) this.sock.close(); - } - - // Make sure our state is now CONNECTED - this.state = VoiceStatus.CONNECTED; - - // Grab endpoint and create a proper URL out of it - this.endpoint = URL("ws", event.endpoint.split(":")[0], 0, Path()); - this.sock = connectWebSocket(this.endpoint); - runTask(&this.run); - - // Send identify - this.send(new VoiceIdentifyPacket( - this.channel.guild.id, - this.client.state.me.id, - this.client.gw.sessionID, - this.token - )); - } - - /// Attempt a connection to the voice channel this VoiceClient is attached to. - bool connect(Duration timeout=5.seconds) { - this.state = VoiceStatus.CONNECTING; - this.waitForConnected = createManualEvent(); - - // Start listening for VoiceServerUpdates - this.updateListener = this.client.gw.eventEmitter.listen!VoiceServerUpdate( - &this.onVoiceServerUpdate - ); - - // Send our VoiceStateUpdate - this.client.gw.send(new VoiceStateUpdatePacket( - this.channel.guild.id, - this.channel.id, - this.mute, - this.deaf - )); - - // Wait for connection event to be emitted (or timeout and disconnect) - if (this.waitForConnected.wait(timeout, 0)) { - return true; - } else { - this.disconnect(false); - return false; - } - } - - /// Disconnects from the voice channel. If clean is true, waits to finish playing. - void disconnect(bool clean=true) { - if (this.playing) { - if (clean) { - this.log.tracef("Requested CLEAN voice disconnect, waiting..."); - this.playerTask.join(); - this.log.tracef("Executing previously requested CLEAN voice disconnect"); - } - } - - // Send gateway update if we requested it - this.client.gw.send(new VoiceStateUpdatePacket( - this.channel.guild.id, - 0, - this.mute, - this.deaf - )); - - // Always make sure our updateListener is unbound - this.updateListener.unbind(); - - // If we're actually connected, close the voice socket - if (this.state >= VoiceStatus.CONNECTING) { - this.state = VoiceStatus.DISCONNECTED; - if (this.sock && this.sock.connected) this.sock.close(); - } - - // If we have a UDP connection, close it - if (this.udp) { - this.udp.close(); - this.udp.destroy(); - this.udp = null; - } - - // Finally set state to disconnected - this.state = VoiceStatus.DISCONNECTED; - } -} diff --git a/src/dscord/voice/package.d b/src/dscord/voice/package.d deleted file mode 100644 index df8a53f..0000000 --- a/src/dscord/voice/package.d +++ /dev/null @@ -1,6 +0,0 @@ -module dscord.voice; - -public import dscord.voice.client; -public import dscord.voice.packets; -public import dscord.voice.playable; -public import dscord.voice.youtubedl; diff --git a/src/dscord/voice/packets.d b/src/dscord/voice/packets.d deleted file mode 100644 index cf9ad94..0000000 --- a/src/dscord/voice/packets.d +++ /dev/null @@ -1,123 +0,0 @@ -/** - Implementations of packets sent over the Voice websocket. -*/ -module dscord.voice.packets; - -import std.stdio; - -import dscord.types, - dscord.gateway; - -enum VoiceOPCode : ushort { - VOICE_IDENTIFY = 0, - VOICE_SELECT_PROTOCOL = 1, - VOICE_READY = 2, - VOICE_HEARTBEAT = 3, - VOICE_SESSION_DESCRIPTION = 4, - VOICE_SPEAKING = 5, - VOICE_HEARTBEAT_ACK = 6, - VOICE_RESUME = 7, - VOICE_HELLO = 8, - VOICE_RESUMED = 9, -} - -class VoiceIdentifyPacket : BasePacket, Serializable { - Snowflake serverID; - Snowflake userID; - string sessionID; - string token; - - this(Snowflake server, Snowflake user, string session, string token) { - this.serverID = server; - this.userID = user; - this.sessionID = session; - this.token = token; - } - - override VibeJSON serialize() { - return super.serialize(VoiceOPCode.VOICE_IDENTIFY, VibeJSON([ - "server_id": VibeJSON(this.serverID), - "user_id": VibeJSON(this.userID), - "session_id": VibeJSON(this.sessionID), - "token": VibeJSON(this.token), - ])); - } -} - -class VoiceReadyPacket : BasePacket, Deserializable { - ushort ssrc; - ushort port; - string[] modes; - ushort heartbeatInterval; - - /* - void deserialize(JSONDecoder obj) { - obj.keySwitch!("ssrc", "port", "modes", "heartbeat_interval")( - { this.ssrc = obj.read!ushort; }, - { this.port = obj.read!ushort; }, - { this.modes = obj.readArray!string; }, - { this.heartbeatInterval = obj.read!ushort; }, - ); - } - */ -} - -class VoiceSelectProtocolPacket : BasePacket, Serializable { - string protocol; - string mode; - string ip; - ushort port; - - this(string protocol, string mode, string ip, ushort port) { - this.protocol = protocol; - this.mode = mode; - this.ip = ip; - this.port = port; - } - - override VibeJSON serialize() { - auto data = VibeJSON([ - "port": VibeJSON(this.port), - "address": VibeJSON(this.ip), - "mode": VibeJSON(this.mode), - ]); - - return super.serialize(VoiceOPCode.VOICE_SELECT_PROTOCOL, VibeJSON([ - "protocol": VibeJSON(this.protocol), - "data": data, - ])); - } -} - -class VoiceHeartbeatPacket : BasePacket, Serializable { - uint ts; - - this(uint ts) { - this.ts = ts; - } - - override VibeJSON serialize() { - return super.serialize(VoiceOPCode.VOICE_HEARTBEAT, VibeJSON(this.ts)); - } -} - -class VoiceSpeakingPacket : BasePacket, Serializable { - bool speaking; - uint delay; - - this(bool speaking, uint delay) { - this.speaking = speaking; - this.delay = delay; - } - - override VibeJSON serialize() { - return super.serialize(VoiceOPCode.VOICE_SPEAKING, VibeJSON([ - "speaking": VibeJSON(this.speaking), - "delay": VibeJSON(this.delay), - ])); - } -} - -class VoiceSessionDescriptionPacket : BasePacket, Deserializable { - byte[] secretKey; -} diff --git a/src/dscord/voice/playable.d b/src/dscord/voice/playable.d deleted file mode 100644 index e344128..0000000 --- a/src/dscord/voice/playable.d +++ /dev/null @@ -1,150 +0,0 @@ -/** - Implementation of types that can be played on a VoiceClient -*/ -module dscord.voice.playable; - -import dcad.types : DCAFile; - -/** - An interface representing a type which can be played over a VoiceClient. -*/ -interface Playable { - /// Duration of the frame in milliseconds - const short getFrameDuration(); - - /// Size of the frame in bytes - const short getFrameSize(); - - /// Returns the next frame to be played - ubyte[] nextFrame(); - - /// Returns true while there are more frames to be played - bool hasMoreFrames(); - - /// Called when the Playable begins to be played - void start(); -} - -/** - Playable implementation for DCAFiles -*/ -class DCAPlayable : Playable { - private { - DCAFile file; - - size_t frameIndex; - } - - this(DCAFile file) { - this.file = file; - } - - // TODO: Don't hardcode this - const short getFrameDuration() { - return 20; - } - - const short getFrameSize() { - return 960; - } - - bool hasMoreFrames() { - return this.frameIndex + 1 < this.file.frames.length; - } - - ubyte[] nextFrame() { - this.frameIndex++; - return this.file.frames[this.frameIndex - 1].data; - } - - void start() {} -} - -interface PlaylistProvider { - bool hasNext(); - Playable getNext(); -} - -class Playlist : Playable { - PlaylistProvider provider; - Playable current; - - this(PlaylistProvider provider) { - this.provider = provider; - } - - const short getFrameDuration() { - return this.current.getFrameDuration(); - } - - const short getFrameSize() { - return this.current.getFrameSize(); - } - - bool hasMoreFrames() { - if (!this.current) return false; - if (this.current.hasMoreFrames()) return true; - if (this.provider.hasNext()) return true; - return false; - } - - ubyte[] nextFrame() { - if (!this.current.hasMoreFrames()) { - if (this.provider.hasNext()) { - this.current = this.provider.getNext(); - } else{ - this.current = null; - } - } - - return this.current.nextFrame(); - } - - void start() { - this.next(); - } - - void next() { - if (this.provider.hasNext()) { - this.current = this.provider.getNext(); - } else { - this.current = null; - } - } -} - -/** - Simple Playlist provider. -*/ -class SimplePlaylistProvider : PlaylistProvider { - private { - Playable[] playlist; - } - - this(Playable[] playlist) { - this.playlist = playlist; - } - - bool hasNext() { - return (this.playlist.length > 0); - } - - Playable getNext() { - assert(this.hasNext(), "No next Playable for SimplePlaylistProvider"); - Playable next = this.playlist[0]; - this.playlist = this.playlist[1..$]; - return next; - } - - @property size_t length() { - return this.playlist.length; - } - - void add(Playable p) { - this.playlist ~= p; - } - - void empty() { - this.playlist = []; - } -} diff --git a/src/dscord/voice/youtubedl.d b/src/dscord/voice/youtubedl.d deleted file mode 100644 index 2719584..0000000 --- a/src/dscord/voice/youtubedl.d +++ /dev/null @@ -1,124 +0,0 @@ -/** - Set of utilties for interfacing with the youtube-dl command line program. -*/ - -module dscord.voice.youtubedl; - -import dcad.types : DCAFile, rawReadFramesFromFile; -import vibe.core.core, - vibe.core.concurrency; - -import dscord.types, - dscord.util.process; - -shared struct WorkerState { -} - -bool maybeSendCompat(T)(Task dest, T data) { - if (!dest.running) return false; - dest.sendCompat(data); - return true; -} - -class YoutubeDL { - static void infoWorker(Task parent, string url) { - auto proc = new Process([ - "youtube-dl", - "-i", - "-j", - "-4", - "--quiet", - "--no-check-certificate", - "--no-warnings", - "--audio-format", "mp3", - "--youtube-skip-dash-manifest", - url]); - - shared string[] lines; - while (!proc.stdout.eof()) { - if (!parent.maybeSendCompat!string(proc.stdout.readln())) { - proc.wait(); - return; - } - } - - parent.maybeSendCompat(null); - - // Let the process terminate - proc.wait(); - } - - /** - Loads songs from a given youtube-dl compatible URL, calling a delegate with - each song. This function is useful for downloading large playlists where - waiting for all the songs to be processed takes a long time. When downloading - is completed, the delegate `complete` will be called with the total number of - songs downloaded/pasred. - - Params: - url = url of playlist or song to download - cb = delegate taking a VibeJSON object for each song downloaded from the URL. - complete = delegate taking a size_t, called when completed (with the total - number of downloaded songs) - */ - static void getInfoAsync(string url, void delegate(VibeJSON) cb, void delegate(size_t) complete=null) { - Task worker = runWorkerTaskH(&YoutubeDL.infoWorker, Task.getThis, url); - - size_t count = 0; - while (true) { - try { - string line = receiveOnlyCompat!(string); - runTask(cb, parseJsonString(line)); - count += 1; - } catch (MessageMismatch e) { - break; - } catch (Exception e) {} - } - - if (complete) complete(count); - } - - /** - Returns a VibeJSON object with information for a given URL. - */ - static VibeJSON[] getInfo(string url) { - VibeJSON[] result; - - Task worker = runWorkerTaskH(&YoutubeDL.infoWorker, Task.getThis, url); - - while (true) { - try { - string line = receiveOnlyCompat!(string); - result ~= parseJsonString(line); - } catch (MessageMismatch e) { - break; - } catch (Exception e) {} - } - - return result; - } - - static void downloadWorker(Task parent, string url) { - auto chain = new ProcessChain(). - run(["youtube-dl", "-v", "-f", "bestaudio", "-o", "-", url]). - run(["ffmpeg", "-i", "pipe:0", "-f", "s16le", "-ar", "48000", "-ac", "2", "pipe:1", "-vol", "100"]). - run(["dcad"]); - - shared ubyte[][] frames = cast(shared ubyte[][])rawReadFramesFromFile(chain.end); - parent.maybeSendCompat!(shared ubyte[][])(frames); - - // Let the process chain terminate - chain.wait(); - } - - /** - Downloads and encodes a given URL into a playable format. This function spawns - a new worker thread to download and encode a given youtube-dl compatabile - URL. - */ - static DCAFile download(string url) { - Task worker = runWorkerTaskH(&YoutubeDL.downloadWorker, Task.getThis, url); - auto frames = receiveOnlyCompat!(shared ubyte[][])(); - return new DCAFile(cast(ubyte[][])frames); - } -}