Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of 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
namespace mod_bigbluebuttonbn;
18
 
19
use cache;
20
use cache_store;
21
use context_course;
22
use core_tag_tag;
23
use Exception;
24
use Firebase\JWT\Key;
25
use mod_bigbluebuttonbn\local\config;
26
use mod_bigbluebuttonbn\local\exceptions\bigbluebutton_exception;
27
use mod_bigbluebuttonbn\local\exceptions\meeting_join_exception;
28
use mod_bigbluebuttonbn\local\helpers\roles;
29
use mod_bigbluebuttonbn\local\proxy\bigbluebutton_proxy;
30
use stdClass;
31
 
32
/**
33
 * Class to describe a BBB Meeting.
34
 *
35
 * @package   mod_bigbluebuttonbn
36
 * @copyright 2021 Andrew Lyons <andrew@nicols.co.uk>
37
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38
 */
39
class meeting {
40
 
41
    /** @var instance The bbb instance */
42
    protected $instance;
43
 
44
    /** @var stdClass Info about the meeting */
45
    protected $meetinginfo = null;
46
 
47
    /**
48
     * Constructor for the meeting object.
49
     *
50
     * @param instance $instance
51
     */
52
    public function __construct(instance $instance) {
53
        $this->instance = $instance;
54
    }
55
 
56
    /**
57
     * Helper to join a meeting.
58
     *
59
     *
60
     * It will create the meeting if not already created.
61
     *
62
     * @param instance $instance
63
     * @param int $origin
64
     * @return string
65
     * @throws meeting_join_exception this is sent if we cannot join (meeting full, user needs to wait...)
66
     */
67
    public static function join_meeting(instance $instance, $origin = logger::ORIGIN_BASE): string {
68
        // See if the session is in progress.
69
        $meeting = new meeting($instance);
70
        // As the meeting doesn't exist, try to create it.
71
        if (empty($meeting->get_meeting_info(true)->createtime)) {
72
            $meeting->create_meeting();
73
        }
74
        return $meeting->join($origin);
75
    }
76
 
77
    /**
78
     * Get currently stored meeting info
79
     *
80
     * @return stdClass
81
     */
82
    public function get_meeting_info() {
83
        if (!$this->meetinginfo) {
84
            $this->meetinginfo = $this->do_get_meeting_info();
85
        }
86
        return $this->meetinginfo;
87
    }
88
 
89
    /**
90
     * Return meeting information for the specified instance.
91
     *
92
     * @param instance $instance
93
     * @param bool $updatecache Whether to update the cache when fetching the information
94
     * @return stdClass
95
     */
96
    public static function get_meeting_info_for_instance(instance $instance, bool $updatecache = false): stdClass {
97
        $meeting = new self($instance);
98
        return $meeting->do_get_meeting_info($updatecache);
99
    }
100
 
101
    /**
102
     * Helper function returns a sha1 encoded string that is unique and will be used as a seed for meetingid.
103
     *
104
     * @return string
105
     */
106
    public static function get_unique_meetingid_seed() {
107
        global $DB;
108
        do {
109
            $encodedseed = sha1(plugin::random_password(12));
110
            $meetingid = (string) $DB->get_field('bigbluebuttonbn', 'meetingid', ['meetingid' => $encodedseed]);
111
        } while ($meetingid == $encodedseed);
112
        return $encodedseed;
113
    }
114
 
115
    /**
116
     * Is meeting running ?
117
     *
118
     * @return bool
119
     */
120
    public function is_running() {
121
        return $this->get_meeting_info()->statusrunning ?? false;
122
    }
123
 
124
    /**
125
     * Force update the meeting in cache.
126
     */
127
    public function update_cache() {
128
        $this->meetinginfo = $this->do_get_meeting_info(true);
129
    }
130
 
131
    /**
132
     * Get meeting attendees
133
     *
134
     * @return array[]
135
     */
136
    public function get_attendees(): array {
137
        return $this->get_meeting_info()->attendees ?? [];
138
    }
139
 
140
    /**
141
     * Can the meeting be joined ?
142
     *
143
     * @return bool
144
     */
145
    public function can_join() {
146
        return $this->get_meeting_info()->canjoin;
147
    }
148
 
149
    /**
150
     * Total number of moderators and viewers.
151
     *
152
     * @return int
153
     */
154
    public function get_participant_count() {
155
        return $this->get_meeting_info()->totalusercount;
156
    }
157
 
158
    /**
159
     * Creates a bigbluebutton meeting, send the message to BBB and returns the response in an array.
160
     *
161
     * @return array
162
     */
163
    public function create_meeting() {
164
        $data = $this->create_meeting_data();
165
        $metadata = $this->create_meeting_metadata();
166
        $presentation = $this->instance->get_presentation_for_bigbluebutton_upload(); // The URL must contain nonce.
167
        $presentationname = $presentation['name'] ?? null;
168
        $presentationurl = $presentation['url'] ?? null;
169
        $response = bigbluebutton_proxy::create_meeting(
170
            $data,
171
            $metadata,
172
            $presentationname,
173
            $presentationurl,
174
            $this->instance->get_instance_id()
175
        );
176
        // New recording management: Insert a recordingID that corresponds to the meeting created.
177
        if ($this->instance->is_recorded()) {
178
            $recording = new recording(0, (object) [
179
                'courseid' => $this->instance->get_course_id(),
180
                'bigbluebuttonbnid' => $this->instance->get_instance_id(),
181
                'recordingid' => $response['internalMeetingID'],
182
                'groupid' => $this->instance->get_group_id()]
183
            );
184
            $recording->create();
185
        }
186
        return $response;
187
    }
188
 
189
    /**
190
     * Send an end meeting message to BBB server
191
     */
192
    public function end_meeting() {
193
        bigbluebutton_proxy::end_meeting(
194
            $this->instance->get_meeting_id(),
195
            $this->instance->get_moderator_password(),
196
            $this->instance->get_instance_id()
197
        );
198
    }
199
 
200
    /**
201
     * Get meeting join URL
202
     *
203
     * @return string
204
     */
205
    public function get_join_url(): string {
206
        return bigbluebutton_proxy::get_join_url($this->instance, $this->get_meeting_info()->createtime);
207
    }
208
 
209
    /**
210
     * Get meeting join URL for guest
211
     *
212
     * @param string $userfullname
213
     * @return string
214
     */
215
    public function get_guest_join_url(string $userfullname): string {
216
        return bigbluebutton_proxy::get_guest_join_url($this->instance, $this->get_meeting_info()->createtime, $userfullname);
217
    }
218
 
219
 
220
    /**
221
     * Return meeting information for this meeting.
222
     *
223
     * @param bool $updatecache Whether to update the cache when fetching the information
224
     * @return stdClass
225
     */
226
    protected function do_get_meeting_info(bool $updatecache = false): stdClass {
227
        $instance = $this->instance;
228
        $meetinginfo = (object) [
229
            'instanceid' => $instance->get_instance_id(),
230
            'bigbluebuttonbnid' => $instance->get_instance_id(),
231
            'groupid' => $instance->get_group_id(),
232
            'meetingid' => $instance->get_meeting_id(),
233
            'cmid' => $instance->get_cm_id(),
234
            'ismoderator' => $instance->is_moderator(),
235
            'joinurl' => $instance->get_join_url()->out(),
236
            'userlimit' => $instance->get_user_limit(),
237
            'presentations' => [],
238
        ];
239
        if ($instance->get_instance_var('openingtime')) {
240
            $meetinginfo->openingtime = intval($instance->get_instance_var('openingtime'));
241
        }
242
        if ($instance->get_instance_var('closingtime')) {
243
            $meetinginfo->closingtime = intval($instance->get_instance_var('closingtime'));
244
        }
245
        $activitystatus = bigbluebutton_proxy::view_get_activity_status($instance);
246
        // This might raise an exception if info cannot be retrieved.
247
        // But this might be totally fine as the meeting is maybe not yet created on BBB side.
248
        $totalusercount = 0;
249
        // This is the default value for any meeting that has not been created.
250
        $meetinginfo->statusrunning = false;
251
        $meetinginfo->createtime = null;
252
 
253
        $info = self::retrieve_cached_meeting_info($this->instance, $updatecache);
254
        if (!empty($info)) {
255
            $meetinginfo->statusrunning = $info['running'] === 'true';
256
            $meetinginfo->createtime = $info['createTime'] ?? null;
257
            $totalusercount = isset($info['participantCount']) ? $info['participantCount'] : 0;
258
        }
259
 
260
        $meetinginfo->statusclosed = $activitystatus === 'ended';
261
        $meetinginfo->statusopen = !$meetinginfo->statusrunning && $activitystatus === 'open';
262
        $meetinginfo->totalusercount = $totalusercount;
263
 
264
        $canjoin = !$instance->user_must_wait_to_join() || $meetinginfo->statusrunning;
265
        // Limit has not been reached.
266
        $canjoin = $canjoin && (!$instance->has_user_limit_been_reached($totalusercount));
267
        // User should only join during scheduled session start and end time, if defined.
268
        $canjoin = $canjoin && ($instance->is_currently_open());
269
        // Double check that the user has the capabilities to join.
270
        $canjoin = $canjoin && $instance->can_join();
271
        $meetinginfo->canjoin = $canjoin;
272
 
273
        // If user is administrator, moderator or if is viewer and no waiting is required, join allowed.
274
        if ($meetinginfo->statusrunning) {
275
            $meetinginfo->startedat = floor(intval($info['startTime']) / 1000); // Milliseconds.
276
            $meetinginfo->moderatorcount = $info['moderatorCount'];
277
            $meetinginfo->moderatorplural = $info['moderatorCount'] > 1;
278
            $meetinginfo->participantcount = $totalusercount - $meetinginfo->moderatorcount;
279
            $meetinginfo->participantplural = $meetinginfo->participantcount > 1;
280
        }
281
        $meetinginfo->statusmessage = $this->get_status_message($meetinginfo, $instance);
282
 
283
        $presentation = $instance->get_presentation(); // This is for internal use.
284
        if (!empty($presentation)) {
285
            $meetinginfo->presentations[] = $presentation;
286
        }
287
        $meetinginfo->attendees = [];
288
        if (!empty($info['attendees'])) {
289
            // Ensure each returned attendee is cast to an array, rather than a simpleXML object.
290
            foreach ($info['attendees'] as $attendee) {
291
                $meetinginfo->attendees[] = (array) $attendee;
292
            }
293
        }
294
        $meetinginfo->guestaccessenabled = $instance->is_guest_allowed();
295
        if ($meetinginfo->guestaccessenabled && $instance->is_moderator()) {
296
            $meetinginfo->guestjoinurl = $instance->get_guest_access_url()->out();
297
            $meetinginfo->guestpassword = $instance->get_guest_access_password();
298
        }
299
 
300
        $meetinginfo->features = $instance->get_enabled_features();
301
        return $meetinginfo;
302
    }
303
 
304
    /**
305
     * Deduce status message from the current meeting info and the instance
306
     *
307
     * Returns the human-readable message depending on if the user must wait to join, the meeting has not
308
     * yet started ...
309
     * @param object $meetinginfo
310
     * @param instance $instance
311
     * @return string
312
     */
313
    protected function get_status_message(object $meetinginfo, instance $instance): string {
314
        if ($instance->has_user_limit_been_reached($meetinginfo->totalusercount)) {
315
            return get_string('view_message_conference_user_limit_reached', 'bigbluebuttonbn');
316
        }
317
        if ($meetinginfo->statusrunning) {
318
            return get_string('view_message_conference_in_progress', 'bigbluebuttonbn');
319
        }
320
        if ($instance->user_must_wait_to_join() && !$instance->user_can_force_join()) {
321
            return get_string('view_message_conference_wait_for_moderator', 'bigbluebuttonbn');
322
        }
323
        if ($instance->before_start_time()) {
324
            return get_string('view_message_conference_not_started', 'bigbluebuttonbn');
325
        }
326
        if ($instance->has_ended()) {
327
            return get_string('view_message_conference_has_ended', 'bigbluebuttonbn');
328
        }
329
        return get_string('view_message_conference_room_ready', 'bigbluebuttonbn');
330
    }
331
 
332
    /**
333
     * Gets a meeting info object cached or fetched from the live session.
334
     *
335
     * @param instance $instance
336
     * @param bool $updatecache
337
     *
338
     * @return array
339
     */
340
    protected static function retrieve_cached_meeting_info(instance $instance, $updatecache = false) {
341
        $meetingid = $instance->get_meeting_id();
342
        $cachettl = (int) config::get('waitformoderator_cache_ttl');
343
        $cache = cache::make_from_params(cache_store::MODE_APPLICATION, 'mod_bigbluebuttonbn', 'meetings_cache');
344
        $result = $cache->get($meetingid);
345
        $now = time();
346
        if (!$updatecache && !empty($result) && $now < ($result['creation_time'] + $cachettl)) {
347
            // Use the value in the cache.
348
            return (array) json_decode($result['meeting_info']);
349
        }
350
        // We set the cache to an empty value so then if get_meeting_info raises an exception we still have the
351
        // info about the last creation_time, so we don't ask the server again for a bit.
352
        $defaultcacheinfo = ['creation_time' => time(), 'meeting_info' => '[]'];
353
        // Pings again and refreshes the cache.
354
        try {
355
            $meetinginfo = bigbluebutton_proxy::get_meeting_info($meetingid);
356
            $cache->set($meetingid, ['creation_time' => time(), 'meeting_info' => json_encode($meetinginfo)]);
357
        } catch (bigbluebutton_exception $e) {
358
            // The meeting is not created on BBB side, so we set the value in the cache so we don't poll again
359
            // and return an empty array.
360
            $cache->set($meetingid, $defaultcacheinfo);
361
            return [];
362
        }
363
        return $meetinginfo;
364
    }
365
 
366
    /**
367
     * Conversion between form settings and lockSettings as set in BBB API.
368
     */
369
    const LOCK_SETTINGS_MEETING_DATA = [
370
        'disablecam' => 'lockSettingsDisableCam',
371
        'disablemic' => 'lockSettingsDisableMic',
372
        'disableprivatechat' => 'lockSettingsDisablePrivateChat',
373
        'disablepublicchat' => 'lockSettingsDisablePublicChat',
374
        'disablenote' => 'lockSettingsDisableNote',
375
        'hideuserlist' => 'lockSettingsHideUserList'
376
    ];
377
    /**
378
     * Helper to prepare data used for create meeting.
379
     * @todo moderatorPW and attendeePW will be removed from create after release of BBB v2.6.
380
     *
381
     * @return array
382
     */
383
    protected function create_meeting_data() {
384
        $data = ['meetingID' => $this->instance->get_meeting_id(),
385
            'name' => \mod_bigbluebuttonbn\plugin::html2text($this->instance->get_meeting_name(), 64),
386
            'attendeePW' => $this->instance->get_viewer_password(),
387
            'moderatorPW' => $this->instance->get_moderator_password(),
388
            'logoutURL' => $this->instance->get_logout_url()->out(false),
389
        ];
390
        $data['record'] = $this->instance->should_record() ? 'true' : 'false';
391
        // Check if auto_start_record is enable.
392
        if ($data['record'] == 'true' && $this->instance->should_record_from_start()) {
393
            $data['autoStartRecording'] = 'true';
394
        }
395
        // Check if hide_record_button is enable.
396
        if (!$this->instance->should_show_recording_button()) {
397
            $data['allowStartStopRecording'] = 'false';
398
        }
399
        $data['welcome'] = trim($this->instance->get_welcome_message());
400
        $voicebridge = intval($this->instance->get_voice_bridge());
401
        if ($voicebridge > 0 && $voicebridge < 79999) {
402
            $data['voiceBridge'] = $voicebridge;
403
        }
404
        $maxparticipants = intval($this->instance->get_user_limit());
405
        if ($maxparticipants > 0) {
406
            $data['maxParticipants'] = $maxparticipants;
407
        }
408
        if ($this->instance->get_mute_on_start()) {
409
            $data['muteOnStart'] = 'true';
410
        }
411
        // Here a bit of a change compared to the API default behaviour: we should not allow guest to join
412
        // a meeting managed by Moodle by default.
413
        if ($this->instance->is_guest_allowed()) {
414
            $data['guestPolicy'] = $this->instance->is_moderator_approval_required() ? 'ASK_MODERATOR' : 'ALWAYS_ACCEPT';
415
        }
416
        // Locks settings.
417
        foreach (self::LOCK_SETTINGS_MEETING_DATA as $instancevarname => $lockname) {
418
            $instancevar = $this->instance->get_instance_var($instancevarname);
419
            if (!is_null($instancevar)) {
420
                $data[$lockname] = $instancevar ? 'true' : 'false';
421
                if ($instancevar) {
422
                    $data['lockSettingsLockOnJoin'] = 'true'; // This will be locked whenever one settings is locked.
423
                }
424
            }
425
        }
426
        return $data;
427
    }
428
 
429
    /**
430
     * Helper for preparing metadata used while creating the meeting.
431
     *
432
     * @return array
433
     */
434
    protected function create_meeting_metadata() {
435
        global $USER;
436
        // Create standard metadata.
437
        $origindata = $this->instance->get_origin_data();
438
        $metadata = [
439
            'bbb-origin' => $origindata->origin,
440
            'bbb-origin-version' => $origindata->originVersion,
441
            'bbb-origin-server-name' => $origindata->originServerName,
442
            'bbb-origin-server-common-name' => $origindata->originServerCommonName,
443
            'bbb-origin-tag' => $origindata->originTag,
444
            'bbb-context' => $this->instance->get_course()->fullname,
445
            'bbb-context-id' => $this->instance->get_course_id(),
446
            'bbb-context-name' => trim(html_to_text($this->instance->get_course()->fullname, 0)),
447
            'bbb-context-label' => trim(html_to_text($this->instance->get_course()->shortname, 0)),
448
            'bbb-recording-name' => plugin::html2text($this->instance->get_meeting_name(), 64),
449
            'bbb-recording-description' => plugin::html2text($this->instance->get_meeting_description(),
450
                64),
451
            'bbb-recording-tags' =>
452
                implode(',', core_tag_tag::get_item_tags_array('core',
453
                    'course_modules', $this->instance->get_cm_id())), // Same as $id.
454
        ];
455
        // Special metadata for recording processing.
456
        if ((boolean) config::get('recordingstatus_enabled')) {
457
            $metadata["bn-recording-status"] = json_encode(
458
                [
459
                    'email' => ['"' . fullname($USER) . '" <' . $USER->email . '>'],
460
                    'context' => $this->instance->get_view_url(),
461
                ]
462
            );
463
        }
464
        if ((boolean) config::get('recordingready_enabled')) {
11 efrain 465
            $metadata['bbb-recording-ready-url'] = $this->instance->get_record_ready_url()->out(false);
1 efrain 466
        }
467
        if ((boolean) config::get('meetingevents_enabled')) {
468
            $metadata['analytics-callback-url'] = $this->instance->get_meeting_event_notification_url()->out(false);
469
        }
470
        return $metadata;
471
    }
472
 
473
    /**
474
     * Helper for responding when storing live meeting events is requested.
475
     *
476
     * The callback with a POST request includes:
477
     *  - Authentication: Bearer <A JWT token containing {"exp":<TIMESTAMP>} encoded with HS512>
478
     *  - Content Type: application/json
479
     *  - Body: <A JSON Object>
480
     *
481
     * @param instance $instance
482
     * @param object $data
483
     * @return string
484
     */
485
    public static function meeting_events(instance $instance, object $data): string {
486
        $bigbluebuttonbn = $instance->get_instance_data();
487
        // Validate that the bigbluebuttonbn activity corresponds to the meeting_id received.
488
        $meetingidelements = explode('[', $data->{'meeting_id'});
489
        $meetingidelements = explode('-', $meetingidelements[0]);
490
        if (!isset($bigbluebuttonbn) || $bigbluebuttonbn->meetingid != $meetingidelements[0]) {
491
            return 'HTTP/1.0 410 Gone. The activity may have been deleted';
492
        }
493
 
494
        // We make sure events are processed only once.
495
        $overrides = ['meetingid' => $data->{'meeting_id'}];
496
        $meta['internalmeetingid'] = $data->{'internal_meeting_id'};
497
        $meta['callback'] = 'meeting_events';
498
        $meta['meetingid'] = $data->{'meeting_id'};
499
 
500
        $eventcount = logger::log_event_callback($instance, $overrides, $meta);
501
        if ($eventcount === 1) {
502
            // Process the events.
503
            self::process_meeting_events($instance, $data);
504
            return 'HTTP/1.0 200 Accepted. Enqueued.';
505
        } else {
506
            return 'HTTP/1.0 202 Accepted. Already processed.';
507
        }
508
    }
509
 
510
    /**
511
     * Helper function enqueues list of meeting events to be stored and processed as for completion.
512
     *
513
     * @param instance $instance
514
     * @param stdClass $jsonobj
515
     */
516
    protected static function process_meeting_events(instance $instance, stdClass $jsonobj) {
517
        $meetingid = $jsonobj->{'meeting_id'};
518
        $recordid = $jsonobj->{'internal_meeting_id'};
519
        $attendees = $jsonobj->{'data'}->{'attendees'};
520
        foreach ($attendees as $attendee) {
521
            $userid = $attendee->{'ext_user_id'};
522
            $overrides['meetingid'] = $meetingid;
523
            $overrides['userid'] = $userid;
524
            $meta['recordid'] = $recordid;
525
            $meta['data'] = $attendee;
526
 
527
            // Stores the log.
528
            logger::log_event_summary($instance, $overrides, $meta);
529
 
530
            // Enqueue a task for processing the completion.
531
            bigbluebutton_proxy::enqueue_completion_event($instance->get_instance_data(), $userid);
532
        }
533
    }
534
 
535
    /**
536
     * Prepare join meeting action
537
     *
538
     * @param int $origin
539
     * @return void
540
     */
541
    protected function prepare_meeting_join_action(int $origin) {
542
        $this->do_get_meeting_info(true);
543
        if ($this->is_running()) {
544
            if ($this->instance->has_user_limit_been_reached($this->get_participant_count())) {
545
                throw new meeting_join_exception('userlimitreached');
546
            }
547
        } else if ($this->instance->user_must_wait_to_join()) {
548
            // If user is not administrator nor moderator (user is student) and waiting is required.
549
            throw new meeting_join_exception('waitformoderator');
550
        }
551
 
552
        // Moodle event logger: Create an event for meeting joined.
553
        logger::log_meeting_joined_event($this->instance, $origin);
554
 
555
        // Before executing the redirect, increment the number of participants.
556
        roles::participant_joined($this->instance->get_meeting_id(), $this->instance->is_moderator());
557
    }
558
    /**
559
     * Join a meeting.
560
     *
561
     * @param int $origin The spec
562
     * @return string The URL to redirect to
563
     * @throws meeting_join_exception
564
     */
565
    public function join(int $origin): string {
566
        $this->prepare_meeting_join_action($origin);
567
        return $this->get_join_url();
568
    }
569
 
570
    /**
571
     * Join a meeting as a guest.
572
     *
573
     * @param int $origin The spec
574
     * @param string $userfullname Fullname for the guest user
575
     * @return string The URL to redirect to
576
     * @throws meeting_join_exception
577
     */
578
    public function guest_join(int $origin, string $userfullname): string {
579
        $this->prepare_meeting_join_action($origin);
580
        return $this->get_join_url();
581
    }
582
}