diff --git a/communication/provider/matrix/classes/communication_feature.php b/communication/provider/matrix/classes/communication_feature.php index 81637438425..5c7aa16e180 100644 --- a/communication/provider/matrix/classes/communication_feature.php +++ b/communication/provider/matrix/classes/communication_feature.php @@ -50,7 +50,7 @@ class communication_feature implements \core_communication\room_user_provider, \core_communication\form_provider { - /** @var matrix_room $room The matrix room object to update room information */ + /** @var ?matrix_room $room The matrix room object to update room information */ private ?matrix_room $room = null; /** @var string|null The URI of the home server */ @@ -127,10 +127,7 @@ class communication_feature implements * @return null|matrix_room */ public function get_room_configuration(): ?matrix_room { - if ($this->room === null) { - $this->room = matrix_room::load_by_processor_id($this->processor->get_id()); - } - + $this->room = matrix_room::load_by_processor_id($this->processor->get_id()); return $this->room; } @@ -184,10 +181,21 @@ class communication_feature implements } } + // Set the power level of the users. + if (!empty($addedmembers) && $this->is_power_levels_update_required($addedmembers)) { + $this->set_matrix_power_levels(); + } + // Mark then users as synced for the added members. $this->processor->mark_users_as_synced($addedmembers); } + public function update_room_membership(array $userids): void { + $this->set_matrix_power_levels(); + // Mark then users as synced for the updated members. + $this->processor->mark_users_as_synced($userids); + } + /** * Add members to a room. * @@ -209,6 +217,11 @@ class communication_feature implements } } + // Set the power level of the users. + if (!empty($addedmembers) && $this->is_power_levels_update_required($addedmembers)) { + $this->set_matrix_power_levels(); + } + // Mark then users as synced for the added members. $this->processor->mark_users_as_synced($addedmembers); @@ -256,6 +269,13 @@ class communication_feature implements // This API requiures the remove_members_from_room feature. $this->matrixapi->require_feature(remove_member_from_room_feature::class); + if($this->get_room_id() === null) { + return; + } + + // Remove the power level for the user first. + $this->set_matrix_power_levels($userids); + $membersremoved = []; foreach ($userids as $userid) { @@ -515,4 +535,82 @@ class communication_feature implements return json_decode($body, false, 512, JSON_THROW_ON_ERROR); } + + /** + * Set the matrix power level with the room. + * + * @param array $resetusers The list of users to override and reset their power level to 0 + */ + private function set_matrix_power_levels(array $resetusers = []): void { + // Get all the current users for the room. + $existingusers = $this->processor->get_all_userids_for_instance(); + + $userpowerlevels = []; + foreach ($existingusers as $existinguser) { + $matrixuserid = matrix_user_manager::get_matrixid_from_moodle($existinguser); + + if (!$matrixuserid) { + continue; + } + + if (!empty($resetusers) && in_array($existinguser, $resetusers, true)) { + $userpowerlevels[$matrixuserid] = matrix_constants::POWER_LEVEL_DEFAULT; + } else { + $existinguserpowerlevel = $this->get_user_allowed_power_level($existinguser); + // We don't need to include the default power level users in request, as matrix will make then default anyways. + if ($existinguserpowerlevel > matrix_constants::POWER_LEVEL_DEFAULT) { + $userpowerlevels[$matrixuserid] = $existinguserpowerlevel; + } + } + } + + // Now add the token user permission to retain the permission in the room. + $response = $this->matrixapi->get_room_info($this->get_room_id()); + $matrixroomdata = self::get_body($response); + $roomadmin = $matrixroomdata->creator; + $userpowerlevels[$roomadmin] = matrix_constants::POWER_LEVEL_MAXIMUM; + + $this->matrixapi->update_room_power_levels($this->get_room_id(), $userpowerlevels); + } + + /** + * Determine if a power level update is required. + * Matrix will always set a user to the default power level of 0 when a power level update is made. + * That is, unless we specify another level. As long as one person's level is greater than the default, + * we will need to set the power levels of all users greater than the default. + * + * @param array $userids The users to evaluate + * @return boolean Returns true if an update is required + */ + private function is_power_levels_update_required(array $userids): bool { + // Is the user's power level greater than the default? + foreach ($userids as $userid) { + if ($this->get_user_allowed_power_level($userid) > matrix_constants::POWER_LEVEL_DEFAULT) { + return true; + } + } + return false; + } + + /** + * Get the allowed power level for the user id according to perms/site admin or default. + * + * @param int $userid + * @return int + */ + public function get_user_allowed_power_level(int $userid): int { + $context = \context_course::instance($this->processor->get_instance_id()); + $powerlevel = matrix_constants::POWER_LEVEL_DEFAULT; + + if (has_capability('communication/matrix:moderator', $context, $userid)) { + $powerlevel = matrix_constants::POWER_LEVEL_MODERATOR; + } + + // If site admin, override all caps. + if (is_siteadmin($userid)) { + $powerlevel = matrix_constants::POWER_LEVEL_MOODLE_SITE_ADMIN; + } + + return $powerlevel; + } } diff --git a/communication/provider/matrix/classes/local/spec/features/matrix/update_room_power_levels_v3.php b/communication/provider/matrix/classes/local/spec/features/matrix/update_room_power_levels_v3.php new file mode 100644 index 00000000000..e840de49ce1 --- /dev/null +++ b/communication/provider/matrix/classes/local/spec/features/matrix/update_room_power_levels_v3.php @@ -0,0 +1,79 @@ +. + +namespace communication_matrix\local\spec\features\matrix; + +use communication_matrix\local\command; +use communication_matrix\matrix_constants; +use GuzzleHttp\Psr7\Response; + +/** + * Matrix API feature to update a room power levels. + * + * Matrix rooms have a concept of power levels, which are used to determine what actions a user can perform in a room. + * + * https://spec.matrix.org/v1.1/client-server-api/#mroompower_levels + * + * @package communication_matrix + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @codeCoverageIgnore + * This code does not warrant being tested. Testing offers no discernible benefit given its usage is tested. + */ +trait update_room_power_levels_v3 { + + /** + * Set the avatar for a room to the specified URL. + * + * @param string $roomid The roomid to set for + * @param array $users The users to set power levels for + * @param int $ban The level required to ban a user + * @param int $invite The level required to invite a user + * @param int $kick The level required to kick a user + * @param array $notifications The level required to send notifications + * @param int $redact The level required to redact events + * @return Response + */ + public function update_room_power_levels( + string $roomid, + array $users, + int $ban = matrix_constants::POWER_LEVEL_MAXIMUM, + int $invite = matrix_constants::POWER_LEVEL_MODERATOR, + int $kick = matrix_constants::POWER_LEVEL_MODERATOR, + array $notifications = [ + 'room' => matrix_constants::POWER_LEVEL_MODERATOR, + ], + int $redact = matrix_constants::POWER_LEVEL_MODERATOR, + ): Response { + $params = [ + ':roomid' => $roomid, + 'ban' => $ban, + 'invite' => $invite, + 'kick' => $kick, + 'notifications' => $notifications, + 'redact' => $redact, + 'users' => $users, + ]; + + return $this->execute(new command( + $this, + method: 'PUT', + endpoint: '_matrix/client/v3/rooms/:roomid/state/m.room.power_levels', + params: $params, + )); + } + +} diff --git a/communication/provider/matrix/classes/local/spec/v1p1.php b/communication/provider/matrix/classes/local/spec/v1p1.php index e75b43343aa..18ea3264912 100644 --- a/communication/provider/matrix/classes/local/spec/v1p1.php +++ b/communication/provider/matrix/classes/local/spec/v1p1.php @@ -34,6 +34,7 @@ class v1p1 extends \communication_matrix\matrix_client { use features\matrix\update_room_name_v3; use features\matrix\update_room_topic_v3; use features\matrix\upload_content_v3; + use features\matrix\update_room_power_levels_v3; // We use the Synapse API here because it can invite users to a room without requiring them to accept the invite. use features\synapse\invite_member_to_room_v1; diff --git a/communication/provider/matrix/classes/matrix_constants.php b/communication/provider/matrix/classes/matrix_constants.php new file mode 100644 index 00000000000..7a539fe936b --- /dev/null +++ b/communication/provider/matrix/classes/matrix_constants.php @@ -0,0 +1,47 @@ +. + +namespace communication_matrix; + +/** + * class matrix_constants to have one location to store all constants related to matrix. + * + * @package communication_matrix + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class matrix_constants { + + /** + * User default power level for matrix. + */ + public const POWER_LEVEL_DEFAULT = 0; + + /** + * User moderator power level for matrix. + */ + public const POWER_LEVEL_MODERATOR = 50; + + /** + * User power level for matrix associated to moodle site admins. It is a custom power level for site admins. + */ + public const POWER_LEVEL_MOODLE_SITE_ADMIN = 90; + + /** + * User maximum power level for matrix. This is only associated to the token user to allow god mode actions. + */ + public const POWER_LEVEL_MAXIMUM = 100; +} diff --git a/communication/provider/matrix/db/access.php b/communication/provider/matrix/db/access.php new file mode 100644 index 00000000000..bb7c6dcae69 --- /dev/null +++ b/communication/provider/matrix/db/access.php @@ -0,0 +1,39 @@ +. + +/** + * Capability definitions for matrix communication. + * + * @package communication_matrix + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$capabilities = [ + + // Matrix moderator capability which aligns with the matrix moderator role or power level 50. + 'communication/matrix:moderator' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + ] + ], +]; diff --git a/communication/provider/matrix/lang/en/communication_matrix.php b/communication/provider/matrix/lang/en/communication_matrix.php index e9042297e39..356e2d160b6 100644 --- a/communication/provider/matrix/lang/en/communication_matrix.php +++ b/communication/provider/matrix/lang/en/communication_matrix.php @@ -35,5 +35,6 @@ $string['matrixelementurl'] = 'Element web URL'; $string['matrixelementurl_desc'] = 'The URL to Element Web instance.'; $string['matrixroomtopic'] = 'Room topic'; $string['matrixroomtopic_help'] = 'A short description of what this room is for.'; +$string['matrix:moderator'] = 'Matrix moderator'; $string['pluginname'] = 'Matrix'; $string['privacy:metadata'] = 'Matrix communication plugin does not store any personal data.'; diff --git a/communication/provider/matrix/tests/communication_feature_test.php b/communication/provider/matrix/tests/communication_feature_test.php index d90fc3483c3..c10073f5cac 100644 --- a/communication/provider/matrix/tests/communication_feature_test.php +++ b/communication/provider/matrix/tests/communication_feature_test.php @@ -18,6 +18,7 @@ namespace communication_matrix; use core_communication\api; use core_communication\communication_test_helper_trait; +use core_communication\processor; use stored_file; defined('MOODLE_INTERNAL') || die(); @@ -191,7 +192,6 @@ class communication_feature_test extends \advanced_testcase { 'Our updated room name', $communication->get_room_name(), ); - } /** @@ -360,6 +360,7 @@ class communication_feature_test extends \advanced_testcase { * @covers ::add_members_to_room * @covers ::add_registered_matrix_user_to_room * @covers ::check_room_membership + * @covers ::set_matrix_power_levels */ public function test_add_and_remove_members_from_room(): void { $user = $this->getDataGenerator()->create_user(); @@ -396,6 +397,96 @@ class communication_feature_test extends \advanced_testcase { $this->assertContains("@{$user2->username}", $userids); } + /** + * Test update of room membership. + * + * @covers ::update_room_membership + * @covers ::set_matrix_power_levels + * @covers ::is_power_levels_update_required + * @covers ::get_user_allowed_power_level + */ + public function test_update_room_membership(): void { + $this->resetAfterTest(); + + global $DB; + + // Create a new room. + $course = $this->get_course('Sampleroom', 'none'); + $user = $this->get_user(); + + $communication = $this->create_room( + component: 'core_course', + itemtype: 'coursecommunication', + itemid: $course->id + ); + $provider = $communication->get_room_user_provider(); + + // Add the members to the room. + $provider->add_members_to_room([$user->id]); + + // Assign teacher role to the user. + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + // Test the tasks added as the role is a teacher. + $provider->update_room_membership([$user->id]); + + $processor = \core_communication\processor::load_by_instance( + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id, + ); + $synceduser = $processor->get_instance_userids( + synced: true, + ); + $synceduser = reset($synceduser); + + // Test if the communication user record is synced. + $this->assertEquals($user->id, $synceduser); + } + + /** + * Test the user power level allocation according to context. + * + * @covers ::get_user_allowed_power_level + */ + public function test_get_user_allowed_power_level(): void { + $this->resetAfterTest(); + global $DB; + + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user($user1->id, $course->id); + $this->getDataGenerator()->enrol_user($user2->id, $course->id); + // Assign roles. + role_assign($teacherrole->id, $user1->id, $coursecontext->id); + role_assign($studentrole->id, $user2->id, $coursecontext->id); + + $communicationprocessor = processor::load_by_instance( + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id + ); + + // Test if the power level is set according to the context. + $this->assertEquals( + matrix_constants::POWER_LEVEL_MODERATOR, + $communicationprocessor->get_room_provider()->get_user_allowed_power_level($user1->id) + ); + $this->assertEquals( + matrix_constants::POWER_LEVEL_DEFAULT, + $communicationprocessor->get_room_provider()->get_user_allowed_power_level($user2->id) + ); + } + /** * Helper to create a room. * diff --git a/communication/provider/matrix/version.php b/communication/provider/matrix/version.php index 645865504d0..803695f03f7 100644 --- a/communication/provider/matrix/version.php +++ b/communication/provider/matrix/version.php @@ -25,6 +25,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'communication_matrix'; -$plugin->version = 2023071900; +$plugin->version = 2023090600; $plugin->requires = 2023011300; $plugin->maturity = MATURITY_ALPHA;