warlof/seat-discord-connector

View on GitHub
src/Driver/DiscordClient.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
98%
<?php

/**
 * This file is part of SeAT Discord Connector.
 *
 * Copyright (C) 2019, 2020  Warlof Tutsimo <loic.leuilliot@gmail.com>
 *
 * SeAT Discord Connector  is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * SeAT Discord Connector is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

namespace Warlof\Seat\Connector\Drivers\Discord\Driver;

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr;
use Seat\Services\Exceptions\SettingException;
use Warlof\Seat\Connector\Drivers\IClient;
use Warlof\Seat\Connector\Drivers\ISet;
use Warlof\Seat\Connector\Drivers\IUser;
use Warlof\Seat\Connector\Exceptions\DriverException;
use Warlof\Seat\Connector\Exceptions\DriverSettingsException;
use Warlof\Seat\Connector\Exceptions\InvalidDriverIdentityException;

/**
 * Class DiscordClient.
 *
 * @package Warlof\Seat\Connector\Drivers\Discord\Driver
 */
class DiscordClient implements IClient
{
    CONST BASE_URI = 'https://discord.com/api';

    CONST VERSION = 'v6';

    /**
     * @var \Warlof\Seat\Connector\Drivers\Discord\Driver\DiscordClient
     */
    private static $instance;

    /**
     * @var \GuzzleHttp\Client
     */
    private $client;

    /**
     * @var string
     */
    private $guild_id;

    /**
     * @var string
     */
    private $bot_token;

    /**
     * @var \Warlof\Seat\Connector\Drivers\IUser[]
     */
    private $members;

    /**
     * @var \Warlof\Seat\Connector\Drivers\ISet[]
     */
    private $roles;

    /**
     * @var string
     */
    private $owner_id;

    /**
     * DiscordClient constructor.
     *
     * @param array $parameters
     */
    private function __construct(array $parameters)
    {
        $this->guild_id  = $parameters['guild_id'];
        $this->bot_token = $parameters['bot_token'];
        $this->owner_id  = $parameters['owner_id'];

        $this->members = collect();
        $this->roles   = collect();

        $fetcher = config('discord-connector.config.fetcher');
        $base_uri = sprintf('%s/%s', rtrim(self::BASE_URI, '/'), self::VERSION);
        $this->client = new $fetcher($base_uri, $this->bot_token);
    }

    /**
     * @return \Warlof\Seat\Connector\Drivers\Discord\Driver\DiscordClient
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    public static function getInstance(): IClient
    {
        if (! isset(self::$instance)) {
            try {
                $settings = setting('seat-connector.drivers.discord', true);
            } catch (SettingException $e) {
                throw new DriverException($e->getMessage(), $e->getCode(), $e);
            }

            if (is_null($settings) || ! is_object($settings))
                throw new DriverSettingsException('The Driver has not been configured yet.');

            if (! property_exists($settings, 'guild_id') || is_null($settings->guild_id) || $settings->guild_id == '')
                throw new DriverSettingsException('Parameter guild_id is missing.');

            if (! property_exists($settings, 'bot_token') || is_null($settings->bot_token) || $settings->bot_token == '')
                throw new DriverSettingsException('Parameter bot_token is missing.');

            self::$instance = new DiscordClient([
                'guild_id'  => $settings->guild_id,
                'bot_token' => $settings->bot_token,
                'owner_id'  => property_exists($settings, 'owner_id') ? $settings->owner_id : null,
            ]);
        }

        return self::$instance;
    }

    /**
     * Reset the instance
     */
    public static function tearDown()
    {
        self::$instance = null;
    }

