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 communication_matrix; 18 19 use communication_matrix\local\spec\features\matrix\{ 20 create_room_v3 as create_room_feature, 21 get_room_members_v3 as get_room_members_feature, 22 remove_member_from_room_v3 as remove_member_from_room_feature, 23 update_room_avatar_v3 as update_room_avatar_feature, 24 update_room_name_v3 as update_room_name_feature, 25 update_room_topic_v3 as update_room_topic_feature, 26 upload_content_v3 as upload_content_feature, 27 media_create_v1 as media_create_feature, 28 }; 29 use communication_matrix\local\spec\features\synapse\{ 30 create_user_v2 as create_user_feature, 31 get_room_info_v1 as get_room_info_feature, 32 get_user_info_v2 as get_user_info_feature, 33 invite_member_to_room_v1 as invite_member_to_room_feature, 34 }; 35 use core_communication\processor; 36 use stdClass; 37 use GuzzleHttp\Psr7\Response; 38 39 /** 40 * class communication_feature to handle matrix specific actions. 41 * 42 * @package communication_matrix 43 * @copyright 2023 Safat Shahin <safat.shahin@moodle.com> 44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 45 */ 46 class communication_feature implements 47 \core_communication\communication_provider, 48 \core_communication\form_provider, 49 \core_communication\room_chat_provider, 50 \core_communication\room_user_provider, 51 \core_communication\user_provider { 52 /** @var ?matrix_room $room The matrix room object to update room information */ 53 private ?matrix_room $room = null; 54 55 /** @var string|null The URI of the home server */ 56 protected ?string $homeserverurl = null; 57 58 /** @var string The URI of the Matrix web client */ 59 protected string $webclienturl; 60 61 /** @var \communication_matrix\local\spec\v1p1|null The Matrix API processor */ 62 protected ?matrix_client $matrixapi; 63 64 /** 65 * Load the communication provider for the communication api. 66 * 67 * @param processor $communication The communication processor object 68 * @return communication_feature The communication provider object 69 */ 70 public static function load_for_instance(processor $communication): self { 71 return new self($communication); 72 } 73 74 /** 75 * Reload the room information. 76 * This may be necessary after a room has been created or updated via the adhoc task. 77 * This is primarily intended for use in unit testing, but may have real world cases too. 78 */ 79 public function reload(): void { 80 $this->room = null; 81 $this->processor = processor::load_by_id($this->processor->get_id()); 82 } 83 84 /** 85 * Constructor for communication provider to initialize necessary objects for api cals etc.. 86 * 87 * @param processor $processor The communication processor object 88 */ 89 private function __construct( 90 private \core_communication\processor $processor, 91 ) { 92 $this->homeserverurl = get_config('communication_matrix', 'matrixhomeserverurl'); 93 $this->webclienturl = get_config('communication_matrix', 'matrixelementurl'); 94 95 if ($processor::is_provider_available('communication_matrix')) { 96 // Generate the API instance. 97 $this->matrixapi = matrix_client::instance( 98 serverurl: $this->homeserverurl, 99 accesstoken: get_config('communication_matrix', 'matrixaccesstoken'), 100 ); 101 } 102 } 103 104 /** 105 * Check whether the room configuration has been created yet. 106 * 107 * @return bool 108 */ 109 protected function room_exists(): bool { 110 return (bool) $this->get_room_configuration(); 111 } 112 113 /** 114 * Whether the room exists on the remote server. 115 * This does not involve a remote call, but checks whether Moodle is aware of the room id. 116 * @return bool 117 */ 118 protected function remote_room_exists(): bool { 119 $room = $this->get_room_configuration(); 120 121 return $room && ($room->get_room_id() !== null); 122 } 123 124 /** 125 * Get the stored room configuration. 126 * @return null|matrix_room 127 */ 128 public function get_room_configuration(): ?matrix_room { 129 $this->room = matrix_room::load_by_processor_id($this->processor->get_id()); 130 return $this->room; 131 } 132 133 /** 134 * Return the current room id. 135 * 136 * @return string|null 137 */ 138 public function get_room_id(): ?string { 139 return $this->get_room_configuration()?->get_room_id(); 140 } 141 142 /** 143 * Create members. 144 * 145 * @param array $userids The Moodle user ids to create 146 */ 147 public function create_members(array $userids): void { 148 $addedmembers = []; 149 150 // This API requiures the create_user feature. 151 $this->matrixapi->require_feature(create_user_feature::class); 152 153 foreach ($userids as $userid) { 154 $user = \core_user::get_user($userid); 155 $userfullname = fullname($user); 156 157 // Proceed if we have a user's full name and email to work with. 158 if (!empty($user->email) && !empty($userfullname)) { 159 $qualifiedmuid = matrix_user_manager::get_formatted_matrix_userid($user->username); 160 161 // First create user in matrix. 162 $response = $this->matrixapi->create_user( 163 userid: $qualifiedmuid, 164 displayname: $userfullname, 165 threepids: [(object) [ 166 'medium' => 'email', 167 'address' => $user->email, 168 ], ], 169 externalids: [], 170 ); 171 $body = json_decode($response->getBody()); 172 173 if (!empty($matrixuserid = $body->name)) { 174 // Then create matrix user id in moodle. 175 matrix_user_manager::set_matrix_userid_in_moodle($userid, $qualifiedmuid); 176 if ($this->add_registered_matrix_user_to_room($matrixuserid)) { 177 $addedmembers[] = $userid; 178 } 179 } 180 } 181 } 182 183 // Set the power level of the users. 184 if (!empty($addedmembers) && $this->is_power_levels_update_required($addedmembers)) { 185 $this->set_matrix_power_levels(); 186 } 187 188 // Mark then users as synced for the added members. 189 $this->processor->mark_users_as_synced($addedmembers); 190 } 191 192 public function update_room_membership(array $userids): void { 193 $this->set_matrix_power_levels(); 194 // Mark the users as synced for the updated members. 195 $this->processor->mark_users_as_synced($userids); 196 } 197 198 /** 199 * Add members to a room. 200 * 201 * @param array $userids The user ids to add 202 */ 203 public function add_members_to_room(array $userids): void { 204 $unregisteredmembers = []; 205 $addedmembers = []; 206 207 foreach ($userids as $userid) { 208 $matrixuserid = matrix_user_manager::get_matrixid_from_moodle($userid); 209 210 if ($matrixuserid && $this->check_user_exists($matrixuserid)) { 211 if ($this->add_registered_matrix_user_to_room($matrixuserid)) { 212 $addedmembers[] = $userid; 213 } 214 } else { 215 $unregisteredmembers[] = $userid; 216 } 217 } 218 219 // Set the power level of the users. 220 if (!empty($addedmembers) && $this->is_power_levels_update_required($addedmembers)) { 221 $this->set_matrix_power_levels(); 222 } 223 224 // Mark then users as synced for the added members. 225 $this->processor->mark_users_as_synced($addedmembers); 226 227 // Create Matrix users. 228 if (count($unregisteredmembers) > 0) { 229 $this->create_members($unregisteredmembers); 230 } 231 } 232 233 /** 234 * Adds the registered matrix user id to room. 235 * 236 * @param string $matrixuserid Registered matrix user id 237 */ 238 private function add_registered_matrix_user_to_room(string $matrixuserid): bool { 239 // Require the invite_member_to_room API feature. 240 $this->matrixapi->require_feature(invite_member_to_room_feature::class); 241 242 if (!$this->check_room_membership($matrixuserid)) { 243 $response = $this->matrixapi->invite_member_to_room( 244 roomid: $this->get_room_id(), 245 userid: $matrixuserid, 246 ); 247 248 $body = self::get_body($response); 249 if (empty($body->room_id)) { 250 return false; 251 } 252 253 if ($body->room_id !== $this->get_room_id()) { 254 return false; 255 } 256 257 return true; 258 } 259 return false; 260 } 261 262 /** 263 * Remove members from a room. 264 * 265 * @param array $userids The Moodle user ids to remove 266 */ 267 public function remove_members_from_room(array $userids): void { 268 // This API requiures the remove_members_from_room feature. 269 $this->matrixapi->require_feature(remove_member_from_room_feature::class); 270 271 if ($this->get_room_id() === null) { 272 return; 273 } 274 275 // Remove the power level for the user first. 276 $this->set_matrix_power_levels($userids); 277 278 $membersremoved = []; 279 280 $currentpowerlevels = $this->get_current_powerlevel_data(); 281 $currentuserpowerlevels = (array) $currentpowerlevels->users ?? []; 282 283 foreach ($userids as $userid) { 284 // Check user is member of room first. 285 $matrixuserid = matrix_user_manager::get_matrixid_from_moodle($userid); 286 287 if (!$matrixuserid) { 288 // Unable to find a matrix userid for this user. 289 continue; 290 } 291 292 if (array_key_exists($matrixuserid, $currentuserpowerlevels)) { 293 if ($currentuserpowerlevels[$matrixuserid] >= matrix_constants::POWER_LEVEL_MAXIMUM) { 294 // Skip removing the user if they are an admin. 295 continue; 296 } 297 } 298 299 if ( 300 $this->check_user_exists($matrixuserid) && 301 $this->check_room_membership($matrixuserid) 302 ) { 303 $this->matrixapi->remove_member_from_room( 304 roomid: $this->get_room_id(), 305 userid: $matrixuserid, 306 ); 307 308 $membersremoved[] = $userid; 309 } 310 } 311 312 $this->processor->delete_instance_user_mapping($membersremoved); 313 } 314 315 /** 316 * Check if a user exists in Matrix. 317 * Use if user existence is needed before doing something else. 318 * 319 * @param string $matrixuserid The Matrix user id to check 320 * @return bool 321 */ 322 public function check_user_exists(string $matrixuserid): bool { 323 // This API requires the get_user_info feature. 324 $this->matrixapi->require_feature(get_user_info_feature::class); 325 326 $response = $this->matrixapi->get_user_info( 327 userid: $matrixuserid, 328 ); 329 $body = self::get_body($response); 330 331 return isset($body->name); 332 } 333 334 /** 335 * Check if a user is a member of a room. 336 * Use if membership confirmation is needed before doing something else. 337 * 338 * @param string $matrixuserid The Matrix user id to check 339 * @return bool 340 */ 341 public function check_room_membership(string $matrixuserid): bool { 342 // This API requires the get_room_members feature. 343 $this->matrixapi->require_feature(get_room_members_feature::class); 344 345 $response = $this->matrixapi->get_room_members( 346 roomid: $this->get_room_id(), 347 ); 348 $body = self::get_body($response); 349 350 // Check user id is in the returned room member ids. 351 return isset($body->joined) && array_key_exists($matrixuserid, (array) $body->joined); 352 } 353 354 /** 355 * Create a room based on the data in the communication instance. 356 * 357 * @return bool 358 */ 359 public function create_chat_room(): bool { 360 if ($this->remote_room_exists()) { 361 // A room already exists. Update it instead. 362 return $this->update_chat_room(); 363 } 364 365 // This method requires the create_room API feature. 366 $this->matrixapi->require_feature(create_room_feature::class); 367 368 $room = $this->get_room_configuration(); 369 370 $response = $this->matrixapi->create_room( 371 name: $this->processor->get_room_name(), 372 visibility: 'private', 373 preset: 'private_chat', 374 initialstate: [], 375 options: [ 376 'topic' => $room->get_topic(), 377 ], 378 ); 379 380 $response = self::get_body($response); 381 382 if (empty($response->room_id)) { 383 throw new \moodle_exception( 384 'Unable to determine ID of matrix room', 385 ); 386 } 387 388 // Update our record of the matrix room_id. 389 $room->update_room_record( 390 roomid: $response->room_id, 391 ); 392 393 // Update the room avatar. 394 $this->update_room_avatar(); 395 return true; 396 } 397 398 public function update_chat_room(): bool { 399 if (!$this->remote_room_exists()) { 400 // No room exists. Create it instead. 401 return $this->create_chat_room(); 402 } 403 404 $this->matrixapi->require_features([ 405 get_room_info_feature::class, 406 update_room_name_feature::class, 407 update_room_topic_feature::class, 408 ]); 409 410 // Get room data. 411 $response = $this->matrixapi->get_room_info( 412 roomid: $this->get_room_id(), 413 ); 414 $remoteroomdata = self::get_body($response); 415 416 // Update the room name when it's updated from the form. 417 if ($remoteroomdata->name !== $this->processor->get_room_name()) { 418 $this->matrixapi->update_room_name( 419 roomid: $this->get_room_id(), 420 name: $this->processor->get_room_name(), 421 ); 422 } 423 424 // Update the room topic if set. 425 $localroomdata = $this->get_room_configuration(); 426 if ($remoteroomdata->topic !== $localroomdata->get_topic()) { 427 $this->matrixapi->update_room_topic( 428 roomid: $localroomdata->get_room_id(), 429 topic: $localroomdata->get_topic(), 430 ); 431 } 432 433 // Update room avatar. 434 $this->update_room_avatar(); 435 436 return true; 437 } 438 439 public function delete_chat_room(): bool { 440 $this->get_room_configuration()->delete_room_record(); 441 $this->room = null; 442 443 return true; 444 } 445 446 /** 447 * Update the room avatar when an instance image is added or updated. 448 */ 449 public function update_room_avatar(): void { 450 // Both of the following features of the remote API are required. 451 $this->matrixapi->require_features([ 452 upload_content_feature::class, 453 update_room_avatar_feature::class, 454 ]); 455 456 // Check if we have an avatar that needs to be synced. 457 if ($this->processor->is_avatar_synced()) { 458 return; 459 } 460 461 $instanceimage = $this->processor->get_avatar(); 462 $contenturi = null; 463 464 if ($this->matrixapi->implements_feature(media_create_feature::class)) { 465 // From version 1.7 we can fetch a mxc URI and use it before uploading the content. 466 if ($instanceimage) { 467 $response = $this->matrixapi->media_create(); 468 $contenturi = self::get_body($response)->content_uri; 469 470 // Now update the room avatar. 471 $response = $this->matrixapi->update_room_avatar( 472 roomid: $this->get_room_id(), 473 avatarurl: $contenturi, 474 ); 475 476 // And finally upload the content. 477 $this->matrixapi->upload_content($instanceimage); 478 } else { 479 $response = $this->matrixapi->update_room_avatar( 480 roomid: $this->get_room_id(), 481 avatarurl: null, 482 ); 483 } 484 } else { 485 // Prior to v1.7 the only way to upload content was to upload the content, which returns a mxc URI to use. 486 487 if ($instanceimage) { 488 // First upload the content. 489 $response = $this->matrixapi->upload_content($instanceimage); 490 $body = self::get_body($response); 491 $contenturi = $body->content_uri; 492 } 493 494 // Now update the room avatar. 495 $response = $this->matrixapi->update_room_avatar( 496 roomid: $this->get_room_id(), 497 avatarurl: $contenturi, 498 ); 499 } 500 501 // Indicate the avatar has been synced if it was successfully set with Matrix. 502 if ($response->getReasonPhrase() === 'OK') { 503 $this->processor->set_avatar_synced_flag(true); 504 } 505 } 506 507 public function get_chat_room_url(): ?string { 508 if (!$this->get_room_id()) { 509 // We don't have a room id for this record. 510 return null; 511 } 512 513 return sprintf( 514 "%s#/room/%s", 515 $this->webclienturl, 516 $this->get_room_id(), 517 ); 518 } 519 520 public function save_form_data(\stdClass $instance): void { 521 $matrixroomtopic = $instance->matrixroomtopic ?? null; 522 $room = $this->get_room_configuration(); 523 524 if ($room) { 525 $room->update_room_record( 526 topic: $matrixroomtopic, 527 ); 528 } else { 529 $this->room = matrix_room::create_room_record( 530 processorid: $this->processor->get_id(), 531 topic: $matrixroomtopic, 532 ); 533 } 534 } 535 536 public function set_form_data(\stdClass $instance): void { 537 if (!empty($instance->id) && !empty($this->processor->get_id())) { 538 if ($this->room_exists()) { 539 $instance->matrixroomtopic = $this->get_room_configuration()->get_topic(); 540 } 541 } 542 } 543 544 public static function set_form_definition(\MoodleQuickForm $mform): void { 545 // Room description for the communication provider. 546 $mform->insertElementBefore($mform->createElement( 547 'text', 548 'matrixroomtopic', 549 get_string('matrixroomtopic', 'communication_matrix'), 550 'maxlength="255" size="20"' 551 ), 'addcommunicationoptionshere'); 552 $mform->addHelpButton('matrixroomtopic', 'matrixroomtopic', 'communication_matrix'); 553 $mform->setType('matrixroomtopic', PARAM_TEXT); 554 } 555 556 /** 557 * Get the body of a response as a stdClass. 558 * 559 * @param Response $response 560 * @return stdClass 561 */ 562 public static function get_body(Response $response): stdClass { 563 $body = $response->getBody(); 564 565 return json_decode($body, false, 512, JSON_THROW_ON_ERROR); 566 } 567 568 /** 569 * Set the matrix power level with the room. 570 * 571 * Users with a non-moodle power level are not typically removed unless specified in the $forceremoval param. 572 * Matrix Admin users are never removed. 573 * 574 * @param array $forceremoval The users to force removal from the room, even if they have a custom power level 575 */ 576 private function set_matrix_power_levels( 577 array $forceremoval = [], 578 ): void { 579 // Get the current power levels. 580 $currentpowerlevels = $this->get_current_powerlevel_data(); 581 $currentuserpowerlevels = (array) $currentpowerlevels->users ?? []; 582 583 // Get all the current users who need to be in the room. 584 $userlist = $this->processor->get_all_userids_for_instance(); 585 586 // Translate the user ids to matrix user ids. 587 $userlist = array_combine( 588 array_map( 589 fn ($userid) => matrix_user_manager::get_matrixid_from_moodle($userid), 590 $userlist, 591 ), 592 $userlist, 593 ); 594 595 // Determine the power levels, and filter out anyone with the default level. 596 $newuserpowerlevels = array_filter( 597 array_map( 598 fn($userid) => $this->get_user_allowed_power_level($userid), 599 $userlist, 600 ), 601 fn($level) => $level !== matrix_constants::POWER_LEVEL_DEFAULT, 602 ); 603 604 // Keep current room admins, and users which don't use our MODERATOR power level without changing them. 605 $staticusers = $this->get_users_with_custom_power_level($currentuserpowerlevels); 606 foreach ($staticusers as $userid => $level) { 607 $newuserpowerlevels[$userid] = $level; 608 } 609 610 if (!empty($forceremoval)) { 611 // Remove the users from the power levels if they are not admins. 612 foreach ($forceremoval as $userid) { 613 if ($newuserpowerlevels < matrix_constants::POWER_LEVEL_MAXIMUM) { 614 unset($newuserpowerlevels[$userid]); 615 } 616 } 617 } 618 619 if (!$this->power_levels_changed($currentuserpowerlevels, $newuserpowerlevels)) { 620 // No changes to make. 621 return; 622 } 623 624 625 // Update the power levels for the room. 626 $this->matrixapi->update_room_power_levels( 627 roomid: $this->get_room_id(), 628 users: $newuserpowerlevels, 629 ); 630 } 631 632 /** 633 * Filter the list of users provided to remove those with a moodle-related power level. 634 * 635 * @param array $users 636 * @return array 637 */ 638 private function get_users_with_custom_power_level(array $users): array { 639 return array_filter( 640 $users, 641 function ($level): bool { 642 switch ($level) { 643 case matrix_constants::POWER_LEVEL_DEFAULT: 644 case matrix_constants::POWER_LEVEL_MOODLE_SITE_ADMIN: 645 case matrix_constants::POWER_LEVEL_MOODLE_MODERATOR: 646 return false; 647 default: 648 return true; 649 } 650 }, 651 ); 652 } 653 654 /** 655 * Check whether power levels have changed compared with the proposed power levels. 656 * 657 * @param array $currentuserpowerlevels The current power levels 658 * @param array $newuserpowerlevels The new power levels proposed 659 * @return bool Whether there is any change to be made 660 */ 661 private function power_levels_changed( 662 array $currentuserpowerlevels, 663 array $newuserpowerlevels, 664 ): bool { 665 if (count($newuserpowerlevels) !== count($currentuserpowerlevels)) { 666 // Different number of keys - there must be a difference then. 667 return true; 668 } 669 670 // Sort the power levels. 671 ksort($newuserpowerlevels, SORT_NUMERIC); 672 673 // Get the current power levels. 674 ksort($currentuserpowerlevels); 675 676 $diff = array_merge( 677 array_diff_assoc( 678 $newuserpowerlevels, 679 $currentuserpowerlevels, 680 ), 681 array_diff_assoc( 682 $currentuserpowerlevels, 683 $newuserpowerlevels, 684 ), 685 ); 686 687 return count($diff) > 0; 688 } 689 690 /** 691 * Get the current power level for the room. 692 * 693 * @return stdClass 694 */ 695 private function get_current_powerlevel_data(): \stdClass { 696 $roomid = $this->get_room_id(); 697 $response = $this->matrixapi->get_room_power_levels( 698 roomid: $roomid, 699 ); 700 if ($response->getStatusCode() !== 200) { 701 throw new \moodle_exception( 702 'Unable to get power levels for room', 703 ); 704 } 705 706 $powerdata = $this->get_body($response); 707 $powerdata = array_filter( 708 $powerdata->rooms->join->{$roomid}->state->events, 709 fn($value) => $value->type === 'm.room.power_levels' 710 ); 711 $powerdata = reset($powerdata); 712 713 return $powerdata->content; 714 } 715 716 /** 717 * Determine if a power level update is required. 718 * Matrix will always set a user to the default power level of 0 when a power level update is made. 719 * That is, unless we specify another level. As long as one person's level is greater than the default, 720 * we will need to set the power levels of all users greater than the default. 721 * 722 * @param array $userids The users to evaluate 723 * @return boolean Returns true if an update is required 724 */ 725 private function is_power_levels_update_required(array $userids): bool { 726 // Is the user's power level greater than the default? 727 foreach ($userids as $userid) { 728 if ($this->get_user_allowed_power_level($userid) > matrix_constants::POWER_LEVEL_DEFAULT) { 729 return true; 730 } 731 } 732 return false; 733 } 734 735 /** 736 * Get the allowed power level for the user id according to perms/site admin or default. 737 * 738 * @param int $userid 739 * @return int 740 */ 741 public function get_user_allowed_power_level(int $userid): int { 742 $powerlevel = matrix_constants::POWER_LEVEL_DEFAULT; 743 744 if (has_capability('communication/matrix:moderator', $this->processor->get_context(), $userid)) { 745 $powerlevel = matrix_constants::POWER_LEVEL_MOODLE_MODERATOR; 746 } 747 748 // If site admin, override all caps. 749 if (is_siteadmin($userid)) { 750 $powerlevel = matrix_constants::POWER_LEVEL_MOODLE_SITE_ADMIN; 751 } 752 753 return $powerlevel; 754 } 755 756 /* 757 * Check if matrix settings are configured 758 * 759 * @return boolean 760 */ 761 public static function is_configured(): bool { 762 // Matrix communication settings. 763 $matrixhomeserverurl = get_config('communication_matrix', 'matrixhomeserverurl'); 764 $matrixaccesstoken = get_config('communication_matrix', 'matrixaccesstoken'); 765 $matrixelementurl = get_config('communication_matrix', 'matrixelementurl'); 766 767 if ( 768 !empty($matrixhomeserverurl) && 769 !empty($matrixaccesstoken) && 770 (PHPUNIT_TEST || defined('BEHAT_SITE_RUNNING') || !empty($matrixelementurl)) 771 ) { 772 return true; 773 } 774 return false; 775 } 776 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body