diff --git a/bot.lua b/bot.lua new file mode 100644 index 0000000..7cfb11e --- /dev/null +++ b/bot.lua @@ -0,0 +1,38 @@ +local discordia = require('discordia') +local client = discordia.Client() +seed = os.time() +math.randomseed(seed) + +client:on('ready', function() + print('Logged in as '.. client.user.username) +end) + +client:on('messageCreate', function(message) + if message.content == '!ping' then + message.channel:send('Pong!') + end +end) + +client:on('messageCreate', function(message) + if message.content == '!roll' then + dice = math.random(20) + message.channel:send('You rolled a ' .. dice .. ' out of 20') + if dice==20 then + message.channel:send('Nat 20! Crititcal Hit') + print('nat20check') + else if dice==1 then + message.channel:send('Nat 1! Critical Miss!') + print('nat1check') + end + end + end +end) + +client:on('messageCreate', function(message) + if message.content == '!time' then + message.channel:send('The current time in military time is ' .. os.date() .. ', atleast in Chicago!') + end +end) + + +client:run('Bot ODczMjU1Mjk2MDI0MzIyMDU5.YQ1wXg.BbXq1fu-4nlG95EkLkHujVEObG4') diff --git a/deps/base64.lua b/deps/base64.lua new file mode 100644 index 0000000..abe3191 --- /dev/null +++ b/deps/base64.lua @@ -0,0 +1,114 @@ +--[[lit-meta + name = "creationix/base64" + description = "A pure lua implemention of base64 using bitop" + tags = {"crypto", "base64", "bitop"} + version = "2.0.0" + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local bit = require 'bit' +local rshift = bit.rshift +local lshift = bit.lshift +local bor = bit.bor +local band = bit.band +local char = string.char +local byte = string.byte +local concat = table.concat +local codes = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' + +-- Loop over input 3 bytes at a time +-- a,b,c are 3 x 8-bit numbers +-- they are encoded into groups of 4 x 6-bit numbers +-- aaaaaa aabbbb bbbbcc cccccc +-- if there is no c, then pad the 4th with = +-- if there is also no b then pad the 3rd with = +local function base64Encode(str) + local parts = {} + local j = 1 + for i = 1, #str, 3 do + local a, b, c = byte(str, i, i + 2) + parts[j] = char( + -- Higher 6 bits of a + byte(codes, rshift(a, 2) + 1), + -- Lower 2 bits of a + high 4 bits of b + byte(codes, bor( + lshift(band(a, 3), 4), + b and rshift(b, 4) or 0 + ) + 1), + -- Low 4 bits of b + High 2 bits of c + b and byte(codes, bor( + lshift(band(b, 15), 2), + c and rshift(c, 6) or 0 + ) + 1) or 61, -- 61 is '=' + -- Lower 6 bits of c + c and byte(codes, band(c, 63) + 1) or 61 -- 61 is '=' + ) + j = j + 1 + end + return concat(parts) +end + +-- Reverse map from character code to 6-bit integer +local map = {} +for i = 1, #codes do + map[byte(codes, i)] = i - 1 +end + +-- loop over input 4 characters at a time +-- The characters are mapped to 4 x 6-bit integers a,b,c,d +-- They need to be reassalbled into 3 x 8-bit bytes +-- aaaaaabb bbbbcccc ccdddddd +-- if d is padding then there is no 3rd byte +-- if c is padding then there is no 2nd byte +local function base64Decode(data) + local bytes = {} + local j = 1 + for i = 1, #data, 4 do + local a = map[byte(data, i)] + local b = map[byte(data, i + 1)] + local c = map[byte(data, i + 2)] + local d = map[byte(data, i + 3)] + + -- higher 6 bits are the first char + -- lower 2 bits are upper 2 bits of second char + bytes[j] = char(bor(lshift(a, 2), rshift(b, 4))) + + -- if the third char is not padding, we have a second byte + if c < 64 then + -- high 4 bits come from lower 4 bits in b + -- low 4 bits come from high 4 bits in c + bytes[j + 1] = char(bor(lshift(band(b, 0xf), 4), rshift(c, 2))) + + -- if the fourth char is not padding, we have a third byte + if d < 64 then + -- Upper 2 bits come from Lower 2 bits of c + -- Lower 6 bits come from d + bytes[j + 2] = char(bor(lshift(band(c, 3), 6), d)) + end + end + j = j + 3 + end + return concat(bytes) +end + +assert(base64Encode("") == "") +assert(base64Encode("f") == "Zg==") +assert(base64Encode("fo") == "Zm8=") +assert(base64Encode("foo") == "Zm9v") +assert(base64Encode("foob") == "Zm9vYg==") +assert(base64Encode("fooba") == "Zm9vYmE=") +assert(base64Encode("foobar") == "Zm9vYmFy") + +assert(base64Decode("") == "") +assert(base64Decode("Zg==") == "f") +assert(base64Decode("Zm8=") == "fo") +assert(base64Decode("Zm9v") == "foo") +assert(base64Decode("Zm9vYg==") == "foob") +assert(base64Decode("Zm9vYmE=") == "fooba") +assert(base64Decode("Zm9vYmFy") == "foobar") + +return { + encode = base64Encode, + decode = base64Decode, +} diff --git a/deps/coro-channel.lua b/deps/coro-channel.lua new file mode 100644 index 0000000..90ae107 --- /dev/null +++ b/deps/coro-channel.lua @@ -0,0 +1,190 @@ +--[[lit-meta + name = "creationix/coro-channel" + version = "3.0.3" + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-channel.lua" + description = "An adapter for wrapping uv streams as coro-streams." + tags = {"coro", "adapter"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +-- local p = require('pretty-print').prettyPrint + +local function assertResume(thread, ...) + local success, err = coroutine.resume(thread, ...) + if not success then + error(debug.traceback(thread, err), 0) + end +end + +local function makeCloser(socket) + local closer = { + read = false, + written = false, + errored = false, + } + + local closed = false + + local function close() + if closed then return end + closed = true + if not closer.readClosed then + closer.readClosed = true + if closer.onClose then + closer.onClose() + end + end + if not socket:is_closing() then + socket:close() + end + end + + closer.close = close + + function closer.check() + if closer.errored or (closer.read and closer.written) then + return close() + end + end + + return closer +end + +local function makeRead(socket, closer) + local paused = true + + local queue = {} + local tindex = 0 + local dindex = 0 + + local function dispatch(data) + + -- p("<-", data[1]) + + if tindex > dindex then + local thread = queue[dindex] + queue[dindex] = nil + dindex = dindex + 1 + assertResume(thread, unpack(data)) + else + queue[dindex] = data + dindex = dindex + 1 + if not paused then + paused = true + assert(socket:read_stop()) + end + end + end + + closer.onClose = function () + if not closer.read then + closer.read = true + return dispatch {nil, closer.errored} + end + end + + local function onRead(err, chunk) + if err then + closer.errored = err + return closer.check() + end + if not chunk then + if closer.read then return end + closer.read = true + dispatch {} + return closer.check() + end + return dispatch {chunk} + end + + local function read() + if dindex > tindex then + local data = queue[tindex] + queue[tindex] = nil + tindex = tindex + 1 + return unpack(data) + end + if paused then + paused = false + assert(socket:read_start(onRead)) + end + queue[tindex] = coroutine.running() + tindex = tindex + 1 + return coroutine.yield() + end + + -- Auto use wrapper library for backwards compat + return read +end + +local function makeWrite(socket, closer) + + local function wait() + local thread = coroutine.running() + return function (err) + assertResume(thread, err) + end + end + + local function write(chunk) + if closer.written then + return nil, "already shutdown" + end + + -- p("->", chunk) + + if chunk == nil then + closer.written = true + closer.check() + local success, err = socket:shutdown(wait()) + if not success then + return nil, err + end + err = coroutine.yield() + return not err, err + end + + local success, err = socket:write(chunk, wait()) + if not success then + closer.errored = err + closer.check() + return nil, err + end + err = coroutine.yield() + return not err, err + end + + return write +end + +local function wrapRead(socket) + local closer = makeCloser(socket) + closer.written = true + return makeRead(socket, closer), closer.close +end + +local function wrapWrite(socket) + local closer = makeCloser(socket) + closer.read = true + return makeWrite(socket, closer), closer.close +end + +local function wrapStream(socket) + assert(socket + and socket.write + and socket.shutdown + and socket.read_start + and socket.read_stop + and socket.is_closing + and socket.close, "socket does not appear to be a socket/uv_stream_t") + + local closer = makeCloser(socket) + return makeRead(socket, closer), makeWrite(socket, closer), closer.close +end + +return { + wrapRead = wrapRead, + wrapWrite = wrapWrite, + wrapStream = wrapStream, +} diff --git a/deps/coro-http.lua b/deps/coro-http.lua new file mode 100644 index 0000000..8873ae7 --- /dev/null +++ b/deps/coro-http.lua @@ -0,0 +1,206 @@ +--[[lit-meta + name = "creationix/coro-http" + version = "3.2.2" + dependencies = { + "creationix/coro-net@3.3.0", + "luvit/http-codec@3.0.0" + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-http.lua" + description = "An coro style http(s) client and server helper." + tags = {"coro", "http"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local httpCodec = require('http-codec') +local net = require('coro-net') + +local function createServer(host, port, onConnect) + return net.createServer({ + host = host, + port = port, + encoder = httpCodec.encoder, + decoder = httpCodec.decoder, + }, function (read, write, socket) + for head in read do + local parts = {} + for part in read do + if #part > 0 then + parts[#parts + 1] = part + else + break + end + end + local body = table.concat(parts) + head, body = onConnect(head, body, socket) + write(head) + if body then write(body) end + write("") + if not head.keepAlive then break end + end + write() + end) +end + +local function parseUrl(url) + local protocol, host, hostname, port, path = url:match("^(https?:)//(([^/:]+):?([0-9]*))(/?.*)$") + if not protocol then error("Not a valid http url: " .. url) end + local tls = protocol == "https:" + port = port and tonumber(port) or (tls and 443 or 80) + if path == "" then path = "/" end + return { + tls = tls, + host = host, + hostname = hostname, + port = port, + path = path + } +end + +local connections = {} + +local function getConnection(host, port, tls, timeout) + for i = #connections, 1, -1 do + local connection = connections[i] + if connection.host == host and connection.port == port and connection.tls == tls then + table.remove(connections, i) + -- Make sure the connection is still alive before reusing it. + if not connection.socket:is_closing() then + connection.reused = true + connection.socket:ref() + return connection + end + end + end + local read, write, socket, updateDecoder, updateEncoder = assert(net.connect { + host = host, + port = port, + tls = tls, + timeout = timeout, + encoder = httpCodec.encoder, + decoder = httpCodec.decoder + }) + return { + socket = socket, + host = host, + port = port, + tls = tls, + read = read, + write = write, + updateEncoder = updateEncoder, + updateDecoder = updateDecoder, + reset = function () + -- This is called after parsing the response head from a HEAD request. + -- If you forget, the codec might hang waiting for a body that doesn't exist. + updateDecoder(httpCodec.decoder()) + end + } +end + +local function saveConnection(connection) + if connection.socket:is_closing() then return end + connections[#connections + 1] = connection + connection.socket:unref() +end + +local function request(method, url, headers, body, customOptions) + -- customOptions = { timeout = number, followRedirects = boolean } + local options = {} + if type(customOptions) == "number" then + -- Ensure backwards compatibility, where customOptions used to just be timeout + options.timeout = customOptions + else + options = customOptions or {} + end + options.followRedirects = options.followRedirects == nil and true or options.followRedirects -- Follow any redirects, Default: true + + local uri = parseUrl(url) + local connection = getConnection(uri.hostname, uri.port, uri.tls, options.timeout) + local read = connection.read + local write = connection.write + + local req = { + method = method, + path = uri.path, + {"Host", uri.host} + } + local contentLength + local chunked + if headers then + for i = 1, #headers do + local key, value = unpack(headers[i]) + key = key:lower() + if key == "content-length" then + contentLength = value + elseif key == "content-encoding" and value:lower() == "chunked" then + chunked = true + end + req[#req + 1] = headers[i] + end + end + + if type(body) == "string" then + if not chunked and not contentLength then + req[#req + 1] = {"Content-Length", #body} + end + end + + write(req) + if body then write(body) end + local res = read() + if not res then + if not connection.socket:is_closing() then + connection.socket:close() + end + -- If we get an immediate close on a reused socket, try again with a new socket. + -- TODO: think about if this could resend requests with side effects and cause + -- them to double execute in the remote server. + if connection.reused then + return request(method, url, headers, body) + end + error("Connection closed") + end + + body = {} + if req.method == "HEAD" then + connection.reset() + else + while true do + local item = read() + if not item then + res.keepAlive = false + break + end + if #item == 0 then + break + end + body[#body + 1] = item + end + end + + if res.keepAlive then + saveConnection(connection) + else + write() + end + + -- Follow redirects + if method == "GET" and (res.code == 302 or res.code == 307) and options.followRedirects then + for i = 1, #res do + local key, location = unpack(res[i]) + if key:lower() == "location" then + return request(method, location, headers) + end + end + end + + return res, table.concat(body) +end + +return { + createServer = createServer, + parseUrl = parseUrl, + getConnection = getConnection, + saveConnection = saveConnection, + request = request, +} diff --git a/deps/coro-net.lua b/deps/coro-net.lua new file mode 100644 index 0000000..b621bbe --- /dev/null +++ b/deps/coro-net.lua @@ -0,0 +1,196 @@ +--[[lit-meta + name = "creationix/coro-net" + version = "3.3.0" + dependencies = { + "creationix/coro-channel@3.0.0", + "creationix/coro-wrapper@3.0.0", + } + optionalDependencies = { + "luvit/secure-socket@1.0.0" + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-net.lua" + description = "An coro style client and server helper for tcp and pipes." + tags = {"coro", "tcp", "pipe", "net"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local uv = require('uv') +local wrapStream = require('coro-channel').wrapStream +local wrapper = require('coro-wrapper') +local merger = wrapper.merger +local decoder = wrapper.decoder +local encoder = wrapper.encoder +local secureSocket -- Lazy required from "secure-socket" on first use. + +local function assertResume(thread, ...) + local success, err = coroutine.resume(thread, ...) + if not success then + error(debug.traceback(thread, err), 0) + end +end + +local function makeCallback(timeout) + local thread = coroutine.running() + local timer, done + if timeout then + timer = uv.new_timer() + timer:start(timeout, 0, function () + if done then return end + done = true + timer:close() + return assertResume(thread, nil, "timeout") + end) + end + return function (err, data) + if done then return end + done = true + if timer then timer:close() end + if err then + return assertResume(thread, nil, err) + end + return assertResume(thread, data or true) + end +end + +local function normalize(options, server) + local t = type(options) + if t == "string" then + options = {path=options} + elseif t == "number" then + options = {port=options} + elseif t ~= "table" then + assert("Net options must be table, string, or number") + end + if options.port or options.host then + options.isTcp = true + options.host = options.host or "127.0.0.1" + assert(options.port, "options.port is required for tcp connections") + elseif options.path then + options.isTcp = false + else + error("Must set either options.path or options.port") + end + if options.tls == true then + options.tls = {} + end + if options.tls then + if server then + options.tls.server = true + assert(options.tls.cert, "TLS servers require a certificate") + assert(options.tls.key, "TLS servers require a key") + else + options.tls.server = false + options.tls.servername = options.host + end + end + return options +end + +local function connect(options) + local socket, success, err + options = normalize(options) + if options.isTcp then + success, err = uv.getaddrinfo(options.host, options.port, { + socktype = options.socktype or "stream", + family = options.family or "inet", + }, makeCallback(options.timeout)) + if not success then return nil, err end + local res + res, err = coroutine.yield() + if not res then return nil, err end + socket = uv.new_tcp() + socket:connect(res[1].addr, res[1].port, makeCallback(options.timeout)) + else + socket = uv.new_pipe(false) + socket:connect(options.path, makeCallback(options.timeout)) + end + success, err = coroutine.yield() + if not success then return nil, err end + local dsocket + if options.tls then + if not secureSocket then secureSocket = require('secure-socket') end + dsocket, err = secureSocket(socket, options.tls) + if not dsocket then + return nil, err + end + else + dsocket = socket + end + + local read, write, close = wrapStream(dsocket) + local updateDecoder, updateEncoder + if options.scan then + -- TODO: Should we expose updateScan somehow? + read = merger(read, options.scan) + end + if options.decoder then + read, updateDecoder = decoder(read, options.decoder()) + elseif options.decode then + read, updateDecoder = decoder(read, options.decode) + end + if options.encoder then + write, updateEncoder = encoder(write, options.encoder()) + elseif options.encode then + write, updateEncoder = encoder(write, options.encode) + end + return read, write, dsocket, updateDecoder, updateEncoder, close +end + +local function createServer(options, onConnect) + local server + options = normalize(options, true) + if options.isTcp then + server = uv.new_tcp() + assert(server:bind(options.host, options.port)) + else + server = uv.new_pipe(false) + assert(server:bind(options.path)) + end + assert(server:listen(256, function (err) + assert(not err, err) + local socket = options.isTcp and uv.new_tcp() or uv.new_pipe(false) + server:accept(socket) + coroutine.wrap(function () + local success, failure = xpcall(function () + local dsocket + if options.tls then + if not secureSocket then secureSocket = require('secure-socket') end + dsocket = assert(secureSocket(socket, options.tls)) + dsocket.socket = socket + else + dsocket = socket + end + + local read, write = wrapStream(dsocket) + local updateDecoder, updateEncoder + if options.scan then + -- TODO: should we expose updateScan somehow? + read = merger(read, options.scan) + end + if options.decoder then + read, updateDecoder = decoder(read, options.decoder()) + elseif options.decode then + read, updateDecoder = decoder(read, options.decode) + end + if options.encoder then + write, updateEncoder = encoder(write, options.encoder()) + elseif options.encode then + write, updateEncoder = encoder(write, options.encode) + end + + return onConnect(read, write, dsocket, updateDecoder, updateEncoder) + end, debug.traceback) + if not success then + print(failure) + end + end)() + end)) + return server +end + +return { + makeCallback = makeCallback, + connect = connect, + createServer = createServer, +} diff --git a/deps/coro-websocket.lua b/deps/coro-websocket.lua new file mode 100644 index 0000000..d225bef --- /dev/null +++ b/deps/coro-websocket.lua @@ -0,0 +1,196 @@ +--[[lit-meta + name = "creationix/coro-websocket" + version = "3.1.1" + dependencies = { + "luvit/http-codec@3.0.0", + "creationix/websocket-codec@3.0.0", + "creationix/coro-net@3.3.0", + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-websocket.lua" + description = "Websocket helpers assuming coro style I/O." + tags = {"coro", "websocket"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local uv = require('uv') +local httpCodec = require('http-codec') +local websocketCodec = require('websocket-codec') +local net = require('coro-net') + +local function parseUrl(url) + local protocol, host, port, pathname = string.match(url, "^(wss?)://([^:/]+):?(%d*)(/?[^#?]*)") + local tls + if protocol == "ws" then + port = tonumber(port) or 80 + tls = false + elseif protocol == "wss" then + port = tonumber(port) or 443 + tls = true + else + return nil, "Sorry, only ws:// or wss:// protocols supported" + end + return { + host = host, + port = port, + tls = tls, + pathname = pathname + } +end + +local function wrapIo(rawRead, rawWrite, options) + + local closeSent = false + + local timer + + local function cleanup() + if timer then + if not timer:is_closing() then + timer:close() + end + timer = nil + end + end + + local function write(message) + if message then + message.mask = options.mask + if message.opcode == 8 then + closeSent = true + rawWrite(message) + cleanup() + return rawWrite() + end + else + if not closeSent then + return write({ + opcode = 8, + payload = "" + }) + end + end + return rawWrite(message) + end + + + local function read() + while true do + local message = rawRead() + if not message then + return cleanup() + end + if message.opcode < 8 then + return message + end + if not closeSent then + if message.opcode == 8 then + write { + opcode = 8, + payload = message.payload + } + elseif message.opcode == 9 then + write { + opcode = 10, + payload = message.payload + } + end + return message + end + end + end + + if options.heartbeat then + local interval = options.heartbeat + timer = uv.new_timer() + timer:unref() + timer:start(interval, interval, function () + coroutine.wrap(function () + local success, err = write { + opcode = 10, + payload = "" + } + if not success then + timer:close() + print(err) + end + end)() + end) + end + + return read, write +end + +-- options table to configure connection +-- options.path +-- options.host +-- options.port +-- options.tls +-- options.pathname +-- options.subprotocol +-- options.headers (as list of header/value pairs) +-- options.timeout +-- options.heartbeat +-- returns res, read, write (res.socket has socket) +local function connect(options) + options = options or {} + local config = { + path = options.path, + host = options.host, + port = options.port, + tls = options.tls, + encoder = httpCodec.encoder, + decoder = httpCodec.decoder, + } + local read, write, socket, updateDecoder, updateEncoder + = net.connect(config, options.timeout or 10000) + if not read then + return nil, write + end + + local res + + local success, err = websocketCodec.handshake({ + host = options.host, + path = options.pathname, + protocol = options.subprotocol + }, function (req) + local headers = options.headers + if headers then + for i = 1, #headers do + req[#req + 1] = headers[i] + end + end + write(req) + res = read() + if not res then error("Missing server response") end + if res.code == 400 then + -- p { req = req, res = res } + local reason = read() or res.reason + error("Invalid request: " .. reason) + end + return res + end) + if not success then + return nil, err + end + + -- Upgrade the protocol to websocket + updateDecoder(websocketCodec.decode) + updateEncoder(websocketCodec.encode) + + read, write = wrapIo(read, write, { + mask = true, + heartbeat = options.heartbeat + }) + + res.socket = socket + return res, read, write + +end + +return { + parseUrl = parseUrl, + wrapIo = wrapIo, + connect = connect, +} diff --git a/deps/coro-wrapper.lua b/deps/coro-wrapper.lua new file mode 100644 index 0000000..c43671e --- /dev/null +++ b/deps/coro-wrapper.lua @@ -0,0 +1,151 @@ +--[[lit-meta + name = "creationix/coro-wrapper" + version = "3.1.0" + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-wrapper.lua" + description = "An adapter for applying decoders to coro-streams." + tags = {"coro", "decoder", "adapter"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local concat = table.concat +local sub = string.sub + +-- Merger allows for effecient merging of many chunks. +-- The scan function returns truthy when the chunk contains a useful delimeter +-- Or in other words, when there is enough data to flush to the decoder. +-- merger(read, scan) -> read, updateScan +-- read() -> chunk or nil +-- scan(chunk) -> should_flush +-- updateScan(scan) +local function merger(read, scan) + local parts = {} + + -- Return a new read function that combines chunks smartly + return function () + + while true do + -- Read the next event from upstream. + local chunk = read() + + -- We got an EOS (end of stream) + if not chunk then + -- If there is nothing left to flush, emit EOS here. + if #parts == 0 then return end + + -- Flush the buffer + chunk = concat(parts) + parts = {} + return chunk + end + + -- Accumulate the chunk + parts[#parts + 1] = chunk + + -- Flush the buffer if scan tells us to. + if scan(chunk) then + chunk = concat(parts) + parts = {} + return chunk + end + + end + end, + + -- This is used to update or disable the scan function. It's useful for + -- protocols that change mid-stream (like HTTP upgrades in websockets) + function (newScan) + scan = newScan + end +end + +-- Decoder takes in a read function and a decode function and returns a new +-- read function that emits decoded events. When decode returns `nil` it means +-- that it needs more data before it can parse. The index output in decode is +-- the index to start the next decode. If output index if nil it means nothing +-- is leftover and next decode starts fresh. +-- decoder(read, decode) -> read, updateDecode +-- read() -> chunk or nil +-- decode(chunk, index) -> nil or (data, index) +-- updateDecode(Decode) +local function decoder(read, decode) + local buffer, index + local want = true + return function () + + while true do + -- If there isn't enough data to decode then get more data. + if want then + local chunk = read() + if buffer then + -- If we had leftover data in the old buffer, trim it down. + if index > 1 then + buffer = sub(buffer, index) + index = 1 + end + if chunk then + -- Concatenate the chunk with the old data + buffer = buffer .. chunk + end + else + -- If there was no leftover data, set new data in the buffer + if chunk then + buffer = chunk + index = 1 + else + buffer = nil + index = nil + end + end + end + + -- Return nil if the buffer is empty + if buffer == '' or buffer == nil then + return nil + end + + -- If we have data, lets try to decode it + local item, newIndex = decode(buffer, index) + + want = not newIndex + if item or newIndex then + -- There was enough data to emit an event! + if newIndex then + assert(type(newIndex) == "number", "index must be a number if set") + -- There was leftover data + index = newIndex + else + want = true + -- There was no leftover data + buffer = nil + index = nil + end + -- Emit the event + return item + end + + + end + end, + function (newDecode) + decode = newDecode + end +end + +local function encoder(write, encode) + return function (item) + if not item then + return write() + end + return write(encode(item)) + end, + function (newEncode) + encode = newEncode + end +end + +return { + merger = merger, + decoder = decoder, + encoder = encoder, +} diff --git a/deps/discordia/docgen.lua b/deps/discordia/docgen.lua new file mode 100644 index 0000000..fd1dc48 --- /dev/null +++ b/deps/discordia/docgen.lua @@ -0,0 +1,373 @@ +--[=[ +@c ClassName [x base_1 x base_2 ... x base_n] +@t tag +@mt methodTag (applies to all class methods) +@p parameterName type +@op optionalParameterName type +@d description+ +]=] + +--[=[ +@m methodName +@t tag +@p parameterName type +@op optionalParameterName type +@r return +@d description+ +]=] + +--[=[ +@p propertyName type description+ +]=] + +local fs = require('fs') +local pathjoin = require('pathjoin') + +local insert, sort, concat = table.insert, table.sort, table.concat +local format = string.format +local pathJoin = pathjoin.pathJoin + +local function scan(dir) + for fileName, fileType in fs.scandirSync(dir) do + local path = pathJoin(dir, fileName) + if fileType == 'file' then + coroutine.yield(path) + else + scan(path) + end + end +end + +local function match(s, pattern) -- only useful for one capture + return assert(s:match(pattern), s) +end + +local function gmatch(s, pattern, hash) -- only useful for one capture + local tbl = {} + if hash then + for k in s:gmatch(pattern) do + tbl[k] = true + end + else + for v in s:gmatch(pattern) do + insert(tbl, v) + end + end + return tbl +end + +local function matchType(s) + return s:match('^@(%S+)') +end + +local function matchComments(s) + return s:gmatch('--%[=%[%s*(.-)%s*%]=%]') +end + +local function matchClassName(s) + return match(s, '@c (%S+)') +end + +local function matchMethodName(s) + return match(s, '@m (%S+)') +end + +local function matchDescription(s) + return match(s, '@d (.+)'):gsub('%s+', ' ') +end + +local function matchParents(s) + return gmatch(s, 'x (%S+)') +end + +local function matchReturns(s) + return gmatch(s, '@r (%S+)') +end + +local function matchTags(s) + return gmatch(s, '@t (%S+)', true) +end + +local function matchMethodTags(s) + return gmatch(s, '@mt (%S+)', true) +end + +local function matchProperty(s) + local a, b, c = s:match('@p (%S+) (%S+) (.+)') + return { + name = assert(a, s), + type = assert(b, s), + desc = assert(c, s):gsub('%s+', ' '), + } +end + +local function matchParameters(s) + local ret = {} + for optional, paramName, paramType in s:gmatch('@(o?)p (%S+) (%S+)') do + insert(ret, {paramName, paramType, optional == 'o'}) + end + return ret +end + +local function matchMethod(s) + return { + name = matchMethodName(s), + desc = matchDescription(s), + parameters = matchParameters(s), + returns = matchReturns(s), + tags = matchTags(s), + } +end + +---- + +local docs = {} + +local function newClass() + + local class = { + methods = {}, + statics = {}, + properties = {}, + } + + local function init(s) + class.name = matchClassName(s) + class.parents = matchParents(s) + class.desc = matchDescription(s) + class.parameters = matchParameters(s) + class.tags = matchTags(s) + class.methodTags = matchMethodTags(s) + assert(not docs[class.name], 'duplicate class: ' .. class.name) + docs[class.name] = class + end + + return class, init + +end + +for f in coroutine.wrap(scan), './libs' do + + local d = assert(fs.readFileSync(f)) + + local class, initClass = newClass() + for s in matchComments(d) do + local t = matchType(s) + if t == 'c' then + initClass(s) + elseif t == 'm' then + local method = matchMethod(s) + for k, v in pairs(class.methodTags) do + method.tags[k] = v + end + method.class = class + insert(method.tags.static and class.statics or class.methods, method) + elseif t == 'p' then + insert(class.properties, matchProperty(s)) + end + end + +end + +---- + +local output = 'docs' + +local function link(str) + if type(str) == 'table' then + local ret = {} + for i, v in ipairs(str) do + ret[i] = link(v) + end + return concat(ret, ', ') + else + local ret = {} + for t in str:gmatch('[^/]+') do + insert(ret, docs[t] and format('[[%s]]', t) or t) + end + return concat(ret, '/') + end +end + +local function sorter(a, b) + return a.name < b.name +end + +local function writeHeading(f, heading) + f:write('## ', heading, '\n\n') +end + +local function writeProperties(f, properties) + sort(properties, sorter) + f:write('| Name | Type | Description |\n') + f:write('|-|-|-|\n') + for _, v in ipairs(properties) do + f:write('| ', v.name, ' | ', link(v.type), ' | ', v.desc, ' |\n') + end + f:write('\n') +end + +local function writeParameters(f, parameters) + f:write('(') + local optional + if #parameters > 0 then + for i, param in ipairs(parameters) do + f:write(param[1]) + if i < #parameters then + f:write(', ') + end + if param[3] then + optional = true + end + end + f:write(')\n\n') + if optional then + f:write('| Parameter | Type | Optional |\n') + f:write('|-|-|:-:|\n') + for _, param in ipairs(parameters) do + local o = param[3] and '✔' or '' + f:write('| ', param[1], ' | ', link(param[2]), ' | ', o, ' |\n') + end + f:write('\n') + else + f:write('| Parameter | Type |\n') + f:write('|-|-|\n') + for _, param in ipairs(parameters) do + f:write('| ', param[1], ' | ', link(param[2]), ' |\n') + end + f:write('\n') + end + else + f:write(')\n\n') + end +end + +local methodTags = {} + +methodTags['http'] = 'This method always makes an HTTP request.' +methodTags['http?'] = 'This method may make an HTTP request.' +methodTags['ws'] = 'This method always makes a WebSocket request.' +methodTags['mem'] = 'This method only operates on data in memory.' + +local function checkTags(tbl, check) + for i, v in ipairs(check) do + if tbl[v] then + for j, w in ipairs(check) do + if i ~= j then + if tbl[w] then + return error(string.format('mutually exclusive tags encountered: %s and %s', v, w), 1) + end + end + end + end + end +end + +local function writeMethods(f, methods) + + sort(methods, sorter) + for _, method in ipairs(methods) do + + f:write('### ', method.name) + writeParameters(f, method.parameters) + f:write(method.desc, '\n\n') + + local tags = method.tags + checkTags(tags, {'http', 'http?', 'mem'}) + checkTags(tags, {'ws', 'mem'}) + + for k in pairs(tags) do + if k ~= 'static' then + assert(methodTags[k], k) + f:write('*', methodTags[k], '*\n\n') + end + end + + f:write('**Returns:** ', link(method.returns), '\n\n----\n\n') + + end + +end + +if not fs.existsSync(output) then + fs.mkdirSync(output) +end + +local function collectParents(parents, k, ret, seen) + ret = ret or {} + seen = seen or {} + for _, parent in ipairs(parents) do + parent = docs[parent] + if parent then + for _, v in ipairs(parent[k]) do + if not seen[v] then + seen[v] = true + insert(ret, v) + end + end + end + collectParents(parent.parents, k, ret, seen) + end + return ret +end + +for _, class in pairs(docs) do + + local f = io.open(pathJoin(output, class.name .. '.md'), 'w') + + local parents = class.parents + local parentLinks = link(parents) + + if next(parents) then + f:write('#### *extends ', parentLinks, '*\n\n') + end + + f:write(class.desc, '\n\n') + + checkTags(class.tags, {'ui', 'abc'}) + if class.tags.ui then + writeHeading(f, 'Constructor') + f:write('### ', class.name) + writeParameters(f, class.parameters) + elseif class.tags.abc then + f:write('*This is an abstract base class. Direct instances should never exist.*\n\n') + else + f:write('*Instances of this class should not be constructed by users.*\n\n') + end + + local properties = collectParents(parents, 'properties') + if next(properties) then + writeHeading(f, 'Properties Inherited From ' .. parentLinks) + writeProperties(f, properties) + end + + if next(class.properties) then + writeHeading(f, 'Properties') + writeProperties(f, class.properties) + end + + local statics = collectParents(parents, 'statics') + if next(statics) then + writeHeading(f, 'Static Methods Inherited From ' .. parentLinks) + writeMethods(f, statics) + end + + local methods = collectParents(parents, 'methods') + if next(methods) then + writeHeading(f, 'Methods Inherited From ' .. parentLinks) + writeMethods(f, methods) + end + + if next(class.statics) then + writeHeading(f, 'Static Methods') + writeMethods(f, class.statics) + end + + if next(class.methods) then + writeHeading(f, 'Methods') + writeMethods(f, class.methods) + end + + f:close() + +end diff --git a/deps/discordia/examples/appender.lua b/deps/discordia/examples/appender.lua new file mode 100644 index 0000000..291d85c --- /dev/null +++ b/deps/discordia/examples/appender.lua @@ -0,0 +1,28 @@ +local discordia = require("discordia") +local client = discordia.Client() + +local lines = {} -- blank table of messages + +client:on("ready", function() -- bot is ready + print("Logged in as " .. client.user.username) +end) + +client:on("messageCreate", function(message) + + local content = message.content + local author = message.author + + if author == client.user then return end -- the bot should not append its own messages + + if content == "!lines" then -- if the lines command is activated + message.channel:send { + file = {"lines.txt", table.concat(lines, "\n")} -- concatenate and send the collected lines in a file + } + lines = {} -- empty the lines table + else -- if the lines command is NOT activated + table.insert(lines, content) -- append the message as a new line + end + +end) + +client:run("Bot BOT_TOKEN") -- replace BOT_TOKEN with your bot token diff --git a/deps/discordia/examples/basicCommands.lua b/deps/discordia/examples/basicCommands.lua new file mode 100644 index 0000000..57498e9 --- /dev/null +++ b/deps/discordia/examples/basicCommands.lua @@ -0,0 +1,25 @@ +local discordia = require("discordia") +local client = discordia.Client() + +discordia.extensions() -- load all helpful extensions + +client:on("ready", function() -- bot is ready + print("Logged in as " .. client.user.username) +end) + +client:on("messageCreate", function(message) + + local content = message.content + local args = content:split(" ") -- split all arguments into a table + + if args[1] == "!ping" then + message:reply("Pong!") + elseif args[1] == "!echo" then + table.remove(args, 1) -- remove the first argument (!echo) from the table + message:reply(table.concat(args, " ")) -- concatenate the arguments into a string, then reply with it + end + +end) + + +client:run("Bot BOT_TOKEN") -- replace BOT_TOKEN with your bot token diff --git a/deps/discordia/examples/embed.lua b/deps/discordia/examples/embed.lua new file mode 100644 index 0000000..3f546f6 --- /dev/null +++ b/deps/discordia/examples/embed.lua @@ -0,0 +1,45 @@ +local discordia = require("discordia") +local client = discordia.Client() + + +client:on("ready", function() -- bot is ready + print("Logged in as " .. client.user.username) +end) + +client:on("messageCreate", function(message) + + local content = message.content + local author = message.author + + if content == "!embed" then + message:reply { + embed = { + title = "Embed Title", + description = "Here is my fancy description!", + author = { + name = author.username, + icon_url = author.avatarURL + }, + fields = { -- array of fields + { + name = "Field 1", + value = "This is some information", + inline = true + }, + { + name = "Field 2", + value = "This is some more information", + inline = false + } + }, + footer = { + text = "Created with Discordia" + }, + color = 0x000000 -- hex color code + } + } + end + +end) + +client:run("Bot BOT_TOKEN") -- replace BOT_TOKEN with your bot token diff --git a/deps/discordia/examples/helpCommandExample.lua b/deps/discordia/examples/helpCommandExample.lua new file mode 100644 index 0000000..e22a3e6 --- /dev/null +++ b/deps/discordia/examples/helpCommandExample.lua @@ -0,0 +1,44 @@ +local discordia = require('discordia') +local client = discordia.Client() +discordia.extensions() -- load all helpful extensions + +local prefix = "." +local commands = { + [prefix .. "ping"] = { + description = "Answers with pong.", + exec = function(message) + message.channel:send("Pong!") + end + }, + [prefix .. "hello"] = { + description = "Answers with world.", + exec = function(message) + message.channel:send("world!") + end + } +} + +client:on('ready', function() + p(string.format('Logged in as %s', client.user.username)) +end) + +client:on("messageCreate", function(message) + local args = message.content:split(" ") -- split all arguments into a table + + local command = commands[args[1]] + if command then -- ping or hello + command.exec(message) -- execute the command + end + + if args[1] == prefix.."help" then -- display all the commands + local output = {} + for word, tbl in pairs(commands) do + table.insert(output, "Command: " .. word .. "\nDescription: " .. tbl.description) + end + + message:reply(table.concat(output, "\n\n")) + end +end) + + +client:run("Bot BOT_TOKEN") -- replace BOT_TOKEN with your bot token diff --git a/deps/discordia/examples/pingPong.lua b/deps/discordia/examples/pingPong.lua new file mode 100644 index 0000000..e78e022 --- /dev/null +++ b/deps/discordia/examples/pingPong.lua @@ -0,0 +1,20 @@ +local discordia = require("discordia") +local client = discordia.Client() + +client:on("ready", function() -- bot is ready + print("Logged in as " .. client.user.username) +end) + +client:on("messageCreate", function(message) + + local content = message.content + + if content == "!ping" then + message:reply("Pong!") + elseif content == "!pong" then + message:reply("Ping!") + end + +end) + +client:run("Bot BOT_TOKEN") -- replace BOT_TOKEN with your bot token diff --git a/deps/discordia/init.lua b/deps/discordia/init.lua new file mode 100644 index 0000000..b10704a --- /dev/null +++ b/deps/discordia/init.lua @@ -0,0 +1,18 @@ +return { + class = require('class'), + enums = require('enums'), + extensions = require('extensions'), + package = require('./package.lua'), + Client = require('client/Client'), + Clock = require('utils/Clock'), + Color = require('utils/Color'), + Date = require('utils/Date'), + Deque = require('utils/Deque'), + Emitter = require('utils/Emitter'), + Logger = require('utils/Logger'), + Mutex = require('utils/Mutex'), + Permissions = require('utils/Permissions'), + Stopwatch = require('utils/Stopwatch'), + Time = require('utils/Time'), + storage = {}, +} diff --git a/deps/discordia/libs/class.lua b/deps/discordia/libs/class.lua new file mode 100644 index 0000000..6c83d43 --- /dev/null +++ b/deps/discordia/libs/class.lua @@ -0,0 +1,165 @@ +local format = string.format + +local meta = {} +local names = {} +local classes = {} +local objects = setmetatable({}, {__mode = 'k'}) + +function meta:__call(...) + local obj = setmetatable({}, self) + objects[obj] = true + obj:__init(...) + return obj +end + +function meta:__tostring() + return 'class ' .. self.__name +end + +local default = {} + +function default:__tostring() + return self.__name +end + +function default:__hash() + return self +end + +local function isClass(cls) + return classes[cls] +end + +local function isObject(obj) + return objects[obj] +end + +local function isSubclass(sub, cls) + if isClass(sub) and isClass(cls) then + if sub == cls then + return true + else + for _, base in ipairs(sub.__bases) do + if isSubclass(base, cls) then + return true + end + end + end + end + return false +end + +local function isInstance(obj, cls) + return isObject(obj) and isSubclass(obj.__class, cls) +end + +local function profile() + local ret = setmetatable({}, {__index = function() return 0 end}) + for obj in pairs(objects) do + local name = obj.__name + ret[name] = ret[name] + 1 + end + return ret +end + +local types = {['string'] = true, ['number'] = true, ['boolean'] = true} + +local function _getPrimitive(v) + return types[type(v)] and v or v ~= nil and tostring(v) or nil +end + +local function serialize(obj) + if isObject(obj) then + local ret = {} + for k, v in pairs(obj.__getters) do + ret[k] = _getPrimitive(v(obj)) + end + return ret + else + return _getPrimitive(obj) + end +end + +local rawtype = type +local function type(obj) + return isObject(obj) and obj.__name or rawtype(obj) +end + +return setmetatable({ + + classes = names, + isClass = isClass, + isObject = isObject, + isSubclass = isSubclass, + isInstance = isInstance, + type = type, + profile = profile, + serialize = serialize, + +}, {__call = function(_, name, ...) + + if names[name] then return error(format('Class %q already defined', name)) end + + local class = setmetatable({}, meta) + classes[class] = true + + for k, v in pairs(default) do + class[k] = v + end + + local bases = {...} + local getters = {} + local setters = {} + + for _, base in ipairs(bases) do + for k1, v1 in pairs(base) do + class[k1] = v1 + for k2, v2 in pairs(base.__getters) do + getters[k2] = v2 + end + for k2, v2 in pairs(base.__setters) do + setters[k2] = v2 + end + end + end + + class.__name = name + class.__class = class + class.__bases = bases + class.__getters = getters + class.__setters = setters + + local pool = {} + local n = #pool + + function class:__index(k) + if getters[k] then + return getters[k](self) + elseif pool[k] then + return rawget(self, pool[k]) + else + return class[k] + end + end + + function class:__newindex(k, v) + if setters[k] then + return setters[k](self, v) + elseif class[k] or getters[k] then + return error(format('Cannot overwrite protected property: %s.%s', name, k)) + elseif k:find('_', 1, true) ~= 1 then + return error(format('Cannot write property to object without leading underscore: %s.%s', name, k)) + else + if not pool[k] then + n = n + 1 + pool[k] = n + end + return rawset(self, pool[k], v) + end + end + + names[name] = class + + return class, getters, setters + +end}) diff --git a/deps/discordia/libs/client/API.lua b/deps/discordia/libs/client/API.lua new file mode 100644 index 0000000..f9228d4 --- /dev/null +++ b/deps/discordia/libs/client/API.lua @@ -0,0 +1,711 @@ +local json = require('json') +local timer = require('timer') +local http = require('coro-http') +local package = require('../../package.lua') +local Mutex = require('utils/Mutex') +local endpoints = require('endpoints') + +local request = http.request +local f, gsub, byte = string.format, string.gsub, string.byte +local max, random = math.max, math.random +local encode, decode, null = json.encode, json.decode, json.null +local insert, concat = table.insert, table.concat +local sleep = timer.sleep +local running = coroutine.running + +local BASE_URL = "https://discord.com/api/v7" + +local JSON = 'application/json' +local PRECISION = 'millisecond' +local MULTIPART = 'multipart/form-data;boundary=' +local USER_AGENT = f('DiscordBot (%s, %s)', package.homepage, package.version) + +local majorRoutes = {guilds = true, channels = true, webhooks = true} +local payloadRequired = {PUT = true, PATCH = true, POST = true} + +local function parseErrors(ret, errors, key) + for k, v in pairs(errors) do + if k == '_errors' then + for _, err in ipairs(v) do + insert(ret, f('%s in %s : %s', err.code, key or 'payload', err.message)) + end + else + if key then + parseErrors(ret, v, f(k:find("^[%a_][%a%d_]*$") and '%s.%s' or tonumber(k) and '%s[%d]' or '%s[%q]', key, k)) + else + parseErrors(ret, v, k) + end + end + end + return concat(ret, '\n\t') +end + +local function sub(path) + return not majorRoutes[path] and path .. '/:id' +end + +local function route(method, endpoint) + + -- special case for reactions + if endpoint:find('reactions') then + endpoint = endpoint:match('.*/reactions') + end + + -- remove the ID from minor routes + endpoint = endpoint:gsub('(%a+)/%d+', sub) + + -- special case for message deletions + if method == 'DELETE' then + local i, j = endpoint:find('/channels/%d+/messages') + if i == 1 and j == #endpoint then + endpoint = method .. endpoint + end + end + + return endpoint + +end + +local function generateBoundary(files, boundary) + boundary = boundary or tostring(random(0, 9)) + for _, v in ipairs(files) do + if v[2]:find(boundary, 1, true) then + return generateBoundary(files, boundary .. random(0, 9)) + end + end + return boundary +end + +local function attachFiles(payload, files) + local boundary = generateBoundary(files) + local ret = { + '--' .. boundary, + 'Content-Disposition:form-data;name="payload_json"', + 'Content-Type:application/json\r\n', + payload, + } + for i, v in ipairs(files) do + insert(ret, '--' .. boundary) + insert(ret, f('Content-Disposition:form-data;name="file%i";filename=%q', i, v[1])) + insert(ret, 'Content-Type:application/octet-stream\r\n') + insert(ret, v[2]) + end + insert(ret, '--' .. boundary .. '--') + return concat(ret, '\r\n'), boundary +end + +local mutexMeta = { + __mode = 'v', + __index = function(self, k) + self[k] = Mutex() + return self[k] + end +} + +local function tohex(char) + return f('%%%02X', byte(char)) +end + +local function urlencode(obj) + return (gsub(tostring(obj), '%W', tohex)) +end + +local API = require('class')('API') + +function API:__init(client) + self._client = client + self._mutexes = setmetatable({}, mutexMeta) +end + +function API:authenticate(token) + self._token = token + return self:getCurrentUser() +end + +function API:request(method, endpoint, payload, query, files) + + local _, main = running() + if main then + return error('Cannot make HTTP request outside of a coroutine', 2) + end + + local url = BASE_URL .. endpoint + + if query and next(query) then + url = {url} + for k, v in pairs(query) do + insert(url, #url == 1 and '?' or '&') + insert(url, urlencode(k)) + insert(url, '=') + insert(url, urlencode(v)) + end + url = concat(url) + end + + local req = { + {'User-Agent', USER_AGENT}, + {'X-RateLimit-Precision', PRECISION}, + {'Authorization', self._token}, + } + + if payloadRequired[method] then + payload = payload and encode(payload) or '{}' + if files and next(files) then + local boundary + payload, boundary = attachFiles(payload, files) + insert(req, {'Content-Type', MULTIPART .. boundary}) + else + insert(req, {'Content-Type', JSON}) + end + insert(req, {'Content-Length', #payload}) + end + + local mutex = self._mutexes[route(method, endpoint)] + + mutex:lock() + local data, err, delay = self:commit(method, url, req, payload, 0) + mutex:unlockAfter(delay) + + if data then + return data + else + return nil, err + end + +end + +function API:commit(method, url, req, payload, retries) + + local client = self._client + local options = client._options + local delay = options.routeDelay + + local success, res, msg = pcall(request, method, url, req, payload) + + if not success then + return nil, res, delay + end + + for i, v in ipairs(res) do + res[v[1]:lower()] = v[2] + res[i] = nil + end + + if res['x-ratelimit-remaining'] == '0' then + delay = max(1000 * res['x-ratelimit-reset-after'], delay) + end + + local data = res['content-type'] == JSON and decode(msg, 1, null) or msg + + if res.code < 300 then + + client:debug('%i - %s : %s %s', res.code, res.reason, method, url) + return data, nil, delay + + else + + if type(data) == 'table' then + + local retry + if res.code == 429 then -- TODO: global ratelimiting + delay = data.retry_after + retry = retries < options.maxRetries + elseif res.code == 502 then + delay = delay + random(2000) + retry = retries < options.maxRetries + end + + if retry then + client:warning('%i - %s : retrying after %i ms : %s %s', res.code, res.reason, delay, method, url) + sleep(delay) + return self:commit(method, url, req, payload, retries + 1) + end + + if data.code and data.message then + msg = f('HTTP Error %i : %s', data.code, data.message) + else + msg = 'HTTP Error' + end + if data.errors then + msg = parseErrors({msg}, data.errors) + end + + end + + client:error('%i - %s : %s %s', res.code, res.reason, method, url) + return nil, msg, delay + + end + +end + +-- start of auto-generated methods -- + +function API:getGuildAuditLog(guild_id, query) + local endpoint = f(endpoints.GUILD_AUDIT_LOGS, guild_id) + return self:request("GET", endpoint, nil, query) +end + +function API:getChannel(channel_id) -- not exposed, use cache + local endpoint = f(endpoints.CHANNEL, channel_id) + return self:request("GET", endpoint) +end + +function API:modifyChannel(channel_id, payload) -- Channel:_modify + local endpoint = f(endpoints.CHANNEL, channel_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteChannel(channel_id) -- Channel:delete + local endpoint = f(endpoints.CHANNEL, channel_id) + return self:request("DELETE", endpoint) +end + +function API:getChannelMessages(channel_id, query) -- TextChannel:get[First|Last]Message, TextChannel:getMessages + local endpoint = f(endpoints.CHANNEL_MESSAGES, channel_id) + return self:request("GET", endpoint, nil, query) +end + +function API:getChannelMessage(channel_id, message_id) -- TextChannel:getMessage fallback + local endpoint = f(endpoints.CHANNEL_MESSAGE, channel_id, message_id) + return self:request("GET", endpoint) +end + +function API:createMessage(channel_id, payload, files) -- TextChannel:send + local endpoint = f(endpoints.CHANNEL_MESSAGES, channel_id) + return self:request("POST", endpoint, payload, nil, files) +end + +function API:createReaction(channel_id, message_id, emoji, payload) -- Message:addReaction + local endpoint = f(endpoints.CHANNEL_MESSAGE_REACTION_ME, channel_id, message_id, urlencode(emoji)) + return self:request("PUT", endpoint, payload) +end + +function API:deleteOwnReaction(channel_id, message_id, emoji) -- Message:removeReaction + local endpoint = f(endpoints.CHANNEL_MESSAGE_REACTION_ME, channel_id, message_id, urlencode(emoji)) + return self:request("DELETE", endpoint) +end + +function API:deleteUserReaction(channel_id, message_id, emoji, user_id) -- Message:removeReaction + local endpoint = f(endpoints.CHANNEL_MESSAGE_REACTION_USER, channel_id, message_id, urlencode(emoji), user_id) + return self:request("DELETE", endpoint) +end + +function API:getReactions(channel_id, message_id, emoji, query) -- Reaction:getUsers + local endpoint = f(endpoints.CHANNEL_MESSAGE_REACTION, channel_id, message_id, urlencode(emoji)) + return self:request("GET", endpoint, nil, query) +end + +function API:deleteAllReactions(channel_id, message_id) -- Message:clearReactions + local endpoint = f(endpoints.CHANNEL_MESSAGE_REACTIONS, channel_id, message_id) + return self:request("DELETE", endpoint) +end + +function API:editMessage(channel_id, message_id, payload) -- Message:_modify + local endpoint = f(endpoints.CHANNEL_MESSAGE, channel_id, message_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteMessage(channel_id, message_id) -- Message:delete + local endpoint = f(endpoints.CHANNEL_MESSAGE, channel_id, message_id) + return self:request("DELETE", endpoint) +end + +function API:bulkDeleteMessages(channel_id, payload) -- GuildTextChannel:bulkDelete + local endpoint = f(endpoints.CHANNEL_MESSAGES_BULK_DELETE, channel_id) + return self:request("POST", endpoint, payload) +end + +function API:editChannelPermissions(channel_id, overwrite_id, payload) -- various PermissionOverwrite methods + local endpoint = f(endpoints.CHANNEL_PERMISSION, channel_id, overwrite_id) + return self:request("PUT", endpoint, payload) +end + +function API:getChannelInvites(channel_id) -- GuildChannel:getInvites + local endpoint = f(endpoints.CHANNEL_INVITES, channel_id) + return self:request("GET", endpoint) +end + +function API:createChannelInvite(channel_id, payload) -- GuildChannel:createInvite + local endpoint = f(endpoints.CHANNEL_INVITES, channel_id) + return self:request("POST", endpoint, payload) +end + +function API:deleteChannelPermission(channel_id, overwrite_id) -- PermissionOverwrite:delete + local endpoint = f(endpoints.CHANNEL_PERMISSION, channel_id, overwrite_id) + return self:request("DELETE", endpoint) +end + +function API:triggerTypingIndicator(channel_id, payload) -- TextChannel:broadcastTyping + local endpoint = f(endpoints.CHANNEL_TYPING, channel_id) + return self:request("POST", endpoint, payload) +end + +function API:getPinnedMessages(channel_id) -- TextChannel:getPinnedMessages + local endpoint = f(endpoints.CHANNEL_PINS, channel_id) + return self:request("GET", endpoint) +end + +function API:addPinnedChannelMessage(channel_id, message_id, payload) -- Message:pin + local endpoint = f(endpoints.CHANNEL_PIN, channel_id, message_id) + return self:request("PUT", endpoint, payload) +end + +function API:deletePinnedChannelMessage(channel_id, message_id) -- Message:unpin + local endpoint = f(endpoints.CHANNEL_PIN, channel_id, message_id) + return self:request("DELETE", endpoint) +end + +function API:groupDMAddRecipient(channel_id, user_id, payload) -- GroupChannel:addRecipient + local endpoint = f(endpoints.CHANNEL_RECIPIENT, channel_id, user_id) + return self:request("PUT", endpoint, payload) +end + +function API:groupDMRemoveRecipient(channel_id, user_id) -- GroupChannel:removeRecipient + local endpoint = f(endpoints.CHANNEL_RECIPIENT, channel_id, user_id) + return self:request("DELETE", endpoint) +end + +function API:listGuildEmojis(guild_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD_EMOJIS, guild_id) + return self:request("GET", endpoint) +end + +function API:getGuildEmoji(guild_id, emoji_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD_EMOJI, guild_id, emoji_id) + return self:request("GET", endpoint) +end + +function API:createGuildEmoji(guild_id, payload) -- Guild:createEmoji + local endpoint = f(endpoints.GUILD_EMOJIS, guild_id) + return self:request("POST", endpoint, payload) +end + +function API:modifyGuildEmoji(guild_id, emoji_id, payload) -- Emoji:_modify + local endpoint = f(endpoints.GUILD_EMOJI, guild_id, emoji_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteGuildEmoji(guild_id, emoji_id) -- Emoji:delete + local endpoint = f(endpoints.GUILD_EMOJI, guild_id, emoji_id) + return self:request("DELETE", endpoint) +end + +function API:createGuild(payload) -- Client:createGuild + local endpoint = endpoints.GUILDS + return self:request("POST", endpoint, payload) +end + +function API:getGuild(guild_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD, guild_id) + return self:request("GET", endpoint) +end + +function API:modifyGuild(guild_id, payload) -- Guild:_modify + local endpoint = f(endpoints.GUILD, guild_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteGuild(guild_id) -- Guild:delete + local endpoint = f(endpoints.GUILD, guild_id) + return self:request("DELETE", endpoint) +end + +function API:getGuildChannels(guild_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD_CHANNELS, guild_id) + return self:request("GET", endpoint) +end + +function API:createGuildChannel(guild_id, payload) -- Guild:create[Text|Voice]Channel + local endpoint = f(endpoints.GUILD_CHANNELS, guild_id) + return self:request("POST", endpoint, payload) +end + +function API:modifyGuildChannelPositions(guild_id, payload) -- GuildChannel:move[Up|Down] + local endpoint = f(endpoints.GUILD_CHANNELS, guild_id) + return self:request("PATCH", endpoint, payload) +end + +function API:getGuildMember(guild_id, user_id) -- Guild:getMember fallback + local endpoint = f(endpoints.GUILD_MEMBER, guild_id, user_id) + return self:request("GET", endpoint) +end + +function API:listGuildMembers(guild_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD_MEMBERS, guild_id) + return self:request("GET", endpoint) +end + +function API:addGuildMember(guild_id, user_id, payload) -- not exposed, limited use + local endpoint = f(endpoints.GUILD_MEMBER, guild_id, user_id) + return self:request("PUT", endpoint, payload) +end + +function API:modifyGuildMember(guild_id, user_id, payload) -- various Member methods + local endpoint = f(endpoints.GUILD_MEMBER, guild_id, user_id) + return self:request("PATCH", endpoint, payload) +end + +function API:modifyCurrentUsersNick(guild_id, payload) -- Member:setNickname + local endpoint = f(endpoints.GUILD_MEMBER_ME_NICK, guild_id) + return self:request("PATCH", endpoint, payload) +end + +function API:addGuildMemberRole(guild_id, user_id, role_id, payload) -- Member:addrole + local endpoint = f(endpoints.GUILD_MEMBER_ROLE, guild_id, user_id, role_id) + return self:request("PUT", endpoint, payload) +end + +function API:removeGuildMemberRole(guild_id, user_id, role_id) -- Member:removeRole + local endpoint = f(endpoints.GUILD_MEMBER_ROLE, guild_id, user_id, role_id) + return self:request("DELETE", endpoint) +end + +function API:removeGuildMember(guild_id, user_id, query) -- Guild:kickUser + local endpoint = f(endpoints.GUILD_MEMBER, guild_id, user_id) + return self:request("DELETE", endpoint, nil, query) +end + +function API:getGuildBans(guild_id) -- Guild:getBans + local endpoint = f(endpoints.GUILD_BANS, guild_id) + return self:request("GET", endpoint) +end + +function API:getGuildBan(guild_id, user_id) -- Guild:getBan + local endpoint = f(endpoints.GUILD_BAN, guild_id, user_id) + return self:request("GET", endpoint) +end + +function API:createGuildBan(guild_id, user_id, query) -- Guild:banUser + local endpoint = f(endpoints.GUILD_BAN, guild_id, user_id) + return self:request("PUT", endpoint, nil, query) +end + +function API:removeGuildBan(guild_id, user_id, query) -- Guild:unbanUser / Ban:delete + local endpoint = f(endpoints.GUILD_BAN, guild_id, user_id) + return self:request("DELETE", endpoint, nil, query) +end + +function API:getGuildRoles(guild_id) -- not exposed, use cache + local endpoint = f(endpoints.GUILD_ROLES, guild_id) + return self:request("GET", endpoint) +end + +function API:createGuildRole(guild_id, payload) -- Guild:createRole + local endpoint = f(endpoints.GUILD_ROLES, guild_id) + return self:request("POST", endpoint, payload) +end + +function API:modifyGuildRolePositions(guild_id, payload) -- Role:move[Up|Down] + local endpoint = f(endpoints.GUILD_ROLES, guild_id) + return self:request("PATCH", endpoint, payload) +end + +function API:modifyGuildRole(guild_id, role_id, payload) -- Role:_modify + local endpoint = f(endpoints.GUILD_ROLE, guild_id, role_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteGuildRole(guild_id, role_id) -- Role:delete + local endpoint = f(endpoints.GUILD_ROLE, guild_id, role_id) + return self:request("DELETE", endpoint) +end + +function API:getGuildPruneCount(guild_id, query) -- Guild:getPruneCount + local endpoint = f(endpoints.GUILD_PRUNE, guild_id) + return self:request("GET", endpoint, nil, query) +end + +function API:beginGuildPrune(guild_id, payload, query) -- Guild:pruneMembers + local endpoint = f(endpoints.GUILD_PRUNE, guild_id) + return self:request("POST", endpoint, payload, query) +end + +function API:getGuildVoiceRegions(guild_id) -- Guild:listVoiceRegions + local endpoint = f(endpoints.GUILD_REGIONS, guild_id) + return self:request("GET", endpoint) +end + +function API:getGuildInvites(guild_id) -- Guild:getInvites + local endpoint = f(endpoints.GUILD_INVITES, guild_id) + return self:request("GET", endpoint) +end + +function API:getGuildIntegrations(guild_id) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_INTEGRATIONS, guild_id) + return self:request("GET", endpoint) +end + +function API:createGuildIntegration(guild_id, payload) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_INTEGRATIONS, guild_id) + return self:request("POST", endpoint, payload) +end + +function API:modifyGuildIntegration(guild_id, integration_id, payload) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_INTEGRATION, guild_id, integration_id) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteGuildIntegration(guild_id, integration_id) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_INTEGRATION, guild_id, integration_id) + return self:request("DELETE", endpoint) +end + +function API:syncGuildIntegration(guild_id, integration_id, payload) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_INTEGRATION_SYNC, guild_id, integration_id) + return self:request("POST", endpoint, payload) +end + +function API:getGuildEmbed(guild_id) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_EMBED, guild_id) + return self:request("GET", endpoint) +end + +function API:modifyGuildEmbed(guild_id, payload) -- not exposed, maybe in the future + local endpoint = f(endpoints.GUILD_EMBED, guild_id) + return self:request("PATCH", endpoint, payload) +end + +function API:getInvite(invite_code, query) -- Client:getInvite + local endpoint = f(endpoints.INVITE, invite_code) + return self:request("GET", endpoint, nil, query) +end + +function API:deleteInvite(invite_code) -- Invite:delete + local endpoint = f(endpoints.INVITE, invite_code) + return self:request("DELETE", endpoint) +end + +function API:acceptInvite(invite_code, payload) -- not exposed, invalidates tokens + local endpoint = f(endpoints.INVITE, invite_code) + return self:request("POST", endpoint, payload) +end + +function API:getCurrentUser() -- API:authenticate + local endpoint = endpoints.USER_ME + return self:request("GET", endpoint) +end + +function API:getUser(user_id) -- Client:getUser + local endpoint = f(endpoints.USER, user_id) + return self:request("GET", endpoint) +end + +function API:modifyCurrentUser(payload) -- Client:_modify + local endpoint = endpoints.USER_ME + return self:request("PATCH", endpoint, payload) +end + +function API:getCurrentUserGuilds() -- not exposed, use cache + local endpoint = endpoints.USER_ME_GUILDS + return self:request("GET", endpoint) +end + +function API:leaveGuild(guild_id) -- Guild:leave + local endpoint = f(endpoints.USER_ME_GUILD, guild_id) + return self:request("DELETE", endpoint) +end + +function API:getUserDMs() -- not exposed, use cache + local endpoint = endpoints.USER_ME_CHANNELS + return self:request("GET", endpoint) +end + +function API:createDM(payload) -- User:getPrivateChannel fallback + local endpoint = endpoints.USER_ME_CHANNELS + return self:request("POST", endpoint, payload) +end + +function API:createGroupDM(payload) -- Client:createGroupChannel + local endpoint = endpoints.USER_ME_CHANNELS + return self:request("POST", endpoint, payload) +end + +function API:getUsersConnections() -- Client:getConnections + local endpoint = endpoints.USER_ME_CONNECTIONS + return self:request("GET", endpoint) +end + +function API:listVoiceRegions() -- Client:listVoiceRegions + local endpoint = endpoints.VOICE_REGIONS + return self:request("GET", endpoint) +end + +function API:createWebhook(channel_id, payload) -- GuildTextChannel:createWebhook + local endpoint = f(endpoints.CHANNEL_WEBHOOKS, channel_id) + return self:request("POST", endpoint, payload) +end + +function API:getChannelWebhooks(channel_id) -- GuildTextChannel:getWebhooks + local endpoint = f(endpoints.CHANNEL_WEBHOOKS, channel_id) + return self:request("GET", endpoint) +end + +function API:getGuildWebhooks(guild_id) -- Guild:getWebhooks + local endpoint = f(endpoints.GUILD_WEBHOOKS, guild_id) + return self:request("GET", endpoint) +end + +function API:getWebhook(webhook_id) -- Client:getWebhook + local endpoint = f(endpoints.WEBHOOK, webhook_id) + return self:request("GET", endpoint) +end + +function API:getWebhookWithToken(webhook_id, webhook_token) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN, webhook_id, webhook_token) + return self:request("GET", endpoint) +end + +function API:modifyWebhook(webhook_id, payload) -- Webhook:_modify + local endpoint = f(endpoints.WEBHOOK, webhook_id) + return self:request("PATCH", endpoint, payload) +end + +function API:modifyWebhookWithToken(webhook_id, webhook_token, payload) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN, webhook_id, webhook_token) + return self:request("PATCH", endpoint, payload) +end + +function API:deleteWebhook(webhook_id) -- Webhook:delete + local endpoint = f(endpoints.WEBHOOK, webhook_id) + return self:request("DELETE", endpoint) +end + +function API:deleteWebhookWithToken(webhook_id, webhook_token) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN, webhook_id, webhook_token) + return self:request("DELETE", endpoint) +end + +function API:executeWebhook(webhook_id, webhook_token, payload) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN, webhook_id, webhook_token) + return self:request("POST", endpoint, payload) +end + +function API:executeSlackCompatibleWebhook(webhook_id, webhook_token, payload) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN_SLACK, webhook_id, webhook_token) + return self:request("POST", endpoint, payload) +end + +function API:executeGitHubCompatibleWebhook(webhook_id, webhook_token, payload) -- not exposed, needs webhook client + local endpoint = f(endpoints.WEBHOOK_TOKEN_GITHUB, webhook_id, webhook_token) + return self:request("POST", endpoint, payload) +end + +function API:getGateway() -- Client:run + local endpoint = endpoints.GATEWAY + return self:request("GET", endpoint) +end + +function API:getGatewayBot() -- Client:run + local endpoint = endpoints.GATEWAY_BOT + return self:request("GET", endpoint) +end + +function API:getCurrentApplicationInformation() -- Client:run + local endpoint = endpoints.OAUTH2_APPLICATION_ME + return self:request("GET", endpoint) +end + +-- end of auto-generated methods -- + +return API diff --git a/deps/discordia/libs/client/Client.lua b/deps/discordia/libs/client/Client.lua new file mode 100644 index 0000000..1bb70e6 --- /dev/null +++ b/deps/discordia/libs/client/Client.lua @@ -0,0 +1,679 @@ +--[=[ +@c Client x Emitter +@t ui +@op options table +@d The main point of entry into a Discordia application. All data relevant to +Discord is accessible through a client instance or its child objects after a +connection to Discord is established with the `run` method. In other words, +client data should not be expected and most client methods should not be called +until after the `ready` event is received. Base emitter methods may be called +at any time. See [[client options]]. +]=] + +local fs = require('fs') +local json = require('json') + +local constants = require('constants') +local enums = require('enums') +local package = require('../../package.lua') + +local API = require('client/API') +local Shard = require('client/Shard') +local Resolver = require('client/Resolver') + +local GroupChannel = require('containers/GroupChannel') +local Guild = require('containers/Guild') +local PrivateChannel = require('containers/PrivateChannel') +local User = require('containers/User') +local Invite = require('containers/Invite') +local Webhook = require('containers/Webhook') +local Relationship = require('containers/Relationship') + +local Cache = require('iterables/Cache') +local WeakCache = require('iterables/WeakCache') +local Emitter = require('utils/Emitter') +local Logger = require('utils/Logger') +local Mutex = require('utils/Mutex') + +local VoiceManager = require('voice/VoiceManager') + +local encode, decode, null = json.encode, json.decode, json.null +local readFileSync, writeFileSync = fs.readFileSync, fs.writeFileSync + +local logLevel = enums.logLevel +local gameType = enums.gameType + +local wrap = coroutine.wrap +local time, difftime = os.time, os.difftime +local format = string.format + +local CACHE_AGE = constants.CACHE_AGE +local GATEWAY_VERSION = constants.GATEWAY_VERSION + +-- do not change these options here +-- pass a custom table on client initialization instead +local defaultOptions = { + routeDelay = 250, + maxRetries = 5, + shardCount = 0, + firstShard = 0, + lastShard = -1, + largeThreshold = 100, + cacheAllMembers = false, + autoReconnect = true, + compress = true, + bitrate = 64000, + logFile = 'discordia.log', + logLevel = logLevel.info, + gatewayFile = 'gateway.json', + dateTime = '%F %T', + syncGuilds = false, +} + +local function parseOptions(customOptions) + if type(customOptions) == 'table' then + local options = {} + for k, default in pairs(defaultOptions) do -- load options + local custom = customOptions[k] + if custom ~= nil then + options[k] = custom + else + options[k] = default + end + end + for k, v in pairs(customOptions) do -- validate options + local default = type(defaultOptions[k]) + local custom = type(v) + if default ~= custom then + return error(format('invalid client option %q (%s expected, got %s)', k, default, custom), 3) + end + if custom == 'number' and (v < 0 or v % 1 ~= 0) then + return error(format('invalid client option %q (number must be a positive integer)', k), 3) + end + end + return options + else + return defaultOptions + end +end + +local Client, get = require('class')('Client', Emitter) + +function Client:__init(options) + Emitter.__init(self) + options = parseOptions(options) + self._options = options + self._shards = {} + self._api = API(self) + self._mutex = Mutex() + self._users = Cache({}, User, self) + self._guilds = Cache({}, Guild, self) + self._group_channels = Cache({}, GroupChannel, self) + self._private_channels = Cache({}, PrivateChannel, self) + self._relationships = Cache({}, Relationship, self) + self._webhooks = WeakCache({}, Webhook, self) -- used for audit logs + self._logger = Logger(options.logLevel, options.dateTime, options.logFile) + self._voice = VoiceManager(self) + self._role_map = {} + self._emoji_map = {} + self._channel_map = {} + self._events = require('client/EventHandler') +end + +for name, level in pairs(logLevel) do + Client[name] = function(self, fmt, ...) + local msg = self._logger:log(level, fmt, ...) + return self:emit(name, msg or format(fmt, ...)) + end +end + +function Client:_deprecated(clsName, before, after) + local info = debug.getinfo(3) + return self:warning( + '%s:%s: %s.%s is deprecated; use %s.%s instead', + info.short_src, + info.currentline, + clsName, + before, + clsName, + after + ) +end + +local function run(self, token) + + self:info('Discordia %s', package.version) + self:info('Connecting to Discord...') + + local api = self._api + local users = self._users + local options = self._options + + local user, err1 = api:authenticate(token) + if not user then + return self:error('Could not authenticate, check token: ' .. err1) + end + self._user = users:_insert(user) + self._token = token + + self:info('Authenticated as %s#%s', user.username, user.discriminator) + + local now = time() + local url, count, owner + + local cache = readFileSync(options.gatewayFile) + cache = cache and decode(cache) + + if cache then + local d = cache[user.id] + if d and difftime(now, d.timestamp) < CACHE_AGE then + url = cache.url + if user.bot then + count = d.shards + owner = d.owner + else + count = 1 + owner = user + end + end + else + cache = {} + end + + if not url or not owner then + + if user.bot then + + local gateway, err2 = api:getGatewayBot() + if not gateway then + return self:error('Could not get gateway: ' .. err2) + end + + local app, err3 = api:getCurrentApplicationInformation() + if not app then + return self:error('Could not get application information: ' .. err3) + end + + url = gateway.url + count = gateway.shards + owner = app.owner + + cache[user.id] = {owner = owner, shards = count, timestamp = now} + + else + + local gateway, err2 = api:getGateway() + if not gateway then + return self:error('Could not get gateway: ' .. err2) + end + + url = gateway.url + count = 1 + owner = user + + cache[user.id] = {timestamp = now} + + end + + cache.url = url + + writeFileSync(options.gatewayFile, encode(cache)) + + end + + self._owner = users:_insert(owner) + + if options.shardCount > 0 then + if count ~= options.shardCount then + self:warning('Requested shard count (%i) is different from recommended count (%i)', options.shardCount, count) + end + count = options.shardCount + end + + local first, last = options.firstShard, options.lastShard + + if last < 0 then + last = count - 1 + end + + if last < first then + return self:error('First shard ID (%i) is greater than last shard ID (%i)', first, last) + end + + local d = last - first + 1 + if d > count then + return self:error('Shard count (%i) is less than target shard range (%i)', count, d) + end + + if first == last then + self:info('Launching shard %i (%i out of %i)...', first, d, count) + else + self:info('Launching shards %i through %i (%i out of %i)...', first, last, d, count) + end + + self._total_shard_count = count + self._shard_count = d + + for id = first, last do + self._shards[id] = Shard(id, self) + end + + local path = format('/?v=%i&encoding=json', GATEWAY_VERSION) + for _, shard in pairs(self._shards) do + wrap(shard.connect)(shard, url, path) + shard:identifyWait() + end + +end + +--[=[ +@m run +@p token string +@op presence table +@r nil +@d Authenticates the current user via HTTPS and launches as many WSS gateway +shards as are required or requested. By using coroutines that are automatically +managed by Luvit libraries and a libuv event loop, multiple clients per process +and multiple shards per client can operate concurrently. This should be the last +method called after all other code and event handlers have been initialized. If +a presence table is provided, it will act as if the user called `setStatus` +and `setGame` after `run`. +]=] +function Client:run(token, presence) + self._presence = presence or {} + return wrap(run)(self, token) +end + +--[=[ +@m stop +@t ws +@r nil +@d Disconnects all shards and effectively stops their loops. This does not +empty any data that the client may have cached. +]=] +function Client:stop() + for _, shard in pairs(self._shards) do + shard:disconnect() + end +end + +function Client:_modify(payload) + local data, err = self._api:modifyCurrentUser(payload) + if data then + data.token = nil + self._user:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m setUsername +@t http +@p username string +@r boolean +@d Sets the client's username. This must be between 2 and 32 characters in +length. This does not change the application name. +]=] +function Client:setUsername(username) + return self:_modify({username = username or null}) +end + +--[=[ +@m setAvatar +@t http +@p avatar Base64-Resolvable +@r boolean +@d Sets the client's avatar. To remove the avatar, pass an empty string or nil. +This does not change the application image. +]=] +function Client:setAvatar(avatar) + avatar = avatar and Resolver.base64(avatar) + return self:_modify({avatar = avatar or null}) +end + +--[=[ +@m createGuild +@t http +@p name string +@r boolean +@d Creates a new guild. The name must be between 2 and 100 characters in length. +This method may not work if the current user is in too many guilds. Note that +this does not return the created guild object; wait for the corresponding +`guildCreate` event if you need the object. +]=] +function Client:createGuild(name) + local data, err = self._api:createGuild({name = name}) + if data then + return true + else + return false, err + end +end + +--[=[ +@m createGroupChannel +@t http +@r GroupChannel +@d Creates a new group channel. This method is only available for user accounts. +]=] +function Client:createGroupChannel() + local data, err = self._api:createGroupDM() + if data then + return self._group_channels:_insert(data) + else + return nil, err + end +end + +--[=[ +@m getWebhook +@t http +@p id string +@r Webhook +@d Gets a webhook object by ID. This always makes an HTTP request to obtain a +static object that is not cached and is not updated by gateway events. +]=] +function Client:getWebhook(id) + local data, err = self._api:getWebhook(id) + if data then + return Webhook(data, self) + else + return nil, err + end +end + +--[=[ +@m getInvite +@t http +@p code string +@op counts boolean +@r Invite +@d Gets an invite object by code. This always makes an HTTP request to obtain a +static object that is not cached and is not updated by gateway events. +]=] +function Client:getInvite(code, counts) + local data, err = self._api:getInvite(code, counts and {with_counts = true}) + if data then + return Invite(data, self) + else + return nil, err + end +end + +--[=[ +@m getUser +@t http? +@p id User-ID-Resolvable +@r User +@d Gets a user object by ID. If the object is already cached, then the cached +object will be returned; otherwise, an HTTP request is made. Under circumstances +which should be rare, the user object may be an old version, not updated by +gateway events. +]=] +function Client:getUser(id) + id = Resolver.userId(id) + local user = self._users:get(id) + if user then + return user + else + local data, err = self._api:getUser(id) + if data then + return self._users:_insert(data) + else + return nil, err + end + end +end + +--[=[ +@m getGuild +@t mem +@p id Guild-ID-Resolvable +@r Guild +@d Gets a guild object by ID. The current user must be in the guild and the client +must be running the appropriate shard that serves this guild. This method never +makes an HTTP request to obtain a guild. +]=] +function Client:getGuild(id) + id = Resolver.guildId(id) + return self._guilds:get(id) +end + +--[=[ +@m getChannel +@t mem +@p id Channel-ID-Resolvable +@r Channel +@d Gets a channel object by ID. For guild channels, the current user must be in +the channel's guild and the client must be running the appropriate shard that +serves the channel's guild. + +For private channels, the channel must have been previously opened and cached. +If the channel is not cached, `User:getPrivateChannel` should be used instead. +]=] +function Client:getChannel(id) + id = Resolver.channelId(id) + local guild = self._channel_map[id] + if guild then + return guild._text_channels:get(id) or guild._voice_channels:get(id) or guild._categories:get(id) + else + return self._private_channels:get(id) or self._group_channels:get(id) + end +end + +--[=[ +@m getRole +@t mem +@p id Role-ID-Resolvable +@r Role +@d Gets a role object by ID. The current user must be in the role's guild and +the client must be running the appropriate shard that serves the role's guild. +]=] +function Client:getRole(id) + id = Resolver.roleId(id) + local guild = self._role_map[id] + return guild and guild._roles:get(id) +end + +--[=[ +@m getEmoji +@t mem +@p id Emoji-ID-Resolvable +@r Emoji +@d Gets an emoji object by ID. The current user must be in the emoji's guild and +the client must be running the appropriate shard that serves the emoji's guild. +]=] +function Client:getEmoji(id) + id = Resolver.emojiId(id) + local guild = self._emoji_map[id] + return guild and guild._emojis:get(id) +end + +--[=[ +@m listVoiceRegions +@t http +@r table +@d Returns a raw data table that contains a list of voice regions as provided by +Discord, with no formatting beyond what is provided by the Discord API. +]=] +function Client:listVoiceRegions() + return self._api:listVoiceRegions() +end + +--[=[ +@m getConnections +@t http +@r table +@d Returns a raw data table that contains a list of connections as provided by +Discord, with no formatting beyond what is provided by the Discord API. +This is unrelated to voice connections. +]=] +function Client:getConnections() + return self._api:getUsersConnections() +end + +--[=[ +@m getApplicationInformation +@t http +@r table +@d Returns a raw data table that contains information about the current OAuth2 +application, with no formatting beyond what is provided by the Discord API. +]=] +function Client:getApplicationInformation() + return self._api:getCurrentApplicationInformation() +end + +local function updateStatus(self) + local presence = self._presence + presence.afk = presence.afk or null + presence.game = presence.game or null + presence.since = presence.since or null + presence.status = presence.status or null + for _, shard in pairs(self._shards) do + shard:updateStatus(presence) + end +end + +--[=[ +@m setStatus +@t ws +@p status string +@r nil +@d Sets the current user's status on all shards that are managed by this client. +See the `status` enumeration for acceptable status values. +]=] +function Client:setStatus(status) + if type(status) == 'string' then + self._presence.status = status + if status == 'idle' then + self._presence.since = 1000 * time() + else + self._presence.since = null + end + else + self._presence.status = null + self._presence.since = null + end + return updateStatus(self) +end + +--[=[ +@m setGame +@t ws +@p game string/table +@r nil +@d Sets the current user's game on all shards that are managed by this client. +If a string is passed, it is treated as the game name. If a table is passed, it +must have a `name` field and may optionally have a `url` or `type` field. Pass `nil` to +remove the game status. +]=] +function Client:setGame(game) + if type(game) == 'string' then + game = {name = game, type = gameType.default} + elseif type(game) == 'table' then + if type(game.name) == 'string' then + if type(game.type) ~= 'number' then + if type(game.url) == 'string' then + game.type = gameType.streaming + else + game.type = gameType.default + end + end + else + game = null + end + else + game = null + end + self._presence.game = game + return updateStatus(self) +end + +--[=[ +@m setAFK +@t ws +@p afk boolean +@r nil +@d Set the current user's AFK status on all shards that are managed by this client. +This generally applies to user accounts and their push notifications. +]=] +function Client:setAFK(afk) + if type(afk) == 'boolean' then + self._presence.afk = afk + else + self._presence.afk = null + end + return updateStatus(self) +end + +--[=[@p shardCount number/nil The number of shards that this client is managing.]=] +function get.shardCount(self) + return self._shard_count +end + +--[=[@p totalShardCount number/nil The total number of shards that the current user is on.]=] +function get.totalShardCount(self) + return self._total_shard_count +end + +--[=[@p user User/nil User object representing the current user.]=] +function get.user(self) + return self._user +end + +--[=[@p owner User/nil User object representing the current user's owner.]=] +function get.owner(self) + return self._owner +end + +--[=[@p verified boolean/nil Whether the current user's owner's account is verified.]=] +function get.verified(self) + return self._user and self._user._verified +end + +--[=[@p mfaEnabled boolean/nil Whether the current user's owner's account has multi-factor (or two-factor) +authentication enabled. This is equivalent to `verified`]=] +function get.mfaEnabled(self) + return self._user and self._user._verified +end + +--[=[@p email string/nil The current user's owner's account's email address (user-accounts only).]=] +function get.email(self) + return self._user and self._user._email +end + +--[=[@p guilds Cache An iterable cache of all guilds that are visible to the client. Note that the +guilds present here correspond to which shards the client is managing. If all +shards are managed by one client, then all guilds will be present.]=] +function get.guilds(self) + return self._guilds +end + +--[=[@p users Cache An iterable cache of all users that are visible to the client. +To access a user that may exist but is not cached, use `Client:getUser`.]=] +function get.users(self) + return self._users +end + +--[=[@p privateChannels Cache An iterable cache of all private channels that are visible to the client. The +channel must exist and must be open for it to be cached here. To access a +private channel that may exist but is not cached, `User:getPrivateChannel`.]=] +function get.privateChannels(self) + return self._private_channels +end + +--[=[@p groupChannels Cache An iterable cache of all group channels that are visible to the client. Only +user-accounts should have these.]=] +function get.groupChannels(self) + return self._group_channels +end + +--[=[@p relationships Cache An iterable cache of all relationships that are visible to the client. Only +user-accounts should have these.]=] +function get.relationships(self) + return self._relationships +end + +return Client diff --git a/deps/discordia/libs/client/EventHandler.lua b/deps/discordia/libs/client/EventHandler.lua new file mode 100644 index 0000000..8457713 --- /dev/null +++ b/deps/discordia/libs/client/EventHandler.lua @@ -0,0 +1,540 @@ +local enums = require('enums') +local json = require('json') + +local channelType = enums.channelType +local insert = table.insert +local null = json.null + +local function warning(client, object, id, event) + return client:warning('Uncached %s (%s) on %s', object, id, event) +end + +local function checkReady(shard) + for _, v in pairs(shard._loading) do + if next(v) then return end + end + shard._ready = true + shard._loading = nil + collectgarbage() + local client = shard._client + client:emit('shardReady', shard._id) + for _, other in pairs(client._shards) do + if not other._ready then return end + end + return client:emit('ready') +end + +local function getChannel(client, id) + local guild = client._channel_map[id] + if guild then + return guild._text_channels:get(id) + else + return client._private_channels:get(id) or client._group_channels:get(id) + end +end + +local EventHandler = setmetatable({}, {__index = function(self, k) + self[k] = function(_, _, shard) + return shard:warning('Unhandled gateway event: %s', k) + end + return self[k] +end}) + +function EventHandler.READY(d, client, shard) + + shard:info('Received READY') + shard:emit('READY') + + shard._session_id = d.session_id + client._user = client._users:_insert(d.user) + + local guilds = client._guilds + local group_channels = client._group_channels + local private_channels = client._private_channels + local relationships = client._relationships + + for _, channel in ipairs(d.private_channels) do + if channel.type == channelType.private then + private_channels:_insert(channel) + elseif channel.type == channelType.group then + group_channels:_insert(channel) + end + end + + local loading = shard._loading + + if d.user.bot then + for _, guild in ipairs(d.guilds) do + guilds:_insert(guild) + loading.guilds[guild.id] = true + end + else + if client._options.syncGuilds then + local ids = {} + for _, guild in ipairs(d.guilds) do + guilds:_insert(guild) + if not guild.unavailable then + loading.syncs[guild.id] = true + insert(ids, guild.id) + end + end + shard:syncGuilds(ids) + else + guilds:_load(d.guilds) + end + end + + relationships:_load(d.relationships) + + for _, presence in ipairs(d.presences) do + local relationship = relationships:get(presence.user.id) + if relationship then + relationship:_loadPresence(presence) + end + end + + return checkReady(shard) + +end + +function EventHandler.RESUMED(_, client, shard) + shard:info('Received RESUMED') + return client:emit('shardResumed', shard._id) +end + +function EventHandler.GUILD_MEMBERS_CHUNK(d, client, shard) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_MEMBERS_CHUNK') end + guild._members:_load(d.members) + if shard._loading and guild._member_count == #guild._members then + shard._loading.chunks[d.guild_id] = nil + return checkReady(shard) + end +end + +function EventHandler.GUILD_SYNC(d, client, shard) + local guild = client._guilds:get(d.id) + if not guild then return warning(client, 'Guild', d.id, 'GUILD_SYNC') end + guild._large = d.large + guild:_loadMembers(d, shard) + if shard._loading then + shard._loading.syncs[d.id] = nil + return checkReady(shard) + end +end + +function EventHandler.CHANNEL_CREATE(d, client) + local channel + local t = d.type + if t == channelType.text or t == channelType.news then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_CREATE') end + channel = guild._text_channels:_insert(d) + elseif t == channelType.voice then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_CREATE') end + channel = guild._voice_channels:_insert(d) + elseif t == channelType.private then + channel = client._private_channels:_insert(d) + elseif t == channelType.group then + channel = client._group_channels:_insert(d) + elseif t == channelType.category then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_CREATE') end + channel = guild._categories:_insert(d) + else + return client:warning('Unhandled CHANNEL_CREATE (type %s)', d.type) + end + return client:emit('channelCreate', channel) +end + +function EventHandler.CHANNEL_UPDATE(d, client) + local channel + local t = d.type + if t == channelType.text or t == channelType.news then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_UPDATE') end + channel = guild._text_channels:_insert(d) + elseif t == channelType.voice then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_UPDATE') end + channel = guild._voice_channels:_insert(d) + elseif t == channelType.private then -- private channels should never update + channel = client._private_channels:_insert(d) + elseif t == channelType.group then + channel = client._group_channels:_insert(d) + elseif t == channelType.category then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_UPDATE') end + channel = guild._categories:_insert(d) + else + return client:warning('Unhandled CHANNEL_UPDATE (type %s)', d.type) + end + return client:emit('channelUpdate', channel) +end + +function EventHandler.CHANNEL_DELETE(d, client) + local channel + local t = d.type + if t == channelType.text or t == channelType.news then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_DELETE') end + channel = guild._text_channels:_remove(d) + elseif t == channelType.voice then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_DELETE') end + channel = guild._voice_channels:_remove(d) + elseif t == channelType.private then + channel = client._private_channels:_remove(d) + elseif t == channelType.group then + channel = client._group_channels:_remove(d) + elseif t == channelType.category then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'CHANNEL_DELETE') end + channel = guild._categories:_remove(d) + else + return client:warning('Unhandled CHANNEL_DELETE (type %s)', d.type) + end + return client:emit('channelDelete', channel) +end + +function EventHandler.CHANNEL_RECIPIENT_ADD(d, client) + local channel = client._group_channels:get(d.channel_id) + if not channel then return warning(client, 'GroupChannel', d.channel_id, 'CHANNEL_RECIPIENT_ADD') end + local user = channel._recipients:_insert(d.user) + return client:emit('recipientAdd', channel, user) +end + +function EventHandler.CHANNEL_RECIPIENT_REMOVE(d, client) + local channel = client._group_channels:get(d.channel_id) + if not channel then return warning(client, 'GroupChannel', d.channel_id, 'CHANNEL_RECIPIENT_REMOVE') end + local user = channel._recipients:_remove(d.user) + return client:emit('recipientRemove', channel, user) +end + +function EventHandler.GUILD_CREATE(d, client, shard) + if client._options.syncGuilds and not d.unavailable and not client._user._bot then + shard:syncGuilds({d.id}) + end + local guild = client._guilds:get(d.id) + if guild then + if guild._unavailable and not d.unavailable then + guild:_load(d) + guild:_makeAvailable(d) + client:emit('guildAvailable', guild) + end + if shard._loading then + shard._loading.guilds[d.id] = nil + return checkReady(shard) + end + else + guild = client._guilds:_insert(d) + return client:emit('guildCreate', guild) + end +end + +function EventHandler.GUILD_UPDATE(d, client) + local guild = client._guilds:_insert(d) + return client:emit('guildUpdate', guild) +end + +function EventHandler.GUILD_DELETE(d, client) + if d.unavailable then + local guild = client._guilds:_insert(d) + return client:emit('guildUnavailable', guild) + else + local guild = client._guilds:_remove(d) + return client:emit('guildDelete', guild) + end +end + +function EventHandler.GUILD_BAN_ADD(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_BAN_ADD') end + local user = client._users:_insert(d.user) + return client:emit('userBan', user, guild) +end + +function EventHandler.GUILD_BAN_REMOVE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_BAN_REMOVE') end + local user = client._users:_insert(d.user) + return client:emit('userUnban', user, guild) +end + +function EventHandler.GUILD_EMOJIS_UPDATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_EMOJIS_UPDATE') end + guild._emojis:_load(d.emojis, true) + return client:emit('emojisUpdate', guild) +end + +function EventHandler.GUILD_MEMBER_ADD(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_MEMBER_ADD') end + local member = guild._members:_insert(d) + guild._member_count = guild._member_count + 1 + return client:emit('memberJoin', member) +end + +function EventHandler.GUILD_MEMBER_UPDATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_MEMBER_UPDATE') end + local member = guild._members:_insert(d) + return client:emit('memberUpdate', member) +end + +function EventHandler.GUILD_MEMBER_REMOVE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_MEMBER_REMOVE') end + local member = guild._members:_remove(d) + guild._member_count = guild._member_count - 1 + return client:emit('memberLeave', member) +end + +function EventHandler.GUILD_ROLE_CREATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_ROLE_CREATE') end + local role = guild._roles:_insert(d.role) + return client:emit('roleCreate', role) +end + +function EventHandler.GUILD_ROLE_UPDATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_ROLE_UPDATE') end + local role = guild._roles:_insert(d.role) + return client:emit('roleUpdate', role) +end + +function EventHandler.GUILD_ROLE_DELETE(d, client) -- role object not provided + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'GUILD_ROLE_DELETE') end + local role = guild._roles:_delete(d.role_id) + if not role then return warning(client, 'Role', d.role_id, 'GUILD_ROLE_DELETE') end + return client:emit('roleDelete', role) +end + +function EventHandler.MESSAGE_CREATE(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_CREATE') end + local message = channel._messages:_insert(d) + return client:emit('messageCreate', message) +end + +function EventHandler.MESSAGE_UPDATE(d, client) -- may not contain the whole message + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_UPDATE') end + local message = channel._messages:get(d.id) + if message then + message:_setOldContent(d) + message:_load(d) + return client:emit('messageUpdate', message) + else + return client:emit('messageUpdateUncached', channel, d.id) + end +end + +function EventHandler.MESSAGE_DELETE(d, client) -- message object not provided + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_DELETE') end + local message = channel._messages:_delete(d.id) + if message then + return client:emit('messageDelete', message) + else + return client:emit('messageDeleteUncached', channel, d.id) + end +end + +function EventHandler.MESSAGE_DELETE_BULK(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_DELETE_BULK') end + for _, id in ipairs(d.ids) do + local message = channel._messages:_delete(id) + if message then + client:emit('messageDelete', message) + else + client:emit('messageDeleteUncached', channel, id) + end + end +end + +function EventHandler.MESSAGE_REACTION_ADD(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_REACTION_ADD') end + local message = channel._messages:get(d.message_id) + if message then + local reaction = message:_addReaction(d) + return client:emit('reactionAdd', reaction, d.user_id) + else + local k = d.emoji.id ~= null and d.emoji.id or d.emoji.name + return client:emit('reactionAddUncached', channel, d.message_id, k, d.user_id) + end +end + +function EventHandler.MESSAGE_REACTION_REMOVE(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_REACTION_REMOVE') end + local message = channel._messages:get(d.message_id) + if message then + local reaction = message:_removeReaction(d) + if not reaction then -- uncached reaction? + local k = d.emoji.id ~= null and d.emoji.id or d.emoji.name + return warning(client, 'Reaction', k, 'MESSAGE_REACTION_REMOVE') + end + return client:emit('reactionRemove', reaction, d.user_id) + else + local k = d.emoji.id ~= null and d.emoji.id or d.emoji.name + return client:emit('reactionRemoveUncached', channel, d.message_id, k, d.user_id) + end +end + +function EventHandler.MESSAGE_REACTION_REMOVE_ALL(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'MESSAGE_REACTION_REMOVE_ALL') end + local message = channel._messages:get(d.message_id) + if message then + local reactions = message._reactions + if reactions then + for reaction in reactions:iter() do + reaction._count = 0 + end + message._reactions = nil + end + return client:emit('reactionRemoveAll', message) + else + return client:emit('reactionRemoveAllUncached', channel, d.message_id) + end +end + +function EventHandler.CHANNEL_PINS_UPDATE(d, client) + local channel = getChannel(client, d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'CHANNEL_PINS_UPDATE') end + return client:emit('pinsUpdate', channel) +end + +function EventHandler.PRESENCE_UPDATE(d, client) -- may have incomplete data + local user = client._users:get(d.user.id) + if user then + user:_load(d.user) + end + if d.guild_id then + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'PRESENCE_UPDATE') end + local member + if client._options.cacheAllMembers then + member = guild._members:get(d.user.id) + if not member then return end -- still loading or member left + else + if d.status == 'offline' then -- uncache offline members + member = guild._members:_delete(d.user.id) + else + if d.user.username then -- member was offline + member = guild._members:_insert(d) + elseif user then -- member was invisible, user is still cached + member = guild._members:_insert(d) + member._user = user + end + end + end + if member then + member:_loadPresence(d) + return client:emit('presenceUpdate', member) + end + else + local relationship = client._relationships:get(d.user.id) + if relationship then + relationship:_loadPresence(d) + return client:emit('relationshipUpdate', relationship) + end + end +end + +function EventHandler.RELATIONSHIP_ADD(d, client) + local relationship = client._relationships:_insert(d) + return client:emit('relationshipAdd', relationship) +end + +function EventHandler.RELATIONSHIP_REMOVE(d, client) + local relationship = client._relationships:_remove(d) + return client:emit('relationshipRemove', relationship) +end + +function EventHandler.TYPING_START(d, client) + return client:emit('typingStart', d.user_id, d.channel_id, d.timestamp) +end + +function EventHandler.USER_UPDATE(d, client) + client._user:_load(d) + return client:emit('userUpdate', client._user) +end + +local function load(obj, d) + for k, v in pairs(d) do obj[k] = v end +end + +function EventHandler.VOICE_STATE_UPDATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'VOICE_STATE_UPDATE') end + local member = d.member and guild._members:_insert(d.member) or guild._members:get(d.user_id) + if not member then return warning(client, 'Member', d.user_id, 'VOICE_STATE_UPDATE') end + local states = guild._voice_states + local channels = guild._voice_channels + local new_channel_id = d.channel_id + local state = states[d.user_id] + if state then -- user is already connected + local old_channel_id = state.channel_id + load(state, d) + if new_channel_id ~= null then -- state changed, but user has not disconnected + if new_channel_id == old_channel_id then -- user did not change channels + client:emit('voiceUpdate', member) + else -- user changed channels + local old = channels:get(old_channel_id) + local new = channels:get(new_channel_id) + if d.user_id == client._user._id then -- move connection to new channel + local connection = old._connection + if connection then + new._connection = connection + old._connection = nil + connection._channel = new + connection:_continue(true) + end + end + client:emit('voiceChannelLeave', member, old) + client:emit('voiceChannelJoin', member, new) + end + else -- user has disconnected + states[d.user_id] = nil + local old = channels:get(old_channel_id) + client:emit('voiceChannelLeave', member, old) + client:emit('voiceDisconnect', member) + end + else -- user has connected + states[d.user_id] = d + local new = channels:get(new_channel_id) + client:emit('voiceConnect', member) + client:emit('voiceChannelJoin', member, new) + end +end + +function EventHandler.VOICE_SERVER_UPDATE(d, client) + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'VOICE_SERVER_UPDATE') end + local state = guild._voice_states[client._user._id] + if not state then return client:warning('Voice state not initialized before VOICE_SERVER_UPDATE') end + load(state, d) + local channel = guild._voice_channels:get(state.channel_id) + if not channel then return warning(client, 'GuildVoiceChannel', state.channel_id, 'VOICE_SERVER_UPDATE') end + local connection = channel._connection + if not connection then return client:warning('Voice connection not initialized before VOICE_SERVER_UPDATE') end + return client._voice:_prepareConnection(state, connection) +end + +function EventHandler.WEBHOOKS_UPDATE(d, client) -- webhook object is not provided + local guild = client._guilds:get(d.guild_id) + if not guild then return warning(client, 'Guild', d.guild_id, 'WEBHOOKS_UDPATE') end + local channel = guild._text_channels:get(d.channel_id) + if not channel then return warning(client, 'TextChannel', d.channel_id, 'WEBHOOKS_UPDATE') end + return client:emit('webhooksUpdate', channel) +end + +return EventHandler diff --git a/deps/discordia/libs/client/Resolver.lua b/deps/discordia/libs/client/Resolver.lua new file mode 100644 index 0000000..0ee2648 --- /dev/null +++ b/deps/discordia/libs/client/Resolver.lua @@ -0,0 +1,202 @@ +local fs = require('fs') +local ffi = require('ffi') +local ssl = require('openssl') +local class = require('class') +local enums = require('enums') + +local permission = enums.permission +local actionType = enums.actionType +local messageFlag = enums.messageFlag +local base64 = ssl.base64 +local readFileSync = fs.readFileSync +local classes = class.classes +local isInstance = class.isInstance +local isObject = class.isObject +local insert = table.insert +local format = string.format + +local Resolver = {} + +local istype = ffi.istype +local int64_t = ffi.typeof('int64_t') +local uint64_t = ffi.typeof('uint64_t') + +local function int(obj) + local t = type(obj) + if t == 'string' then + if tonumber(obj) then + return obj + end + elseif t == 'cdata' then + if istype(int64_t, obj) or istype(uint64_t, obj) then + return tostring(obj):match('%d*') + end + elseif t == 'number' then + return format('%i', obj) + elseif isInstance(obj, classes.Date) then + return obj:toSnowflake() + end +end + +function Resolver.userId(obj) + if isObject(obj) then + if isInstance(obj, classes.User) then + return obj.id + elseif isInstance(obj, classes.Member) then + return obj.user.id + elseif isInstance(obj, classes.Message) then + return obj.author.id + elseif isInstance(obj, classes.Guild) then + return obj.ownerId + end + end + return int(obj) +end + +function Resolver.messageId(obj) + if isInstance(obj, classes.Message) then + return obj.id + end + return int(obj) +end + +function Resolver.channelId(obj) + if isInstance(obj, classes.Channel) then + return obj.id + end + return int(obj) +end + +function Resolver.roleId(obj) + if isInstance(obj, classes.Role) then + return obj.id + end + return int(obj) +end + +function Resolver.emojiId(obj) + if isInstance(obj, classes.Emoji) then + return obj.id + elseif isInstance(obj, classes.Reaction) then + return obj.emojiId + elseif isInstance(obj, classes.Activity) then + return obj.emojiId + end + return int(obj) +end + +function Resolver.guildId(obj) + if isInstance(obj, classes.Guild) then + return obj.id + end + return int(obj) +end + +function Resolver.entryId(obj) + if isInstance(obj, classes.AuditLogEntry) then + return obj.id + end + return int(obj) +end + +function Resolver.messageIds(objs) + local ret = {} + if isInstance(objs, classes.Iterable) then + for obj in objs:iter() do + insert(ret, Resolver.messageId(obj)) + end + elseif type(objs) == 'table' then + for _, obj in pairs(objs) do + insert(ret, Resolver.messageId(obj)) + end + end + return ret +end + +function Resolver.roleIds(objs) + local ret = {} + if isInstance(objs, classes.Iterable) then + for obj in objs:iter() do + insert(ret, Resolver.roleId(obj)) + end + elseif type(objs) == 'table' then + for _, obj in pairs(objs) do + insert(ret, Resolver.roleId(obj)) + end + end + return ret +end + +function Resolver.emoji(obj) + if isInstance(obj, classes.Emoji) then + return obj.hash + elseif isInstance(obj, classes.Reaction) then + return obj.emojiHash + elseif isInstance(obj, classes.Activity) then + return obj.emojiHash + end + return tostring(obj) +end + +function Resolver.color(obj) + if isInstance(obj, classes.Color) then + return obj.value + end + return tonumber(obj) +end + +function Resolver.permissions(obj) + if isInstance(obj, classes.Permissions) then + return obj.value + end + return tonumber(obj) +end + +function Resolver.permission(obj) + local t = type(obj) + local n = nil + if t == 'string' then + n = permission[obj] + elseif t == 'number' then + n = permission(obj) and obj + end + return n +end + +function Resolver.actionType(obj) + local t = type(obj) + local n = nil + if t == 'string' then + n = actionType[obj] + elseif t == 'number' then + n = actionType(obj) and obj + end + return n +end + +function Resolver.messageFlag(obj) + local t = type(obj) + local n = nil + if t == 'string' then + n = messageFlag[obj] + elseif t == 'number' then + n = messageFlag(obj) and obj + end + return n +end + +function Resolver.base64(obj) + if type(obj) == 'string' then + if obj:find('data:.*;base64,') == 1 then + return obj + end + local data, err = readFileSync(obj) + if not data then + return nil, err + end + return 'data:;base64,' .. base64(data) + end + return nil +end + +return Resolver diff --git a/deps/discordia/libs/client/Shard.lua b/deps/discordia/libs/client/Shard.lua new file mode 100644 index 0000000..9c82673 --- /dev/null +++ b/deps/discordia/libs/client/Shard.lua @@ -0,0 +1,257 @@ +local json = require('json') +local timer = require('timer') + +local EventHandler = require('client/EventHandler') +local WebSocket = require('client/WebSocket') + +local constants = require('constants') +local enums = require('enums') + +local logLevel = enums.logLevel +local min, max, random = math.min, math.max, math.random +local null = json.null +local format = string.format +local sleep = timer.sleep +local setInterval, clearInterval = timer.setInterval, timer.clearInterval +local wrap = coroutine.wrap + +local ID_DELAY = constants.ID_DELAY + +local DISPATCH = 0 +local HEARTBEAT = 1 +local IDENTIFY = 2 +local STATUS_UPDATE = 3 +local VOICE_STATE_UPDATE = 4 +-- local VOICE_SERVER_PING = 5 -- TODO +local RESUME = 6 +local RECONNECT = 7 +local REQUEST_GUILD_MEMBERS = 8 +local INVALID_SESSION = 9 +local HELLO = 10 +local HEARTBEAT_ACK = 11 +local GUILD_SYNC = 12 + +local ignore = { + ['CALL_DELETE'] = true, + ['CHANNEL_PINS_ACK'] = true, + ['GUILD_INTEGRATIONS_UPDATE'] = true, + ['MESSAGE_ACK'] = true, + ['PRESENCES_REPLACE'] = true, + ['USER_SETTINGS_UPDATE'] = true, + ['USER_GUILD_SETTINGS_UPDATE'] = true, + ['SESSIONS_REPLACE'] = true, + ['INVITE_CREATE'] = true, + ['INVITE_DELETE'] = true, + ['INTEGRATION_CREATE'] = true, + ['INTEGRATION_UPDATE'] = true, + ['INTEGRATION_DELETE'] = true, +} + +local Shard = require('class')('Shard', WebSocket) + +function Shard:__init(id, client) + WebSocket.__init(self, client) + self._id = id + self._client = client + self._backoff = 1000 +end + +for name in pairs(logLevel) do + Shard[name] = function(self, fmt, ...) + local client = self._client + return client[name](client, format('Shard %i : %s', self._id, fmt), ...) + end +end + +function Shard:__tostring() + return format('Shard: %i', self._id) +end + +local function getReconnectTime(self, n, m) + return self._backoff * (n + random() * (m - n)) +end + +local function incrementReconnectTime(self) + self._backoff = min(self._backoff * 2, 60000) +end + +local function decrementReconnectTime(self) + self._backoff = max(self._backoff / 2, 1000) +end + +function Shard:handleDisconnect(url, path) + self._client:emit('shardDisconnect', self._id) + if self._reconnect then + self:info('Reconnecting...') + return self:connect(url, path) + elseif self._reconnect == nil and self._client._options.autoReconnect then + local backoff = getReconnectTime(self, 0.9, 1.1) + incrementReconnectTime(self) + self:info('Reconnecting after %i ms...', backoff) + sleep(backoff) + return self:connect(url, path) + end +end + +function Shard:handlePayload(payload) + + local client = self._client + + local s = payload.s + local t = payload.t + local d = payload.d + local op = payload.op + + if t ~= null then + self:debug('WebSocket OP %s : %s : %s', op, t, s) + else + self:debug('WebSocket OP %s', op) + end + + if op == DISPATCH then + + self._seq = s + if not ignore[t] then + EventHandler[t](d, client, self) + end + + elseif op == HEARTBEAT then + + self:heartbeat() + + elseif op == RECONNECT then + + self:info('Discord has requested a reconnection') + self:disconnect(true) + + elseif op == INVALID_SESSION then + + if payload.d and self._session_id then + self:info('Session invalidated, resuming...') + self:resume() + else + self:info('Session invalidated, re-identifying...') + sleep(random(1000, 5000)) + self:identify() + end + + elseif op == HELLO then + + self:info('Received HELLO') + self:startHeartbeat(d.heartbeat_interval) + if self._session_id then + self:resume() + else + self:identify() + end + + elseif op == HEARTBEAT_ACK then + + client:emit('heartbeat', self._id, self._sw.milliseconds) + + elseif op then + + self:warning('Unhandled WebSocket payload OP %i', op) + + end + +end + +local function loop(self) + decrementReconnectTime(self) + return wrap(self.heartbeat)(self) +end + +function Shard:startHeartbeat(interval) + if self._heartbeat then + clearInterval(self._heartbeat) + end + self._heartbeat = setInterval(interval, loop, self) +end + +function Shard:stopHeartbeat() + if self._heartbeat then + clearInterval(self._heartbeat) + end + self._heartbeat = nil +end + +function Shard:identifyWait() + if self:waitFor('READY', 1.5 * ID_DELAY) then + return sleep(ID_DELAY) + end +end + +function Shard:heartbeat() + self._sw:reset() + return self:_send(HEARTBEAT, self._seq or json.null) +end + +function Shard:identify() + + local client = self._client + local mutex = client._mutex + local options = client._options + + mutex:lock() + wrap(function() + self:identifyWait() + mutex:unlock() + end)() + + self._seq = nil + self._session_id = nil + self._ready = false + self._loading = {guilds = {}, chunks = {}, syncs = {}} + + return self:_send(IDENTIFY, { + token = client._token, + properties = { + ['$os'] = jit.os, + ['$browser'] = 'Discordia', + ['$device'] = 'Discordia', + ['$referrer'] = '', + ['$referring_domain'] = '', + }, + compress = options.compress, + large_threshold = options.largeThreshold, + shard = {self._id, client._total_shard_count}, + presence = next(client._presence) and client._presence, + }, true) + +end + +function Shard:resume() + return self:_send(RESUME, { + token = self._client._token, + session_id = self._session_id, + seq = self._seq + }) +end + +function Shard:requestGuildMembers(id) + return self:_send(REQUEST_GUILD_MEMBERS, { + guild_id = id, + query = '', + limit = 0, + }) +end + +function Shard:updateStatus(presence) + return self:_send(STATUS_UPDATE, presence) +end + +function Shard:updateVoice(guild_id, channel_id, self_mute, self_deaf) + return self:_send(VOICE_STATE_UPDATE, { + guild_id = guild_id, + channel_id = channel_id or null, + self_mute = self_mute or false, + self_deaf = self_deaf or false, + }) +end + +function Shard:syncGuilds(ids) + return self:_send(GUILD_SYNC, ids) +end + +return Shard diff --git a/deps/discordia/libs/client/WebSocket.lua b/deps/discordia/libs/client/WebSocket.lua new file mode 100644 index 0000000..412d0d6 --- /dev/null +++ b/deps/discordia/libs/client/WebSocket.lua @@ -0,0 +1,121 @@ +local json = require('json') +local miniz = require('miniz') +local Mutex = require('utils/Mutex') +local Emitter = require('utils/Emitter') +local Stopwatch = require('utils/Stopwatch') + +local websocket = require('coro-websocket') +local constants = require('constants') + +local inflate = miniz.inflate +local encode, decode, null = json.encode, json.decode, json.null +local ws_parseUrl, ws_connect = websocket.parseUrl, websocket.connect + +local GATEWAY_DELAY = constants.GATEWAY_DELAY + +local TEXT = 1 +local BINARY = 2 +local CLOSE = 8 + +local function connect(url, path) + local options = assert(ws_parseUrl(url)) + options.pathname = path + return assert(ws_connect(options)) +end + +local WebSocket = require('class')('WebSocket', Emitter) + +function WebSocket:__init(parent) + Emitter.__init(self) + self._parent = parent + self._mutex = Mutex() + self._sw = Stopwatch() +end + +function WebSocket:connect(url, path) + + local success, res, read, write = pcall(connect, url, path) + + if success then + self._read = read + self._write = write + self._reconnect = nil + self:info('Connected to %s', url) + local parent = self._parent + for message in self._read do + local payload, str = self:parseMessage(message) + if not payload then break end + parent:emit('raw', str) + if self.handlePayload then -- virtual method + self:handlePayload(payload) + end + end + self:info('Disconnected') + else + self:error('Could not connect to %s (%s)', url, res) -- TODO: get new url? + end + + self._read = nil + self._write = nil + self._identified = nil + + if self.stopHeartbeat then -- virtual method + self:stopHeartbeat() + end + + if self.handleDisconnect then -- virtual method + return self:handleDisconnect(url, path) + end + +end + +function WebSocket:parseMessage(message) + + local opcode = message.opcode + local payload = message.payload + + if opcode == TEXT then + + return decode(payload, 1, null), payload + + elseif opcode == BINARY then + + payload = inflate(payload, 1) + return decode(payload, 1, null), payload + + elseif opcode == CLOSE then + + local code, i = ('>H'):unpack(payload) + local msg = #payload > i and payload:sub(i) or 'Connection closed' + self:warning('%i - %s', code, msg) + return nil + + end + +end + +function WebSocket:_send(op, d, identify) + self._mutex:lock() + local success, err + if identify or self._session_id then + if self._write then + success, err = self._write {opcode = TEXT, payload = encode {op = op, d = d}} + else + success, err = false, 'Not connected to gateway' + end + else + success, err = false, 'Invalid session' + end + self._mutex:unlockAfter(GATEWAY_DELAY) + return success, err +end + +function WebSocket:disconnect(reconnect) + if not self._write then return end + self._reconnect = not not reconnect + self._write() + self._read = nil + self._write = nil +end + +return WebSocket diff --git a/deps/discordia/libs/constants.lua b/deps/discordia/libs/constants.lua new file mode 100644 index 0000000..5836d94 --- /dev/null +++ b/deps/discordia/libs/constants.lua @@ -0,0 +1,17 @@ +return { + CACHE_AGE = 3600, -- seconds + ID_DELAY = 5000, -- milliseconds + GATEWAY_DELAY = 500, -- milliseconds, + DISCORD_EPOCH = 1420070400000, -- milliseconds + GATEWAY_VERSION = 6, + DEFAULT_AVATARS = 5, + ZWSP = '\226\128\139', + NS_PER_US = 1000, + US_PER_MS = 1000, + MS_PER_S = 1000, + S_PER_MIN = 60, + MIN_PER_HOUR = 60, + HOUR_PER_DAY = 24, + DAY_PER_WEEK = 7, + GATEWAY_VERSION_VOICE = 3, +} diff --git a/deps/discordia/libs/containers/Activity.lua b/deps/discordia/libs/containers/Activity.lua new file mode 100644 index 0000000..0ed9524 --- /dev/null +++ b/deps/discordia/libs/containers/Activity.lua @@ -0,0 +1,157 @@ +--[=[ +@c Activity +@d Represents a Discord user's presence data, either plain game or streaming presence or a rich presence. +Most if not all properties may be nil. +]=] + +local Container = require('containers/abstract/Container') + +local format = string.format + +local Activity, get = require('class')('Activity', Container) + +function Activity:__init(data, parent) + Container.__init(self, data, parent) + return self:_loadMore(data) +end + +function Activity:_load(data) + Container._load(self, data) + return self:_loadMore(data) +end + +function Activity:_loadMore(data) + local timestamps = data.timestamps + self._start = timestamps and timestamps.start + self._stop = timestamps and timestamps['end'] -- thanks discord + local assets = data.assets + self._small_text = assets and assets.small_text + self._large_text = assets and assets.large_text + self._small_image = assets and assets.small_image + self._large_image = assets and assets.large_image + local party = data.party + self._party_id = party and party.id + self._party_size = party and party.size and party.size[1] + self._party_max = party and party.size and party.size[2] + local emoji = data.emoji + self._emoji_name = emoji and emoji.name + self._emoji_id = emoji and emoji.id + self._emoji_animated = emoji and emoji.animated +end + +--[=[ +@m __hash +@r string +@d Returns `Activity.parent:__hash()` +]=] +function Activity:__hash() + return self._parent:__hash() +end + +--[=[@p start number/nil The Unix timestamp for when this Rich Presence activity was started.]=] +function get.start(self) + return self._start +end + +--[=[@p stop number/nil The Unix timestamp for when this Rich Presence activity was stopped.]=] +function get.stop(self) + return self._stop +end + +--[=[@p name string/nil The game that the user is currently playing.]=] +function get.name(self) + return self._name +end + +--[=[@p type number/nil The type of user's game status. See the `activityType` +enumeration for a human-readable representation.]=] +function get.type(self) + return self._type +end + +--[=[@p url string/nil The URL that is set for a user's streaming game status.]=] +function get.url(self) + return self._url +end + +--[=[@p applicationId string/nil The application id controlling this Rich Presence activity.]=] +function get.applicationId(self) + return self._application_id +end + +--[=[@p state string/nil string for the Rich Presence state section.]=] +function get.state(self) + return self._state +end + +--[=[@p details string/nil string for the Rich Presence details section.]=] +function get.details(self) + return self._details +end + +--[=[@p textSmall string/nil string for the Rich Presence small image text.]=] +function get.textSmall(self) + return self._small_text +end + +--[=[@p textLarge string/nil string for the Rich Presence large image text.]=] +function get.textLarge(self) + return self._large_text +end + +--[=[@p imageSmall string/nil URL for the Rich Presence small image.]=] +function get.imageSmall(self) + return self._small_image +end + +--[=[@p imageLarge string/nil URL for the Rich Presence large image.]=] +function get.imageLarge(self) + return self._large_image +end + +--[=[@p partyId string/nil Party id for this Rich Presence.]=] +function get.partyId(self) + return self._party_id +end + +--[=[@p partySize number/nil Size of the Rich Presence party.]=] +function get.partySize(self) + return self._party_size +end + +--[=[@p partyMax number/nil Max size for the Rich Presence party.]=] +function get.partyMax(self) + return self._party_max +end + +--[=[@p emojiId string/nil The ID of the emoji used in this presence if one is +set and if it is a custom emoji.]=] +function get.emojiId(self) + return self._emoji_id +end + +--[=[@p emojiName string/nil The name of the emoji used in this presence if one +is set and if it has a custom emoji. This will be the raw string for a standard emoji.]=] +function get.emojiName(self) + return self._emoji_name +end + +--[=[@p emojiHash string/nil The discord hash for the emoji used in this presence if one is +set. This will be the raw string for a standard emoji.]=] +function get.emojiHash(self) + if self._emoji_id then + return self._emoji_name .. ':' .. self._emoji_id + else + return self._emoji_name + end +end + +--[=[@p emojiURL string/nil string The URL that can be used to view a full +version of the emoji used in this activity if one is set and if it is a custom emoji.]=] +function get.emojiURL(self) + local id = self._emoji_id + local ext = self._emoji_animated and 'gif' or 'png' + return id and format('https://cdn.discordapp.com/emojis/%s.%s', id, ext) or nil +end + +return Activity diff --git a/deps/discordia/libs/containers/AuditLogEntry.lua b/deps/discordia/libs/containers/AuditLogEntry.lua new file mode 100644 index 0000000..c82fe90 --- /dev/null +++ b/deps/discordia/libs/containers/AuditLogEntry.lua @@ -0,0 +1,227 @@ +--[=[ +@c AuditLogEntry x Snowflake +@d Represents an entry made into a guild's audit log. +]=] + +local Snowflake = require('containers/abstract/Snowflake') + +local enums = require('enums') +local actionType = enums.actionType + +local AuditLogEntry, get = require('class')('AuditLogEntry', Snowflake) + +function AuditLogEntry:__init(data, parent) + Snowflake.__init(self, data, parent) + if data.changes then + for i, change in ipairs(data.changes) do + data.changes[change.key] = change + data.changes[i] = nil + change.key = nil + change.old = change.old_value + change.new = change.new_value + change.old_value = nil + change.new_value = nil + end + self._changes = data.changes + end + self._options = data.options +end + +--[=[ +@m getBeforeAfter +@t mem +@r table +@r table +@d Returns two tables of the target's properties before the change, and after the change. +]=] +function AuditLogEntry:getBeforeAfter() + local before, after = {}, {} + for k, change in pairs(self._changes) do + before[k], after[k] = change.old, change.new + end + return before, after +end + +local function unknown(self) + return nil, 'unknown audit log action type: ' .. self._action_type +end + +local targets = setmetatable({ + + [actionType.guildUpdate] = function(self) + return self._parent + end, + + [actionType.channelCreate] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.channelUpdate] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.channelDelete] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.channelOverwriteCreate] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.channelOverwriteUpdate] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.channelOverwriteDelete] = function(self) + return self._parent:getChannel(self._target_id) + end, + + [actionType.memberKick] = function(self) + return self._parent._parent:getUser(self._target_id) + end, + + [actionType.memberPrune] = function() + return nil + end, + + [actionType.memberBanAdd] = function(self) + return self._parent._parent:getUser(self._target_id) + end, + + [actionType.memberBanRemove] = function(self) + return self._parent._parent:getUser(self._target_id) + end, + + [actionType.memberUpdate] = function(self) + return self._parent:getMember(self._target_id) + end, + + [actionType.memberRoleUpdate] = function(self) + return self._parent:getMember(self._target_id) + end, + + [actionType.roleCreate] = function(self) + return self._parent:getRole(self._target_id) + end, + + [actionType.roleUpdate] = function(self) + return self._parent:getRole(self._target_id) + end, + + [actionType.roleDelete] = function(self) + return self._parent:getRole(self._target_id) + end, + + [actionType.inviteCreate] = function() + return nil + end, + + [actionType.inviteUpdate] = function() + return nil + end, + + [actionType.inviteDelete] = function() + return nil + end, + + [actionType.webhookCreate] = function(self) + return self._parent._parent._webhooks:get(self._target_id) + end, + + [actionType.webhookUpdate] = function(self) + return self._parent._parent._webhooks:get(self._target_id) + end, + + [actionType.webhookDelete] = function(self) + return self._parent._parent._webhooks:get(self._target_id) + end, + + [actionType.emojiCreate] = function(self) + return self._parent:getEmoji(self._target_id) + end, + + [actionType.emojiUpdate] = function(self) + return self._parent:getEmoji(self._target_id) + end, + + [actionType.emojiDelete] = function(self) + return self._parent:getEmoji(self._target_id) + end, + + [actionType.messageDelete] = function(self) + return self._parent._parent:getUser(self._target_id) + end, + +}, {__index = function() return unknown end}) + +--[=[ +@m getTarget +@t http? +@r * +@d Gets the target object of the affected entity. The returned object can be: [[Guild]], +[[GuildChannel]], [[User]], [[Member]], [[Role]], [[Webhook]], [[Emoji]], nil +]=] +function AuditLogEntry:getTarget() + return targets[self._action_type](self) +end + +--[=[ +@m getUser +@t http? +@r User +@d Gets the user who performed the changes. +]=] +function AuditLogEntry:getUser() + return self._parent._parent:getUser(self._user_id) +end + +--[=[ +@m getMember +@t http? +@r Member/nil +@d Gets the member object of the user who performed the changes. +]=] +function AuditLogEntry:getMember() + return self._parent:getMember(self._user_id) +end + +--[=[@p changes table/nil A table of audit log change objects. The key represents +the property of the changed target and the value contains a table of `new` and +possibly `old`, representing the property's new and old value.]=] +function get.changes(self) + return self._changes +end + +--[=[@p options table/nil A table of optional audit log information.]=] +function get.options(self) + return self._options +end + +--[=[@p actionType number The action type. Use the `actionType `enumeration +for a human-readable representation.]=] +function get.actionType(self) + return self._action_type +end + +--[=[@p targetId string/nil The Snowflake ID of the affected entity. Will +be `nil` for certain targets.]=] +function get.targetId(self) + return self._target_id +end + +--[=[@p userId string The Snowflake ID of the user who commited the action.]=] +function get.userId(self) + return self._user_id +end + +--[=[@p reason string/nil The reason provided by the user for the change.]=] +function get.reason(self) + return self._reason +end + +--[=[@p guild Guild The guild in which this audit log entry was found.]=] +function get.guild(self) + return self._parent +end + +return AuditLogEntry diff --git a/deps/discordia/libs/containers/Ban.lua b/deps/discordia/libs/containers/Ban.lua new file mode 100644 index 0000000..09d37d5 --- /dev/null +++ b/deps/discordia/libs/containers/Ban.lua @@ -0,0 +1,52 @@ +--[=[ +@c Ban x Container +@d Represents a Discord guild ban. Essentially a combination of the banned user and +a reason explaining the ban, if one was provided. +]=] + +local Container = require('containers/abstract/Container') + +local Ban, get = require('class')('Ban', Container) + +function Ban:__init(data, parent) + Container.__init(self, data, parent) + self._user = self.client._users:_insert(data.user) +end + +--[=[ +@m __hash +@r string +@d Returns `Ban.user.id` +]=] +function Ban:__hash() + return self._user._id +end + +--[=[ +@m delete +@t http +@r boolean +@d Deletes the ban object, unbanning the corresponding user. +Equivalent to `Ban.guild:unbanUser(Ban.user)`. +]=] +function Ban:delete() + return self._parent:unbanUser(self._user) +end + +--[=[@p reason string/nil The reason for the ban, if one was set. This should be from 1 to 512 characters +in length.]=] +function get.reason(self) + return self._reason +end + +--[=[@p guild Guild The guild in which this ban object exists.]=] +function get.guild(self) + return self._parent +end + +--[=[@p user User The user that this ban object represents.]=] +function get.user(self) + return self._user +end + +return Ban diff --git a/deps/discordia/libs/containers/Emoji.lua b/deps/discordia/libs/containers/Emoji.lua new file mode 100644 index 0000000..52c4de6 --- /dev/null +++ b/deps/discordia/libs/containers/Emoji.lua @@ -0,0 +1,168 @@ +--[=[ +@c Emoji x Snowflake +@d Represents a custom emoji object usable in message content and reactions. +Standard unicode emojis do not have a class; they are just strings. +]=] + +local Snowflake = require('containers/abstract/Snowflake') +local Resolver = require('client/Resolver') +local ArrayIterable = require('iterables/ArrayIterable') +local json = require('json') + +local format = string.format + +local Emoji, get = require('class')('Emoji', Snowflake) + +function Emoji:__init(data, parent) + Snowflake.__init(self, data, parent) + self.client._emoji_map[self._id] = parent + return self:_loadMore(data) +end + +function Emoji:_load(data) + Snowflake._load(self, data) + return self:_loadMore(data) +end + +function Emoji:_loadMore(data) + if data.roles then + local roles = #data.roles > 0 and data.roles or nil + if self._roles then + self._roles._array = roles + else + self._roles_raw = roles + end + end +end + +function Emoji:_modify(payload) + local data, err = self.client._api:modifyGuildEmoji(self._parent._id, self._id, payload) + if data then + self:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the emoji's name. The name must be between 2 and 32 characters in length. +]=] +function Emoji:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setRoles +@t http +@p roles Role-ID-Resolvables +@r boolean +@d Sets the roles that can use the emoji. +]=] +function Emoji:setRoles(roles) + roles = Resolver.roleIds(roles) + return self:_modify({roles = roles or json.null}) +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the emoji. This cannot be undone! +]=] +function Emoji:delete() + local data, err = self.client._api:deleteGuildEmoji(self._parent._id, self._id) + if data then + local cache = self._parent._emojis + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +--[=[ +@m hasRole +@t mem +@p id Role-ID-Resolvable +@r boolean +@d Returns whether or not the provided role is allowed to use the emoji. +]=] +function Emoji:hasRole(id) + id = Resolver.roleId(id) + local roles = self._roles and self._roles._array or self._roles_raw + if roles then + for _, v in ipairs(roles) do + if v == id then + return true + end + end + end + return false +end + +--[=[@p name string The name of the emoji.]=] +function get.name(self) + return self._name +end + +--[=[@p guild Guild The guild in which the emoji exists.]=] +function get.guild(self) + return self._parent +end + +--[=[@p mentionString string A string that, when included in a message content, may resolve as an emoji image +in the official Discord client.]=] +function get.mentionString(self) + local fmt = self._animated and '' or '<:%s>' + return format(fmt, self.hash) +end + +--[=[@p url string The URL that can be used to view a full version of the emoji.]=] +function get.url(self) + local ext = self._animated and 'gif' or 'png' + return format('https://cdn.discordapp.com/emojis/%s.%s', self._id, ext) +end + +--[=[@p managed boolean Whether this emoji is managed by an integration such as Twitch or YouTube.]=] +function get.managed(self) + return self._managed +end + +--[=[@p requireColons boolean Whether this emoji requires colons to be used in the official Discord client.]=] +function get.requireColons(self) + return self._require_colons +end + +--[=[@p hash string String with the format `name:id`, used in HTTP requests. +This is different from `Emoji:__hash`, which returns only the Snowflake ID. +]=] +function get.hash(self) + return self._name .. ':' .. self._id +end + +--[=[@p animated boolean Whether this emoji is animated.]=] +function get.animated(self) + return self._animated +end + +--[=[@p roles ArrayIterable An iterable array of roles that may be required to use this emoji, generally +related to integration-managed emojis. Object order is not guaranteed.]=] +function get.roles(self) + if not self._roles then + local roles = self._parent._roles + self._roles = ArrayIterable(self._roles_raw, function(id) + return roles:get(id) + end) + self._roles_raw = nil + end + return self._roles +end + +return Emoji diff --git a/deps/discordia/libs/containers/GroupChannel.lua b/deps/discordia/libs/containers/GroupChannel.lua new file mode 100644 index 0000000..4fc4ced --- /dev/null +++ b/deps/discordia/libs/containers/GroupChannel.lua @@ -0,0 +1,122 @@ +--[=[ +@c GroupChannel x TextChannel +@d Represents a Discord group channel. Essentially a private channel that may have +more than one and up to ten recipients. This class should only be relevant to +user-accounts; bots cannot normally join group channels. +]=] + +local json = require('json') + +local TextChannel = require('containers/abstract/TextChannel') +local SecondaryCache = require('iterables/SecondaryCache') +local Resolver = require('client/Resolver') + +local format = string.format + +local GroupChannel, get = require('class')('GroupChannel', TextChannel) + +function GroupChannel:__init(data, parent) + TextChannel.__init(self, data, parent) + self._recipients = SecondaryCache(data.recipients, self.client._users) +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the channel's name. This must be between 1 and 100 characters in length. +]=] +function GroupChannel:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setIcon +@t http +@p icon Base64-Resolvable +@r boolean +@d Sets the channel's icon. To remove the icon, pass `nil`. +]=] +function GroupChannel:setIcon(icon) + icon = icon and Resolver.base64(icon) + return self:_modify({icon = icon or json.null}) +end + +--[=[ +@m addRecipient +@t http +@p id User-ID-Resolvable +@r boolean +@d Adds a user to the channel. +]=] +function GroupChannel:addRecipient(id) + id = Resolver.userId(id) + local data, err = self.client._api:groupDMAddRecipient(self._id, id) + if data then + return true + else + return false, err + end +end + +--[=[ +@m removeRecipient +@t http +@p id User-ID-Resolvable +@r boolean +@d Removes a user from the channel. +]=] +function GroupChannel:removeRecipient(id) + id = Resolver.userId(id) + local data, err = self.client._api:groupDMRemoveRecipient(self._id, id) + if data then + return true + else + return false, err + end +end + +--[=[ +@m leave +@t http +@r boolean +@d Removes the client's user from the channel. If no users remain, the channel +is destroyed. +]=] +function GroupChannel:leave() + return self:_delete() +end + +--[=[@p recipients SecondaryCache A secondary cache of users that are present in the channel.]=] +function get.recipients(self) + return self._recipients +end + +--[=[@p name string The name of the channel.]=] +function get.name(self) + return self._name +end + +--[=[@p ownerId string The Snowflake ID of the user that owns (created) the channel.]=] +function get.ownerId(self) + return self._owner_id +end + +--[=[@p owner User/nil Equivalent to `GroupChannel.recipients:get(GroupChannel.ownerId)`.]=] +function get.owner(self) + return self._recipients:get(self._owner_id) +end + +--[=[@p icon string/nil The hash for the channel's custom icon, if one is set.]=] +function get.icon(self) + return self._icon +end + +--[=[@p iconURL string/nil The URL that can be used to view the channel's icon, if one is set.]=] +function get.iconURL(self) + local icon = self._icon + return icon and format('https://cdn.discordapp.com/channel-icons/%s/%s.png', self._id, icon) +end + +return GroupChannel diff --git a/deps/discordia/libs/containers/Guild.lua b/deps/discordia/libs/containers/Guild.lua new file mode 100644 index 0000000..51603f4 --- /dev/null +++ b/deps/discordia/libs/containers/Guild.lua @@ -0,0 +1,921 @@ +--[=[ +@c Guild x Snowflake +@d Represents a Discord guild (or server). Guilds are a collection of members, +channels, and roles that represents one community. +]=] + +local Cache = require('iterables/Cache') +local Role = require('containers/Role') +local Emoji = require('containers/Emoji') +local Invite = require('containers/Invite') +local Webhook = require('containers/Webhook') +local Ban = require('containers/Ban') +local Member = require('containers/Member') +local Resolver = require('client/Resolver') +local AuditLogEntry = require('containers/AuditLogEntry') +local GuildTextChannel = require('containers/GuildTextChannel') +local GuildVoiceChannel = require('containers/GuildVoiceChannel') +local GuildCategoryChannel = require('containers/GuildCategoryChannel') +local Snowflake = require('containers/abstract/Snowflake') + +local json = require('json') +local enums = require('enums') + +local channelType = enums.channelType +local floor = math.floor +local format = string.format + +local Guild, get = require('class')('Guild', Snowflake) + +function Guild:__init(data, parent) + Snowflake.__init(self, data, parent) + self._roles = Cache({}, Role, self) + self._emojis = Cache({}, Emoji, self) + self._members = Cache({}, Member, self) + self._text_channels = Cache({}, GuildTextChannel, self) + self._voice_channels = Cache({}, GuildVoiceChannel, self) + self._categories = Cache({}, GuildCategoryChannel, self) + self._voice_states = {} + if not data.unavailable then + return self:_makeAvailable(data) + end +end + +function Guild:_load(data) + Snowflake._load(self, data) + return self:_loadMore(data) +end + +function Guild:_loadMore(data) + if data.features then + self._features = data.features + end +end + +function Guild:_makeAvailable(data) + + self._roles:_load(data.roles) + self._emojis:_load(data.emojis) + self:_loadMore(data) + + if not data.channels then return end -- incomplete guild + + local states = self._voice_states + for _, state in ipairs(data.voice_states) do + states[state.user_id] = state + end + + local text_channels = self._text_channels + local voice_channels = self._voice_channels + local categories = self._categories + + for _, channel in ipairs(data.channels) do + local t = channel.type + if t == channelType.text or t == channelType.news then + text_channels:_insert(channel) + elseif t == channelType.voice then + voice_channels:_insert(channel) + elseif t == channelType.category then + categories:_insert(channel) + end + end + + return self:_loadMembers(data) + +end + +function Guild:_loadMembers(data) + local members = self._members + members:_load(data.members) + for _, presence in ipairs(data.presences) do + local member = members:get(presence.user.id) + if member then -- rogue presence check + member:_loadPresence(presence) + end + end + if self._large and self.client._options.cacheAllMembers then + return self:requestMembers() + end +end + +function Guild:_modify(payload) + local data, err = self.client._api:modifyGuild(self._id, payload) + if data then + self:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m requestMembers +@t ws +@r boolean +@d Asynchronously loads all members for this guild. You do not need to call this +if the `cacheAllMembers` client option (and the `syncGuilds` option for +user-accounts) is enabled on start-up. +]=] +function Guild:requestMembers() + local shard = self.client._shards[self.shardId] + if not shard then + return false, 'Invalid shard' + end + if shard._loading then + shard._loading.chunks[self._id] = true + end + return shard:requestGuildMembers(self._id) +end + +--[=[ +@m sync +@t ws +@r boolean +@d Asynchronously loads certain data and enables the receiving of certain events +for this guild. You do not need to call this if the `syncGuilds` client option +is enabled on start-up. + +Note: This is only for user accounts. Bot accounts never need to sync guilds! +]=] +function Guild:sync() + local shard = self.client._shards[self.shardId] + if not shard then + return false, 'Invalid shard' + end + if shard._loading then + shard._loading.syncs[self._id] = true + end + return shard:syncGuilds({self._id}) +end + +--[=[ +@m getMember +@t http? +@p id User-ID-Resolvable +@r Member +@d Gets a member object by ID. If the object is already cached, then the cached +object will be returned; otherwise, an HTTP request is made. +]=] +function Guild:getMember(id) + id = Resolver.userId(id) + local member = self._members:get(id) + if member then + return member + else + local data, err = self.client._api:getGuildMember(self._id, id) + if data then + return self._members:_insert(data) + else + return nil, err + end + end +end + +--[=[ +@m getRole +@t mem +@p id Role-ID-Resolvable +@r Role +@d Gets a role object by ID. +]=] +function Guild:getRole(id) + id = Resolver.roleId(id) + return self._roles:get(id) +end + +--[=[ +@m getEmoji +@t mem +@p id Emoji-ID-Resolvable +@r Emoji +@d Gets a emoji object by ID. +]=] +function Guild:getEmoji(id) + id = Resolver.emojiId(id) + return self._emojis:get(id) +end + +--[=[ +@m getChannel +@t mem +@p id Channel-ID-Resolvable +@r GuildChannel +@d Gets a text, voice, or category channel object by ID. +]=] +function Guild:getChannel(id) + id = Resolver.channelId(id) + return self._text_channels:get(id) or self._voice_channels:get(id) or self._categories:get(id) +end + +--[=[ +@m createTextChannel +@t http +@p name string +@r GuildTextChannel +@d Creates a new text channel in this guild. The name must be between 2 and 100 +characters in length. +]=] +function Guild:createTextChannel(name) + local data, err = self.client._api:createGuildChannel(self._id, {name = name, type = channelType.text}) + if data then + return self._text_channels:_insert(data) + else + return nil, err + end +end + +--[=[ +@m createVoiceChannel +@t http +@p name string +@r GuildVoiceChannel +@d Creates a new voice channel in this guild. The name must be between 2 and 100 +characters in length. +]=] +function Guild:createVoiceChannel(name) + local data, err = self.client._api:createGuildChannel(self._id, {name = name, type = channelType.voice}) + if data then + return self._voice_channels:_insert(data) + else + return nil, err + end +end + +--[=[ +@m createCategory +@t http +@p name string +@r GuildCategoryChannel +@d Creates a channel category in this guild. The name must be between 2 and 100 +characters in length. +]=] +function Guild:createCategory(name) + local data, err = self.client._api:createGuildChannel(self._id, {name = name, type = channelType.category}) + if data then + return self._categories:_insert(data) + else + return nil, err + end +end + +--[=[ +@m createRole +@t http +@p name string +@r Role +@d Creates a new role in this guild. The name must be between 1 and 100 characters +in length. +]=] +function Guild:createRole(name) + local data, err = self.client._api:createGuildRole(self._id, {name = name}) + if data then + return self._roles:_insert(data) + else + return nil, err + end +end + +--[=[ +@m createEmoji +@t http +@p name string +@p image Base64-Resolvable +@r Emoji +@d Creates a new emoji in this guild. The name must be between 2 and 32 characters +in length. The image must not be over 256kb, any higher will return a 400 Bad Request +]=] +function Guild:createEmoji(name, image) + image = Resolver.base64(image) + local data, err = self.client._api:createGuildEmoji(self._id, {name = name, image = image}) + if data then + return self._emojis:_insert(data) + else + return nil, err + end +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the guilds name. This must be between 2 and 100 characters in length. +]=] +function Guild:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setRegion +@t http +@p region string +@r boolean +@d Sets the guild's voice region (eg: `us-east`). See `listVoiceRegions` for a list +of acceptable regions. +]=] +function Guild:setRegion(region) + return self:_modify({region = region or json.null}) +end + +--[=[ +@m setVerificationLevel +@t http +@p verification_level number +@r boolean +@d Sets the guild's verification level setting. See the `verificationLevel` +enumeration for acceptable values. +]=] +function Guild:setVerificationLevel(verification_level) + return self:_modify({verification_level = verification_level or json.null}) +end + +--[=[ +@m setNotificationSetting +@t http +@p default_message_notifications number +@r boolean +@d Sets the guild's default notification setting. See the `notficationSetting` +enumeration for acceptable values. +]=] +function Guild:setNotificationSetting(default_message_notifications) + return self:_modify({default_message_notifications = default_message_notifications or json.null}) +end + +--[=[ +@m setExplicitContentSetting +@t http +@p explicit_content_filter number +@r boolean +@d Sets the guild's explicit content level setting. See the `explicitContentLevel` +enumeration for acceptable values. +]=] +function Guild:setExplicitContentSetting(explicit_content_filter) + return self:_modify({explicit_content_filter = explicit_content_filter or json.null}) +end + +--[=[ +@m setAFKTimeout +@t http +@p afk_timeout number +@r number +@d Sets the guild's AFK timeout in seconds. +]=] +function Guild:setAFKTimeout(afk_timeout) + return self:_modify({afk_timeout = afk_timeout or json.null}) +end + +--[=[ +@m setAFKChannel +@t http +@p id Channel-ID-Resolvable +@r boolean +@d Sets the guild's AFK channel. +]=] +function Guild:setAFKChannel(id) + id = id and Resolver.channelId(id) + return self:_modify({afk_channel_id = id or json.null}) +end + +--[=[ +@m setSystemChannel +@t http +@p id Channel-Id-Resolvable +@r boolean +@d Sets the guild's join message channel. +]=] +function Guild:setSystemChannel(id) + id = id and Resolver.channelId(id) + return self:_modify({system_channel_id = id or json.null}) +end + +--[=[ +@m setOwner +@t http +@p id User-ID-Resolvable +@r boolean +@d Transfers ownership of the guild to another user. Only the current guild owner +can do this. +]=] +function Guild:setOwner(id) + id = id and Resolver.userId(id) + return self:_modify({owner_id = id or json.null}) +end + +--[=[ +@m setIcon +@t http +@p icon Base64-Resolvable +@r boolean +@d Sets the guild's icon. To remove the icon, pass `nil`. +]=] +function Guild:setIcon(icon) + icon = icon and Resolver.base64(icon) + return self:_modify({icon = icon or json.null}) +end + +--[=[ +@m setBanner +@t http +@p banner Base64-Resolvable +@r boolean +@d Sets the guild's banner. To remove the banner, pass `nil`. +]=] +function Guild:setBanner(banner) + banner = banner and Resolver.base64(banner) + return self:_modify({banner = banner or json.null}) +end + +--[=[ +@m setSplash +@t http +@p splash Base64-Resolvable +@r boolean +@d Sets the guild's splash. To remove the splash, pass `nil`. +]=] +function Guild:setSplash(splash) + splash = splash and Resolver.base64(splash) + return self:_modify({splash = splash or json.null}) +end + +--[=[ +@m getPruneCount +@t http +@op days number +@r number +@d Returns the number of members that would be pruned from the guild if a prune +were to be executed. +]=] +function Guild:getPruneCount(days) + local data, err = self.client._api:getGuildPruneCount(self._id, days and {days = days} or nil) + if data then + return data.pruned + else + return nil, err + end +end + +--[=[ +@m pruneMembers +@t http +@op days number +@op count boolean +@r number +@d Prunes (removes) inactive, roleless members from the guild who have not been online in the last provided days. +If the `count` boolean is provided, the number of pruned members is returned; otherwise, `0` is returned. +]=] +function Guild:pruneMembers(days, count) + local t1 = type(days) + if t1 == 'number' then + count = type(count) == 'boolean' and count + elseif t1 == 'boolean' then + count = days + days = nil + end + local data, err = self.client._api:beginGuildPrune(self._id, nil, { + days = days, + compute_prune_count = count, + }) + if data then + return data.pruned + else + return nil, err + end +end + +--[=[ +@m getBans +@t http +@r Cache +@d Returns a newly constructed cache of all ban objects for the guild. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. +]=] +function Guild:getBans() + local data, err = self.client._api:getGuildBans(self._id) + if data then + return Cache(data, Ban, self) + else + return nil, err + end +end + +--[=[ +@m getBan +@t http +@p id User-ID-Resolvable +@r Ban +@d This will return a Ban object for a giver user if that user is banned +from the guild; otherwise, `nil` is returned. +]=] +function Guild:getBan(id) + id = Resolver.userId(id) + local data, err = self.client._api:getGuildBan(self._id, id) + if data then + return Ban(data, self) + else + return nil, err + end +end + +--[=[ +@m getInvites +@t http +@r Cache +@d Returns a newly constructed cache of all invite objects for the guild. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. +]=] +function Guild:getInvites() + local data, err = self.client._api:getGuildInvites(self._id) + if data then + return Cache(data, Invite, self.client) + else + return nil, err + end +end + +--[=[ +@m getAuditLogs +@t http +@op query table +@r Cache +@d Returns a newly constructed cache of audit log entry objects for the guild. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. + +If included, the query parameters include: query.limit: number, query.user: UserId Resolvable +query.before: EntryId Resolvable, query.type: ActionType Resolvable +]=] +function Guild:getAuditLogs(query) + if type(query) == 'table' then + query = { + limit = query.limit, + user_id = Resolver.userId(query.user), + before = Resolver.entryId(query.before), + action_type = Resolver.actionType(query.type), + } + end + local data, err = self.client._api:getGuildAuditLog(self._id, query) + if data then + self.client._users:_load(data.users) + self.client._webhooks:_load(data.webhooks) + return Cache(data.audit_log_entries, AuditLogEntry, self) + else + return nil, err + end +end + +--[=[ +@m getWebhooks +@t http +@r Cache +@d Returns a newly constructed cache of all webhook objects for the guild. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. +]=] +function Guild:getWebhooks() + local data, err = self.client._api:getGuildWebhooks(self._id) + if data then + return Cache(data, Webhook, self.client) + else + return nil, err + end +end + +--[=[ +@m listVoiceRegions +@t http +@r table +@d Returns a raw data table that contains a list of available voice regions for +this guild, as provided by Discord, with no additional parsing. +]=] +function Guild:listVoiceRegions() + return self.client._api:getGuildVoiceRegions(self._id) +end + +--[=[ +@m leave +@t http +@r boolean +@d Removes the current user from the guild. +]=] +function Guild:leave() + local data, err = self.client._api:leaveGuild(self._id) + if data then + return true + else + return false, err + end +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the guild. The current user must owner the server. This cannot be undone! +]=] +function Guild:delete() + local data, err = self.client._api:deleteGuild(self._id) + if data then + local cache = self._parent._guilds + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +--[=[ +@m kickUser +@t http +@p id User-ID-Resolvable +@op reason string +@r boolean +@d Kicks a user/member from the guild with an optional reason. +]=] +function Guild:kickUser(id, reason) + id = Resolver.userId(id) + local query = reason and {reason = reason} + local data, err = self.client._api:removeGuildMember(self._id, id, query) + if data then + return true + else + return false, err + end +end + +--[=[ +@m banUser +@t http +@p id User-ID-Resolvable +@op reason string +@op days number +@r boolean +@d Bans a user/member from the guild with an optional reason. The `days` parameter +is the number of days to consider when purging messages, up to 7. +]=] +function Guild:banUser(id, reason, days) + local query = reason and {reason = reason} + if days then + query = query or {} + query['delete-message-days'] = days + end + id = Resolver.userId(id) + local data, err = self.client._api:createGuildBan(self._id, id, query) + if data then + return true + else + return false, err + end +end + +--[=[ +@m unbanUser +@t http +@p id User-ID-Resolvable +@op reason string +@r boolean +@d Unbans a user/member from the guild with an optional reason. +]=] +function Guild:unbanUser(id, reason) + id = Resolver.userId(id) + local query = reason and {reason = reason} + local data, err = self.client._api:removeGuildBan(self._id, id, query) + if data then + return true + else + return false, err + end +end + +--[=[@p shardId number The ID of the shard on which this guild is served. If only one shard is in +operation, then this will always be 0.]=] +function get.shardId(self) + return floor(self._id / 2^22) % self.client._total_shard_count +end + +--[=[@p name string The guild's name. This should be between 2 and 100 characters in length.]=] +function get.name(self) + return self._name +end + +--[=[@p icon string/nil The hash for the guild's custom icon, if one is set.]=] +function get.icon(self) + return self._icon +end + +--[=[@p iconURL string/nil The URL that can be used to view the guild's icon, if one is set.]=] +function get.iconURL(self) + local icon = self._icon + return icon and format('https://cdn.discordapp.com/icons/%s/%s.png', self._id, icon) +end + +--[=[@p splash string/nil The hash for the guild's custom splash image, if one is set. Only partnered +guilds may have this.]=] +function get.splash(self) + return self._splash +end + +--[=[@p splashURL string/nil The URL that can be used to view the guild's custom splash image, if one is set. +Only partnered guilds may have this.]=] +function get.splashURL(self) + local splash = self._splash + return splash and format('https://cdn.discordapp.com/splashes/%s/%s.png', self._id, splash) +end + +--[=[@p banner string/nil The hash for the guild's custom banner, if one is set.]=] +function get.banner(self) + return self._banner +end + +--[=[@p bannerURL string/nil The URL that can be used to view the guild's banner, if one is set.]=] +function get.bannerURL(self) + local banner = self._banner + return banner and format('https://cdn.discordapp.com/banners/%s/%s.png', self._id, banner) +end + +--[=[@p large boolean Whether the guild has an arbitrarily large amount of members. Guilds that are +"large" will not initialize with all members cached.]=] +function get.large(self) + return self._large +end + +--[=[@p lazy boolean Whether the guild follows rules for the lazy-loading of client data.]=] +function get.lazy(self) + return self._lazy +end + +--[=[@p region string The voice region that is used for all voice connections in the guild.]=] +function get.region(self) + return self._region +end + +--[=[@p vanityCode string/nil The guild's vanity invite URL code, if one exists.]=] +function get.vanityCode(self) + return self._vanity_url_code +end + +--[=[@p description string/nil The guild's custom description, if one exists.]=] +function get.description(self) + return self._description +end + +--[=[@p maxMembers number/nil The guild's maximum member count, if available.]=] +function get.maxMembers(self) + return self._max_members +end + +--[=[@p maxPresences number/nil The guild's maximum presence count, if available.]=] +function get.maxPresences(self) + return self._max_presences +end + +--[=[@p mfaLevel number The guild's multi-factor (or two-factor) verification level setting. A value of +0 indicates that MFA is not required; a value of 1 indicates that MFA is +required for administrative actions.]=] +function get.mfaLevel(self) + return self._mfa_level +end + +--[=[@p joinedAt string The date and time at which the current user joined the guild, represented as +an ISO 8601 string plus microseconds when available.]=] +function get.joinedAt(self) + return self._joined_at +end + +--[=[@p afkTimeout number The guild's voice AFK timeout in seconds.]=] +function get.afkTimeout(self) + return self._afk_timeout +end + +--[=[@p unavailable boolean Whether the guild is unavailable. If the guild is unavailable, then no property +is guaranteed to exist except for this one and the guild's ID.]=] +function get.unavailable(self) + return self._unavailable or false +end + +--[=[@p totalMemberCount number The total number of members that belong to this guild. This should always be +greater than or equal to the total number of cached members.]=] +function get.totalMemberCount(self) + return self._member_count +end + +--[=[@p verificationLevel number The guild's verification level setting. See the `verificationLevel` +enumeration for a human-readable representation.]=] +function get.verificationLevel(self) + return self._verification_level +end + +--[=[@p notificationSetting number The guild's default notification setting. See the `notficationSetting` +enumeration for a human-readable representation.]=] +function get.notificationSetting(self) + return self._default_message_notifications +end + +--[=[@p explicitContentSetting number The guild's explicit content level setting. See the `explicitContentLevel` +enumeration for a human-readable representation.]=] +function get.explicitContentSetting(self) + return self._explicit_content_filter +end + +--[=[@p premiumTier number The guild's premier tier affected by nitro server +boosts. See the `premiumTier` enumeration for a human-readable representation]=] +function get.premiumTier(self) + return self._premium_tier +end + +--[=[@p premiumSubscriptionCount number The number of users that have upgraded +the guild with nitro server boosting.]=] +function get.premiumSubscriptionCount(self) + return self._premium_subscription_count +end + +--[=[@p features table Raw table of VIP features that are enabled for the guild.]=] +function get.features(self) + return self._features +end + +--[=[@p me Member/nil Equivalent to `Guild.members:get(Guild.client.user.id)`.]=] +function get.me(self) + return self._members:get(self.client._user._id) +end + +--[=[@p owner Member/nil Equivalent to `Guild.members:get(Guild.ownerId)`.]=] +function get.owner(self) + return self._members:get(self._owner_id) +end + +--[=[@p ownerId string The Snowflake ID of the guild member that owns the guild.]=] +function get.ownerId(self) + return self._owner_id +end + +--[=[@p afkChannelId string/nil The Snowflake ID of the channel that is used for AFK members, if one is set.]=] +function get.afkChannelId(self) + return self._afk_channel_id +end + +--[=[@p afkChannel GuildVoiceChannel/nil Equivalent to `Guild.voiceChannels:get(Guild.afkChannelId)`.]=] +function get.afkChannel(self) + return self._voice_channels:get(self._afk_channel_id) +end + +--[=[@p systemChannelId string/nil The channel id where Discord's join messages will be displayed.]=] +function get.systemChannelId(self) + return self._system_channel_id +end + +--[=[@p systemChannel GuildTextChannel/nil The channel where Discord's join messages will be displayed.]=] +function get.systemChannel(self) + return self._text_channels:get(self._system_channel_id) +end + +--[=[@p defaultRole Role Equivalent to `Guild.roles:get(Guild.id)`.]=] +function get.defaultRole(self) + return self._roles:get(self._id) +end + +--[=[@p connection VoiceConnection/nil The VoiceConnection for this guild if one exists.]=] +function get.connection(self) + return self._connection +end + +--[=[@p roles Cache An iterable cache of all roles that exist in this guild. This includes the +default everyone role.]=] +function get.roles(self) + return self._roles +end + +--[=[@p emojis Cache An iterable cache of all emojis that exist in this guild. Note that standard +unicode emojis are not found here; only custom emojis.]=] +function get.emojis(self) + return self._emojis +end + +--[=[@p members Cache An iterable cache of all members that exist in this guild and have been +already loaded. If the `cacheAllMembers` client option (and the `syncGuilds` +option for user-accounts) is enabled on start-up, then all members will be +cached. Otherwise, offline members may not be cached. To access a member that +may exist, but is not cached, use `Guild:getMember`.]=] +function get.members(self) + return self._members +end + +--[=[@p textChannels Cache An iterable cache of all text channels that exist in this guild.]=] +function get.textChannels(self) + return self._text_channels +end + +--[=[@p voiceChannels Cache An iterable cache of all voice channels that exist in this guild.]=] +function get.voiceChannels(self) + return self._voice_channels +end + +--[=[@p categories Cache An iterable cache of all channel categories that exist in this guild.]=] +function get.categories(self) + return self._categories +end + +return Guild diff --git a/deps/discordia/libs/containers/GuildCategoryChannel.lua b/deps/discordia/libs/containers/GuildCategoryChannel.lua new file mode 100644 index 0000000..8dedd10 --- /dev/null +++ b/deps/discordia/libs/containers/GuildCategoryChannel.lua @@ -0,0 +1,83 @@ +--[=[ +@c GuildCategoryChannel x GuildChannel +@d Represents a channel category in a Discord guild, used to organize individual +text or voice channels in that guild. +]=] + +local GuildChannel = require('containers/abstract/GuildChannel') +local FilteredIterable = require('iterables/FilteredIterable') +local enums = require('enums') + +local channelType = enums.channelType + +local GuildCategoryChannel, get = require('class')('GuildCategoryChannel', GuildChannel) + +function GuildCategoryChannel:__init(data, parent) + GuildChannel.__init(self, data, parent) +end + +--[=[ +@m createTextChannel +@t http +@p name string +@r GuildTextChannel +@d Creates a new GuildTextChannel with this category as it's parent. Similar to `Guild:createTextChannel(name)` +]=] +function GuildCategoryChannel:createTextChannel(name) + local guild = self._parent + local data, err = guild.client._api:createGuildChannel(guild._id, { + name = name, + type = channelType.text, + parent_id = self._id + }) + if data then + return guild._text_channels:_insert(data) + else + return nil, err + end +end + +--[=[ +@m createVoiceChannel +@t http +@p name string +@r GuildVoiceChannel +@d Creates a new GuildVoiceChannel with this category as it's parent. Similar to `Guild:createVoiceChannel(name)` +]=] +function GuildCategoryChannel:createVoiceChannel(name) + local guild = self._parent + local data, err = guild.client._api:createGuildChannel(guild._id, { + name = name, + type = channelType.voice, + parent_id = self._id + }) + if data then + return guild._voice_channels:_insert(data) + else + return nil, err + end +end + +--[=[@p textChannels FilteredIterable Iterable of all textChannels in the Category.]=] +function get.textChannels(self) + if not self._text_channels then + local id = self._id + self._text_channels = FilteredIterable(self._parent._text_channels, function(c) + return c._parent_id == id + end) + end + return self._text_channels +end + +--[=[@p voiceChannels FilteredIterable Iterable of all voiceChannels in the Category.]=] +function get.voiceChannels(self) + if not self._voice_channels then + local id = self._id + self._voice_channels = FilteredIterable(self._parent._voice_channels, function(c) + return c._parent_id == id + end) + end + return self._voice_channels +end + +return GuildCategoryChannel diff --git a/deps/discordia/libs/containers/GuildTextChannel.lua b/deps/discordia/libs/containers/GuildTextChannel.lua new file mode 100644 index 0000000..388f57b --- /dev/null +++ b/deps/discordia/libs/containers/GuildTextChannel.lua @@ -0,0 +1,165 @@ +--[=[ +@c GuildTextChannel x GuildChannel x TextChannel +@d Represents a text channel in a Discord guild, where guild members and webhooks +can send and receive messages. +]=] + +local json = require('json') + +local GuildChannel = require('containers/abstract/GuildChannel') +local TextChannel = require('containers/abstract/TextChannel') +local FilteredIterable = require('iterables/FilteredIterable') +local Webhook = require('containers/Webhook') +local Cache = require('iterables/Cache') +local Resolver = require('client/Resolver') + +local GuildTextChannel, get = require('class')('GuildTextChannel', GuildChannel, TextChannel) + +function GuildTextChannel:__init(data, parent) + GuildChannel.__init(self, data, parent) + TextChannel.__init(self, data, parent) +end + +function GuildTextChannel:_load(data) + GuildChannel._load(self, data) + TextChannel._load(self, data) +end + +--[=[ +@m createWebhook +@t http +@p name string +@r Webhook +@d Creates a webhook for this channel. The name must be between 2 and 32 characters +in length. +]=] +function GuildTextChannel:createWebhook(name) + local data, err = self.client._api:createWebhook(self._id, {name = name}) + if data then + return Webhook(data, self.client) + else + return nil, err + end +end + +--[=[ +@m getWebhooks +@t http +@r Cache +@d Returns a newly constructed cache of all webhook objects for the channel. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. +]=] +function GuildTextChannel:getWebhooks() + local data, err = self.client._api:getChannelWebhooks(self._id) + if data then + return Cache(data, Webhook, self.client) + else + return nil, err + end +end + +--[=[ +@m bulkDelete +@t http +@p messages Message-ID-Resolvables +@r boolean +@d Bulk deletes multiple messages, from 2 to 100, from the channel. Messages over +2 weeks old cannot be deleted and will return an error. +]=] +function GuildTextChannel:bulkDelete(messages) + messages = Resolver.messageIds(messages) + local data, err + if #messages == 1 then + data, err = self.client._api:deleteMessage(self._id, messages[1]) + else + data, err = self.client._api:bulkDeleteMessages(self._id, {messages = messages}) + end + if data then + return true + else + return false, err + end +end + +--[=[ +@m setTopic +@t http +@p topic string +@r boolean +@d Sets the channel's topic. This must be between 1 and 1024 characters. Pass `nil` +to remove the topic. +]=] +function GuildTextChannel:setTopic(topic) + return self:_modify({topic = topic or json.null}) +end + +--[=[ +@m setRateLimit +@t http +@p limit number +@r boolean +@d Sets the channel's slowmode rate limit in seconds. This must be between 0 and 120. +Passing 0 or `nil` will clear the limit. +]=] +function GuildTextChannel:setRateLimit(limit) + return self:_modify({rate_limit_per_user = limit or json.null}) +end + +--[=[ +@m enableNSFW +@t http +@r boolean +@d Enables the NSFW setting for the channel. NSFW channels are hidden from users +until the user explicitly requests to view them. +]=] +function GuildTextChannel:enableNSFW() + return self:_modify({nsfw = true}) +end + +--[=[ +@m disableNSFW +@t http +@r boolean +@d Disables the NSFW setting for the channel. NSFW channels are hidden from users +until the user explicitly requests to view them. +]=] +function GuildTextChannel:disableNSFW() + return self:_modify({nsfw = false}) +end + +--[=[@p topic string/nil The channel's topic. This should be between 1 and 1024 characters.]=] +function get.topic(self) + return self._topic +end + +--[=[@p nsfw boolean Whether this channel is marked as NSFW (not safe for work).]=] +function get.nsfw(self) + return self._nsfw or false +end + +--[=[@p rateLimit number Slowmode rate limit per guild member.]=] +function get.rateLimit(self) + return self._rate_limit_per_user or 0 +end + +--[=[@p isNews boolean Whether this channel is a news channel of type 5.]=] +function get.isNews(self) + return self._type == 5 +end + +--[=[@p members FilteredIterable A filtered iterable of guild members that have +permission to read this channel. If you want to check whether a specific member +has permission to read this channel, it would be better to get the member object +elsewhere and use `Member:hasPermission` rather than check whether the member +exists here.]=] +function get.members(self) + if not self._members then + self._members = FilteredIterable(self._parent._members, function(m) + return m:hasPermission(self, 'readMessages') + end) + end + return self._members +end + +return GuildTextChannel diff --git a/deps/discordia/libs/containers/GuildVoiceChannel.lua b/deps/discordia/libs/containers/GuildVoiceChannel.lua new file mode 100644 index 0000000..d7ce63c --- /dev/null +++ b/deps/discordia/libs/containers/GuildVoiceChannel.lua @@ -0,0 +1,138 @@ +--[=[ +@c GuildVoiceChannel x GuildChannel +@d Represents a voice channel in a Discord guild, where guild members can connect +and communicate via voice chat. +]=] + +local json = require('json') + +local GuildChannel = require('containers/abstract/GuildChannel') +local VoiceConnection = require('voice/VoiceConnection') +local TableIterable = require('iterables/TableIterable') + +local GuildVoiceChannel, get = require('class')('GuildVoiceChannel', GuildChannel) + +function GuildVoiceChannel:__init(data, parent) + GuildChannel.__init(self, data, parent) +end + +--[=[ +@m setBitrate +@t http +@p bitrate number +@r boolean +@d Sets the channel's audio bitrate in bits per second (bps). This must be between +8000 and 96000 (or 128000 for partnered servers). If `nil` is passed, the +default is set, which is 64000. +]=] +function GuildVoiceChannel:setBitrate(bitrate) + return self:_modify({bitrate = bitrate or json.null}) +end + +--[=[ +@m setUserLimit +@t http +@p user_limit number +@r boolean +@d Sets the channel's user limit. This must be between 0 and 99 (where 0 is +unlimited). If `nil` is passed, the default is set, which is 0. +]=] +function GuildVoiceChannel:setUserLimit(user_limit) + return self:_modify({user_limit = user_limit or json.null}) +end + +--[=[ +@m join +@t ws +@r VoiceConnection +@d Join this channel and form a connection to the Voice Gateway. +]=] +function GuildVoiceChannel:join() + + local success, err + + local connection = self._connection + + if connection then + + if connection._ready then + return connection + end + + else + + local guild = self._parent + local client = guild._parent + + success, err = client._shards[guild.shardId]:updateVoice(guild._id, self._id) + + if not success then + return nil, err + end + + connection = guild._connection + + if not connection then + connection = VoiceConnection(self) + guild._connection = connection + end + + self._connection = connection + + end + + success, err = connection:_await() + + if success then + return connection + else + return nil, err + end + +end + +--[=[ +@m leave +@t http +@r boolean +@d Leave this channel if there is an existing voice connection to it. +Equivalent to GuildVoiceChannel.connection:close() +]=] +function GuildVoiceChannel:leave() + if self._connection then + return self._connection:close() + else + return false, 'No voice connection exists for this channel' + end +end + +--[=[@p bitrate number The channel's bitrate in bits per second (bps). This should be between 8000 and +96000 (or 128000 for partnered servers).]=] +function get.bitrate(self) + return self._bitrate +end + +--[=[@p userLimit number The amount of users allowed to be in this channel. +Users with `moveMembers` permission ignore this limit.]=] +function get.userLimit(self) + return self._user_limit +end + +--[=[@p connectedMembers TableIterable An iterable of all users connected to the channel.]=] +function get.connectedMembers(self) + if not self._connected_members then + local id = self._id + local members = self._parent._members + self._connected_members = TableIterable(self._parent._voice_states, function(state) + return state.channel_id == id and members:get(state.user_id) + end) + end + return self._connected_members +end + +--[=[@p connection VoiceConnection/nil The VoiceConnection for this channel if one exists.]=] +function get.connection(self) + return self._connection +end + +return GuildVoiceChannel diff --git a/deps/discordia/libs/containers/Invite.lua b/deps/discordia/libs/containers/Invite.lua new file mode 100644 index 0000000..6479892 --- /dev/null +++ b/deps/discordia/libs/containers/Invite.lua @@ -0,0 +1,187 @@ +--[=[ +@c Invite x Container +@d Represents an invitation to a Discord guild channel. Invites can be used to join +a guild, though they are not always permanent. +]=] + +local Container = require('containers/abstract/Container') +local json = require('json') + +local format = string.format +local null = json.null + +local function load(v) + return v ~= null and v or nil +end + +local Invite, get = require('class')('Invite', Container) + +function Invite:__init(data, parent) + Container.__init(self, data, parent) + self._guild_id = load(data.guild.id) + self._channel_id = load(data.channel.id) + self._guild_name = load(data.guild.name) + self._guild_icon = load(data.guild.icon) + self._guild_splash = load(data.guild.splash) + self._guild_banner = load(data.guild.banner) + self._guild_description = load(data.guild.description) + self._guild_verification_level = load(data.guild.verification_level) + self._channel_name = load(data.channel.name) + self._channel_type = load(data.channel.type) + if data.inviter then + self._inviter = self.client._users:_insert(data.inviter) + end +end + +--[=[ +@m __hash +@r string +@d Returns `Invite.code` +]=] +function Invite:__hash() + return self._code +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the invite. This cannot be undone! +]=] +function Invite:delete() + local data, err = self.client._api:deleteInvite(self._code) + if data then + return true + else + return false, err + end +end + +--[=[@p code string The invite's code which can be used to identify the invite.]=] +function get.code(self) + return self._code +end + +--[=[@p guildId string The Snowflake ID of the guild to which this invite belongs.]=] +function get.guildId(self) + return self._guild_id +end + +--[=[@p guildName string The name of the guild to which this invite belongs.]=] +function get.guildName(self) + return self._guild_name +end + +--[=[@p channelId string The Snowflake ID of the channel to which this belongs.]=] +function get.channelId(self) + return self._channel_id +end + +--[=[@p channelName string The name of the channel to which this invite belongs.]=] +function get.channelName(self) + return self._channel_name +end + +--[=[@p channelType number The type of the channel to which this invite belongs. Use the `channelType` +enumeration for a human-readable representation.]=] +function get.channelType(self) + return self._channel_type +end + +--[=[@p guildIcon string/nil The hash for the guild's custom icon, if one is set.]=] +function get.guildIcon(self) + return self._guild_icon +end + +--[=[@p guildBanner string/nil The hash for the guild's custom banner, if one is set.]=] +function get.guildBanner(self) + return self._guild_banner +end + +--[=[@p guildSplash string/nil The hash for the guild's custom splash, if one is set.]=] +function get.guildSplash(self) + return self._guild_splash +end + +--[=[@p guildIconURL string/nil The URL that can be used to view the guild's icon, if one is set.]=] +function get.guildIconURL(self) + local icon = self._guild_icon + return icon and format('https://cdn.discordapp.com/icons/%s/%s.png', self._guild_id, icon) or nil +end + +--[=[@p guildBannerURL string/nil The URL that can be used to view the guild's banner, if one is set.]=] +function get.guildBannerURL(self) + local banner = self._guild_banner + return banner and format('https://cdn.discordapp.com/banners/%s/%s.png', self._guild_id, banner) or nil +end + +--[=[@p guildSplashURL string/nil The URL that can be used to view the guild's splash, if one is set.]=] +function get.guildSplashURL(self) + local splash = self._guild_splash + return splash and format('https://cdn.discordapp.com/splashs/%s/%s.png', self._guild_id, splash) or nil +end + +--[=[@p guildDescription string/nil The guild's custom description, if one is set.]=] +function get.guildDescription(self) + return self._guild_description +end + +--[=[@p guildVerificationLevel number/nil The guild's verification level, if available.]=] +function get.guildVerificationLevel(self) + return self._guild_verification_level +end + +--[=[@p inviter User/nil The object of the user that created the invite. This will not exist if the +invite is a guild widget or a vanity invite.]=] +function get.inviter(self) + return self._inviter +end + +--[=[@p uses number/nil How many times this invite has been used. This will not exist if the invite is +accessed via `Client:getInvite`.]=] +function get.uses(self) + return self._uses +end + +--[=[@p maxUses number/nil The maximum amount of times this invite can be used. This will not exist if the +invite is accessed via `Client:getInvite`.]=] +function get.maxUses(self) + return self._max_uses +end + +--[=[@p maxAge number/nil How long, in seconds, this invite lasts before it expires. This will not exist +if the invite is accessed via `Client:getInvite`.]=] +function get.maxAge(self) + return self._max_age +end + +--[=[@p temporary boolean/nil Whether the invite grants temporary membership. This will not exist if the +invite is accessed via `Client:getInvite`.]=] +function get.temporary(self) + return self._temporary +end + +--[=[@p createdAt string/nil The date and time at which the invite was created, represented as an ISO 8601 +string plus microseconds when available. This will not exist if the invite is +accessed via `Client:getInvite`.]=] +function get.createdAt(self) + return self._created_at +end + +--[=[@p revoked boolean/nil Whether the invite has been revoked. This will not exist if the invite is +accessed via `Client:getInvite`.]=] +function get.revoked(self) + return self._revoked +end + +--[=[@p approximatePresenceCount number/nil The approximate count of online members.]=] +function get.approximatePresenceCount(self) + return self._approximate_presence_count +end + +--[=[@p approximateMemberCount number/nil The approximate count of all members.]=] +function get.approximateMemberCount(self) + return self._approximate_member_count +end + +return Invite diff --git a/deps/discordia/libs/containers/Member.lua b/deps/discordia/libs/containers/Member.lua new file mode 100644 index 0000000..f3e24f1 --- /dev/null +++ b/deps/discordia/libs/containers/Member.lua @@ -0,0 +1,540 @@ +--[=[ +@c Member x UserPresence +@d Represents a Discord guild member. Though one user may be a member in more than +one guild, each presence is represented by a different member object associated +with that guild. Note that any method or property that exists for the User class is +also available in the Member class. +]=] + +local json = require('json') +local enums = require('enums') +local class = require('class') +local UserPresence = require('containers/abstract/UserPresence') +local ArrayIterable = require('iterables/ArrayIterable') +local Color = require('utils/Color') +local Resolver = require('client/Resolver') +local GuildChannel = require('containers/abstract/GuildChannel') +local Permissions = require('utils/Permissions') + +local insert, remove, sort = table.insert, table.remove, table.sort +local band, bor, bnot = bit.band, bit.bor, bit.bnot +local isInstance = class.isInstance +local permission = enums.permission + +local Member, get = class('Member', UserPresence) + +function Member:__init(data, parent) + UserPresence.__init(self, data, parent) + return self:_loadMore(data) +end + +function Member:_load(data) + UserPresence._load(self, data) + return self:_loadMore(data) +end + +function Member:_loadMore(data) + if data.roles then + local roles = #data.roles > 0 and data.roles or nil + if self._roles then + self._roles._array = roles + else + self._roles_raw = roles + end + end +end + +local function sorter(a, b) + if a._position == b._position then + return tonumber(a._id) < tonumber(b._id) + else + return a._position > b._position + end +end + +local function predicate(role) + return role._color > 0 +end + +--[=[ +@m getColor +@t mem +@r Color +@d Returns a color object that represents the member's color as determined by +its highest colored role. If the member has no colored roles, then the default +color with a value of 0 is returned. +]=] +function Member:getColor() + local roles = {} + for role in self.roles:findAll(predicate) do + insert(roles, role) + end + sort(roles, sorter) + return roles[1] and roles[1]:getColor() or Color() +end + +local function has(a, b) + return band(a, b) > 0 +end + +--[=[ +@m hasPermission +@t mem +@op channel GuildChannel +@p perm Permissions-Resolvable +@r boolean +@d Checks whether the member has a specific permission. If `channel` is omitted, +then only guild-level permissions are checked. This is a relatively expensive +operation. If you need to check multiple permissions at once, use the +`getPermissions` method and check the resulting object. +]=] +function Member:hasPermission(channel, perm) + + if not perm then + perm = channel + channel = nil + end + + local guild = self.guild + if channel then + if not isInstance(channel, GuildChannel) or channel.guild ~= guild then + return error('Invalid GuildChannel: ' .. tostring(channel), 2) + end + end + + local n = Resolver.permission(perm) + if not n then + return error('Invalid permission: ' .. tostring(perm), 2) + end + + if self.id == guild.ownerId then + return true + end + + local rolePermissions = guild.defaultRole.permissions + + for role in self.roles:iter() do + if role.id ~= guild.id then -- just in case + rolePermissions = bor(rolePermissions, role.permissions) + end + end + + if has(rolePermissions, permission.administrator) then + return true + end + + if channel then + + local overwrites = channel.permissionOverwrites + + local overwrite = overwrites:get(self.id) + if overwrite then + if has(overwrite.allowedPermissions, n) then + return true + end + if has(overwrite.deniedPermissions, n) then + return false + end + end + + local allow, deny = 0, 0 + for role in self.roles:iter() do + if role.id ~= guild.id then -- just in case + overwrite = overwrites:get(role.id) + if overwrite then + allow = bor(allow, overwrite.allowedPermissions) + deny = bor(deny, overwrite.deniedPermissions) + end + end + end + + if has(allow, n) then + return true + end + if has(deny, n) then + return false + end + + local everyone = overwrites:get(guild.id) + if everyone then + if has(everyone.allowedPermissions, n) then + return true + end + if has(everyone.deniedPermissions, n) then + return false + end + end + + end + + return has(rolePermissions, n) + +end + +--[=[ +@m getPermissions +@t mem +@op channel GuildChannel +@r Permissions +@d Returns a permissions object that represents the member's total permissions for +the guild, or for a specific channel if one is provided. If you just need to +check one permission, use the `hasPermission` method. +]=] +function Member:getPermissions(channel) + + local guild = self.guild + if channel then + if not isInstance(channel, GuildChannel) or channel.guild ~= guild then + return error('Invalid GuildChannel: ' .. tostring(channel), 2) + end + end + + if self.id == guild.ownerId then + return Permissions.all() + end + + local ret = guild.defaultRole.permissions + + for role in self.roles:iter() do + if role.id ~= guild.id then -- just in case + ret = bor(ret, role.permissions) + end + end + + if band(ret, permission.administrator) > 0 then + return Permissions.all() + end + + if channel then + + local overwrites = channel.permissionOverwrites + + local everyone = overwrites:get(guild.id) + if everyone then + ret = band(ret, bnot(everyone.deniedPermissions)) + ret = bor(ret, everyone.allowedPermissions) + end + + local allow, deny = 0, 0 + for role in self.roles:iter() do + if role.id ~= guild.id then -- just in case + local overwrite = overwrites:get(role.id) + if overwrite then + deny = bor(deny, overwrite.deniedPermissions) + allow = bor(allow, overwrite.allowedPermissions) + end + end + end + ret = band(ret, bnot(deny)) + ret = bor(ret, allow) + + local overwrite = overwrites:get(self.id) + if overwrite then + ret = band(ret, bnot(overwrite.deniedPermissions)) + ret = bor(ret, overwrite.allowedPermissions) + end + + end + + return Permissions(ret) + +end + +--[=[ +@m addRole +@t http? +@p id Role-ID-Resolvable +@r boolean +@d Adds a role to the member. If the member already has the role, then no action is +taken. Note that the everyone role cannot be explicitly added. +]=] +function Member:addRole(id) + if self:hasRole(id) then return true end + id = Resolver.roleId(id) + local data, err = self.client._api:addGuildMemberRole(self._parent._id, self.id, id) + if data then + local roles = self._roles and self._roles._array or self._roles_raw + if roles then + insert(roles, id) + else + self._roles_raw = {id} + end + return true + else + return false, err + end +end + +--[=[ +@m removeRole +@t http? +@p id Role-ID-Resolvable +@r boolean +@d Removes a role from the member. If the member does not have the role, then no +action is taken. Note that the everyone role cannot be removed. +]=] +function Member:removeRole(id) + if not self:hasRole(id) then return true end + id = Resolver.roleId(id) + local data, err = self.client._api:removeGuildMemberRole(self._parent._id, self.id, id) + if data then + local roles = self._roles and self._roles._array or self._roles_raw + if roles then + for i, v in ipairs(roles) do + if v == id then + remove(roles, i) + break + end + end + if #roles == 0 then + if self._roles then + self._roles._array = nil + else + self._roles_raw = nil + end + end + end + return true + else + return false, err + end +end + +--[=[ +@m hasRole +@t mem +@p id Role-ID-Resolvable +@r boolean +@d Checks whether the member has a specific role. This will return true for the +guild's default role in addition to any explicitly assigned roles. +]=] +function Member:hasRole(id) + id = Resolver.roleId(id) + if id == self._parent._id then return true end -- @everyone + local roles = self._roles and self._roles._array or self._roles_raw + if roles then + for _, v in ipairs(roles) do + if v == id then + return true + end + end + end + return false +end + +--[=[ +@m setNickname +@t http +@p nick string +@r boolean +@d Sets the member's nickname. This must be between 1 and 32 characters in length. +Pass `nil` to remove the nickname. +]=] +function Member:setNickname(nick) + nick = nick or '' + local data, err + if self.id == self.client._user._id then + data, err = self.client._api:modifyCurrentUsersNick(self._parent._id, {nick = nick}) + else + data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {nick = nick}) + end + if data then + self._nick = nick ~= '' and nick or nil + return true + else + return false, err + end +end + +--[=[ +@m setVoiceChannel +@t http +@p id Channel-ID-Resolvable +@r boolean +@d Moves the member to a new voice channel, but only if the member has an active +voice connection in the current guild. Due to complexities in voice state +handling, the member's `voiceChannel` property will update asynchronously via +WebSocket; not as a result of the HTTP request. +]=] +function Member:setVoiceChannel(id) + id = id and Resolver.channelId(id) + local data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {channel_id = id or json.null}) + if data then + return true + else + return false, err + end +end + +--[=[ +@m mute +@t http +@r boolean +@d Mutes the member in its guild. +]=] +function Member:mute() + local data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {mute = true}) + if data then + self._mute = true + return true + else + return false, err + end +end + +--[=[ +@m unmute +@t http +@r boolean +@d Unmutes the member in its guild. +]=] +function Member:unmute() + local data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {mute = false}) + if data then + self._mute = false + return true + else + return false, err + end +end + +--[=[ +@m deafen +@t http +@r boolean +@d Deafens the member in its guild. +]=] +function Member:deafen() + local data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {deaf = true}) + if data then + self._deaf = true + return true + else + return false, err + end +end + +--[=[ +@m undeafen +@t http +@r boolean +@d Undeafens the member in its guild. +]=] +function Member:undeafen() + local data, err = self.client._api:modifyGuildMember(self._parent._id, self.id, {deaf = false}) + if data then + self._deaf = false + return true + else + return false, err + end +end + +--[=[ +@m kick +@t http +@p reason string +@r boolean +@d Equivalent to `Member.guild:kickUser(Member.user, reason)` +]=] +function Member:kick(reason) + return self._parent:kickUser(self._user, reason) +end + +--[=[ +@m ban +@t http +@p reason string +@p days number +@r boolean +@d Equivalent to `Member.guild:banUser(Member.user, reason, days)` +]=] +function Member:ban(reason, days) + return self._parent:banUser(self._user, reason, days) +end + +--[=[ +@m unban +@t http +@p reason string +@r boolean +@d Equivalent to `Member.guild:unbanUser(Member.user, reason)` +]=] +function Member:unban(reason) + return self._parent:unbanUser(self._user, reason) +end + +--[=[@p roles ArrayIterable An iterable array of guild roles that the member has. This does not explicitly +include the default everyone role. Object order is not guaranteed.]=] +function get.roles(self) + if not self._roles then + local roles = self._parent._roles + self._roles = ArrayIterable(self._roles_raw, function(id) + return roles:get(id) + end) + self._roles_raw = nil + end + return self._roles +end + +--[=[@p name string If the member has a nickname, then this will be equivalent to that nickname. +Otherwise, this is equivalent to `Member.user.username`.]=] +function get.name(self) + return self._nick or self._user._username +end + +--[=[@p nickname string/nil The member's nickname, if one is set.]=] +function get.nickname(self) + return self._nick +end + +--[=[@p joinedAt string/nil The date and time at which the current member joined the guild, represented as +an ISO 8601 string plus microseconds when available. Member objects generated +via presence updates lack this property.]=] +function get.joinedAt(self) + return self._joined_at +end + +--[=[@p premiumSince string/nil The date and time at which the current member boosted the guild, represented as +an ISO 8601 string plus microseconds when available.]=] +function get.premiumSince(self) + return self._premium_since +end + +--[=[@p voiceChannel GuildVoiceChannel/nil The voice channel to which this member is connected in the current guild.]=] +function get.voiceChannel(self) + local guild = self._parent + local state = guild._voice_states[self:__hash()] + return state and guild._voice_channels:get(state.channel_id) +end + +--[=[@p muted boolean Whether the member is voice muted in its guild.]=] +function get.muted(self) + local state = self._parent._voice_states[self:__hash()] + return state and (state.mute or state.self_mute) or self._mute +end + +--[=[@p deafened boolean Whether the member is voice deafened in its guild.]=] +function get.deafened(self) + local state = self._parent._voice_states[self:__hash()] + return state and (state.deaf or state.self_deaf) or self._deaf +end + +--[=[@p guild Guild The guild in which this member exists.]=] +function get.guild(self) + return self._parent +end + +--[=[@p highestRole Role The highest positioned role that the member has. If the member has no +explicit roles, then this is equivalent to `Member.guild.defaultRole`.]=] +function get.highestRole(self) + local ret + for role in self.roles:iter() do + if not ret or sorter(role, ret) then + ret = role + end + end + return ret or self.guild.defaultRole +end + +return Member diff --git a/deps/discordia/libs/containers/Message.lua b/deps/discordia/libs/containers/Message.lua new file mode 100644 index 0000000..0fe0cf8 --- /dev/null +++ b/deps/discordia/libs/containers/Message.lua @@ -0,0 +1,601 @@ +--[=[ +@c Message x Snowflake +@d Represents a text message sent in a Discord text channel. Messages can contain +simple content strings, rich embeds, attachments, or reactions. +]=] + +local json = require('json') +local enums = require('enums') +local constants = require('constants') +local Cache = require('iterables/Cache') +local ArrayIterable = require('iterables/ArrayIterable') +local Snowflake = require('containers/abstract/Snowflake') +local Reaction = require('containers/Reaction') +local Resolver = require('client/Resolver') + +local insert = table.insert +local null = json.null +local format = string.format +local messageFlag = enums.messageFlag +local band, bor, bnot = bit.band, bit.bor, bit.bnot + +local Message, get = require('class')('Message', Snowflake) + +function Message:__init(data, parent) + Snowflake.__init(self, data, parent) + self._author = self.client._users:_insert(data.author) + if data.member then + data.member.user = data.author + self._parent._parent._members:_insert(data.member) + end + self._timestamp = nil -- waste of space; can be calculated from Snowflake ID + if data.reactions and #data.reactions > 0 then + self._reactions = Cache(data.reactions, Reaction, self) + end + return self:_loadMore(data) +end + +function Message:_load(data) + Snowflake._load(self, data) + return self:_loadMore(data) +end + +local function parseMentions(content, pattern) + if not content:find('%b<>') then return end + local mentions, seen = {}, {} + for id in content:gmatch(pattern) do + if not seen[id] then + insert(mentions, id) + seen[id] = true + end + end + return mentions +end + +function Message:_loadMore(data) + + local mentions = {} + if data.mentions then + for _, user in ipairs(data.mentions) do + mentions[user.id] = true + if user.member then + user.member.user = user + self._parent._parent._members:_insert(user.member) + else + self.client._users:_insert(user) + end + end + end + + if data.referenced_message and data.referenced_message ~= null then + if mentions[data.referenced_message.author.id] then + self._reply_target = data.referenced_message.author.id + end + self._referencedMessage = self._parent._messages:_insert(data.referenced_message) + end + + local content = data.content + if content then + if self._mentioned_users then + self._mentioned_users._array = parseMentions(content, '<@!?(%d+)>') + if self._reply_target then + insert(self._mentioned_users._array, 1, self._reply_target) + end + end + if self._mentioned_roles then + self._mentioned_roles._array = parseMentions(content, '<@&(%d+)>') + end + if self._mentioned_channels then + self._mentioned_channels._array = parseMentions(content, '<#(%d+)>') + end + if self._mentioned_emojis then + self._mentioned_emojis._array = parseMentions(content, '') + end + self._clean_content = nil + end + + if data.embeds then + self._embeds = #data.embeds > 0 and data.embeds or nil + end + + if data.attachments then + self._attachments = #data.attachments > 0 and data.attachments or nil + end + +end + +function Message:_addReaction(d) + + local reactions = self._reactions + + if not reactions then + reactions = Cache({}, Reaction, self) + self._reactions = reactions + end + + local emoji = d.emoji + local k = emoji.id ~= null and emoji.id or emoji.name + local reaction = reactions:get(k) + + if reaction then + reaction._count = reaction._count + 1 + if d.user_id == self.client._user._id then + reaction._me = true + end + else + d.me = d.user_id == self.client._user._id + d.count = 1 + reaction = reactions:_insert(d) + end + return reaction + +end + +function Message:_removeReaction(d) + + local reactions = self._reactions + if not reactions then return nil end + + local emoji = d.emoji + local k = emoji.id ~= null and emoji.id or emoji.name + local reaction = reactions:get(k) or nil + + if not reaction then return nil end -- uncached reaction? + + reaction._count = reaction._count - 1 + if d.user_id == self.client._user._id then + reaction._me = false + end + + if reaction._count == 0 then + reactions:_delete(k) + end + + return reaction + +end + +function Message:_setOldContent(d) + local ts = d.edited_timestamp + if not ts then return end + local old = self._old + if old then + old[ts] = old[ts] or self._content + else + self._old = {[ts] = self._content} + end +end + +function Message:_modify(payload) + local data, err = self.client._api:editMessage(self._parent._id, self._id, payload) + if data then + self:_setOldContent(data) + self:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m setContent +@t http +@p content string +@r boolean +@d Sets the message's content. The message must be authored by the current user +(ie: you cannot change the content of messages sent by other users). The content +must be from 1 to 2000 characters in length. +]=] +function Message:setContent(content) + return self:_modify({content = content or null}) +end + +--[=[ +@m setEmbed +@t http +@p embed table +@r boolean +@d Sets the message's embed. The message must be authored by the current user. +(ie: you cannot change the embed of messages sent by other users). +]=] +function Message:setEmbed(embed) + return self:_modify({embed = embed or null}) +end + +--[=[ +@m hideEmbeds +@t http +@r boolean +@d Hides all embeds for this message. +]=] +function Message:hideEmbeds() + local flags = bor(self._flags or 0, messageFlag.suppressEmbeds) + return self:_modify({flags = flags}) +end + +--[=[ +@m showEmbeds +@t http +@r boolean +@d Shows all embeds for this message. +]=] +function Message:showEmbeds() + local flags = band(self._flags or 0, bnot(messageFlag.suppressEmbeds)) + return self:_modify({flags = flags}) +end + +--[=[ +@m hasFlag +@t mem +@p flag Message-Flag-Resolvable +@r boolean +@d Indicates whether the message has a particular flag set. +]=] +function Message:hasFlag(flag) + flag = Resolver.messageFlag(flag) + return band(self._flags or 0, flag) > 0 +end + +--[=[ +@m update +@t http +@p data table +@r boolean +@d Sets multiple properties of the message at the same time using a table similar +to the one supported by `TextChannel.send`, except only `content` and `embed` +are valid fields; `mention(s)`, `file(s)`, etc are not supported. The message +must be authored by the current user. (ie: you cannot change the embed of messages +sent by other users). +]=] +function Message:update(data) + return self:_modify({ + content = data.content or null, + embed = data.embed or null, + }) +end + +--[=[ +@m pin +@t http +@r boolean +@d Pins the message in the channel. +]=] +function Message:pin() + local data, err = self.client._api:addPinnedChannelMessage(self._parent._id, self._id) + if data then + self._pinned = true + return true + else + return false, err + end +end + +--[=[ +@m unpin +@t http +@r boolean +@d Unpins the message in the channel. +]=] +function Message:unpin() + local data, err = self.client._api:deletePinnedChannelMessage(self._parent._id, self._id) + if data then + self._pinned = false + return true + else + return false, err + end +end + +--[=[ +@m addReaction +@t http +@p emoji Emoji-Resolvable +@r boolean +@d Adds a reaction to the message. Note that this does not return the new reaction +object; wait for the `reactionAdd` event instead. +]=] +function Message:addReaction(emoji) + emoji = Resolver.emoji(emoji) + local data, err = self.client._api:createReaction(self._parent._id, self._id, emoji) + if data then + return true + else + return false, err + end +end + +--[=[ +@m removeReaction +@t http +@p emoji Emoji-Resolvable +@op id User-ID-Resolvable +@r boolean +@d Removes a reaction from the message. Note that this does not return the old +reaction object; wait for the `reactionRemove` event instead. If no user is +indicated, then this will remove the current user's reaction. +]=] +function Message:removeReaction(emoji, id) + emoji = Resolver.emoji(emoji) + local data, err + if id then + id = Resolver.userId(id) + data, err = self.client._api:deleteUserReaction(self._parent._id, self._id, emoji, id) + else + data, err = self.client._api:deleteOwnReaction(self._parent._id, self._id, emoji) + end + if data then + return true + else + return false, err + end +end + +--[=[ +@m clearReactions +@t http +@r boolean +@d Removes all reactions from the message. +]=] +function Message:clearReactions() + local data, err = self.client._api:deleteAllReactions(self._parent._id, self._id) + if data then + return true + else + return false, err + end +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the message. This cannot be undone! +]=] +function Message:delete() + local data, err = self.client._api:deleteMessage(self._parent._id, self._id) + if data then + local cache = self._parent._messages + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +--[=[ +@m reply +@t http +@p content string/table +@r Message +@d Equivalent to `Message.channel:send(content)`. +]=] +function Message:reply(content) + return self._parent:send(content) +end + +--[=[@p reactions Cache An iterable cache of all reactions that exist for this message.]=] +function get.reactions(self) + if not self._reactions then + self._reactions = Cache({}, Reaction, self) + end + return self._reactions +end + +--[=[@p mentionedUsers ArrayIterable An iterable array of all users that are mentioned in this message.]=] +function get.mentionedUsers(self) + if not self._mentioned_users then + local users = self.client._users + local mentions = parseMentions(self._content, '<@!?(%d+)>') + if self._reply_target then + insert(mentions, 1, self._reply_target) + end + self._mentioned_users = ArrayIterable(mentions, function(id) + return users:get(id) + end) + end + return self._mentioned_users +end + +--[=[@p mentionedRoles ArrayIterable An iterable array of known roles that are mentioned in this message, excluding +the default everyone role. The message must be in a guild text channel and the +roles must be cached in that channel's guild for them to appear here.]=] +function get.mentionedRoles(self) + if not self._mentioned_roles then + local client = self.client + local mentions = parseMentions(self._content, '<@&(%d+)>') + self._mentioned_roles = ArrayIterable(mentions, function(id) + local guild = client._role_map[id] + return guild and guild._roles:get(id) or nil + end) + end + return self._mentioned_roles +end + +--[=[@p mentionedEmojis ArrayIterable An iterable array of all known emojis that are mentioned in this message. If +the client does not have the emoji cached, then it will not appear here.]=] +function get.mentionedEmojis(self) + if not self._mentioned_emojis then + local client = self.client + local mentions = parseMentions(self._content, '') + self._mentioned_emojis = ArrayIterable(mentions, function(id) + local guild = client._emoji_map[id] + return guild and guild._emojis:get(id) + end) + end + return self._mentioned_emojis +end + +--[=[@p mentionedChannels ArrayIterable An iterable array of all known channels that are mentioned in this message. If +the client does not have the channel cached, then it will not appear here.]=] +function get.mentionedChannels(self) + if not self._mentioned_channels then + local client = self.client + local mentions = parseMentions(self._content, '<#(%d+)>') + self._mentioned_channels = ArrayIterable(mentions, function(id) + local guild = client._channel_map[id] + if guild then + return guild._text_channels:get(id) or guild._voice_channels:get(id) or guild._categories:get(id) + else + return client._private_channels:get(id) or client._group_channels:get(id) + end + end) + end + return self._mentioned_channels +end + +local usersMeta = {__index = function(_, k) return '@' .. k end} +local rolesMeta = {__index = function(_, k) return '@' .. k end} +local channelsMeta = {__index = function(_, k) return '#' .. k end} +local everyone = '@' .. constants.ZWSP .. 'everyone' +local here = '@' .. constants.ZWSP .. 'here' + +--[=[@p cleanContent string The message content with all recognized mentions replaced by names and with +@everyone and @here mentions escaped by a zero-width space (ZWSP).]=] +function get.cleanContent(self) + + if not self._clean_content then + + local content = self._content + local guild = self.guild + + local users = setmetatable({}, usersMeta) + for user in self.mentionedUsers:iter() do + local member = guild and guild._members:get(user._id) + users[user._id] = '@' .. (member and member._nick or user._username) + end + + local roles = setmetatable({}, rolesMeta) + for role in self.mentionedRoles:iter() do + roles[role._id] = '@' .. role._name + end + + local channels = setmetatable({}, channelsMeta) + for channel in self.mentionedChannels:iter() do + channels[channel._id] = '#' .. channel._name + end + + self._clean_content = content + :gsub('<@!?(%d+)>', users) + :gsub('<@&(%d+)>', roles) + :gsub('<#(%d+)>', channels) + :gsub('', '%1') + :gsub('@everyone', everyone) + :gsub('@here', here) + + end + + return self._clean_content + +end + +--[=[@p mentionsEveryone boolean Whether this message mentions @everyone or @here.]=] +function get.mentionsEveryone(self) + return self._mention_everyone +end + +--[=[@p pinned boolean Whether this message belongs to its channel's pinned messages.]=] +function get.pinned(self) + return self._pinned +end + +--[=[@p tts boolean Whether this message is a text-to-speech message.]=] +function get.tts(self) + return self._tts +end + +--[=[@p nonce string/number/boolean/nil Used by the official Discord client to detect the success of a sent message.]=] +function get.nonce(self) + return self._nonce +end + +--[=[@p editedTimestamp string/nil The date and time at which the message was most recently edited, represented as +an ISO 8601 string plus microseconds when available.]=] +function get.editedTimestamp(self) + return self._edited_timestamp +end + +--[=[@p oldContent string/table Yields a table containing keys as timestamps and +value as content of the message at that time.]=] +function get.oldContent(self) + return self._old +end + +--[=[@p content string The raw message content. This should be between 0 and 2000 characters in length.]=] +function get.content(self) + return self._content +end + +--[=[@p author User The object of the user that created the message.]=] +function get.author(self) + return self._author +end + +--[=[@p channel TextChannel The channel in which this message was sent.]=] +function get.channel(self) + return self._parent +end + +--[=[@p type number The message type. Use the `messageType` enumeration for a human-readable +representation.]=] +function get.type(self) + return self._type +end + +--[=[@p embed table/nil A raw data table that represents the first rich embed that exists in this +message. See the Discord documentation for more information.]=] +function get.embed(self) + return self._embeds and self._embeds[1] +end + +--[=[@p attachment table/nil A raw data table that represents the first file attachment that exists in this +message. See the Discord documentation for more information.]=] +function get.attachment(self) + return self._attachments and self._attachments[1] +end + +--[=[@p embeds table A raw data table that contains all embeds that exist for this message. If +there are none, this table will not be present.]=] +function get.embeds(self) + return self._embeds +end + +--[=[@p attachments table A raw data table that contains all attachments that exist for this message. If +there are none, this table will not be present.]=] +function get.attachments(self) + return self._attachments +end + +--[=[@p guild Guild/nil The guild in which this message was sent. This will not exist if the message +was not sent in a guild text channel. Equivalent to `Message.channel.guild`.]=] +function get.guild(self) + return self._parent.guild +end + +--[=[@p member Member/nil The member object of the message's author. This will not exist if the message +is not sent in a guild text channel or if the member object is not cached. +Equivalent to `Message.guild.members:get(Message.author.id)`.]=] +function get.member(self) + local guild = self.guild + return guild and guild._members:get(self._author._id) +end + +--[=[@p referencedMessage Message/nil If available, the previous message that +this current message references as seen in replies.]=] +function get.referencedMessage(self) + return self._referencedMessage +end + +--[=[@p link string URL that can be used to jump-to the message in the Discord client.]=] +function get.link(self) + local guild = self.guild + return format('https://discord.com/channels/%s/%s/%s', guild and guild._id or '@me', self._parent._id, self._id) +end + +--[=[@p webhookId string/nil The ID of the webhook that generated this message, if applicable.]=] +function get.webhookId(self) + return self._webhook_id +end + +return Message diff --git a/deps/discordia/libs/containers/PermissionOverwrite.lua b/deps/discordia/libs/containers/PermissionOverwrite.lua new file mode 100644 index 0000000..294ab97 --- /dev/null +++ b/deps/discordia/libs/containers/PermissionOverwrite.lua @@ -0,0 +1,234 @@ +--[=[ +@c PermissionOverwrite x Snowflake +@d Represents an object that is used to allow or deny specific permissions for a +role or member in a Discord guild channel. +]=] + +local Snowflake = require('containers/abstract/Snowflake') +local Permissions = require('utils/Permissions') +local Resolver = require('client/Resolver') + +local band, bnot = bit.band, bit.bnot + +local PermissionOverwrite, get = require('class')('PermissionOverwrite', Snowflake) + +function PermissionOverwrite:__init(data, parent) + Snowflake.__init(self, data, parent) +end + +--[=[ +@m delete +@t http +@r boolean +@d Deletes the permission overwrite. This can be undone by creating a new version of +the same overwrite. +]=] +function PermissionOverwrite:delete() + local data, err = self.client._api:deleteChannelPermission(self._parent._id, self._id) + if data then + local cache = self._parent._permission_overwrites + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +--[=[ +@m getObject +@t http? +@r Role/Member +@d Returns the object associated with this overwrite, either a role or member. +This may make an HTTP request if the object is not cached. +]=] +function PermissionOverwrite:getObject() + local guild = self._parent._parent + if self._type == 'role' then + return guild:getRole(self._id) + elseif self._type == 'member' then + return guild:getMember(self._id) + end +end + +local function getPermissions(self) + return Permissions(self._allow), Permissions(self._deny) +end + +local function setPermissions(self, allow, deny) + local data, err = self.client._api:editChannelPermissions(self._parent._id, self._id, { + allow = allow, deny = deny, type = self._type + }) + if data then + self._allow, self._deny = allow, deny + return true + else + return false, err + end +end + +--[=[ +@m getAllowedPermissions +@t mem +@r Permissions +@d Returns a permissions object that represents the permissions that this overwrite +explicitly allows. +]=] +function PermissionOverwrite:getAllowedPermissions() + return Permissions(self._allow) +end + +--[=[ +@m getDeniedPermissions +@t mem +@r Permissions +@d Returns a permissions object that represents the permissions that this overwrite +explicitly denies. +]=] +function PermissionOverwrite:getDeniedPermissions() + return Permissions(self._deny) +end + +--[=[ +@m setPermissions +@t http +@p allowed Permissions-Resolvables +@p denied Permissions-Resolvables +@r boolean +@d Sets the permissions that this overwrite explicitly allows and denies. This +method does NOT resolve conflicts. Please be sure to use the correct parameters. +]=] +function PermissionOverwrite:setPermissions(allowed, denied) + local allow = Resolver.permissions(allowed) + local deny = Resolver.permissions(denied) + return setPermissions(self, allow, deny) +end + +--[=[ +@m setAllowedPermissions +@t http +@p allowed Permissions-Resolvables +@r boolean +@d Sets the permissions that this overwrite explicitly allows. +]=] +function PermissionOverwrite:setAllowedPermissions(allowed) + local allow = Resolver.permissions(allowed) + local deny = band(bnot(allow), self._deny) -- un-deny the allowed permissions + return setPermissions(self, allow, deny) +end + +--[=[ +@m setDeniedPermissions +@t http +@p denied Permissions-Resolvables +@r boolean +@d Sets the permissions that this overwrite explicitly denies. +]=] +function PermissionOverwrite:setDeniedPermissions(denied) + local deny = Resolver.permissions(denied) + local allow = band(bnot(deny), self._allow) -- un-allow the denied permissions + return setPermissions(self, allow, deny) +end + +--[=[ +@m allowPermissions +@t http +@p ... Permission-Resolvables +@r boolean +@d Allows individual permissions in this overwrite. +]=] +function PermissionOverwrite:allowPermissions(...) + local allowed, denied = getPermissions(self) + allowed:enable(...); denied:disable(...) + return setPermissions(self, allowed._value, denied._value) +end + +--[=[ +@m denyPermissions +@t http +@p ... Permission-Resolvables +@r boolean +@d Denies individual permissions in this overwrite. +]=] +function PermissionOverwrite:denyPermissions(...) + local allowed, denied = getPermissions(self) + allowed:disable(...); denied:enable(...) + return setPermissions(self, allowed._value, denied._value) +end + +--[=[ +@m clearPermissions +@t http +@p ... Permission-Resolvables +@r boolean +@d Clears individual permissions in this overwrite. +]=] +function PermissionOverwrite:clearPermissions(...) + local allowed, denied = getPermissions(self) + allowed:disable(...); denied:disable(...) + return setPermissions(self, allowed._value, denied._value) +end + +--[=[ +@m allowAllPermissions +@t http +@r boolean +@d Allows all permissions in this overwrite. +]=] +function PermissionOverwrite:allowAllPermissions() + local allowed, denied = getPermissions(self) + allowed:enableAll(); denied:disableAll() + return setPermissions(self, allowed._value, denied._value) +end + +--[=[ +@m denyAllPermissions +@t http +@r boolean +@d Denies all permissions in this overwrite. +]=] +function PermissionOverwrite:denyAllPermissions() + local allowed, denied = getPermissions(self) + allowed:disableAll(); denied:enableAll() + return setPermissions(self, allowed._value, denied._value) +end + +--[=[ +@m clearAllPermissions +@t http +@r boolean +@d Clears all permissions in this overwrite. +]=] +function PermissionOverwrite:clearAllPermissions() + local allowed, denied = getPermissions(self) + allowed:disableAll(); denied:disableAll() + return setPermissions(self, allowed._value, denied._value) +end + +--[=[@p type string The overwrite type; either "role" or "member".]=] +function get.type(self) + return self._type +end + +--[=[@p channel GuildChannel The channel in which this overwrite exists.]=] +function get.channel(self) + return self._parent +end + +--[=[@p guild Guild The guild in which this overwrite exists. Equivalent to `PermissionOverwrite.channel.guild`.]=] +function get.guild(self) + return self._parent._parent +end + +--[=[@p allowedPermissions number The number representing the total permissions allowed by this overwrite.]=] +function get.allowedPermissions(self) + return self._allow +end + +--[=[@p deniedPermissions number The number representing the total permissions denied by this overwrite.]=] +function get.deniedPermissions(self) + return self._deny +end + +return PermissionOverwrite diff --git a/deps/discordia/libs/containers/PrivateChannel.lua b/deps/discordia/libs/containers/PrivateChannel.lua new file mode 100644 index 0000000..2ab1471 --- /dev/null +++ b/deps/discordia/libs/containers/PrivateChannel.lua @@ -0,0 +1,37 @@ +--[=[ +@c PrivateChannel x TextChannel +@d Represents a private Discord text channel used to track correspondences between +the current user and one other recipient. +]=] + +local TextChannel = require('containers/abstract/TextChannel') + +local PrivateChannel, get = require('class')('PrivateChannel', TextChannel) + +function PrivateChannel:__init(data, parent) + TextChannel.__init(self, data, parent) + self._recipient = self.client._users:_insert(data.recipients[1]) +end + +--[=[ +@m close +@t http +@r boolean +@d Closes the channel. This does not delete the channel. To re-open the channel, +use `User:getPrivateChannel`. +]=] +function PrivateChannel:close() + return self:_delete() +end + +--[=[@p name string Equivalent to `PrivateChannel.recipient.username`.]=] +function get.name(self) + return self._recipient._username +end + +--[=[@p recipient User The recipient of this channel's messages, other than the current user.]=] +function get.recipient(self) + return self._recipient +end + +return PrivateChannel diff --git a/deps/discordia/libs/containers/Reaction.lua b/deps/discordia/libs/containers/Reaction.lua new file mode 100644 index 0000000..9270b0f --- /dev/null +++ b/deps/discordia/libs/containers/Reaction.lua @@ -0,0 +1,149 @@ +--[=[ +@c Reaction x Container +@d Represents an emoji that has been used to react to a Discord text message. Both +standard and custom emojis can be used. +]=] + +local json = require('json') +local Container = require('containers/abstract/Container') +local SecondaryCache = require('iterables/SecondaryCache') +local Resolver = require('client/Resolver') + +local null = json.null +local format = string.format + +local Reaction, get = require('class')('Reaction', Container) + +function Reaction:__init(data, parent) + Container.__init(self, data, parent) + local emoji = data.emoji + self._emoji_id = emoji.id ~= null and emoji.id or nil + self._emoji_name = emoji.name + if emoji.animated ~= null and emoji.animated ~= nil then -- not always present + self._emoji_animated = emoji.animated + end +end + +--[=[ +@m __hash +@r string +@d Returns `Reaction.emojiId or Reaction.emojiName` +]=] +function Reaction:__hash() + return self._emoji_id or self._emoji_name +end + +local function getUsers(self, query) + local emoji = Resolver.emoji(self) + local message = self._parent + local channel = message._parent + local data, err = self.client._api:getReactions(channel._id, message._id, emoji, query) + if data then + return SecondaryCache(data, self.client._users) + else + return nil, err + end +end + +--[=[ +@m getUsers +@t http +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of all users that have used this reaction in +its parent message. The cache is not automatically updated via gateway events, +but the internally referenced user objects may be updated. You must call this +method again to guarantee that the objects are update to date. +]=] +function Reaction:getUsers(limit) + return getUsers(self, limit and {limit = limit}) +end + +--[=[ +@m getUsersBefore +@t http +@p id User-ID-Resolvable +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of all users that have used this reaction before the specified id in +its parent message. The cache is not automatically updated via gateway events, +but the internally referenced user objects may be updated. You must call this +method again to guarantee that the objects are update to date. +]=] +function Reaction:getUsersBefore(id, limit) + id = Resolver.userId(id) + return getUsers(self, {before = id, limit = limit}) +end + +--[=[ +@m getUsersAfter +@t http +@p id User-ID-Resolvable +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of all users that have used this reaction +after the specified id in its parent message. The cache is not automatically +updated via gateway events, but the internally referenced user objects may be +updated. You must call this method again to guarantee that the objects are update to date. +]=] +function Reaction:getUsersAfter(id, limit) + id = Resolver.userId(id) + return getUsers(self, {after = id, limit = limit}) +end + +--[=[ +@m delete +@t http +@op id User-ID-Resolvable +@r boolean +@d Equivalent to `Reaction.message:removeReaction(Reaction)` +]=] +function Reaction:delete(id) + return self._parent:removeReaction(self, id) +end + +--[=[@p emojiId string/nil The ID of the emoji used in this reaction if it is a custom emoji.]=] +function get.emojiId(self) + return self._emoji_id +end + +--[=[@p emojiName string The name of the emoji used in this reaction. +This will be the raw string for a standard emoji.]=] +function get.emojiName(self) + return self._emoji_name +end + +--[=[@p emojiHash string The discord hash for the emoji used in this reaction. +This will be the raw string for a standard emoji.]=] +function get.emojiHash(self) + if self._emoji_id then + return self._emoji_name .. ':' .. self._emoji_id + else + return self._emoji_name + end +end + +--[=[@p emojiURL string/nil string The URL that can be used to view a full +version of the emoji used in this reaction if it is a custom emoji.]=] +function get.emojiURL(self) + local id = self._emoji_id + local ext = self._emoji_animated and 'gif' or 'png' + return id and format('https://cdn.discordapp.com/emojis/%s.%s', id, ext) or nil +end + +--[=[@p me boolean Whether the current user has used this reaction.]=] +function get.me(self) + return self._me +end + +--[=[@p count number The total number of users that have used this reaction.]=] +function get.count(self) + return self._count +end + +--[=[@p message Message The message on which this reaction exists.]=] +function get.message(self) + return self._parent +end + +return Reaction diff --git a/deps/discordia/libs/containers/Relationship.lua b/deps/discordia/libs/containers/Relationship.lua new file mode 100644 index 0000000..d7acc7a --- /dev/null +++ b/deps/discordia/libs/containers/Relationship.lua @@ -0,0 +1,27 @@ +--[=[ +@c Relationship x UserPresence +@d Represents a relationship between the current user and another Discord user. +This is generally either a friend or a blocked user. This class should only be +relevant to user-accounts; bots cannot normally have relationships. +]=] + +local UserPresence = require('containers/abstract/UserPresence') + +local Relationship, get = require('class')('Relationship', UserPresence) + +function Relationship:__init(data, parent) + UserPresence.__init(self, data, parent) +end + +--[=[@p name string Equivalent to `Relationship.user.username`.]=] +function get.name(self) + return self._user._username +end + +--[=[@p type number The relationship type. See the `relationshipType` enumeration for a +human-readable representation.]=] +function get.type(self) + return self._type +end + +return Relationship diff --git a/deps/discordia/libs/containers/Role.lua b/deps/discordia/libs/containers/Role.lua new file mode 100644 index 0000000..4d35dcb --- /dev/null +++ b/deps/discordia/libs/containers/Role.lua @@ -0,0 +1,383 @@ +--[=[ +@c Role x Snowflake +@d Represents a Discord guild role, which is used to assign priority, permissions, +and a color to guild members. +]=] + +local json = require('json') +local Snowflake = require('containers/abstract/Snowflake') +local Color = require('utils/Color') +local Permissions = require('utils/Permissions') +local Resolver = require('client/Resolver') +local FilteredIterable = require('iterables/FilteredIterable') + +local format = string.format +local insert, sort = table.insert, table.sort +local min, max, floor = math.min, math.max, math.floor +local huge = math.huge + +local Role, get = require('class')('Role', Snowflake) + +function Role:__init(data, parent) + Snowflake.__init(self, data, parent) + self.client._role_map[self._id] = parent +end + +function Role:_modify(payload) + local data, err = self.client._api:modifyGuildRole(self._parent._id, self._id, payload) + if data then + self:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the role. This cannot be undone! +]=] +function Role:delete() + local data, err = self.client._api:deleteGuildRole(self._parent._id, self._id) + if data then + local cache = self._parent._roles + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +local function sorter(a, b) + if a.position == b.position then + return tonumber(a.id) < tonumber(b.id) + else + return a.position < b.position + end +end + +local function getSortedRoles(self) + local guild = self._parent + local id = self._parent._id + local ret = {} + for role in guild.roles:iter() do + if role._id ~= id then + insert(ret, {id = role._id, position = role._position}) + end + end + sort(ret, sorter) + return ret +end + +local function setSortedRoles(self, roles) + local id = self._parent._id + insert(roles, {id = id, position = 0}) + local data, err = self.client._api:modifyGuildRolePositions(id, roles) + if data then + return true + else + return false, err + end +end + +--[=[ +@m moveDown +@t http +@p n number +@r boolean +@d Moves a role down its list. The parameter `n` indicates how many spaces the +role should be moved, clamped to the lowest position, with a default of 1 if +it is omitted. This will also normalize the positions of all roles. Note that +the default everyone role cannot be moved. +]=] +function Role:moveDown(n) -- TODO: fix attempt to move roles that cannot be moved + + n = tonumber(n) or 1 + if n < 0 then + return self:moveDown(-n) + end + + local roles = getSortedRoles(self) + + local new = huge + for i = #roles, 1, -1 do + local v = roles[i] + if v.id == self._id then + new = max(1, i - floor(n)) + v.position = new + elseif i >= new then + v.position = i + 1 + else + v.position = i + end + end + + return setSortedRoles(self, roles) + +end + +--[=[ +@m moveUp +@t http +@p n number +@r boolean +@d Moves a role up its list. The parameter `n` indicates how many spaces the +role should be moved, clamped to the highest position, with a default of 1 if +it is omitted. This will also normalize the positions of all roles. Note that +the default everyone role cannot be moved. +]=] +function Role:moveUp(n) -- TODO: fix attempt to move roles that cannot be moved + + n = tonumber(n) or 1 + if n < 0 then + return self:moveUp(-n) + end + + local roles = getSortedRoles(self) + + local new = -huge + for i = 1, #roles do + local v = roles[i] + if v.id == self._id then + new = min(i + floor(n), #roles) + v.position = new + elseif i <= new then + v.position = i - 1 + else + v.position = i + end + end + + return setSortedRoles(self, roles) + +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the role's name. The name must be between 1 and 100 characters in length. +]=] +function Role:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setColor +@t http +@p color Color-Resolvable +@r boolean +@d Sets the role's display color. +]=] +function Role:setColor(color) + color = color and Resolver.color(color) + return self:_modify({color = color or json.null}) +end + +--[=[ +@m setPermissions +@t http +@p permissions Permissions-Resolvable +@r boolean +@d Sets the permissions that this role explicitly allows. +]=] +function Role:setPermissions(permissions) + permissions = permissions and Resolver.permissions(permissions) + return self:_modify({permissions = permissions or json.null}) +end + +--[=[ +@m hoist +@t http +@r boolean +@d Causes members with this role to display above unhoisted roles in the member +list. +]=] +function Role:hoist() + return self:_modify({hoist = true}) +end + +--[=[ +@m unhoist +@t http +@r boolean +@d Causes member with this role to display amongst other unhoisted members. +]=] +function Role:unhoist() + return self:_modify({hoist = false}) +end + +--[=[ +@m enableMentioning +@t http +@r boolean +@d Allows anyone to mention this role in text messages. +]=] +function Role:enableMentioning() + return self:_modify({mentionable = true}) +end + +--[=[ +@m disableMentioning +@t http +@r boolean +@d Disallows anyone to mention this role in text messages. +]=] +function Role:disableMentioning() + return self:_modify({mentionable = false}) +end + +--[=[ +@m enablePermissions +@t http +@p ... Permission-Resolvables +@r boolean +@d Enables individual permissions for this role. This does not necessarily fully +allow the permissions. +]=] +function Role:enablePermissions(...) + local permissions = self:getPermissions() + permissions:enable(...) + return self:setPermissions(permissions) +end + +--[=[ +@m disablePermissions +@t http +@p ... Permission-Resolvables +@r boolean +@d Disables individual permissions for this role. This does not necessarily fully +disallow the permissions. +]=] +function Role:disablePermissions(...) + local permissions = self:getPermissions() + permissions:disable(...) + return self:setPermissions(permissions) +end + +--[=[ +@m enableAllPermissions +@t http +@r boolean +@d Enables all permissions for this role. This does not necessarily fully +allow the permissions. +]=] +function Role:enableAllPermissions() + local permissions = self:getPermissions() + permissions:enableAll() + return self:setPermissions(permissions) +end + +--[=[ +@m disableAllPermissions +@t http +@r boolean +@d Disables all permissions for this role. This does not necessarily fully +disallow the permissions. +]=] +function Role:disableAllPermissions() + local permissions = self:getPermissions() + permissions:disableAll() + return self:setPermissions(permissions) +end + +--[=[ +@m getColor +@t mem +@r Color +@d Returns a color object that represents the role's display color. +]=] +function Role:getColor() + return Color(self._color) +end + +--[=[ +@m getPermissions +@t mem +@r Permissions +@d Returns a permissions object that represents the permissions that this role +has enabled. +]=] +function Role:getPermissions() + return Permissions(self._permissions) +end + +--[=[@p hoisted boolean Whether members with this role should be shown separated from other members +in the guild member list.]=] +function get.hoisted(self) + return self._hoist +end + +--[=[@p mentionable boolean Whether this role can be mentioned in a text channel message.]=] +function get.mentionable(self) + return self._mentionable +end + +--[=[@p managed boolean Whether this role is managed by some integration or bot inclusion.]=] +function get.managed(self) + return self._managed +end + +--[=[@p name string The name of the role. This should be between 1 and 100 characters in length.]=] +function get.name(self) + return self._name +end + +--[=[@p position number The position of the role, where 0 is the lowest.]=] +function get.position(self) + return self._position +end + +--[=[@p color number Represents the display color of the role as a decimal value.]=] +function get.color(self) + return self._color +end + +--[=[@p permissions number Represents the total permissions of the role as a decimal value.]=] +function get.permissions(self) + return self._permissions +end + +--[=[@p mentionString string A string that, when included in a message content, may resolve as a role +notification in the official Discord client.]=] +function get.mentionString(self) + return format('<@&%s>', self._id) +end + +--[=[@p guild Guild The guild in which this role exists.]=] +function get.guild(self) + return self._parent +end + +--[=[@p members FilteredIterable A filtered iterable of guild members that have +this role. If you want to check whether a specific member has this role, it would +be better to get the member object elsewhere and use `Member:hasRole` rather +than check whether the member exists here.]=] +function get.members(self) + if not self._members then + self._members = FilteredIterable(self._parent._members, function(m) + return m:hasRole(self) + end) + end + return self._members +end + +--[=[@p emojis FilteredIterable A filtered iterable of guild emojis that have +this role. If you want to check whether a specific emoji has this role, it would +be better to get the emoji object elsewhere and use `Emoji:hasRole` rather +than check whether the emoji exists here.]=] +function get.emojis(self) + if not self._emojis then + self._emojis = FilteredIterable(self._parent._emojis, function(e) + return e:hasRole(self) + end) + end + return self._emojis +end + +return Role diff --git a/deps/discordia/libs/containers/User.lua b/deps/discordia/libs/containers/User.lua new file mode 100644 index 0000000..81d5509 --- /dev/null +++ b/deps/discordia/libs/containers/User.lua @@ -0,0 +1,186 @@ +--[=[ +@c User x Snowflake +@d Represents a single user of Discord, either a human or a bot, outside of any +specific guild's context. +]=] + +local Snowflake = require('containers/abstract/Snowflake') +local FilteredIterable = require('iterables/FilteredIterable') +local constants = require('constants') + +local format = string.format +local DEFAULT_AVATARS = constants.DEFAULT_AVATARS + +local User, get = require('class')('User', Snowflake) + +function User:__init(data, parent) + Snowflake.__init(self, data, parent) +end + +--[=[ +@m getAvatarURL +@t mem +@op size number +@op ext string +@r string +@d Returns a URL that can be used to view the user's full avatar. If provided, the +size must be a power of 2 while the extension must be a valid image format. If +the user does not have a custom avatar, the default URL is returned. +]=] +function User:getAvatarURL(size, ext) + local avatar = self._avatar + if avatar then + ext = ext or avatar:find('a_') == 1 and 'gif' or 'png' + if size then + return format('https://cdn.discordapp.com/avatars/%s/%s.%s?size=%s', self._id, avatar, ext, size) + else + return format('https://cdn.discordapp.com/avatars/%s/%s.%s', self._id, avatar, ext) + end + else + return self:getDefaultAvatarURL(size) + end +end + +--[=[ +@m getDefaultAvatarURL +@t mem +@op size number +@r string +@d Returns a URL that can be used to view the user's default avatar. +]=] +function User:getDefaultAvatarURL(size) + local avatar = self.defaultAvatar + if size then + return format('https://cdn.discordapp.com/embed/avatars/%s.png?size=%s', avatar, size) + else + return format('https://cdn.discordapp.com/embed/avatars/%s.png', avatar) + end +end + +--[=[ +@m getPrivateChannel +@t http +@r PrivateChannel +@d Returns a private channel that can be used to communicate with the user. If the +channel is not cached an HTTP request is made to open one. +]=] +function User:getPrivateChannel() + local id = self._id + local client = self.client + local channel = client._private_channels:find(function(e) return e._recipient._id == id end) + if channel then + return channel + else + local data, err = client._api:createDM({recipient_id = id}) + if data then + return client._private_channels:_insert(data) + else + return nil, err + end + end +end + +--[=[ +@m send +@t http +@p content string/table +@r Message +@d Equivalent to `User:getPrivateChannel():send(content)` +]=] +function User:send(content) + local channel, err = self:getPrivateChannel() + if channel then + return channel:send(content) + else + return nil, err + end +end + +--[=[ +@m sendf +@t http +@p content string +@r Message +@d Equivalent to `User:getPrivateChannel():sendf(content)` +]=] +function User:sendf(content, ...) + local channel, err = self:getPrivateChannel() + if channel then + return channel:sendf(content, ...) + else + return nil, err + end +end + +--[=[@p bot boolean Whether this user is a bot.]=] +function get.bot(self) + return self._bot or false +end + +--[=[@p name string Equivalent to `User.username`.]=] +function get.name(self) + return self._username +end + +--[=[@p username string The name of the user. This should be between 2 and 32 characters in length.]=] +function get.username(self) + return self._username +end + +--[=[@p discriminator number The discriminator of the user. This is a 4-digit string that is used to +discriminate the user from other users with the same username.]=] +function get.discriminator(self) + return self._discriminator +end + +--[=[@p tag string The user's username and discriminator concatenated by an `#`.]=] +function get.tag(self) + return self._username .. '#' .. self._discriminator +end + +function get.fullname(self) + self.client:_deprecated(self.__name, 'fullname', 'tag') + return self._username .. '#' .. self._discriminator +end + +--[=[@p avatar string/nil The hash for the user's custom avatar, if one is set.]=] +function get.avatar(self) + return self._avatar +end + +--[=[@p defaultAvatar number The user's default avatar. See the `defaultAvatar` enumeration for a +human-readable representation.]=] +function get.defaultAvatar(self) + return self._discriminator % DEFAULT_AVATARS +end + +--[=[@p avatarURL string Equivalent to the result of calling `User:getAvatarURL()`.]=] +function get.avatarURL(self) + return self:getAvatarURL() +end + +--[=[@p defaultAvatarURL string Equivalent to the result of calling `User:getDefaultAvatarURL()`.]=] +function get.defaultAvatarURL(self) + return self:getDefaultAvatarURL() +end + +--[=[@p mentionString string A string that, when included in a message content, may resolve as user +notification in the official Discord client.]=] +function get.mentionString(self) + return format('<@%s>', self._id) +end + +--[=[@p mutualGuilds FilteredIterable A iterable cache of all guilds where this user shares a membership with the +current user. The guild must be cached on the current client and the user's +member object must be cached in that guild in order for it to appear here.]=] +function get.mutualGuilds(self) + if not self._mutual_guilds then + local id = self._id + self._mutual_guilds = FilteredIterable(self.client._guilds, function(g) + return g._members:get(id) + end) + end + return self._mutual_guilds +end + +return User diff --git a/deps/discordia/libs/containers/Webhook.lua b/deps/discordia/libs/containers/Webhook.lua new file mode 100644 index 0000000..610af95 --- /dev/null +++ b/deps/discordia/libs/containers/Webhook.lua @@ -0,0 +1,147 @@ +--[=[ +@c Webhook x Snowflake +@d Represents a handle used to send webhook messages to a guild text channel in a +one-way fashion. This class defines methods and properties for managing the +webhook, not for sending messages. +]=] + +local json = require('json') +local enums = require('enums') +local Snowflake = require('containers/abstract/Snowflake') +local User = require('containers/User') +local Resolver = require('client/Resolver') + +local defaultAvatar = enums.defaultAvatar + +local Webhook, get = require('class')('Webhook', Snowflake) + +function Webhook:__init(data, parent) + Snowflake.__init(self, data, parent) + self._user = data.user and self.client._users:_insert(data.user) -- DNE if getting by token +end + +function Webhook:_modify(payload) + local data, err = self.client._api:modifyWebhook(self._id, payload) + if data then + self:_load(data) + return true + else + return false, err + end +end + +--[=[ +@m getAvatarURL +@t mem +@op size number +@op ext string +@r string +@d Returns a URL that can be used to view the webhooks's full avatar. If provided, +the size must be a power of 2 while the extension must be a valid image format. +If the webhook does not have a custom avatar, the default URL is returned. +]=] +function Webhook:getAvatarURL(size, ext) + return User.getAvatarURL(self, size, ext) +end + +--[=[ +@m getDefaultAvatarURL +@t mem +@op size number +@r string +@d Returns a URL that can be used to view the webhooks's default avatar. +]=] +function Webhook:getDefaultAvatarURL(size) + return User.getDefaultAvatarURL(self, size) +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the webhook's name. This must be between 2 and 32 characters in length. +]=] +function Webhook:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setAvatar +@t http +@p avatar Base64-Resolvable +@r boolean +@d Sets the webhook's avatar. If `nil` is passed, the avatar is removed. +]=] +function Webhook:setAvatar(avatar) + avatar = avatar and Resolver.base64(avatar) + return self:_modify({avatar = avatar or json.null}) +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the webhook. This cannot be undone! +]=] +function Webhook:delete() + local data, err = self.client._api:deleteWebhook(self._id) + if data then + return true + else + return false, err + end +end + +--[=[@p guildId string The ID of the guild in which this webhook exists.]=] +function get.guildId(self) + return self._guild_id +end + +--[=[@p channelId string The ID of the channel in which this webhook exists.]=] +function get.channelId(self) + return self._channel_id +end + +--[=[@p user User/nil The user that created this webhook.]=] +function get.user(self) + return self._user +end + +--[=[@p token string The token that can be used to access this webhook.]=] +function get.token(self) + return self._token +end + +--[=[@p name string The name of the webhook. This should be between 2 and 32 characters in length.]=] +function get.name(self) + return self._name +end + +--[=[@p type number The type of the webhook. See the `webhookType` enum for a human-readable representation.]=] +function get.type(self) + return self._type +end + +--[=[@p avatar string/nil The hash for the webhook's custom avatar, if one is set.]=] +function get.avatar(self) + return self._avatar +end + +--[=[@p avatarURL string Equivalent to the result of calling `Webhook:getAvatarURL()`.]=] +function get.avatarURL(self) + return self:getAvatarURL() +end + +--[=[@p defaultAvatar number The default avatar for the webhook. See the `defaultAvatar` enumeration for +a human-readable representation. This should always be `defaultAvatar.blurple`.]=] +function get.defaultAvatar() + return defaultAvatar.blurple +end + +--[=[@p defaultAvatarURL string Equivalent to the result of calling `Webhook:getDefaultAvatarURL()`.]=] +function get.defaultAvatarURL(self) + return self:getDefaultAvatarURL() +end + +return Webhook diff --git a/deps/discordia/libs/containers/abstract/Channel.lua b/deps/discordia/libs/containers/abstract/Channel.lua new file mode 100644 index 0000000..d8f4d1a --- /dev/null +++ b/deps/discordia/libs/containers/abstract/Channel.lua @@ -0,0 +1,66 @@ +--[=[ +@c Channel x Snowflake +@t abc +@d Defines the base methods and properties for all Discord channel types. +]=] + +local Snowflake = require('containers/abstract/Snowflake') +local enums = require('enums') + +local format = string.format +local channelType = enums.channelType + +local Channel, get = require('class')('Channel', Snowflake) + +function Channel:__init(data, parent) + Snowflake.__init(self, data, parent) +end + +function Channel:_modify(payload) + local data, err = self.client._api:modifyChannel(self._id, payload) + if data then + self:_load(data) + return true + else + return false, err + end +end + +function Channel:_delete() + local data, err = self.client._api:deleteChannel(self._id) + if data then + local cache + local t = self._type + if t == channelType.text or t == channelType.news then + cache = self._parent._text_channels + elseif t == channelType.private then + cache = self._parent._private_channels + elseif t == channelType.group then + cache = self._parent._group_channels + elseif t == channelType.voice then + cache = self._parent._voice_channels + elseif t == channelType.category then + cache = self._parent._categories + end + if cache then + cache:_delete(self._id) + end + return true + else + return false, err + end +end + +--[=[@p type number The channel type. See the `channelType` enumeration for a +human-readable representation.]=] +function get.type(self) + return self._type +end + +--[=[@p mentionString string A string that, when included in a message content, +may resolve as a link to a channel in the official Discord client.]=] +function get.mentionString(self) + return format('<#%s>', self._id) +end + +return Channel diff --git a/deps/discordia/libs/containers/abstract/Container.lua b/deps/discordia/libs/containers/abstract/Container.lua new file mode 100644 index 0000000..7826267 --- /dev/null +++ b/deps/discordia/libs/containers/abstract/Container.lua @@ -0,0 +1,68 @@ +--[=[ +@c Container +@t abc +@d Defines the base methods and properties for all Discord objects and +structures. Container classes are constructed internally with information +received from Discord and should never be manually constructed. +]=] + +local json = require('json') + +local null = json.null +local format = string.format + +local Container, get = require('class')('Container') + +local types = {['string'] = true, ['number'] = true, ['boolean'] = true} + +local function load(self, data) + -- assert(type(data) == 'table') -- debug + for k, v in pairs(data) do + if types[type(v)] then + self['_' .. k] = v + elseif v == null then + self['_' .. k] = nil + end + end +end + +function Container:__init(data, parent) + -- assert(type(parent) == 'table') -- debug + self._parent = parent + return load(self, data) +end + +--[=[ +@m __eq +@r boolean +@d Defines the behavior of the `==` operator. Allows containers to be directly +compared according to their type and `__hash` return values. +]=] +function Container:__eq(other) + return self.__class == other.__class and self:__hash() == other:__hash() +end + +--[=[ +@m __tostring +@r string +@d Defines the behavior of the `tostring` function. All containers follow the format +`ClassName: hash`. +]=] +function Container:__tostring() + return format('%s: %s', self.__name, self:__hash()) +end + +Container._load = load + +--[=[@p client Client A shortcut to the client object to which this container is visible.]=] +function get.client(self) + return self._parent.client or self._parent +end + +--[=[@p parent Container/Client The parent object of to which this container is +a child. For example, the parent of a role is the guild in which the role exists.]=] +function get.parent(self) + return self._parent +end + +return Container diff --git a/deps/discordia/libs/containers/abstract/GuildChannel.lua b/deps/discordia/libs/containers/abstract/GuildChannel.lua new file mode 100644 index 0000000..3fa4b5f --- /dev/null +++ b/deps/discordia/libs/containers/abstract/GuildChannel.lua @@ -0,0 +1,281 @@ +--[=[ +@c GuildChannel x Channel +@t abc +@d Defines the base methods and properties for all Discord guild channels. +]=] + +local json = require('json') +local enums = require('enums') +local class = require('class') +local Channel = require('containers/abstract/Channel') +local PermissionOverwrite = require('containers/PermissionOverwrite') +local Invite = require('containers/Invite') +local Cache = require('iterables/Cache') +local Resolver = require('client/Resolver') + +local isInstance = class.isInstance +local classes = class.classes +local channelType = enums.channelType + +local insert, sort = table.insert, table.sort +local min, max, floor = math.min, math.max, math.floor +local huge = math.huge + +local GuildChannel, get = class('GuildChannel', Channel) + +function GuildChannel:__init(data, parent) + Channel.__init(self, data, parent) + self.client._channel_map[self._id] = parent + self._permission_overwrites = Cache({}, PermissionOverwrite, self) + return self:_loadMore(data) +end + +function GuildChannel:_load(data) + Channel._load(self, data) + return self:_loadMore(data) +end + +function GuildChannel:_loadMore(data) + return self._permission_overwrites:_load(data.permission_overwrites, true) +end + +--[=[ +@m setName +@t http +@p name string +@r boolean +@d Sets the channel's name. This must be between 2 and 100 characters in length. +]=] +function GuildChannel:setName(name) + return self:_modify({name = name or json.null}) +end + +--[=[ +@m setCategory +@t http +@p id Channel-ID-Resolvable +@r boolean +@d Sets the channel's parent category. +]=] +function GuildChannel:setCategory(id) + id = Resolver.channelId(id) + return self:_modify({parent_id = id or json.null}) +end + +local function sorter(a, b) + if a.position == b.position then + return tonumber(a.id) < tonumber(b.id) + else + return a.position < b.position + end +end + +local function getSortedChannels(self) + + local channels + local t = self._type + if t == channelType.text or t == channelType.news then + channels = self._parent._text_channels + elseif t == channelType.voice then + channels = self._parent._voice_channels + elseif t == channelType.category then + channels = self._parent._categories + end + + local ret = {} + for channel in channels:iter() do + insert(ret, {id = channel._id, position = channel._position}) + end + sort(ret, sorter) + + return ret + +end + +local function setSortedChannels(self, channels) + local data, err = self.client._api:modifyGuildChannelPositions(self._parent._id, channels) + if data then + return true + else + return false, err + end +end + +--[=[ +@m moveUp +@t http +@p n number +@r boolean +@d Moves a channel up its list. The parameter `n` indicates how many spaces the +channel should be moved, clamped to the highest position, with a default of 1 if +it is omitted. This will also normalize the positions of all channels. +]=] +function GuildChannel:moveUp(n) + + n = tonumber(n) or 1 + if n < 0 then + return self:moveDown(-n) + end + + local channels = getSortedChannels(self) + + local new = huge + for i = #channels - 1, 0, -1 do + local v = channels[i + 1] + if v.id == self._id then + new = max(0, i - floor(n)) + v.position = new + elseif i >= new then + v.position = i + 1 + else + v.position = i + end + end + + return setSortedChannels(self, channels) + +end + +--[=[ +@m moveDown +@t http +@p n number +@r boolean +@d Moves a channel down its list. The parameter `n` indicates how many spaces the +channel should be moved, clamped to the lowest position, with a default of 1 if +it is omitted. This will also normalize the positions of all channels. +]=] +function GuildChannel:moveDown(n) + + n = tonumber(n) or 1 + if n < 0 then + return self:moveUp(-n) + end + + local channels = getSortedChannels(self) + + local new = -huge + for i = 0, #channels - 1 do + local v = channels[i + 1] + if v.id == self._id then + new = min(i + floor(n), #channels - 1) + v.position = new + elseif i <= new then + v.position = i - 1 + else + v.position = i + end + end + + return setSortedChannels(self, channels) + +end + +--[=[ +@m createInvite +@t http +@op payload table +@r Invite +@d Creates an invite to the channel. Optional payload fields are: max_age: number +time in seconds until expiration, default = 86400 (24 hours), max_uses: number +total number of uses allowed, default = 0 (unlimited), temporary: boolean whether +the invite grants temporary membership, default = false, unique: boolean whether +a unique code should be guaranteed, default = false +]=] +function GuildChannel:createInvite(payload) + local data, err = self.client._api:createChannelInvite(self._id, payload) + if data then + return Invite(data, self.client) + else + return nil, err + end +end + +--[=[ +@m getInvites +@t http +@r Cache +@d Returns a newly constructed cache of all invite objects for the channel. The +cache and its objects are not automatically updated via gateway events. You must +call this method again to get the updated objects. +]=] +function GuildChannel:getInvites() + local data, err = self.client._api:getChannelInvites(self._id) + if data then + return Cache(data, Invite, self.client) + else + return nil, err + end +end + +--[=[ +@m getPermissionOverwriteFor +@t mem +@p obj Role/Member +@r PermissionOverwrite +@d Returns a permission overwrite object corresponding to the provided member or +role object. If a cached overwrite is not found, an empty overwrite with +zero-permissions is returned instead. Therefore, this can be used to create a +new overwrite when one does not exist. Note that the member or role must exist +in the same guild as the channel does. +]=] +function GuildChannel:getPermissionOverwriteFor(obj) + local id, type + if isInstance(obj, classes.Role) and self._parent == obj._parent then + id, type = obj._id, 'role' + elseif isInstance(obj, classes.Member) and self._parent == obj._parent then + id, type = obj._user._id, 'member' + else + return nil, 'Invalid Role or Member: ' .. tostring(obj) + end + local overwrites = self._permission_overwrites + return overwrites:get(id) or overwrites:_insert(setmetatable({ + id = id, type = type, allow = 0, deny = 0 + }, {__jsontype = 'object'})) +end + +--[=[ +@m delete +@t http +@r boolean +@d Permanently deletes the channel. This cannot be undone! +]=] +function GuildChannel:delete() + return self:_delete() +end + +--[=[@p permissionOverwrites Cache An iterable cache of all overwrites that exist in this channel. To access an +overwrite that may exist, but is not cached, use `GuildChannel:getPermissionOverwriteFor`.]=] +function get.permissionOverwrites(self) + return self._permission_overwrites +end + +--[=[@p name string The name of the channel. This should be between 2 and 100 characters in length.]=] +function get.name(self) + return self._name +end + +--[=[@p position number The position of the channel, where 0 is the highest.]=] +function get.position(self) + return self._position +end + +--[=[@p guild Guild The guild in which this channel exists.]=] +function get.guild(self) + return self._parent +end + +--[=[@p category GuildCategoryChannel/nil The parent channel category that may contain this channel.]=] +function get.category(self) + return self._parent._categories:get(self._parent_id) +end + +--[=[@p private boolean Whether the "everyone" role has permission to view this +channel. In the Discord channel, private text channels are indicated with a lock +icon and private voice channels are not visible.]=] +function get.private(self) + local overwrite = self._permission_overwrites:get(self._parent._id) + return overwrite and overwrite:getDeniedPermissions():has('readMessages') +end + +return GuildChannel diff --git a/deps/discordia/libs/containers/abstract/Snowflake.lua b/deps/discordia/libs/containers/abstract/Snowflake.lua new file mode 100644 index 0000000..8b36de6 --- /dev/null +++ b/deps/discordia/libs/containers/abstract/Snowflake.lua @@ -0,0 +1,63 @@ +--[=[ +@c Snowflake x Container +@t abc +@d Defines the base methods and/or properties for all Discord objects that have +a Snowflake ID. +]=] + +local Date = require('utils/Date') +local Container = require('containers/abstract/Container') + +local Snowflake, get = require('class')('Snowflake', Container) + +function Snowflake:__init(data, parent) + Container.__init(self, data, parent) +end + +--[=[ +@m __hash +@r string +@d Returns `Snowflake.id` +]=] +function Snowflake:__hash() + return self._id +end + +--[=[ +@m getDate +@t mem +@r Date +@d Returns a unique Date object that represents when the object was created by Discord. + +Equivalent to `Date.fromSnowflake(Snowflake.id)` +]=] +function Snowflake:getDate() + return Date.fromSnowflake(self._id) +end + +--[=[@p id string The Snowflake ID that can be used to identify the object. This is guaranteed to +be unique except in cases where an object shares the ID of its parent.]=] +function get.id(self) + return self._id +end + +--[=[@p createdAt number The Unix time in seconds at which this object was created by Discord. Additional +decimal points may be present, though only the first 3 (milliseconds) should be +considered accurate. + +Equivalent to `Date.parseSnowflake(Snowflake.id)`. +]=] +function get.createdAt(self) + return Date.parseSnowflake(self._id) +end + +--[=[@p timestamp string The date and time at which this object was created by Discord, represented as +an ISO 8601 string plus microseconds when available. + +Equivalent to `Date.fromSnowflake(Snowflake.id):toISO()`. +]=] +function get.timestamp(self) + return Date.fromSnowflake(self._id):toISO() +end + +return Snowflake diff --git a/deps/discordia/libs/containers/abstract/TextChannel.lua b/deps/discordia/libs/containers/abstract/TextChannel.lua new file mode 100644 index 0000000..3060a98 --- /dev/null +++ b/deps/discordia/libs/containers/abstract/TextChannel.lua @@ -0,0 +1,337 @@ +--[=[ +@c TextChannel x Channel +@t abc +@d Defines the base methods and properties for all Discord text channels. +]=] + +local pathjoin = require('pathjoin') +local Channel = require('containers/abstract/Channel') +local Message = require('containers/Message') +local WeakCache = require('iterables/WeakCache') +local SecondaryCache = require('iterables/SecondaryCache') +local Resolver = require('client/Resolver') +local fs = require('fs') + +local splitPath = pathjoin.splitPath +local insert, remove, concat = table.insert, table.remove, table.concat +local format = string.format +local readFileSync = fs.readFileSync + +local TextChannel, get = require('class')('TextChannel', Channel) + +function TextChannel:__init(data, parent) + Channel.__init(self, data, parent) + self._messages = WeakCache({}, Message, self) +end + +--[=[ +@m getMessage +@t http +@p id Message-ID-Resolvable +@r Message +@d Gets a message object by ID. If the object is already cached, then the cached +object will be returned; otherwise, an HTTP request is made. +]=] +function TextChannel:getMessage(id) + id = Resolver.messageId(id) + local message = self._messages:get(id) + if message then + return message + else + local data, err = self.client._api:getChannelMessage(self._id, id) + if data then + return self._messages:_insert(data) + else + return nil, err + end + end +end + +--[=[ +@m getFirstMessage +@t http +@r Message +@d Returns the first message found in the channel, if any exist. This is not a +cache shortcut; an HTTP request is made each time this method is called. +]=] +function TextChannel:getFirstMessage() + local data, err = self.client._api:getChannelMessages(self._id, {after = self._id, limit = 1}) + if data then + if data[1] then + return self._messages:_insert(data[1]) + else + return nil, 'Channel has no messages' + end + else + return nil, err + end +end + +--[=[ +@m getLastMessage +@t http +@r Message +@d Returns the last message found in the channel, if any exist. This is not a +cache shortcut; an HTTP request is made each time this method is called. +]=] +function TextChannel:getLastMessage() + local data, err = self.client._api:getChannelMessages(self._id, {limit = 1}) + if data then + if data[1] then + return self._messages:_insert(data[1]) + else + return nil, 'Channel has no messages' + end + else + return nil, err + end +end + +local function getMessages(self, query) + local data, err = self.client._api:getChannelMessages(self._id, query) + if data then + return SecondaryCache(data, self._messages) + else + return nil, err + end +end + +--[=[ +@m getMessages +@t http +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of between 1 and 100 (default = 50) message +objects found in the channel. While the cache will never automatically gain or +lose objects, the objects that it contains may be updated by gateway events. +]=] +function TextChannel:getMessages(limit) + return getMessages(self, limit and {limit = limit}) +end + +--[=[ +@m getMessagesAfter +@t http +@p id Message-ID-Resolvable +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of between 1 and 100 (default = 50) message +objects found in the channel after a specific id. While the cache will never +automatically gain or lose objects, the objects that it contains may be updated +by gateway events. +]=] +function TextChannel:getMessagesAfter(id, limit) + id = Resolver.messageId(id) + return getMessages(self, {after = id, limit = limit}) +end + +--[=[ +@m getMessagesBefore +@t http +@p id Message-ID-Resolvable +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of between 1 and 100 (default = 50) message +objects found in the channel before a specific id. While the cache will never +automatically gain or lose objects, the objects that it contains may be updated +by gateway events. +]=] +function TextChannel:getMessagesBefore(id, limit) + id = Resolver.messageId(id) + return getMessages(self, {before = id, limit = limit}) +end + +--[=[ +@m getMessagesAround +@t http +@p id Message-ID-Resolvable +@op limit number +@r SecondaryCache +@d Returns a newly constructed cache of between 1 and 100 (default = 50) message +objects found in the channel around a specific point. While the cache will never +automatically gain or lose objects, the objects that it contains may be updated +by gateway events. +]=] +function TextChannel:getMessagesAround(id, limit) + id = Resolver.messageId(id) + return getMessages(self, {around = id, limit = limit}) +end + +--[=[ +@m getPinnedMessages +@t http +@r SecondaryCache +@d Returns a newly constructed cache of up to 50 messages that are pinned in the +channel. While the cache will never automatically gain or lose objects, the +objects that it contains may be updated by gateway events. +]=] +function TextChannel:getPinnedMessages() + local data, err = self.client._api:getPinnedMessages(self._id) + if data then + return SecondaryCache(data, self._messages) + else + return nil, err + end +end + +--[=[ +@m broadcastTyping +@t http +@r boolean +@d Indicates in the channel that the client's user "is typing". +]=] +function TextChannel:broadcastTyping() + local data, err = self.client._api:triggerTypingIndicator(self._id) + if data then + return true + else + return false, err + end +end + +local function parseFile(obj, files) + if type(obj) == 'string' then + local data, err = readFileSync(obj) + if not data then + return nil, err + end + files = files or {} + insert(files, {remove(splitPath(obj)), data}) + elseif type(obj) == 'table' and type(obj[1]) == 'string' and type(obj[2]) == 'string' then + files = files or {} + insert(files, obj) + else + return nil, 'Invalid file object: ' .. tostring(obj) + end + return files +end + +local function parseMention(obj, mentions) + if type(obj) == 'table' and obj.mentionString then + mentions = mentions or {} + insert(mentions, obj.mentionString) + else + return nil, 'Unmentionable object: ' .. tostring(obj) + end + return mentions +end + +--[=[ +@m send +@t http +@p content string/table +@r Message +@d Sends a message to the channel. If `content` is a string, then this is simply +sent as the message content. If it is a table, more advanced formatting is +allowed. See [[managing messages]] for more information. +]=] +function TextChannel:send(content) + + local data, err + + if type(content) == 'table' then + + local tbl = content + content = tbl.content + + if type(tbl.code) == 'string' then + content = format('```%s\n%s\n```', tbl.code, content) + elseif tbl.code == true then + content = format('```\n%s\n```', content) + end + + local mentions + if tbl.mention then + mentions, err = parseMention(tbl.mention) + if err then + return nil, err + end + end + if type(tbl.mentions) == 'table' then + for _, mention in ipairs(tbl.mentions) do + mentions, err = parseMention(mention, mentions) + if err then + return nil, err + end + end + end + + if mentions then + insert(mentions, content) + content = concat(mentions, ' ') + end + + local files + if tbl.file then + files, err = parseFile(tbl.file) + if err then + return nil, err + end + end + if type(tbl.files) == 'table' then + for _, file in ipairs(tbl.files) do + files, err = parseFile(file, files) + if err then + return nil, err + end + end + end + + local refMessage, refMention + if tbl.reference then + refMessage = {message_id = Resolver.messageId(tbl.reference.message)} + refMention = { + parse = {'users', 'roles', 'everyone'}, + replied_user = not not tbl.reference.mention, + } + end + + data, err = self.client._api:createMessage(self._id, { + content = content, + tts = tbl.tts, + nonce = tbl.nonce, + embed = tbl.embed, + message_reference = refMessage, + allowed_mentions = refMention, + }, files) + + else + + data, err = self.client._api:createMessage(self._id, {content = content}) + + end + + if data then + return self._messages:_insert(data) + else + return nil, err + end + +end + +--[=[ +@m sendf +@t http +@p content string +@p ... * +@r Message +@d Sends a message to the channel with content formatted with `...` via `string.format` +]=] +function TextChannel:sendf(content, ...) + local data, err = self.client._api:createMessage(self._id, {content = format(content, ...)}) + if data then + return self._messages:_insert(data) + else + return nil, err + end +end + +--[=[@p messages WeakCache An iterable weak cache of all messages that are +visible to the client. Messages that are not referenced elsewhere are eventually +garbage collected. To access a message that may exist but is not cached, +use `TextChannel:getMessage`.]=] +function get.messages(self) + return self._messages +end + +return TextChannel diff --git a/deps/discordia/libs/containers/abstract/UserPresence.lua b/deps/discordia/libs/containers/abstract/UserPresence.lua new file mode 100644 index 0000000..022e615 --- /dev/null +++ b/deps/discordia/libs/containers/abstract/UserPresence.lua @@ -0,0 +1,127 @@ +--[=[ +@c UserPresence x Container +@t abc +@d Defines the base methods and/or properties for classes that represent a +user's current presence information. Note that any method or property that +exists for the User class is also available in the UserPresence class and its +subclasses. +]=] + +local null = require('json').null +local User = require('containers/User') +local Activity = require('containers/Activity') +local Container = require('containers/abstract/Container') + +local UserPresence, get = require('class')('UserPresence', Container) + +function UserPresence:__init(data, parent) + Container.__init(self, data, parent) + self._user = self.client._users:_insert(data.user) +end + +--[=[ +@m __hash +@r string +@d Returns `UserPresence.user.id` +]=] +function UserPresence:__hash() + return self._user._id +end + +local activities = setmetatable({}, {__mode = 'v'}) + +function UserPresence:_loadPresence(presence) + self._status = presence.status + local status = presence.client_status + if status then + self._web_status = status.web + self._mobile_status = status.mobile + self._desktop_status = status.desktop + end + local game = presence.game + if game == null then + self._activity = nil + elseif game then + local arr = presence.activities + if arr and arr[2] then + for i = 2, #arr do + for k, v in pairs(arr[i]) do + game[k] = v + end + end + end + if self._activity then + self._activity:_load(game) + else + local activity = activities[self:__hash()] + if activity then + activity:_load(game) + else + activity = Activity(game, self) + activities[self:__hash()] = activity + end + self._activity = activity + end + end +end + +function get.gameName(self) + self.client:_deprecated(self.__name, 'gameName', 'activity.name') + return self._activity and self._activity._name +end + +function get.gameType(self) + self.client:_deprecated(self.__name, 'gameType', 'activity.type') + return self._activity and self._activity._type +end + +function get.gameURL(self) + self.client:_deprecated(self.__name, 'gameURL', 'activity.url') + return self._activity and self._activity._url +end + +--[=[@p status string The user's overall status (online, dnd, idle, offline).]=] +function get.status(self) + return self._status or 'offline' +end + +--[=[@p webStatus string The user's web status (online, dnd, idle, offline).]=] +function get.webStatus(self) + return self._web_status or 'offline' +end + +--[=[@p mobileStatus string The user's mobile status (online, dnd, idle, offline).]=] +function get.mobileStatus(self) + return self._mobile_status or 'offline' +end + +--[=[@p desktopStatus string The user's desktop status (online, dnd, idle, offline).]=] +function get.desktopStatus(self) + return self._desktop_status or 'offline' +end + +--[=[@p user User The user that this presence represents.]=] +function get.user(self) + return self._user +end + +--[=[@p activity Activity/nil The Activity that this presence represents.]=] +function get.activity(self) + return self._activity +end + +-- user shortcuts + +for k, v in pairs(User) do + UserPresence[k] = UserPresence[k] or function(self, ...) + return v(self._user, ...) + end +end + +for k, v in pairs(User.__getters) do + get[k] = get[k] or function(self) + return v(self._user) + end +end + +return UserPresence diff --git a/deps/discordia/libs/endpoints.lua b/deps/discordia/libs/endpoints.lua new file mode 100644 index 0000000..e893980 --- /dev/null +++ b/deps/discordia/libs/endpoints.lua @@ -0,0 +1,54 @@ +return { + CHANNEL = "/channels/%s", + CHANNEL_INVITES = "/channels/%s/invites", + CHANNEL_MESSAGE = "/channels/%s/messages/%s", + CHANNEL_MESSAGES = "/channels/%s/messages", + CHANNEL_MESSAGES_BULK_DELETE = "/channels/%s/messages/bulk-delete", + CHANNEL_MESSAGE_REACTION = "/channels/%s/messages/%s/reactions/%s", + CHANNEL_MESSAGE_REACTIONS = "/channels/%s/messages/%s/reactions", + CHANNEL_MESSAGE_REACTION_ME = "/channels/%s/messages/%s/reactions/%s/@me", + CHANNEL_MESSAGE_REACTION_USER = "/channels/%s/messages/%s/reactions/%s/%s", + CHANNEL_PERMISSION = "/channels/%s/permissions/%s", + CHANNEL_PIN = "/channels/%s/pins/%s", + CHANNEL_PINS = "/channels/%s/pins", + CHANNEL_RECIPIENT = "/channels/%s/recipients/%s", + CHANNEL_TYPING = "/channels/%s/typing", + CHANNEL_WEBHOOKS = "/channels/%s/webhooks", + GATEWAY = "/gateway", + GATEWAY_BOT = "/gateway/bot", + GUILD = "/guilds/%s", + GUILDS = "/guilds", + GUILD_AUDIT_LOGS = "/guilds/%s/audit-logs", + GUILD_BAN = "/guilds/%s/bans/%s", + GUILD_BANS = "/guilds/%s/bans", + GUILD_CHANNELS = "/guilds/%s/channels", + GUILD_EMBED = "/guilds/%s/embed", + GUILD_EMOJI = "/guilds/%s/emojis/%s", + GUILD_EMOJIS = "/guilds/%s/emojis", + GUILD_INTEGRATION = "/guilds/%s/integrations/%s", + GUILD_INTEGRATIONS = "/guilds/%s/integrations", + GUILD_INTEGRATION_SYNC = "/guilds/%s/integrations/%s/sync", + GUILD_INVITES = "/guilds/%s/invites", + GUILD_MEMBER = "/guilds/%s/members/%s", + GUILD_MEMBERS = "/guilds/%s/members", + GUILD_MEMBER_ME_NICK = "/guilds/%s/members/@me/nick", + GUILD_MEMBER_ROLE = "/guilds/%s/members/%s/roles/%s", + GUILD_PRUNE = "/guilds/%s/prune", + GUILD_REGIONS = "/guilds/%s/regions", + GUILD_ROLE = "/guilds/%s/roles/%s", + GUILD_ROLES = "/guilds/%s/roles", + GUILD_WEBHOOKS = "/guilds/%s/webhooks", + INVITE = "/invites/%s", + OAUTH2_APPLICATION_ME = "/oauth2/applications/@me", + USER = "/users/%s", + USER_ME = "/users/@me", + USER_ME_CHANNELS = "/users/@me/channels", + USER_ME_CONNECTIONS = "/users/@me/connections", + USER_ME_GUILD = "/users/@me/guilds/%s", + USER_ME_GUILDS = "/users/@me/guilds", + VOICE_REGIONS = "/voice/regions", + WEBHOOK = "/webhooks/%s", + WEBHOOK_TOKEN = "/webhooks/%s/%s", + WEBHOOK_TOKEN_GITHUB = "/webhooks/%s/%s/github", + WEBHOOK_TOKEN_SLACK = "/webhooks/%s/%s/slack", +} diff --git a/deps/discordia/libs/enums.lua b/deps/discordia/libs/enums.lua new file mode 100644 index 0000000..f3b4327 --- /dev/null +++ b/deps/discordia/libs/enums.lua @@ -0,0 +1,214 @@ +local function enum(tbl) + local call = {} + for k, v in pairs(tbl) do + if call[v] then + return error(string.format('enum clash for %q and %q', k, call[v])) + end + call[v] = k + end + return setmetatable({}, { + __call = function(_, k) + if call[k] then + return call[k] + else + return error('invalid enumeration: ' .. tostring(k)) + end + end, + __index = function(_, k) + if tbl[k] then + return tbl[k] + else + return error('invalid enumeration: ' .. tostring(k)) + end + end, + __pairs = function() + return next, tbl + end, + __newindex = function() + return error('cannot overwrite enumeration') + end, + }) +end + +local enums = {enum = enum} + +enums.defaultAvatar = enum { + blurple = 0, + gray = 1, + green = 2, + orange = 3, + red = 4, +} + +enums.notificationSetting = enum { + allMessages = 0, + onlyMentions = 1, +} + +enums.channelType = enum { + text = 0, + private = 1, + voice = 2, + group = 3, + category = 4, + news = 5, +} + +enums.webhookType = enum { + incoming = 1, + channelFollower = 2, +} + +enums.messageType = enum { + default = 0, + recipientAdd = 1, + recipientRemove = 2, + call = 3, + channelNameChange = 4, + channelIconchange = 5, + pinnedMessage = 6, + memberJoin = 7, + premiumGuildSubscription = 8, + premiumGuildSubscriptionTier1 = 9, + premiumGuildSubscriptionTier2 = 10, + premiumGuildSubscriptionTier3 = 11, +} + +enums.relationshipType = enum { + none = 0, + friend = 1, + blocked = 2, + pendingIncoming = 3, + pendingOutgoing = 4, +} + +enums.activityType = enum { + default = 0, + streaming = 1, + listening = 2, + custom = 4, +} + +enums.status = enum { + online = 'online', + idle = 'idle', + doNotDisturb = 'dnd', + invisible = 'invisible', +} + +enums.gameType = enum { -- NOTE: deprecated; use activityType + default = 0, + streaming = 1, + listening = 2, + custom = 4, +} + +enums.verificationLevel = enum { + none = 0, + low = 1, + medium = 2, + high = 3, -- (╯°□°)╯︵ ┻━┻ + veryHigh = 4, -- ┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻ +} + +enums.explicitContentLevel = enum { + none = 0, + medium = 1, + high = 2, +} + +enums.premiumTier = enum { + none = 0, + tier1 = 1, + tier2 = 2, + tier3 = 3, +} + +enums.permission = enum { + createInstantInvite = 0x00000001, + kickMembers = 0x00000002, + banMembers = 0x00000004, + administrator = 0x00000008, + manageChannels = 0x00000010, + manageGuild = 0x00000020, + addReactions = 0x00000040, + viewAuditLog = 0x00000080, + prioritySpeaker = 0x00000100, + stream = 0x00000200, + readMessages = 0x00000400, + sendMessages = 0x00000800, + sendTextToSpeech = 0x00001000, + manageMessages = 0x00002000, + embedLinks = 0x00004000, + attachFiles = 0x00008000, + readMessageHistory = 0x00010000, + mentionEveryone = 0x00020000, + useExternalEmojis = 0x00040000, + connect = 0x00100000, + speak = 0x00200000, + muteMembers = 0x00400000, + deafenMembers = 0x00800000, + moveMembers = 0x01000000, + useVoiceActivity = 0x02000000, + changeNickname = 0x04000000, + manageNicknames = 0x08000000, + manageRoles = 0x10000000, + manageWebhooks = 0x20000000, + manageEmojis = 0x40000000, +} + +enums.messageFlag = enum { + crossposted = 0x00000001, + isCrosspost = 0x00000002, + suppressEmbeds = 0x00000004, + sourceMessageDeleted = 0x00000008, + urgent = 0x00000010, +} + +enums.actionType = enum { + guildUpdate = 1, + channelCreate = 10, + channelUpdate = 11, + channelDelete = 12, + channelOverwriteCreate = 13, + channelOverwriteUpdate = 14, + channelOverwriteDelete = 15, + memberKick = 20, + memberPrune = 21, + memberBanAdd = 22, + memberBanRemove = 23, + memberUpdate = 24, + memberRoleUpdate = 25, + memberMove = 26, + memberDisconnect = 27, + botAdd = 28, + roleCreate = 30, + roleUpdate = 31, + roleDelete = 32, + inviteCreate = 40, + inviteUpdate = 41, + inviteDelete = 42, + webhookCreate = 50, + webhookUpdate = 51, + webhookDelete = 52, + emojiCreate = 60, + emojiUpdate = 61, + emojiDelete = 62, + messageDelete = 72, + messageBulkDelete = 73, + messagePin = 74, + messageUnpin = 75, + integrationCreate = 80, + integrationUpdate = 81, + integrationDelete = 82, +} + +enums.logLevel = enum { + none = 0, + error = 1, + warning = 2, + info = 3, + debug = 4, +} + +return enums diff --git a/deps/discordia/libs/extensions.lua b/deps/discordia/libs/extensions.lua new file mode 100644 index 0000000..5cb835f --- /dev/null +++ b/deps/discordia/libs/extensions.lua @@ -0,0 +1,253 @@ +--[[ NOTE: +These standard library extensions are NOT used in Discordia. They are here as a +convenience for those who wish to use them. + +There are multiple ways to implement some of these commonly used functions. +Please pay attention to the implementations used here and make sure that they +match your expectations. + +You may freely add to, remove, or edit any of the code here without any effect +on the rest of the library. If you do make changes, do be careful when sharing +your expectations with other users. + +You can inject these extensions into the standard Lua global tables by +calling either the main module (ex: discordia.extensions()) or each sub-module +(ex: discordia.extensions.string()) +]] + +local sort, concat = table.sort, table.concat +local insert, remove = table.insert, table.remove +local byte, char = string.byte, string.char +local gmatch, match = string.gmatch, string.match +local rep, find, sub = string.rep, string.find, string.sub +local min, max, random = math.min, math.max, math.random +local ceil, floor = math.ceil, math.floor + +local table = {} + +function table.count(tbl) + local n = 0 + for _ in pairs(tbl) do + n = n + 1 + end + return n +end + +function table.deepcount(tbl) + local n = 0 + for _, v in pairs(tbl) do + n = type(v) == 'table' and n + table.deepcount(v) or n + 1 + end + return n +end + +function table.copy(tbl) + local ret = {} + for k, v in pairs(tbl) do + ret[k] = v + end + return ret +end + +function table.deepcopy(tbl) + local ret = {} + for k, v in pairs(tbl) do + ret[k] = type(v) == 'table' and table.deepcopy(v) or v + end + return ret +end + +function table.reverse(tbl) + for i = 1, #tbl do + insert(tbl, i, remove(tbl)) + end +end + +function table.reversed(tbl) + local ret = {} + for i = #tbl, 1, -1 do + insert(ret, tbl[i]) + end + return ret +end + +function table.keys(tbl) + local ret = {} + for k in pairs(tbl) do + insert(ret, k) + end + return ret +end + +function table.values(tbl) + local ret = {} + for _, v in pairs(tbl) do + insert(ret, v) + end + return ret +end + +function table.randomipair(tbl) + local i = random(#tbl) + return i, tbl[i] +end + +function table.randompair(tbl) + local rand = random(table.count(tbl)) + local n = 0 + for k, v in pairs(tbl) do + n = n + 1 + if n == rand then + return k, v + end + end +end + +function table.sorted(tbl, fn) + local ret = {} + for i, v in ipairs(tbl) do + ret[i] = v + end + sort(ret, fn) + return ret +end + +function table.search(tbl, value) + for k, v in pairs(tbl) do + if v == value then + return k + end + end + return nil +end + +function table.slice(tbl, start, stop, step) + local ret = {} + for i = start or 1, stop or #tbl, step or 1 do + insert(ret, tbl[i]) + end + return ret +end + +local string = {} + +function string.split(str, delim) + local ret = {} + if not str then + return ret + end + if not delim or delim == '' then + for c in gmatch(str, '.') do + insert(ret, c) + end + return ret + end + local n = 1 + while true do + local i, j = find(str, delim, n) + if not i then break end + insert(ret, sub(str, n, i - 1)) + n = j + 1 + end + insert(ret, sub(str, n)) + return ret +end + +function string.trim(str) + return match(str, '^%s*(.-)%s*$') +end + +function string.pad(str, len, align, pattern) + pattern = pattern or ' ' + if align == 'right' then + return rep(pattern, (len - #str) / #pattern) .. str + elseif align == 'center' then + local pad = 0.5 * (len - #str) / #pattern + return rep(pattern, floor(pad)) .. str .. rep(pattern, ceil(pad)) + else -- left + return str .. rep(pattern, (len - #str) / #pattern) + end +end + +function string.startswith(str, pattern, plain) + local start = 1 + return find(str, pattern, start, plain) == start +end + +function string.endswith(str, pattern, plain) + local start = #str - #pattern + 1 + return find(str, pattern, start, plain) == start +end + +function string.levenshtein(str1, str2) + + if str1 == str2 then return 0 end + + local len1 = #str1 + local len2 = #str2 + + if len1 == 0 then + return len2 + elseif len2 == 0 then + return len1 + end + + local matrix = {} + for i = 0, len1 do + matrix[i] = {[0] = i} + end + for j = 0, len2 do + matrix[0][j] = j + end + + for i = 1, len1 do + for j = 1, len2 do + local cost = byte(str1, i) == byte(str2, j) and 0 or 1 + matrix[i][j] = min(matrix[i-1][j] + 1, matrix[i][j-1] + 1, matrix[i-1][j-1] + cost) + end + end + + return matrix[len1][len2] + +end + +function string.random(len, mn, mx) + local ret = {} + mn = mn or 0 + mx = mx or 255 + for _ = 1, len do + insert(ret, char(random(mn, mx))) + end + return concat(ret) +end + +local math = {} + +function math.clamp(n, minValue, maxValue) + return min(max(n, minValue), maxValue) +end + +function math.round(n, i) + local m = 10 ^ (i or 0) + return floor(n * m + 0.5) / m +end + +local ext = setmetatable({ + table = table, + string = string, + math = math, +}, {__call = function(self) + for _, v in pairs(self) do + v() + end +end}) + +for n, m in pairs(ext) do + setmetatable(m, {__call = function(self) + for k, v in pairs(self) do + _G[n][k] = v + end + end}) +end + +return ext diff --git a/deps/discordia/libs/iterables/ArrayIterable.lua b/deps/discordia/libs/iterables/ArrayIterable.lua new file mode 100644 index 0000000..1a4dede --- /dev/null +++ b/deps/discordia/libs/iterables/ArrayIterable.lua @@ -0,0 +1,108 @@ +--[=[ +@c ArrayIterable x Iterable +@mt mem +@d Iterable class that contains objects in a constant, ordered fashion, although +the order may change if the internal array is modified. Some versions may use a +map function to shape the objects before they are accessed. +]=] + +local Iterable = require('iterables/Iterable') + +local ArrayIterable, get = require('class')('ArrayIterable', Iterable) + +function ArrayIterable:__init(array, map) + self._array = array + self._map = map +end + +function ArrayIterable:__len() + local array = self._array + if not array or #array == 0 then + return 0 + end + local map = self._map + if map then -- map can return nil + return Iterable.__len(self) + else + return #array + end +end + +--[=[@p first * The first object in the array]=] +function get.first(self) + local array = self._array + if not array or #array == 0 then + return nil + end + local map = self._map + if map then + for i = 1, #array, 1 do + local v = array[i] + local obj = v and map(v) + if obj then + return obj + end + end + else + return array[1] + end +end + +--[=[@p last * The last object in the array]=] +function get.last(self) + local array = self._array + if not array or #array == 0 then + return nil + end + local map = self._map + if map then + for i = #array, 1, -1 do + local v = array[i] + local obj = v and map(v) + if obj then + return obj + end + end + else + return array[#array] + end +end + +--[=[ +@m iter +@r function +@d Returns an iterator for all contained objects in a consistent order. +]=] +function ArrayIterable:iter() + local array = self._array + if not array or #array == 0 then + return function() -- new closure for consistency + return nil + end + end + local map = self._map + if map then + local i = 0 + return function() + while true do + i = i + 1 + local v = array[i] + if not v then + return nil + end + v = map(v) + if v then + return v + end + end + end + else + local i = 0 + return function() + i = i + 1 + return array[i] + end + end +end + +return ArrayIterable diff --git a/deps/discordia/libs/iterables/Cache.lua b/deps/discordia/libs/iterables/Cache.lua new file mode 100644 index 0000000..a9c757b --- /dev/null +++ b/deps/discordia/libs/iterables/Cache.lua @@ -0,0 +1,150 @@ +--[=[ +@c Cache x Iterable +@mt mem +@d Iterable class that holds references to Discordia Class objects in no particular order. +]=] + +local json = require('json') +local Iterable = require('iterables/Iterable') + +local null = json.null + +local Cache = require('class')('Cache', Iterable) + +local meta = {__mode = 'v'} + +function Cache:__init(array, constructor, parent) + local objects = {} + for _, data in ipairs(array) do + local obj = constructor(data, parent) + objects[obj:__hash()] = obj + end + self._count = #array + self._objects = objects + self._constructor = constructor + self._parent = parent + self._deleted = setmetatable({}, meta) +end + +function Cache:__pairs() + return next, self._objects +end + +function Cache:__len() + return self._count +end + +local function insert(self, k, obj) + self._objects[k] = obj + self._count = self._count + 1 + return obj +end + +local function remove(self, k, obj) + self._objects[k] = nil + self._deleted[k] = obj + self._count = self._count - 1 + return obj +end + +local function hash(data) + -- local meta = getmetatable(data) -- debug + -- assert(meta and meta.__jsontype == 'object') -- debug + if data.id then -- snowflakes + return data.id + elseif data.user then -- members + return data.user.id + elseif data.emoji then -- reactions + return data.emoji.id ~= null and data.emoji.id or data.emoji.name + elseif data.code then -- invites + return data.code + else + return nil, 'json data could not be hashed' + end +end + +function Cache:_insert(data) + local k = assert(hash(data)) + local old = self._objects[k] + if old then + old:_load(data) + return old + elseif self._deleted[k] then + return insert(self, k, self._deleted[k]) + else + local obj = self._constructor(data, self._parent) + return insert(self, k, obj) + end +end + +function Cache:_remove(data) + local k = assert(hash(data)) + local old = self._objects[k] + if old then + old:_load(data) + return remove(self, k, old) + elseif self._deleted[k] then + return self._deleted[k] + else + return self._constructor(data, self._parent) + end +end + +function Cache:_delete(k) + local old = self._objects[k] + if old then + return remove(self, k, old) + elseif self._deleted[k] then + return self._deleted[k] + else + return nil + end +end + +function Cache:_load(array, update) + if update then + local updated = {} + for _, data in ipairs(array) do + local obj = self:_insert(data) + updated[obj:__hash()] = true + end + for obj in self:iter() do + local k = obj:__hash() + if not updated[k] then + self:_delete(k) + end + end + else + for _, data in ipairs(array) do + self:_insert(data) + end + end +end + +--[=[ +@m get +@p k * +@r * +@d Returns an individual object by key, where the key should match the result of +calling `__hash` on the contained objects. Unlike Iterable:get, this +method operates with O(1) complexity. +]=] +function Cache:get(k) + return self._objects[k] +end + +--[=[ +@m iter +@r function +@d Returns an iterator that returns all contained objects. The order of the objects +is not guaranteed. +]=] +function Cache:iter() + local objects, k, obj = self._objects + return function() + k, obj = next(objects, k) + return obj + end +end + +return Cache diff --git a/deps/discordia/libs/iterables/FilteredIterable.lua b/deps/discordia/libs/iterables/FilteredIterable.lua new file mode 100644 index 0000000..5fdfe1e --- /dev/null +++ b/deps/discordia/libs/iterables/FilteredIterable.lua @@ -0,0 +1,27 @@ +--[=[ +@c FilteredIterable x Iterable +@mt mem +@d Iterable class that wraps another iterable and serves a subset of the objects +that the original iterable contains. +]=] + +local Iterable = require('iterables/Iterable') + +local FilteredIterable = require('class')('FilteredIterable', Iterable) + +function FilteredIterable:__init(base, predicate) + self._base = base + self._predicate = predicate +end + +--[=[ +@m iter +@r function +@d Returns an iterator that returns all contained objects. The order of the objects +is not guaranteed. +]=] +function FilteredIterable:iter() + return self._base:findAll(self._predicate) +end + +return FilteredIterable diff --git a/deps/discordia/libs/iterables/Iterable.lua b/deps/discordia/libs/iterables/Iterable.lua new file mode 100644 index 0000000..ebfb7b4 --- /dev/null +++ b/deps/discordia/libs/iterables/Iterable.lua @@ -0,0 +1,278 @@ +--[=[ +@c Iterable +@mt mem +@d Abstract base class that defines the base methods and properties for a +general purpose data structure with features that are better suited for an +object-oriented environment. + +Note: All sub-classes should implement their own `__init` and `iter` methods and +all stored objects should have a `__hash` method. +]=] + +local random = math.random +local insert, sort, pack = table.insert, table.sort, table.pack + +local Iterable = require('class')('Iterable') + +--[=[ +@m __pairs +@r function +@d Defines the behavior of the `pairs` function. Returns an iterator that returns +a `key, value` pair, where `key` is the result of calling `__hash` on the `value`. +]=] +function Iterable:__pairs() + local gen = self:iter() + return function() + local obj = gen() + if not obj then + return nil + end + return obj:__hash(), obj + end +end + +--[=[ +@m __len +@r function +@d Defines the behavior of the `#` operator. Returns the total number of objects +stored in the iterable. +]=] +function Iterable:__len() + local n = 0 + for _ in self:iter() do + n = n + 1 + end + return n +end + +--[=[ +@m get +@p k * +@r * +@d Returns an individual object by key, where the key should match the result of +calling `__hash` on the contained objects. Operates with up to O(n) complexity. +]=] +function Iterable:get(k) -- objects must be hashable + for obj in self:iter() do + if obj:__hash() == k then + return obj + end + end + return nil +end + +--[=[ +@m find +@p fn function +@r * +@d Returns the first object that satisfies a predicate. +]=] +function Iterable:find(fn) + for obj in self:iter() do + if fn(obj) then + return obj + end + end + return nil +end + +--[=[ +@m findAll +@p fn function +@r function +@d Returns an iterator that returns all objects that satisfy a predicate. +]=] +function Iterable:findAll(fn) + local gen = self:iter() + return function() + while true do + local obj = gen() + if not obj then + return nil + end + if fn(obj) then + return obj + end + end + end +end + +--[=[ +@m forEach +@p fn function +@r nil +@d Iterates through all objects and calls a function `fn` that takes the +objects as an argument. +]=] +function Iterable:forEach(fn) + for obj in self:iter() do + fn(obj) + end +end + +--[=[ +@m random +@r * +@d Returns a random object that is contained in the iterable. +]=] +function Iterable:random() + local n = 1 + local rand = random(#self) + for obj in self:iter() do + if n == rand then + return obj + end + n = n + 1 + end +end + +--[=[ +@m count +@op fn function +@r number +@d If a predicate is provided, this returns the number of objects in the iterable +that satisfy the predicate; otherwise, the total number of objects. +]=] +function Iterable:count(fn) + if not fn then + return self:__len() + end + local n = 0 + for _ in self:findAll(fn) do + n = n + 1 + end + return n +end + +local function sorter(a, b) + local t1, t2 = type(a), type(b) + if t1 == 'string' then + if t2 == 'string' then + local n1 = tonumber(a) + if n1 then + local n2 = tonumber(b) + if n2 then + return n1 < n2 + end + end + return a:lower() < b:lower() + elseif t2 == 'number' then + local n1 = tonumber(a) + if n1 then + return n1 < b + end + return a:lower() < tostring(b) + end + elseif t1 == 'number' then + if t2 == 'number' then + return a < b + elseif t2 == 'string' then + local n2 = tonumber(b) + if n2 then + return a < n2 + end + return tostring(a) < b:lower() + end + end + local m1 = getmetatable(a) + if m1 and m1.__lt then + local m2 = getmetatable(b) + if m2 and m2.__lt then + return a < b + end + end + return tostring(a) < tostring(b) +end + +--[=[ +@m toArray +@op sortBy string +@op fn function +@r table +@d Returns a sequentially-indexed table that contains references to all objects. +If a `sortBy` string is provided, then the table is sorted by that particular +property. If a predicate is provided, then only objects that satisfy it will +be included. +]=] +function Iterable:toArray(sortBy, fn) + local t1 = type(sortBy) + if t1 == 'string' then + fn = type(fn) == 'function' and fn + elseif t1 == 'function' then + fn = sortBy + sortBy = nil + end + local ret = {} + for obj in self:iter() do + if not fn or fn(obj) then + insert(ret, obj) + end + end + if sortBy then + sort(ret, function(a, b) + return sorter(a[sortBy], b[sortBy]) + end) + end + return ret +end + +--[=[ +@m select +@p ... string +@r table +@d Similarly to an SQL query, this returns a sorted Lua table of rows where each +row corresponds to each object in the iterable, and each value in the row is +selected from the objects according to the keys provided. +]=] +function Iterable:select(...) + local rows = {} + local keys = pack(...) + for obj in self:iter() do + local row = {} + for i = 1, keys.n do + row[i] = obj[keys[i]] + end + insert(rows, row) + end + sort(rows, function(a, b) + for i = 1, keys.n do + if a[i] ~= b[i] then + return sorter(a[i], b[i]) + end + end + end) + return rows +end + +--[=[ +@m pick +@p ... string/function +@r function +@d This returns an iterator that, when called, returns the values from each +encountered object, picked by the provided keys. If a key is a string, the objects +are indexed with the string. If a key is a function, the function is called with +the object passed as its first argument. +]=] +function Iterable:pick(...) + local keys = pack(...) + local values = {} + local n = keys.n + local gen = self:iter() + return function() + local obj = gen() + if not obj then + return nil + end + for i = 1, n do + local k = keys[i] + if type(k) == 'function' then + values[i] = k(obj) + else + values[i] = obj[k] + end + end + return unpack(values, 1, n) + end +end + +return Iterable diff --git a/deps/discordia/libs/iterables/SecondaryCache.lua b/deps/discordia/libs/iterables/SecondaryCache.lua new file mode 100644 index 0000000..660fd2c --- /dev/null +++ b/deps/discordia/libs/iterables/SecondaryCache.lua @@ -0,0 +1,78 @@ +--[=[ +@c SecondaryCache x Iterable +@mt mem +@d Iterable class that wraps another cache. Objects added to or removed from a +secondary cache are also automatically added to or removed from the primary +cache that it wraps. +]=] + +local Iterable = require('iterables/Iterable') + +local SecondaryCache = require('class')('SecondaryCache', Iterable) + +function SecondaryCache:__init(array, primary) + local objects = {} + for _, data in ipairs(array) do + local obj = primary:_insert(data) + objects[obj:__hash()] = obj + end + self._count = #array + self._objects = objects + self._primary = primary +end + +function SecondaryCache:__pairs() + return next, self._objects +end + +function SecondaryCache:__len() + return self._count +end + +function SecondaryCache:_insert(data) + local obj = self._primary:_insert(data) + local k = obj:__hash() + if not self._objects[k] then + self._objects[k] = obj + self._count = self._count + 1 + end + return obj +end + +function SecondaryCache:_remove(data) + local obj = self._primary:_insert(data) -- yes, this is correct + local k = obj:__hash() + if self._objects[k] then + self._objects[k] = nil + self._count = self._count - 1 + end + return obj +end + +--[=[ +@m get +@p k * +@r * +@d Returns an individual object by key, where the key should match the result of +calling `__hash` on the contained objects. Unlike the default version, this +method operates with O(1) complexity. +]=] +function SecondaryCache:get(k) + return self._objects[k] +end + +--[=[ +@m iter +@r function +@d Returns an iterator that returns all contained objects. The order of the objects +is not guaranteed. +]=] +function SecondaryCache:iter() + local objects, k, obj = self._objects + return function() + k, obj = next(objects, k) + return obj + end +end + +return SecondaryCache diff --git a/deps/discordia/libs/iterables/TableIterable.lua b/deps/discordia/libs/iterables/TableIterable.lua new file mode 100644 index 0000000..699389d --- /dev/null +++ b/deps/discordia/libs/iterables/TableIterable.lua @@ -0,0 +1,53 @@ +--[=[ +@c TableIterable x Iterable +@mt mem +@d Iterable class that wraps a basic Lua table, where order is not guaranteed. +Some versions may use a map function to shape the objects before they are accessed. +]=] + +local Iterable = require('iterables/Iterable') + +local TableIterable = require('class')('TableIterable', Iterable) + +function TableIterable:__init(tbl, map) + self._tbl = tbl + self._map = map +end + +--[=[ +@m iter +@r function +@d Returns an iterator that returns all contained objects. The order of the objects is not guaranteed. +]=] +function TableIterable:iter() + local tbl = self._tbl + if not tbl then + return function() + return nil + end + end + local map = self._map + if map then + local k, v + return function() + while true do + k, v = next(tbl, k) + if not v then + return nil + end + v = map(v) + if v then + return v + end + end + end + else + local k, v + return function() + k, v = next(tbl, k) + return v + end + end +end + +return TableIterable diff --git a/deps/discordia/libs/iterables/WeakCache.lua b/deps/discordia/libs/iterables/WeakCache.lua new file mode 100644 index 0000000..8d89ba8 --- /dev/null +++ b/deps/discordia/libs/iterables/WeakCache.lua @@ -0,0 +1,25 @@ +--[=[ +@c WeakCache x Cache +@mt mem +@d Extends the functionality of a regular cache by making use of weak references +to the objects that are cached. If all references to an object are weak, as they +are here, then the object will be deleted on the next garbage collection cycle. +]=] + +local Cache = require('iterables/Cache') +local Iterable = require('iterables/Iterable') + +local WeakCache = require('class')('WeakCache', Cache) + +local meta = {__mode = 'v'} + +function WeakCache:__init(array, constructor, parent) + Cache.__init(self, array, constructor, parent) + setmetatable(self._objects, meta) +end + +function WeakCache:__len() -- NOTE: _count is not accurate for weak caches + return Iterable.__len(self) +end + +return WeakCache diff --git a/deps/discordia/libs/utils/Clock.lua b/deps/discordia/libs/utils/Clock.lua new file mode 100644 index 0000000..892abc2 --- /dev/null +++ b/deps/discordia/libs/utils/Clock.lua @@ -0,0 +1,56 @@ +--[=[ +@c Clock x Emitter +@t ui +@mt mem +@d Used to periodically execute code according to the ticking of the system clock instead of an arbitrary interval. +]=] + +local timer = require('timer') +local Emitter = require('utils/Emitter') + +local date = os.date +local setInterval, clearInterval = timer.setInterval, timer.clearInterval + +local Clock = require('class')('Clock', Emitter) + +function Clock:__init() + Emitter.__init(self) +end + +--[=[ +@m start +@op utc boolean +@r nil +@d Starts the main loop for the clock. If a truthy argument is passed, then UTC +time is used; otherwise, local time is used. As the clock ticks, an event is +emitted for every `os.date` value change. The event name is the key of the value +that changed and the event argument is the corresponding date table. +]=] +function Clock:start(utc) + if self._interval then return end + local fmt = utc and '!*t' or '*t' + local prev = date(fmt) + self._interval = setInterval(1000, function() + local now = date(fmt) + for k, v in pairs(now) do + if v ~= prev[k] then + self:emit(k, now) + end + end + prev = now + end) +end + +--[=[ +@m stop +@r nil +@d Stops the main loop for the clock. +]=] +function Clock:stop() + if self._interval then + clearInterval(self._interval) + self._interval = nil + end +end + +return Clock diff --git a/deps/discordia/libs/utils/Color.lua b/deps/discordia/libs/utils/Color.lua new file mode 100644 index 0000000..3d01bd9 --- /dev/null +++ b/deps/discordia/libs/utils/Color.lua @@ -0,0 +1,313 @@ +--[=[ +@c Color +@t ui +@mt mem +@p value number +@d Wrapper for 24-bit colors packed as a decimal value. See the static constructors for more information. +]=] + +local class = require('class') + +local format = string.format +local min, max, abs, floor = math.min, math.max, math.abs, math.floor +local lshift, rshift = bit.lshift, bit.rshift +local band, bor = bit.band, bit.bor +local bnot = bit.bnot +local isInstance = class.isInstance + +local Color, get = class('Color') + +local function check(self, other) + if not isInstance(self, Color) or not isInstance(other, Color) then + return error('Cannot perform operation with non-Color object', 2) + end +end + +local function clamp(n, mn, mx) + return min(max(n, mn), mx) +end + +function Color:__init(value) + value = tonumber(value) + self._value = value and band(value, 0xFFFFFF) or 0 +end + +function Color:__tostring() + return format('Color: %s (%i, %i, %i)', self:toHex(), self:toRGB()) +end + +function Color:__eq(other) check(self, other) + return self._value == other._value +end + +function Color:__add(other) check(self, other) + local r = clamp(self.r + other.r, 0, 0xFF) + local g = clamp(self.g + other.g, 0, 0xFF) + local b = clamp(self.b + other.b, 0, 0xFF) + return Color.fromRGB(r, g, b) +end + +function Color:__sub(other) check(self, other) + local r = clamp(self.r - other.r, 0, 0xFF) + local g = clamp(self.g - other.g, 0, 0xFF) + local b = clamp(self.b - other.b, 0, 0xFF) + return Color.fromRGB(r, g, b) +end + +function Color:__mul(other) + if not isInstance(self, Color) then + self, other = other, self + end + other = tonumber(other) + if other then + local r = clamp(self.r * other, 0, 0xFF) + local g = clamp(self.g * other, 0, 0xFF) + local b = clamp(self.b * other, 0, 0xFF) + return Color.fromRGB(r, g, b) + else + return error('Cannot perform operation with non-numeric object') + end +end + +function Color:__div(other) + if not isInstance(self, Color) then + return error('Division with Color is not commutative') + end + other = tonumber(other) + if other then + local r = clamp(self.r / other, 0, 0xFF) + local g = clamp(self.g / other, 0, 0xFF) + local b = clamp(self.b / other, 0, 0xFF) + return Color.fromRGB(r, g, b) + else + return error('Cannot perform operation with non-numeric object') + end +end + +--[=[ +@m fromHex +@t static +@p hex string +@r Color +@d Constructs a new Color object from a hexadecimal string. The string may or may +not be prefixed by `#`; all other characters are interpreted as a hex string. +]=] +function Color.fromHex(hex) + return Color(tonumber(hex:match('#?(.*)'), 16)) +end + +--[=[ +@m fromRGB +@t static +@p r number +@p g number +@p b number +@r Color +@d Constructs a new Color object from RGB values. Values are allowed to overflow +though one component will not overflow to the next component. +]=] +function Color.fromRGB(r, g, b) + r = band(lshift(r, 16), 0xFF0000) + g = band(lshift(g, 8), 0x00FF00) + b = band(b, 0x0000FF) + return Color(bor(bor(r, g), b)) +end + +local function fromHue(h, c, m) + local x = c * (1 - abs(h / 60 % 2 - 1)) + local r, g, b + if 0 <= h and h < 60 then + r, g, b = c, x, 0 + elseif 60 <= h and h < 120 then + r, g, b = x, c, 0 + elseif 120 <= h and h < 180 then + r, g, b = 0, c, x + elseif 180 <= h and h < 240 then + r, g, b = 0, x, c + elseif 240 <= h and h < 300 then + r, g, b = x, 0, c + elseif 300 <= h and h < 360 then + r, g, b = c, 0, x + end + r = (r + m) * 0xFF + g = (g + m) * 0xFF + b = (b + m) * 0xFF + return r, g, b +end + +local function toHue(r, g, b) + r = r / 0xFF + g = g / 0xFF + b = b / 0xFF + local mn = min(r, g, b) + local mx = max(r, g, b) + local d = mx - mn + local h + if d == 0 then + h = 0 + elseif mx == r then + h = (g - b) / d % 6 + elseif mx == g then + h = (b - r) / d + 2 + elseif mx == b then + h = (r - g) / d + 4 + end + h = floor(h * 60 + 0.5) + return h, d, mx, mn +end + +--[=[ +@m fromHSV +@t static +@p h number +@p s number +@p v number +@r Color +@d Constructs a new Color object from HSV values. Hue is allowed to overflow +while saturation and value are clamped to [0, 1]. +]=] +function Color.fromHSV(h, s, v) + h = h % 360 + s = clamp(s, 0, 1) + v = clamp(v, 0, 1) + local c = v * s + local m = v - c + local r, g, b = fromHue(h, c, m) + return Color.fromRGB(r, g, b) +end + +--[=[ +@m fromHSL +@t static +@p h number +@p s number +@p l number +@r Color +@d Constructs a new Color object from HSL values. Hue is allowed to overflow +while saturation and lightness are clamped to [0, 1]. +]=] +function Color.fromHSL(h, s, l) + h = h % 360 + s = clamp(s, 0, 1) + l = clamp(l, 0, 1) + local c = (1 - abs(2 * l - 1)) * s + local m = l - c * 0.5 + local r, g, b = fromHue(h, c, m) + return Color.fromRGB(r, g, b) +end + +--[=[ +@m toHex +@r string +@d Returns a 6-digit hexadecimal string that represents the color value. +]=] +function Color:toHex() + return format('#%06X', self._value) +end + +--[=[ +@m toRGB +@r number +@r number +@r number +@d Returns the red, green, and blue values that are packed into the color value. +]=] +function Color:toRGB() + return self.r, self.g, self.b +end + +--[=[ +@m toHSV +@r number +@r number +@r number +@d Returns the hue, saturation, and value that represents the color value. +]=] +function Color:toHSV() + local h, d, mx = toHue(self.r, self.g, self.b) + local v = mx + local s = mx == 0 and 0 or d / mx + return h, s, v +end + +--[=[ +@m toHSL +@r number +@r number +@r number +@d Returns the hue, saturation, and lightness that represents the color value. +]=] +function Color:toHSL() + local h, d, mx, mn = toHue(self.r, self.g, self.b) + local l = (mx + mn) * 0.5 + local s = d == 0 and 0 or d / (1 - abs(2 * l - 1)) + return h, s, l +end + +--[=[@p value number The raw decimal value that represents the color value.]=] +function get.value(self) + return self._value +end + +local function getByte(value, offset) + return band(rshift(value, offset), 0xFF) +end + +--[=[@p r number The value that represents the color's red-level.]=] +function get.r(self) + return getByte(self._value, 16) +end + +--[=[@p g number The value that represents the color's green-level.]=] +function get.g(self) + return getByte(self._value, 8) +end + +--[=[@p b number The value that represents the color's blue-level.]=] +function get.b(self) + return getByte(self._value, 0) +end + +local function setByte(value, offset, new) + local byte = lshift(0xFF, offset) + value = band(value, bnot(byte)) + return bor(value, band(lshift(new, offset), byte)) +end + +--[=[ +@m setRed +@r nil +@d Sets the color's red-level. +]=] +function Color:setRed(r) + self._value = setByte(self._value, 16, r) +end + +--[=[ +@m setGreen +@r nil +@d Sets the color's green-level. +]=] +function Color:setGreen(g) + self._value = setByte(self._value, 8, g) +end + +--[=[ +@m setBlue +@r nil +@d Sets the color's blue-level. +]=] +function Color:setBlue(b) + self._value = setByte(self._value, 0, b) +end + +--[=[ +@m copy +@r Color +@d Returns a new copy of the original color object. +]=] +function Color:copy() + return Color(self._value) +end + +return Color diff --git a/deps/discordia/libs/utils/Date.lua b/deps/discordia/libs/utils/Date.lua new file mode 100644 index 0000000..ec2ddfe --- /dev/null +++ b/deps/discordia/libs/utils/Date.lua @@ -0,0 +1,394 @@ +--[=[ +@c Date +@t ui +@mt mem +@op seconds number +@op microseconds number +@d Represents a single moment in time and provides utilities for converting to +and from different date and time formats. Although microsecond precision is available, +most formats are implemented with only second precision. +]=] + +local class = require('class') +local constants = require('constants') +local Time = require('utils/Time') + +local abs, modf, fmod, floor = math.abs, math.modf, math.fmod, math.floor +local format = string.format +local date, time, difftime = os.date, os.time, os.difftime +local isInstance = class.isInstance + +local MS_PER_S = constants.MS_PER_S +local US_PER_MS = constants.US_PER_MS +local US_PER_S = US_PER_MS * MS_PER_S + +local DISCORD_EPOCH = constants.DISCORD_EPOCH + +local months = { + Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, + Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12 +} + +local function offset() -- difference between *t and !*t + return difftime(time(), time(date('!*t'))) +end + +local Date = class('Date') + +local function check(self, other) + if not isInstance(self, Date) or not isInstance(other, Date) then + return error('Cannot perform operation with non-Date object', 2) + end +end + +function Date:__init(seconds, micro) + + local f + seconds = tonumber(seconds) + if seconds then + seconds, f = modf(seconds) + else + seconds = time() + end + + micro = tonumber(micro) + if micro then + seconds = seconds + modf(micro / US_PER_S) + micro = fmod(micro, US_PER_S) + else + micro = 0 + end + + if f and f > 0 then + micro = micro + US_PER_S * f + end + + self._s = seconds + self._us = floor(micro + 0.5) + +end + +function Date:__tostring() + return 'Date: ' .. self:toString() +end + +--[=[ +@m toString +@op fmt string +@r string +@d Returns a string from this Date object via Lua's `os.date`. +If no format string is provided, the default is '%a %b %d %Y %T GMT%z (%Z)'. +]=] +function Date:toString(fmt) + if not fmt or fmt == '*t' or fmt == '!*t' then + fmt = '%a %b %d %Y %T GMT%z (%Z)' + end + return date(fmt, self._s) +end + +function Date:__eq(other) check(self, other) + return self._s == other._s and self._us == other._us +end + +function Date:__lt(other) check(self, other) + return self:toMicroseconds() < other:toMicroseconds() +end + +function Date:__le(other) check(self, other) + return self:toMicroseconds() <= other:toMicroseconds() +end + +function Date:__add(other) + if not isInstance(self, Date) then + self, other = other, self + end + if not isInstance(other, Time) then + return error('Cannot perform operation with non-Time object') + end + return Date(self:toSeconds() + other:toSeconds()) +end + +function Date:__sub(other) + if isInstance(self, Date) then + if isInstance(other, Date) then + return Time(abs(self:toMilliseconds() - other:toMilliseconds())) + elseif isInstance(other, Time) then + return Date(self:toSeconds() - other:toSeconds()) + else + return error('Cannot perform operation with non-Date/Time object') + end + else + return error('Cannot perform operation with non-Date object') + end +end + +--[=[ +@m parseISO +@t static +@p str string +@r number +@r number +@d Converts an ISO 8601 string into a Unix time in seconds. For compatibility +with Discord's timestamp format, microseconds are also provided as a second +return value. +]=] +function Date.parseISO(str) + local year, month, day, hour, min, sec, other = str:match( + '(%d+)-(%d+)-(%d+).(%d+):(%d+):(%d+)(.*)' + ) + other = other:match('%.%d+') + return Date.parseTableUTC { + day = day, month = month, year = year, + hour = hour, min = min, sec = sec, isdst = false, + }, other and other * US_PER_S or 0 +end + +--[=[ +@m parseHeader +@t static +@p str string +@r number +@d Converts an RFC 2822 string (an HTTP Date header) into a Unix time in seconds. +]=] +function Date.parseHeader(str) + local day, month, year, hour, min, sec = str:match( + '%a+, (%d+) (%a+) (%d+) (%d+):(%d+):(%d+) GMT' + ) + return Date.parseTableUTC { + day = day, month = months[month], year = year, + hour = hour, min = min, sec = sec, isdst = false, + } +end + +--[=[ +@m parseSnowflake +@t static +@p id string +@r number +@d Converts a Discord Snowflake ID into a Unix time in seconds. Additional +decimal points may be present, though only the first 3 (milliseconds) should be +considered accurate. +]=] +function Date.parseSnowflake(id) + return (id / 2^22 + DISCORD_EPOCH) / MS_PER_S +end + +--[=[ +@m parseTable +@t static +@p tbl table +@r number +@d Interprets a Lua date table as a local time and converts it to a Unix time in +seconds. Equivalent to `os.time(tbl)`. +]=] +function Date.parseTable(tbl) + return time(tbl) +end + +--[=[ +@m parseTableUTC +@t static +@p tbl table +@r number +@d Interprets a Lua date table as a UTC time and converts it to a Unix time in +seconds. Equivalent to `os.time(tbl)` with a correction for UTC. +]=] +function Date.parseTableUTC(tbl) + return time(tbl) + offset() +end + +--[=[ +@m fromISO +@t static +@p str string +@r Date +@d Constructs a new Date object from an ISO 8601 string. Equivalent to +`Date(Date.parseISO(str))`. +]=] +function Date.fromISO(str) + return Date(Date.parseISO(str)) +end + +--[=[ +@m fromHeader +@t static +@p str string +@r Date +@d Constructs a new Date object from an RFC 2822 string. Equivalent to +`Date(Date.parseHeader(str))`. +]=] +function Date.fromHeader(str) + return Date(Date.parseHeader(str)) +end + +--[=[ +@m fromSnowflake +@t static +@p id string +@r Date +@d Constructs a new Date object from a Discord/Twitter Snowflake ID. Equivalent to +`Date(Date.parseSnowflake(id))`. +]=] +function Date.fromSnowflake(id) + return Date(Date.parseSnowflake(id)) +end + +--[=[ +@m fromTable +@t static +@p tbl table +@r Date +@d Constructs a new Date object from a Lua date table interpreted as a local time. +Equivalent to `Date(Date.parseTable(tbl))`. +]=] +function Date.fromTable(tbl) + return Date(Date.parseTable(tbl)) +end + +--[=[ +@m fromTableUTC +@t static +@p tbl table +@r Date +@d Constructs a new Date object from a Lua date table interpreted as a UTC time. +Equivalent to `Date(Date.parseTableUTC(tbl))`. +]=] +function Date.fromTableUTC(tbl) + return Date(Date.parseTableUTC(tbl)) +end + +--[=[ +@m fromSeconds +@t static +@p s number +@r Date +@d Constructs a new Date object from a Unix time in seconds. +]=] +function Date.fromSeconds(s) + return Date(s) +end + +--[=[ +@m fromMilliseconds +@t static +@p ms number +@r Date +@d Constructs a new Date object from a Unix time in milliseconds. +]=] +function Date.fromMilliseconds(ms) + return Date(ms / MS_PER_S) +end + +--[=[ +@m fromMicroseconds +@t static +@p us number +@r Date +@d Constructs a new Date object from a Unix time in microseconds. +]=] +function Date.fromMicroseconds(us) + return Date(0, us) +end + +--[=[ +@m toISO +@op sep string +@op tz string +@r string +@d Returns an ISO 8601 string that represents the stored date and time. +If `sep` and `tz` are both provided, then they are used as a custom separator +and timezone; otherwise, `T` is used for the separator and `+00:00` is used for +the timezone, plus microseconds if available. +]=] +function Date:toISO(sep, tz) + if sep and tz then + local ret = date('!%F%%s%T%%s', self._s) + return format(ret, sep, tz) + else + if self._us == 0 then + return date('!%FT%T', self._s) .. '+00:00' + else + return date('!%FT%T', self._s) .. format('.%06i+00:00', self._us) + end + end +end + +--[=[ +@m toHeader +@r string +@d Returns an RFC 2822 string that represents the stored date and time. +]=] +function Date:toHeader() + return date('!%a, %d %b %Y %T GMT', self._s) +end + +--[=[ +@m toSnowflake +@r string +@d Returns a synthetic Discord Snowflake ID based on the stored date and time. +Due to the lack of native 64-bit support, the result may lack precision. +In other words, `Date.fromSnowflake(id):toSnowflake() == id` may be `false`. +]=] +function Date:toSnowflake() + local n = (self:toMilliseconds() - DISCORD_EPOCH) * 2^22 + return format('%f', n):match('%d*') +end + +--[=[ +@m toTable +@r table +@d Returns a Lua date table that represents the stored date and time as a local +time. Equivalent to `os.date('*t', s)` where `s` is the Unix time in seconds. +]=] +function Date:toTable() + return date('*t', self._s) +end + +--[=[ +@m toTableUTC +@r table +@d Returns a Lua date table that represents the stored date and time as a UTC +time. Equivalent to `os.date('!*t', s)` where `s` is the Unix time in seconds. +]=] +function Date:toTableUTC() + return date('!*t', self._s) +end + +--[=[ +@m toSeconds +@r number +@d Returns a Unix time in seconds that represents the stored date and time. +]=] +function Date:toSeconds() + return self._s + self._us / US_PER_S +end + +--[=[ +@m toMilliseconds +@r number +@d Returns a Unix time in milliseconds that represents the stored date and time. +]=] +function Date:toMilliseconds() + return self._s * MS_PER_S + self._us / US_PER_MS +end + +--[=[ +@m toMicroseconds +@r number +@d Returns a Unix time in microseconds that represents the stored date and time. +]=] +function Date:toMicroseconds() + return self._s * US_PER_S + self._us +end + +--[=[ +@m toParts +@r number +@r number +@d Returns the seconds and microseconds that are stored in the date object. +]=] +function Date:toParts() + return self._s, self._us +end + +return Date diff --git a/deps/discordia/libs/utils/Deque.lua b/deps/discordia/libs/utils/Deque.lua new file mode 100644 index 0000000..bfdabc0 --- /dev/null +++ b/deps/discordia/libs/utils/Deque.lua @@ -0,0 +1,105 @@ +--[=[ +@c Deque +@t ui +@mt mem +@d An implementation of a double-ended queue. +]=] + +local Deque = require('class')('Deque') + +function Deque:__init() + self._objects = {} + self._first = 0 + self._last = -1 +end + +--[=[ +@m getCount +@r number +@d Returns the total number of values stored. +]=] +function Deque:getCount() + return self._last - self._first + 1 +end + +--[=[ +@m pushLeft +@p obj * +@r nil +@d Adds a value of any type to the left side of the deque. +]=] +function Deque:pushLeft(obj) + self._first = self._first - 1 + self._objects[self._first] = obj +end + +--[=[ +@m pushRight +@p obj * +@r nil +@d Adds a value of any type to the right side of the deque. +]=] +function Deque:pushRight(obj) + self._last = self._last + 1 + self._objects[self._last] = obj +end + +--[=[ +@m popLeft +@r * +@d Removes and returns a value from the left side of the deque. +]=] +function Deque:popLeft() + if self._first > self._last then return nil end + local obj = self._objects[self._first] + self._objects[self._first] = nil + self._first = self._first + 1 + return obj +end + +--[=[ +@m popRight +@r * +@d Removes and returns a value from the right side of the deque. +]=] +function Deque:popRight() + if self._first > self._last then return nil end + local obj = self._objects[self._last] + self._objects[self._last] = nil + self._last = self._last - 1 + return obj +end + +--[=[ +@m peekLeft +@r * +@d Returns the value at the left side of the deque without removing it. +]=] +function Deque:peekLeft() + return self._objects[self._first] +end + +--[=[ +@m peekRight +@r * +@d Returns the value at the right side of the deque without removing it. +]=] +function Deque:peekRight() + return self._objects[self._last] +end + +--[=[ +@m iter +@r function +@d Iterates over the deque from left to right. +]=] +function Deque:iter() + local t = self._objects + local i = self._first - 1 + return function() + i = i + 1 + return t[i] + end +end + +return Deque diff --git a/deps/discordia/libs/utils/Emitter.lua b/deps/discordia/libs/utils/Emitter.lua new file mode 100644 index 0000000..d7a3972 --- /dev/null +++ b/deps/discordia/libs/utils/Emitter.lua @@ -0,0 +1,226 @@ +--[=[ +@c Emitter +@t ui +@mt mem +@d Implements an asynchronous event emitter where callbacks can be subscribed to +specific named events. When events are emitted, the callbacks are called in the +order that they were originally registered. +]=] + +local timer = require('timer') + +local wrap, yield = coroutine.wrap, coroutine.yield +local resume, running = coroutine.resume, coroutine.running +local insert, remove = table.insert, table.remove +local setTimeout, clearTimeout = timer.setTimeout, timer.clearTimeout + +local Emitter = require('class')('Emitter') + +function Emitter:__init() + self._listeners = {} +end + +local function new(self, name, listener) + local listeners = self._listeners[name] + if not listeners then + listeners = {} + self._listeners[name] = listeners + end + insert(listeners, listener) + return listener.fn +end + +--[=[ +@m on +@p name string +@p fn function +@r function +@d Subscribes a callback to be called every time the named event is emitted. +Callbacks registered with this method will automatically be wrapped as a new +coroutine when they are called. Returns the original callback for convenience. +]=] +function Emitter:on(name, fn) + return new(self, name, {fn = fn}) +end + +--[=[ +@m once +@p name string +@p fn function +@r function +@d Subscribes a callback to be called only the first time this event is emitted. +Callbacks registered with this method will automatically be wrapped as a new +coroutine when they are called. Returns the original callback for convenience. +]=] +function Emitter:once(name, fn) + return new(self, name, {fn = fn, once = true}) +end + +--[=[ +@m onSync +@p name string +@p fn function +@r function +@d Subscribes a callback to be called every time the named event is emitted. +Callbacks registered with this method are not automatically wrapped as a +coroutine. Returns the original callback for convenience. +]=] +function Emitter:onSync(name, fn) + return new(self, name, {fn = fn, sync = true}) +end + +--[=[ +@m onceSync +@p name string +@p fn function +@r function +@d Subscribes a callback to be called only the first time this event is emitted. +Callbacks registered with this method are not automatically wrapped as a coroutine. +Returns the original callback for convenience. +]=] +function Emitter:onceSync(name, fn) + return new(self, name, {fn = fn, once = true, sync = true}) +end + +--[=[ +@m emit +@p name string +@op ... * +@r nil +@d Emits the named event and a variable number of arguments to pass to the event callbacks. +]=] +function Emitter:emit(name, ...) + local listeners = self._listeners[name] + if not listeners then return end + for i = 1, #listeners do + local listener = listeners[i] + if listener then + local fn = listener.fn + if listener.once then + listeners[i] = false + end + if listener.sync then + fn(...) + else + wrap(fn)(...) + end + end + end + if listeners._removed then + for i = #listeners, 1, -1 do + if not listeners[i] then + remove(listeners, i) + end + end + if #listeners == 0 then + self._listeners[name] = nil + end + listeners._removed = nil + end +end + +--[=[ +@m getListeners +@p name string +@r function +@d Returns an iterator for all callbacks registered to the named event. +]=] +function Emitter:getListeners(name) + local listeners = self._listeners[name] + if not listeners then return function() end end + local i = 0 + return function() + while i < #listeners do + i = i + 1 + if listeners[i] then + return listeners[i].fn + end + end + end +end + +--[=[ +@m getListenerCount +@p name string +@r number +@d Returns the number of callbacks registered to the named event. +]=] +function Emitter:getListenerCount(name) + local listeners = self._listeners[name] + if not listeners then return 0 end + local n = 0 + for _, listener in ipairs(listeners) do + if listener then + n = n + 1 + end + end + return n +end + +--[=[ +@m removeListener +@p name string +@p fn function +@r nil +@d Unregisters all instances of the callback from the named event. +]=] +function Emitter:removeListener(name, fn) + local listeners = self._listeners[name] + if not listeners then return end + for i, listener in ipairs(listeners) do + if listener and listener.fn == fn then + listeners[i] = false + end + end + listeners._removed = true +end + +--[=[ +@m removeAllListeners +@p name string/nil +@r nil +@d Unregisters all callbacks for the emitter. If a name is passed, then only +callbacks for that specific event are unregistered. +]=] +function Emitter:removeAllListeners(name) + if name then + self._listeners[name] = nil + else + for k in pairs(self._listeners) do + self._listeners[k] = nil + end + end +end + +--[=[ +@m waitFor +@p name string +@op timeout number +@op predicate function +@r boolean +@r ... +@d When called inside of a coroutine, this will yield the coroutine until the +named event is emitted. If a timeout (in milliseconds) is provided, the function +will return after the time expires, regardless of whether the event is emitted, +and `false` will be returned; otherwise, `true` is returned. If a predicate is +provided, events that do not pass the predicate will be ignored. +]=] +function Emitter:waitFor(name, timeout, predicate) + local thread = running() + local fn + fn = self:onSync(name, function(...) + if predicate and not predicate(...) then return end + if timeout then + clearTimeout(timeout) + end + self:removeListener(name, fn) + return assert(resume(thread, true, ...)) + end) + timeout = timeout and setTimeout(timeout, function() + self:removeListener(name, fn) + return assert(resume(thread, false)) + end) + return yield() +end + +return Emitter diff --git a/deps/discordia/libs/utils/Logger.lua b/deps/discordia/libs/utils/Logger.lua new file mode 100644 index 0000000..cc582ec --- /dev/null +++ b/deps/discordia/libs/utils/Logger.lua @@ -0,0 +1,82 @@ +--[=[ +@c Logger +@t ui +@mt mem +@p level number +@p dateTime string +@op file string +@d Used to log formatted messages to stdout (the console) or to a file. +The `dateTime` argument should be a format string that is accepted by `os.date`. +The file argument should be a relative or absolute file path or `nil` if no log +file is desired. See the `logLevel` enumeration for acceptable log level values. +]=] + +local fs = require('fs') + +local date = os.date +local format = string.format +local stdout = _G.process.stdout.handle +local openSync, writeSync = fs.openSync, fs.writeSync + +-- local BLACK = 30 +local RED = 31 +local GREEN = 32 +local YELLOW = 33 +-- local BLUE = 34 +-- local MAGENTA = 35 +local CYAN = 36 +-- local WHITE = 37 + +local config = { + {'[ERROR] ', RED}, + {'[WARNING]', YELLOW}, + {'[INFO] ', GREEN}, + {'[DEBUG] ', CYAN}, +} + +do -- parse config + local bold = 1 + for _, v in ipairs(config) do + v[2] = format('\27[%i;%im%s\27[0m', bold, v[2], v[1]) + end +end + +local Logger = require('class')('Logger') + +function Logger:__init(level, dateTime, file) + self._level = level + self._dateTime = dateTime + self._file = file and openSync(file, 'a') +end + +--[=[ +@m log +@p level number +@p msg string +@p ... * +@r string +@d If the provided level is less than or equal to the log level set on +initialization, this logs a message to stdout as defined by Luvit's `process` +module and to a file if one was provided on initialization. The `msg, ...` pair +is formatted according to `string.format` and returned if the message is logged. +]=] +function Logger:log(level, msg, ...) + + if self._level < level then return end + + local tag = config[level] + if not tag then return end + + msg = format(msg, ...) + + local d = date(self._dateTime) + if self._file then + writeSync(self._file, -1, format('%s | %s | %s\n', d, tag[1], msg)) + end + stdout:write(format('%s | %s | %s\n', d, tag[2], msg)) + + return msg + +end + +return Logger diff --git a/deps/discordia/libs/utils/Mutex.lua b/deps/discordia/libs/utils/Mutex.lua new file mode 100644 index 0000000..fd57580 --- /dev/null +++ b/deps/discordia/libs/utils/Mutex.lua @@ -0,0 +1,68 @@ +--[=[ +@c Mutex +@t ui +@mt mem +@d Mutual exclusion class used to control Lua coroutine execution order. +]=] + +local Deque = require('./Deque') +local timer = require('timer') + +local yield = coroutine.yield +local resume = coroutine.resume +local running = coroutine.running +local setTimeout = timer.setTimeout + +local Mutex = require('class')('Mutex', Deque) + +function Mutex:__init() + Deque.__init(self) + self._active = false +end + +--[=[ +@m lock +@op prepend boolean +@r nil +@d If the mutex is not active (if a coroutine is not queued), this will activate +the mutex; otherwise, this will yield and queue the current coroutine. +]=] +function Mutex:lock(prepend) + if self._active then + if prepend then + return yield(self:pushLeft(running())) + else + return yield(self:pushRight(running())) + end + else + self._active = true + end +end + +--[=[ +@m unlock +@r nil +@d If the mutex is active (if a coroutine is queued), this will dequeue and +resume the next available coroutine; otherwise, this will deactivate the mutex. +]=] +function Mutex:unlock() + if self:getCount() > 0 then + return assert(resume(self:popLeft())) + else + self._active = false + end +end + +--[=[ +@m unlockAfter +@p delay number +@r uv_timer +@d Asynchronously unlocks the mutex after a specified time in milliseconds. +The relevant `uv_timer` object is returned. +]=] +local unlock = Mutex.unlock +function Mutex:unlockAfter(delay) + return setTimeout(delay, unlock, self) +end + +return Mutex diff --git a/deps/discordia/libs/utils/Permissions.lua b/deps/discordia/libs/utils/Permissions.lua new file mode 100644 index 0000000..b6df32c --- /dev/null +++ b/deps/discordia/libs/utils/Permissions.lua @@ -0,0 +1,254 @@ +--[=[ +@c Permissions +@t ui +@mt mem +@d Wrapper for a bitfield that is more specifically used to represent Discord +permissions. See the `permission` enumeration for acceptable permission values. +]=] + +local enums = require('enums') +local Resolver = require('client/Resolver') + +local permission = enums.permission + +local format = string.format +local band, bor, bnot, bxor = bit.band, bit.bor, bit.bnot, bit.bxor +local sort, insert, concat = table.sort, table.insert, table.concat + +local ALL = 0 +for _, value in pairs(permission) do + ALL = bor(ALL, value) +end + +local Permissions, get = require('class')('Permissions') + +function Permissions:__init(value) + self._value = tonumber(value) or 0 +end + +--[=[ +@m __tostring +@r string +@d Defines the behavior of the `tostring` function. Returns a readable list of +permissions stored for convenience of introspection. +]=] +function Permissions:__tostring() + if self._value == 0 then + return 'Permissions: 0 (none)' + else + local a = self:toArray() + sort(a) + return format('Permissions: %i (%s)', self._value, concat(a, ', ')) + end +end + +--[=[ +@m fromMany +@t static +@p ... Permission-Resolvables +@r Permissions +@d Returns a Permissions object with all of the defined permissions. +]=] +function Permissions.fromMany(...) + local ret = Permissions() + ret:enable(...) + return ret +end + +--[=[ +@m all +@t static +@r Permissions +@d Returns a Permissions object with all permissions. +]=] +function Permissions.all() + return Permissions(ALL) +end + +--[=[ +@m __eq +@r boolean +@d Defines the behavior of the `==` operator. Allows permissions to be directly +compared according to their value. +]=] +function Permissions:__eq(other) + return self._value == other._value +end + +local function getPerm(i, ...) + local v = select(i, ...) + local n = Resolver.permission(v) + if not n then + return error('Invalid permission: ' .. tostring(v), 2) + end + return n +end + +--[=[ +@m enable +@p ... Permission-Resolvables +@r nil +@d Enables a specific permission or permissions. See the `permission` enumeration +for acceptable permission values. +]=] +function Permissions:enable(...) + local value = self._value + for i = 1, select('#', ...) do + local perm = getPerm(i, ...) + value = bor(value, perm) + end + self._value = value +end + +--[=[ +@m disable +@p ... Permission-Resolvables +@r nil +@d Disables a specific permission or permissions. See the `permission` enumeration +for acceptable permission values. +]=] +function Permissions:disable(...) + local value = self._value + for i = 1, select('#', ...) do + local perm = getPerm(i, ...) + value = band(value, bnot(perm)) + end + self._value = value +end + +--[=[ +@m has +@p ... Permission-Resolvables +@r boolean +@d Returns whether this set has a specific permission or permissions. See the +`permission` enumeration for acceptable permission values. +]=] +function Permissions:has(...) + local value = self._value + for i = 1, select('#', ...) do + local perm = getPerm(i, ...) + if band(value, perm) == 0 then + return false + end + end + return true +end + +--[=[ +@m enableAll +@r nil +@d Enables all permissions values. +]=] +function Permissions:enableAll() + self._value = ALL +end + +--[=[ +@m disableAll +@r nil +@d Disables all permissions values. +]=] +function Permissions:disableAll() + self._value = 0 +end + +--[=[ +@m toHex +@r string +@d Returns the hexadecimal string that represents the permissions value. +]=] +function Permissions:toHex() + return format('0x%08X', self._value) +end + +--[=[ +@m toTable +@r table +@d Returns a table that represents the permissions value, where the keys are the +permission names and the values are `true` or `false`. +]=] +function Permissions:toTable() + local ret = {} + local value = self._value + for k, v in pairs(permission) do + ret[k] = band(value, v) > 0 + end + return ret +end + +--[=[ +@m toArray +@r table +@d Returns an array of the names of the permissions that this object represents. +]=] +function Permissions:toArray() + local ret = {} + local value = self._value + for k, v in pairs(permission) do + if band(value, v) > 0 then + insert(ret, k) + end + end + return ret +end + +--[=[ +@m union +@p other Permissions +@r Permissions +@d Returns a new Permissions object that contains the permissions that are in +either `self` or `other` (bitwise OR). +]=] +function Permissions:union(other) + return Permissions(bor(self._value, other._value)) +end + +--[=[ +@m intersection +@p other Permissions +@r Permissions +@d Returns a new Permissions object that contains the permissions that are in +both `self` and `other` (bitwise AND). +]=] +function Permissions:intersection(other) -- in both + return Permissions(band(self._value, other._value)) +end + +--[=[ +@m difference +@p other Permissions +@r Permissions +@d Returns a new Permissions object that contains the permissions that are not +in `self` or `other` (bitwise XOR). +]=] +function Permissions:difference(other) -- not in both + return Permissions(bxor(self._value, other._value)) +end + +--[=[ +@m complement +@p other Permissions +@r Permissions +@d Returns a new Permissions object that contains the permissions that are not +in `self`, but are in `other` (or the set of all permissions if omitted). +]=] +function Permissions:complement(other) -- in other not in self + local value = other and other._value or ALL + return Permissions(band(bnot(self._value), value)) +end + +--[=[ +@m copy +@r Permissions +@d Returns a new copy of the original permissions object. +]=] +function Permissions:copy() + return Permissions(self._value) +end + +--[=[@p value number The raw decimal value that represents the permissions value.]=] +function get.value(self) + return self._value +end + +return Permissions diff --git a/deps/discordia/libs/utils/Stopwatch.lua b/deps/discordia/libs/utils/Stopwatch.lua new file mode 100644 index 0000000..96715ba --- /dev/null +++ b/deps/discordia/libs/utils/Stopwatch.lua @@ -0,0 +1,85 @@ +--[=[ +@c Stopwatch +@t ui +@mt mem +@d Used to measure an elapsed period of time. If a truthy value is passed as an +argument, then the stopwatch will initialize in an idle state; otherwise, it will +initialize in an active state. Although nanosecond precision is available, Lua +can only reliably provide microsecond accuracy due to the lack of native 64-bit +integer support. Generally, milliseconds should be sufficient here. +]=] + +local hrtime = require('uv').hrtime +local constants = require('constants') +local Time = require('utils/Time') + +local format = string.format + +local MS_PER_NS = 1 / (constants.NS_PER_US * constants.US_PER_MS) + +local Stopwatch, get = require('class')('Stopwatch') + +function Stopwatch:__init(stopped) + local t = hrtime() + self._initial = t + self._final = stopped and t or nil +end + +--[=[ +@m __tostring +@r string +@d Defines the behavior of the `tostring` function. Returns a string that +represents the elapsed milliseconds for convenience of introspection. +]=] +function Stopwatch:__tostring() + return format('Stopwatch: %s ms', self.milliseconds) +end + +--[=[ +@m stop +@r nil +@d Effectively stops the stopwatch. +]=] +function Stopwatch:stop() + if self._final then return end + self._final = hrtime() +end + +--[=[ +@m start +@r nil +@d Effectively starts the stopwatch. +]=] +function Stopwatch:start() + if not self._final then return end + self._initial = self._initial + hrtime() - self._final + self._final = nil +end + +--[=[ +@m reset +@r nil +@d Effectively resets the stopwatch. +]=] +function Stopwatch:reset() + self._initial = self._final or hrtime() +end + +--[=[ +@m getTime +@r Time +@d Returns a new Time object that represents the currently elapsed time. This is +useful for "catching" the current time and comparing its many forms as required. +]=] +function Stopwatch:getTime() + return Time(self.milliseconds) +end + +--[=[@p milliseconds number The total number of elapsed milliseconds. If the +stopwatch is running, this will naturally be different each time that it is accessed.]=] +function get.milliseconds(self) + local ns = (self._final or hrtime()) - self._initial + return ns * MS_PER_NS +end + +return Stopwatch diff --git a/deps/discordia/libs/utils/Time.lua b/deps/discordia/libs/utils/Time.lua new file mode 100644 index 0000000..08efd31 --- /dev/null +++ b/deps/discordia/libs/utils/Time.lua @@ -0,0 +1,277 @@ +--[=[ +@c Time +@t ui +@mt mem +@d Represents a length of time and provides utilities for converting to and from +different formats. Supported units are: weeks, days, hours, minutes, seconds, +and milliseconds. +]=] + +local class = require('class') +local constants = require('constants') + +local MS_PER_S = constants.MS_PER_S +local MS_PER_MIN = MS_PER_S * constants.S_PER_MIN +local MS_PER_HOUR = MS_PER_MIN * constants.MIN_PER_HOUR +local MS_PER_DAY = MS_PER_HOUR * constants.HOUR_PER_DAY +local MS_PER_WEEK = MS_PER_DAY * constants.DAY_PER_WEEK + +local insert, concat = table.insert, table.concat +local modf, fmod = math.modf, math.fmod +local isInstance = class.isInstance + +local function decompose(value, mult) + return modf(value / mult), fmod(value, mult) +end + +local units = { + {'weeks', MS_PER_WEEK}, + {'days', MS_PER_DAY}, + {'hours', MS_PER_HOUR}, + {'minutes', MS_PER_MIN}, + {'seconds', MS_PER_S}, + {'milliseconds', 1}, +} + +local Time = class('Time') + +local function check(self, other) + if not isInstance(self, Time) or not isInstance(other, Time) then + return error('Cannot perform operation with non-Time object', 2) + end +end + +function Time:__init(value) + self._value = tonumber(value) or 0 +end + +function Time:__tostring() + return 'Time: ' .. self:toString() +end + +--[=[ +@m toString +@r string +@d Returns a human-readable string built from the set of normalized time values +that the object represents. +]=] +function Time:toString() + local ret = {} + local ms = self:toMilliseconds() + for _, unit in ipairs(units) do + local n + n, ms = decompose(ms, unit[2]) + if n == 1 then + insert(ret, n .. ' ' .. unit[1]:sub(1, -2)) + elseif n > 0 then + insert(ret, n .. ' ' .. unit[1]) + end + end + return #ret > 0 and concat(ret, ', ') or '0 milliseconds' +end + +function Time:__eq(other) check(self, other) + return self._value == other._value +end + +function Time:__lt(other) check(self, other) + return self._value < other._value +end + +function Time:__le(other) check(self, other) + return self._value <= other._value +end + +function Time:__add(other) check(self, other) + return Time(self._value + other._value) +end + +function Time:__sub(other) check(self, other) + return Time(self._value - other._value) +end + +function Time:__mul(other) + if not isInstance(self, Time) then + self, other = other, self + end + other = tonumber(other) + if other then + return Time(self._value * other) + else + return error('Cannot perform operation with non-numeric object') + end +end + +function Time:__div(other) + if not isInstance(self, Time) then + return error('Division with Time is not commutative') + end + other = tonumber(other) + if other then + return Time(self._value / other) + else + return error('Cannot perform operation with non-numeric object') + end +end + +--[=[ +@m fromWeeks +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as weeks, where a week +is equal to 7 days. +]=] +function Time.fromWeeks(t) + return Time(t * MS_PER_WEEK) +end + +--[=[ +@m fromDays +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as days, where a day is +equal to 24 hours. +]=] +function Time.fromDays(t) + return Time(t * MS_PER_DAY) +end + +--[=[ +@m fromHours +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as hours, where an hour is +equal to 60 minutes. +]=] +function Time.fromHours(t) + return Time(t * MS_PER_HOUR) +end + +--[=[ +@m fromMinutes +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as minutes, where a minute +is equal to 60 seconds. +]=] +function Time.fromMinutes(t) + return Time(t * MS_PER_MIN) +end + +--[=[ +@m fromSeconds +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as seconds, where a second +is equal to 1000 milliseconds. +]=] +function Time.fromSeconds(t) + return Time(t * MS_PER_S) +end + +--[=[ +@m fromMilliseconds +@t static +@p t number +@r Time +@d Constructs a new Time object from a value interpreted as milliseconds, the base +unit represented. +]=] +function Time.fromMilliseconds(t) + return Time(t) +end + +--[=[ +@m fromTable +@t static +@p t table +@r Time +@d Constructs a new Time object from a table of time values where the keys are +defined in the constructors above (eg: `weeks`, `days`, `hours`). +]=] +function Time.fromTable(t) + local n = 0 + for _, v in ipairs(units) do + local m = tonumber(t[v[1]]) + if m then + n = n + m * v[2] + end + end + return Time(n) +end + +--[=[ +@m toWeeks +@r number +@d Returns the total number of weeks that the time object represents. +]=] +function Time:toWeeks() + return self:toMilliseconds() / MS_PER_WEEK +end + +--[=[ +@m toDays +@r number +@d Returns the total number of days that the time object represents. +]=] +function Time:toDays() + return self:toMilliseconds() / MS_PER_DAY +end + +--[=[ +@m toHours +@r number +@d Returns the total number of hours that the time object represents. +]=] +function Time:toHours() + return self:toMilliseconds() / MS_PER_HOUR +end + +--[=[ +@m toMinutes +@r number +@d Returns the total number of minutes that the time object represents. +]=] +function Time:toMinutes() + return self:toMilliseconds() / MS_PER_MIN +end + +--[=[ +@m toSeconds +@r number +@d Returns the total number of seconds that the time object represents. +]=] +function Time:toSeconds() + return self:toMilliseconds() / MS_PER_S +end + +--[=[ +@m toMilliseconds +@r number +@d Returns the total number of milliseconds that the time object represents. +]=] +function Time:toMilliseconds() + return self._value +end + +--[=[ +@m toTable +@r number +@d Returns a table of normalized time values that represent the time object in +a more accessible form. +]=] +function Time:toTable() + local ret = {} + local ms = self:toMilliseconds() + for _, unit in ipairs(units) do + ret[unit[1]], ms = decompose(ms, unit[2]) + end + return ret +end + +return Time diff --git a/deps/discordia/libs/voice/VoiceConnection.lua b/deps/discordia/libs/voice/VoiceConnection.lua new file mode 100644 index 0000000..d1fd744 --- /dev/null +++ b/deps/discordia/libs/voice/VoiceConnection.lua @@ -0,0 +1,432 @@ +--[=[ +@c VoiceConnection +@d Represents a connection to a Discord voice server. +]=] + +local PCMString = require('voice/streams/PCMString') +local PCMStream = require('voice/streams/PCMStream') +local PCMGenerator = require('voice/streams/PCMGenerator') +local FFmpegProcess = require('voice/streams/FFmpegProcess') + +local uv = require('uv') +local ffi = require('ffi') +local constants = require('constants') +local opus = require('voice/opus') +local sodium = require('voice/sodium') + +local CHANNELS = 2 +local SAMPLE_RATE = 48000 -- Hz +local FRAME_DURATION = 20 -- ms +local COMPLEXITY = 5 + +local MIN_BITRATE = 8000 -- bps +local MAX_BITRATE = 128000 -- bps +local MIN_COMPLEXITY = 0 +local MAX_COMPLEXITY = 10 + +local MAX_SEQUENCE = 0xFFFF +local MAX_TIMESTAMP = 0xFFFFFFFF + +local HEADER_FMT = '>BBI2I4I4' +local PADDING = string.rep('\0', 12) + +local MS_PER_NS = 1 / (constants.NS_PER_US * constants.US_PER_MS) +local MS_PER_S = constants.MS_PER_S + +local max = math.max +local hrtime = uv.hrtime +local ffi_string = ffi.string +local pack = string.pack -- luacheck: ignore +local format = string.format +local insert = table.insert +local running, resume, yield = coroutine.running, coroutine.resume, coroutine.yield + +-- timer.sleep is redefined here to avoid a memory leak in the luvit module +local function sleep(delay) + local thread = running() + local t = uv.new_timer() + t:start(delay, 0, function() + t:stop() + t:close() + return assert(resume(thread)) + end) + return yield() +end + +local function asyncResume(thread) + local t = uv.new_timer() + t:start(0, 0, function() + t:stop() + t:close() + return assert(resume(thread)) + end) +end + +local function check(n, mn, mx) + if not tonumber(n) or n < mn or n > mx then + return error(format('Value must be a number between %s and %s', mn, mx), 2) + end + return n +end + +local VoiceConnection, get = require('class')('VoiceConnection') + +function VoiceConnection:__init(channel) + self._channel = channel + self._pending = {} +end + +function VoiceConnection:_prepare(key, socket) + + self._key = sodium.key(key) + self._socket = socket + self._ip = socket._ip + self._port = socket._port + self._udp = socket._udp + self._ssrc = socket._ssrc + self._mode = socket._mode + self._manager = socket._manager + self._client = socket._client + + self._s = 0 + self._t = 0 + + self._encoder = opus.Encoder(SAMPLE_RATE, CHANNELS) + + self:setBitrate(self._client._options.bitrate) + self:setComplexity(COMPLEXITY) + + self._ready = true + self:_continue(true) + +end + +function VoiceConnection:_await() + local thread = running() + insert(self._pending, thread) + if not self._timeout then + local t = uv.new_timer() + t:start(10000, 0, function() + t:stop() + t:close() + self._timeout = nil + if not self._ready then + local id = self._channel and self._channel._id + return self:_cleanup(format('voice connection for channel %s failed to initialize', id)) + end + end) + self._timeout = t + end + return yield() +end + +function VoiceConnection:_continue(success, err) + local t = self._timeout + if t then + t:stop() + t:close() + self._timeout = nil + end + for i, thread in ipairs(self._pending) do + self._pending[i] = nil + assert(resume(thread, success, err)) + end +end + +function VoiceConnection:_cleanup(err) + self:stopStream() + self._ready = nil + self._channel._parent._connection = nil + self._channel._connection = nil + self:_continue(nil, err or 'connection closed') +end + +--[=[ +@m getBitrate +@t mem +@r nil +@d Returns the bitrate of the interal Opus encoder in bits per second (bps). +]=] +function VoiceConnection:getBitrate() + return self._encoder:get(opus.GET_BITRATE_REQUEST) +end + +--[=[ +@m setBitrate +@t mem +@p bitrate number +@r nil +@d Sets the bitrate of the interal Opus encoder in bits per second (bps). +This should be between 8000 and 128000, inclusive. +]=] +function VoiceConnection:setBitrate(bitrate) + bitrate = check(bitrate, MIN_BITRATE, MAX_BITRATE) + self._encoder:set(opus.SET_BITRATE_REQUEST, bitrate) +end + +--[=[ +@m getComplexity +@t mem +@r number +@d Returns the complexity of the interal Opus encoder. +]=] +function VoiceConnection:getComplexity() + return self._encoder:get(opus.GET_COMPLEXITY_REQUEST) +end + +--[=[ +@m setComplexity +@t mem +@p complexity number +@r nil +@d Sets the complexity of the interal Opus encoder. +This should be between 0 and 10, inclusive. +]=] +function VoiceConnection:setComplexity(complexity) + complexity = check(complexity, MIN_COMPLEXITY, MAX_COMPLEXITY) + self._encoder:set(opus.SET_COMPLEXITY_REQUEST, complexity) +end + +---- debugging +local t0, m0 +local t_sum, m_sum, n = 0, 0, 0 +local function open() -- luacheck: ignore + -- collectgarbage() + m0 = collectgarbage('count') + t0 = hrtime() +end +local function close() -- luacheck: ignore + local dt = (hrtime() - t0) * MS_PER_NS + local dm = collectgarbage('count') - m0 + n = n + 1 + t_sum = t_sum + dt + m_sum = m_sum + dm + print(format('dt: %g | dm: %g | avg dt: %g | avg dm: %g', dt, dm, t_sum / n, m_sum / n)) +end +---- debugging + +function VoiceConnection:_play(stream, duration) + + self:stopStream() + self:_setSpeaking(true) + + duration = tonumber(duration) or math.huge + + local elapsed = 0 + local udp, ip, port = self._udp, self._ip, self._port + local ssrc, key = self._ssrc, self._key + local encoder = self._encoder + + local frame_size = SAMPLE_RATE * FRAME_DURATION / MS_PER_S + local pcm_len = frame_size * CHANNELS + + local start = hrtime() + local reason + + while elapsed < duration do + + local pcm = stream:read(pcm_len) + if not pcm then + reason = 'stream exhausted or errored' + break + end + + local data, len = encoder:encode(pcm, pcm_len, frame_size, pcm_len * 2) + if not data then + reason = 'could not encode audio data' + break + end + + local s, t = self._s, self._t + local header = pack(HEADER_FMT, 0x80, 0x78, s, t, ssrc) + + s = s + 1 + t = t + frame_size + + self._s = s > MAX_SEQUENCE and 0 or s + self._t = t > MAX_TIMESTAMP and 0 or t + + local encrypted, encrypted_len = sodium.encrypt(data, len, header .. PADDING, key) + if not encrypted then + reason = 'could not encrypt audio data' + break + end + + local packet = header .. ffi_string(encrypted, encrypted_len) + udp:send(packet, ip, port) + + elapsed = elapsed + FRAME_DURATION + local delay = elapsed - (hrtime() - start) * MS_PER_NS + sleep(max(delay, 0)) + + if self._paused then + asyncResume(self._paused) + self._paused = running() + local pause = hrtime() + yield() + start = start + hrtime() - pause + asyncResume(self._resumed) + self._resumed = nil + end + + if self._stopped then + reason = 'stream stopped' + break + end + + end + + self:_setSpeaking(false) + + if self._stopped then + asyncResume(self._stopped) + self._stopped = nil + end + + return elapsed, reason + +end + +function VoiceConnection:_setSpeaking(speaking) + self._speaking = speaking + return self._socket:setSpeaking(speaking) +end + +--[=[ +@m playPCM +@t mem +@p source string/function/table/userdata +@op duration number +@r number +@r string +@d Plays PCM data over the established connection. If a duration (in milliseconds) +is provided, the audio stream will automatically stop after that time has elapsed; +otherwise, it will play until the source is exhausted. The returned number is the +time elapsed while streaming and the returned string is a message detailing the +reason why the stream stopped. For more information about acceptable sources, +see the [[voice]] page. +]=] +function VoiceConnection:playPCM(source, duration) + + if not self._ready then + return nil, 'Connection is not ready' + end + + local t = type(source) + + local stream + if t == 'string' then + stream = PCMString(source) + elseif t == 'function' then + stream = PCMGenerator(source) + elseif (t == 'table' or t == 'userdata') and type(source.read) == 'function' then + stream = PCMStream(source) + else + return error('Invalid audio source: ' .. tostring(source)) + end + + return self:_play(stream, duration) + +end + +--[=[ +@m playFFmpeg +@t mem +@p path string +@op duration number +@r number +@r string +@d Plays audio over the established connection using an FFmpeg process, assuming +FFmpeg is properly configured. If a duration (in milliseconds) +is provided, the audio stream will automatically stop after that time has elapsed; +otherwise, it will play until the source is exhausted. The returned number is the +time elapsed while streaming and the returned string is a message detailing the +reason why the stream stopped. For more information about using FFmpeg, +see the [[voice]] page. +]=] +function VoiceConnection:playFFmpeg(path, duration) + + if not self._ready then + return nil, 'Connection is not ready' + end + + local stream = FFmpegProcess(path, SAMPLE_RATE, CHANNELS) + + local elapsed, reason = self:_play(stream, duration) + stream:close() + return elapsed, reason + +end + +--[=[ +@m pauseStream +@t mem +@r nil +@d Temporarily pauses the audio stream for this connection, if one is active. +Like most Discordia methods, this must be called inside of a coroutine, as it +will yield until the stream is actually paused, usually on the next tick. +]=] +function VoiceConnection:pauseStream() + if not self._speaking then return end + if self._paused then return end + self._paused = running() + return yield() +end + +--[=[ +@m resumeStream +@t mem +@r nil +@d Resumes the audio stream for this connection, if one is active and paused. +Like most Discordia methods, this must be called inside of a coroutine, as it +will yield until the stream is actually resumed, usually on the next tick. +]=] +function VoiceConnection:resumeStream() + if not self._speaking then return end + if not self._paused then return end + asyncResume(self._paused) + self._paused = nil + self._resumed = running() + return yield() +end + +--[=[ +@m stopStream +@t mem +@r nil +@d Irreversibly stops the audio stream for this connection, if one is active. +Like most Discordia methods, this must be called inside of a coroutine, as it +will yield until the stream is actually stopped, usually on the next tick. +]=] +function VoiceConnection:stopStream() + if not self._speaking then return end + if self._stopped then return end + self._stopped = running() + self:resumeStream() + return yield() +end + +--[=[ +@m close +@t ws +@r boolean +@d Stops the audio stream for this connection, if one is active, disconnects from +the voice server, and leaves the corresponding voice channel. Like most Discordia +methods, this must be called inside of a coroutine. +]=] +function VoiceConnection:close() + self:stopStream() + if self._socket then + self._socket:disconnect() + end + local guild = self._channel._parent + return self._client._shards[guild.shardId]:updateVoice(guild._id) +end + +--[=[@p channel GuildVoiceChannel/nil The corresponding GuildVoiceChannel for +this connection, if one exists.]=] +function get.channel(self) + return self._channel +end + +return VoiceConnection diff --git a/deps/discordia/libs/voice/VoiceManager.lua b/deps/discordia/libs/voice/VoiceManager.lua new file mode 100644 index 0000000..84ab0a7 --- /dev/null +++ b/deps/discordia/libs/voice/VoiceManager.lua @@ -0,0 +1,33 @@ +local VoiceSocket = require('voice/VoiceSocket') +local Emitter = require('utils/Emitter') + +local opus = require('voice/opus') +local sodium = require('voice/sodium') +local constants = require('constants') + +local wrap = coroutine.wrap +local format = string.format + +local GATEWAY_VERSION_VOICE = constants.GATEWAY_VERSION_VOICE + +local VoiceManager = require('class')('VoiceManager', Emitter) + +function VoiceManager:__init(client) + Emitter.__init(self) + self._client = client +end + +function VoiceManager:_prepareConnection(state, connection) + if not next(opus) then + return self._client:error('Cannot prepare voice connection: libopus not found') + end + if not next(sodium) then + return self._client:error('Cannot prepare voice connection: libsodium not found') + end + local socket = VoiceSocket(state, connection, self) + local url = 'wss://' .. state.endpoint:gsub(':%d*$', '') + local path = format('/?v=%i', GATEWAY_VERSION_VOICE) + return wrap(socket.connect)(socket, url, path) +end + +return VoiceManager diff --git a/deps/discordia/libs/voice/VoiceSocket.lua b/deps/discordia/libs/voice/VoiceSocket.lua new file mode 100644 index 0000000..867de25 --- /dev/null +++ b/deps/discordia/libs/voice/VoiceSocket.lua @@ -0,0 +1,197 @@ +local uv = require('uv') +local class = require('class') +local timer = require('timer') +local enums = require('enums') + +local WebSocket = require('client/WebSocket') + +local logLevel = enums.logLevel +local format = string.format +local setInterval, clearInterval = timer.setInterval, timer.clearInterval +local wrap = coroutine.wrap +local time = os.time +local unpack = string.unpack -- luacheck: ignore + +local ENCRYPTION_MODE = 'xsalsa20_poly1305' +local PADDING = string.rep('\0', 70) + +local IDENTIFY = 0 +local SELECT_PROTOCOL = 1 +local READY = 2 +local HEARTBEAT = 3 +local DESCRIPTION = 4 +local SPEAKING = 5 +local HEARTBEAT_ACK = 6 +local RESUME = 7 +local HELLO = 8 +local RESUMED = 9 + +local function checkMode(modes) + for _, mode in ipairs(modes) do + if mode == ENCRYPTION_MODE then + return mode + end + end +end + +local VoiceSocket = class('VoiceSocket', WebSocket) + +for name in pairs(logLevel) do + VoiceSocket[name] = function(self, fmt, ...) + local client = self._client + return client[name](client, format('Voice : %s', fmt), ...) + end +end + +function VoiceSocket:__init(state, connection, manager) + WebSocket.__init(self, manager) + self._state = state + self._manager = manager + self._client = manager._client + self._connection = connection + self._session_id = state.session_id +end + +function VoiceSocket:handleDisconnect() + -- TODO: reconnecting and resuming + self._connection:_cleanup() +end + +function VoiceSocket:handlePayload(payload) + + local manager = self._manager + + local d = payload.d + local op = payload.op + + self:debug('WebSocket OP %s', op) + + if op == HELLO then + + self:info('Received HELLO') + self:startHeartbeat(d.heartbeat_interval * 0.75) -- NOTE: hotfix for API bug + self:identify() + + elseif op == READY then + + self:info('Received READY') + local mode = checkMode(d.modes) + if mode then + self._mode = mode + self._ssrc = d.ssrc + self:handshake(d.ip, d.port) + else + self:error('No supported encryption mode available') + self:disconnect() + end + + elseif op == RESUMED then + + self:info('Received RESUMED') + + elseif op == DESCRIPTION then + + if d.mode == self._mode then + self._connection:_prepare(d.secret_key, self) + else + self:error('%q encryption mode not available', self._mode) + self:disconnect() + end + + elseif op == HEARTBEAT_ACK then + + manager:emit('heartbeat', nil, self._sw.milliseconds) -- TODO: id + + elseif op == SPEAKING then + + return -- TODO + + elseif op == 12 or op == 13 then + + return -- ignore + + elseif op then + + self:warning('Unhandled WebSocket payload OP %i', op) + + end + +end + +local function loop(self) + return wrap(self.heartbeat)(self) +end + +function VoiceSocket:startHeartbeat(interval) + if self._heartbeat then + clearInterval(self._heartbeat) + end + self._heartbeat = setInterval(interval, loop, self) +end + +function VoiceSocket:stopHeartbeat() + if self._heartbeat then + clearInterval(self._heartbeat) + end + self._heartbeat = nil +end + +function VoiceSocket:heartbeat() + self._sw:reset() + return self:_send(HEARTBEAT, time()) +end + +function VoiceSocket:identify() + local state = self._state + return self:_send(IDENTIFY, { + server_id = state.guild_id, + user_id = state.user_id, + session_id = state.session_id, + token = state.token, + }, true) +end + +function VoiceSocket:resume() + local state = self._state + return self:_send(RESUME, { + server_id = state.guild_id, + session_id = state.session_id, + token = state.token, + }) +end + +function VoiceSocket:handshake(server_ip, server_port) + local udp = uv.new_udp() + self._udp = udp + self._ip = server_ip + self._port = server_port + udp:recv_start(function(err, data) + assert(not err, err) + udp:recv_stop() + local client_ip = unpack('xxxxz', data) + local client_port = unpack('= opus.OK and value or throw(value) +end + +local Encoder = {} +Encoder.__index = Encoder + +function Encoder:__new(sample_rate, channels, app) -- luacheck: ignore self + + app = app or opus.APPLICATION_AUDIO -- TODO: test different applications + + local err = int_ptr_t() + local state = lib.opus_encoder_create(sample_rate, channels, app, err) + check(err[0]) + + check(lib.opus_encoder_init(state, sample_rate, channels, app)) + + return gc(state, lib.opus_encoder_destroy) + +end + +function Encoder:encode(input, input_len, frame_size, max_data_bytes) + + local pcm = new('opus_int16[?]', input_len, input) + local data = new('unsigned char[?]', max_data_bytes) + + local ret = lib.opus_encode(self, pcm, frame_size, data, max_data_bytes) + + return data, check(ret) + +end + +function Encoder:get(id) + local ret = opus_int32_ptr_t() + lib.opus_encoder_ctl(self, id, ret) + return check(ret[0]) +end + +function Encoder:set(id, value) + if type(value) ~= 'number' then return throw(opus.BAD_ARG) end + local ret = lib.opus_encoder_ctl(self, id, opus_int32_t(value)) + return check(ret) +end + +opus.Encoder = ffi.metatype('OpusEncoder', Encoder) + +local Decoder = {} +Decoder.__index = Decoder + +function Decoder:__new(sample_rate, channels) -- luacheck: ignore self + + local err = int_ptr_t() + local state = lib.opus_decoder_create(sample_rate, channels, err) + check(err[0]) + + check(lib.opus_decoder_init(state, sample_rate, channels)) + + return gc(state, lib.opus_decoder_destroy) + +end + +function Decoder:decode(data, len, frame_size, output_len) + + local pcm = new('opus_int16[?]', output_len) + + local ret = lib.opus_decode(self, data, len, pcm, frame_size, 0) + + return pcm, check(ret) + +end + +function Decoder:get(id) + local ret = opus_int32_ptr_t() + lib.opus_decoder_ctl(self, id, ret) + return check(ret[0]) +end + +function Decoder:set(id, value) + if type(value) ~= 'number' then return throw(opus.BAD_ARG) end + local ret = lib.opus_decoder_ctl(self, id, opus_int32_t(value)) + return check(ret) +end + +opus.Decoder = ffi.metatype('OpusDecoder', Decoder) + +return opus diff --git a/deps/discordia/libs/voice/sodium.lua b/deps/discordia/libs/voice/sodium.lua new file mode 100644 index 0000000..573e996 --- /dev/null +++ b/deps/discordia/libs/voice/sodium.lua @@ -0,0 +1,85 @@ +local ffi = require('ffi') + +local loaded, lib = pcall(ffi.load, 'sodium') +if not loaded then + return nil, lib +end + +local typeof = ffi.typeof +local format = string.format + +ffi.cdef[[ +const char *sodium_version_string(void); +const char *crypto_secretbox_primitive(void); + +size_t crypto_secretbox_keybytes(void); +size_t crypto_secretbox_noncebytes(void); +size_t crypto_secretbox_macbytes(void); +size_t crypto_secretbox_zerobytes(void); + +int crypto_secretbox_easy( + unsigned char *c, + const unsigned char *m, + unsigned long long mlen, + const unsigned char *n, + const unsigned char *k +); + +int crypto_secretbox_open_easy( + unsigned char *m, + const unsigned char *c, + unsigned long long clen, + const unsigned char *n, + const unsigned char *k +); + +void randombytes(unsigned char* const buf, const unsigned long long buf_len); +]] + +local sodium = {} + +local MACBYTES = lib.crypto_secretbox_macbytes() +local NONCEBYTES = lib.crypto_secretbox_noncebytes() +local KEYBYTES = lib.crypto_secretbox_keybytes() + +local key_t = typeof(format('const unsigned char[%i]', tonumber(KEYBYTES))) +local nonce_t = typeof(format('unsigned char[%i] const', tonumber(NONCEBYTES))) +local unsigned_char_array_t = typeof('unsigned char[?]') + +function sodium.key(key) + return key_t(key) +end + +function sodium.nonce() + local nonce = nonce_t() + lib.randombytes(nonce, NONCEBYTES) + return nonce, NONCEBYTES +end + +function sodium.encrypt(decrypted, decrypted_len, nonce, key) + + local encrypted_len = decrypted_len + MACBYTES + local encrypted = unsigned_char_array_t(encrypted_len) + + if lib.crypto_secretbox_easy(encrypted, decrypted, decrypted_len, nonce, key) < 0 then + return error('libsodium encryption failed') + end + + return encrypted, encrypted_len + +end + +function sodium.decrypt(encrypted, encrypted_len, nonce, key) + + local decrypted_len = encrypted_len - MACBYTES + local decrypted = unsigned_char_array_t(decrypted_len) + + if lib.crypto_secretbox_open_easy(decrypted, encrypted, encrypted_len, nonce, key) < 0 then + return error('libsodium decryption failed') + end + + return decrypted, decrypted_len + +end + +return sodium diff --git a/deps/discordia/libs/voice/streams/FFmpegProcess.lua b/deps/discordia/libs/voice/streams/FFmpegProcess.lua new file mode 100644 index 0000000..19e22b6 --- /dev/null +++ b/deps/discordia/libs/voice/streams/FFmpegProcess.lua @@ -0,0 +1,88 @@ +local uv = require('uv') + +local remove = table.remove +local unpack = string.unpack -- luacheck: ignore +local rep = string.rep +local yield, resume, running = coroutine.yield, coroutine.resume, coroutine.running + +local function onExit() end + +local fmt = setmetatable({}, { + __index = function(self, n) + self[n] = '<' .. rep('i2', n) + return self[n] + end +}) + +local FFmpegProcess = require('class')('FFmpegProcess') + +function FFmpegProcess:__init(path, rate, channels) + + local stdout = uv.new_pipe(false) + + self._child = assert(uv.spawn('ffmpeg', { + args = {'-i', path, '-ar', rate, '-ac', channels, '-f', 's16le', 'pipe:1', '-loglevel', 'warning'}, + stdio = {0, stdout, 2}, + }, onExit), 'ffmpeg could not be started, is it installed and on your executable path?') + + local buffer + local thread = running() + stdout:read_start(function(err, chunk) + if err or not chunk then + self:close() + else + buffer = chunk + end + stdout:read_stop() + return assert(resume(thread)) + end) + + self._buffer = buffer or '' + self._stdout = stdout + + yield() + +end + +function FFmpegProcess:read(n) + + local buffer = self._buffer + local stdout = self._stdout + local bytes = n * 2 + + if not self._closed and #buffer < bytes then + + local thread = running() + stdout:read_start(function(err, chunk) + if err or not chunk then + self:close() + elseif #chunk > 0 then + buffer = buffer .. chunk + end + if #buffer >= bytes or self._closed then + stdout:read_stop() + return assert(resume(thread)) + end + end) + yield() + + end + + if #buffer >= bytes then + self._buffer = buffer:sub(bytes + 1) + local pcm = {unpack(fmt[n], buffer)} + remove(pcm) + return pcm + end + +end + +function FFmpegProcess:close() + self._closed = true + self._child:kill() + if not self._stdout:is_closing() then + self._stdout:close() + end +end + +return FFmpegProcess diff --git a/deps/discordia/libs/voice/streams/PCMGenerator.lua b/deps/discordia/libs/voice/streams/PCMGenerator.lua new file mode 100644 index 0000000..cbbede3 --- /dev/null +++ b/deps/discordia/libs/voice/streams/PCMGenerator.lua @@ -0,0 +1,18 @@ +local PCMGenerator = require('class')('PCMGenerator') + +function PCMGenerator:__init(fn) + self._fn = fn +end + +function PCMGenerator:read(n) + local pcm = {} + local fn = self._fn + for i = 1, n, 2 do + local left, right = fn() + pcm[i] = tonumber(left) or 0 + pcm[i + 1] = tonumber(right) or pcm[i] + end + return pcm +end + +return PCMGenerator diff --git a/deps/discordia/libs/voice/streams/PCMStream.lua b/deps/discordia/libs/voice/streams/PCMStream.lua new file mode 100644 index 0000000..186c2ff --- /dev/null +++ b/deps/discordia/libs/voice/streams/PCMStream.lua @@ -0,0 +1,28 @@ +local remove = table.remove +local unpack = string.unpack -- luacheck: ignore +local rep = string.rep + +local fmt = setmetatable({}, { + __index = function(self, n) + self[n] = '<' .. rep('i2', n) + return self[n] + end +}) + +local PCMStream = require('class')('PCMStream') + +function PCMStream:__init(stream) + self._stream = stream +end + +function PCMStream:read(n) + local m = n * 2 + local str = self._stream:read(m) + if str and #str == m then + local pcm = {unpack(fmt[n], str)} + remove(pcm) + return pcm + end +end + +return PCMStream diff --git a/deps/discordia/libs/voice/streams/PCMString.lua b/deps/discordia/libs/voice/streams/PCMString.lua new file mode 100644 index 0000000..2d6c5ce --- /dev/null +++ b/deps/discordia/libs/voice/streams/PCMString.lua @@ -0,0 +1,28 @@ +local remove = table.remove +local unpack = string.unpack -- luacheck: ignore +local rep = string.rep + +local fmt = setmetatable({}, { + __index = function(self, n) + self[n] = '<' .. rep('i2', n) + return self[n] + end +}) + +local PCMString = require('class')('PCMString') + +function PCMString:__init(str) + self._len = #str + self._str = str +end + +function PCMString:read(n) + local i = self._i or 1 + if i + n * 2 < self._len then + local pcm = {unpack(fmt[n], self._str, i)} + self._i = remove(pcm) + return pcm + end +end + +return PCMString diff --git a/deps/discordia/package.lua b/deps/discordia/package.lua new file mode 100644 index 0000000..cc04af5 --- /dev/null +++ b/deps/discordia/package.lua @@ -0,0 +1,36 @@ +--[[The MIT License (MIT) + +Copyright (c) 2016-2020 SinisterRectus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.]] + +return { + name = 'SinisterRectus/discordia', + version = '2.9.1', + homepage = 'https://github.com/SinisterRectus/Discordia', + dependencies = { + 'creationix/coro-http@3.1.0', + 'creationix/coro-websocket@3.1.0', + 'luvit/secure-socket@1.2.2', + }, + tags = {'discord', 'api'}, + license = 'MIT', + author = 'Sinister Rectus', + files = {'**.lua'}, +} diff --git a/deps/http-codec.lua b/deps/http-codec.lua new file mode 100644 index 0000000..b90af33 --- /dev/null +++ b/deps/http-codec.lua @@ -0,0 +1,301 @@ +--[[ + +Copyright 2014-2015 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] + +--[[lit-meta + name = "luvit/http-codec" + version = "3.0.6" + homepage = "https://github.com/luvit/luvit/blob/master/deps/http-codec.lua" + description = "A simple pair of functions for converting between hex and raw strings." + tags = {"codec", "http"} + license = "Apache 2" + author = { name = "Tim Caswell" } +]] + +local sub = string.sub +local gsub = string.gsub +local lower = string.lower +local find = string.find +local format = string.format +local concat = table.concat +local match = string.match + +local STATUS_CODES = { + [100] = 'Continue', + [101] = 'Switching Protocols', + [102] = 'Processing', -- RFC 2518, obsoleted by RFC 4918 + [200] = 'OK', + [201] = 'Created', + [202] = 'Accepted', + [203] = 'Non-Authoritative Information', + [204] = 'No Content', + [205] = 'Reset Content', + [206] = 'Partial Content', + [207] = 'Multi-Status', -- RFC 4918 + [300] = 'Multiple Choices', + [301] = 'Moved Permanently', + [302] = 'Moved Temporarily', + [303] = 'See Other', + [304] = 'Not Modified', + [305] = 'Use Proxy', + [307] = 'Temporary Redirect', + [400] = 'Bad Request', + [401] = 'Unauthorized', + [402] = 'Payment Required', + [403] = 'Forbidden', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [406] = 'Not Acceptable', + [407] = 'Proxy Authentication Required', + [408] = 'Request Time-out', + [409] = 'Conflict', + [410] = 'Gone', + [411] = 'Length Required', + [412] = 'Precondition Failed', + [413] = 'Request Entity Too Large', + [414] = 'Request-URI Too Large', + [415] = 'Unsupported Media Type', + [416] = 'Requested Range Not Satisfiable', + [417] = 'Expectation Failed', + [418] = "I'm a teapot", -- RFC 2324 + [422] = 'Unprocessable Entity', -- RFC 4918 + [423] = 'Locked', -- RFC 4918 + [424] = 'Failed Dependency', -- RFC 4918 + [425] = 'Unordered Collection', -- RFC 4918 + [426] = 'Upgrade Required', -- RFC 2817 + [428] = 'Precondition Required', -- RFC 6585 + [429] = 'Too Many Requests', -- RFC 6585 + [431] = 'Request Header Fields Too Large', -- RFC 6585 + [500] = 'Internal Server Error', + [501] = 'Not Implemented', + [502] = 'Bad Gateway', + [503] = 'Service Unavailable', + [504] = 'Gateway Time-out', + [505] = 'HTTP Version not supported', + [506] = 'Variant Also Negotiates', -- RFC 2295 + [507] = 'Insufficient Storage', -- RFC 4918 + [509] = 'Bandwidth Limit Exceeded', + [510] = 'Not Extended', -- RFC 2774 + [511] = 'Network Authentication Required' -- RFC 6585 +} + +local function encoder() + + local mode + local encodeHead, encodeRaw, encodeChunked + + function encodeHead(item) + if not item or item == "" then + return item + elseif not (type(item) == "table") then + error("expected a table but got a " .. type(item) .. " when encoding data") + end + local head, chunkedEncoding + local version = item.version or 1.1 + if item.method then + local path = item.path + assert(path and #path > 0, "expected non-empty path") + head = { item.method .. ' ' .. item.path .. ' HTTP/' .. version .. '\r\n' } + else + local reason = item.reason or STATUS_CODES[item.code] + head = { 'HTTP/' .. version .. ' ' .. item.code .. ' ' .. reason .. '\r\n' } + end + for i = 1, #item do + local key, value = unpack(item[i]) + local lowerKey = lower(key) + if lowerKey == "transfer-encoding" then + chunkedEncoding = lower(value) == "chunked" + end + value = gsub(tostring(value), "[\r\n]+", " ") + head[#head + 1] = key .. ': ' .. tostring(value) .. '\r\n' + end + head[#head + 1] = '\r\n' + + mode = chunkedEncoding and encodeChunked or encodeRaw + return concat(head) + end + + function encodeRaw(item) + if type(item) ~= "string" then + mode = encodeHead + return encodeHead(item) + end + return item + end + + function encodeChunked(item) + if type(item) ~= "string" then + mode = encodeHead + local extra = encodeHead(item) + if extra then + return "0\r\n\r\n" .. extra + else + return "0\r\n\r\n" + end + end + if #item == 0 then + mode = encodeHead + end + return format("%x", #item) .. "\r\n" .. item .. "\r\n" + end + + mode = encodeHead + return function (item) + return mode(item) + end +end + +local function decoder() + + -- This decoder is somewhat stateful with 5 different parsing states. + local decodeHead, decodeEmpty, decodeRaw, decodeChunked, decodeCounted + local mode -- state variable that points to various decoders + local bytesLeft -- For counted decoder + + -- This state is for decoding the status line and headers. + function decodeHead(chunk, index) + if not chunk or index > #chunk then return end + + local _, last = find(chunk, "\r?\n\r?\n", index) + -- First make sure we have all the head before continuing + if not last then + if (#chunk - index) <= 8 * 1024 then return end + -- But protect against evil clients by refusing heads over 8K long. + error("entity too large") + end + + -- Parse the status/request line + local head = {} + local _, offset + local version + _, offset, version, head.code, head.reason = + find(chunk, "^HTTP/(%d%.%d) (%d+) ([^\r\n]*)\r?\n", index) + if offset then + head.code = tonumber(head.code) + else + _, offset, head.method, head.path, version = + find(chunk, "^(%u+) ([^ ]+) HTTP/(%d%.%d)\r?\n", index) + if not offset then + error("expected HTTP data") + end + end + version = tonumber(version) + head.version = version + head.keepAlive = version > 1.0 + + -- We need to inspect some headers to know how to parse the body. + local contentLength + local chunkedEncoding + + -- Parse the header lines + while true do + local key, value + _, offset, key, value = find(chunk, "^([^:\r\n]+): *([^\r\n]*)\r?\n", offset + 1) + if not offset then break end + local lowerKey = lower(key) + + -- Inspect a few headers and remember the values + if lowerKey == "content-length" then + contentLength = tonumber(value) + elseif lowerKey == "transfer-encoding" then + chunkedEncoding = lower(value) == "chunked" + elseif lowerKey == "connection" then + head.keepAlive = lower(value) == "keep-alive" + end + head[#head + 1] = {key, value} + end + + if head.keepAlive and (not (chunkedEncoding or (contentLength and contentLength > 0))) + or (head.method == "GET" or head.method == "HEAD") then + mode = decodeEmpty + elseif chunkedEncoding then + mode = decodeChunked + elseif contentLength then + bytesLeft = contentLength + mode = decodeCounted + elseif not head.keepAlive then + mode = decodeRaw + end + return head, last + 1 + + end + + -- This is used for inserting a single empty string into the output string for known empty bodies + function decodeEmpty(chunk, index) + mode = decodeHead + return "", index + end + + function decodeRaw(chunk, index) + if #chunk < index then return end + return sub(chunk, index) + end + + function decodeChunked(chunk, index) + local len, term + len, term = match(chunk, "^(%x+)(..)", index) + if not len then return end + if term ~= "\r\n" then + -- Wait for full chunk-size\r\n header + if #chunk < 18 then return end + -- But protect against evil clients by refusing chunk-sizes longer than 16 hex digits. + error("chunk-size field too large") + end + index = index + #len + 2 + local offset = index - 1 + local length = tonumber(len, 16) + if #chunk < offset + length + 2 then return end + if length == 0 then + mode = decodeHead + end + assert(sub(chunk, index + length, index + length + 1) == "\r\n") + local piece = sub(chunk, index, index + length - 1) + return piece, index + length + 2 + end + + function decodeCounted(chunk, index) + if bytesLeft == 0 then + mode = decodeEmpty + return mode(chunk, index) + end + local offset = index - 1 + local length = #chunk - offset + -- Make sure we have at least one byte to process + if length == 0 then return end + + -- If there isn't enough data left, emit what we got so far + if length < bytesLeft then + bytesLeft = bytesLeft - length + return sub(chunk, index) + end + + mode = decodeEmpty + return sub(chunk, index, offset + bytesLeft), index + bytesLeft + end + + -- Switch between states by changing which decoder mode points to + mode = decodeHead + return function (chunk, index) + return mode(chunk, index) + end + +end + +return { + encoder = encoder, + decoder = decoder, +} diff --git a/deps/pathjoin.lua b/deps/pathjoin.lua new file mode 100644 index 0000000..ce20f77 --- /dev/null +++ b/deps/pathjoin.lua @@ -0,0 +1,124 @@ +--[[lit-meta + name = "creationix/pathjoin" + description = "The path utilities that used to be part of luvi" + version = "2.0.0" + tags = {"path"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local getPrefix, splitPath, joinParts + +local isWindows +if _G.jit then + isWindows = _G.jit.os == "Windows" +else + isWindows = not not package.path:match("\\") +end + +if isWindows then + -- Windows aware path utilities + function getPrefix(path) + return path:match("^%a:\\") or + path:match("^/") or + path:match("^\\+") + end + function splitPath(path) + local parts = {} + for part in string.gmatch(path, '([^/\\]+)') do + table.insert(parts, part) + end + return parts + end + function joinParts(prefix, parts, i, j) + if not prefix then + return table.concat(parts, '/', i, j) + elseif prefix ~= '/' then + return prefix .. table.concat(parts, '\\', i, j) + else + return prefix .. table.concat(parts, '/', i, j) + end + end +else + -- Simple optimized versions for UNIX systems + function getPrefix(path) + return path:match("^/") + end + function splitPath(path) + local parts = {} + for part in string.gmatch(path, '([^/]+)') do + table.insert(parts, part) + end + return parts + end + function joinParts(prefix, parts, i, j) + if prefix then + return prefix .. table.concat(parts, '/', i, j) + end + return table.concat(parts, '/', i, j) + end +end + +local function pathJoin(...) + local inputs = {...} + local l = #inputs + + -- Find the last segment that is an absolute path + -- Or if all are relative, prefix will be nil + local i = l + local prefix + while true do + prefix = getPrefix(inputs[i]) + if prefix or i <= 1 then break end + i = i - 1 + end + + -- If there was one, remove its prefix from its segment + if prefix then + inputs[i] = inputs[i]:sub(#prefix) + end + + -- Split all the paths segments into one large list + local parts = {} + while i <= l do + local sub = splitPath(inputs[i]) + for j = 1, #sub do + parts[#parts + 1] = sub[j] + end + i = i + 1 + end + + -- Evaluate special segments in reverse order. + local skip = 0 + local reversed = {} + for idx = #parts, 1, -1 do + local part = parts[idx] + if part ~= '.' then + if part == '..' then + skip = skip + 1 + elseif skip > 0 then + skip = skip - 1 + else + reversed[#reversed + 1] = part + end + end + end + + -- Reverse the list again to get the correct order + parts = reversed + for idx = 1, #parts / 2 do + local j = #parts - idx + 1 + parts[idx], parts[j] = parts[j], parts[idx] + end + + local path = joinParts(prefix, parts) + return path +end + +return { + isWindows = isWindows, + getPrefix = getPrefix, + splitPath = splitPath, + joinParts = joinParts, + pathJoin = pathJoin, +} diff --git a/deps/resource.lua b/deps/resource.lua new file mode 100644 index 0000000..f8e73cc --- /dev/null +++ b/deps/resource.lua @@ -0,0 +1,88 @@ +--[[ + +Copyright 2014-2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] + +--[[lit-meta + name = "luvit/resource" + version = "2.1.0" + license = "Apache 2" + homepage = "https://github.com/luvit/luvit/blob/master/deps/resource.lua" + description = "Utilities for loading relative resources" + dependencies = { + "creationix/pathjoin@2.0.0" + } + tags = {"luvit", "relative", "resource"} +]] + +local pathJoin = require('pathjoin').pathJoin +local bundle = require('luvi').bundle +local uv = require('uv') + +local function getPath() + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + return caller:sub(2) + elseif caller:sub(1, 7) == "bundle:" then + return caller + end + error("Unknown file path type: " .. caller) +end + +local function getDir() + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + return pathJoin(caller:sub(2), "..") + elseif caller:sub(1, 7) == "bundle:" then + return "bundle:" .. pathJoin(caller:sub(8), "..") + end + error("Unknown file path type: " .. caller) +end + +local function innerResolve(path, resolveOnly) + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + path = pathJoin(caller:sub(2), "..", path) + if resolveOnly then return path end + local fd = assert(uv.fs_open(path, "r", 420)) + local stat = assert(uv.fs_fstat(fd)) + local data = assert(uv.fs_read(fd, stat.size, 0)) + uv.fs_close(fd) + return data, path + elseif caller:sub(1, 7) == "bundle:" then + path = pathJoin(caller:sub(8), "..", path) + if resolveOnly then return path end + return bundle.readfile(path), "bundle:" .. path + end +end + +local function resolve(path) + return innerResolve(path, true) +end + +local function load(path) + return innerResolve(path, false) +end + +local function getProp(self, key) + if key == "path" then return getPath() end + if key == "dir" then return getDir() end +end + +return setmetatable({ + resolve = resolve, + load = load, +}, { __index = getProp }) diff --git a/deps/secure-socket/biowrap.lua b/deps/secure-socket/biowrap.lua new file mode 100644 index 0000000..50f9f19 --- /dev/null +++ b/deps/secure-socket/biowrap.lua @@ -0,0 +1,115 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local openssl = require('openssl') + +-- writeCipher is called when ssl needs something written on the socket +-- handshakeComplete is called when the handhake is complete and it's safe +-- onPlain is called when plaintext comes out. +return function (ctx, isServer, socket, handshakeComplete, servername) + + local bin, bout = openssl.bio.mem(8192), openssl.bio.mem(8192) + local ssl = ctx:ssl(bin, bout, isServer) + + if not isServer and servername then + ssl:set('hostname', servername) + end + + local ssocket = {tls=true} + local onPlain + + local function flush(callback) + local chunks = {} + local i = 0 + while bout:pending() > 0 do + i = i + 1 + chunks[i] = bout:read() + end + if i == 0 then + if callback then callback() end + return true + end + return socket:write(chunks, callback) + end + + local function handshake(callback) + if ssl:handshake() then + local success, result = ssl:getpeerverification() + socket:read_stop() + if not success and result then + handshakeComplete("Error verifying peer: " .. result[1].error_string) + end + handshakeComplete(nil, ssocket) + end + return flush(callback) + end + + local function onCipher(err, data) + if not onPlain then + if err or not data then + return handshakeComplete(err or "Peer aborted the SSL handshake", data) + end + bin:write(data) + return handshake() + end + if err or not data then + return onPlain(err, data) + end + bin:write(data) + while true do + local plain = ssl:read() + if not plain then break end + onPlain(nil, plain) + end + end + + -- When requested to start reading, start the real socket and setup + -- onPlain handler + function ssocket.read_start(_, onRead) + onPlain = onRead + return socket:read_start(onCipher) + end + + -- When requested to write plain data, encrypt it and write to socket + function ssocket.write(_, plain, callback) + ssl:write(plain) + return flush(callback) + end + + function ssocket.shutdown(_, ...) + return socket:shutdown(...) + end + function ssocket.read_stop(_, ...) + return socket:read_stop(...) + end + function ssocket.is_closing(_, ...) + return socket:is_closing(...) + end + function ssocket.close(_, ...) + return socket:close(...) + end + function ssocket.unref(_, ...) + return socket:unref(...) + end + function ssocket.ref(_, ...) + return socket:ref(...) + end + + handshake() + socket:read_start(onCipher) + +end diff --git a/deps/secure-socket/context.lua b/deps/secure-socket/context.lua new file mode 100644 index 0000000..58b6fd0 --- /dev/null +++ b/deps/secure-socket/context.lua @@ -0,0 +1,121 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local openssl = require('openssl') + +local loadResource +if type(module) == "table" then + function loadResource(path) + return module:load(path) + end +else + loadResource = require('resource').load +end +local bit = require('bit') + +local DEFAULT_SECUREPROTOCOL +do + local _, _, V = openssl.version() + local isLibreSSL = V:find('^LibreSSL') + + _, _, V = openssl.version(true) + local isTLSv1_3 = not isLibreSSL and V >= 0x10101000 + + if isTLSv1_3 then + DEFAULT_SECUREPROTOCOL = 'TLS' + else + DEFAULT_SECUREPROTOCOL = 'SSLv23' + end +end +local DEFAULT_CIPHERS = 'TLS_AES_128_GCM_SHA256:TLS_AES_128_CCM_SHA256:' .. --TLS 1.3 + 'ECDHE-RSA-AES128-SHA256:AES128-GCM-SHA256:' .. --TLS 1.2 + 'RC4:HIGH:!MD5:!aNULL:!EDH' --TLS 1.0 +local DEFAULT_CA_STORE +do + local data = assert(loadResource("./root_ca.dat")) + DEFAULT_CA_STORE = openssl.x509.store:new() + local index = 1 + local dataLength = #data + while index < dataLength do + local len = bit.bor(bit.lshift(data:byte(index), 8), data:byte(index + 1)) + index = index + 2 + local cert = assert(openssl.x509.read(data:sub(index, index + len))) + index = index + len + assert(DEFAULT_CA_STORE:add(cert)) + end +end + +local function returnOne() + return 1 +end + +return function (options) + local ctx = openssl.ssl.ctx_new( + options.protocol or DEFAULT_SECUREPROTOCOL, + options.ciphers or DEFAULT_CIPHERS) + + local key, cert, ca + if options.key then + key = assert(openssl.pkey.read(options.key, true, 'pem')) + end + if options.cert then + cert = {} + for chunk in options.cert:gmatch("%-+BEGIN[^-]+%-+[^-]+%-+END[^-]+%-+") do + cert[#cert + 1] = assert(openssl.x509.read(chunk)) + end + end + if options.ca then + if type(options.ca) == "string" then + ca = { assert(openssl.x509.read(options.ca)) } + elseif type(options.ca) == "table" then + ca = {} + for i = 1, #options.ca do + ca[i] = assert(openssl.x509.read(options.ca[i])) + end + else + error("options.ca must be string or table of strings") + end + end + if key and cert then + local first = table.remove(cert, 1) + assert(ctx:use(key, first)) + if #cert > 0 then + -- TODO: find out if there is a way to not need to duplicate the last cert here + -- as a dummy fill for the root CA cert + assert(ctx:add(cert[#cert], cert)) + end + end + if ca then + local store = openssl.x509.store:new() + for i = 1, #ca do + assert(store:add(ca[i])) + end + ctx:cert_store(store) + elseif DEFAULT_CA_STORE then + ctx:cert_store(DEFAULT_CA_STORE) + end + if not (options.insecure or options.key) then + ctx:verify_mode(openssl.ssl.peer, returnOne) + end + + ctx:options(bit.bor( + openssl.ssl.no_sslv2, + openssl.ssl.no_sslv3, + openssl.ssl.no_compression)) + + return ctx +end diff --git a/deps/secure-socket/init.lua b/deps/secure-socket/init.lua new file mode 100644 index 0000000..9ba79eb --- /dev/null +++ b/deps/secure-socket/init.lua @@ -0,0 +1,41 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local getContext = require('./context') +local bioWrap = require('./biowrap') + +local function assertResume(thread, ...) + local success, err = coroutine.resume(thread, ...) + if not success then + error(debug.traceback(thread, err), 0) + end +end + +return function (socket, options, callback) + if options == true then options = {} end + local ctx = getContext(options) + local thread + if not callback then + thread = coroutine.running() + end + bioWrap(ctx, options.server, socket, callback or function (err, ssocket) + return assertResume(thread, ssocket, err) +end, options.servername) + if not callback then + return coroutine.yield() + end +end diff --git a/deps/secure-socket/package.lua b/deps/secure-socket/package.lua new file mode 100644 index 0000000..1a1c7f1 --- /dev/null +++ b/deps/secure-socket/package.lua @@ -0,0 +1,12 @@ +return { + name = "luvit/secure-socket", + version = "1.2.3", + homepage = "https://github.com/luvit/luvit/blob/master/deps/secure-socket", + description = "Wrapper for luv streams to apply ssl/tls", + dependencies = { + "luvit/resource@2.1.0" + }, + tags = {"ssl", "socket","tls"}, + license = "Apache 2", + author = { name = "Tim Caswell" } +} diff --git a/deps/secure-socket/root_ca.dat b/deps/secure-socket/root_ca.dat new file mode 100644 index 0000000..8c9097e Binary files /dev/null and b/deps/secure-socket/root_ca.dat differ diff --git a/deps/sha1/init.lua b/deps/sha1/init.lua new file mode 100644 index 0000000..d15a5d1 --- /dev/null +++ b/deps/sha1/init.lua @@ -0,0 +1,194 @@ +--[[lit-meta + name = "creationix/sha1" + version = "1.0.4" + homepage = "https://github.com/luvit/lit/blob/master/deps/sha1.lua" + description = "Pure Lua implementation of SHA1 using bitop" + authors = { + "Tim Caswell" + } +]] + +-- http://csrc.nist.gov/groups/ST/toolkit/documents/Examples/SHA_All.pdf + +local bit = require('bit') +local band = bit.band +local bor = bit.bor +local bxor = bit.bxor +local lshift = bit.lshift +local rshift = bit.rshift +local rol = bit.rol +local tobit = bit.tobit +local tohex = bit.tohex + +local byte = string.byte +local concat = table.concat +local floor = table.floor + +local hasFFi, ffi = pcall(require, "ffi") +local newBlock = hasFFi and function () + return ffi.new("uint32_t[80]") +end or function () + local t = {} + for i = 0, 79 do + t[i] = 0 + end + return t +end + +local shared = newBlock() + +local function unsigned(n) + return n < 0 and (n + 0x100000000) or n +end + +local function create(sync) + local h0 = 0x67452301 + local h1 = 0xEFCDAB89 + local h2 = 0x98BADCFE + local h3 = 0x10325476 + local h4 = 0xC3D2E1F0 + -- The first 64 bytes (16 words) is the data chunk + local W = sync and shared or newBlock() + local offset = 0 + local shift = 24 + local totalLength = 0 + + local update, write, processBlock, digest + + -- The user gave us more data. Store it! + function update(chunk) + local length = #chunk + totalLength = totalLength + length * 8 + for i = 1, length do + write(byte(chunk, i)) + end + end + + function write(data) + W[offset] = bor(W[offset], lshift(band(data, 0xff), shift)) + if shift > 0 then + shift = shift - 8 + else + offset = offset + 1 + shift = 24 + end + if offset == 16 then + return processBlock() + end + end + + -- No more data will come, pad the block, process and return the result. + function digest() + -- Pad + write(0x80) + if offset > 14 or (offset == 14 and shift < 24) then + processBlock() + end + offset = 14 + shift = 24 + + -- 64-bit length big-endian + write(0x00) -- numbers this big aren't accurate in lua anyway + write(0x00) -- ..So just hard-code to zero. + write(totalLength > 0xffffffffff and floor(totalLength / 0x10000000000) or 0x00) + write(totalLength > 0xffffffff and floor(totalLength / 0x100000000) or 0x00) + for s = 24, 0, -8 do + write(rshift(totalLength, s)) + end + + -- At this point one last processBlock() should trigger and we can pull out the result. + return concat { + tohex(h0), + tohex(h1), + tohex(h2), + tohex(h3), + tohex(h4) + } + end + + -- We have a full block to process. Let's do it! + function processBlock() + + -- Extend the sixteen 32-bit words into eighty 32-bit words: + for i = 16, 79, 1 do + W[i] = + rol(bxor(W[i - 3], W[i - 8], W[i - 14], W[i - 16]), 1) + end + + -- print("Block Contents:") + -- for i = 0, 15 do + -- print(string.format(" W[%d] = %s", i, tohex(W[i]))) + -- end + -- print() + + -- Initialize hash value for this chunk: + local a = h0 + local b = h1 + local c = h2 + local d = h3 + local e = h4 + local f, k + + -- print(" A B C D E") + -- local format = + -- "t=%02d: %s %s %s %s %s" + -- Main loop: + for t = 0, 79 do + if t < 20 then + f = bxor(d, band(b, bxor(c, d))) + k = 0x5A827999 + elseif t < 40 then + f = bxor(b, c, d) + k = 0x6ED9EBA1 + elseif t < 60 then + f = bor(band(b, c), (band(d, bor(b, c)))) + k = 0x8F1BBCDC + else + f = bxor(b, c, d) + k = 0xCA62C1D6 + end + e, d, c, b, a = + d, + c, + rol(b, 30), + a, + tobit( + unsigned(rol(a, 5)) + + unsigned(f) + + unsigned(e) + + unsigned(k) + + W[t] + ) + -- print(string.format(format, t, tohex(a), tohex(b), tohex(c), tohex(d), tohex(e))) + end + + -- Add this chunk's hash to result so far: + h0 = tobit(unsigned(h0) + a) + h1 = tobit(unsigned(h1) + b) + h2 = tobit(unsigned(h2) + c) + h3 = tobit(unsigned(h3) + d) + h4 = tobit(unsigned(h4) + e) + + -- The block is now reusable. + offset = 0 + for i = 0, 15 do + W[i] = 0 + end + end + + return { + update = update, + digest = digest + } + +end + +return function (buffer) + -- Pass in false or nil to get a streaming interface. + if not buffer then + return create(false) + end + local shasum = create(true) + shasum.update(buffer) + return shasum.digest() +end diff --git a/deps/sha1/test-sha1.lua b/deps/sha1/test-sha1.lua new file mode 100644 index 0000000..f4b4aa2 --- /dev/null +++ b/deps/sha1/test-sha1.lua @@ -0,0 +1,22 @@ + +local sha1 = require('./init') +assert(sha1("") == "da39a3ee5e6b4b0d3255bfef95601890afd80709") +assert(sha1("abc") == "a9993e364706816aba3e25717850c26c9cd0d89d") +assert(sha1("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") + == "84983e441c3bd26ebaae4aa1f95129e5e54670f1") +assert(sha1("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu") + == "a49b2446a02c645bf419f995b67091253a04a259") +assert(sha1(string.rep("a", 1000000)) + == "34aa973cd4c4daa4f61eeb2bdbad27316534016f") +local sum = sha1() +sum.update("a") +sum.update("bc") +assert(sum.digest() == "a9993e364706816aba3e25717850c26c9cd0d89d") +sum = sha1() +local aa = string.rep("a", 1000) +for i = 1, 1000 do + sum.update(aa) +end +assert(sum.digest() == "34aa973cd4c4daa4f61eeb2bdbad27316534016f") + +print("All tests pass") diff --git a/deps/websocket-codec.lua b/deps/websocket-codec.lua new file mode 100644 index 0000000..3ea1611 --- /dev/null +++ b/deps/websocket-codec.lua @@ -0,0 +1,301 @@ +--[[lit-meta + name = "creationix/websocket-codec" + description = "A codec implementing websocket framing and helpers for handshakeing" + version = "3.0.2" + dependencies = { + "creationix/base64@2.0.0", + "creationix/sha1@1.0.0", + } + homepage = "https://github.com/luvit/lit/blob/master/deps/websocket-codec.lua" + tags = {"http", "websocket", "codec"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local base64 = require('base64').encode +local sha1 = require('sha1') +local bit = require('bit') + +local band = bit.band +local bor = bit.bor +local bxor = bit.bxor +local rshift = bit.rshift +local lshift = bit.lshift +local char = string.char +local byte = string.byte +local sub = string.sub +local gmatch = string.gmatch +local lower = string.lower +local gsub = string.gsub +local concat = table.concat +local floor = math.floor +local random = math.random + +local function rand4() + -- Generate 32 bits of pseudo random data + local num = floor(random() * 0x100000000) + -- Return as a 4-byte string + return char( + rshift(num, 24), + band(rshift(num, 16), 0xff), + band(rshift(num, 8), 0xff), + band(num, 0xff) + ) +end + +local function applyMask(data, mask) + local bytes = { + [0] = byte(mask, 1), + [1] = byte(mask, 2), + [2] = byte(mask, 3), + [3] = byte(mask, 4) + } + local out = {} + for i = 1, #data do + out[i] = char( + bxor(byte(data, i), bytes[(i - 1) % 4]) + ) + end + return concat(out) +end + +local function decode(chunk, index) + local start = index - 1 + local length = #chunk - start + if length < 2 then return end + local second = byte(chunk, start + 2) + local len = band(second, 0x7f) + local offset + if len == 126 then + if length < 4 then return end + len = bor( + lshift(byte(chunk, start + 3), 8), + byte(chunk, start + 4)) + offset = 4 + elseif len == 127 then + if length < 10 then return end + len = bor( + lshift(byte(chunk, start + 3), 24), + lshift(byte(chunk, start + 4), 16), + lshift(byte(chunk, start + 5), 8), + byte(chunk, start + 6) + ) * 0x100000000 + bor( + lshift(byte(chunk, start + 7), 24), + lshift(byte(chunk, start + 8), 16), + lshift(byte(chunk, start + 9), 8), + byte(chunk, start + 10) + ) + offset = 10 + else + offset = 2 + end + local mask = band(second, 0x80) > 0 + if mask then + offset = offset + 4 + end + offset = offset + start + if #chunk < offset + len then return end + + local first = byte(chunk, start + 1) + local payload = sub(chunk, offset + 1, offset + len) + assert(#payload == len, "Length mismatch") + if mask then + payload = applyMask(payload, sub(chunk, offset - 3, offset)) + end + return { + fin = band(first, 0x80) > 0, + rsv1 = band(first, 0x40) > 0, + rsv2 = band(first, 0x20) > 0, + rsv3 = band(first, 0x10) > 0, + opcode = band(first, 0xf), + mask = mask, + len = len, + payload = payload + }, offset + len + 1 +end + +local function encode(item) + if type(item) == "string" then + item = { + opcode = 2, + payload = item + } + end + local payload = item.payload + assert(type(payload) == "string", "payload must be string") + local len = #payload + local fin = item.fin + if fin == nil then fin = true end + local rsv1 = item.rsv1 + local rsv2 = item.rsv2 + local rsv3 = item.rsv3 + local opcode = item.opcode or 2 + local mask = item.mask + local chars = { + char(bor( + fin and 0x80 or 0, + rsv1 and 0x40 or 0, + rsv2 and 0x20 or 0, + rsv3 and 0x10 or 0, + opcode + )), + char(bor( + mask and 0x80 or 0, + len < 126 and len or (len < 0x10000) and 126 or 127 + )) + } + if len >= 0x10000 then + local high = len / 0x100000000 + chars[3] = char(band(rshift(high, 24), 0xff)) + chars[4] = char(band(rshift(high, 16), 0xff)) + chars[5] = char(band(rshift(high, 8), 0xff)) + chars[6] = char(band(high, 0xff)) + chars[7] = char(band(rshift(len, 24), 0xff)) + chars[8] = char(band(rshift(len, 16), 0xff)) + chars[9] = char(band(rshift(len, 8), 0xff)) + chars[10] = char(band(len, 0xff)) + elseif len >= 126 then + chars[3] = char(band(rshift(len, 8), 0xff)) + chars[4] = char(band(len, 0xff)) + end + if mask then + local key = rand4() + return concat(chars) .. key .. applyMask(payload, key) + end + return concat(chars) .. payload +end + +local websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +-- Given two hex characters, return a single character +local function hexToBin(cc) + return string.char(tonumber(cc, 16)) +end + +local function decodeHex(hex) + local bin = string.gsub(hex, "..", hexToBin) + return bin +end + +local function acceptKey(key) + return gsub(base64(decodeHex(sha1(key .. websocketGuid))), "\n", "") +end + +-- Make a client handshake connection +local function handshake(options, request) + -- Generate 20 bytes of pseudo-random data + local key = concat({rand4(), rand4(), rand4(), rand4(), rand4()}) + key = base64(key) + local host = options.host + local path = options.path or "/" + local protocol = options.protocol + local req = { + method = "GET", + path = path, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", key}, + } + for i = 1, #options do + req[#req + 1] = options[i] + end + if host then + req[#req + 1] = {"Host", host} + end + if protocol then + req[#req + 1] = {"Sec-WebSocket-Protocol", protocol} + end + local res = request(req) + if not res then + return nil, "Missing response from server" + end + -- Parse the headers for quick reading + if res.code ~= 101 then + return nil, "response must be code 101" + end + + local headers = {} + for i = 1, #res do + local name, value = unpack(res[i]) + headers[lower(name)] = value + end + + if not headers.connection or lower(headers.connection) ~= "upgrade" then + return nil, "Invalid or missing connection upgrade header in response" + end + if headers["sec-websocket-accept"] ~= acceptKey(key) then + return nil, "challenge key missing or mismatched" + end + if protocol and headers["sec-websocket-protocol"] ~= protocol then + return nil, "protocol missing or mistmatched" + end + return true +end + +local function handleHandshake(head, protocol) + + -- WebSocket connections must be GET requests + if not head.method == "GET" then return end + + -- Parse the headers for quick reading + local headers = {} + for i = 1, #head do + local name, value = unpack(head[i]) + headers[lower(name)] = value + end + + -- Must have 'Upgrade: websocket' and 'Connection: Upgrade' headers + if not (headers.connection and headers.upgrade and + headers.connection:lower():find("upgrade", 1, true) and + headers.upgrade:lower():find("websocket", 1, true)) then return end + + -- Make sure it's a new client speaking v13 of the protocol + if tonumber(headers["sec-websocket-version"]) < 13 then + return nil, "only websocket protocol v13 supported" + end + + local key = headers["sec-websocket-key"] + if not key then + return nil, "websocket security key missing" + end + + -- If the server wants a specified protocol, check for it. + if protocol then + local foundProtocol = false + local list = headers["sec-websocket-protocol"] + if list then + for item in gmatch(list, "[^, ]+") do + if item == protocol then + foundProtocol = true + break + end + end + end + if not foundProtocol then + return nil, "specified protocol missing in request" + end + end + + local accept = acceptKey(key) + + local res = { + code = 101, + {"Upgrade", "websocket"}, + {"Connection", "Upgrade"}, + {"Sec-WebSocket-Accept", accept}, + } + if protocol then + res[#res + 1] = {"Sec-WebSocket-Protocol", protocol} + end + + return res +end + +return { + decode = decode, + encode = encode, + acceptKey = acceptKey, + handshake = handshake, + handleHandshake = handleHandshake, +} diff --git a/gateway.json b/gateway.json new file mode 100644 index 0000000..447da71 --- /dev/null +++ b/gateway.json @@ -0,0 +1 @@ +{"url":"wss://gateway.discord.gg","873255296024322059":{"shards":1,"timestamp":1628272516,"owner":{"avatar":null,"discriminator":"0000","id":"872299874857676820","public_flags":1024,"username":"team872299874857676820","flags":1024}}} \ No newline at end of file