Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of the Zoom plugin for Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Handles API calls to Zoom REST API.
19
 *
20
 * @package   mod_zoom
21
 * @copyright 2015 UC Regents
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace mod_zoom;
26
 
27
defined('MOODLE_INTERNAL') || die();
28
 
29
require_once($CFG->dirroot . '/mod/zoom/locallib.php');
30
require_once($CFG->libdir . '/filelib.php');
31
 
32
use cache;
33
use core_user;
34
use curl;
35
use moodle_exception;
36
use stdClass;
37
 
38
/**
39
 * Web service class.
40
 */
41
class webservice {
42
    /**
43
     * API calls: maximum number of retries.
44
     * @var int
45
     */
46
    public const MAX_RETRIES = 5;
47
 
48
    /**
49
     * Default meeting_password_requirement object.
50
     * @var array
51
     */
52
    public const DEFAULT_MEETING_PASSWORD_REQUIREMENT = [
53
        'length' => 0,
54
        'consecutive_characters_length' => 0,
55
        'have_letter' => false,
56
        'have_number' => false,
57
        'have_upper_and_lower_characters' => false,
58
        'have_special_character' => false,
59
        'only_allow_numeric' => false,
60
        'weak_enhance_detection' => false,
61
    ];
62
 
63
    /**
64
     * Client ID
65
     * @var string
66
     */
67
    protected $clientid;
68
 
69
    /**
70
     * Client secret
71
     * @var string
72
     */
73
    protected $clientsecret;
74
 
75
    /**
76
     * Account ID
77
     * @var string
78
     */
79
    protected $accountid;
80
 
81
    /**
82
     * API base URL.
83
     * @var string
84
     */
85
    protected $apiurl;
86
 
87
    /**
88
     * Whether to recycle licenses.
89
     * @var bool
90
     */
91
    protected $recyclelicenses;
92
 
93
    /**
94
     * Whether to check instance users
95
     * @var bool
96
     */
97
    protected $instanceusers;
98
 
99
    /**
100
     * Maximum limit of paid users
101
     * @var int
102
     */
103
    protected $numlicenses;
104
 
105
    /**
106
     * List of users
107
     * @var array
108
     */
109
    protected static $userslist;
110
 
111
    /**
112
     * Number of retries we've made for make_call
113
     * @var int
114
     */
115
    protected $makecallretries = 0;
116
 
117
    /**
118
     * Granted OAuth scopes
119
     * @var array
120
     */
121
    protected $scopes;
122
 
123
    /**
124
     * The constructor for the webservice class.
125
     * @throws moodle_exception Moodle exception is thrown for missing config settings.
126
     */
127
    public function __construct() {
128
        $config = get_config('zoom');
129
 
130
        $requiredfields = [
131
            'clientid',
132
            'clientsecret',
133
            'accountid',
134
        ];
135
 
136
        try {
137
            // Get and remember each required field.
138
            foreach ($requiredfields as $requiredfield) {
139
                if (!empty($config->$requiredfield)) {
140
                    $this->$requiredfield = $config->$requiredfield;
141
                } else {
142
                    throw new moodle_exception('zoomerr_field_missing', 'mod_zoom', '', get_string($requiredfield, 'mod_zoom'));
143
                }
144
            }
145
 
146
            // Get and remember the API URL.
147
            $this->apiurl = zoom_get_api_url();
148
 
149
            // Get and remember the plugin settings to recycle licenses.
150
            if (!empty($config->utmost)) {
151
                $this->recyclelicenses = $config->utmost;
152
                $this->instanceusers = !empty($config->instanceusers);
153
            }
154
 
155
            if ($this->recyclelicenses) {
156
                if (!empty($config->licensesnumber)) {
157
                    $this->numlicenses = $config->licensesnumber;
158
                } else {
159
                    throw new moodle_exception('zoomerr_licensesnumber_missing', 'mod_zoom');
160
                }
161
            }
162
        } catch (moodle_exception $exception) {
163
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $exception->getMessage());
164
        }
165
    }
166
 
167
    /**
168
     * Makes the call to curl using the specified method, url, and parameter data.
169
     * This has been moved out of make_call to make unit testing possible.
170
     *
171
     * @param \curl $curl The curl object used to make the request.
172
     * @param string $method The HTTP method to use.
173
     * @param string $url The URL to append to the API URL
174
     * @param array|string $data The data to attach to the call.
175
     * @return stdClass The call's result.
176
     */
177
    protected function make_curl_call(&$curl, $method, $url, $data) {
178
        return $curl->$method($url, $data);
179
    }
180
 
181
    /**
182
     * Gets a curl object in order to make API calls. This function was created
183
     * to enable unit testing for the webservice class.
184
     * @return curl The curl object used to make the API calls
185
     */
186
    protected function get_curl_object() {
187
        global $CFG;
188
 
189
        $proxyhost = get_config('zoom', 'proxyhost');
190
 
191
        if (!empty($proxyhost)) {
192
            $cfg = new stdClass();
193
            $cfg->proxyhost = $CFG->proxyhost;
194
            $cfg->proxyport = $CFG->proxyport;
195
            $cfg->proxyuser = $CFG->proxyuser;
196
            $cfg->proxypassword = $CFG->proxypassword;
197
            $cfg->proxytype = $CFG->proxytype;
198
 
199
            // Parse string as host:port, delimited by a colon (:).
200
            [$host, $port] = explode(':', $proxyhost);
201
 
202
            // Temporarily set new values on the global $CFG.
203
            $CFG->proxyhost = $host;
204
            $CFG->proxyport = $port;
205
            $CFG->proxytype = 'HTTP';
206
            $CFG->proxyuser = '';
207
            $CFG->proxypassword = '';
208
        }
209
 
210
        // Create $curl, which implicitly uses the proxy settings from $CFG.
211
        $curl = new curl();
212
 
213
        if (!empty($proxyhost)) {
214
            // Restore the stored global proxy settings from above.
215
            $CFG->proxyhost = $cfg->proxyhost;
216
            $CFG->proxyport = $cfg->proxyport;
217
            $CFG->proxyuser = $cfg->proxyuser;
218
            $CFG->proxypassword = $cfg->proxypassword;
219
            $CFG->proxytype = $cfg->proxytype;
220
        }
221
 
222
        return $curl;
223
    }
