Proyectos de Subversion Moodle

Rev

Autoría | Ultima modificación | Ver Log |

<?php
// This file is part of the Zoom plugin for Moodle - http://moodle.org/
//
// Moodle 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
// (at your option) any later version.
//
// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Handles API calls to Zoom REST API.
 *
 * @package   mod_zoom
 * @copyright 2015 UC Regents
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace mod_zoom;

defined('MOODLE_INTERNAL') || die();

require_once($CFG->dirroot . '/mod/zoom/locallib.php');
require_once($CFG->libdir . '/filelib.php');

use cache;
use core_user;
use curl;
use moodle_exception;
use stdClass;

/**
 * Web service class.
 */
class webservice {
    /**
     * API calls: maximum number of retries.
     * @var int
     */
    public const MAX_RETRIES = 5;

    /**
     * Default meeting_password_requirement object.
     * @var array
     */
    public const DEFAULT_MEETING_PASSWORD_REQUIREMENT = [
        'length' => 0,
        'consecutive_characters_length' => 0,
        'have_letter' => false,
        'have_number' => false,
        'have_upper_and_lower_characters' => false,
        'have_special_character' => false,
        'only_allow_numeric' => false,
        'weak_enhance_detection' => false,
    ];

    /**
     * Client ID
     * @var string
     */
    protected $clientid;

    /**
     * Client secret
     * @var string
     */
    protected $clientsecret;

    /**
     * Account ID
     * @var string
     */
    protected $accountid;

    /**
     * API base URL.
     * @var string
     */
    protected $apiurl;

    /**
     * Whether to recycle licenses.
     * @var bool
     */
    protected $recyclelicenses;

    /**
     * Whether to check instance users
     * @var bool
     */
    protected $instanceusers;

    /**
     * Maximum limit of paid users
     * @var int
     */
    protected $numlicenses;

    /**
     * List of users
     * @var array
     */
    protected static $userslist;

    /**
     * Number of retries we've made for make_call
     * @var int
     */
    protected $makecallretries = 0;

    /**
     * Granted OAuth scopes
     * @var array
     */
    protected $scopes;

