Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 400 and 401] [Versions 400 and 402] [Versions 400 and 403]

   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 mixed
 135       */
 136      public function get_attendees() {
 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($data, $metadata, $presentationname, $presentationurl);
 170          // New recording management: Insert a recordingID that corresponds to the meeting created.
 171          if ($this->instance->is_recorded()) {
 172              $recording = new recording(0, (object) [
 173                  'courseid' => $this->instance->get_course_id(),
 174                  'bigbluebuttonbnid' => $this->instance->get_instance_id(),
 175                  'recordingid' => $response['internalMeetingID'],
 176                  'groupid' => $this->instance->get_group_id()]
 177              );
 178              $recording->create();
 179          }
 180          return $response;
 181      }
 182  
 183      /**
 184       * Send an end meeting message to BBB server
 185       */
 186      public function end_meeting() {
 187          bigbluebutton_proxy::end_meeting($this->instance->get_meeting_id(), $this->instance->get_moderator_password());
 188      }
 189  
 190      /**
 191       * Get meeting join URL
 192       *
 193       * @return string
 194       */
 195      public function get_join_url() {
 196          return bigbluebutton_proxy::get_join_url(
 197              $this->instance->get_meeting_id(),
 198              $this->instance->get_user_fullname(),
 199              $this->instance->get_current_user_password(),
 200              $this->instance->get_logout_url()->out(false),
 201              $this->instance->get_current_user_role(),
 202              null,
 203              $this->instance->get_user_id(),
 204              $this->get_meeting_info()->createtime
 205          );
 206      }
 207  
 208      /**
 209       * Return meeting information for this meeting.
 210       *
 211       * @param bool $updatecache Whether to update the cache when fetching the information
 212       * @return stdClass
 213       */
 214      protected function do_get_meeting_info(bool $updatecache = false): stdClass {
 215          $instance = $this->instance;
 216          $meetinginfo = (object) [
 217              'instanceid' => $instance->get_instance_id(),
 218              'bigbluebuttonbnid' => $instance->get_instance_id(),
 219              'groupid' => $instance->get_group_id(),
 220              'meetingid' => $instance->get_meeting_id(),
 221              'cmid' => $instance->get_cm_id(),
 222              'ismoderator' => $instance->is_moderator(),
 223              'joinurl' => $instance->get_join_url()->out(),
 224              'userlimit' => $instance->get_user_limit(),
 225              'presentations' => [],
 226          ];
 227          if ($instance->get_instance_var('openingtime')) {
 228              $meetinginfo->openingtime = intval($instance->get_instance_var('openingtime'));
 229          }
 230          if ($instance->get_instance_var('closingtime')) {
 231              $meetinginfo->closingtime = intval($instance->get_instance_var('closingtime'));
 232          }
 233          $activitystatus = bigbluebutton_proxy::view_get_activity_status($instance);
 234          // This might raise an exception if info cannot be retrieved.
 235          // But this might be totally fine as the meeting is maybe not yet created on BBB side.
 236          $totalusercount = 0;
 237          // This is the default value for any meeting that has not been created.
 238          $meetinginfo->statusrunning = false;
 239          $meetinginfo->createtime = null;
 240  
 241          $info = self::retrieve_cached_meeting_info($this->instance->get_meeting_id(), $updatecache);
 242          if (!empty($info)) {
 243              $meetinginfo->statusrunning = $info['running'] === 'true';
 244              $meetinginfo->createtime = $info['createTime'] ?? null;
 245              $totalusercount = isset($info['participantCount']) ? $info['participantCount'] : 0;
 246          }
 247  
 248          $meetinginfo->statusclosed = $activitystatus === 'ended';
 249          $meetinginfo->statusopen = !$meetinginfo->statusrunning && $activitystatus === 'open';
 250          $meetinginfo->totalusercount = $totalusercount;
 251  
 252          $canjoin = !$instance->user_must_wait_to_join() || $meetinginfo->statusrunning;
 253          // Limit has not been reached.
 254          $canjoin = $canjoin && (!$instance->has_user_limit_been_reached($totalusercount));
 255          // User should only join during scheduled session start and end time, if defined.
 256          $canjoin = $canjoin && ($instance->is_currently_open());
 257          // Double check that the user has the capabilities to join.
 258          $canjoin = $canjoin && $instance->can_join();
 259          $meetinginfo->canjoin = $canjoin;
 260  
 261          // If user is administrator, moderator or if is viewer and no waiting is required, join allowed.
 262          if ($meetinginfo->statusrunning) {
 263              $meetinginfo->startedat = floor(intval($info['startTime']) / 1000); // Milliseconds.
 264              $meetinginfo->moderatorcount = $info['moderatorCount'];
 265              $meetinginfo->moderatorplural = $info['moderatorCount'] > 1;
 266              $meetinginfo->participantcount = $totalusercount - $meetinginfo->moderatorcount;
 267              $meetinginfo->participantplural = $meetinginfo->participantcount > 1;
 268          }
 269          $meetinginfo->statusmessage = $this->get_status_message($meetinginfo, $instance);
 270  
 271          $presentation = $instance->get_presentation(); // This is for internal use.
 272          if (!empty($presentation)) {
 273              $meetinginfo->presentations[] = $presentation;
 274          }
 275          $meetinginfo->attendees = [];
 276          if (!empty($info['attendees'])) {
 277              // Make sure attendees is an array of object, not a simpleXML object.
 278              foreach ($info['attendees'] as $attendee) {
 279                  $meetinginfo->attendees[] = (array) $attendee;
 280              }
 281          }
 282          return $meetinginfo;
 283      }
 284  
 285      /**
 286       * Deduce status message from the current meeting info and the instance
 287       *
 288       * Returns the human-readable message depending on if the user must wait to join, the meeting has not
 289       * yet started ...
 290       * @param object $meetinginfo
 291       * @param instance $instance
 292       * @return string
 293       */
 294      protected function get_status_message(object $meetinginfo, instance $instance): string {
 295          if ($instance->has_user_limit_been_reached($meetinginfo->totalusercount)) {
 296              return get_string('view_message_conference_user_limit_reached', 'bigbluebuttonbn');
 297          }
 298          if ($meetinginfo->statusrunning) {
 299              return get_string('view_message_conference_in_progress', 'bigbluebuttonbn');
 300          }
 301          if ($instance->user_must_wait_to_join() && !$instance->user_can_force_join()) {
 302              return get_string('view_message_conference_wait_for_moderator', 'bigbluebuttonbn');
 303          }
 304          if ($instance->before_start_time()) {
 305              return get_string('view_message_conference_not_started', 'bigbluebuttonbn');
 306          }
 307          if ($instance->has_ended()) {
 308              return get_string('view_message_conference_has_ended', 'bigbluebuttonbn');
 309          }
 310          return get_string('view_message_conference_room_ready', 'bigbluebuttonbn');
 311      }
 312  
 313      /**
 314       * Gets a meeting info object cached or fetched from the live session.
 315       *
 316       * @param string $meetingid
 317       * @param bool $updatecache
 318       *
 319       * @return array
 320       */
 321      protected static function retrieve_cached_meeting_info($meetingid, $updatecache = false) {
 322          $cachettl = (int) config::get('waitformoderator_cache_ttl');
 323          $cache = cache::make_from_params(cache_store::MODE_APPLICATION, 'mod_bigbluebuttonbn', 'meetings_cache');
 324          $result = $cache->get($meetingid);
 325          $now = time();
 326          if (!$updatecache && !empty($result) && $now < ($result['creation_time'] + $cachettl)) {
 327              // Use the value in the cache.
 328              return (array) json_decode($result['meeting_info']);
 329          }
 330          // We set the cache to an empty value so then if get_meeting_info raises an exception we still have the
 331          // info about the last creation_time, so we don't ask the server again for a bit.
 332          $defaultcacheinfo = ['creation_time' => time(), 'meeting_info' => '[]'];
 333          // Pings again and refreshes the cache.
 334          try {
 335              $meetinginfo = bigbluebutton_proxy::get_meeting_info($meetingid);
 336              $cache->set($meetingid, ['creation_time' => time(), 'meeting_info' => json_encode($meetinginfo)]);
 337          } catch (bigbluebutton_exception $e) {
 338              // The meeting is not created on BBB side, so we set the value in the cache so we don't poll again
 339              // and return an empty array.
 340              $cache->set($meetingid, $defaultcacheinfo);
 341              return [];
 342          }
 343          return $meetinginfo;
 344      }
 345  
 346      /**
 347       * Conversion between form settings and lockSettings as set in BBB API.
 348       */
 349      const LOCK_SETTINGS_MEETING_DATA = [
 350          'disablecam' => 'lockSettingsDisableCam',
 351          'disablemic' => 'lockSettingsDisableMic',
 352          'disableprivatechat' => 'lockSettingsDisablePrivateChat',
 353          'disablepublicchat' => 'lockSettingsDisablePublicChat',
 354          'disablenote' => 'lockSettingsDisableNote',
 355          'lockonjoin' => 'lockSettingsLockOnJoin',
 356          'hideuserlist' => 'lockSettingsHideUserList'
 357      ];
 358      /**
 359       * Helper to prepare data used for create meeting.
 360       * @todo moderatorPW and attendeePW will be removed from create after release of BBB v2.6.
 361       *
 362       * @return array
 363       */
 364      protected function create_meeting_data() {
 365          $data = ['meetingID' => $this->instance->get_meeting_id(),
 366              'name' => \mod_bigbluebuttonbn\plugin::html2text($this->instance->get_meeting_name(), 64),
 367              'attendeePW' => $this->instance->get_viewer_password(),
 368              'moderatorPW' => $this->instance->get_moderator_password(),
 369              'logoutURL' => $this->instance->get_logout_url()->out(false),
 370          ];
 371          $data['record'] = $this->instance->should_record() ? 'true' : 'false';
 372          // Check if auto_start_record is enable.
 373          if ($data['record'] == 'true' && $this->instance->should_record_from_start()) {
 374              $data['autoStartRecording'] = 'true';
 375          }
 376          // Check if hide_record_button is enable.
 377          if (!$this->instance->should_show_recording_button()) {
 378              $data['allowStartStopRecording'] = 'false';
 379          }
 380          $data['welcome'] = trim($this->instance->get_welcome_message());
 381          $voicebridge = intval($this->instance->get_voice_bridge());
 382          if ($voicebridge > 0 && $voicebridge < 79999) {
 383              $data['voiceBridge'] = $voicebridge;
 384          }
 385          $maxparticipants = intval($this->instance->get_user_limit());
 386          if ($maxparticipants > 0) {
 387              $data['maxParticipants'] = $maxparticipants;
 388          }
 389          if ($this->instance->get_mute_on_start()) {
 390              $data['muteOnStart'] = 'true';
 391          }
 392          // Locks settings.
 393          foreach (self::LOCK_SETTINGS_MEETING_DATA as $instancevarname => $lockname) {
 394              $instancevar = $this->instance->get_instance_var($instancevarname);
 395              if (!is_null($instancevar)) {
 396                  $data[$lockname] = $instancevar ? 'true' : 'false';
 397              }
 398          }
 399          return $data;
 400      }
 401  
 402      /**
 403       * Helper for preparing metadata used while creating the meeting.
 404       *
 405       * @return array
 406       */
 407      protected function create_meeting_metadata() {
 408          global $USER;
 409          // Create standard metadata.
 410          $origindata = $this->instance->get_origin_data();
 411          $metadata = [
 412              'bbb-origin' => $origindata->origin,
 413              'bbb-origin-version' => $origindata->originVersion,
 414              'bbb-origin-server-name' => $origindata->originServerName,
 415              'bbb-origin-server-common-name' => $origindata->originServerCommonName,
 416              'bbb-origin-tag' => $origindata->originTag,
 417              'bbb-context' => $this->instance->get_course()->fullname,
 418              'bbb-context-id' => $this->instance->get_course_id(),
 419              'bbb-context-name' => trim(html_to_text($this->instance->get_course()->fullname, 0)),
 420              'bbb-context-label' => trim(html_to_text($this->instance->get_course()->shortname, 0)),
 421              'bbb-recording-name' => plugin::html2text($this->instance->get_meeting_name(), 64),
 422              'bbb-recording-description' => plugin::html2text($this->instance->get_meeting_description(),
 423                  64),
 424              'bbb-recording-tags' =>
 425                  implode(',', core_tag_tag::get_item_tags_array('core',
 426                      'course_modules', $this->instance->get_cm_id())), // Same as $id.
 427              'bbb-meeting-size-hint' =>
 428                  count_enrolled_users(context_course::instance($this->instance->get_course_id()),
 429                      '', $this->instance->get_group_id()),
 430          ];
 431          // Special metadata for recording processing.
 432          if ((boolean) config::get('recordingstatus_enabled')) {
 433              $metadata["bn-recording-status"] = json_encode(
 434                  [
 435                      'email' => ['"' . fullname($USER) . '" <' . $USER->email . '>'],
 436                      'context' => $this->instance->get_view_url(),
 437                  ]
 438              );
 439          }
 440          if ((boolean) config::get('recordingready_enabled')) {
 441              $metadata['bn-recording-ready-url'] = $this->instance->get_record_ready_url()->out(false);
 442          }
 443          if ((boolean) config::get('meetingevents_enabled')) {
 444              $metadata['analytics-callback-url'] = $this->instance->get_meeting_event_notification_url()->out(false);
 445          }
 446          return $metadata;
 447      }
 448  
 449      /**
 450       * Helper for responding when storing live meeting events is requested.
 451       *
 452       * The callback with a POST request includes:
 453       *  - Authentication: Bearer <A JWT token containing {"exp":<TIMESTAMP>} encoded with HS512>
 454       *  - Content Type: application/json
 455       *  - Body: <A JSON Object>
 456       *
 457       * @param instance $instance
 458       * @param object $data
 459       * @return string
 460       */
 461      public static function meeting_events(instance $instance, object $data):  string {
 462          $bigbluebuttonbn = $instance->get_instance_data();
 463          // Validate that the bigbluebuttonbn activity corresponds to the meeting_id received.
 464          $meetingidelements = explode('[', $data->{'meeting_id'});
 465          $meetingidelements = explode('-', $meetingidelements[0]);
 466          if (!isset($bigbluebuttonbn) || $bigbluebuttonbn->meetingid != $meetingidelements[0]) {
 467              return 'HTTP/1.0 410 Gone. The activity may have been deleted';
 468          }
 469  
 470          // We make sure events are processed only once.
 471          $overrides = ['meetingid' => $data->{'meeting_id'}];
 472          $meta['internalmeetingid'] = $data->{'internal_meeting_id'};
 473          $meta['callback'] = 'meeting_events';
 474          $meta['meetingid'] = $data->{'meeting_id'};
 475  
 476          $eventcount = logger::log_event_callback($instance, $overrides, $meta);
 477          if ($eventcount === 1) {
 478              // Process the events.
 479              self::process_meeting_events($instance, $data);
 480              return 'HTTP/1.0 200 Accepted. Enqueued.';
 481          } else {
 482              return 'HTTP/1.0 202 Accepted. Already processed.';
 483          }
 484      }
 485  
 486      /**
 487       * Helper function enqueues list of meeting events to be stored and processed as for completion.
 488       *
 489       * @param instance $instance
 490       * @param stdClass $jsonobj
 491       */
 492      protected static function process_meeting_events(instance $instance, stdClass $jsonobj) {
 493          $meetingid = $jsonobj->{'meeting_id'};
 494          $recordid = $jsonobj->{'internal_meeting_id'};
 495          $attendees = $jsonobj->{'data'}->{'attendees'};
 496          foreach ($attendees as $attendee) {
 497              $userid = $attendee->{'ext_user_id'};
 498              $overrides['meetingid'] = $meetingid;
 499              $overrides['userid'] = $userid;
 500              $meta['recordid'] = $recordid;
 501              $meta['data'] = $attendee;
 502  
 503              // Stores the log.
 504              logger::log_event_summary($instance, $overrides, $meta);
 505  
 506              // Enqueue a task for processing the completion.
 507              bigbluebutton_proxy::enqueue_completion_event($instance->get_instance_data(), $userid);
 508          }
 509      }
 510  
 511      /**
 512       * Join a meeting.
 513       *
 514       * @param int $origin The spec
 515       * @return string The URL to redirect to
 516       * @throws meeting_join_exception
 517       */
 518      public function join(int $origin): string {
 519          $this->do_get_meeting_info(true);
 520          if ($this->is_running()) {
 521              if ($this->instance->has_user_limit_been_reached($this->get_participant_count())) {
 522                  throw new meeting_join_exception('userlimitreached');
 523              }
 524          } else if ($this->instance->user_must_wait_to_join()) {
 525              // If user is not administrator nor moderator (user is student) and waiting is required.
 526              throw new meeting_join_exception('waitformoderator');
 527          }
 528  
 529          // Moodle event logger: Create an event for meeting joined.
 530          logger::log_meeting_joined_event($this->instance, $origin);
 531  
 532          // Before executing the redirect, increment the number of participants.
 533          roles::participant_joined($this->instance->get_meeting_id(), $this->instance->is_moderator());
 534          return $this->get_join_url();
 535      }
 536  }