224
 
225
    /**
226
     * Makes a REST call.
227
     *
228
     * @param string $path The path to append to the API URL
229
     * @param array|string $data The data to attach to the call.
230
     * @param string $method The HTTP method to use.
231
     * @return stdClass The call's result in JSON format.
232
     * @throws moodle_exception Moodle exception is thrown for curl errors.
233
     */
234
    private function make_call($path, $data = [], $method = 'get') {
235
        $url = $this->apiurl . $path;
236
        $method = strtolower($method);
237
 
238
        $token = $this->get_access_token();
239
 
240
        $curl = $this->get_curl_object();
241
        $curl->setHeader('Authorization: Bearer ' . $token);
242
        $curl->setHeader('Accept: application/json');
243
 
244
        if ($method != 'get') {
245
            $curl->setHeader('Content-Type: application/json');
246
            $data = is_array($data) ? json_encode($data) : $data;
247
        }
248
 
249
        $attempts = 0;
250
        do {
251
            if ($attempts > 0) {
252
                sleep(1);
253
                debugging('retrying after curl error 35, retry attempt ' . $attempts);
254
            }
255
 
256
            $rawresponse = $this->make_curl_call($curl, $method, $url, $data);
257
            $attempts++;
258
        } while ($curl->get_errno() === 35 && $attempts <= self::MAX_RETRIES);
259
 
260
        if ($curl->get_errno()) {
261
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $curl->error);
262
        }
263
 
264
        $response = json_decode($rawresponse);
265
 
266
        $httpstatus = $curl->get_info()['http_code'];
267
 
