Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 400 and 403] [Versions 401 and 403] [Versions 402 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 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              'bbb-meeting-size-hint' =>
 455                  count_enrolled_users(context_course::instance($this->instance->get_course_id()),
 456                      '', $this->instance->get_group_id()),
 457          ];
 458          // Special metadata for recording processing.
 459          if ((boolean) config::get('recordingstatus_enabled')) {
 460              $metadata["bn-recording-status"] = json_encode(
 461                  [
 462                      'email' => ['"' . fullname($USER) . '" <' . $USER->email . '>'],
 463                      'context' => $this->instance->get_view_url(),
 464                  ]
 465              );
 466          }
 467          if ((boolean) config::get('recordingready_enabled')) {
 468              $metadata['bn-recording-ready-url'] = $this->instance->get_record_ready_url()->out(false);
 469          }
 470          if ((boolean) config::get('meetingevents_enabled')) {
 471              $metadata['analytics-callback-url'] = $this->instance->get_meeting_event_notification_url()->out(false);
 472          }
 473          return $metadata;
 474      }
 475  
 476      /**
 477       * Helper for responding when storing live meeting events is requested.
 478       *
 479       * The callback with a POST request includes:
 480       *  - Authentication: Bearer <A JWT token containing {"exp":<TIMESTAMP>} encoded with HS512>
 481       *  - Content Type: application/json
 482       *  - Body: <A JSON Object>
 483       *
 484       * @param instance $instance
 485       * @param object $data
 486       * @return string
 487       */
 488      public static function meeting_events(instance $instance, object $data):  string {
 489          $bigbluebuttonbn = $instance->get_instance_data();
 490          // Validate that the bigbluebuttonbn activity corresponds to the meeting_id received.
 491          $meetingidelements = explode('[', $data->{'meeting_id'});
 492          $meetingidelements = explode('-', $meetingidelements[0]);
 493          if (!isset($bigbluebuttonbn) || $bigbluebuttonbn->meetingid != $meetingidelements[0]) {
 494              return 'HTTP/1.0 410 Gone. The activity may have been deleted';
 495          }
 496  
 497          // We make sure events are processed only once.
 498          $overrides = ['meetingid' => $data->{'meeting_id'}];
 499          $meta['internalmeetingid'] = $data->{'internal_meeting_id'};
 500          $meta['callback'] = 'meeting_events';
 501          $meta['meetingid'] = $data->{'meeting_id'};
 502  
 503          $eventcount = logger::log_event_callback($instance, $overrides, $meta);
 504          if ($eventcount === 1) {
 505              // Process the events.
 506              self::process_meeting_events($instance, $data);
 507              return 'HTTP/1.0 200 Accepted. Enqueued.';
 508          } else {
 509              return 'HTTP/1.0 202 Accepted. Already processed.';
 510          }
 511      }
 512  
 513      /**
 514       * Helper function enqueues list of meeting events to be stored and processed as for completion.
 515       *
 516       * @param instance $instance
 517       * @param stdClass $jsonobj
 518       */
 519      protected static function process_meeting_events(instance $instance, stdClass $jsonobj) {
 520          $meetingid = $jsonobj->{'meeting_id'};
 521          $recordid = $jsonobj->{'internal_meeting_id'};
 522          $attendees = $jsonobj->{'data'}->{'attendees'};
 523          foreach ($attendees as $attendee) {
 524              $userid = $attendee->{'ext_user_id'};
 525              $overrides['meetingid'] = $meetingid;
 526              $overrides['userid'] = $userid;
 527              $meta['recordid'] = $recordid;
 528              $meta['data'] = $attendee;
 529  
 530              // Stores the log.
 531              logger::log_event_summary($instance, $overrides, $meta);
 532  
 533              // Enqueue a task for processing the completion.
 534              bigbluebutton_proxy::enqueue_completion_event($instance->get_instance_data(), $userid);
 535          }
 536      }
 537  
 538      /**
 539       * Prepare join meeting action
 540       *
 541       * @param int $origin
 542       * @return void
 543       */
 544      protected function prepare_meeting_join_action(int $origin) {
 545          $this->do_get_meeting_info(true);
 546          if ($this->is_running()) {
 547              if ($this->instance->has_user_limit_been_reached($this->get_participant_count())) {
 548                  throw new meeting_join_exception('userlimitreached');
 549              }
 550          } else if ($this->instance->user_must_wait_to_join()) {
 551              // If user is not administrator nor moderator (user is student) and waiting is required.
 552              throw new meeting_join_exception('waitformoderator');
 553          }
 554  
 555          // Moodle event logger: Create an event for meeting joined.
 556          logger::log_meeting_joined_event($this->instance, $origin);
 557  
 558          // Before executing the redirect, increment the number of participants.
 559          roles::participant_joined($this->instance->get_meeting_id(), $this->instance->is_moderator());
 560      }
 561      /**
 562       * Join a meeting.
 563       *
 564       * @param int $origin The spec
 565       * @return string The URL to redirect to
 566       * @throws meeting_join_exception
 567       */
 568      public function join(int $origin): string {
 569          $this->prepare_meeting_join_action($origin);
 570          return $this->get_join_url();
 571      }
 572  
 573      /**
 574       * Join a meeting as a guest.
 575       *
 576       * @param int $origin The spec
 577       * @param string $userfullname Fullname for the guest user
 578       * @return string The URL to redirect to
 579       * @throws meeting_join_exception
 580       */
 581      public function guest_join(int $origin, string $userfullname): string {
 582          $this->prepare_meeting_join_action($origin);
 583          return $this->get_join_url();
 584      }
 585  }