Proyectos de Subversion Moodle

Rev

Rev 11 | | 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;
1441 ariadna 286
            $meetinginfo->showpresentations = $instance->should_show_presentation();
1 efrain 287
        }
288
        $meetinginfo->attendees = [];
289
        if (!empty($info['attendees'])) {
290
            // Ensure each returned attendee is cast to an array, rather than a simpleXML object.
291
            foreach ($info['attendees'] as $attendee) {
292
                $meetinginfo->attendees[] = (array) $attendee;
293
            }
294
        }
295
        $meetinginfo->guestaccessenabled = $instance->is_guest_allowed();
296
        if ($meetinginfo->guestaccessenabled && $instance->is_moderator()) {
297
            $meetinginfo->guestjoinurl = $instance->get_guest_access_url()->out();
298
            $meetinginfo->guestpassword = $instance->get_guest_access_password();
299
        }
300
 
301
        $meetinginfo->features = $instance->get_enabled_features();
302
        return $meetinginfo;
303
    }
304
 
305
    /**
306
     * Deduce status message from the current meeting info and the instance
307
     *
308
     * Returns the human-readable message depending on if the user must wait to join, the meeting has not
309
     * yet started ...
310
     * @param object $meetinginfo
311
     * @param instance $instance
312
     * @return string
313
     */
314
    protected function get_status_message(object $meetinginfo, instance $instance): string {
315
        if ($instance->has_user_limit_been_reached($meetinginfo->totalusercount)) {
316
            return get_string('view_message_conference_user_limit_reached', 'bigbluebuttonbn');
317
        }
318
        if ($meetinginfo->statusrunning) {
319
            return get_string('view_message_conference_in_progress', 'bigbluebuttonbn');
320
        }
321
        if ($instance->user_must_wait_to_join() && !$instance->user_can_force_join()) {
322
            return get_string('view_message_conference_wait_for_moderator', 'bigbluebuttonbn');
323
        }
324
        if ($instance->before_start_time()) {
325
            return get_string('view_message_conference_not_started', 'bigbluebuttonbn');
326
        }
327
        if ($instance->has_ended()) {
328
            return get_string('view_message_conference_has_ended', 'bigbluebuttonbn');
329
        }
330
        return get_string('view_message_conference_room_ready', 'bigbluebuttonbn');
331
    }
332
 
333
    /**
334
     * Gets a meeting info object cached or fetched from the live session.
335
     *
336
     * @param instance $instance
337
     * @param bool $updatecache
338
     *
339
     * @return array
340
     */
341
    protected static function retrieve_cached_meeting_info(instance $instance, $updatecache = false) {
342
        $meetingid = $instance->get_meeting_id();
343
        $cachettl = (int) config::get('waitformoderator_cache_ttl');
344
        $cache = cache::make_from_params(cache_store::MODE_APPLICATION, 'mod_bigbluebuttonbn', 'meetings_cache');
345
        $result = $cache->get($meetingid);
346
        $now = time();
347
        if (!$updatecache && !empty($result) && $now < ($result['creation_time'] + $cachettl)) {
348
            // Use the value in the cache.
349
            return (array) json_decode($result['meeting_info']);
350
        }
351
        // We set the cache to an empty value so then if get_meeting_info raises an exception we still have the
352
        // info about the last creation_time, so we don't ask the server again for a bit.
353
        $defaultcacheinfo = ['creation_time' => time(), 'meeting_info' => '[]'];
354
        // Pings again and refreshes the cache.
355
        try {
356
            $meetinginfo = bigbluebutton_proxy::get_meeting_info($meetingid);
357
            $cache->set($meetingid, ['creation_time' => time(), 'meeting_info' => json_encode($meetinginfo)]);
358
        } catch (bigbluebutton_exception $e) {
359
            // The meeting is not created on BBB side, so we set the value in the cache so we don't poll again
360
            // and return an empty array.
361
            $cache->set($meetingid, $defaultcacheinfo);
362
            return [];
363
        }
364
        return $meetinginfo;
365
    }
366
 
367
    /**
368
     * Conversion between form settings and lockSettings as set in BBB API.
369
     */
370
    const LOCK_SETTINGS_MEETING_DATA = [
371
        'disablecam' => 'lockSettingsDisableCam',
372
        'disablemic' => 'lockSettingsDisableMic',
373
        'disableprivatechat' => 'lockSettingsDisablePrivateChat',
374
        'disablepublicchat' => 'lockSettingsDisablePublicChat',
1441 ariadna 375
        'disablenote' => 'lockSettingsDisableNotes',
1 efrain 376
        'hideuserlist' => 'lockSettingsHideUserList'
377
    ];
378
    /**
379
     * Helper to prepare data used for create meeting.
380
     * @todo moderatorPW and attendeePW will be removed from create after release of BBB v2.6.
381
     *
382
     * @return array
383
     */