268
        if ($httpstatus >= 400) {
269
            switch ($httpstatus) {
270
                case 400:
271
                    $errorstring = '';
272
                    if (!empty($response->errors)) {
273
                        foreach ($response->errors as $error) {
274
                            $errorstring .= ' ' . $error->message;
275
                        }
276
                    }
277
                    throw new bad_request_exception($response->message . $errorstring, $response->code);
278
 
279
                case 404:
280
                    throw new not_found_exception($response->message, $response->code);
281
 
282
                case 429:
283
                    $this->makecallretries += 1;
284
                    if ($this->makecallretries > self::MAX_RETRIES) {
285
                        throw new retry_failed_exception($response->message, $response->code);
286
                    }
287
 
288
                    $header = $curl->getResponse();
289
                    // Header can have mixed case, normalize it.
290
                    $header = array_change_key_case($header, CASE_LOWER);
291
 
292
                    // Default to 1 second for max requests per second limit.
293
                    $timediff = 1;
294
 
295
                    // Check if we hit the max requests per minute (only for Dashboard API).
296
                    if (
297
                        array_key_exists('x-ratelimit-type', $header) &&
298
                        $header['x-ratelimit-type'] == 'QPS' &&
299
                        strpos($path, 'metrics') !== false
300
                    ) {
301
                        $timediff = 60; // Try the next minute.
302
                    } else if (array_key_exists('retry-after', $header)) {
303
                        $retryafter = strtotime($header['retry-after']);
304
                        $timediff = $retryafter - time();
305
                        // If we have no API calls remaining, save retry-after.
306
                        if ($header['x-ratelimit-remaining'] == 0 && !empty($retryafter)) {
307
                            set_config('retry-after', $retryafter, 'zoom');
308
                            throw new api_limit_exception($response->message, $response->code, $retryafter);
309
                        } else if (!(defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
310
                            // When running CLI we might want to know how many calls remaining.
311
                            debugging('x-ratelimit-remaining = ' . $header['x-ratelimit-remaining']);
312
                        }
313
                    }
314
 
315
                    debugging('Received 429 response, sleeping ' . strval($timediff) .
316
                            ' seconds until next retry. Current retry: ' . $this->makecallretries);
317
                    if ($timediff > 0 && !(defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
318
                        sleep($timediff);
319
                    }
320
                    return $this->make_call($path, $data, $method);
321
 
322
                default:
323
                    if ($response) {
324
                        throw new webservice_exception(
325
                            $response->message,
326
                            $response->code,
327
                            'errorwebservice',
328
                            'mod_zoom',
329
                            '',
330
                            $response->message
331
                        );
332
                    } else {
333
                        throw new moodle_exception('errorwebservice', 'mod_zoom', '', "HTTP Status $httpstatus");
334
                    }
335
            }
336
        }
337
 
338
        $this->makecallretries = 0;
339
 
340
        return $response;
341
    }
342
 
343
    /**
344
     * Makes a paginated REST call.
345
     * Makes a call like make_call() but specifically for GETs with paginated results.
346
     *
347
     * @param string $url The URL to append to the API URL
348
     * @param array $data The data to attach to the call.
349
     * @param string $datatoget The name of the array of the data to get.
350
     * @return array The retrieved data.
351
     * @see make_call()
352
     */
353
    private function make_paginated_call($url, $data, $datatoget) {
354
        $aggregatedata = [];
355
        $data['page_size'] = ZOOM_MAX_RECORDS_PER_CALL;
356
        do {
357
            $callresult = null;
358
            $moredata = false;
359
            $callresult = $this->make_call($url, $data);
360
 
361
            if ($callresult) {
362
                $aggregatedata = array_merge($aggregatedata, $callresult->$datatoget);
363
                if (!empty($callresult->next_page_token)) {
364
                    $data['next_page_token'] = $callresult->next_page_token;
365
                    $moredata = true;
366
                } else if (!empty($callresult->page_number) && $callresult->page_number < $callresult->page_count) {
367
                    $data['page_number'] = $callresult->page_number + 1;
368
                    $moredata = true;
369
                }
370
            }
371
        } while ($moredata);
372
 
373
        return $aggregatedata;
374
    }
375
 
376
    /**
377
     * Autocreate a user on Zoom.
378
     *
379
     * @param stdClass $user The user to create.
380
     * @return bool Whether the user was succesfully created.
381
     * @deprecated Has never been used by internal code.
382
     */
383
    public function autocreate_user($user) {
384
        // Classic: user:write:admin.
385
        // Granular: user:write:user:admin.
386
        $url = 'users';
387
        $data = ['action' => 'autocreate'];
388
        $data['user_info'] = [
389
            'email' => zoom_get_api_identifier($user),
390
            'type' => ZOOM_USER_TYPE_PRO,
391
            'first_name' => $user->firstname,
392
            'last_name' => $user->lastname,
393
            'password' => base64_encode(random_bytes(16)),
394
        ];
395
 
396
        try {
397
            $this->make_call($url, $data, 'post');
398
        } catch (moodle_exception $error) {
399
            // If the user already exists, the error will contain 'User already in the account'.
400
            if (strpos($error->getMessage(), 'User already in the account') === true) {
401
                return false;
402
            } else {
403
                throw $error;
404
            }
405
        }
406
 
407
        return true;
408
    }
409
 
410
    /**
411
     * Get users list.
412
     *
413
     * @return array An array of users.
414
     */
415
    public function list_users() {
416
        if (empty(self::$userslist)) {
417
            // Classic: user:read:admin.
418
            // Granular: user:read:list_users:admin.
419
            self::$userslist = $this->make_paginated_call('users', [], 'users');
420
        }
421
 
422
        return self::$userslist;
423
    }
424
 
425
    /**
426
     * Checks whether the paid user license limit has been reached.
427
     *
428
     * Incrementally retrieves the active paid users and compares against $numlicenses.
429
     * @see $numlicenses
430
     * @return bool Whether the paid user license limit has been reached.
431
     */
432
    private function paid_user_limit_reached() {
433
        $userslist = $this->list_users();
434
        $numusers = 0;
435
        foreach ($userslist as $user) {
436
            if ($user->type != ZOOM_USER_TYPE_BASIC) {
437
                // Count the user if we're including all users or if the user is on this instance.
438
                if (!$this->instanceusers || core_user::get_user_by_email($user->email)) {
439
                    $numusers++;
440
                    if ($numusers >= $this->numlicenses) {
441
                        return true;
442
                    }
443
                }
444
            }
445
        }
446
 
447
        return false;
448
    }
449
 
450
    /**
451
     * Gets the ID of the user, of all the paid users, with the oldest last login time.
452
     *
453
     * @return string|false If user is found, returns the User ID. Otherwise, returns false.
454
     */
455
    private function get_least_recently_active_paid_user_id() {
456
        $usertimes = [];
457
 
458
        // Classic: user:read:admin.
459
        // Granular: user:read:list_users:admin.
460
        $userslist = $this->list_users();
461
 
462
        foreach ($userslist as $user) {
463
            if ($user->type != ZOOM_USER_TYPE_BASIC && isset($user->last_login_time)) {
464
                // Count the user if we're including all users or if the user is on this instance.
465
                if (!$this->instanceusers || core_user::get_user_by_email($user->email)) {
466
                    $usertimes[$user->id] = strtotime($user->last_login_time);
467
                }
468
            }
469
        }
470
 
471
        if (!empty($usertimes)) {
472
            return array_search(min($usertimes), $usertimes);
473
        }
474
 
475
        return false;
476
    }
477
 
478
    /**
479
     * Gets a user's settings.
480
     *
481
     * @param string $userid The user's ID.
482
     * @return stdClass The call's result in JSON format.
483
     */
484
    public function get_user_settings($userid) {
485
        // Classic: user:read:admin.
486
        // Granular: user:read:settings:admin.
487
        return $this->make_call('users/' . $userid . '/settings');
488
    }
489
 
490
    /**
491
     * Gets the user's meeting security settings, including password requirements.
492
     *
493
     * @param string $userid The user's ID.
494
     * @return stdClass The call's result in JSON format.
495
     */
496
    public function get_account_meeting_security_settings($userid) {
497
        // Classic: user:read:admin.
498
        // Granular: user:read:settings:admin.
499
        $url = 'users/' . $userid . '/settings?option=meeting_security';
500
        try {
501
            $response = $this->make_call($url);
502
            $meetingsecurity = $response->meeting_security;
503
        } catch (moodle_exception $error) {
504
            // Only available for Paid account, return default settings.
505
            $meetingsecurity = new stdClass();
506
            // If some other error, show debug message.
507
            if (isset($error->zoomerrorcode) && $error->zoomerrorcode != 200) {
508
                debugging($error->getMessage());
509
            }
510
        }
511
 
512
        // Set a default meeting password requirment if it is not present.
513
        if (!isset($meetingsecurity->meeting_password_requirement)) {
514
              $meetingsecurity->meeting_password_requirement = (object) self::DEFAULT_MEETING_PASSWORD_REQUIREMENT;
515
        }
516
 
517
        // Set a default encryption setting if it is not present.
518
        if (!isset($meetingsecurity->end_to_end_encrypted_meetings)) {
519
            $meetingsecurity->end_to_end_encrypted_meetings = false;
520
        }
521
 
522
        return $meetingsecurity;
523
    }
524
 
525
    /**
526
     * Gets a user.
527
     *
528
     * @param string|int $identifier The user's email or the user's ID per Zoom API.
529
     * @return stdClass|false If user is found, returns the User object. Otherwise, returns false.
530
     */
531
    public function get_user($identifier) {
532
        $founduser = false;
533
 
534
        // Classic: user:read:admin.
535
        // Granular: user:read:user:admin.
536
        $url = 'users/' . $identifier;
537
 
538
        try {
539
            $founduser = $this->make_call($url);
540
        } catch (webservice_exception $error) {
541
            if (zoom_is_user_not_found_error($error)) {
542
                return false;
543
            } else {
544
                throw $error;
545
            }
546
        }
547
 
548
        return $founduser;
549
    }
550
 
551
    /**
552
     * Gets a list of users that the given person can schedule meetings for.
553
     *
554
     * @param string $identifier The user's email or the user's ID per Zoom API.
555
     * @return array|false If schedulers are returned array of {id,email} objects. Otherwise returns false.
556
     */
557
    public function get_schedule_for_users($identifier) {
558
        // Classic: user:read:admin.
559
        // Granular: user:read:list_schedulers:admin.
560
        $url = "users/{$identifier}/schedulers";
561
 
562
        $schedulerswithoutkey = [];
563
        $schedulers = [];
564
        try {
565
            $response = $this->make_call($url);
566
            if (is_array($response->schedulers)) {
567
                $schedulerswithoutkey = $response->schedulers;
568
            }
569
 
570
            foreach ($schedulerswithoutkey as $s) {
571
                $schedulers[$s->id] = $s;
572
            }
573
        } catch (moodle_exception $error) {
574
            // We don't care if this throws an exception.
575
            $schedulers = [];
576
        }
577
 
578
        return $schedulers;
579
    }
580
 
581
    /**
582
     * Converts a zoom object from database format to API format.
583
     *
584
     * The database and the API use different fields and formats for the same information. This function changes the
585
     * database fields to the appropriate API request fields.
586
     *
587
     * @param stdClass $zoom The zoom meeting to format.
588
     * @return array The formatted meetings for the meeting.
589
     */
590
    private function database_to_api($zoom) {
591
        global $CFG;
592
 
593
        $data = [
594
            'topic' => $zoom->name,
595
            'settings' => [
596
                'host_video' => (bool) ($zoom->option_host_video),
597
                'audio' => $zoom->option_audio,
598
            ],
599
        ];
600
        if (isset($zoom->intro)) {
601
            $data['agenda'] = content_to_text($zoom->intro, FORMAT_MOODLE);
602
        }
603
 
604
        if (isset($CFG->timezone) && !empty($CFG->timezone)) {
605
            $data['timezone'] = $CFG->timezone;
606
        } else {
607
            $data['timezone'] = date_default_timezone_get();
608
        }
609
 
610
        if (isset($zoom->password)) {
611
            $data['password'] = $zoom->password;
612
        }
613
 
614
        if (isset($zoom->schedule_for)) {
615
            $data['schedule_for'] = $zoom->schedule_for;
616
        }
617
 
618
        if (isset($zoom->alternative_hosts)) {
619
            $data['settings']['alternative_hosts'] = $zoom->alternative_hosts;
620
        }
621
 
622
        if (isset($zoom->option_authenticated_users)) {
623
            $data['settings']['meeting_authentication'] = (bool) $zoom->option_authenticated_users;
624
        }
625
 
626
        if (isset($zoom->registration)) {
627
            $data['settings']['approval_type'] = $zoom->registration;
628
        }
629
 
630
        if (!empty($zoom->webinar)) {
631
            if ($zoom->recurring) {
632
                if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_NOTIME) {
633
                    $data['type'] = ZOOM_RECURRING_WEBINAR;
634
                } else {
635
                    $data['type'] = ZOOM_RECURRING_FIXED_WEBINAR;
636
                }
637
            } else {
638
                $data['type'] = ZOOM_SCHEDULED_WEBINAR;
639
            }
640
        } else {
641
            if ($zoom->recurring) {
642
                if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_NOTIME) {
643
                    $data['type'] = ZOOM_RECURRING_MEETING;
644
                } else {
645
                    $data['type'] = ZOOM_RECURRING_FIXED_MEETING;
646
                }
647
            } else {
648
                $data['type'] = ZOOM_SCHEDULED_MEETING;
649
            }
650
        }
