You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

680 lines
18 KiB
Lua

--[=[
@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