384
    protected function create_meeting_data() {
385
        $data = ['meetingID' => $this->instance->get_meeting_id(),
386
            'name' => \mod_bigbluebuttonbn\plugin::html2text($this->instance->get_meeting_name(), 64),
387
            'attendeePW' => $this->instance->get_viewer_password(),
388
            'moderatorPW' => $this->instance->get_moderator_password(),
389
            'logoutURL' => $this->instance->get_logout_url()->out(false),
390
        ];
391
        $data['record'] = $this->instance->should_record() ? 'true' : 'false';
392
        // Check if auto_start_record is enable.
393
        if ($data['record'] == 'true' && $this->instance->should_record_from_start()) {
394
            $data['autoStartRecording'] = 'true';
395
        }
396
        // Check if hide_record_button is enable.
397
        if (!$this->instance->should_show_recording_button()) {
398
            $data['allowStartStopRecording'] = 'false';
399
        }
400
        $data['welcome'] = trim($this->instance->get_welcome_message());
401
        $voicebridge = intval($this->instance->get_voice_bridge());
402
        if ($voicebridge > 0 && $voicebridge < 79999) {
403
            $data['voiceBridge'] = $voicebridge;
404
        }
405
        $maxparticipants = intval($this->instance->get_user_limit());
406
        if ($maxparticipants > 0) {
407
            $data['maxParticipants'] = $maxparticipants;
408
        }
409
        if ($this->instance->get_mute_on_start()) {
410
            $data['muteOnStart'] = 'true';
411
        }
412
        // Here a bit of a change compared to the API default behaviour: we should not allow guest to join
413
        // a meeting managed by Moodle by default.
414
        if ($this->instance->is_guest_allowed()) {
415
            $data['guestPolicy'] = $this->instance->is_moderator_approval_required() ? 'ASK_MODERATOR' : 'ALWAYS_ACCEPT';
416
        }
417
        // Locks settings.
418
        foreach (self::LOCK_SETTINGS_MEETING_DATA as $instancevarname => $lockname) {
419
            $instancevar = $this->instance->get_instance_var($instancevarname);
420
            if (!is_null($instancevar)) {
421
                $data[$lockname] = $instancevar ? 'true' : 'false';
422
                if ($instancevar) {
423
                    $data['lockSettingsLockOnJoin'] = 'true'; // This will be locked whenever one settings is locked.
424
                }
425
            }
426
        }
427
        return $data;
428
    }
429
 
430
    /**
431
     * Helper for preparing metadata used while creating the meeting.
432
     *
433
     * @return array
434
     */
435
    protected function create_meeting_metadata() {
436
        global $USER;
437
        // Create standard metadata.
438
        $origindata = $this->instance->get_origin_data();
439
        $metadata = [
440
            'bbb-origin' => $origindata->origin,
441
            'bbb-origin-version' => $origindata->originVersion,
442
            'bbb-origin-server-name' => $origindata->originServerName,
443
            'bbb-origin-server-common-name' => $origindata->originServerCommonName,
444
            'bbb-origin-tag' => $origindata->originTag,
445
            'bbb-context' => $this->instance->get_course()->fullname,
446
            'bbb-context-id' => $this->instance->get_course_id(),
447
            'bbb-context-name' => trim(html_to_text($this->instance->get_course()->fullname, 0)),
448
            'bbb-context-label' => trim(html_to_text($this->instance->get_course()->shortname, 0)),
449
            'bbb-recording-name' => plugin::html2text($this->instance->get_meeting_name(), 64),
450
            'bbb-recording-description' => plugin::html2text($this->instance->get_meeting_description(),
451
                64),
452
            'bbb-recording-tags' =>
453
                implode(',', core_tag_tag::get_item_tags_array('core',
454
                    'course_modules', $this->instance->get_cm_id())), // Same as $id.
455
        ];
456
        // Special metadata for recording processing.
457
        if ((boolean) config::get('recordingstatus_enabled')) {
458
            $metadata["bn-recording-status"] = json_encode(
459
                [
460
                    'email' => ['"' . fullname($USER) . '" <' . $USER->email . '>'],
461
                    'context' => $this->instance->get_view_url(),
462
                ]
463
            );
464
        }
465
        if ((boolean) config::get('recordingready_enabled')) {
11 efrain 466
            $metadata['bbb-recording-ready-url'] = $this->instance->get_record_ready_url()->out(false);
1 efrain 467
        }
468
        if ((boolean) config::get('meetingevents_enabled')) {
469
            $metadata['analytics-callback-url'] = $this->instance->get_meeting_event_notification_url()->out(false);
470
        }
471
        return $metadata;
472
    }
473
 