651
 
652
        if (!empty($zoom->option_auto_recording)) {
653
            $data['settings']['auto_recording'] = $zoom->option_auto_recording;
654
        } else {
655
            $recordingoption = get_config('zoom', 'recordingoption');
656
            if ($recordingoption === ZOOM_AUTORECORDING_USERDEFAULT) {
657
                if (isset($zoom->schedule_for)) {
658
                    $zoomuser = zoom_get_user($zoom->schedule_for);
659
                    $zoomuserid = $zoomuser->id;
660
                } else {
661
                    $zoomuserid = zoom_get_user_id();
662
                }
663
 
664
                $autorecording = zoom_get_user_settings($zoomuserid)->recording->auto_recording;
665
                $data['settings']['auto_recording'] = $autorecording;
666
            } else {
667
                $data['settings']['auto_recording'] = $recordingoption;
668
            }
669
        }
670
 
671
        // Add fields which are effective for meetings only, but not for webinars.
672
        if (empty($zoom->webinar)) {
673
            $data['settings']['participant_video'] = (bool) ($zoom->option_participants_video);
674
            $data['settings']['join_before_host'] = (bool) ($zoom->option_jbh);
675
            $data['settings']['encryption_type'] = (isset($zoom->option_encryption_type) &&
676
                    $zoom->option_encryption_type === ZOOM_ENCRYPTION_TYPE_E2EE) ?
677
                    ZOOM_ENCRYPTION_TYPE_E2EE : ZOOM_ENCRYPTION_TYPE_ENHANCED;
678
            $data['settings']['waiting_room'] = (bool) ($zoom->option_waiting_room);
679
            $data['settings']['mute_upon_entry'] = (bool) ($zoom->option_mute_upon_entry);
680
        }
681
 
682
        // Add recurrence object.
683
        if ($zoom->recurring && $zoom->recurrence_type != ZOOM_RECURRINGTYPE_NOTIME) {
684
            $data['recurrence']['type'] = (int) $zoom->recurrence_type;
685
            $data['recurrence']['repeat_interval'] = (int) $zoom->repeat_interval;
686
            if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_WEEKLY) {
687
                $data['recurrence']['weekly_days'] = $zoom->weekly_days;
688
            }
689
 
690
            if ($zoom->recurrence_type == ZOOM_RECURRINGTYPE_MONTHLY) {
691
                if ($zoom->monthly_repeat_option == ZOOM_MONTHLY_REPEAT_OPTION_DAY) {
692
                    $data['recurrence']['monthly_day'] = (int) $zoom->monthly_day;
693
                } else {
694
                    $data['recurrence']['monthly_week'] = (int) $zoom->monthly_week;
695
                    $data['recurrence']['monthly_week_day'] = (int) $zoom->monthly_week_day;
696
                }
697
            }
