Search moodle.org's
Developer Documentation

See Release Notes

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

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