474
    /**
475
     * Helper for responding when storing live meeting events is requested.
476
     *
477
     * The callback with a POST request includes:
478
     *  - Authentication: Bearer <A JWT token containing {"exp":<TIMESTAMP>} encoded with HS512>
479
     *  - Content Type: application/json
480
     *  - Body: <A JSON Object>
481
     *
482
     * @param instance $instance
483
     * @param object $data
484
     * @return string
485
     */
486
    public static function meeting_events(instance $instance, object $data): string {
487
        $bigbluebuttonbn = $instance->get_instance_data();
488
        // Validate that the bigbluebuttonbn activity corresponds to the meeting_id received.
489
        $meetingidelements = explode('[', $data->{'meeting_id'});
490
        $meetingidelements = explode('-', $meetingidelements[0]);
491
        if (!isset($bigbluebuttonbn) || $bigbluebuttonbn->meetingid != $meetingidelements[0]) {
492
            return 'HTTP/1.0 410 Gone. The activity may have been deleted';
493
        }
494
 
495
        // We make sure events are processed only once.
496
        $overrides = ['meetingid' => $data->{'meeting_id'}];
497
        $meta['internalmeetingid'] = $data->{'internal_meeting_id'};
498
        $meta['callback'] = 'meeting_events';
499
        $meta['meetingid'] = $data->{'meeting_id'};
1441 ariadna 500
        // Remove attendees from data to avoid duplicating callback logs; they are stored as summary logs.
501
        $meta['data'] = clone $data->{'data'};
502
        unset($meta['data']->{'attendees'});
1 efrain 503
 
504
        $eventcount = logger::log_event_callback($instance, $overrides, $meta);
505
        if ($eventcount === 1) {
506
            // Process the events.
507
            self::process_meeting_events($instance, $data);
508
            return 'HTTP/1.0 200 Accepted. Enqueued.';
509
        } else {
510
            return 'HTTP/1.0 202 Accepted. Already processed.';
511
        }
512
    }
513
 
514
    /**
515
     * Helper function enqueues list of meeting events to be stored and processed as for completion.
516
     *
517
     * @param instance $instance
518
     * @param stdClass $jsonobj
519
     */
520
    protected static function process_meeting_events(instance $instance, stdClass $jsonobj) {
521
        $meetingid = $jsonobj->{'meeting_id'};
522
        $recordid = $jsonobj->{'internal_meeting_id'};
523
        $attendees = $jsonobj->{'data'}->{'attendees'};
524
        foreach ($attendees as $attendee) {
525
            $userid = $attendee->{'ext_user_id'};
526
            $overrides['meetingid'] = $meetingid;
527
            $overrides['userid'] = $userid;
528
            $meta['recordid'] = $recordid;
529
            $meta['data'] = $attendee;
530
 
531
            // Stores the log.
532
            logger::log_event_summary($instance, $overrides, $meta);
533
 
534
            // Enqueue a task for processing the completion.
535
            bigbluebutton_proxy::enqueue_completion_event($instance->get_instance_data(), $userid);
536
        }
537
    }
538
 
539
    /**
540
     * Prepare join meeting action
541
     *
542
     * @param int $origin
543
     * @return void
544
     */
545
    protected function prepare_meeting_join_action(int $origin) {
546
        $this->do_get_meeting_info(true);
547
        if ($this->is_running()) {
548
            if ($this->instance->has_user_limit_been_reached($this->get_participant_count())) {
549
                throw new meeting_join_exception('userlimitreached');
550
            }
551
        } else if ($this->instance->user_must_wait_to_join()) {
552
            // If user is not administrator nor moderator (user is student) and waiting is required.
553
            throw new meeting_join_exception('waitformoderator');
554
        }
555
 
556
        // Moodle event logger: Create an event for meeting joined.
557
        logger::log_meeting_joined_event($this->instance, $origin);
558
 
559
        // Before executing the redirect, increment the number of participants.
560
        roles::participant_joined($this->instance->get_meeting_id(), $this->instance->is_moderator());
561
    }
562
    /**
563
     * Join a meeting.
564
     *
565
     * @param int $origin The spec
566
     * @return string The URL to redirect to
567
     * @throws meeting_join_exception
568
     */
569
    public function join(int $origin): string {
570
        $this->prepare_meeting_join_action($origin);
571
        return $this->get_join_url();
572
    }
573
 
574
    /**
575
     * Join a meeting as a guest.
576
     *
577
     * @param int $origin The spec
578
     * @param string $userfullname Fullname for the guest user
579
     * @return string The URL to redirect to
580
     * @throws meeting_join_exception
581
     */
582
    public function guest_join(int $origin, string $userfullname): string {
583
        $this->prepare_meeting_join_action($origin);
584
        return $this->get_join_url();
585
    }
586
}