698
 
699
            if ($zoom->end_date_option == ZOOM_END_DATE_OPTION_AFTER) {
700
                $data['recurrence']['end_times'] = (int) $zoom->end_times;
701
            } else {
702
                $data['recurrence']['end_date_time'] = gmdate('Y-m-d\TH:i:s\Z', $zoom->end_date_time);
703
            }
704
        }
705
 
706
        if (
707
            $data['type'] === ZOOM_SCHEDULED_MEETING ||
708
            $data['type'] === ZOOM_RECURRING_FIXED_MEETING ||
709
            $data['type'] === ZOOM_SCHEDULED_WEBINAR ||
710
            $data['type'] === ZOOM_RECURRING_FIXED_WEBINAR
711
        ) {
712
            // Convert timestamp to ISO-8601. The API seems to insist that it end with 'Z' to indicate UTC.
713
            $data['start_time'] = gmdate('Y-m-d\TH:i:s\Z', $zoom->start_time);
714
            $data['duration'] = (int) ceil($zoom->duration / 60);
715
        }
716
 
717
        // Add tracking field to data.
718
        $defaulttrackingfields = zoom_clean_tracking_fields();
719
        $tfarray = [];
720
        foreach ($defaulttrackingfields as $key => $defaulttrackingfield) {
721
            if (isset($zoom->$key)) {
722
                $tf = new stdClass();
723
                $tf->field = $defaulttrackingfield;
724
                $tf->value = $zoom->$key;
725
                $tfarray[] = $tf;
726
            }
727
        }
728
 
729
        $data['tracking_fields'] = $tfarray;
730
 
731
        if (isset($zoom->breakoutrooms)) {
732
            $breakoutroom = ['enable' => true, 'rooms' => $zoom->breakoutrooms];
733
            $data['settings']['breakout_room'] = $breakoutroom;
734
        }
735
 
736
        return $data;
737
    }
738
 
739
    /**
740
     * Provide a user with a license if needed and recycling is enabled.
741
     *
742
     * @param stdClass $zoomuserid The Zoom user to upgrade.
743
     * @return void
744
     */
745
    public function provide_license($zoomuserid) {
746
        // Checks whether we need to recycle licenses and acts accordingly.
747
        // Classic: user:read:admin.
748
        // Granular: user:read:user:admin.
749
        if ($this->recyclelicenses && $this->make_call("users/$zoomuserid")->type == ZOOM_USER_TYPE_BASIC) {
750
            if ($this->paid_user_limit_reached()) {
751
                $leastrecentlyactivepaiduserid = $this->get_least_recently_active_paid_user_id();
752
                // Changes least_recently_active_user to a basic user so we can use their license.
753
                $this->make_call("users/$leastrecentlyactivepaiduserid", ['type' => ZOOM_USER_TYPE_BASIC], 'patch');
754
            }
755
 
756
            // Changes current user to pro so they can make a meeting.
757
            // Classic: user:write:admin.
758
            // Granular: user:update:user:admin.
759
            $this->make_call("users/$zoomuserid", ['type' => ZOOM_USER_TYPE_PRO], 'patch');
760
        }
761
    }
762
 
763
    /**
764
     * Create a meeting/webinar on Zoom.
765
     * Take a $zoom object as returned from the Moodle form and respond with an object that can be saved to the database.
766
     *
767
     * @param stdClass $zoom The meeting to create.
768
     * @return stdClass The call response.
769
     */
770
    public function create_meeting($zoom) {
771
        // Provide license if needed.
772
        $this->provide_license($zoom->host_id);
773
 
774
        // Classic: meeting:write:admin.
775
        // Granular: meeting:write:meeting:admin.
776
        // Classic: webinar:write:admin.
777
        // Granular: webinar:write:webinar:admin.
778
        $url = "users/$zoom->host_id/" . (!empty($zoom->webinar) ? 'webinars' : 'meetings');
779
        return $this->make_call($url, $this->database_to_api($zoom), 'post');
780
    }
781
 
782
    /**
783
     * Update a meeting/webinar on Zoom.
784
     *
785
     * @param stdClass $zoom The meeting to update.
786
     * @return void
787
     */
788
    public function update_meeting($zoom) {
789
        // Classic: meeting:write:admin.
790
        // Granular: meeting:update:meeting:admin.
791
        // Classic: webinar:write:admin.
792
        // Granular: webinar:update:webinar:admin.
793
        $url = ($zoom->webinar ? 'webinars/' : 'meetings/') . $zoom->meeting_id;
794
        $this->make_call($url, $this->database_to_api($zoom), 'patch');
795
    }
796
 
797
    /**
798
     * Delete a meeting or webinar on Zoom.
799
     *
800
     * @param int $id The meeting_id or webinar_id of the meeting or webinar to delete.
801
     * @param bool $webinar Whether the meeting or webinar you want to delete is a webinar.
802
     * @return void
803
     */
804
    public function delete_meeting($id, $webinar) {
805
        // Classic: meeting:write:admin.
806
        // Granular: meeting:delete:meeting:admin.
807
        // Classic: webinar:write:admin.
808
        // Granular: webinar:delete:webinar:admin.
809
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '?schedule_for_reminder=false';
810
        $this->make_call($url, null, 'delete');
811
    }
812
 
813
    /**
814
     * Get a meeting or webinar's information from Zoom.
815
     *
816
     * @param int $id The meeting_id or webinar_id of the meeting or webinar to retrieve.
817
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
818
     * @return stdClass The meeting's or webinar's information.
819
     */
820
    public function get_meeting_webinar_info($id, $webinar) {
821
        // Classic: meeting:read:admin.
822
        // Granular: meeting:read:meeting:admin.
823
        // Classic: webinar:read:admin.
824
        // Granular: webinar:read:webinar:admin.
825
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id;
826
        $response = $this->make_call($url);
827
        return $response;
828
    }
829
 
830
    /**
831
     * Get the meeting invite note that was sent for a specific meeting from Zoom.
832
     *
833
     * @param stdClass $zoom The zoom meeting
834
     * @return \mod_zoom\invitation The meeting's invitation.
835
     */