    /**
     * The constructor for the webservice class.
     * @throws moodle_exception Moodle exception is thrown for missing config settings.
     */
    public function __construct() {
        $config = get_config('zoom');

        $requiredfields = [
            'clientid',
            'clientsecret',
            'accountid',
        ];

        try {
            // Get and remember each required field.
            foreach ($requiredfields as $requiredfield) {
                if (!empty($config->$requiredfield)) {
                    $this->$requiredfield = $config->$requiredfield;
                } else {
                    throw new moodle_exception('zoomerr_field_missing', 'mod_zoom', '', get_string($requiredfield, 'mod_zoom'));
                }
            }

            // Get and remember the API URL.
            $this->apiurl = zoom_get_api_url();

            // Get and remember the plugin settings to recycle licenses.
            if (!empty($config->utmost)) {
                $this->recyclelicenses = $config->utmost;
                $this->instanceusers = !empty($config->instanceusers);
            }

            if ($this->recyclelicenses) {
                if (!empty($config->licensesnumber)) {
                    $this->numlicenses = $config->licensesnumber;
                } else {
                    throw new moodle_exception('zoomerr_licensesnumber_missing', 'mod_zoom');
                }
            }
        } catch (moodle_exception $exception) {
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $exception->getMessage());
        }
    }

    /**
     * Makes the call to curl using the specified method, url, and parameter data.
     * This has been moved out of make_call to make unit testing possible.
     *
     * @param \curl $curl The curl object used to make the request.
     * @param string $method The HTTP method to use.
     * @param string $url The URL to append to the API URL
     * @param array|string $data The data to attach to the call.
     * @return stdClass The call's result.
     */
    protected function make_curl_call(&$curl, $method, $url, $data) {
        return $curl->$method($url, $data);
    }

    /**
     * Gets a curl object in order to make API calls. This function was created
     * to enable unit testing for the webservice class.
     * @return curl The curl object used to make the API calls
     */
    protected function get_curl_object() {
        global $CFG;

        $proxyhost = get_config('zoom', 'proxyhost');

        if (!empty($proxyhost)) {
            $cfg = new stdClass();
            $cfg->proxyhost = $CFG->proxyhost;
            $cfg->proxyport = $CFG->proxyport;
            $cfg->proxyuser = $CFG->proxyuser;
            $cfg->proxypassword = $CFG->proxypassword;
            $cfg->proxytype = $CFG->proxytype;

            // Parse string as host:port, delimited by a colon (:).
            [$host, $port] = explode(':', $proxyhost);

            // Temporarily set new values on the global $CFG.
            $CFG->proxyhost = $host;
            $CFG->proxyport = $port;
            $CFG->proxytype = 'HTTP';
            $CFG->proxyuser = '';
            $CFG->proxypassword = '';
        }

        // Create $curl, which implicitly uses the proxy settings from $CFG.
        $curl = new curl();

        if (!empty($proxyhost)) {
            // Restore the stored global proxy settings from above.
            $CFG->proxyhost = $cfg->proxyhost;
            $CFG->proxyport = $cfg->proxyport;
            $CFG->proxyuser = $cfg->proxyuser;
            $CFG->proxypassword = $cfg->proxypassword;
            $CFG->proxytype = $cfg->proxytype;
        }

        return $curl;
    }

    /**
     * Makes a REST call.
     *
     * @param string $path The path to append to the API URL
     * @param array|string $data The data to attach to the call.
     * @param string $method The HTTP method to use.
     * @return stdClass The call's result in JSON format.
     * @throws moodle_exception Moodle exception is thrown for curl errors.
     */
    private function make_call($path, $data = [], $method = 'get') {
        $url = $this->apiurl . $path;
        $method = strtolower($method);

        $token = $this->get_access_token();

        $curl = $this->get_curl_object();
        $curl->setHeader('Authorization: Bearer ' . $token);
        $curl->setHeader('Accept: application/json');

        if ($method != 'get') {
            $curl->setHeader('Content-Type: application/json');
            $data = is_array($data) ? json_encode($data) : $data;
        }

        $attempts = 0;
        do {
            if ($attempts > 0) {
                sleep(1);
                debugging('retrying after curl error 35, retry attempt ' . $attempts);
            }

            $rawresponse = $this->make_curl_call($curl, $method, $url, $data);
            $attempts++;
        } while ($curl->get_errno() === 35 && $attempts <= self::MAX_RETRIES);

        if ($curl->get_errno()) {
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $curl->error);
        }

        $response = json_decode($rawresponse);

        $httpstatus = $curl->get_info()['http_code'];

        if ($httpstatus >= 400) {
            switch ($httpstatus) {
                case 400:
                    $errorstring = '';
                    if (!empty($response->errors)) {
                        foreach ($response->errors as $error) {
                            $errorstring .= ' ' . $error->message;
                        }
                    }
                    throw new bad_request_exception($response->message . $errorstring, $response->code);

                case 404:
                    throw new not_found_exception($response->message, $response->code);

                case 429:
                    $this->makecallretries += 1;
                    if ($this->makecallretries > self::MAX_RETRIES) {
                        throw new retry_failed_exception($response->message, $response->code);
                    }

                    $header = $curl->getResponse();
                    // Header can have mixed case, normalize it.
                    $header = array_change_key_case($header, CASE_LOWER);

                    // Default to 1 second for max requests per second limit.
                    $timediff = 1;

                    // Check if we hit the max requests per minute (only for Dashboard API).
                    if (
                        array_key_exists('x-ratelimit-type', $header) &&
                        $header['x-ratelimit-type'] == 'QPS' &&
                        strpos($path, 'metrics') !== false
                    ) {
                        $timediff = 60; // Try the next minute.
                    } else if (array_key_exists('retry-after', $header)) {
                        $retryafter = strtotime($header['retry-after']);
                        $timediff = $retryafter - time();
                        // If we have no API calls remaining, save retry-after.
                        if ($header['x-ratelimit-remaining'] == 0 && !empty($retryafter)) {
                            set_config('retry-after', $retryafter, 'zoom');
                            throw new api_limit_exception($response->message, $response->code, $retryafter);
                        } else if (!(defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
                            // When running CLI we might want to know how many calls remaining.
                            debugging('x-ratelimit-remaining = ' . $header['x-ratelimit-remaining']);
                        }
                    }

                    debugging('Received 429 response, sleeping ' . strval($timediff) .
                            ' seconds until next retry. Current retry: ' . $this->makecallretries);
                    if ($timediff > 0 && !(defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
                        sleep($timediff);
                    }
                    return $this->make_call($path, $data, $method);

                default:
                    if ($response) {
                        throw new webservice_exception(
                            $response->message,
                            $response->code,
                            'errorwebservice',
                            'mod_zoom',
                            '',
                            $response->message
                        );
                    } else {
                        throw new moodle_exception('errorwebservice', 'mod_zoom', '', "HTTP Status $httpstatus");
                    }
            }
        }

        $this->makecallretries = 0;

        return $response;
    }

    /**
     * Makes a paginated REST call.
     * Makes a call like make_call() but specifically for GETs with paginated results.
     *
     * @param string $url The URL to append to the API URL
     * @param array $data The data to attach to the call.
     * @param string $datatoget The name of the array of the data to get.
     * @return array The retrieved data.
     * @see make_call()
     */
    private function make_paginated_call($url, $data, $datatoget) {
        $aggregatedata = [];
        $data['page_size'] = ZOOM_MAX_RECORDS_PER_CALL;
        do {
            $callresult = null;
            $moredata = false;
            $callresult = $this->make_call($url, $data);

            if ($callresult) {
                $aggregatedata = array_merge($aggregatedata, $callresult->$datatoget);
                if (!empty($callresult->next_page_token)) {
                    $data['next_page_token'] = $callresult->next_page_token;
                    $moredata = true;
                } else if (!empty($callresult->page_number) && $callresult->page_number < $callresult->page_count) {
                    $data['page_number'] = $callresult->page_number + 1;
                    $moredata = true;
                }
            }
        } while ($moredata);

        return $aggregatedata;
    }

    /**
     * Autocreate a user on Zoom.
     *
     * @param stdClass $user The user to create.
     * @return bool Whether the user was succesfully created.
     * @deprecated Has never been used by internal code.
     */
    public function autocreate_user($user) {
        // Classic: user:write:admin.
        // Granular: user:write:user:admin.
        $url = 'users';
        $data = ['action' => 'autocreate'];
        $data['user_info'] = [
            'email' => zoom_get_api_identifier($user),
            'type' => ZOOM_USER_TYPE_PRO,
            'first_name' => $user->firstname,
            'last_name' => $user->lastname,
            'password' => base64_encode(random_bytes(16)),
        ];

        try {
            $this->make_call($url, $data, 'post');
        } catch (moodle_exception $error) {
            // If the user already exists, the error will contain 'User already in the account'.
            if (strpos($error->getMessage(), 'User already in the account') === true) {
                return false;
            } else {
                throw $error;
            }
        }

        return true;
    }

    /**
     * Get users list.
     *
     * @return array An array of users.
     */
    public function list_users() {
        if (empty(self::$userslist)) {
            // Classic: user:read:admin.
            // Granular: user:read:list_users:admin.
            self::$userslist = $this->make_paginated_call('users', [], 'users');
        }

        return self::$userslist;
    }

    /**
     * Checks whether the paid user license limit has been reached.
     *
     * Incrementally retrieves the active paid users and compares against $numlicenses.
     * @see $numlicenses
     * @return bool Whether the paid user license limit has been reached.
     */
    private function paid_user_limit_reached() {
        $userslist = $this->list_users();
        $numusers = 0;
        foreach ($userslist as $user) {
            if ($user->type != ZOOM_USER_TYPE_BASIC) {
                // Count the user if we're including all users or if the user is on this instance.
                if (!$this->instanceusers || core_user::get_user_by_email($user->email)) {
                    $numusers++;
                    if ($numusers >= $this->numlicenses) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * Gets the ID of the user, of all the paid users, with the oldest last login time.
     *
     * @return string|false If user is found, returns the User ID. Otherwise, returns false.
     */
    private function get_least_recently_active_paid_user_id() {
        $usertimes = [];

        // Classic: user:read:admin.
        // Granular: user:read:list_users:admin.
        $userslist = $this->list_users();

        foreach ($userslist as $user) {
            if ($user->type != ZOOM_USER_TYPE_BASIC && isset($user->last_login_time)) {
                // Count the user if we're including all users or if the user is on this instance.
                if (!$this->instanceusers || core_user::get_user_by_email($user->email)) {
                    $usertimes[$user->id] = strtotime($user->last_login_time);
                }
            }
        }

        if (!empty($usertimes)) {
            return array_search(min($usertimes), $usertimes);
        }

        return false;
    }

    /**
     * Gets a user's settings.
     *
     * @param string $userid The user's ID.
     * @return stdClass The call's result in JSON format.
     */
    public function get_user_settings($userid) {
        // Classic: user:read:admin.
        // Granular: user:read:settings:admin.
        return $this->make_call('users/' . $userid . '/settings');
    }

    /**
     * Gets the user's meeting security settings, including password requirements.
     *
     * @param string $userid The user's ID.
     * @return stdClass The call's result in JSON format.
     */
    public function get_account_meeting_security_settings($userid) {
        // Classic: user:read:admin.
        // Granular: user:read:settings:admin.
        $url = 'users/' . $userid . '/settings?option=meeting_security';
        try {
            $response = $this->make_call($url);
            $meetingsecurity = $response->meeting_security;
        } catch (moodle_exception $error) {
            // Only available for Paid account, return default settings.
            $meetingsecurity = new stdClass();
            // If some other error, show debug message.
            if (isset($error->zoomerrorcode) && $error->zoomerrorcode != 200) {
                debugging($error->getMessage());
            }
        }

        // Set a default meeting password requirment if it is not present.
        if (!isset($meetingsecurity->meeting_password_requirement)) {
              $meetingsecurity->meeting_password_requirement = (object) self::DEFAULT_MEETING_PASSWORD_REQUIREMENT;
        }

        // Set a default encryption setting if it is not present.
        if (!isset($meetingsecurity->end_to_end_encrypted_meetings)) {
            $meetingsecurity->end_to_end_encrypted_meetings = false;
        }

        return $meetingsecurity;
    }

    /**
     * Gets a user.
     *
     * @param string|int $identifier The user's email or the user's ID per Zoom API.
     * @return stdClass|false If user is found, returns the User object. Otherwise, returns false.
     */
    public function get_user($identifier) {
        $founduser = false;

        // Classic: user:read:admin.
        // Granular: user:read:user:admin.
        $url = 'users/' . $identifier;

        try {
            $founduser = $this->make_call($url);
        } catch (webservice_exception $error) {
            if (zoom_is_user_not_found_error($error)) {
                return false;
            } else {
                throw $error;
            }
        }

        return $founduser;
    }

    /**
     * Gets a list of users that the given person can schedule meetings for.
     *
     * @param string $identifier The user's email or the user's ID per Zoom API.
     * @return array|false If schedulers are returned array of {id,email} objects. Otherwise returns false.
     */
    public function get_schedule_for_users($identifier) {
        // Classic: user:read:admin.
        // Granular: user:read:list_schedulers:admin.
        $url = "users/{$identifier}/schedulers";

        $schedulerswithoutkey = [];
        $schedulers = [];
        try {
            $response = $this->make_call($url);
            if (is_array($response->schedulers)) {
                $schedulerswithoutkey = $response->schedulers;
            }

            foreach ($schedulerswithoutkey as $s) {
                $schedulers[$s->id] = $s;
            }
        } catch (moodle_exception $error) {
            // We don't care if this throws an exception.
            $schedulers = [];
        }

        return $schedulers;
    }

    /**
     * Converts a zoom object from database format to API format.
     *
     * The database and the API use different fields and formats for the same information. This function changes the
     * database fields to the appropriate API request fields.
     *
     * @param stdClass $zoom The zoom meeting to format.
     * @return array The formatted meetings for the meeting.
     */
    private function database_to_api($zoom) {
        global $CFG;

        $data = [
            'topic' => $zoom->name,
            'settings' => [
                'host_video' => (bool) ($zoom->option_host_video),
                'audio' => $zoom->option_audio,
            ],
        ];
        if (isset($zoom->intro)) {
            $data['agenda'] = content_to_text($zoom->intro, FORMAT_MOODLE);
        }

        if (isset($CFG->timezone) && !empty($CFG->timezone)) {
            $data['timezone'] = $CFG->timezone;
        } else {
            $data['timezone'] = date_default_timezone_get();
        }

        if (isset($zoom->password)) {
            $data['password'] = $zoom->password;
        }

        if (isset($zoom->schedule_for)) {
            $data['schedule_for'] = $zoom->schedule_for;
        }

        if (isset($zoom->alternative_hosts)) {
            $data['settings']['alternative_hosts'] = $zoom->alternative_hosts;
        }

        if (isset($zoom->option_authenticated_users)) {
            $data['settings']['meeting_authentication'] = (bool) $zoom->option_authenticated_users;
        }

        if (isset($zoom->registration)) {
            $data['settings']['approval_type'] = $zoom->registration;
        }

        if (!empty($zoom->webinar)) {
            if ($zoom->recurring) {
                if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_NOTIME) {
                    $data['type'] = ZOOM_RECURRING_WEBINAR;
                } else {
                    $data['type'] = ZOOM_RECURRING_FIXED_WEBINAR;
                }
            } else {
                $data['type'] = ZOOM_SCHEDULED_WEBINAR;
            }
        } else {
            if ($zoom->recurring) {
                if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_NOTIME) {
                    $data['type'] = ZOOM_RECURRING_MEETING;
                } else {
                    $data['type'] = ZOOM_RECURRING_FIXED_MEETING;
                }
            } else {
                $data['type'] = ZOOM_SCHEDULED_MEETING;
            }
        }

        if (!empty($zoom->option_auto_recording)) {
            $data['settings']['auto_recording'] = $zoom->option_auto_recording;
        } else {
            $recordingoption = get_config('zoom', 'recordingoption');
            if ($recordingoption === ZOOM_AUTORECORDING_USERDEFAULT) {
                if (isset($zoom->schedule_for)) {
                    $zoomuser = zoom_get_user($zoom->schedule_for);
                    $zoomuserid = $zoomuser->id;
                } else {
                    $zoomuserid = zoom_get_user_id();
                }

                $autorecording = zoom_get_user_settings($zoomuserid)->recording->auto_recording;
                $data['settings']['auto_recording'] = $autorecording;
            } else {
                $data['settings']['auto_recording'] = $recordingoption;
            }
        }

        // Add fields which are effective for meetings only, but not for webinars.
        if (empty($zoom->webinar)) {
            $data['settings']['participant_video'] = (bool) ($zoom->option_participants_video);
            $data['settings']['join_before_host'] = (bool) ($zoom->option_jbh);
            $data['settings']['encryption_type'] = (isset($zoom->option_encryption_type) &&
                    $zoom->option_encryption_type === ZOOM_ENCRYPTION_TYPE_E2EE) ?
                    ZOOM_ENCRYPTION_TYPE_E2EE : ZOOM_ENCRYPTION_TYPE_ENHANCED;
            $data['settings']['waiting_room'] = (bool) ($zoom->option_waiting_room);
            $data['settings']['mute_upon_entry'] = (bool) ($zoom->option_mute_upon_entry);
        }

        // Add recurrence object.
        if ($zoom->recurring && $zoom->recurrence_type != ZOOM_RECURRINGTYPE_NOTIME) {
            $data['recurrence']['type'] = (int) $zoom->recurrence_type;
            $data['recurrence']['repeat_interval'] = (int) $zoom->repeat_interval;
            if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_WEEKLY) {
                $data['recurrence']['weekly_days'] = $zoom->weekly_days;
            }

            if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_MONTHLY) {
                if ($zoom->monthly_repeat_option == ZOOM_MONTHLY_REPEAT_OPTION_DAY) {
                    $data['recurrence']['monthly_day'] = (int) $zoom->monthly_day;
                } else {
                    $data['recurrence']['monthly_week'] = (int) $zoom->monthly_week;
                    $data['recurrence']['monthly_week_day'] = (int) $zoom->monthly_week_day;
                }
            }

            if ($zoom->end_date_option == ZOOM_END_DATE_OPTION_AFTER) {
                $data['recurrence']['end_times'] = (int) $zoom->end_times;
            } else {
                $data['recurrence']['end_date_time'] = gmdate('Y-m-d\TH:i:s\Z', $zoom->end_date_time);
            }
        }

        if (
            $data['type'] === ZOOM_SCHEDULED_MEETING ||
            $data['type'] === ZOOM_RECURRING_FIXED_MEETING ||
            $data['type'] === ZOOM_SCHEDULED_WEBINAR ||
            $data['type'] === ZOOM_RECURRING_FIXED_WEBINAR
        ) {
            // Convert timestamp to ISO-8601. The API seems to insist that it end with 'Z' to indicate UTC.
            $data['start_time'] = gmdate('Y-m-d\TH:i:s\Z', $zoom->start_time);
            $data['duration'] = (int) ceil($zoom->duration / 60);
        }

        // Add tracking field to data.
        $defaulttrackingfields = zoom_clean_tracking_fields();
        $tfarray = [];
        foreach ($defaulttrackingfields as $key => $defaulttrackingfield) {
            if (isset($zoom->$key)) {
                $tf = new stdClass();
                $tf->field = $defaulttrackingfield;
                $tf->value = $zoom->$key;
                $tfarray[] = $tf;
            }
        }

        $data['tracking_fields'] = $tfarray;

        if (isset($zoom->breakoutrooms)) {
            $breakoutroom = ['enable' => true, 'rooms' => $zoom->breakoutrooms];
            $data['settings']['breakout_room'] = $breakoutroom;
        }

        return $data;
    }

    /**
     * Provide a user with a license if needed and recycling is enabled.
     *
     * @param stdClass $zoomuserid The Zoom user to upgrade.
     * @return void
     */
    public function provide_license($zoomuserid) {
        // Checks whether we need to recycle licenses and acts accordingly.
        // Classic: user:read:admin.
        // Granular: user:read:user:admin.
        if ($this->recyclelicenses && $this->make_call("users/$zoomuserid")->type == ZOOM_USER_TYPE_BASIC) {
            if ($this->paid_user_limit_reached()) {
                $leastrecentlyactivepaiduserid = $this->get_least_recently_active_paid_user_id();
                // Changes least_recently_active_user to a basic user so we can use their license.
                $this->make_call("users/$leastrecentlyactivepaiduserid", ['type' => ZOOM_USER_TYPE_BASIC], 'patch');
            }

            // Changes current user to pro so they can make a meeting.
            // Classic: user:write:admin.
            // Granular: user:update:user:admin.
            $this->make_call("users/$zoomuserid", ['type' => ZOOM_USER_TYPE_PRO], 'patch');
        }
    }

    /**
     * Create a meeting/webinar on Zoom.
     * Take a $zoom object as returned from the Moodle form and respond with an object that can be saved to the database.
     *
     * @param stdClass $zoom The meeting to create.
     * @return stdClass The call response.
     */
    public function create_meeting($zoom) {
        // Provide license if needed.
        $this->provide_license($zoom->host_id);

        // Classic: meeting:write:admin.
        // Granular: meeting:write:meeting:admin.
        // Classic: webinar:write:admin.
        // Granular: webinar:write:webinar:admin.
        $url = "users/$zoom->host_id/" . (!empty($zoom->webinar) ? 'webinars' : 'meetings');
        return $this->make_call($url, $this->database_to_api($zoom), 'post');
    }

    /**
     * Update a meeting/webinar on Zoom.
     *
     * @param stdClass $zoom The meeting to update.
     * @return void
     */
    public function update_meeting($zoom) {
        // Classic: meeting:write:admin.
        // Granular: meeting:update:meeting:admin.
        // Classic: webinar:write:admin.
        // Granular: webinar:update:webinar:admin.
        $url = ($zoom->webinar ? 'webinars/' : 'meetings/') . $zoom->meeting_id;
        $this->make_call($url, $this->database_to_api($zoom), 'patch');
    }

    /**
     * Delete a meeting or webinar on Zoom.
     *
     * @param int $id The meeting_id or webinar_id of the meeting or webinar to delete.
     * @param bool $webinar Whether the meeting or webinar you want to delete is a webinar.
     * @return void
     */
    public function delete_meeting($id, $webinar) {
        // Classic: meeting:write:admin.
        // Granular: meeting:delete:meeting:admin.
        // Classic: webinar:write:admin.
        // Granular: webinar:delete:webinar:admin.
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '?schedule_for_reminder=false';
        $this->make_call($url, null, 'delete');
    }

    /**
     * Get a meeting or webinar's information from Zoom.
     *
     * @param int $id The meeting_id or webinar_id of the meeting or webinar to retrieve.
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
     * @return stdClass The meeting's or webinar's information.
     */
    public function get_meeting_webinar_info($id, $webinar) {
        // Classic: meeting:read:admin.
        // Granular: meeting:read:meeting:admin.
        // Classic: webinar:read:admin.
        // Granular: webinar:read:webinar:admin.
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id;
        $response = $this->make_call($url);
        return $response;
    }

    /**
     * Get the meeting invite note that was sent for a specific meeting from Zoom.
     *
     * @param stdClass $zoom The zoom meeting
     * @return \mod_zoom\invitation The meeting's invitation.
     */
    public function get_meeting_invitation($zoom) {
        global $CFG;
        require_once($CFG->dirroot . '/mod/zoom/classes/invitation.php');

        // Webinar does not have meeting invite info.
        if ($zoom->webinar) {
            return new invitation(null);
        }

        // Classic: meeting:read:admin.
        // Granular: meeting:read:invitation:admin.
        $url = 'meetings/' . $zoom->meeting_id . '/invitation';

        try {
            $response = $this->make_call($url);
        } catch (moodle_exception $error) {
            debugging($error->getMessage());
            return new invitation(null);
        }

        return new invitation($response->invitation);
    }

    /**
     * Retrieve ended meetings report for a specified user and period. Handles multiple pages.
     *
     * @param string $userid Id of user of interest
     * @param string $from Start date of period in the form YYYY-MM-DD
     * @param string $to End date of period in the form YYYY-MM-DD
     * @return array The retrieved meetings.
     */
    public function get_user_report($userid, $from, $to) {
        // Classic: report:read:admin.
        // Granular: report:read:user:admin.
        $url = 'report/users/' . $userid . '/meetings';
        $data = ['from' => $from, 'to' => $to];
        return $this->make_paginated_call($url, $data, 'meetings');
    }

    /**
     * List all meeting or webinar information for a user.
     *
     * @param string $userid The user whose meetings or webinars to retrieve.
     * @param boolean $webinar Whether to list meetings or to list webinars.
     * @return array An array of meeting information.
     * @deprecated Has never been used by internal code.
     */
    public function list_meetings($userid, $webinar) {
        // Classic: meeting:read:admin.
        // Granular: meeting:read:list_meetings:admin.
        // Classic: webinar:read:admin.
        // Granular: webinar:read:list_webinars:admin.
        $url = 'users/' . $userid . ($webinar ? '/webinars' : '/meetings');
        $instances = $this->make_paginated_call($url, [], ($webinar ? 'webinars' : 'meetings'));
        return $instances;
    }

    /**
     * Get the participants who attended a meeting
     * @param string $meetinguuid The meeting or webinar's UUID.
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
     * @return array An array of meeting participant objects.
     */
    public function get_meeting_participants($meetinguuid, $webinar) {
        $meetinguuid = $this->encode_uuid($meetinguuid);

        $meetingtype = ($webinar ? 'webinars' : 'meetings');
        $meetingtypesingular = ($webinar ? 'webinar' : 'meeting');

        $reportscopes = [
            // Classic.
            'report:read:admin',

            // Granular.
            'report:read:list_' . $meetingtypesingular . '_participants:admin',
        ];

        $dashboardscopes = [
            // Classic.
            'dashboard_' . $meetingtype . ':read:admin',

            // Granular.
            'dashboard:read:list_' . $meetingtypesingular . '_participants:admin',
        ];

        if ($this->has_scope($reportscopes)) {
            $apitype = 'report';
        } else if ($this->has_scope($dashboardscopes)) {
            $apitype = 'metrics';
        } else {
            mtrace('Missing OAuth scopes required for reports.');
            return [];
        }

        $url = $apitype . '/' . $meetingtype . '/' . $meetinguuid . '/participants';
        return $this->make_paginated_call($url, [], 'participants');
    }

    /**
     * Retrieve the UUIDs of hosts that were active in the last 30 days.
     *
     * @param int $from The time to start the query from, in Unix timestamp format.
     * @param int $to The time to end the query at, in Unix timestamp format.
     * @return array An array of UUIDs.
     */
    public function get_active_hosts_uuids($from, $to) {
        // Classic: report:read:admin.
        // Granular: report:read:list_users:admin.
        $users = $this->make_paginated_call('report/users', ['type' => 'active', 'from' => $from, 'to' => $to], 'users');
        $uuids = [];
        foreach ($users as $user) {
            $uuids[] = $user->id;
        }

        return $uuids;
    }

    /**
     * Retrieve past meetings that occurred in specified time period.
     *
     * Ignores meetings that were attended only by one user.
     *
     * NOTE: Requires Business or a higher plan and have "Dashboard" feature
     * enabled. This query is rated "Resource-intensive"
     *
     * @param int $from Start date in YYYY-MM-DD format.
     * @param int $to End date in YYYY-MM-DD format.
     * @return array An array of meeting objects.
     */
    public function get_meetings($from, $to) {
        // Classic: dashboard_meetings:read:admin.
        // Granular: dashboard:read:list_meetings:admin.
        return $this->make_paginated_call(
            'metrics/meetings',
            [
                'type' => 'past',
                'from' => $from,
                'to' => $to,
                'query_date_type' => 'end_time',
            ],
            'meetings'
        );
    }

    /**
     * Retrieve past meetings that occurred in specified time period.
     *
     * Ignores meetings that were attended only by one user.
     *
     * NOTE: Requires Business or a higher plan and have "Dashboard" feature
     * enabled. This query is rated "Resource-intensive"
     *
     * @param int $from Start date in YYYY-MM-DD format.
     * @param int $to End date in YYYY-MM-DD format.
     * @return array An array of meeting objects.
     */
    public function get_webinars($from, $to) {
        // Classic: dashboard_webinars:read:admin.
        // Granular: dashboard:read:list_webinars:admin.
        return $this->make_paginated_call('metrics/webinars', ['type' => 'past', 'from' => $from, 'to' => $to], 'webinars');
    }

    /**
     * Lists tracking fields configured on the account.
     *
     * @return ?stdClass The call's result in JSON format.
     */
    public function list_tracking_fields() {
        $response = null;
        try {
            // Classic: tracking_fields:read:admin.
            // Granular: Not yet implemented by Zoom.
            $response = $this->make_call('tracking_fields');
        } catch (moodle_exception $error) {
            debugging($error->getMessage());
        }

        return $response;
    }

    /**
     * If the UUID begins with a ‘/’ or contains ‘//’ in it we need to double encode it when using it for API calls.
     *
     * See https://devforum.zoom.us/t/cant-retrieve-data-when-meeting-uuid-contains-double-slash/2776
     *
     * @param string $uuid
     * @return string
     */
    public function encode_uuid($uuid) {
        if (substr($uuid, 0, 1) === '/' || strpos($uuid, '//') !== false) {
            // Use similar function to JS encodeURIComponent, see https://stackoverflow.com/a/1734255/6001.
            $encodeuricomponent = function ($str) {
                $revert = ['%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')'];
                return strtr(rawurlencode($str), $revert);
            };
            $uuid = $encodeuricomponent($encodeuricomponent($uuid));
        }

        return $uuid;
    }

    /**
     * Returns the download URLs and recording types for the cloud recording if one exists on zoom for a particular meeting id.
     * There can be more than one url for the same meeting if the host stops the recording in the middle
     * of the meeting and then starts recording again without ending the meeting.
     *
     * @param string $meetingid The string meeting UUID.
     * @return array Returns the list of recording URLs and the type of recording that is being sent back.
     */
    public function get_recording_url_list($meetingid) {
        $recordings = [];

        // Only pick the video recording and audio only recordings.
        // The transcript is available in both of these, so the extra file is unnecessary.
        $allowedrecordingtypes = [
            'MP4' => 'video',
            'M4A' => 'audio',
            'TRANSCRIPT' => 'transcript',
            'CHAT' => 'chat',
            'CC' => 'captions',
        ];

        try {
            // Classic: recording:read:admin.
            // Granular: cloud_recording:read:list_recording_files:admin.
            $url = 'meetings/' . $this->encode_uuid($meetingid) . '/recordings';
            $response = $this->make_call($url);

            if (!empty($response->recording_files)) {
                foreach ($response->recording_files as $recording) {
                    $url = $recording->play_url ?? $recording->download_url ?? null;
                    if (!empty($url) && isset($allowedrecordingtypes[$recording->file_type])) {
                        $recordinginfo = new stdClass();
                        $recordinginfo->recordingid = $recording->id;
                        $recordinginfo->meetinguuid = $response->uuid;
                        $recordinginfo->url = $url;
                        $recordinginfo->filetype = $recording->file_type;
                        $recordinginfo->recordingtype = $recording->recording_type;
                        $recordinginfo->passcode = $response->password;
                        $recordinginfo->recordingstart = strtotime($recording->recording_start);

                        $recordings[$recording->id] = $recordinginfo;
                    }
                }
            }
        } catch (moodle_exception $error) {
            // No recordings found for this meeting id.
            $recordings = [];
        }

        return $recordings;
    }

    /**
     * Retrieve recordings for a specified user and period. Handles multiple pages.
     *
     * @param string $userid User ID.
     * @param string $from Start date of period in the form YYYY-MM-DD
     * @param string $to End date of period in the form YYYY-MM-DD
     * @return array
     */
    public function get_user_recordings($userid, $from, $to) {
        $recordings = [];

        // Only pick the video recording and audio only recordings.
        // The transcript is available in both of these, so the extra file is unnecessary.
        $allowedrecordingtypes = [
            'MP4' => 'video',
            'M4A' => 'audio',
            'TRANSCRIPT' => 'transcript',
            'CHAT' => 'chat',
            'CC' => 'captions',
        ];

        try {
            // Classic: recording:read:admin.
            // Granular: cloud_recording:read:list_user_recordings:admin.
            $url = 'users/' . $userid . '/recordings';
            $data = ['from' => $from, 'to' => $to];
            $response = $this->make_paginated_call($url, $data, 'meetings');

            foreach ($response as $meeting) {
                foreach ($meeting->recording_files as $recording) {
                    $url = $recording->play_url ?? $recording->download_url ?? null;
                    if (!empty($url) && isset($allowedrecordingtypes[$recording->file_type])) {
                        $recordinginfo = new stdClass();
                        $recordinginfo->recordingid = $recording->id;
                        $recordinginfo->meetingid = $meeting->id;
                        $recordinginfo->meetinguuid = $meeting->uuid;
                        $recordinginfo->url = $url;
                        $recordinginfo->filetype = $recording->file_type;
                        $recordinginfo->recordingtype = $recording->recording_type;
                        $recordinginfo->recordingstart = strtotime($recording->recording_start);

                        $recordings[$recording->id] = $recordinginfo;
                    }
                }
            }
        } catch (moodle_exception $error) {
            // No recordings found for this user.
            $recordings = [];
        }

        return $recordings;
    }

    /**
     * Returns a server to server oauth access token, good for 1 hour.
     *
     * @throws moodle_exception
     * @return string access token
     */
    protected function get_access_token() {
        $cache = cache::make('mod_zoom', 'oauth');
        $token = $cache->get('accesstoken');
        $expires = $cache->get('expires');
        if (empty($token) || empty($expires) || time() >= $expires) {
            $token = $this->oauth($cache);
        } else {
            $this->scopes = $cache->get('scopes');
        }

        return $token;
    }

    /**
     * Has one of the required OAuth scopes been granted?
     *
     * @param array $scopes OAuth scopes.
     * @throws moodle_exception
     * @return bool
     */
    public function has_scope($scopes) {
        if (!isset($this->scopes)) {
            $this->get_access_token();
        }

        mtrace('checking has_scope(' . implode(' || ', $scopes) . ')');

        $matchingscopes = \array_intersect($scopes, $this->scopes);
        return !empty($matchingscopes);
    }

    /**
     * Stores token and expiration in cache, returns token from OAuth call.
     *
     * @param cache $cache
     * @throws moodle_exception
     * @return string access token
     */
    private function oauth($cache) {
        $curl = $this->get_curl_object();
        $curl->setHeader('Authorization: Basic ' . base64_encode($this->clientid . ':' . $this->clientsecret));
        $curl->setHeader('Accept: application/json');

        // Force HTTP/1.1 to avoid HTTP/2 "stream not closed" issue.
        $curl->setopt([
            'CURLOPT_HTTP_VERSION' => \CURL_HTTP_VERSION_1_1,
        ]);

        $timecalled = time();
        $data = [
            'grant_type' => 'account_credentials',
            'account_id' => $this->accountid,
        ];
        $response = $this->make_curl_call($curl, 'post', 'https://zoom.us/oauth/token', $data);

        if ($curl->get_errno()) {
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $curl->error);
        }

        $response = json_decode($response);

        if (empty($response->access_token)) {
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', get_string('zoomerr_no_access_token', 'mod_zoom'));
        }

        $scopes = explode(' ', $response->scope);

        // Assume that we are using granular scopes.
        $requiredscopes = [
            'meeting:read:meeting:admin',
            'meeting:read:invitation:admin',
            'meeting:delete:meeting:admin',
            'meeting:update:meeting:admin',
            'meeting:write:meeting:admin',
            'user:read:list_schedulers:admin',
            'user:read:settings:admin',
            'user:read:user:admin',
        ];

        // Check if we received classic scopes.
        if (in_array('meeting:read:admin', $scopes, true)) {
            $requiredscopes = [
                'meeting:read:admin',
                'meeting:write:admin',
                'user:read:admin',
            ];
        }

        $missingscopes = array_diff($requiredscopes, $scopes);

        // Keep the scope information in memory.
        $this->scopes = $scopes;

        if (!empty($missingscopes)) {
            $missingscopes = implode(', ', $missingscopes);
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', get_string('zoomerr_scopes', 'mod_zoom', $missingscopes));
        }

        $token = $response->access_token;

        if (isset($response->expires_in)) {
            $expires = $response->expires_in + $timecalled;
        } else {
            $expires = 3599 + $timecalled;
        }

        $cache->set_many([
            'accesstoken' => $token,
            'expires' => $expires,
            'scopes' => $scopes,
        ]);

        return $token;
    }

    /**
     * List the meeting or webinar registrants from Zoom.
     *
     * @param string $id The meeting_id or webinar_id of the meeting or webinar to retrieve.
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
     * @return stdClass The meeting's or webinar's information.
     */
    public function get_meeting_registrants($id, $webinar) {
        // Classic: meeting:read:admin.
        // Granular: meeting:read:list_registrants:admin.
        // Classic: webinar:read:admin.
        // Granular: webinar:read:list_registrants:admin.
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '/registrants';
        $response = $this->make_call($url);
        return $response;
    }

    /**
     * Get the recording settings for a meeting.
     *
     * @param string $meetinguuid The UUID of a meeting with recordings.
     * @return stdClass The meeting's recording settings.
     */
    public function get_recording_settings($meetinguuid) {
        // Classic: recording:read:admin.
        // Granular: cloud_recording:read:recording_settings:admin.
        $url = 'meetings/' . $this->encode_uuid($meetinguuid) . '/recordings/settings';
        $response = $this->make_call($url);
        return $response;
    }
}