    /**
     * @return \Warlof\Seat\Connector\Drivers\IUser[]
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    public function getUsers(): array
    {
        if ($this->members->isEmpty()) {
            try {
                $this->seedMembers();
            } catch (GuzzleException $e) {
                throw new DriverException($e->getMessage(), $e->getCode(), $e);
            }
        }

        return $this->members->toArray();
    }

    /**
     * @param string $id
     * @return \Warlof\Seat\Connector\Drivers\IUser|null
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    public function getUser(string $id): ?IUser
    {
        $member = $this->members->get($id);

        if (! is_null($member))
            return $member;

        try {
            $member = $this->sendCall('GET', '/guilds/{guild.id}/members/{user.id}', [
                'guild.id' => $this->guild_id,
                'user.id' => $id,
            ]);
        } catch (ClientException $e) {
            logger()->error($e->getMessage(), $e->getTrace());

            $error = json_decode($e->getResponse()->getBody());

            if (! is_null($error) && property_exists($error, 'code')) {
                switch ($error->code) {
                    // provided Guild is not found
                    // ref: https://discordapp.com/developers/docs/topics/opcodes-and-status-codes
                    case 10004:
                        throw new DriverSettingsException(sprintf('Configured Guild ID %s is invalid.', $this->guild_id));
                    // provided User is not found into the Guild
                    // ref: https://discordapp.com/developers/docs/topics/opcodes-and-status-codes
                    case 10007:
                        throw new InvalidDriverIdentityException(sprintf('User ID %s is not found in Guild %s.', $id, $this->guild_id));
                }
            }

            throw new DriverException($e->getMessage(), $e->getCode(), $e);
        } catch (GuzzleException $e) {
            throw new DriverException($e->getMessage(), $e->getCode(), $e);
        }

        $member = new DiscordMember((array) $member);
        $this->members->put($member->getClientId(), $member);

        return $member;
    }

    /**
     * @return \Warlof\Seat\Connector\Drivers\ISet[]
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    public function getSets(): array
    {
        if ($this->roles->isEmpty()) {
            try {
                $this->seedRoles();
            } catch (GuzzleException $e) {
                throw new DriverException($e->getMessage(), $e->getCode(), $e);
            }
        }

        return $this->roles->toArray();
    }

    /**
     * @param string $id
     * @return \Warlof\Seat\Connector\Drivers\ISet|null
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    public function getSet(string $id): ?ISet
    {
        if ($this->roles->isEmpty()) {
            try {
                $this->seedRoles();
            } catch (GuzzleException $e) {
                throw new DriverException($e->getMessage(), $e->getCode(), $e);
            }
        }

        return $this->roles->get($id);
    }

    /**
     * @return string
     */
    public function getGuildId(): string
    {
        return $this->guild_id;
    }

    /**
     * @return string
     * @throws \GuzzleHttp\Exception\GuzzleException
     * @throws \Seat\Services\Exceptions\SettingException
     */
    public function getOwnerId(): string
    {
        if (is_null($this->owner_id)) {
            $guild = $this->sendCall('GET', '/guilds/{guild.id}', [
                'guild.id' => $this->guild_id,
            ]);

            $this->owner_id = $guild['owner_id'];

            $settings  = setting('seat-connector.drivers.discord', true);
            $settings->owner_id = $this->owner_id;

            setting(['seat-connector.drivers.discord', $settings], true);
        }

        return $this->owner_id;
    }

    /**
     * @param string $method
     * @param string $endpoint
     * @param array $arguments
     * @return object
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function sendCall(string $method, string $endpoint, array $arguments = [])
    {
        $uri = ltrim($endpoint, '/');

        foreach ($arguments as $uri_parameter => $value) {
            if (strpos($uri, sprintf('{%s}', $uri_parameter)) === false)
                continue;

            $uri = str_replace(sprintf('{%s}', $uri_parameter), $value, $uri);
            Arr::pull($arguments, $uri_parameter);
        }

        if ($method == 'GET') {
            $response = $this->client->request($method, $uri, [
                'query' => $arguments,
            ]);
        } else {
            $response = $this->client->request($method, $uri, [
                'body' => json_encode($arguments),
            ]);
        }

        logger()->debug(
            sprintf('[seat-connector][discord] [http %d, %s] %s -> /%s',
                $response->getStatusCode(), $response->getReasonPhrase(), $method, $uri)
        );

        return json_decode($response->getBody(), true);
    }

    /**
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    private function seedMembers()
    {
        $after = null;

        do {
            $options= [
                'guild.id' => $this->guild_id,
                'limit'    => 1000,
            ];

            if ($after)
                $options['after'] = $after;

            $members = $this->sendCall('GET', '/guilds/{guild.id}/members', $options);

            if (empty($members))
                break;

            $after = end($members)['user']['id'];

            foreach ($members as $member_attributes) {

                // skip all bot users
                if (array_key_exists('bot', $member_attributes['user']) && $member_attributes['user']['bot'])
                    continue;

                $member = new DiscordMember($member_attributes);
                $this->members->put($member->getClientId(), $member);
            }
        } while (true);
    }

    /**
     * @throws \GuzzleHttp\Exception\GuzzleException
     * @throws \Warlof\Seat\Connector\Exceptions\DriverException
     */
    private function seedRoles()
    {
        $roles = DiscordClient::getInstance()->sendCall('GET', '/guilds/{guild.id}/roles', [
            'guild.id' => $this->guild_id,
        ]);

        foreach ($roles as $role_attributes) {
            if ($role_attributes['name'] == '@everyone') continue;

            // ignore managed roles (ie: booster)
            if ($role_attributes['managed']) continue;

            $role = new DiscordRole($role_attributes);
            $this->roles->put($role->getId(), $role);
        }
    }
}