836
    public function get_meeting_invitation($zoom) {
837
        global $CFG;
838
        require_once($CFG->dirroot . '/mod/zoom/classes/invitation.php');
839
 
840
        // Webinar does not have meeting invite info.
841
        if ($zoom->webinar) {
842
            return new invitation(null);
843
        }
844
 
845
        // Classic: meeting:read:admin.
846
        // Granular: meeting:read:invitation:admin.
847
        $url = 'meetings/' . $zoom->meeting_id . '/invitation';
848
 
849
        try {
850
            $response = $this->make_call($url);
851
        } catch (moodle_exception $error) {
852
            debugging($error->getMessage());
853
            return new invitation(null);
854
        }
855
 
856
        return new invitation($response->invitation);
857
    }
858
 
859
    /**
860
     * Retrieve ended meetings report for a specified user and period. Handles multiple pages.
861
     *
862
     * @param string $userid Id of user of interest
863
     * @param string $from Start date of period in the form YYYY-MM-DD
864
     * @param string $to End date of period in the form YYYY-MM-DD
865
     * @return array The retrieved meetings.
866
     */
867
    public function get_user_report($userid, $from, $to) {
868
        // Classic: report:read:admin.
869
        // Granular: report:read:user:admin.
870
        $url = 'report/users/' . $userid . '/meetings';
871
        $data = ['from' => $from, 'to' => $to];
872
        return $this->make_paginated_call($url, $data, 'meetings');
873
    }
874
 
875
    /**
876
     * List all meeting or webinar information for a user.
877
     *
878
     * @param string $userid The user whose meetings or webinars to retrieve.
879
     * @param boolean $webinar Whether to list meetings or to list webinars.
880
     * @return array An array of meeting information.
881
     * @deprecated Has never been used by internal code.
882
     */
883
    public function list_meetings($userid, $webinar) {
884
        // Classic: meeting:read:admin.
885
        // Granular: meeting:read:list_meetings:admin.
886
        // Classic: webinar:read:admin.
887
        // Granular: webinar:read:list_webinars:admin.
888
        $url = 'users/' . $userid . ($webinar ? '/webinars' : '/meetings');
889
        $instances = $this->make_paginated_call($url, [], ($webinar ? 'webinars' : 'meetings'));
890
        return $instances;
891
    }
892
 
893
    /**
894
     * Get the participants who attended a meeting
895
     * @param string $meetinguuid The meeting or webinar's UUID.
896
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
897
     * @return array An array of meeting participant objects.
898
     */
899
    public function get_meeting_participants($meetinguuid, $webinar) {
900
        $meetinguuid = $this->encode_uuid($meetinguuid);
901
 
902
        $meetingtype = ($webinar ? 'webinars' : 'meetings');
903
        $meetingtypesingular = ($webinar ? 'webinar' : 'meeting');
904
 
905
        $reportscopes = [
906
            // Classic.
907
            'report:read:admin',
908
 
909
            // Granular.
910
            'report:read:list_' . $meetingtypesingular . '_participants:admin',
911
        ];
912
 
913
        $dashboardscopes = [
914
            // Classic.
915
            'dashboard_' . $meetingtype . ':read:admin',
916
 
917
            // Granular.
918
            'dashboard:read:list_' . $meetingtypesingular . '_participants:admin',
919
        ];
920
 
921
        if ($this->has_scope($reportscopes)) {
922
            $apitype = 'report';
923
        } else if ($this->has_scope($dashboardscopes)) {
924
            $apitype = 'metrics';
925
        } else {
926
            mtrace('Missing OAuth scopes required for reports.');
927
            return [];
928
        }
929
 
930
        $url = $apitype . '/' . $meetingtype . '/' . $meetinguuid . '/participants';
931
        return $this->make_paginated_call($url, [], 'participants');
932
    }
933
 
934
    /**
935
     * Retrieve the UUIDs of hosts that were active in the last 30 days.
936
     *
937
     * @param int $from The time to start the query from, in Unix timestamp format.
938
     * @param int $to The time to end the query at, in Unix timestamp format.
939
     * @return array An array of UUIDs.
940
     */
941
    public function get_active_hosts_uuids($from, $to) {
942
        // Classic: report:read:admin.
943
        // Granular: report:read:list_users:admin.
944
        $users = $this->make_paginated_call('report/users', ['type' => 'active', 'from' => $from, 'to' => $to], 'users');
945
        $uuids = [];
946
        foreach ($users as $user) {
947
            $uuids[] = $user->id;
948
        }
949
 
950
        return $uuids;
951
    }
952
 
953
    /**
954
     * Retrieve past meetings that occurred in specified time period.
955
     *
956
     * Ignores meetings that were attended only by one user.
957
     *
958
     * NOTE: Requires Business or a higher plan and have "Dashboard" feature
959
     * enabled. This query is rated "Resource-intensive"
960
     *
961
     * @param int $from Start date in YYYY-MM-DD format.
962
     * @param int $to End date in YYYY-MM-DD format.
963
     * @return array An array of meeting objects.
964
     */
965
    public function get_meetings($from, $to) {
966
        // Classic: dashboard_meetings:read:admin.
967
        // Granular: dashboard:read:list_meetings:admin.
968
        return $this->make_paginated_call(
969
            'metrics/meetings',
970
            [
971
                'type' => 'past',
972
                'from' => $from,
973
                'to' => $to,
974
                'query_date_type' => 'end_time',
975
            ],
976
            'meetings'
977
        );
978
    }
979
 
980
    /**
981
     * Retrieve past meetings that occurred in specified time period.
982
     *
983
     * Ignores meetings that were attended only by one user.
984
     *
985
     * NOTE: Requires Business or a higher plan and have "Dashboard" feature
986
     * enabled. This query is rated "Resource-intensive"
987
     *
988
     * @param int $from Start date in YYYY-MM-DD format.
989
     * @param int $to End date in YYYY-MM-DD format.
990
     * @return array An array of meeting objects.
991
     */
