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;
}
}