992
    public function get_webinars($from, $to) {
993
        // Classic: dashboard_webinars:read:admin.
994
        // Granular: dashboard:read:list_webinars:admin.
995
        return $this->make_paginated_call('metrics/webinars', ['type' => 'past', 'from' => $from, 'to' => $to], 'webinars');
996
    }
997
 
998
    /**
999
     * Lists tracking fields configured on the account.
1000
     *
1001
     * @return ?stdClass The call's result in JSON format.
1002
     */
1003
    public function list_tracking_fields() {
1004
        $response = null;
1005
        try {
1006
            // Classic: tracking_fields:read:admin.
1007
            // Granular: Not yet implemented by Zoom.
1008
            $response = $this->make_call('tracking_fields');
1009
        } catch (moodle_exception $error) {
1010
            debugging($error->getMessage());
1011
        }
1012
 
1013
        return $response;
1014
    }
1015
 
1016
    /**
1017
     * If the UUID begins with a ‘/’ or contains ‘//’ in it we need to double encode it when using it for API calls.
1018
     *
1019
     * See https://devforum.zoom.us/t/cant-retrieve-data-when-meeting-uuid-contains-double-slash/2776
1020
     *
1021
     * @param string $uuid
1022
     * @return string
1023
     */
1024
    public function encode_uuid($uuid) {
1025
        if (substr($uuid, 0, 1) === '/' || strpos($uuid, '//') !== false) {
1026
            // Use similar function to JS encodeURIComponent, see https://stackoverflow.com/a/1734255/6001.
1027
            $encodeuricomponent = function ($str) {
1028
                $revert = ['%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')'];
1029
                return strtr(rawurlencode($str), $revert);
1030
            };
1031
            $uuid = $encodeuricomponent($encodeuricomponent($uuid));
1032
        }
1033
 
1034
        return $uuid;
1035
    }
1036
 
1037
    /**
1038
     * Returns the download URLs and recording types for the cloud recording if one exists on zoom for a particular meeting id.
1039
     * There can be more than one url for the same meeting if the host stops the recording in the middle
1040
     * of the meeting and then starts recording again without ending the meeting.
1041
     *
1042
     * @param string $meetingid The string meeting UUID.
1043
     * @return array Returns the list of recording URLs and the type of recording that is being sent back.
1044
     */
1045
    public function get_recording_url_list($meetingid) {
1046
        $recordings = [];
1047
 
1048
        // Only pick the video recording and audio only recordings.
1049
        // The transcript is available in both of these, so the extra file is unnecessary.
1050
        $allowedrecordingtypes = [
1051
            'MP4' => 'video',
1052
            'M4A' => 'audio',
1053
            'TRANSCRIPT' => 'transcript',
1054
            'CHAT' => 'chat',
1055
            'CC' => 'captions',
1056
        ];
1057
 
1058
        try {
1059
            // Classic: recording:read:admin.
1060
            // Granular: cloud_recording:read:list_recording_files:admin.
1061
            $url = 'meetings/' . $this->encode_uuid($meetingid) . '/recordings';
1062
            $response = $this->make_call($url);
1063
 
1064
            if (!empty($response->recording_files)) {
1065
                foreach ($response->recording_files as $recording) {
1066
                    $url = $recording->play_url ?? $recording->download_url ?? null;
1067
                    if (!empty($url) && isset($allowedrecordingtypes[$recording->file_type])) {
1068
                        $recordinginfo = new stdClass();
1069
                        $recordinginfo->recordingid = $recording->id;
1070
                        $recordinginfo->meetinguuid = $response->uuid;
1071
                        $recordinginfo->url = $url;
1072
                        $recordinginfo->filetype = $recording->file_type;
1073
                        $recordinginfo->recordingtype = $recording->recording_type;
1074
                        $recordinginfo->passcode = $response->password;
1075
                        $recordinginfo->recordingstart = strtotime($recording->recording_start);
1076
 
1077
                        $recordings[$recording->id] = $recordinginfo;
1078
                    }
1079
                }
1080
            }
1081
        } catch (moodle_exception $error) {
1082
            // No recordings found for this meeting id.
1083
            $recordings = [];
1084
        }
1085
 
1086
        return $recordings;
1087
    }
1088
 
1089
    /**
1090
     * Retrieve recordings for a specified user and period. Handles multiple pages.
1091
     *
1092
     * @param string $userid User ID.
1093
     * @param string $from Start date of period in the form YYYY-MM-DD
1094
     * @param string $to End date of period in the form YYYY-MM-DD
1095
     * @return array
1096
     */
1097
    public function get_user_recordings($userid, $from, $to) {
1098
        $recordings = [];
1099
 
1100
        // Only pick the video recording and audio only recordings.
1101
        // The transcript is available in both of these, so the extra file is unnecessary.
1102
        $allowedrecordingtypes = [
1103
            'MP4' => 'video',
1104
            'M4A' => 'audio',
1105
            'TRANSCRIPT' => 'transcript',
1106
            'CHAT' => 'chat',
1107
            'CC' => 'captions',
1108
        ];
1109
 
1110
        try {
1111
            // Classic: recording:read:admin.
1112
            // Granular: cloud_recording:read:list_user_recordings:admin.
1113
            $url = 'users/' . $userid . '/recordings';
1114
            $data = ['from' => $from, 'to' => $to];
1115
            $response = $this->make_paginated_call($url, $data, 'meetings');
1116
 
1117
            foreach ($response as $meeting) {
1118
                foreach ($meeting->recording_files as $recording) {
1119
                    $url = $recording->play_url ?? $recording->download_url ?? null;
1120
                    if (!empty($url) && isset($allowedrecordingtypes[$recording->file_type])) {
1121
                        $recordinginfo = new stdClass();
1122
                        $recordinginfo->recordingid = $recording->id;
1123
                        $recordinginfo->meetingid = $meeting->id;
1124
                        $recordinginfo->meetinguuid = $meeting->uuid;
1125
                        $recordinginfo->url = $url;
1126
                        $recordinginfo->filetype = $recording->file_type;
1127
                        $recordinginfo->recordingtype = $recording->recording_type;
1128
                        $recordinginfo->recordingstart = strtotime($recording->recording_start);
1129
 
1130
                        $recordings[$recording->id] = $recordinginfo;
1131
                    }
1132
                }
1133
            }
1134
        } catch (moodle_exception $error) {
1135
            // No recordings found for this user.
1136
            $recordings = [];
1137
        }
1138
 
1139
        return $recordings;
1140
    }
1141
 
1142
    /**
1143
     * Returns a server to server oauth access token, good for 1 hour.
1144
     *
1145
     * @throws moodle_exception
1146
     * @return string access token
1147
     */
1148
    protected function get_access_token() {
1149
        $cache = cache::make('mod_zoom', 'oauth');
1150
        $token = $cache->get('accesstoken');
1151
        $expires = $cache->get('expires');
1152
        if (empty($token) || empty($expires) || time() >= $expires) {
1153
            $token = $this->oauth($cache);
1154
        } else {
1155
            $this->scopes = $cache->get('scopes');
1156
        }
1157
 
1158
        return $token;
1159
    }
1160
 
1161
    /**
1162
     * Has one of the required OAuth scopes been granted?
1163
     *
1164
     * @param array $scopes OAuth scopes.
1165
     * @throws moodle_exception
1166
     * @return bool
1167
     */
1168
    public function has_scope($scopes) {
1169
        if (!isset($this->scopes)) {
1170
            $this->get_access_token();
1171
        }
1172
 
1173
        mtrace('checking has_scope(' . implode(' || ', $scopes) . ')');
1174
 
1175
        $matchingscopes = \array_intersect($scopes, $this->scopes);
1176
        return !empty($matchingscopes);
1177
    }
1178
 
1179
    /**
1180
     * Stores token and expiration in cache, returns token from OAuth call.
1181
     *
1182
     * @param cache $cache
1183
     * @throws moodle_exception
1184
     * @return string access token
1185
     */
1186
    private function oauth($cache) {
1187
        $curl = $this->get_curl_object();
1188
        $curl->setHeader('Authorization: Basic ' . base64_encode($this->clientid . ':' . $this->clientsecret));
1189
        $curl->setHeader('Accept: application/json');
1190
 
1191
        // Force HTTP/1.1 to avoid HTTP/2 "stream not closed" issue.
1192
        $curl->setopt([
1193
            'CURLOPT_HTTP_VERSION' => \CURL_HTTP_VERSION_1_1,
1194
        ]);
1195
 
1196
        $timecalled = time();
1197
        $data = [
1198
            'grant_type' => 'account_credentials',
1199
            'account_id' => $this->accountid,
1200
        ];
1201
        $response = $this->make_curl_call($curl, 'post', 'https://zoom.us/oauth/token', $data);
1202
 
1203
        if ($curl->get_errno()) {
1204
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', $curl->error);
1205
        }
1206
 
1207
        $response = json_decode($response);
1208
 
1209
        if (empty($response->access_token)) {
1210
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', get_string('zoomerr_no_access_token', 'mod_zoom'));
1211
        }
1212
 
1213
        $scopes = explode(' ', $response->scope);
1214
 
1215
        // Assume that we are using granular scopes.
1216
        $requiredscopes = [
1217
            'meeting:read:meeting:admin',
1218
            'meeting:read:invitation:admin',
1219
            'meeting:delete:meeting:admin',
1220
            'meeting:update:meeting:admin',
1221
            'meeting:write:meeting:admin',
1222
            'user:read:list_schedulers:admin',
1223
            'user:read:settings:admin',
1224
            'user:read:user:admin',
1225
        ];
1226
 
1227
        // Check if we received classic scopes.
1228
        if (in_array('meeting:read:admin', $scopes, true)) {
1229
            $requiredscopes = [
1230
                'meeting:read:admin',
1231
                'meeting:write:admin',
1232
                'user:read:admin',
1233
            ];
1234
        }
1235
 
1236
        $missingscopes = array_diff($requiredscopes, $scopes);
1237
 
1238
        // Keep the scope information in memory.
1239
        $this->scopes = $scopes;
1240
 
1241
        if (!empty($missingscopes)) {
1242
            $missingscopes = implode(', ', $missingscopes);
1243
            throw new moodle_exception('errorwebservice', 'mod_zoom', '', get_string('zoomerr_scopes', 'mod_zoom', $missingscopes));
1244
        }
1245
 
1246
        $token = $response->access_token;
1247
 
1248
        if (isset($response->expires_in)) {
1249
            $expires = $response->expires_in + $timecalled;
1250
        } else {
1251
            $expires = 3599 + $timecalled;
1252
        }
1253
 
1254
        $cache->set_many([
1255
            'accesstoken' => $token,
1256
            'expires' => $expires,
1257
            'scopes' => $scopes,
1258
        ]);
1259
 
1260
        return $token;
1261
    }
1262
 
1263
    /**
1264
     * List the meeting or webinar registrants from Zoom.
1265
     *
1266
     * @param string $id The meeting_id or webinar_id of the meeting or webinar to retrieve.
1267
     * @param bool $webinar Whether the meeting or webinar whose information you want is a webinar.
1268
     * @return stdClass The meeting's or webinar's information.
1269
     */
1270
    public function get_meeting_registrants($id, $webinar) {
1271
        // Classic: meeting:read:admin.
1272
        // Granular: meeting:read:list_registrants:admin.
1273
        // Classic: webinar:read:admin.
1274
        // Granular: webinar:read:list_registrants:admin.
1275
        $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '/registrants';
1276
        $response = $this->make_call($url);
1277
        return $response;
1278
    }
1279
 
1280
    /**
1281
     * Get the recording settings for a meeting.
1282
     *
1283
     * @param string $meetinguuid The UUID of a meeting with recordings.
1284
     * @return stdClass The meeting's recording settings.
1285
     */
1286
    public function get_recording_settings($meetinguuid) {
1287
        // Classic: recording:read:admin.
1288
        // Granular: cloud_recording:read:recording_settings:admin.
1289
        $url = 'meetings/' . $this->encode_uuid($meetinguuid) . '/recordings/settings';
1290
        $response = $this->make_call($url);
1291
        return $response;
1292
    }
1293
}