ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç lib.php000064400000003540152265255750006043 0ustar00. /** * Callback and library methods for core communication. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Serve the files from the core_communication file areas. * * @param stdClass $course the course object * @param stdClass $cm the course module object * @param context $context the context * @param string $filearea the name of the file area * @param array $args extra arguments (itemid, path) * @param bool $forcedownload whether force download * @param array $options additional options affecting the file serving */ function core_communication_pluginfile( $course, $cm, $context, $filearea, $args, $forcedownload, array $options = [] ): void { if ($filearea !== 'avatar') { return; } $itemid = array_shift($args); $filename = array_pop($args); // Retrieve the file from the Files API. $fs = get_file_storage(); $file = $fs->get_file($context->id, 'core_communication', $filearea, $itemid, '/', $filename); if (!$file) { return; } send_file($file, $filename); } classes/api.php000064400000071737152265255750007520 0ustar00. namespace core_communication; use core\context; use core_communication\task\add_members_to_room_task; use core_communication\task\create_and_configure_room_task; use core_communication\task\delete_room_task; use core_communication\task\remove_members_from_room; use core_communication\task\synchronise_provider_task; use core_communication\task\update_room_task; use core_communication\task\update_room_membership_task; use stdClass; /** * Class api is the public endpoint of the communication api. This class is the point of contact for api usage. * * Communication api allows to add ad-hoc tasks to the queue to perform actions on the communication providers. This api will * not allow any immediate actions to be performed on the communication providers. It will only add the tasks to the queue. The * exception has been made for deletion of members in case of deleting the user. This is because the user will not be available. * The member management api part allows run actions immediately if required. * * Communication api does allow to have form elements related to communication api in the required forms. This is done by using * the form_definition method. This method will add the form elements to the form. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class api { /** * @var null|processor $communication The communication settings object */ private ?processor $communication; /** * Communication handler constructor to manage and handle all communication related actions. * * This class is the entrypoint for all kinda usages. * It will be used by the other api to manage the communication providers. * * @param context $context The context of the item for the instance * @param string $component The component of the item for the instance * @param string $instancetype The type of the item for the instance * @param int $instanceid The id of the instance * @param string|null $provider The provider type - if null will load for this context's active provider. * */ private function __construct( private context $context, private string $component, private string $instancetype, private int $instanceid, private ?string $provider = null, ) { $this->communication = processor::load_by_instance( context: $context, component: $component, instancetype: $instancetype, instanceid: $instanceid, provider: $provider, ); } /** * Get the communication processor object. * * @param context $context The context of the item for the instance * @param string $component The component of the item for the instance * @param string $instancetype The type of the item for the instance * @param int $instanceid The id of the instance * @param string|null $provider The provider type - if null will load for this context's active provider. * @return api */ public static function load_by_instance( context $context, string $component, string $instancetype, int $instanceid, ?string $provider = null, ): self { return new self( context: $context, component: $component, instancetype: $instancetype, instanceid: $instanceid, provider: $provider, ); } /** * Reload in the internal instance data. */ public function reload(): void { $this->communication = processor::load_by_instance( context: $this->context, component: $this->component, instancetype: $this->instancetype, instanceid: $this->instanceid, provider: $this->provider, ); } /** * Return the underlying communication processor object. * * @return ?processor */ public function get_processor(): ?processor { return $this->communication; } /** * Return the room provider. * * @return \core_communication\room_chat_provider */ public function get_room_provider(): \core_communication\room_chat_provider { return $this->communication->get_room_provider(); } /** * Return the user provider. * * @return \core_communication\user_provider */ public function get_user_provider(): \core_communication\user_provider { return $this->communication->get_user_provider(); } /** * Return the room user provider. * * @return \core_communication\room_user_provider */ public function get_room_user_provider(): \core_communication\room_user_provider { return $this->communication->get_room_user_provider(); } /** * Return the form provider. * * @return \core_communication\form_provider */ public function get_form_provider(): \core_communication\form_provider { return $this->communication->get_form_provider(); } /** * Check if the communication api is enabled. */ public static function is_available(): bool { return (bool) get_config('core', 'enablecommunicationsubsystem'); } /** * Get the communication room url. * * @return string|null */ public function get_communication_room_url(): ?string { return $this->communication?->get_room_url(); } /** * Get the list of plugins for form selection. * * @return array */ public static function get_communication_plugin_list_for_form(): array { // Add the option to have communication disabled. $selection[processor::PROVIDER_NONE] = get_string('nocommunicationselected', 'communication'); $communicationplugins = \core\plugininfo\communication::get_enabled_plugins(); foreach ($communicationplugins as $pluginname => $notusing) { $provider = 'communication_' . $pluginname; if (processor::is_provider_available($provider)) { $selection[$provider] = get_string('pluginname', 'communication_' . $pluginname); } } return $selection; } /** * Get the enabled communication providers and default provider according to the selected provider. * * @param string|null $selecteddefaulprovider * @return array */ public static function get_enabled_providers_and_default(?string $selecteddefaulprovider = null): array { $communicationproviders = self::get_communication_plugin_list_for_form(); $defaulprovider = processor::PROVIDER_NONE; if (!empty($selecteddefaulprovider) && array_key_exists($selecteddefaulprovider, $communicationproviders)) { $defaulprovider = $selecteddefaulprovider; } return [$communicationproviders, $defaulprovider]; } /** * Define the form elements for the communication api. * This method will be called from the form definition method of the instance. * * @param \MoodleQuickForm $mform The form element * @param string $selectdefaultcommunication The default selected communication provider in the form field */ public function form_definition( \MoodleQuickForm $mform, string $selectdefaultcommunication = processor::PROVIDER_NONE ): void { global $PAGE; [$communicationproviders, $defaulprovider] = self::get_enabled_providers_and_default($selectdefaultcommunication); $PAGE->requires->js_call_amd('core_communication/providerchooser', 'init'); // List the communication providers. $mform->addElement( 'select', 'selectedcommunication', get_string('selectcommunicationprovider', 'communication'), $communicationproviders, ['data-communicationchooser-field' => 'selector'], ); $mform->addHelpButton('selectedcommunication', 'selectcommunicationprovider', 'communication'); $mform->setDefault('selectedcommunication', $defaulprovider); $mform->registerNoSubmitButton('updatecommunicationprovider'); $mform->addElement( 'submit', 'updatecommunicationprovider', 'update communication', ['data-communicationchooser-field' => 'updateButton', 'class' => 'd-none'] ); // Just a placeholder for the communication options. $mform->addElement('hidden', 'addcommunicationoptionshere'); $mform->setType('addcommunicationoptionshere', PARAM_BOOL); } /** * Set the form definitions for the plugins. * * @param \MoodleQuickForm $mform The moodle form * @param string $provider The provider name */ public function form_definition_for_provider(\MoodleQuickForm $mform, string $provider = processor::PROVIDER_NONE): void { if ($provider === processor::PROVIDER_NONE) { return; } // Room name for the communication provider. $mform->insertElementBefore( $mform->createElement( 'text', $provider . 'roomname', get_string('communicationroomname', 'communication'), 'maxlength="100" size="20"' ), 'addcommunicationoptionshere' ); $mform->setType($provider . 'roomname', PARAM_TEXT); $mform->insertElementBefore( $mform->createElement( 'static', 'communicationroomnameinfo', '', get_string('communicationroomnameinfo', 'communication'), ), 'addcommunicationoptionshere', ); processor::set_provider_specific_form_definition($provider, $mform); } /** * Get the avatar file. * * @return null|\stored_file */ public function get_avatar(): ?\stored_file { $filename = $this->communication->get_avatar_filename(); if ($filename === null) { return null; } $fs = get_file_storage(); $args = (array) $this->get_avatar_filerecord($filename); return $fs->get_file(...$args) ?: null; } /** * Get the avatar file record for the avatar for filesystem. * * @param string $filename The filename of the avatar * @return stdClass */ protected function get_avatar_filerecord(string $filename): stdClass { return (object) [ 'contextid' => \core\context\system::instance()->id, 'component' => 'core_communication', 'filearea' => 'avatar', 'itemid' => $this->communication->get_id(), 'filepath' => '/', 'filename' => $filename, ]; } /** * Get the avatar file. * * If null is set, then delete the old area file and set the avatarfilename to null. * This will make sure the plugin api deletes the avatar from the room. * * @param null|\stored_file $avatar The stored file for the avatar * @return bool */ public function set_avatar(?\stored_file $avatar): bool { $currentfilename = $this->communication->get_avatar_filename(); if ($avatar === null && empty($currentfilename)) { return false; } $currentfilerecord = $this->get_avatar(); if ($avatar && $currentfilerecord) { $currentfilehash = $currentfilerecord->get_contenthash(); $updatedfilehash = $avatar->get_contenthash(); // No update required. if ($currentfilehash === $updatedfilehash) { return false; } } $context = \core\context\system::instance(); $fs = get_file_storage(); $fs->delete_area_files( $context->id, 'core_communication', 'avatar', $this->communication->get_id() ); if ($avatar) { $fs->create_file_from_storedfile( $this->get_avatar_filerecord($avatar->get_filename()), $avatar, ); $this->communication->set_avatar_filename($avatar->get_filename()); } else { $this->communication->set_avatar_filename(null); } // Indicate that we need to sync the avatar when the update task is run. $this->communication->set_avatar_synced_flag(false); return true; } /** * A helper to fetch the room name * * @return string */ public function get_room_name(): string { if (!$this->communication) { return ''; } return $this->communication->get_room_name(); } /** * Set the form data if the data is already available. * * @param \stdClass $instance The instance object */ public function set_data(\stdClass $instance): void { if (!empty($instance->id) && $this->communication) { $instance->selectedcommunication = $this->communication->get_provider(); $roomnameidentifier = $this->get_provider() . 'roomname'; $instance->$roomnameidentifier = $this->communication->get_room_name(); $this->communication->get_form_provider()->set_form_data($instance); } } /** * Get the communication provider. * * @return string */ public function get_provider(): string { if (!$this->communication) { return ''; } return $this->communication->get_provider(); } /** * Configure the room and membership by provider selected for the communication instance. * * This method will add a task to the queue to configure the room and membership by comparing the change of provider. * There are some major cases to consider for this method to allow minimum duplication when this api is used. * Some of the major cases are: * 1. If the communication instance is not created at all, then create it and add members. * 2. If the current provider is none and the new provider is also none, then nothing to do. * 3. If the current and existing provider is the same, don't need to do anything. * 4. If provider set to none, remove all the members. * 5. If previous provider was not none and current provider is not none, but a different provider, remove members and add * for the new one. * 6. If previous provider was none and current provider is not none, don't need to remove, just * update the selected provider and add users to that provider. Do not queue the task to add members to room as the room * might not have created yet. The add room task adds the task to add members to room anyway. * 7. If it's a new provider, never used/created, now create the room after considering all these cases for a new provider. * * @param string $provider The provider name * @param \stdClass $instance The instance object * @param string $communicationroomname The communication room name * @param array $users The user ids to add to the room * @param null|\stored_file $instanceimage The stored file for the avatar * @param bool $queue Queue the task for the provider room or not */ public function configure_room_and_membership_by_provider( string $provider, stdClass $instance, string $communicationroomname, array $users, ?\stored_file $instanceimage = null, bool $queue = true, ): void { // If the current provider is inactive and the new provider is also none, then nothing to do. if ( $this->communication !== null && $this->communication->get_provider_status() === processor::PROVIDER_INACTIVE && $provider === processor::PROVIDER_NONE ) { return; } // If provider set to none, remove all the members. if ( $this->communication !== null && $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && $provider === processor::PROVIDER_NONE ) { $this->remove_all_members_from_room(); $this->update_room( active: processor::PROVIDER_INACTIVE, communicationroomname: $communicationroomname, avatar: $instanceimage, instance: $instance, queue: $queue, ); return; } if ( // If previous provider was active and not none and current provider is not none, but a different provider, // remove members and de-activate the previous provider. $this->communication !== null && $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && $provider !== $this->get_provider() ) { $this->remove_all_members_from_room(); // Now deactivate the previous provider. $this->update_room( active: processor::PROVIDER_INACTIVE, instance: $instance, queue: $queue, ); } // Now re-init the constructor for the new provider. $this->__construct( context: $this->context, component: $this->component, instancetype: $this->instancetype, instanceid: $this->instanceid, provider: $provider, ); // If it's a new provider, never used/created, now create the room. if ($this->communication === null) { $this->create_and_configure_room( communicationroomname: $communicationroomname, avatar: $instanceimage, instance: $instance, queue: $queue, ); $queueusertask = false; } else { // Otherwise update the room. $this->update_room( active: processor::PROVIDER_ACTIVE, communicationroomname: $communicationroomname, avatar: $instanceimage, instance: $instance, queue: $queue, ); $queueusertask = true; } // Now add the members. $this->add_members_to_room( userids: $users, queue: $queueusertask, ); } /** * Create a communication ad-hoc task for create operation. * This method will add a task to the queue to create the room. * * @param string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object * @param bool $queue Whether to queue the task or not */ public function create_and_configure_room( string $communicationroomname, ?\stored_file $avatar = null, ?\stdClass $instance = null, bool $queue = true, ): void { if ($this->provider === processor::PROVIDER_NONE || $this->provider === '') { return; } // Create communication record. $this->communication = processor::create_instance( context: $this->context, provider: $this->provider, instanceid: $this->instanceid, component: $this->component, instancetype: $this->instancetype, roomname: $communicationroomname, ); // Update provider record from form data. if ($instance !== null) { $this->communication->get_form_provider()->save_form_data($instance); } // Set the avatar. if (!empty($avatar)) { $this->set_avatar($avatar); } // Nothing else to do if the queue is false. if (!$queue) { return; } // Add ad-hoc task to create the provider room. create_and_configure_room_task::queue( $this->communication, ); } /** * Create a communication ad-hoc task for update operation. * This method will add a task to the queue to update the room. * * @param null|int $active The selected active state of the provider * @param null|string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object * @param bool $queue Whether to queue the task or not */ public function update_room( ?int $active = null, ?string $communicationroomname = null, ?\stored_file $avatar = null, ?\stdClass $instance = null, bool $queue = true, ): void { if (!$this->communication) { return; } // If the provider is none, we don't need to do anything from room point of view. if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; } $roomnamechange = null; $activestatuschange = null; // Check if the room name is being changed. if ( $communicationroomname !== null && $communicationroomname !== $this->communication->get_room_name() ) { $roomnamechange = $communicationroomname; } // Check if the active status of the provider is being changed. if ( $active !== null && $active !== $this->communication->is_instance_active() ) { $activestatuschange = $active; } if ($roomnamechange !== null || $activestatuschange !== null) { $this->communication->update_instance( active: $active, roomname: $communicationroomname, ); } // Update provider record from form data. if ($instance !== null) { $this->communication->get_form_provider()->save_form_data($instance); } // Update the avatar. // If the value is `null`, then unset the avatar. $this->set_avatar($avatar); // Nothing else to do if the queue is false. if (!$queue) { return; } // Always queue a room update, even if none of the above standard fields have changed. // It is possible for providers to have custom fields that have been updated. update_room_task::queue( $this->communication, ); } /** * Create a communication ad-hoc task for delete operation. * This method will add a task to the queue to delete the room. */ public function delete_room(): void { if ($this->communication !== null) { // Add the ad-hoc task to remove the room data from the communication table and associated provider actions. delete_room_task::queue( $this->communication, ); } } /** * Create a communication ad-hoc task for add members operation and add the user mapping. * * This method will add a task to the queue to add the room users. * * @param array $userids The user ids to add to the room * @param bool $queue Whether to queue the task or not */ public function add_members_to_room(array $userids, bool $queue = true): void { // No communication object? something not done right. if (!$this->communication) { return; } // No user IDs or this provider does not manage users? No action required. if (empty($userids) || !$this->communication->supports_user_features()) { return; } $this->communication->create_instance_user_mapping($userids); if ($queue) { add_members_to_room_task::queue( $this->communication ); } } /** * Create a communication ad-hoc task for updating members operation and update the user mapping. * * This method will add a task to the queue to update the room users. * * @param array $userids The user ids to add to the room * @param bool $queue Whether to queue the task or not */ public function update_room_membership(array $userids, bool $queue = true): void { // No communication object? something not done right. if (!$this->communication) { return; } // No user IDs or this provider does not manage users? No action required. if (empty($userids) || !$this->communication->supports_user_features()) { return; } $this->communication->reset_users_sync_flag($userids); if ($queue) { update_room_membership_task::queue( $this->communication ); } } /** * Create a communication ad-hoc task for remove members operation or action immediately. * * This method will add a task to the queue to remove the room users. * * @param array $userids The user ids to remove from the room * @param bool $queue Whether to queue the task or not */ public function remove_members_from_room(array $userids, bool $queue = true): void { // No communication object? something not done right. if (!$this->communication) { return; } $provider = $this->communication->get_provider(); if ($provider === processor::PROVIDER_NONE) { return; } // No user IDs or this provider does not manage users? No action required. if (empty($userids) || !$this->communication->supports_user_features()) { return; } $this->communication->add_delete_user_flag($userids); if ($queue) { remove_members_from_room::queue( $this->communication ); } } /** * Remove all users from the room. * * @param bool $queue Whether to queue the task or not */ public function remove_all_members_from_room(bool $queue = true): void { // No communication object? something not done right. if (!$this->communication) { return; } if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; } // This provider does not manage users? No action required. if (!$this->communication->supports_user_features()) { return; } $this->communication->add_delete_user_flag($this->communication->get_all_userids_for_instance()); if ($queue) { remove_members_from_room::queue( $this->communication ); } } /** * Display the communication room status notification. */ public function show_communication_room_status_notification(): void { // No communication, no room. if (!$this->communication) { return; } if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; } $roomstatus = $this->get_communication_room_url() ? constants::COMMUNICATION_STATUS_READY : constants::COMMUNICATION_STATUS_PENDING; $pluginname = get_string('pluginname', $this->get_provider()); $message = get_string('communicationroom' . $roomstatus, 'communication', $pluginname); // We only show the ready notification once per user. // We check this with a custom user preference. $roomreadypreference = "{$this->component}_{$this->instancetype}_{$this->instanceid}_room_ready"; switch ($roomstatus) { case constants::COMMUNICATION_STATUS_PENDING: \core\notification::add($message, \core\notification::INFO); unset_user_preference($roomreadypreference); break; case constants::COMMUNICATION_STATUS_READY: if (empty(get_user_preferences($roomreadypreference))) { \core\notification::add($message, \core\notification::SUCCESS); set_user_preference($roomreadypreference, true); } break; } } /** * Add the task to sync the provider data with local Moodle data. */ public function sync_provider(): void { // No communication, return. if (!$this->communication) { return; } if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; } synchronise_provider_task::queue( $this->communication ); } } classes/form/configure_form.php000064400000012047152265255750012703 0ustar00. /** * Configure communication for a given instance - the form definition. * * @package core_communication * @copyright 2023 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_communication\form; use core\context; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Defines the configure communication form. */ class configure_form extends \moodleform { /** * @var \core_communication\api $communication The communication api object. */ protected $communication; /** * Class constructor * * @param context $context Context object * @param int|null $instanceid Instance ID * @param string|null $instancetype Instance type * @param string|null $component Component name * @param string|null $selectedcommunication Selected communication service (provider) * @param stdClass|null $instancedata Instance data */ public function __construct( context $context, ?int $instanceid = null, ?string $instancetype = null, ?string $component = null, ?string $selectedcommunication = null, ?stdClass $instancedata = null, ) { parent::__construct( null, [ 'context' => $context, 'instanceid' => $instanceid, 'instancetype' => $instancetype, 'component' => $component, 'selectedcommunication' => $selectedcommunication, 'instancedata' => $instancedata, ], ); } /** * Defines the form fields. */ public function definition() { $mform = $this->_form; $context = $this->_customdata['context']; $instanceid = $this->_customdata['instanceid']; $instancetype = $this->_customdata['instancetype']; $component = $this->_customdata['component']; $instancedata = $this->_customdata['instancedata']; // Add communication plugins to the form. $this->communication = \core_communication\api::load_by_instance( context: $context, component: $component, instancetype: $instancetype, instanceid: $instanceid, provider: $this->_customdata['selectedcommunication'], ); $this->communication->form_definition($mform); $this->communication->set_data($instancedata); $this->set_form_definition_for_provider(); // Form buttons. $buttonarray = []; $buttonarray[] = $mform->createElement('submit', 'saveandreturn', get_string('savechanges')); $buttonarray[] = $mform->createElement('cancel'); $mform->addGroup($buttonarray, 'buttonar', '', [' '], false); $mform->closeHeaderBefore('buttonar'); // Hidden elements. $mform->addElement('hidden', 'contextid', $context->id); $mform->setType('contextid', PARAM_INT); $mform->addElement('hidden', 'instanceid', $instanceid); $mform->setType('instanceid', PARAM_INT); $mform->addElement('hidden', 'instancetype', $instancetype); $mform->setType('instancetype', PARAM_TEXT); $mform->addElement('hidden', 'component', $component); $mform->setType('component', PARAM_TEXT); // Finally set the current form data. $this->set_data($instancedata); } /** * Defines the requested/current provider * * Get the selected communication service (provider), * and then use it to show the provider form fields. */ private function set_form_definition_for_provider(): void { $instancedata = $this->_customdata['instancedata']; if ($selectedcommunication = $this->_customdata['selectedcommunication']) { // First is to check whether the selected communication was selected from the form. $provider = $selectedcommunication; } else if (isset($instancedata->selectedcommunication)) { // If the form is not yet submitted, get the value from the DB. $provider = $instancedata->selectedcommunication; } else { // Otherwise, set to PROVIDER_NONE. $provider = \core_communication\processor::PROVIDER_NONE; } $this->communication->form_definition_for_provider($this->_form, $provider); } } classes/room_chat_provider.php000064400000003133152265255750012615 0ustar00. namespace core_communication; /** * Class communication_room_base to manage the room operations of communication providers. * * Every plugin that supports room operation must implement/extend this class in the plugin. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface room_chat_provider { /** * Create a provider room when a instance is created. */ public function create_chat_room(): bool; /** * Update a provider room when a instance is updated. */ public function update_chat_room(): bool; /** * Delete a provider room when a instance is deleted. */ public function delete_chat_room(): bool; /** * Generate a room url if there is a room. * * @return string|null */ public function get_chat_room_url(): ?string; } classes/privacy/provider.php000064400000005165152265255750012246 0ustar00. namespace core_communication\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; /** * Privacy Subsystem for core_communication implementing null_provider. * * @package core_communication * @copyright 2023 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\request\core_userlist_provider, \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider { public static function get_metadata(collection $collection): collection { $collection->add_database_table('communication_user', [ 'commid' => 'privacy:metadata:communication_user:commid', 'userid' => 'privacy:metadata:communication_user:userid', 'synced' => 'privacy:metadata:communication_user:synced', ], 'privacy:metadata:communication_user'); return $collection; } public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } public static function export_user_data(approved_contextlist $contextlist) { // None of the core communication tables should be exported. } public static function delete_data_for_all_users_in_context(\context $context) { // None of the data from these tables should be deleted. } public static function delete_data_for_user(approved_contextlist $contextlist) { // None of the data from these tables should be deleted. } public static function get_users_in_context(userlist $userlist) { // Don't add any users. } public static function delete_data_for_users(approved_userlist $userlist) { // None of the data from these tables should be deleted. } } classes/form_provider.php000064400000003352152265255750011610 0ustar00. namespace core_communication; /** * Interface form_provider to manage communication provider form options from provider plugins. * * Every provider plugin should implement this class to return the implemented form elements for custom data. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface form_provider { /** * Set the form data to the instance if any data is available. * * @param \stdClass $instance The actual instance to set the data */ public function save_form_data(\stdClass $instance): void; /** * Set the form data to the instance if any data is available. * * @param \stdClass $instance The actual instance to set the data */ public function set_form_data(\stdClass $instance): void; /** * Set the form definitions. * * @param \MoodleQuickForm $mform The form object */ public static function set_form_definition(\MoodleQuickForm $mform): void; } classes/user_provider.php000064400000002205152265255750011617 0ustar00. namespace core_communication; /** * Class communication_user_base to manage communication provider users. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface user_provider { /** * Create members. * * @param array $userid The users ids to be created */ public function create_members(array $userid): void; } classes/synchronise_provider.php000064400000002244152265255750013210 0ustar00. namespace core_communication; /** * Interface synchronise_provider to check if the users and room is synced properly for the communication provider. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface synchronise_provider { /** * Ensure the users are in sync with the communication provider. */ public function synchronise_room_members(): void; } classes/hook_listener.php000064400000056545152265255750011614 0ustar00. namespace core_communication; use context_course; use core\hook\access\after_role_assigned; use core\hook\access\after_role_unassigned; use core_enrol\hook\before_enrol_instance_deleted; use core_enrol\hook\after_enrol_instance_status_updated; use core_enrol\hook\after_user_enrolled; use core_enrol\hook\before_user_enrolment_updated; use core_enrol\hook\before_user_enrolment_removed; use core_course\hook\after_course_created; use core_course\hook\before_course_deleted; use core_course\hook\after_course_updated; use core_group\hook\after_group_created; use core_group\hook\after_group_deleted; use core_group\hook\after_group_membership_added; use core_group\hook\after_group_membership_removed; use core_group\hook\after_group_updated; use core_user\hook\before_user_deleted; use core_user\hook\before_user_updated; /** * Hook listener for communication api. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_listener { /** * Get the course and group object for the group hook. * * @param mixed $hook The hook object. * @return array */ protected static function get_group_and_course_data_for_group_hook(mixed $hook): array { $group = $hook->groupinstance; $course = helper::get_course( courseid: $group->courseid, ); return [ $group, $course, ]; } /** * Communication api call to create room for a group if course has group mode enabled. * * @param after_group_created $hook The group created hook. */ public static function create_group_communication( after_group_created $hook, ): void { [$group, $course] = self::get_group_and_course_data_for_group_hook( hook: $hook, ); // Check if group mode enabled before handling the communication. if (!helper::is_group_mode_enabled_for_course(course: $course)) { return; } $coursecontext = \context_course::instance(courseid: $course->id); // Get the course communication instance to set the provider. $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); // Check we have communication correctly set up before proceeding. if ($coursecommunication->get_processor() === null) { return; } $communication = api::load_by_instance( context: $coursecontext, component: constants::GROUP_COMMUNICATION_COMPONENT, instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, instanceid: $group->id, provider: $coursecommunication->get_provider(), ); $communicationroomname = helper::format_group_room_name( baseroomname: $coursecommunication->get_room_name(), groupname: $group->name, ); $communication->create_and_configure_room( communicationroomname: $communicationroomname, instance: $course, ); // As it's a new group, we need to add the users with all access group role to the room. $enrolledusers = helper::get_enrolled_users_for_course(course: $course); $userstoadd = helper::get_users_has_access_to_all_groups( userids: $enrolledusers, courseid: $course->id, ); $communication->add_members_to_room( userids: $userstoadd, queue: false, ); } /** * Communication api call to update room for a group if course has group mode enabled. * * @param after_group_updated $hook The group updated hook. */ public static function update_group_communication( after_group_updated $hook, ): void { [$group, $course] = self::get_group_and_course_data_for_group_hook( hook: $hook, ); // Check if group mode enabled before handling the communication. if (!helper::is_group_mode_enabled_for_course(course: $course)) { return; } $coursecontext = \context_course::instance(courseid: $course->id); $communication = helper::load_by_group( groupid: $group->id, context: $coursecontext, ); // Get the course communication instance so we can extract the base room name. $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $communicationroomname = helper::format_group_room_name( baseroomname: $coursecommunication->get_room_name(), groupname: $group->name, ); // If the name didn't change, then we don't need to update the room. if ($communicationroomname === $communication->get_room_name()) { return; } $communication->update_room( active: processor::PROVIDER_ACTIVE, communicationroomname: $communicationroomname, instance: $course, ); } /** * Delete the communication room for a group if course has group mode enabled. * * @param after_group_deleted $hook The group deleted hook. */ public static function delete_group_communication( after_group_deleted $hook ): void { [$group, $course] = self::get_group_and_course_data_for_group_hook( hook: $hook, ); // Check if group mode enabled before handling the communication. if (!helper::is_group_mode_enabled_for_course(course: $course)) { return; } $context = context_course::instance($course->id); $communication = helper::load_by_group( groupid: $group->id, context: $context, ); $communication->delete_room(); } /** * Add members to group room when a new member is added to the group. * * @param after_group_membership_added $hook The group membership added hook. */ public static function add_members_to_group_room( after_group_membership_added $hook, ): void { [$group, $course] = self::get_group_and_course_data_for_group_hook( hook: $hook, ); // Check if group mode enabled before handling the communication. if (!helper::is_group_mode_enabled_for_course(course: $course)) { return; } $context = context_course::instance($course->id); $communication = helper::load_by_group( groupid: $group->id, context: $context, ); // Filter out users who are not active in this course. $enrolledusers = helper::get_enrolled_users_for_course($course, true); $userids = array_intersect($hook->userids, $enrolledusers); $communication->add_members_to_room( userids: $userids, ); } /** * Remove members from the room when a member is removed from group room. * * @param after_group_membership_removed $hook The group membership removed hook. */ public static function remove_members_from_group_room( after_group_membership_removed $hook, ): void { [$group, $course] = self::get_group_and_course_data_for_group_hook( hook: $hook, ); // Check if group mode enabled before handling the communication. if (!helper::is_group_mode_enabled_for_course(course: $course)) { return; } $context = context_course::instance($course->id); $communication = helper::load_by_group( groupid: $group->id, context: $context, ); $communication->remove_members_from_room( userids: $hook->userids, ); } /** * Create course communication instance. * * @param after_course_created $hook The course created hook. */ public static function create_course_communication( after_course_created $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $course = $hook->course; // Check for default provider config setting. $defaultprovider = get_config( plugin: 'moodlecourse', name: 'coursecommunicationprovider', ); $provider = $course->selectedcommunication ?? $defaultprovider; if (empty($provider) || $provider === processor::PROVIDER_NONE) { return; } // Check for group mode, we will have to get the course data again as the group info is not always in the object. $createcourseroom = true; $creategrouprooms = false; $coursedata = get_course(courseid: $course->id); $groupmode = $course->groupmode ?? $coursedata->groupmode; if ((int)$groupmode !== NOGROUPS) { $createcourseroom = false; $creategrouprooms = true; } // Prepare the communication api data. $courseimage = course_get_courseimage(course: $course); $communicationroomname = !empty($course->communicationroomname) ? $course->communicationroomname : $coursedata->fullname; $coursecontext = \context_course::instance(courseid: $course->id); // Communication api call for course communication. $communication = \core_communication\api::load_by_instance( context: $coursecontext, component: constants::COURSE_COMMUNICATION_COMPONENT, instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, instanceid: $course->id, provider: $provider, ); $communication->create_and_configure_room( communicationroomname: $communicationroomname, avatar: $courseimage, instance: $course, queue: $createcourseroom, ); // Communication api call for group communication. if ($creategrouprooms) { helper::update_group_communication_instances_for_course( course: $course, provider: $provider, ); } else { $enrolledusers = helper::get_enrolled_users_for_course(course: $course); $communication->add_members_to_room( userids: $enrolledusers, queue: false, ); } } /** * Update the course communication instance. * * @param after_course_updated $hook The course updated hook. */ public static function update_course_communication( after_course_updated $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $course = $hook->course; $oldcourse = $hook->oldcourse; $changeincoursecat = $hook->changeincoursecat; $groupmode = $course->groupmode ?? get_course($course->id)->groupmode; if ($changeincoursecat || $groupmode !== $oldcourse->groupmode) { helper::update_course_communication_instance( course: $course, changesincoursecat: $changeincoursecat, ); } } /** * Delete course communication data and remove members. * Course can have communication data if it is a group or a course. * This action is important to perform even if the experimental feature is disabled. * * @param before_course_deleted $hook The course deleted hook. */ public static function delete_course_communication( before_course_deleted $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $course = $hook->course; $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; $coursecontext = \context_course::instance(courseid: $course->id); // If group mode is not set then just handle the course communication room. if ((int)$groupmode === NOGROUPS) { $communication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $communication->delete_room(); } else { // If group mode is set then handle the group communication rooms. $coursegroups = groups_get_all_groups(courseid: $course->id); foreach ($coursegroups as $coursegroup) { $communication = helper::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $communication->delete_room(); } } } /** * Update the room membership for the user updates. * * @param before_user_updated $hook The user updated hook. */ public static function update_user_room_memberships( before_user_updated $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $user = $hook->user; $currentuserrecord = $hook->currentuserdata; // Get the user courses. $usercourses = enrol_get_users_courses(userid: $user->id); // If the user is suspended then remove the user from all the rooms. // Otherwise add the user to all the rooms for the courses the user enrolled in. if (!empty($currentuserrecord) && isset($user->suspended) && $currentuserrecord->suspended !== $user->suspended) { // Decide the action for the communication api for the user. $memberaction = ($user->suspended === 0) ? 'add_members_to_room' : 'remove_members_from_room'; foreach ($usercourses as $usercourse) { helper::update_course_communication_room_membership( course: $usercourse, userids: [$user->id], memberaction: $memberaction, ); } } } /** * Delete all room memberships for a user. * * @param before_user_deleted $hook The user deleted hook. */ public static function delete_user_room_memberships( before_user_deleted $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $user = $hook->user; foreach (enrol_get_users_courses(userid: $user->id) as $course) { $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; $coursecontext = \context_course::instance(courseid: $course->id); if ((int)$groupmode === NOGROUPS) { $communication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $processor = $communication->get_processor(); if ($processor?->supports_room_user_features()) { $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); $processor->delete_instance_user_mapping(userids: [$user->id]); } } else { // If group mode is set then handle the group communication rooms. $coursegroups = groups_get_all_groups(courseid: $course->id); foreach ($coursegroups as $coursegroup) { $communication = helper::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $processor = $communication->get_processor(); if ($processor?->supports_room_user_features()) { $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); $processor->delete_instance_user_mapping(userids: [$user->id]); } } } } } /** * Update the room membership of the user for role assigned in a course. * * @param after_role_assigned|after_role_unassigned $hook */ public static function update_user_membership_for_role_changes( after_role_assigned|after_role_unassigned $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $context = $hook->context; if ($coursecontext = $context->get_course_context(strict: false)) { helper::update_course_communication_room_membership( course: get_course(courseid: $coursecontext->instanceid), userids: [$hook->userid], memberaction: 'update_room_membership', ); } } /** * Update the communication memberships for enrol status change. * * @param after_enrol_instance_status_updated $hook The enrol status updated hook. */ public static function update_communication_memberships_for_enrol_status_change( after_enrol_instance_status_updated $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $enrolinstance = $hook->enrolinstance; // No need to do anything for guest instances. if ($enrolinstance->enrol === 'guest') { return; } $newstatus = $hook->newstatus; // Check if a valid status is given. if ( $newstatus !== ENROL_INSTANCE_ENABLED || $newstatus !== ENROL_INSTANCE_DISABLED ) { return; } // Check if the status provided is valid. switch ($newstatus) { case ENROL_INSTANCE_ENABLED: $action = 'add_members_to_room'; break; case ENROL_INSTANCE_DISABLED: $action = 'remove_members_from_room'; break; default: return; } global $DB; $instanceusers = $DB->get_records( table: 'user_enrolments', conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], ); $enrolledusers = array_column($instanceusers, 'userid'); helper::update_course_communication_room_membership( course: get_course(courseid: $enrolinstance->courseid), userids: $enrolledusers, memberaction: $action, ); } /** * Remove the communication instance memberships when an enrolment instance is deleted. * * @param before_enrol_instance_deleted $hook The enrol instance deleted hook. */ public static function remove_communication_memberships_for_enrol_instance_deletion( before_enrol_instance_deleted $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $enrolinstance = $hook->enrolinstance; // No need to do anything for guest instances. if ($enrolinstance->enrol === 'guest') { return; } global $DB; $instanceusers = $DB->get_records( table: 'user_enrolments', conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], ); $enrolledusers = array_column($instanceusers, 'userid'); helper::update_course_communication_room_membership( course: get_course(courseid: $enrolinstance->courseid), userids: $enrolledusers, memberaction: 'remove_members_from_room', ); } /** * Add communication instance membership for an enrolled user. * * @param after_user_enrolled $hook The user enrolled hook. */ public static function add_communication_membership_for_enrolled_user( after_user_enrolled $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $enrolinstance = $hook->enrolinstance; // No need to do anything for guest instances. if ($enrolinstance->enrol === 'guest') { return; } helper::update_course_communication_room_membership( course: get_course($enrolinstance->courseid), userids: [$hook->get_userid()], memberaction: 'add_members_to_room', ); } /** * Update the communication instance membership for the user enrolment updates. * * @param before_user_enrolment_updated $hook The user enrolment updated hook. */ public static function update_communication_membership_for_updated_user_enrolment( before_user_enrolment_updated $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $enrolinstance = $hook->enrolinstance; // No need to do anything for guest instances. if ($enrolinstance->enrol === 'guest') { return; } $userenrolmentinstance = $hook->userenrolmentinstance; $statusmodified = $hook->statusmodified; $timeendmodified = $hook->timeendmodified; if ( ($statusmodified && ((int) $userenrolmentinstance->status === 1)) || ($timeendmodified && $userenrolmentinstance->timeend !== 0 && (time() > $userenrolmentinstance->timeend)) ) { $action = 'remove_members_from_room'; } else { $action = 'add_members_to_room'; } helper::update_course_communication_room_membership( course: get_course($enrolinstance->courseid), userids: [$hook->get_userid()], memberaction: $action, ); } /** * Remove communication instance membership for an enrolled user. * * @param before_user_enrolment_removed $hook The user unenrolled hook. */ public static function remove_communication_membership_for_unenrolled_user( before_user_enrolment_removed $hook, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } $enrolinstance = $hook->enrolinstance; // No need to do anything for guest instances. if ($enrolinstance->enrol === 'guest') { return; } helper::update_course_communication_room_membership( course: get_course($enrolinstance->courseid), userids: [$hook->get_userid()], memberaction: 'remove_members_from_room', ); } } classes/processor.php000064400000051715152265255750010760 0ustar00. namespace core_communication; use core\context; use stdClass; use stored_file; /** * Class processor to manage the base operations of the providers. * * This class is responsible for creating, updating, deleting and loading the communication instance, associated actions. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class processor { /** @var string The magic 'none' provider */ public const PROVIDER_NONE = 'none'; /** @var int The provider active flag */ public const PROVIDER_ACTIVE = 1; /** @var int The provider inactive flag */ public const PROVIDER_INACTIVE = 0; /** * @var communication_provider|room_chat_provider|room_user_provider|synchronise_provider|user_provider|null The provider class */ private communication_provider|user_provider|room_chat_provider|room_user_provider|synchronise_provider|null $provider = null; /** * Communication processor constructor. * * @param stdClass $instancedata The instance data object */ protected function __construct( private stdClass $instancedata, ) { $providercomponent = $this->instancedata->provider; $providerclass = $this->get_classname_for_provider($providercomponent); if (!class_exists($providerclass)) { throw new \moodle_exception('communicationproviderclassnotfound', 'core_communication', '', $providerclass); } if (!is_a($providerclass, communication_provider::class, true)) { // At the moment we only have one communication provider interface. // In the future, we may have others, at which point we will support the newest first and // emit a debugging notice for older ones. throw new \moodle_exception('communicationproviderclassinvalid', 'core_communication', '', $providerclass); } $this->provider = $providerclass::load_for_instance($this); } /** * Create communication instance. * * @param context $context The context of the item for the instance * @param string $provider The communication provider * @param int $instanceid The instance id * @param string $component The component name * @param string $instancetype The instance type * @param string $roomname The room name * @return processor|null */ public static function create_instance( context $context, string $provider, int $instanceid, string $component, string $instancetype, string $roomname, ): ?self { global $DB; if ($provider === self::PROVIDER_NONE) { return null; } $record = (object) [ 'contextid' => $context->id, 'provider' => $provider, 'instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype, 'roomname' => $roomname, 'avatarfilename' => null, 'active' => self::PROVIDER_ACTIVE, 'avatarsynced' => 0, ]; $record->id = $DB->insert_record('communication', $record); return new self($record); } /** * Update the communication instance with any changes. * * @param null|string $active Active state of the instance (processor::PROVIDER_ACTIVE or processor::PROVIDER_INACTIVE) * @param null|string $roomname The room name */ public function update_instance( ?string $active = null, ?string $roomname = null, ): void { global $DB; if ($active !== null && in_array($active, [self::PROVIDER_ACTIVE, self::PROVIDER_INACTIVE])) { $this->instancedata->active = $active; } if ($roomname !== null) { $this->instancedata->roomname = $roomname; } $DB->update_record('communication', $this->instancedata); } /** * Delete communication data. */ public function delete_instance(): void { global $DB; $DB->delete_records('communication', ['id' => $this->instancedata->id]); } /** * Get non synced instance user ids for the instance. * * @param bool $synced The synced status * @param bool $deleted The deleted status * @return array */ public function get_instance_userids(bool $synced = false, bool $deleted = false): array { global $DB; return $DB->get_fieldset_select( 'communication_user', 'userid', 'commid = ? AND synced = ? AND deleted = ?', [$this->instancedata->id, (int) $synced, (int) $deleted] ); } /** * Get existing instance user ids. * * @return array */ public function get_all_userids_for_instance(): array { global $DB; return $DB->get_fieldset_select( 'communication_user', 'userid', 'commid = ?', [$this->instancedata->id] ); } /** * Get all the user ids flagged as deleted. * * @return array */ public function get_all_delete_flagged_userids(): array { global $DB; return $DB->get_fieldset_select( 'communication_user', 'userid', 'commid = ? AND deleted = ?', [$this->instancedata->id, 1] ); } /** * Create communication user record for mapping and sync. * * @param array $userids The user ids */ public function create_instance_user_mapping(array $userids): void { global $DB; // Check if user ids exits in existing user ids. $useridstoadd = array_diff($userids, $this->get_all_userids_for_instance()); foreach ($useridstoadd as $userid) { $record = (object) [ 'commid' => $this->instancedata->id, 'userid' => $userid, ]; $DB->insert_record('communication_user', $record); } $this->mark_users_as_not_deleted($userids); } /** * Mark users as not deleted for the instance. * * @param array $userids The user ids */ public function mark_users_as_not_deleted(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->set_field_select( 'communication_user', 'deleted', 0, 'commid = ? AND userid IN (' . implode(',', $userids) . ')', [$this->instancedata->id] ); } /** * Mark users as synced for the instance. * * @param array $userids The user ids */ public function mark_users_as_synced(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->set_field_select( 'communication_user', 'synced', 1, 'commid = ? AND userid IN (' . implode(',', $userids) . ')', [$this->instancedata->id] ); } /** * Reset users sync flag for the instance. * * @param array $userids The user ids */ public function reset_users_sync_flag(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->set_field_select( 'communication_user', 'synced', 0, 'commid = ? AND userid IN (' . implode(',', $userids) . ')', [$this->instancedata->id] ); } /** * Delete users flag for the instance users. * * @param array $userids The user ids */ public function add_delete_user_flag(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->set_field_select( 'communication_user', 'deleted', 1, 'commid = ? AND userid IN (' . implode(',', $userids) . ')', [$this->instancedata->id] ); } /** * Delete communication user record for userid. * * @param array $userids The user ids */ public function delete_instance_user_mapping(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->delete_records_select( 'communication_user', 'commid = ? AND userid IN (' . implode(',', $userids) . ')', [$this->instancedata->id] ); } /** * Delete communication user record for userid who are not synced. * * @param array $userids The user ids */ public function delete_instance_non_synced_user_mapping(array $userids): void { global $DB; if (empty($userids)) { return; } $DB->delete_records_select( 'communication_user', 'commid = ? AND userid IN (' . implode(',', $userids) . ') AND synced = ?', [$this->instancedata->id, 0] ); } /** * Delete communication user record for instance. */ public function delete_user_mappings_for_instance(): void { global $DB; $DB->delete_records('communication_user', [ 'commid' => $this->instancedata->id, ]); } /** * Load communication instance by id. * * @param int $id The communication instance id * @return processor|null */ public static function load_by_id(int $id): ?self { global $DB; $record = $DB->get_record('communication', ['id' => $id]); if ($record && self::is_provider_available($record->provider)) { return new self($record); } return null; } /** * Load communication instance by instance id. * * @param context $context The context of the item for the instance * @param string $component The component name * @param string $instancetype The instance type * @param int $instanceid The instance id * @param string|null $provider The provider type - if null will load for this context's active provider. * @return processor|null */ public static function load_by_instance( context $context, string $component, string $instancetype, int $instanceid, ?string $provider = null, ): ?self { global $DB; $params = [ 'contextid' => $context->id, 'instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype, ]; if ($provider === null) { // Fetch the active provider in this context. $params['active'] = 1; } else { // Fetch a specific provider in this context (which may be inactive). $params['provider'] = $provider; } $record = $DB->get_record('communication', $params); if ($record && self::is_provider_available($record->provider)) { return new self($record); } return null; } /** * Check if communication instance is active. * * @return bool */ public function is_instance_active(): bool { return $this->instancedata->active; } /** * Get communication provider class name. * * @param string $component The component name. * @return string */ private function get_classname_for_provider(string $component): string { return "{$component}\\communication_feature"; } /** * Get communication instance id after creating the instance in communication table. * * @return int */ public function get_id(): int { return $this->instancedata->id; } /** * Get the context of the communication instance. * * @return context */ public function get_context(): context { return context::instance_by_id($this->get_context_id()); } /** * Get the context id of the communication instance. * * @return int */ public function get_context_id(): int { return $this->instancedata->contextid; } /** * Get communication instance type. * * @return string */ public function get_instance_type(): string { return $this->instancedata->instancetype; } /** * Get communication instance id. * * @return int */ public function get_instance_id(): int { return $this->instancedata->instanceid; } /** * Get communication instance component. * * @return string */ public function get_component(): string { return $this->instancedata->component; } /** * Get communication provider type. * * @return string|null */ public function get_provider(): ?string { return $this->instancedata->provider; } /** * Get room name. * * @return string|null */ public function get_room_name(): ?string { return $this->instancedata->roomname; } /** * Get provider active status. * * @return int */ public function get_provider_status(): int { return $this->instancedata->active; } /** * Get communication instance id. * * @return room_chat_provider */ public function get_room_provider(): room_chat_provider { $this->require_api_enabled(); $this->require_room_features(); return $this->provider; } /** * Get communication instance id. * * @return user_provider */ public function get_user_provider(): user_provider { $this->require_api_enabled(); $this->require_user_features(); return $this->provider; } /** * Get communication instance id. * * @return room_user_provider */ public function get_room_user_provider(): room_user_provider { $this->require_api_enabled(); $this->require_room_features(); $this->require_room_user_features(); return $this->provider; } /** * Get the provider after checking if it supports sync features. * * @return synchronise_provider */ public function get_sync_provider(): synchronise_provider { $this->require_api_enabled(); $this->require_sync_provider_features(); return $this->provider; } /** * Set provider specific form definition. * * @param string $provider The provider name * @param \MoodleQuickForm $mform The moodle form */ public static function set_provider_specific_form_definition(string $provider, \MoodleQuickForm $mform): void { $providerclass = "{$provider}\\communication_feature"; $providerclass::set_form_definition($mform); } /** * Get communication instance for form feature. * * @return form_provider */ public function get_form_provider(): form_provider { $this->requires_form_features(); return $this->provider; } /** * Get communication instance id. * * @return bool */ public function supports_user_features(): bool { return ($this->provider instanceof user_provider); } /** * Get communication instance id. * * @return bool */ public function supports_room_user_features(): bool { if (!$this->supports_user_features()) { return false; } if (!$this->supports_room_features()) { return false; } return ($this->provider instanceof room_user_provider); } /** * Check form feature available. * * @return bool */ public function requires_form_features(): void { if (!$this->supports_form_features()) { throw new \coding_exception('Form features are not supported by the provider ' . get_class($this->provider)); } } /** * Check support for form feature. * * @return bool */ public function supports_form_features(): bool { return ($this->provider instanceof form_provider); } /** * Get communication instance id. */ public function require_user_features(): void { if (!$this->supports_user_features()) { throw new \coding_exception('User features are not supported by the provider ' . get_class($this->provider)); } } /** * Get communication instance id. * * @return bool */ public function supports_room_features(): bool { return ($this->provider instanceof room_chat_provider); } /** * Check if communication api is enabled. */ public function require_api_enabled(): void { if (!api::is_available()) { throw new \coding_exception('Communication API is not enabled, please enable it from experimental features'); } } /** * Get communication instance id. */ public function require_room_features(): void { if (!$this->supports_room_features()) { throw new \coding_exception('room features are not supported by the provider ' . get_class($this->provider)); } } /** * Get communication instance id. */ public function require_room_user_features(): void { if (!$this->supports_room_user_features()) { throw new \coding_exception('room user features are not supported by the provider ' . get_class($this->provider)); } } /** * Check if the provider supports sync features. * * @return bool whether the provider supports sync features or not */ public function supports_sync_provider_features(): bool { return ($this->provider instanceof synchronise_provider); } /** * Check if the provider supports sync features when required. */ public function require_sync_provider_features(): void { if (!$this->supports_sync_provider_features()) { throw new \coding_exception('sync features are not supported by the provider ' . get_class($this->provider)); } } /** * Get communication instance id. * * @return bool|\stored_file */ public function get_avatar(): ?stored_file { $fs = get_file_storage(); $file = $fs->get_file( (\context_system::instance())->id, 'core_communication', 'avatar', $this->instancedata->id, '/', $this->instancedata->avatarfilename, ); return $file ?: null; } /** * Set the avatar file name. * * @param string|null $filename */ public function set_avatar_filename(?string $filename): void { global $DB; $this->instancedata->avatarfilename = $filename; $DB->set_field('communication', 'avatarfilename', $filename, ['id' => $this->instancedata->id]); } /** * Get the avatar file name. * * @return string|null */ public function get_avatar_filename(): ?string { return $this->instancedata->avatarfilename; } /** * Check if the avatar has been synced with the provider. * * @return bool */ public function is_avatar_synced(): bool { return (bool) $this->instancedata->avatarsynced; } /** * Indicate if the avatar has been synced with the provider. * * @param boolean $synced True if avatar has been synced. */ public function set_avatar_synced_flag(bool $synced): void { global $DB; $this->instancedata->avatarsynced = (int) $synced; $DB->set_field('communication', 'avatarsynced', (int) $synced, ['id' => $this->instancedata->id]); } /** * Get a room url. * * @return string|null */ public function get_room_url(): ?string { if ($this->provider && $this->is_instance_active()) { return $this->get_room_provider()->get_chat_room_url(); } return null; } /** * Is the communication provider enabled and configured, or disabled. * * @param string $provider provider component name * @return bool */ public static function is_provider_available(string $provider): bool { if (\core\plugininfo\communication::is_plugin_enabled($provider)) { $providerclass = "{$provider}\\communication_feature"; return $providerclass::is_configured(); } return false; } } classes/helper.php000064400000053547152265255750010225 0ustar00. namespace core_communication; use context; use stdClass; /** * Helper method for communication. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Load the communication instance for group id. * * @param int $groupid The group id * @param context $context The context, to make sure any instance using group can load the communication instance * @return api The communication instance. */ public static function load_by_group(int $groupid, context $context): api { return \core_communication\api::load_by_instance( context: $context, component: constants::GROUP_COMMUNICATION_COMPONENT, instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, instanceid: $groupid, ); } /** * Load the communication instance for course id. * * @param int $courseid The course id * @param \context $context The context * @param string|null $provider The provider name * @return api The communication instance */ public static function load_by_course( int $courseid, \context $context, ?string $provider = null, ): api { return \core_communication\api::load_by_instance( context: $context, component: constants::COURSE_COMMUNICATION_COMPONENT, instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, instanceid: $courseid, provider: $provider, ); } /** * Communication api call to create room for a group if course has group mode enabled. * * @param int $courseid The course id. * @return stdClass */ public static function get_course(int $courseid): stdClass { global $DB; return $DB->get_record( table: 'course', conditions: ['id' => $courseid], strictness: MUST_EXIST, ); } /** * Is group mode enabled for the course. * * @param stdClass $course The course object */ public static function is_group_mode_enabled_for_course(stdClass $course): bool { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return false; } $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; return (int)$groupmode !== NOGROUPS; } /** * Helper to update room membership according to action passed. * This method will help reduce a large amount of duplications of code in different places in core. * * @param \stdClass $course The course object. * @param array $userids The user ids to add to the communication room. * @param string $memberaction The action to perform on the communication room. */ public static function update_course_communication_room_membership( \stdClass $course, array $userids, string $memberaction, ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } // Validate communication api action. $roomuserprovider = new \ReflectionClass(room_user_provider::class); if (!$roomuserprovider->hasMethod($memberaction)) { throw new \coding_exception('Invalid action provided.'); } $coursecontext = \context_course::instance(courseid: $course->id); $communication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); // Check we have communication correctly set up before proceeding. if ($communication->get_processor() === null) { return; } // Get the group mode for this course. $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; if ((int)$groupmode === NOGROUPS) { // If group mode is not set, then just handle the update normally for these users. $communication->$memberaction($userids); } else { // If group mode is set, then handle the update for these users with repect to the group they are in. $coursegroups = groups_get_all_groups(courseid: $course->id); $usershandled = []; // Filter all the users that have the capability to access all groups. $allaccessgroupusers = self::get_users_has_access_to_all_groups( userids: $userids, courseid: $course->id, ); foreach ($coursegroups as $coursegroup) { // Get all group members. $groupmembers = groups_get_members(groupid: $coursegroup->id, fields: 'u.id'); $groupmembers = array_column($groupmembers, 'id'); // Find the common user ids between the group members and incoming userids. $groupuserstohandle = array_intersect( $groupmembers, $userids, ); // Add users who have the capability to access this group (and haven't been added already). foreach ($allaccessgroupusers as $allaccessgroupuser) { if (!in_array($allaccessgroupuser, $groupuserstohandle, true)) { $groupuserstohandle[] = $allaccessgroupuser; } } // Keep track of the users we have handled already. $usershandled = array_merge($usershandled, $groupuserstohandle); // Let's check if we need to add/remove members from room because of a role change. // First, get all the instance users for this group. $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $instanceusers = $communication->get_processor()->get_all_userids_for_instance(); // The difference between the instance users and the group members are the ones we want to check. $roomuserstocheck = array_diff( $instanceusers, $groupmembers ); if (!empty($roomuserstocheck)) { // Check if they still have the capability to keep their access in the room. $userslostcaps = array_diff( $roomuserstocheck, self::get_users_has_access_to_all_groups( userids: $roomuserstocheck, courseid: $course->id, ), ); // Remove users who no longer have the capability. if (!empty($userslostcaps)) { $communication->remove_members_from_room(userids: $userslostcaps); } } // Check if we have to add any room members who have gained the capability. $usersgainedcaps = array_diff( $allaccessgroupusers, $instanceusers, ); // If we have users, add them to the room. if (!empty($usersgainedcaps)) { $communication->add_members_to_room(userids: $usersgainedcaps); } // Finally, trigger the update task for the users who need to be handled. $communication->$memberaction($groupuserstohandle); } // If the user was not in any group, but an update/remove action was requested for the user, // then the user must have had a role with the capablity, but made a regular user. $usersnothandled = array_diff($userids, $usershandled); // These users are not handled and not in any group, so logically these users lost their permission to stay in the room. foreach ($coursegroups as $coursegroup) { $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $communication->remove_members_from_room(userids: $usersnothandled); } } } /** * Get users with the capability to access all groups. * * @param array $userids user ids to check the permission * @param int $courseid course id * @return array of userids */ public static function get_users_has_access_to_all_groups( array $userids, int $courseid ): array { $allgroupsusers = []; $context = \context_course::instance(courseid: $courseid); foreach ($userids as $userid) { if ( has_capability( capability: 'moodle/site:accessallgroups', context: $context, user: $userid, ) ) { $allgroupsusers[] = $userid; } } return $allgroupsusers; } /** * Get the course communication url according to course setup. * * @param stdClass $course The course object. * @return string The communication room url. */ public static function get_course_communication_url(stdClass $course): string { // If it's called from site context, then just return. if ($course->id === SITEID) { return ''; } // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return ''; } $url = ''; // Get the group mode for this course. $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; $coursecontext = \context_course::instance(courseid: $course->id); // If group mode is not set then just handle the course communication for these users. if ((int)$groupmode === NOGROUPS) { $communication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); $url = $communication->get_communication_room_url(); } else { // If group mode is set then handle the group communication rooms for these users. $coursegroups = groups_get_all_groups(courseid: $course->id); $numberofgroups = count($coursegroups); // If no groups available, nothing to show. if ($numberofgroups === 0) { return ''; } $readygroups = []; foreach ($coursegroups as $coursegroup) { $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; if ($roomstatus === 'ready') { $readygroups[$communication->get_processor()->get_id()] = $communication->get_communication_room_url(); } } if (!empty($readygroups)) { $highestkey = max(array_keys($readygroups)); $url = $readygroups[$highestkey]; } } return empty($url) ? '' : $url; } /** * Get the enrolled users for course. * * @param stdClass $course The course object. * @param bool $onlyactive Only enrolments that are active (e.g. not suspended). * @return array */ public static function get_enrolled_users_for_course(stdClass $course, bool $onlyactive = true): array { global $CFG; require_once($CFG->libdir . '/enrollib.php'); return array_column( enrol_get_course_users(courseid: $course->id, onlyactive: $onlyactive), 'id', ); } /** * Get the course communication status notification for course. * * @param \stdClass $course The course object. */ public static function get_course_communication_status_notification(\stdClass $course): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } // Get the group mode for this course. $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; $coursecontext = \context_course::instance(courseid: $course->id); // If group mode is not set then just handle the course communication for these users. if ((int)$groupmode === NOGROUPS) { $communication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); $communication->show_communication_room_status_notification(); } else { // If group mode is set then handle the group communication rooms for these users. $coursegroups = groups_get_all_groups(courseid: $course->id); $numberofgroups = count($coursegroups); // If no groups available, nothing to show. if ($numberofgroups === 0) { return; } $numberofreadygroups = 0; foreach ($coursegroups as $coursegroup) { $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; switch ($roomstatus) { case 'ready': $numberofreadygroups ++; break; case 'pending': $pendincommunicationobject = $communication; break; } } if ($numberofgroups === $numberofreadygroups) { $communication->show_communication_room_status_notification(); } else { $pendincommunicationobject->show_communication_room_status_notification(); } } } /** * Update course communication according to course data. * Course can have course or group rooms. Group mode enabling will create rooms for groups. * * @param stdClass $course The course data * @param bool $changesincoursecat Whether the course moved to a different category */ public static function update_course_communication_instance( stdClass $course, bool $changesincoursecat ): void { // If the communication subsystem is not enabled then just ignore. if (!api::is_available()) { return; } // Check if provider is selected. $provider = $course->selectedcommunication ?? null; // If the course moved to hidden category, set provider to none. if ($changesincoursecat && empty($course->visible)) { $provider = processor::PROVIDER_NONE; } // Get the course context. $coursecontext = \context_course::instance(courseid: $course->id); // Get the course image. $courseimage = course_get_courseimage(course: $course); // Get the course communication instance. $coursecommunication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); // Attempt to get the communication provider if it wasn't provided in the data. if (empty($provider)) { $provider = $coursecommunication->get_provider(); } $roomnameidentifier = $provider . 'roomname'; // Determine the communication room name if none was provided and add it to the course data. if (empty($course->$roomnameidentifier)) { $course->$roomnameidentifier = $coursecommunication->get_room_name(); if (empty($course->$roomnameidentifier)) { $course->$roomnameidentifier = $course->fullname ?? get_course($course->id)->fullname; } } // List of enrolled users for course communication. $enrolledusers = self::get_enrolled_users_for_course(course: $course); // Check for group mode, we will have to get the course data again as the group info is not always in the object. $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; // If group mode is disabled, get the communication information for creating room for a course. if ((int)$groupmode === NOGROUPS) { // Remove all the members from active group rooms if there is any. $coursegroups = groups_get_all_groups(courseid: $course->id); foreach ($coursegroups as $coursegroup) { $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); // Remove the members from the group room. $communication->remove_all_members_from_room(); // Now delete the group room. $communication->update_room(active: processor::PROVIDER_INACTIVE); } // Now create/update the course room. $communication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $course->$roomnameidentifier, users: $enrolledusers, instanceimage: $courseimage, ); } else { // Update the group communication instances. self::update_group_communication_instances_for_course( course: $course, provider: $provider, ); // Remove all the members for the course room if instance available. $communication = self::load_by_course( courseid: $course->id, context: $coursecontext, ); // Now handle the course communication according to the provider. $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $course->$roomnameidentifier, users: $enrolledusers, instanceimage: $courseimage, queue: false, ); // As the course is in group mode, make sure no users are in the course room. $communication->reload(); $communication->remove_all_members_from_room(); } } /** * Update the group communication instances. * * @param stdClass $course The course object. * @param string $provider The provider name. */ public static function update_group_communication_instances_for_course( stdClass $course, string $provider, ): void { $coursegroups = groups_get_all_groups(courseid: $course->id); $coursecontext = \context_course::instance(courseid: $course->id); $allaccessgroupusers = self::get_users_has_access_to_all_groups( userids: self::get_enrolled_users_for_course(course: $course), courseid: $course->id, ); foreach ($coursegroups as $coursegroup) { $groupusers = array_column( groups_get_members(groupid: $coursegroup->id), 'id', ); // Filter out users who are not active in this course. $enrolledusers = self::get_enrolled_users_for_course(course: $course); $groupuserstoadd = array_intersect($groupusers, $enrolledusers); foreach ($allaccessgroupusers as $allaccessgroupuser) { if (!in_array($allaccessgroupuser, $groupuserstoadd, true)) { $groupuserstoadd[] = $allaccessgroupuser; } } // Now create/update the group room. $communication = self::load_by_group( groupid: $coursegroup->id, context: $coursecontext, ); $roomnameidentifier = $provider . 'roomname'; $communicationroomname = self::format_group_room_name( baseroomname: $course->$roomnameidentifier, groupname: $coursegroup->name, ); $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $communicationroomname, users: $groupuserstoadd, ); } } /** * Format a group communication room name with the following syntax: 'Group A (Course 1)'. * * @param string $baseroomname The base room name. * @param string $groupname The group name. */ public static function format_group_room_name( string $baseroomname, string $groupname ): string { return get_string('communicationgrouproomnameformat', 'core_communication', [ 'groupname' => $groupname, 'baseroomname' => $baseroomname, ]); } } classes/constants.php000064400000003540152265255750010746 0ustar00. namespace core_communication; /** * Constants for communication api. * * @package core_communication * @copyright 2024 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class constants { /** @var string GROUP_COMMUNICATION_INSTANCETYPE The group communication instance type. */ public const GROUP_COMMUNICATION_INSTANCETYPE = 'groupcommunication'; /** @var string GROUP_COMMUNICATION_COMPONENT The group communication component. */ public const GROUP_COMMUNICATION_COMPONENT = 'core_group'; /** @var string COURSE_COMMUNICATION_INSTANCETYPE The course communication instance type. */ public const COURSE_COMMUNICATION_INSTANCETYPE = 'coursecommunication'; /** @var string COURSE_COMMUNICATION_COMPONENT The course communication component. */ public const COURSE_COMMUNICATION_COMPONENT = 'core_course'; /** @var string COMMUNICATION_STATUS_PENDING The communication status pending. */ public const COMMUNICATION_STATUS_PENDING = 'pending'; /** @var string COMMUNICATION_STATUS_READY The communication status sent. */ public const COMMUNICATION_STATUS_READY = 'ready'; } classes/admin/manage_communication_providers_page.php000064400000011520152265255750017265 0ustar00. namespace core_communication\admin; use admin_setting; use core_plugin_manager; use core_text; use html_table; use html_table_row; use html_writer; use moodle_url; /** * Communication providers manager. Allow enable/disable communication providers and jump to settings. * * @package core_communication * @copyright 2023 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manage_communication_providers_page extends admin_setting { public function __construct() { $this->nosave = true; parent::__construct( 'managecommunications', new \lang_string('managecommunicationproviders', 'core_communication'), '', '' ); } public function get_setting(): bool { return true; } public function write_setting($data): string { // Do not write any setting. return ''; } public function output_html($data, $query = ''): string { global $OUTPUT; $pluginmanager = core_plugin_manager::instance(); $plugins = $pluginmanager->get_plugins_of_type('communication'); if (empty($plugins)) { return get_string('nocommunicationprovider', 'core_communication'); } $table = new html_table(); $table->head = [ get_string('name'), get_string('enable'), get_string('settings'), get_string('uninstallplugin', 'core_admin'), ]; $table->align = ['left', 'center', 'center', 'center']; $table->attributes['class'] = 'managecommunicationtable generaltable admintable table table-hover'; $table->data = []; foreach ($plugins as $plugin) { $class = ''; $actionurl = new moodle_url('/admin/communication.php', ['sesskey' => sesskey(), 'name' => $plugin->name]); if ( $pluginmanager->get_plugin_info('communication_' . $plugin->name)->get_status() === core_plugin_manager::PLUGIN_STATUS_MISSING ) { $strtypename = $plugin->displayname . ' (' . get_string('missingfromdisk') . ')'; } else { $strtypename = $plugin->displayname; } if ($plugin->is_enabled()) { $hideshow = html_writer::link( $actionurl->out(false, ['action' => 'disable']), $OUTPUT->pix_icon('t/hide', get_string('disable'), 'moodle', ['class' => 'iconsmall']) ); } else { $class = 'dimmed_text'; $hideshow = html_writer::link( $actionurl->out(false, ['action' => 'enable']), $OUTPUT->pix_icon('t/show', get_string('enable'), 'moodle', ['class' => 'iconsmall']) ); } $settings = ''; if ($plugin->get_settings_url()) { $settings = html_writer::link($plugin->get_settings_url(), get_string('settings')); } $uninstall = ''; if ( $uninstallurl = core_plugin_manager::instance()->get_uninstall_url( 'communication_' . $plugin->name, 'manage' ) ) { $uninstall = html_writer::link($uninstallurl, get_string('uninstallplugin', 'core_admin')); } $row = new html_table_row([$strtypename, $hideshow, $settings, $uninstall]); if ($class) { $row->attributes['class'] = $class; } $table->data[] = $row; } return highlight($query, html_writer::table($table)); } public function is_related($query): bool { if (parent::is_related($query)) { return true; } $types = core_plugin_manager::instance()->get_plugins_of_type('communication'); foreach ($types as $type) { if ( strpos($type->component, $query) !== false || strpos(core_text::strtolower($type->displayname), $query) !== false ) { return true; } } return false; } } classes/communication_provider.php000064400000003305152265255750013510 0ustar00. namespace core_communication; /** * A base communication provider. * * This interface should be used to declare support for the instantiation method for communication providers. * * Every communication provider must, as a minimum, implement this provider. * * @package core_communication * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface communication_provider { /** * A base communication provider. * * @param processor $communication The communication object */ public static function load_for_instance(processor $communication): self; /** * Check if the provider is configured or not. * * This method is intended to check if the plugin have got any settings and if all the settings are set properly. * This checking helps to reduce errors in future when a communication instance is added for the provider and not configured. */ public static function is_configured(): bool; } classes/room_user_provider.php000064400000003042152265255750012653 0ustar00. namespace core_communication; /** * Class communication_user_base to manage communication provider users. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface room_user_provider { /** * Add members to communication room. * * @param array $userids The user ids to be added */ public function add_members_to_room(array $userids): void; /** * Update room membership for the communication room. * * @param array $userids The user ids to be updated */ public function update_room_membership(array $userids): void; /** * Remove members from room. * * @param array $userids The user ids to be removed */ public function remove_members_from_room(array $userids): void; } classes/task/synchronise_providers_task.php000064400000004343152265255750015361 0ustar00. namespace core_communication\task; use core\task\scheduled_task; use core_communication\api; use core_communication\processor; /** * Class synchronise_providers to add a task to synchronise the providers and execute the task to action the synchronisation. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class synchronise_providers_task extends scheduled_task { public function get_name() { return get_string('synchroniseproviders', 'core_communication'); } public function execute() { // Communication is not enabled? nothing to do. if (!api::is_available()) { return; } global $DB; $communicationinstances = $DB->get_records( table: 'communication', conditions: ['active' => processor::PROVIDER_ACTIVE], ); foreach ($communicationinstances as $communicationinstance) { $communication = \core_communication\api::load_by_instance( context: \context::instance_by_id($communicationinstance->contextid), component: $communicationinstance->component, instancetype: $communicationinstance->instancetype, instanceid: $communicationinstance->instanceid, ); $processor = $communication->get_processor(); if ($processor->supports_sync_provider_features()) { $communication->sync_provider(); } } } } classes/task/add_members_to_room_task.php000064400000004232152265255750014715 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class add_members_to_room_task to add the task to add members to the room and execute the task to action the addition. * * @package core_communication * @copyright 2023 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class add_members_to_room_task extends adhoc_task { public function execute() { // Initialize the custom data operation to be used for the action. $data = $this->get_custom_data(); // Call the communication api to action the operation. $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping room creation because the instance does not exist"); return; } $communication->get_room_user_provider()->add_members_to_room($communication->get_instance_userids()); } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/synchronise_provider_task.php000064400000004036152265255750015175 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class synchronise_provider_task to add a task to synchronise the provider and execute the task to action the synchronisation. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class synchronise_provider_task extends adhoc_task { public function execute() { $data = $this->get_custom_data(); $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping provider sync because the instance does not exist"); return; } // Sync room members for the instance. $communication->get_sync_provider()->synchronise_room_members(); } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/create_and_configure_room_task.php000064400000005073152265255750016103 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class create_and_configure_room_task to add a task to create a room and execute the task to action the creation. * * this task will be queued by the communication api and will use the communication handler api to action the creation. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class create_and_configure_room_task extends adhoc_task { public function execute() { $data = $this->get_custom_data(); // Call the communication api to action the operation. $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping room creation because the instance does not exist"); return; } if (!$communication->is_instance_active()) { mtrace("Skipping room creation because the instance is not active"); return; } // If the room is created successfully, add members to the room if supported by the provider. if ($communication->get_room_provider()->create_chat_room() && $communication->supports_user_features()) { add_members_to_room_task::queue( $communication ); } } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication, ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/update_room_task.php000064400000004172152265255750013236 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class update_room_task to add a task to update a room and execute the task to action the update. * * this task will be queued by the communication api and will use the communication handler api to action the updates. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_room_task extends adhoc_task { public function execute() { $data = $this->get_custom_data(); // Call the communication api to action the operation. $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping room creation because the instance does not exist"); return; } $communication->get_room_provider()->update_chat_room(); } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication, ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/update_room_membership_task.php000064400000004364152265255750015454 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class update_room_membership_task to add the task to update members for the room and execute the task to action the addition. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_room_membership_task extends adhoc_task { public function execute() { // Initialize the custom data operation to be used for the action. $data = $this->get_custom_data(); // Call the communication api to action the operation. $communication = processor::load_by_id($data->id); if ($communication === null || !$communication->supports_user_features()) { mtrace("Skipping room membership because the instance does not exist or does not support user features"); return; } $communication->get_room_user_provider()->update_room_membership($communication->get_instance_userids()); } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/remove_members_from_room.php000064400000004545152265255750014770 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class remove_members_from_room to add the task to remove members to the room and execute the task to action the removal. * * @package core_communication * @copyright 2023 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class remove_members_from_room extends adhoc_task { public function execute() { // Initialize the custom data operation to be used for the action. $data = $this->get_custom_data(); // Call the communication api to action the operation. $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping room creation because the instance does not exist"); return; } $communication->get_room_user_provider()->remove_members_from_room($communication->get_all_delete_flagged_userids()); // Now remove any mapping for users who are not in the room. $communication->delete_instance_non_synced_user_mapping($communication->get_instance_userids(false, true)); } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } classes/task/delete_room_task.php000064400000005010152265255750013206 0ustar00. namespace core_communication\task; use core\task\adhoc_task; use core_communication\processor; /** * Class delete_room_task to add a task to delete a room and execute the task to action the deletion. * * this task will be queued by the communication api and will use the communication handler api to action the deletion. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class delete_room_task extends adhoc_task { public function execute() { $data = $this->get_custom_data(); $communication = processor::load_by_id($data->id); if ($communication === null) { mtrace("Skipping room creation because the instance does not exist"); return; } // First remove the members from the room. $communication->get_room_user_provider()->remove_members_from_room($communication->get_instance_userids(true, true)); // Now remove any mapping for users who are not in the room. $communication->delete_instance_non_synced_user_mapping($communication->get_instance_userids(false, true)); // Now delete the room. if ($communication->get_room_provider()->delete_chat_room()) { $communication->delete_instance(); } } /** * Queue the task for the next run. * * @param processor $communication The communication processor to perform the action on */ public static function queue( processor $communication, ): void { // Add ad-hoc task to update the provider room. $task = new self(); $task->set_custom_data([ 'id' => $communication->get_id(), ]); // Queue the task for the next run. \core\task\manager::queue_adhoc_task($task); } } upgrade.txt000064400000000343152265255750006752 0ustar00=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This file describes API changes in /communication/* Information provided here is intended especially for developers. provider/customlink/db/install.xml000064400000002107152265255750013361 0ustar00
provider/customlink/db/caches.php000064400000002166152265255750013135 0ustar00. /** * Defined caches used internally by the provider. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); defined('MOODLE_INTERNAL') || die(); $definitions = [ 'customlink' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, ], ]; provider/customlink/lang/en/communication_customlink.php000064400000002670152265255750017762 0ustar00. /** * Strings for component communication_customlink, language 'en'. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['cachedef_customlink'] = 'Custom link data'; $string['customlinkurl'] = 'Custom link URL'; $string['customlinkurl_help'] = 'Provide a link to an existing room from any communication service you would like to make available to participants - such as Microsoft Teams, Slack or Matrix.'; $string['customlinkurlinfo'] = 'The URL of an existing room already set up for this course.'; $string['pluginname'] = 'Custom link'; $string['privacy:metadata'] = 'Custom link communication plugin does not store any personal data.'; provider/customlink/version.php000064400000002127152265255750013004 0ustar00. /** * Version information for communication_customlink. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'communication_customlink'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_ALPHA; provider/customlink/classes/communication_feature.php000064400000013467152265255750017345 0ustar00. namespace communication_customlink; use core_communication\processor; /** * class communication_feature to handle custom link specific actions. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class communication_feature implements \core_communication\communication_provider, \core_communication\form_provider, \core_communication\room_chat_provider { /** @var string The database table storing custom link specific data */ protected const CUSTOMLINK_TABLE = 'communication_customlink'; /** @var \cache_application $cache The application cache for this provider. */ protected \cache_application $cache; /** * Load the communication provider for the communication API. * * @param processor $communication The communication processor object. * @return communication_feature The communication provider object. */ public static function load_for_instance(processor $communication): self { return new self($communication); } /** * Constructor for communication provider. * * @param processor $communication The communication processor object. */ private function __construct( private \core_communication\processor $communication, ) { $this->cache = \cache::make('communication_customlink', 'customlink'); } /** * Create room - room existence managed externally, always return true. * * @return boolean */ public function create_chat_room(): bool { return true; } /** * Update room - room existence managed externally, always return true. * * @return boolean */ public function update_chat_room(): bool { return true; } /** * Delete room - room existence managed externally, always return true. * * @return boolean */ public function delete_chat_room(): bool { return true; } /** * Fetch the URL for this custom link provider. * * @return string|null The custom URL, or null if not found. */ public function get_chat_room_url(): ?string { global $DB; $commid = $this->communication->get_id(); $cachekey = "link_url_{$commid}"; // Attempt to fetch the room URL from the cache. if ($url = $this->cache->get($cachekey)) { return $url; } // If not found in the cache, fetch the URL from the database. $url = $DB->get_field( self::CUSTOMLINK_TABLE, 'url', ['commid' => $commid], ); // Cache the URL. $this->cache->set($cachekey, $url); return $url; } public function save_form_data(\stdClass $instance): void { if (empty($instance->customlinkurl)) { return; } global $DB; $commid = $this->communication->get_id(); $cachekey = "link_url_{$commid}"; $newrecord = new \stdClass(); $newrecord->url = $instance->customlinkurl; $existingrecord = $DB->get_record( self::CUSTOMLINK_TABLE, ['commid' => $commid], 'id, url' ); if (!$existingrecord) { // Create the record if it does not exist. $newrecord->commid = $commid; $DB->insert_record(self::CUSTOMLINK_TABLE, $newrecord); } else if ($instance->customlinkurl !== $existingrecord->url) { // Update record if the URL has changed. $newrecord->id = $existingrecord->id; $DB->update_record(self::CUSTOMLINK_TABLE, $newrecord); } else { // No change made. return; } // Cache the new URL. $this->cache->set($cachekey, $newrecord->url); } public function set_form_data(\stdClass $instance): void { if (!empty($instance->id) && !empty($this->communication->get_id())) { $instance->customlinkurl = $this->get_chat_room_url(); } } public static function set_form_definition(\MoodleQuickForm $mform): void { // Custom link description for the communication provider. $mform->insertElementBefore($mform->createElement( 'text', 'customlinkurl', get_string('customlinkurl', 'communication_customlink'), 'maxlength="255" size="40"' ), 'addcommunicationoptionshere'); $mform->addHelpButton('customlinkurl', 'customlinkurl', 'communication_customlink'); $mform->setType('customlinkurl', PARAM_URL); $mform->addRule('customlinkurl', get_string('required'), 'required', null, 'client'); $mform->addRule('customlinkurl', get_string('maximumchars', '', 255), 'maxlength', 255); $mform->insertElementBefore($mform->createElement( 'static', 'customlinkurlinfo', '', get_string('customlinkurlinfo', 'communication_customlink'), 'addcommunicationoptionshere' ), 'addcommunicationoptionshere'); } public static function is_configured(): bool { return true; } } provider/customlink/classes/privacy/provider.php000064400000002506152265255750016264 0ustar00. namespace communication_customlink\privacy; use core_privacy\local\metadata\null_provider; /** * Privacy Subsystem for communication_customlink implementing null_provider. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } provider/customlink/tests/communication_feature_test.php000064400000013160152265255750020077 0ustar00. namespace communication_customlink; use core_communication\processor; use core_communication\communication_test_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../../../tests/communication_test_helper_trait.php'); /** * Class communication_feature_test to test the custom link features implemented using the core interfaces. * * @package communication_customlink * @category test * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \communication_customlink\communication_feature */ final class communication_feature_test extends \advanced_testcase { use communication_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); } /** * Test create, update and delete chat room. * * @covers ::load_for_instance */ public function test_load_for_instance(): void { $communicationprocessor = $this->get_test_communication_processor(); $instance = communication_feature::load_for_instance($communicationprocessor); $this->assertInstanceOf('communication_customlink\communication_feature', $instance); } /** * Test create, update and delete chat room. * * @covers ::create_chat_room * @covers ::update_chat_room * @covers ::delete_chat_room */ public function test_create_update_delete_chat_room(): void { $communicationprocessor = $this->get_test_communication_processor(); // Create, update and delete room should always return true because this provider contains // a link to a room, but does not manage the existence of the room. $createroomresult = $communicationprocessor->get_room_provider()->create_chat_room(); $updateroomresult = $communicationprocessor->get_room_provider()->update_chat_room(); $deleteroomresult = $communicationprocessor->get_room_provider()->delete_chat_room(); $this->assertTrue($createroomresult); $this->assertTrue($updateroomresult); $this->assertTrue($deleteroomresult); } /** * Test save form data with provider's custom field and fetching with get_chat_room_url(). * * @covers ::save_form_data * @covers ::get_chat_room_url */ public function test_save_form_data(): void { $communicationprocessor = $this->get_test_communication_processor(); $customlinkurl = 'https://moodle.org/message/index.php'; $formdatainstance = (object) ['customlinkurl' => $customlinkurl]; // Test the custom link URL is saved and can be retrieved as expected. $communicationprocessor->get_form_provider()->save_form_data($formdatainstance); $fetchedurl = $communicationprocessor->get_room_provider()->get_chat_room_url(); $this->assertEquals($customlinkurl, $fetchedurl); // Test with empty customlinkurl. $customlinkurlempty = ''; $formdatainstance = (object) ['customlinkurl' => $customlinkurlempty]; $communicationprocessor->get_form_provider()->save_form_data($formdatainstance); $fetchedurl = $communicationprocessor->get_room_provider()->get_chat_room_url(); // It should not update the url to an empty one. $this->assertEquals($customlinkurl, $fetchedurl); // Test with null customlinkurl. $customlinkurlempty = null; $formdatainstance = (object) ['customlinkurl' => $customlinkurlempty]; $communicationprocessor->get_form_provider()->save_form_data($formdatainstance); $fetchedurl = $communicationprocessor->get_room_provider()->get_chat_room_url(); // It should not update the url to a null one. $this->assertEquals($customlinkurl, $fetchedurl); } /** * Create a test custom link communication processor object. * * @return processor */ protected function get_test_communication_processor(): processor { $course = $this->getDataGenerator()->create_course(); $instanceid = $course->id; $context = \core\context\system::instance(); $component = 'core_course'; $instancetype = 'coursecommunication'; $selectedcommunication = 'communication_customlink'; $communicationroomname = 'communicationroom'; $communicationprocessor = processor::create_instance( $context, $selectedcommunication, $instanceid, $component, $instancetype, $communicationroomname, ); return $communicationprocessor; } /** * Test if the selected provider is configured. * * @covers ::is_configured */ public function test_is_configured(): void { $communicationprocessor = $this->get_test_communication_processor(); $this->assertTrue($communicationprocessor->get_form_provider()->is_configured()); } } provider/customlink/tests/behat/fixtures/custom_link_test_page.php000064400000003164152265255750022001 0ustar00. /** * A page which can be used to represent a messaging service while testing the custom link communication provider. * * The current Moodle user's username is listed in the heading to make it easier to confirm the page has been * opened by the expected user. * * @package communication_customlink * @copyright 2023 Michael Hawkins * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../../../../config.php'); defined('BEHAT_SITE_RUNNING') || die(); global $OUTPUT, $PAGE, $USER; $PAGE->set_url('/communication/provider/customlink/tests/behat/fixtures/custom_link_test_page.php'); require_login(); $PAGE->set_context(core\context\system::instance()); echo $OUTPUT->header(); echo "

Example messaging service - {$USER->username}

"; echo "

Imagine this is a wonderful messaging service being accessed directly from a link in Moodle!

"; echo $OUTPUT->footer(); provider/customlink/tests/behat/custom_link.feature000064400000011572152265255750016743 0ustar00@communication @communication_customlink @javascript Feature: Communication custom link In order to facilitate easy access to an existing communication platform As a teacher I need to be able to make a custom communication link available in my course Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following config values are set as admin: | enablecommunicationsubsystem | 1 | Scenario: As a teacher I can configure a custom communication provider for my course Given I am on the "Course 1" "Course" page logged in as "teacher1" And "Chat to course participants" "button" should not be visible When I navigate to "Communication" in current page administration And the "Provider" select box should contain "Custom link" And I should not see "Custom link URL" And I select "Custom link" from the "Provider" singleselect And I should see "Custom link URL" And I set the following fields to these values: | communication_customlinkroomname | Test URL | | customlinkurl | #wwwroot#/communication/provider/customlink/tests/behat/fixtures/custom_link_test_page.php | And I press "Save changes" Then "Chat to course participants" "button" should be visible And I click on "Chat to course participants" "button" # Check the link hits the expected destination. And I switch to a second window And I should see "Example messaging service - teacher1" in the "region-main" "region" And I close all opened windows # Ensure any communication subsystem tasks have no impact on availability. And I run all adhoc tasks And I am on the "Course 1" course page And "Chat to course participants" "button" should be visible And I click on "Chat to course participants" "button" And I switch to a second window And I should see "Example messaging service - teacher1" in the "region-main" "region" And I close all opened windows And I log out # Confirm student also has access to the custom link. And I am on the "Course 1" "Course" page logged in as "student1" And "Chat to course participants" "button" should be visible And I click on "Chat to course participants" "button" And I switch to a second window And I should see "Example messaging service - student1" in the "region-main" "region" Scenario: As a teacher I can disable and re-enable a custom communication provider for my course Given I am on the "Course 1" "Course" page logged in as "teacher1" And "Chat to course participants" "button" should not be visible When I navigate to "Communication" in current page administration And I select "Custom link" from the "Provider" singleselect And I set the following fields to these values: | communication_customlinkroomname | Test URL | | customlinkurl | #wwwroot#/communication/provider/customlink/tests/behat/fixtures/custom_link_test_page.php | And I press "Save changes" And "Chat to course participants" "button" should be visible And I run all adhoc tasks And I navigate to "Communication" in current page administration And I select "None" from the "Provider" singleselect And I press "Save changes" And "Chat to course participants" "button" should not be visible And I run all adhoc tasks And I am on the "Course 1" course page And "Chat to course participants" "button" should not be visible And I navigate to "Communication" in current page administration And I select "Custom link" from the "Provider" singleselect And I set the following fields to these values: | communication_customlinkroomname | Test URL | | customlinkurl | #wwwroot#/communication/provider/customlink/tests/behat/fixtures/custom_link_test_page.php | And I press "Save changes" And "Chat to course participants" "button" should be visible And I run all adhoc tasks And I am on the "Course 1" course page And "Chat to course participants" "button" should be visible And I click on "Chat to course participants" "button" And I switch to a second window And I should see "Example messaging service - teacher1" in the "region-main" "region" provider/upgrade.txt000064400000000354152265255750010606 0ustar00=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This file describes API changes in /communication/provider/* Information provided here is intended especially for developers. provider/matrix/db/access.php000064400000002507152265255750012263 0ustar00. /** * 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, ], ], ]; provider/matrix/db/upgrade.php000064400000004307152265255750012451 0ustar00. /** * Install steps for communication_matrix. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade procedures for the matrix plugin. * * @return bool */ function xmldb_communication_matrix_upgrade($oldversion) { global $DB; $dbman = $DB->get_manager(); if ($oldversion < 2023060101) { $table = new xmldb_table('matrix_rooms'); $field = new xmldb_field('topic', XMLDB_TYPE_CHAR, '255', null, false, false, null, 'roomid'); if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Plugin savepoint reached. upgrade_plugin_savepoint(true, 2023060101, 'communication', 'matrix'); } if ($oldversion < 2023071900) { $table = new xmldb_table('matrix_rooms'); $dbman->rename_table($table, 'matrix_room'); // Plugin savepoint reached. upgrade_plugin_savepoint(true, 2023071900, 'communication', 'matrix'); } // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.4.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } provider/matrix/db/install.xml000064400000002313152265255750012474 0ustar00
provider/matrix/db/caches.php000064400000002762152265255750012253 0ustar00. /** * Cache definition for the Matrix Communication plugin. * * @package communication_matrix * @category cache * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $definitions = [ // Used to store processed lang files. // The keys used are the revision, lang and component of the string file. // The static acceleration size has been based upon student access of the site. 'serverversions' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, 'staticacceleration' => true, 'staticaccelerationsize' => 1, 'canuselocalstore' => true, // Cache for one day. 'ttl' => 60 * 60 * 24, ], ]; provider/matrix/lang/en/communication_matrix.php000064400000003667152265255750016221 0ustar00. /** * Strings for component communication_matrix, language 'en'. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['cachedef_serverversions'] = 'Matrix server version information for running servers'; $string['matrixhomeservername'] = 'Homeserver name'; $string['matrixhomeservername_desc'] = 'The part after @user: in your Matrix ID (e.g. example.com in @user:example.com)'; $string['matrixhomeserverurl'] = 'Homeserver URL'; $string['matrixhomeserverurl_desc'] = 'Server URL for connecting and creating accounts e.g. https://matrix.example.com.'; $string['matrixaccesstoken'] = 'Access token'; $string['matrixaccesstoken_desc'] = 'Access token for the account which will perform actions on the homeserver.'; $string['matrixelementurl'] = 'Element web URL'; $string['matrixroomtopic'] = 'Room topic'; $string['matrixroomtopic_help'] = 'A short description of what this room is for.'; $string['matrixuserid'] = 'Matrix user ID'; $string['matrix:moderator'] = 'Matrix moderator'; $string['pluginname'] = 'Matrix'; $string['privacy:metadata'] = 'The Matrix communication plugin does not store any personal data.'; provider/matrix/version.php000064400000002114152265255750012114 0ustar00. /** * Version information for communication_matrix. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'communication_matrix'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_ALPHA; provider/matrix/classes/communication_feature.php000064400000065416152265255750016462 0ustar00. namespace communication_matrix; use communication_matrix\local\spec\features\matrix\{ create_room_v3 as create_room_feature, get_room_members_v3 as get_room_members_feature, remove_member_from_room_v3 as remove_member_from_room_feature, update_room_avatar_v3 as update_room_avatar_feature, update_room_name_v3 as update_room_name_feature, update_room_topic_v3 as update_room_topic_feature, upload_content_v3 as upload_content_feature, media_create_v1 as media_create_feature, }; use communication_matrix\local\spec\features\synapse\{ create_user_v2 as create_user_feature, get_room_info_v1 as get_room_info_feature, get_user_info_v2 as get_user_info_feature, invite_member_to_room_v1 as invite_member_to_room_feature, }; use core_communication\processor; use stdClass; use GuzzleHttp\Psr7\Response; /** * class communication_feature to handle matrix specific actions. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class communication_feature implements \core_communication\communication_provider, \core_communication\form_provider, \core_communication\room_chat_provider, \core_communication\room_user_provider, \core_communication\synchronise_provider, \core_communication\user_provider { /** @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 */ protected ?string $homeserverurl = null; /** @var string The URI of the Matrix web client */ protected string $webclienturl; /** @var \communication_matrix\local\spec\v1p1|null The Matrix API processor */ protected ?matrix_client $matrixapi; /** * Load the communication provider for the communication api. * * @param processor $communication The communication processor object * @return communication_feature The communication provider object */ public static function load_for_instance(processor $communication): self { return new self($communication); } /** * Reload the room information. * This may be necessary after a room has been created or updated via the adhoc task. * This is primarily intended for use in unit testing, but may have real world cases too. */ public function reload(): void { $this->room = null; $this->processor = processor::load_by_id($this->processor->get_id()); } /** * Constructor for communication provider to initialize necessary objects for api cals etc.. * * @param processor $processor The communication processor object */ private function __construct( private \core_communication\processor $processor, ) { $this->homeserverurl = get_config('communication_matrix', 'matrixhomeserverurl'); $this->webclienturl = get_config('communication_matrix', 'matrixelementurl'); if ($processor::is_provider_available('communication_matrix')) { // Generate the API instance. $this->matrixapi = matrix_client::instance( serverurl: $this->homeserverurl, accesstoken: get_config('communication_matrix', 'matrixaccesstoken'), ); } } /** * Check whether the room configuration has been created yet. * * @return bool */ protected function room_exists(): bool { return (bool) $this->get_room_configuration(); } /** * Whether the room exists on the remote server. * This does not involve a remote call, but checks whether Moodle is aware of the room id. * @return bool */ protected function remote_room_exists(): bool { $room = $this->get_room_configuration(); return $room && ($room->get_room_id() !== null); } /** * Get the stored room configuration. * @return null|matrix_room */ public function get_room_configuration(): ?matrix_room { $this->room = matrix_room::load_by_processor_id($this->processor->get_id()); return $this->room; } /** * Return the current room id. * * @return string|null */ public function get_room_id(): ?string { return $this->get_room_configuration()?->get_room_id(); } /** * Create members. * * @param array $userids The Moodle user ids to create */ public function create_members(array $userids): void { $addedmembers = []; // This API requiures the create_user feature. $this->matrixapi->require_feature(create_user_feature::class); foreach ($userids as $userid) { $user = \core_user::get_user($userid); $userfullname = fullname($user); // Proceed if we have a user's full name and email to work with. if (!empty($user->email) && !empty($userfullname)) { $qualifiedmuid = matrix_user_manager::get_formatted_matrix_userid($user->username); // First create user in matrix. $response = $this->matrixapi->create_user( userid: $qualifiedmuid, displayname: $userfullname, threepids: [(object) [ 'medium' => 'email', 'address' => $user->email, ], ], externalids: [], ); $body = json_decode($response->getBody()); if (!empty($matrixuserid = $body->name)) { // Then create matrix user id in moodle. matrix_user_manager::set_matrix_userid_in_moodle($userid, $qualifiedmuid); if ($this->add_registered_matrix_user_to_room($matrixuserid)) { $addedmembers[] = $userid; } } } } // 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 { // Filter out any users that are not room members yet. $response = $this->matrixapi->get_room_members( roomid: $this->get_room_id(), ); $body = self::get_body($response); if (isset($body->joined)) { foreach ($userids as $key => $userid) { $matrixuserid = matrix_user_manager::get_matrixid_from_moodle( userid: $userid, ); if (!array_key_exists($matrixuserid, (array) $body->joined)) { unset($userids[$key]); } } } $this->set_matrix_power_levels(); // Mark the users as synced for the updated members. $this->processor->mark_users_as_synced($userids); } /** * Add members to a room. * * @param array $userids The user ids to add */ public function add_members_to_room(array $userids): void { $unregisteredmembers = []; $addedmembers = []; foreach ($userids as $userid) { $matrixuserid = matrix_user_manager::get_matrixid_from_moodle($userid); if ($matrixuserid && $this->check_user_exists($matrixuserid)) { if ($this->add_registered_matrix_user_to_room($matrixuserid)) { $addedmembers[] = $userid; } } else { $unregisteredmembers[] = $userid; } } // 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); // Create Matrix users. if (count($unregisteredmembers) > 0) { $this->create_members($unregisteredmembers); } } /** * Adds the registered matrix user id to room. * * @param string $matrixuserid Registered matrix user id */ private function add_registered_matrix_user_to_room(string $matrixuserid): bool { // Require the invite_member_to_room API feature. $this->matrixapi->require_feature(invite_member_to_room_feature::class); if (!$this->check_room_membership($matrixuserid)) { $response = $this->matrixapi->invite_member_to_room( roomid: $this->get_room_id(), userid: $matrixuserid, ); $body = self::get_body($response); if (empty($body->room_id)) { return false; } if ($body->room_id !== $this->get_room_id()) { return false; } return true; } return false; } /** * Remove members from a room. * * @param array $userids The Moodle user ids to remove */ public function remove_members_from_room(array $userids): void { // 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 = []; $currentpowerlevels = $this->get_current_powerlevel_data(); $currentuserpowerlevels = (array) $currentpowerlevels->users ?? []; foreach ($userids as $userid) { // Check user is member of room first. $matrixuserid = matrix_user_manager::get_matrixid_from_moodle($userid); if (!$matrixuserid) { // Unable to find a matrix userid for this user. continue; } if (array_key_exists($matrixuserid, $currentuserpowerlevels)) { if ($currentuserpowerlevels[$matrixuserid] >= matrix_constants::POWER_LEVEL_MAXIMUM) { // Skip removing the user if they are an admin. continue; } } if ( $this->check_user_exists($matrixuserid) && $this->check_room_membership($matrixuserid) ) { $this->matrixapi->remove_member_from_room( roomid: $this->get_room_id(), userid: $matrixuserid, ); $membersremoved[] = $userid; } } $this->processor->delete_instance_user_mapping($membersremoved); } /** * Check if a user exists in Matrix. * Use if user existence is needed before doing something else. * * @param string $matrixuserid The Matrix user id to check * @return bool */ public function check_user_exists(string $matrixuserid): bool { // This API requires the get_user_info feature. $this->matrixapi->require_feature(get_user_info_feature::class); $response = $this->matrixapi->get_user_info( userid: $matrixuserid, ); $body = self::get_body($response); return isset($body->name); } /** * Check if a user is a member of a room. * Use if membership confirmation is needed before doing something else. * * @param string $matrixuserid The Matrix user id to check * @return bool */ public function check_room_membership(string $matrixuserid): bool { // This API requires the get_room_members feature. $this->matrixapi->require_feature(get_room_members_feature::class); $response = $this->matrixapi->get_room_members( roomid: $this->get_room_id(), ); $body = self::get_body($response); // Check user id is in the returned room member ids. return isset($body->joined) && array_key_exists($matrixuserid, (array) $body->joined); } /** * Create a room based on the data in the communication instance. * * @return bool */ public function create_chat_room(): bool { if ($this->remote_room_exists()) { // A room already exists. Update it instead. return $this->update_chat_room(); } // This method requires the create_room API feature. $this->matrixapi->require_feature(create_room_feature::class); $room = $this->get_room_configuration(); $response = $this->matrixapi->create_room( name: $this->processor->get_room_name(), visibility: 'private', preset: 'private_chat', initialstate: [], options: [ 'topic' => $room->get_topic(), ], ); $response = self::get_body($response); if (empty($response->room_id)) { throw new \moodle_exception( 'Unable to determine ID of matrix room', ); } // Update our record of the matrix room_id. $room->update_room_record( roomid: $response->room_id, ); // Update the room avatar. $this->update_room_avatar(); return true; } public function update_chat_room(): bool { if (!$this->remote_room_exists()) { // No room exists. Create it instead. return $this->create_chat_room(); } $this->matrixapi->require_features([ get_room_info_feature::class, update_room_name_feature::class, update_room_topic_feature::class, ]); // Get room data. $response = $this->matrixapi->get_room_info( roomid: $this->get_room_id(), ); $remoteroomdata = self::get_body($response); // Update the room name when it's updated from the form. if ($remoteroomdata->name !== $this->processor->get_room_name()) { $this->matrixapi->update_room_name( roomid: $this->get_room_id(), name: $this->processor->get_room_name(), ); } // Update the room topic if set. $localroomdata = $this->get_room_configuration(); if ($remoteroomdata->topic !== $localroomdata->get_topic()) { $this->matrixapi->update_room_topic( roomid: $localroomdata->get_room_id(), topic: $localroomdata->get_topic(), ); } // Update room avatar. $this->update_room_avatar(); return true; } public function delete_chat_room(): bool { $this->get_room_configuration()->delete_room_record(); $this->room = null; return true; } /** * Update the room avatar when an instance image is added or updated. */ public function update_room_avatar(): void { // Both of the following features of the remote API are required. $this->matrixapi->require_features([ upload_content_feature::class, update_room_avatar_feature::class, ]); // Check if we have an avatar that needs to be synced. if ($this->processor->is_avatar_synced()) { return; } $instanceimage = $this->processor->get_avatar(); $contenturi = null; if ($this->matrixapi->implements_feature(media_create_feature::class)) { // From version 1.7 we can fetch a mxc URI and use it before uploading the content. if ($instanceimage) { $response = $this->matrixapi->media_create(); $contenturi = self::get_body($response)->content_uri; // Now update the room avatar. $response = $this->matrixapi->update_room_avatar( roomid: $this->get_room_id(), avatarurl: $contenturi, ); // And finally upload the content. $this->matrixapi->upload_content($instanceimage); } else { $response = $this->matrixapi->update_room_avatar( roomid: $this->get_room_id(), avatarurl: null, ); } } else { // Prior to v1.7 the only way to upload content was to upload the content, which returns a mxc URI to use. if ($instanceimage) { // First upload the content. $response = $this->matrixapi->upload_content($instanceimage); $body = self::get_body($response); $contenturi = $body->content_uri; } // Now update the room avatar. $response = $this->matrixapi->update_room_avatar( roomid: $this->get_room_id(), avatarurl: $contenturi, ); } // Indicate the avatar has been synced if it was successfully set with Matrix. if ($response->getReasonPhrase() === 'OK') { $this->processor->set_avatar_synced_flag(true); } } public function get_chat_room_url(): ?string { if (!$this->get_room_id()) { // We don't have a room id for this record. return null; } return sprintf( "%s#/room/%s", $this->webclienturl, $this->get_room_id(), ); } public function save_form_data(\stdClass $instance): void { $matrixroomtopic = $instance->matrixroomtopic ?? null; $room = $this->get_room_configuration(); if ($room) { $room->update_room_record( topic: $matrixroomtopic, ); } else { $this->room = matrix_room::create_room_record( processorid: $this->processor->get_id(), topic: $matrixroomtopic, ); } } public function set_form_data(\stdClass $instance): void { if (!empty($instance->id) && !empty($this->processor->get_id())) { if ($this->room_exists()) { $instance->matrixroomtopic = $this->get_room_configuration()->get_topic(); } } } public static function set_form_definition(\MoodleQuickForm $mform): void { // Room description for the communication provider. $mform->insertElementBefore($mform->createElement( 'text', 'matrixroomtopic', get_string('matrixroomtopic', 'communication_matrix'), 'maxlength="255" size="20"' ), 'addcommunicationoptionshere'); $mform->addHelpButton('matrixroomtopic', 'matrixroomtopic', 'communication_matrix'); $mform->setType('matrixroomtopic', PARAM_TEXT); } /** * Get the body of a response as a stdClass. * * @param Response $response * @return stdClass */ public static function get_body(Response $response): stdClass { $body = $response->getBody(); return json_decode($body, false, 512, JSON_THROW_ON_ERROR); } /** * Set the matrix power level with the room. * * Users with a non-moodle power level are not typically removed unless specified in the $forceremoval param. * Matrix Admin users are never removed. * * @param array $forceremoval The users to force removal from the room, even if they have a custom power level */ private function set_matrix_power_levels( array $forceremoval = [], ): void { // Get the current power levels. $currentpowerlevels = $this->get_current_powerlevel_data(); $currentuserpowerlevels = (array) $currentpowerlevels->users ?? []; // Get all the current users who need to be in the room. $userlist = $this->processor->get_all_userids_for_instance(); // Translate the user ids to matrix user ids. $userlist = array_combine( array_map( fn ($userid) => matrix_user_manager::get_matrixid_from_moodle($userid), $userlist, ), $userlist, ); // Determine the power levels, and filter out anyone with the default level. $newuserpowerlevels = array_filter( array_map( fn($userid) => $this->get_user_allowed_power_level($userid), $userlist, ), fn($level) => $level !== matrix_constants::POWER_LEVEL_DEFAULT, ); // Keep current room admins, and users which don't use our MODERATOR power level without changing them. $staticusers = $this->get_users_with_custom_power_level($currentuserpowerlevels); foreach ($staticusers as $userid => $level) { $newuserpowerlevels[$userid] = $level; } if (!empty($forceremoval)) { // Remove the users from the power levels if they are not admins. foreach ($forceremoval as $userid) { $muid = matrix_user_manager::get_matrixid_from_moodle($userid); if (isset($newuserpowerlevels[$muid]) && $newuserpowerlevels[$muid] < matrix_constants::POWER_LEVEL_MAXIMUM) { unset($newuserpowerlevels[$muid]); } } } if (!$this->power_levels_changed($currentuserpowerlevels, $newuserpowerlevels)) { // No changes to make. return; } // Update the power levels for the room. $this->matrixapi->update_room_power_levels( roomid: $this->get_room_id(), users: $newuserpowerlevels, ); } /** * Filter the list of users provided to remove those with a moodle-related power level. * * @param array $users * @return array */ private function get_users_with_custom_power_level(array $users): array { return array_filter( $users, function ($level): bool { switch ($level) { case matrix_constants::POWER_LEVEL_DEFAULT: case matrix_constants::POWER_LEVEL_MOODLE_SITE_ADMIN: case matrix_constants::POWER_LEVEL_MOODLE_MODERATOR: return false; default: return true; } }, ); } /** * Check whether power levels have changed compared with the proposed power levels. * * @param array $currentuserpowerlevels The current power levels * @param array $newuserpowerlevels The new power levels proposed * @return bool Whether there is any change to be made */ private function power_levels_changed( array $currentuserpowerlevels, array $newuserpowerlevels, ): bool { if (count($newuserpowerlevels) !== count($currentuserpowerlevels)) { // Different number of keys - there must be a difference then. return true; } // Sort the power levels. ksort($newuserpowerlevels, SORT_NUMERIC); // Get the current power levels. ksort($currentuserpowerlevels); $diff = array_merge( array_diff_assoc( $newuserpowerlevels, $currentuserpowerlevels, ), array_diff_assoc( $currentuserpowerlevels, $newuserpowerlevels, ), ); return count($diff) > 0; } /** * Get the current power level for the room. * * @return stdClass */ private function get_current_powerlevel_data(): \stdClass { $roomid = $this->get_room_id(); $response = $this->matrixapi->get_room_power_levels( roomid: $roomid, ); if ($response->getStatusCode() !== 200) { throw new \moodle_exception( 'Unable to get power levels for room', ); } return $this->get_body($response); } /** * 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 { $powerlevel = matrix_constants::POWER_LEVEL_DEFAULT; if (has_capability('communication/matrix:moderator', $this->processor->get_context(), $userid)) { $powerlevel = matrix_constants::POWER_LEVEL_MOODLE_MODERATOR; } // If site admin, override all caps. if (is_siteadmin($userid)) { $powerlevel = matrix_constants::POWER_LEVEL_MOODLE_SITE_ADMIN; } return $powerlevel; } /* * Check if matrix settings are configured * * @return boolean */ public static function is_configured(): bool { // Matrix communication settings. $matrixhomeserverurl = get_config('communication_matrix', 'matrixhomeserverurl'); $matrixaccesstoken = get_config('communication_matrix', 'matrixaccesstoken'); $matrixelementurl = get_config('communication_matrix', 'matrixelementurl'); if ( !empty($matrixhomeserverurl) && !empty($matrixaccesstoken) && (PHPUNIT_TEST || defined('BEHAT_SITE_RUNNING') || !empty($matrixelementurl)) ) { return true; } return false; } public function synchronise_room_members(): void { $this->set_matrix_power_levels(); } } provider/matrix/classes/privacy/provider.php000064400000002522152265255750015376 0ustar00. namespace communication_matrix\privacy; use core_privacy\local\metadata\null_provider; /** * Privacy Subsystem for communication_matrix implementing null_provider. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } } provider/matrix/classes/local/spec/v1p4.php000064400000002130152265255750014700 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.4 of the Matrix specification. * * https://spec.matrix.org/v1.4/client-server-api/ * https://spec.matrix.org/v1.4/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p4 extends v1p3 { } provider/matrix/classes/local/spec/v1p1.php000064400000003704152265255750014705 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.1 of the Matrix specification. * * https://spec.matrix.org/v1.1/client-server-api/ * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p1 extends \communication_matrix\matrix_client { // Use the standard matrix API for these features. use features\matrix\create_room_v3; use features\matrix\get_room_members_v3; use features\matrix\remove_member_from_room_v3; use features\matrix\update_room_avatar_v3; 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; use features\matrix\get_room_powerlevels_from_sync_v3; use features\matrix\get_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; // User information and creation is a server-specific feature. use features\synapse\get_user_info_v2; use features\synapse\create_user_v2; use features\synapse\get_room_info_v1; } provider/matrix/classes/local/spec/features/synapse/get_user_info_v2.php000064400000003320152265255750022647 0ustar00. namespace communication_matrix\local\spec\features\synapse; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Synapse API feature for fetching info about a user. * * https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#query-user-account * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 get_user_info_v2 { /** * Get user info. * * @param string $userid * @return Response */ public function get_user_info(string $userid): Response { return $this->execute(new command( $this, method: 'GET', endpoint: '_synapse/admin/v2/users/:userid', ignorehttperrors: true, params: [ ':userid' => $userid, ], )); } } provider/matrix/classes/local/spec/features/synapse/create_user_v2.php000064400000004203152265255750022321 0ustar00. namespace communication_matrix\local\spec\features\synapse; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Synapse API feature for creating a user. * * https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#create-or-modify-account * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 create_user_v2 { /** * Create a new user. * * @param string $userid The Matrix user id. * @param string $displayname The visible name of the user * @param array $threepids The third-party identifiers of the user. * @param null|array $externalids */ public function create_user( string $userid, string $displayname, array $threepids, ?array $externalids = null, ): Response { $params = [ ':userid' => $userid, 'displayname' => $displayname, 'threepids' => $threepids, ]; if ($externalids !== null) { $params['externalids'] = $externalids; } return $this->execute(new command( $this, method: 'PUT', endpoint: '_synapse/admin/v2/users/:userid', params: $params, )); } } provider/matrix/classes/local/spec/features/synapse/get_room_info_v1.php000064400000003231152265255750022645 0ustar00. namespace communication_matrix\local\spec\features\synapse; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Synapse API feature for fetching room info. * * https://matrix-org.github.io/synapse/latest/admin_api/rooms.html#room-details-api * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 get_room_info_v1 { /** * Get room info. * * @param string $roomid * @return Response */ public function get_room_info(string $roomid): Response { return $this->execute(new command( $this, method: 'GET', endpoint: '_synapse/admin/v1/rooms/:roomid', params: [ ':roomid' => $roomid, ], )); } } provider/matrix/classes/local/spec/features/synapse/invite_member_to_room_v1.php000064400000003557152265255750024415 0ustar00. namespace communication_matrix\local\spec\features\synapse; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Synapse API feature to invite a user into a room. * * https://matrix-org.github.io/synapse/latest/admin_api/room_membership.html#edit-room-membership-api * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 invite_member_to_room_v1 { /** * Join a user to a room. * * Note: This joins the user, and does not invite them. * * @param string $roomid * @param string $userid * @return Response */ public function invite_member_to_room(string $roomid, string $userid): Response { $params = [ ':roomid' => $roomid, 'user_id' => $userid, ]; return $this->execute(new command( $this, method: 'POST', endpoint: '_synapse/admin/v1/join/:roomid', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/update_room_power_levels_v3.php000064400000005566152265255750024764 0ustar00. 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, )); } } provider/matrix/classes/local/spec/features/matrix/get_room_members_v3.php000064400000003354152265255750023176 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to fetch a list of room members. * * https://spec.matrix.org/v1.1/client-server-api/#get_matrixclientv3roomsroomidjoined_members * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 get_room_members_v3 { /** * Get a list of room members. * * @param string $roomid The room ID * @return Response */ public function get_room_members(string $roomid): Response { $params = [ ':roomid' => $roomid, ]; return $this->execute(new command( $this, method: 'GET', endpoint: '_matrix/client/v3/rooms/:roomid/joined_members', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/media_create_v1.php000064400000003030152265255750022240 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to create an mxc Media URI. * * https://spec.matrix.org/v1.1/client-server-api/#post_matrixmediav3upload * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 media_create_v1 { /** * Create a media URI. * * @return Response */ public function media_create(): Response { return $this->execute(new command( $this, method: 'POST', endpoint: '_matrix/media/v1/create', )); } } provider/matrix/classes/local/spec/features/matrix/create_room_v3.php000064400000004654152265255750022154 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature for room creation. * * https://spec.matrix.org/v1.1/client-server-api/#post_matrixclientv3createroom * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 create_room_v3 { /** * Create a new room. * * @param string $name The room name * @param null|string $visibility The room visibility * @param null|string $preset The preset to use * @param null|array $initialstate Initial state variables * @param array $options Any additional options * @return Response */ public function create_room( string $name, ?string $visibility = null, ?string $preset = null, ?array $initialstate = null, array $options = [], ): Response { $params = [ 'name' => $name, ]; if ($visibility !== null) { $params['visibility'] = $visibility; } if ($preset !== null) { $params['preset'] = $preset; } if ($initialstate !== null) { $params['initial_state'] = $initialstate; } if (array_key_exists('topic', $options)) { $params['topic'] = $options['topic'] ?? ''; } return $this->execute(new command( $this, method: 'POST', endpoint: '_matrix/client/v3/createRoom', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/get_room_power_levels_v3.php000064400000003402152265255750024244 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to fetch room power levels. * * https://spec.matrix.org/v1.1/client-server-api/#mroompower_levels * * @package communication_matrix * @copyright 2024 David Woloszyn * @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 get_room_power_levels_v3 { /** * Get a list of room members and their power levels. * * @param string $roomid The room ID * @return Response */ public function get_room_power_levels(string $roomid): Response { $params = [ ':roomid' => $roomid, ]; return $this->execute(new command( $this, method: 'GET', endpoint: '_matrix/client/r0/rooms/:roomid/state/m.room.power_levels', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/update_room_avatar_v3.php000064400000003673152265255750023531 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to update a room avatar. * * https://spec.matrix.org/v1.1/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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_avatar_v3 { /** * Set the avatar for a room to the specified URL. * * @param string $roomid The roomid to set for * @param null|string $avatarurl The mxc URL to use * @return Response */ public function update_room_avatar( string $roomid, ?string $avatarurl, ): Response { $params = [ ':roomid' => $roomid, 'url' => $avatarurl, ]; return $this->execute(new command( $this, method: 'PUT', endpoint: '_matrix/client/v3/rooms/:roomid/state/m.room.avatar', ignorehttperrors: true, params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/upload_content_v3.php000064400000005640152265255750022667 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; /** * Matrix API feature to upload content. * * https://spec.matrix.org/v1.1/client-server-api/#post_matrixmediav3upload * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 upload_content_v3 { /** * Upload the content in the matrix/synapse server. * * @param null|\stored_file $content The content to be uploaded * @param null|string $mediaid The mediaid to associate a file with. Supported for v1.7 API an above only. * @return Response */ public function upload_content( ?\stored_file $content, ?string $mediaid = null, ): Response { $query = []; if ($content) { $query['filename'] = $content->get_filename(); } if ($mediaid !== null) { // Specification of the mediaid requires version 1.7 or above of the upload API. // See https://spec.matrix.org/v1.7/client-server-api/#put_matrixmediav3uploadservernamemediaid. $this->requires_version('1.7'); $command = new command( $this, method: 'PUT', endpoint: '_matrix/media/v3/upload/:mediaid', sendasjson: false, query: $query, params: [ ':mediaid' => $mediaid, ], ); } else { $command = new command( $this, method: 'POST', endpoint: '_matrix/media/v3/upload', sendasjson: false, query: $query, ); } if ($content) { // Add the content-type, and header. $command = $command->withHeader('Content-Type', $content->get_mimetype()); $command = $command->withBody(Utils::streamFor($content->get_content())); } return $this->execute($command); } } provider/matrix/classes/local/spec/features/matrix/update_room_name_v3.php000064400000003444152265255750023167 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to update a room name. * * https://spec.matrix.org/v1.1/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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_name_v3 { /** * Set the name for a room. * * @param string $roomid * @param string $name * @return Response */ public function update_room_name(string $roomid, string $name): Response { $params = [ ':roomid' => $roomid, 'name' => $name, ]; return $this->execute(new command( $this, method: 'PUT', endpoint: '_matrix/client/v3/rooms/:roomid/state/m.room.name', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/remove_member_from_room_v3.php000064400000003561152265255750024554 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to remove a member from a room. * * https://spec.matrix.org/v1.1/client-server-api/#post_matrixclientv3roomsroomidkick * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 remove_member_from_room_v3 { /** * Remove a member from a room. * * @param string $roomid The roomid to remove from * @param string $userid The member to remove * @return Response */ public function remove_member_from_room( string $roomid, string $userid, ): Response { $params = [ ':roomid' => $roomid, 'user_id' => $userid, ]; return $this->execute(new command( $this, method: 'POST', endpoint: '_matrix/client/v3/rooms/:roomid/kick', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/update_room_topic_v3.php000064400000003455152265255750023367 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to update a room topic. * * https://spec.matrix.org/v1.1/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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_topic_v3 { /** * Set the topic for a room. * * @param string $roomid * @param string $topic * @return Response */ public function update_room_topic(string $roomid, string $topic): Response { $params = [ ':roomid' => $roomid, 'topic' => $topic, ]; return $this->execute(new command( $this, method: 'PUT', endpoint: '_matrix/client/v3/rooms/:roomid/state/m.room.topic', params: $params, )); } } provider/matrix/classes/local/spec/features/matrix/get_room_powerlevels_from_sync_v3.php000064400000006227152265255750026174 0ustar00. namespace communication_matrix\local\spec\features\matrix; use communication_matrix\local\command; use GuzzleHttp\Psr7\Response; /** * Matrix API feature to fetch room power levels using the sync API. * * https://spec.matrix.org/v1.1/client-server-api/#get_matrixclientv3sync * * @package communication_matrix * @copyright 2023 Andrew Lyons * @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 get_room_powerlevels_from_sync_v3 { /** * Get a list of room members. * * @param string $roomid The room ID * @return Response */ public function get_room_power_levels_from_sync(string $roomid): Response { // Filter the event data according to the API: // https://spec.matrix.org/v1.1/client-server-api/#filtering // We have to filter out all of the object data that we do not want, // and set a filter to only fetch the one room that we do want. $filter = (object) [ "account_data" => (object) [ // We don't want any account info for this call. "not_types" => ['*'], ], "event_fields" => [ // We only care about type, and content. Not sender. "type", "content", ], "event_format" => "client", "presence" => (object) [ // We don't need any presence data. "not_types" => ['*'], ], "room" => (object) [ // We only want state information for power levels, not timeline and ephemeral data. "rooms" => [ $roomid, ], "state" => (object) [ "types" => [ "m.room.power_levels", ], ], "ephemeral" => (object) [ "not_types" => ['*'], ], "timeline" => (object) [ "not_types" => ['*'], ], ], ]; $query = [ 'filter' => json_encode($filter), ]; return $this->execute(new command( $this, method: 'GET', endpoint: '_matrix/client/v3/sync', query: $query, sendasjson: false, )); } } provider/matrix/classes/local/spec/v1p6.php000064400000002130152265255750014702 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.6 of the Matrix specification. * * https://spec.matrix.org/v1.6/client-server-api/ * https://spec.matrix.org/v1.6/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p6 extends v1p5 { } provider/matrix/classes/local/spec/v1p3.php000064400000002130152265255750014677 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.3 of the Matrix specification. * * https://spec.matrix.org/v1.3/client-server-api/ * https://spec.matrix.org/v1.3/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p3 extends v1p2 { } provider/matrix/classes/local/spec/v1p2.php000064400000002130152265255750014676 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.2 of the Matrix specification. * * https://spec.matrix.org/v1.2/client-server-api/ * https://spec.matrix.org/v1.2/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p2 extends v1p1 { } provider/matrix/classes/local/spec/v1p7.php000064400000002435152265255750014713 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.7 of the Matrix specification. * * https://spec.matrix.org/v1.7/client-server-api/ * https://spec.matrix.org/v1.7/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p7 extends v1p6 { // Note: A new Content Upload API was introduced. // See details in the spec: // https://github.com/matrix-org/matrix-spec-proposals/pull/2246. use features\matrix\media_create_v1; } provider/matrix/classes/local/spec/v1p5.php000064400000002130152265255750014701 0ustar00. namespace communication_matrix\local\spec; /** * Matrix API to support version v1.5 of the Matrix specification. * * https://spec.matrix.org/v1.5/client-server-api/ * https://spec.matrix.org/v1.5/changelog/#api-changes * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class v1p5 extends v1p4 { } provider/matrix/classes/local/command.php000064400000013464152265255750014606 0ustar00. namespace communication_matrix\local; use communication_matrix\matrix_client; use GuzzleHttp\Psr7\Request; use OutOfRangeException; /** * A command to be sent to the Matrix server. * * This class is a wrapper around the PSR-7 Request Interface implementation provided by Guzzle. * * It takes a set of common parameters and configurations and turns them into a Request that can be called against a live server. * * @package communication_matrix * @copyright Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class command extends Request { /** @var array $command The raw command data */ /** @var array|null $params The parameters passed into the command */ /** @var bool $sendasjson Whether to send params as JSON */ /** @var bool $requireauthorization Whether authorization is required for this request */ /** @var bool $ignorehttperrors Whether to ignore HTTP Errors */ /** @var array $query Any query parameters to set on the URL */ /** @var array|null Any parameters not used in the URI which are to be passed to the server via body or query params */ protected array $remainingparams = []; /** * Create a new Command. * * @param matrix_client $client The URL for this method * @param string $method (GET|POST|PUT|DELETE) * @param string $endpoint The URL * @param array $params Any parameters to pass * @param array $query Any query parameters to set on the URL * @param bool $ignorehttperrors Whether to ignore HTTP Errors * @param bool $requireauthorization Whether authorization is required for this request * @param bool $sendasjson Whether to send params as JSON */ public function __construct( protected matrix_client $client, string $method, string $endpoint, protected array $params = [], protected array $query = [], protected bool $ignorehttperrors = false, protected bool $requireauthorization = true, protected bool $sendasjson = true, ) { foreach ($params as $name => $value) { if ($name[0] === ':') { if (preg_match("/{$name}\\b/", $endpoint) !== 1) { throw new OutOfRangeException("Parameter not found in URL '{$name}'"); } $endpoint = preg_replace("/{$name}\\b/", urlencode($value), $endpoint); unset($params[$name]); } } // Store the modified params. $this->remainingparams = $params; if (str_contains($endpoint, '/:')) { throw new OutOfRangeException("URL contains untranslated parameters '{$endpoint}'"); } // Process the required headers. $headers = [ 'Content-Type' => 'application/json', ]; if ($this->require_authorization()) { $headers['Authorization'] = 'Bearer ' . $this->client->get_token(); } // Construct the final request. parent::__construct( $method, $this->get_url($endpoint), $headers, ); } /** * Get the URL of the endpoint on the server. * * @param string $endpoint * @return string */ protected function get_url(string $endpoint): string { return sprintf( "%s/%s", $this->client->get_server_url(), $endpoint, ); } /** * Get all parameters, including those set in the URL. * * @return array */ public function get_all_params(): array { return $this->params; } /** * Get the parameters provided to the command which are not used in the URL. * * These are typically passed to the server as query or body parameters instead. * * @return array */ public function get_remaining_params(): array { return $this->remainingparams; } /** * Get the Guzzle options to pass into the request. * * @return array */ public function get_options(): array { $options = []; if (count($this->query)) { $options['query'] = $this->query; } if ($this->should_send_params_as_json()) { $options['json'] = $this->get_remaining_params(); } if ($this->should_ignore_http_errors()) { $options['http_errors'] = false; } return $options; } /** * Whether authorization is required. * * Based on the 'authorization' attribute set in a raw command. * * @return bool */ public function require_authorization(): bool { return $this->requireauthorization; } /** * Whether to ignore http errors on the response. * * Based on the 'ignore_http_errors' attribute set in a raw command. * * @return bool */ public function should_ignore_http_errors(): bool { return $this->ignorehttperrors; } /** * Whether to send remaining parameters as JSON. * * @return bool */ public function should_send_params_as_json(): bool { return $this->sendasjson; } } provider/matrix/classes/matrix_constants.php000064400000003243152265255750015470 0ustar00. 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 moderator power level for matrix. */ public const POWER_LEVEL_MOODLE_MODERATOR = 51; /** * 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; } provider/matrix/classes/matrix_room.php000064400000007306152265255750014434 0ustar00. namespace communication_matrix; use stdClass; /** * Class to manage the updates to the room information in db. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class matrix_room { private const TABLE = 'matrix_room'; /** @var \stdClass|null $record The matrix room record from db */ /** * Load the matrix room record for the supplied processor. * @param int $processorid * @return null|self */ public static function load_by_processor_id( int $processorid, ): ?self { global $DB; $record = $DB->get_record(self::TABLE, ['commid' => $processorid]); if (!$record) { return null; } return new self($record); } /** * Matrix rooms constructor to load the matrix room information from matrix_room table. * * @param stdClass $record */ private function __construct( private stdClass $record, ) { } /** * Create matrix room data. * * @param int $processorid The id of the communication record * @param string|null $topic The topic of the room for matrix * @param string|null $roomid The id of the room from matrix * @return self */ public static function create_room_record( int $processorid, ?string $topic, ?string $roomid = null, ): self { global $DB; $roomrecord = (object) [ 'commid' => $processorid, 'roomid' => $roomid, 'topic' => $topic, ]; $roomrecord->id = $DB->insert_record(self::TABLE, $roomrecord); return self::load_by_processor_id($processorid); } /** * Update matrix room data. * * @param string|null $roomid The id of the room from matrix * @param string|null $topic The topic of the room for matrix */ public function update_room_record( ?string $roomid = null, ?string $topic = null, ): void { global $DB; if ($roomid !== null) { $this->record->roomid = $roomid; } if ($topic !== null) { $this->record->topic = $topic; } $DB->update_record(self::TABLE, $this->record); } /** * Delete matrix room data. */ public function delete_room_record(): void { global $DB; $DB->delete_records(self::TABLE, ['commid' => $this->record->commid]); unset($this->record); } /** * Get the processor id. * * @return int */ public function get_processor_id(): int { return $this->record->commid; } /** * Get the matrix room id. * * @return string|null */ public function get_room_id(): ?string { return $this->record->roomid; } /** * Get the matrix room topic. * * @return string */ public function get_topic(): string { return $this->record->topic ?? ''; } } provider/matrix/classes/matrix_client.php000064400000025677152265255750014751 0ustar00. namespace communication_matrix; use communication_matrix\local\command; use core\http_client; use DirectoryIterator; use Exception; use GuzzleHttp\Psr7\Response; /** * The abstract class for a versioned API client for Matrix. * * Matrix uses a versioned API, and a handshake occurs between the Client (Moodle) and server, to determine the APIs available. * * This client represents a version-less API client. * Versions are implemented by combining the various features into a versionedclass. * See v1p1 for example. * * @package communication_matrix * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class matrix_client { /** @var string $serverurl The URL of the home server */ /** @var string $accesstoken The access token of the matrix server */ /** @var http_client|null The client to use */ protected static http_client|null $client = null; /** * Matrix events constructor to get the room id and refresh token usage if required. * * @param string $serverurl The URL of the API server * @param string $accesstoken The admin access token */ protected function __construct( protected string $serverurl, protected string $accesstoken, ) { } /** * Return the versioned instance of the API. * * @param string $serverurl The URL of the API server * @param string $accesstoken The admin access token to use * @return matrix_client|null */ public static function instance( string $serverurl, string $accesstoken, ): ?matrix_client { // Fetch the list of supported API versions. $clientversions = self::get_supported_versions(); // Fetch the supported versions from the server. $serversupports = self::query_server_supports($serverurl); if ($serversupports === null) { // Unable to fetch the server versions. return null; } $serverversions = $serversupports->versions; // Calculate the intersections and sort to determine the highest combined version. $versions = array_intersect($clientversions, $serverversions); if (count($versions) === 0) { // No versions in common. throw new \moodle_exception('No supported Matrix API versions found.'); } asort($versions); $version = array_key_last($versions); $classname = \communication_matrix\local\spec::class . '\\' . $version; return new $classname( $serverurl, $accesstoken, ); } /** * Determine if the API supports a feature. * * If an Array is provided, this will return true if any of the specified features is implemented. * * @param string[]|string $feature The feature to check. This is in the form of a namespaced class. * @return bool */ public function implements_feature(array|string $feature): bool { if (is_array($feature)) { foreach ($feature as $thisfeature) { if ($this->implements_feature($thisfeature)) { return true; } } // None of the features are implemented in this API version. return false; } return in_array($feature, $this->get_supported_features()); } /** * Get a list of the features supported by this client. * * @return string[] */ public function get_supported_features(): array { $features = []; $class = static::class; do { $features = array_merge($features, class_uses($class)); $class = get_parent_class($class); } while ($class); return $features; } /** * Require that the API supports a feature. * * If an Array is provided, this is treated as a require any of the features. * * @param string[]|string $feature The feature to test * @throws \moodle_exception */ public function require_feature(array|string $feature): void { if (!$this->implements_feature($feature)) { if (is_array($feature)) { $features = implode(', ', $feature); throw new \moodle_exception( "None of the possible feature are implemented in this Matrix Client: '{$features}'" ); } throw new \moodle_exception("The requested feature is not implemented in this Matrix Client: '{$feature}'"); } } /** * Require that the API supports a list of features. * * All features specified will be required. * * If an array is provided as one of the features, any of the items in the nested array will be required. * * @param string[]|array[] $features The list of features required * * Here is an example usage: * * $matrixapi->require_features([ * * \communication_matrix\local\spec\features\create_room::class, * [ * \communication_matrix\local\spec\features\get_room_info_v1::class, * \communication_matrix\local\spec\features\get_room_info_v2::class, * ] * ]) * */ public function require_features(array $features): void { array_walk($features, [$this, 'require_feature']); } /** * Get the URL of the server. * * @return string */ public function get_server_url(): string { return $this->serverurl; } /** * Query the supported versions, and any unstable features, from the server. * * Servers must implement the client versions API described here: * - https://spec.matrix.org/latest/client-server-api/#get_matrixclientversions * * @param string $serverurl The server base * @return null|\stdClass The list of supported versions and a list of enabled unstable features */ protected static function query_server_supports(string $serverurl): ?\stdClass { // Attempt to return from the cache first. $cache = \cache::make('communication_matrix', 'serverversions'); $serverkey = sha1($serverurl); if ($cache->get($serverkey)) { return $cache->get($serverkey); } // Not in the cache - fetch and store in the cache. try { $client = static::get_http_client(); $response = $client->get("{$serverurl}/_matrix/client/versions"); $supportsdata = json_decode( json: $response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR, ); $cache->set($serverkey, $supportsdata); return $supportsdata; } catch (\GuzzleHttp\Exception\TransferException $e) { return null; } } /** * Get the list of supported versions based on the available classes. * * @return array */ public static function get_supported_versions(): array { $versions = []; $iterator = new DirectoryIterator(__DIR__ . '/local/spec'); foreach ($iterator as $fileinfo) { if ($fileinfo->isDir()) { continue; } // Get the classname from the filename. $classname = substr($fileinfo->getFilename(), 0, -4); if (!preg_match('/^v\d+p\d+$/', $classname)) { // @codeCoverageIgnoreStart // This file does not fit the format v[MAJOR]p[MINOR]]. continue; // @codeCoverageIgnoreEnd } $versions[$classname] = "v" . self::get_version_from_classname($classname); } return $versions; } /** * Get the current token in use. * * @return string */ public function get_token(): string { return $this->accesstoken; } /** * Helper to fetch the HTTP Client for the instance. * * @return \core\http_client */ protected function get_client(): \core\http_client { return static::get_http_client(); } /** * Helper to fetch the HTTP Client. * * @return \core\http_client */ protected static function get_http_client(): \core\http_client { if (static::$client !== null) { return static::$client; } // @codeCoverageIgnoreStart return new http_client(); // @codeCoverageIgnoreEnd } /** * Execute the specified command. * * @param command $command * @return Response */ protected function execute( command $command, ): Response { $client = $this->get_client(); return $client->send( $command, $command->get_options(), ); } /** * Get the API version of the current instance. * * @return string */ public function get_version(): string { $reflect = new \ReflectionClass(static::class); $classname = $reflect->getShortName(); return self::get_version_from_classname($classname); } /** * Normalise an API version from a classname. * * @param string $classname The short classname, omitting any namespace or file extension * @return string The normalised version */ protected static function get_version_from_classname(string $classname): string { $classname = str_replace('v', '', $classname); $classname = str_replace('p', '.', $classname); return $classname; } /** * Check if the API version is at least the specified version. * * @param string $minversion The minimum API version required * @return bool */ public function meets_version(string $minversion): bool { $thisversion = $this->get_version(); return version_compare($thisversion, $minversion) >= 0; } /** * Assert that the API version is at least the specified version. * * @param string $minversion The minimum API version required * @throws Exception */ public function requires_version(string $minversion): void { if ($this->meets_version($minversion)) { return; } throw new \moodle_exception("Matrix API version {$minversion} or higher is required for this command."); } } provider/matrix/classes/matrix_user_manager.php000064400000015406152265255750016130 0ustar00. namespace communication_matrix; /** * class matrix_user_manager to handle specific actions. * * @package communication_matrix * @copyright 2023 Stevani Andolo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class matrix_user_manager { /** * Prefix for Matrix usernames when they are detected as numeric. */ const MATRIX_USER_PREFIX = 'user'; /** * Gets matrix user id from moodle. * * @param int $userid Moodle user id * @return string|null */ public static function get_matrixid_from_moodle( int $userid, ): ?string { self::load_requirements(); $field = profile_user_record($userid); $matrixprofilefield = get_config('communication_matrix', 'matrixuserid_field'); if ($matrixprofilefield === false) { return null; } return $field->{$matrixprofilefield} ?? null; } /** * Get a qualified matrix user id based on a Moodle username. * * @param string $username The moodle username to turn into a Matrix username * @return string */ public static function get_formatted_matrix_userid( string $username, ): string { $username = preg_replace('/[@#$%^&*()+{}|<>?!,]/i', '.', $username); $username = ltrim(rtrim($username, '.'), '.'); // Matrix/Synapse servers will not allow numeric usernames. if (is_numeric($username)) { $username = self::MATRIX_USER_PREFIX . $username; } $homeserver = get_config('communication_matrix', 'matrixhomeservername'); // Fall back to homeserver URL if name not set. if (empty($homeserver)) { $homeserver = self::get_formatted_matrix_home_server(); } return "@{$username}:{$homeserver}"; } /** * Add user's Matrix user id. * * @param int $userid Moodle user id * @param string $matrixuserid Matrix user id */ public static function set_matrix_userid_in_moodle( int $userid, string $matrixuserid, ): void { $matrixprofilefield = self::get_profile_field_name(); $field = profile_get_custom_field_data_by_shortname($matrixprofilefield); if ($field === null) { return; } $userinfodata = (object) [ 'id' => $userid, 'data' => $matrixuserid, 'fieldid' => $field->id, "profile_field_{$matrixprofilefield}" => $matrixuserid, ]; profile_save_data($userinfodata); } /** * Sets home server for user matrix id * * @return string */ public static function get_formatted_matrix_home_server(): string { $homeserver = get_config('communication_matrix', 'matrixhomeserverurl'); if ($homeserver === false) { throw new \moodle_exception('Unknown matrix homeserver url'); } $homeserver = parse_url($homeserver)['host']; if (str_starts_with($homeserver, 'www.')) { $homeserver = str_replace('www.', '', $homeserver); } return $homeserver; } /** * Insert "Communication" category and "matrixuserid" field. * * @return string */ public static function create_matrix_user_profile_fields(): string { global $CFG, $DB; require_once($CFG->dirroot . '/user/profile/definelib.php'); require_once($CFG->dirroot . '/user/profile/field/text/define.class.php'); // Check if communication category exists. $categoryname = get_string('communication', 'core_communication'); $category = $DB->count_records('user_info_category', ['name' => $categoryname]); if ($category < 1) { $data = new \stdClass(); $data->sortorder = $DB->count_records('user_info_category') + 1; $data->name = $categoryname; $data->id = $DB->insert_record('user_info_category', $data); $createdcategory = $DB->get_record('user_info_category', ['id' => $data->id]); $categoryid = $createdcategory->id; \core\event\user_info_category_created::create_from_category($createdcategory)->trigger(); } else { $category = $DB->get_record('user_info_category', ['name' => $categoryname]); $categoryid = $category->id; } set_config('communication_category_field', $categoryname, 'core_communication'); // Check if matrixuserid exists in user_info_field table. $matrixuserid = $DB->count_records('user_info_field', [ 'shortname' => 'matrixuserid', 'categoryid' => $categoryid, ]); if ($matrixuserid < 1) { $profileclass = new \profile_define_text(); $data = (object) [ 'shortname' => 'matrixuserid', 'name' => get_string('matrixuserid', 'communication_matrix'), 'datatype' => 'text', 'categoryid' => $categoryid, 'forceunique' => 1, 'visible' => 0, 'locked' => 1, 'param1' => 30, 'param2' => 2048, ]; $profileclass->define_save($data); set_config('matrixuserid_field', 'matrixuserid', 'communication_matrix'); return 'matrixuserid'; } } /** * Get the profile field name, creating the profiel field if it does not exist. * * @return string */ protected static function get_profile_field_name(): string { self::load_requirements(); $matrixprofilefield = get_config('communication_matrix', 'matrixuserid_field'); if ($matrixprofilefield === false) { $matrixprofilefield = self::create_matrix_user_profile_fields(); } return $matrixprofilefield; } /** * Load requirements for profile field management. * * This is just a helper to keep loading legacy files isolated. */ protected static function load_requirements(): void { global $CFG; require_once("{$CFG->dirroot}/user/profile/lib.php"); } } provider/matrix/tests/fixtures/mocked_matrix_client.php000064400000003031152265255750017625 0ustar00. namespace communication_matrix\tests\fixtures; use core\http_client; /** * Tests for the api_base class. * * @package communication_matrix * @category test * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mocked_matrix_client extends \communication_matrix\matrix_client { /** * Public variant of the constructor. */ public function __construct() { parent::__construct(...func_get_args()); } /** * Reset the test client. */ public static function reset_client(): void { self::$client = null; } /** * Set the http_client to the client specified. * * @param http_client $client */ public static function set_client(http_client $client): void { self::$client = $client; } } provider/matrix/tests/matrix_client_test_trait.php000064400000013560152265255750016704 0ustar00. namespace communication_matrix; use communication_matrix\local\spec\{v1p1, v1p2, v1p3, v1p4, v1p5, v1p6, v1p7}; use communication_matrix\tests\fixtures\mocked_matrix_client; use core\http_client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; /** * A trait with shared tooling for handling matrix_client tests. * * @package communication_matrix * @category test * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait matrix_client_test_trait { public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); // Ensure that the mocked client is available. require_once(__DIR__ . '/fixtures/mocked_matrix_client.php'); } public function setUp(): void { parent::setUp(); // Reset the test client. mocked_matrix_client::reset_client(); } public function tearDown(): void { parent::tearDown(); // Reset the test client. mocked_matrix_client::reset_client(); } /** * Get a mocked instance for a specific Matrix API version, * * @param string $version * @param array $historycontainer An array which will be filled with history for the mocked client. * @param MockHandler|null $mock A MockHandler object that can be appended to * @return matrix_client */ protected function get_mocked_instance_for_version( string $version, array &$historycontainer = [], ?MockHandler $mock = null, ): matrix_client { // If no mock is provided, use get_mocked_http_client to create the mock and client. if ($mock === null) { ['mock' => $mock, 'client' => $client] = $this->get_mocked_http_client( history: $historycontainer ); } else { // If mock is provided, create the handlerstack and history middleware. $handlerstack = HandlerStack::create($mock); $history = Middleware::history($historycontainer); $handlerstack->push($history); $client = new http_client(['handler' => $handlerstack]); } // Add the version response. $mock->append($this->get_mocked_version_response([$version])); mocked_matrix_client::set_client($client); $instance = mocked_matrix_client::instance( 'https://example.com', 'testtoken', ); // Remove the request that is required to fetch the version from the history. array_shift($historycontainer); return $instance; } /** * Get a mocked response for the /versions well-known URI. * * @param array|null $versions * @param array|null $unstablefeatures * @return Response */ protected function get_mocked_version_response( ?array $versions = null, ?array $unstablefeatures = null, ): Response { $data = (object) [ "versions" => array_values(self::get_current_versions()), "unstable_features" => self::get_current_unstable_features(), ]; if ($versions) { $data->versions = array_values($versions); } if ($unstablefeatures) { $data->unstable_features = $unstablefeatures; } return new Response(200, [], json_encode($data)); } /** * A helper to get the current versions returned by synapse. * * @return array */ protected static function get_current_versions(): array { return [ v1p1::class => "v1.1", v1p2::class => "v1.2", v1p3::class => "v1.3", v1p4::class => "v1.4", v1p5::class => "v1.5", v1p6::class => "v1.6", v1p7::class => "v1.7", ]; } /** * A helper to get the current unstable features returned by synapse. * @return array */ protected static function get_current_unstable_features(): array { return [ "org.matrix.label_based_filtering" => true, "org.matrix.e2e_cross_signing" => true, "org.matrix.msc2432" => true, "uk.half-shot.msc2666.query_mutual_rooms" => true, "io.element.e2ee_forced.public" => false, "io.element.e2ee_forced.private" => false, "io.element.e2ee_forced.trusted_private" => false, "org.matrix.msc3026.busy_presence" => false, "org.matrix.msc2285.stable" => true, "org.matrix.msc3827.stable" => true, "org.matrix.msc2716" => false, "org.matrix.msc3440.stable" => true, "org.matrix.msc3771" => true, "org.matrix.msc3773" => false, "fi.mau.msc2815" => false, "fi.mau.msc2659.stable" => true, "org.matrix.msc3882" => false, "org.matrix.msc3881" => false, "org.matrix.msc3874" => false, "org.matrix.msc3886" => false, "org.matrix.msc3912" => false, "org.matrix.msc3952_intentional_mentions" => false, "org.matrix.msc3981" => false, "org.matrix.msc3391" => false, ]; } } provider/matrix/tests/local/command_test.php000064400000030437152265255750015351 0ustar00. namespace communication_matrix\local; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use ReflectionMethod; defined('MOODLE_INTERNAL') || die(); require_once(dirname(__DIR__) . '/matrix_client_test_trait.php'); /** * Tests for the Matrix command class. * * @package communication_matrix * @category test * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \communication_matrix\local\command * @coversDefaultClass \communication_matrix\local\command */ final class command_test extends \advanced_testcase { use \communication_matrix\matrix_client_test_trait; /** * Test instantiation of a command when no method is provided. */ public function test_standard_instantiation(): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: 'example/endpoint', ); // Check the standard functionality. $this->assertEquals('/example/endpoint', $command->getUri()->getPath()); $this->assertEquals('PUT', $command->getMethod()); $this->assertArrayHasKey('Authorization', $command->getHeaders()); } /** * Test instantiation of a command when no method is provided. */ public function test_instantiation_without_auth(): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: 'example/endpoint', requireauthorization: false, ); // Check the standard functionality. $this->assertEquals('/example/endpoint', $command->getUri()->getPath()); $this->assertEquals('PUT', $command->getMethod()); $this->assertArrayNotHasKey('Authorization', $command->getHeaders()); } /** * Test processing of command URL properties. * * @dataProvider url_parsing_provider * @param string $url * @param array $params * @param string $expected */ public function test_url_parsing( string $url, array $params, string $expected, ): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: $url, params: $params, ); $this->assertEquals($expected, $command->getUri()->getPath()); } /** * Data provider for url parsing tests. * * @return array */ public static function url_parsing_provider(): array { return [ [ 'example/:id/endpoint', [':id' => '39492'], '/example/39492/endpoint', ], [ 'example/:id/endpoint/:id', [':id' => '39492'], '/example/39492/endpoint/39492', ], [ 'example/:id/endpoint/:id/:name', [ ':id' => '39492', ':name' => 'matrix', ], '/example/39492/endpoint/39492/matrix', ], ]; } /** * Test processing of command URL properties with an array which contains untranslated parameters. */ public function test_url_parsing_extra_properties(): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $this->expectException(\OutOfRangeException::class); $this->expectExceptionMessage("URL contains untranslated parameters 'example/:id/endpoint'"); new command( $instance, method: 'PUT', endpoint: 'example/:id/endpoint', ); } /** * Test processing of command URL properties with an array which contains untranslated parameters. */ public function test_url_parsing_unused_properites(): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $this->expectException(\OutOfRangeException::class); $this->expectExceptionMessage("Parameter not found in URL ':id'"); new command( $instance, method: 'PUT', endpoint: 'example/:ids/endpoint', params: [ ':id' => 12345, ], ); } /** * Test the parameter fetching, processing, and parsing. * * @dataProvider parameter_and_option_provider * @param string $endpoint * @param array $params * @param array $remainingparams * @param array $allparams * @param array $options */ public function test_parameters( string $endpoint, array $params, array $remainingparams, array $allparams, array $options, ): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: $endpoint, params: $params, ); $this->assertSame($remainingparams, $command->get_remaining_params()); $this->assertSame($allparams, $command->get_all_params()); $this->assertSame($options, $command->get_options()); } /** * Data provider for parameter tests. * * @return array */ public static function parameter_and_option_provider(): array { $command = [ 'method' => 'PUT', 'endpoint' => 'example/:id/endpoint', ]; return [ 'no parameters' => [ 'endpoint' => 'example/endpoint', 'params' => [], 'remainingparams' => [], 'allparams' => [], 'options' => [ 'json' => [], ], ], 'named params' => [ 'endpoint' => 'example/:id/endpoint', 'params' => [ ':id' => 12345, ], 'remainingparams' => [], 'allparams' => [ ':id' => 12345, ], 'options' => [ 'json' => [], ], ], 'mixture of params' => [ 'endpoint' => 'example/:id/endpoint', 'params' => [ ':id' => 12345, 'name' => 'matrix', ], 'remainingparams' => [ 'name' => 'matrix', ], 'allparams' => [ ':id' => 12345, 'name' => 'matrix', ], 'options' => [ 'json' => [ 'name' => 'matrix', ], ], ], ]; } /** * Test the query parameter handling. * * @dataProvider query_provider * @param array $query * @param string $expected */ public function test_query_parameters( array $query, string $expected, ): void { // The query parameter is only added at the time we call send. // That's because it can only be provided to Guzzle as an Option, not as part of the URL. // Options can only be applied at time of transfer. // Unfortuantely that leads to slightly less ideal testing that we'd like here. $mock = new MockHandler(); $instance = $this->get_mocked_instance_for_version( 'v1.7', mock: $mock, ); $mock->append(function (Request $request) use ($expected): Response { $this->assertSame( $expected, $request->getUri()->getQuery(), ); return new Response(); }); $command = new command( $instance, method: 'PUT', endpoint: 'example/endpoint', query: $query, ); $execute = new ReflectionMethod($instance, 'execute'); $execute->invoke($instance, $command); } /** * Data provider for query parameter tests. * @return array */ public static function query_provider(): array { return [ 'no query' => [ 'query' => [], 'expected' => '', ], 'single query' => [ 'query' => [ 'name' => 'matrix', ], 'expected' => 'name=matrix', ], 'multiple queries' => [ 'query' => [ 'name' => 'matrix', 'type' => 'room', ], 'expected' => 'name=matrix&type=room', ], ]; } /** * Test the sendasjson constructor parameter. * * @dataProvider sendasjson_provider * @param bool $sendasjson * @param string $endpoint * @param array $params * @param array $remainingparams * @param array $allparams * @param array $expectedoptions */ public function test_send_as_json( bool $sendasjson, string $endpoint, array $params, array $remainingparams, array $allparams, array $expectedoptions, ): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: $endpoint, params: $params, sendasjson: $sendasjson, ); $this->assertSame($remainingparams, $command->get_remaining_params()); $this->assertSame($allparams, $command->get_all_params()); $this->assertSame($expectedoptions, $command->get_options()); } /** * Test the sendasjosn option to the command constructor. * * @return array */ public static function sendasjson_provider(): array { return [ 'As JSON' => [ 'sendasjson' => true, 'endpoint' => 'example/:id/endpoint', 'params' => [ ':id' => 12345, 'name' => 'matrix', ], 'remainingparams' => [ 'name' => 'matrix', ], 'allparams' => [ ':id' => 12345, 'name' => 'matrix', ], 'expectedoptions' => [ 'json' => [ 'name' => 'matrix', ], ], ], 'Not as JSON' => [ 'sendasjson' => false, 'endpoint' => 'example/:id/endpoint', 'params' => [ ':id' => 12345, 'name' => 'matrix', ], 'remainingparams' => [ 'name' => 'matrix', ], 'allparams' => [ ':id' => 12345, 'name' => 'matrix', ], 'expectedoptions' => [ ], ], ]; } /** * Test the sendasjosn option to the command constructor. */ public function test_ignorehttperrors(): void { $instance = $this->get_mocked_instance_for_version('v1.7'); $command = new command( $instance, method: 'PUT', endpoint: 'example/endpoint', ignorehttperrors: true, ); $options = $command->get_options(); $this->assertArrayHasKey('http_errors', $options); $this->assertFalse($options['http_errors']); } } provider/matrix/tests/matrix_room_test.php000064400000011343152265255750015174 0ustar00. namespace communication_matrix; /** * Tests for the matrix_room class. * * @package communication_matrix * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \communication_matrix\matrix_room */ final class matrix_room_test extends \advanced_testcase { /** * Test for load_by_processor_id with no record. * * @covers ::load_by_processor_id */ public function test_load_by_processor_id_none(): void { $this->assertNull(matrix_room::load_by_processor_id(999999999)); } /** * Test for load_by_processor_id with valid records. * * @covers ::create_room_record * @covers ::__construct * @covers ::load_by_processor_id * @covers ::get_processor_id * @covers ::get_room_id * @covers ::get_topic */ public function test_create_room_record(): void { $this->resetAfterTest(); $room = matrix_room::create_room_record( processorid: 10000, topic: null, ); $this->assertInstanceOf(matrix_room::class, $room); $this->assertEquals(10000, $room->get_processor_id()); $this->assertNotNull('', $room->get_topic()); $this->assertEquals('', $room->get_topic()); $this->assertNull($room->get_room_id()); $room = matrix_room::create_room_record( processorid: 12345, topic: 'The topic of this room is thusly', ); $this->assertInstanceOf(matrix_room::class, $room); $this->assertEquals(12345, $room->get_processor_id()); $this->assertEquals('The topic of this room is thusly', $room->get_topic()); $this->assertNull($room->get_room_id()); $room = matrix_room::create_room_record( processorid: 54321, topic: 'The topic of this room is thusly', roomid: 'This is a roomid', ); $this->assertInstanceOf(matrix_room::class, $room); $this->assertEquals(54321, $room->get_processor_id()); $this->assertEquals('The topic of this room is thusly', $room->get_topic()); $this->assertEquals('This is a roomid', $room->get_room_id()); $reloadedroom = matrix_room::load_by_processor_id(54321); $this->assertEquals(54321, $reloadedroom->get_processor_id()); $this->assertEquals('The topic of this room is thusly', $reloadedroom->get_topic()); $this->assertEquals('This is a roomid', $reloadedroom->get_room_id()); } /** * Test for update_room_record. * * @covers ::update_room_record */ public function test_update_room_record(): void { $this->resetAfterTest(); $room = matrix_room::create_room_record( processorid: 12345, topic: 'The topic of this room is that', ); // Add a roomid. $room->update_room_record( roomid: 'This is a roomid', ); $this->assertEquals('This is a roomid', $room->get_room_id()); $this->assertEquals('The topic of this room is that', $room->get_topic()); $this->assertEquals(12345, $room->get_processor_id()); // Alter the roomid and topic. $room->update_room_record( roomid: 'updatedRoomId', topic: 'updatedTopic is here', ); $this->assertEquals('updatedRoomId', $room->get_room_id()); $this->assertEquals('updatedTopic is here', $room->get_topic()); $this->assertEquals(12345, $room->get_processor_id()); } /** * Tests for delete_room_record. * * @covers ::delete_room_record */ public function test_delete_room_record(): void { global $DB; $this->resetAfterTest(); $room = matrix_room::create_room_record( processorid: 12345, topic: 'The topic of this room is that', ); $this->assertCount(1, $DB->get_records('matrix_room')); $room->delete_room_record(); $this->assertCount(0, $DB->get_records('matrix_room')); } } provider/matrix/tests/communication_feature_test.php000064400000045024152265255750017217 0ustar00. namespace communication_matrix; use core\context; use core_communication\api; use core_communication\communication_test_helper_trait; use core_communication\processor; use stored_file; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/matrix_test_helper_trait.php'); require_once(__DIR__ . '/../../../tests/communication_test_helper_trait.php'); /** * Class communication_feature_test to test the matrix features implemented using the core interfaces. * * @package communication_matrix * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \communication_matrix\communication_feature * @coversDefaultClass \communication_matrix\communication_feature */ final class communication_feature_test extends \advanced_testcase { use matrix_test_helper_trait; use communication_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); $this->initialise_mock_server(); } /** * Test create or update chat room. * * @covers ::create_chat_room */ public function test_create_chat_room(): void { // Set up the test data first. $communication = \core_communication\api::load_by_instance( context: \core\context\system::instance(), component: 'communication_matrix', instancetype: 'example', instanceid: 1, provider: 'communication_matrix', ); $communication->create_and_configure_room( communicationroomname: 'Room name', instance: (object) [ 'matrixroomtopic' => 'A fun topic', ], ); // phpcs:ignore moodle.Commenting.InlineComment.DocBlock /** @var communication_feature */ $provider = $communication->get_room_provider(); $this->assertInstanceOf( communication_feature::class, $provider, ); // Run the create_chat_room task. $result = $provider->create_chat_room(); $this->assertTrue($result); // Ensure that a room_id was set. $this->assertNotEmpty($provider->get_room_id()); // Fetch the back office room data. $remoteroom = $this->backoffice_get_room(); // The roomid set in the database must match the one set on the remote server. $this->assertEquals( $remoteroom->room_id, $provider->get_room_id(), ); // The name is a feature of the communication API itself. $this->assertEquals( 'Room name', $communication->get_room_name(), ); $this->assertEquals( $communication->get_room_name(), $remoteroom->name, ); // The topic is a Matrix feature. $roomconfig = $provider->get_room_configuration(); $this->assertEquals( 'A fun topic', $roomconfig->get_topic(), ); $this->assertEquals( $remoteroom->topic, $roomconfig->get_topic(), ); // The avatar features are checked in a separate test. } /** * Test update of a chat room. * * @covers ::update_chat_room */ public function test_update_chat_room(): void { $communication = $this->create_room( roomname: 'Our room name', roomtopic: 'Our room topic', ); // phpcs:ignore moodle.Commenting.InlineComment.DocBlock /** @var communication_feature */ $provider = $communication->get_room_provider(); $this->assertInstanceOf( communication_feature::class, $provider, ); // Update the room name. // Note: We have to update the record via the API, and then call the provider update method. // That's because the update is performed asynchronously. $communication->update_room( communicationroomname: 'Our updated room name', ); $provider->reload(); // Now call the provider's update method. $provider->update_chat_room(); // And assert that it was updated remotely. $remoteroom = $this->backoffice_get_room(); $this->assertEquals( 'Our updated room name', $communication->get_room_name(), ); $this->assertEquals( $communication->get_room_name(), $remoteroom->name, ); // The remote topic should not have changed. $this->assertEquals( 'Our room topic', $remoteroom->topic, ); // Now update just the topic. // First in the local API. $communication->update_room( instance: (object) [ 'matrixroomtopic' => 'Our updated room topic', ], ); $provider->reload(); // Then call the provider's update method to actually perform the change. $provider->update_chat_room(); // And assert that it was updated remotely. $remoteroom = $this->backoffice_get_room(); $this->assertEquals( 'Our updated room topic', $provider->get_room_configuration()->get_topic(), ); // The remote topic should have been updated. $this->assertEquals( 'Our updated room topic', $remoteroom->topic, ); // The name should not have changed. $this->assertEquals( 'Our updated room name', $communication->get_room_name(), ); } /** * Test delete chat room. * * @covers ::delete_chat_room */ public function test_delete_chat_room(): void { $communication = $this->create_room(); $processor = $communication->get_processor(); $provider = $communication->get_room_provider(); $room = matrix_room::load_by_processor_id($processor->get_id()); // Run the delete method. $this->assertTrue($provider->delete_chat_room()); // The record of the room should have been removed. $this->assertNull(matrix_room::load_by_processor_id($processor->get_id())); // But the room itself shoudl exist. $matrixroomdata = $this->get_matrix_room_data($room->get_room_id()); $this->assertNotEmpty($matrixroomdata); $this->assertEquals($processor->get_room_name(), $matrixroomdata->name); $this->assertEquals($room->get_topic(), $matrixroomdata->topic); } /** * Test update room avatar. * * @covers ::update_room_avatar * @dataProvider avatar_provider */ public function test_update_room_avatar( ?string $before, ?string $after, ): void { $this->setAdminUser(); // Create a new draft file. $logo = $this->create_communication_file('moodle_logo.jpg', 'logo.jpg'); $circle = $this->create_communication_file('circle.png', 'circle.png'); if ($before === 'logo') { $before = $logo; } else if ($before === 'circle') { $before = $circle; } if ($after === 'logo') { $after = $logo; } else if ($after === 'circle') { $after = $circle; } $communication = $this->create_matrix_room( component: 'communication_matrix', itemtype: 'example_room', itemid: 1, roomname: 'Example room name', roomavatar: $before, ); // Confirm that the avatar was set remotely. $remoteroom = $this->backoffice_get_room(); if ($before) { $this->assertStringEndsWith($before->get_filename(), $remoteroom->avatar); $avatarcontent = download_file_content($remoteroom->avatar); $this->assertEquals($before->get_content(), $avatarcontent); } else { $this->assertEmpty($remoteroom->avatar); } // Reload the API instance as the information stored has changed. $communication->reload(); // Update the avatar with the 'after' avatar. $communication->update_room( avatar: $after, ); $this->run_all_adhoc_tasks(); // Confirm that the avatar was updated remotely. $remoteroom = $this->backoffice_get_room(); if ($after) { $this->assertStringEndsWith($after->get_filename(), $remoteroom->avatar); $avatarcontent = download_file_content($remoteroom->avatar); $this->assertEquals($after->get_content(), $avatarcontent); } else { $this->assertEmpty($remoteroom->avatar); } } /** * Tests for setting and updating the room avatar. * * @return array */ public static function avatar_provider(): array { return [ 'Empty to avatar' => [ null, 'circle', ], 'Avatar to empty' => [ 'circle', null, ], 'Avatar to new avatar' => [ 'circle', 'logo', ], ]; } /** * Test get chat room url. * * @covers ::get_chat_room_url */ public function test_get_chat_room_url(): void { $communication = $this->create_room(); $provider = $communication->get_room_provider(); $url = $provider->get_chat_room_url(); $this->assertNotNull($url); // Fetch the room information from the server. $remoteroom = $this->backoffice_get_room(); $this->assertStringEndsWith( $remoteroom->room_id, $url, ); } /** * Test create members. * * @covers ::create_members * @covers ::add_registered_matrix_user_to_room */ public function test_create_members(): void { $user = $this->getDataGenerator()->create_user(); $communication = $this->create_room( members: [ $user->id, ], ); $remoteroom = $this->backoffice_get_room(); $this->assertCount(1, $remoteroom->members); $member = reset($remoteroom->members); $this->assertStringStartsWith("@{$user->username}", $member->userid); } /** * Test add/remove members from room. * * @covers ::remove_members_from_room * @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(); $user2 = $this->getDataGenerator()->create_user(); $communication = $this->create_room(); $provider = $communication->get_room_user_provider(); $remoteroom = $this->backoffice_get_room(); $this->assertCount(0, $remoteroom->members); // Add the members to the room. $provider->add_members_to_room([$user->id, $user2->id]); // Ensure that they have been created. $remoteroom = $this->backoffice_get_room(); $this->assertCount(2, $remoteroom->members); $userids = array_map(fn($member) => $member->userid, $remoteroom->members); $userids = array_map(fn($userid) => substr($userid, 0, strpos($userid, ':')), $userids); $this->assertContains("@{$user->username}", $userids); $this->assertContains("@{$user2->username}", $userids); // Remove member from matrix room. $provider->remove_members_from_room([$user->id]); // Ensure that they have been removed. $remoteroom = $this->backoffice_get_room(); $members = (array) $remoteroom->members; $this->assertCount(1, $members); $userids = array_map(fn ($member) => $member->userid, $members); $userids = array_map(fn ($userid) => substr($userid, 0, strpos($userid, ':')), $userids); $this->assertNotContains("@{$user->username}", $userids); $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'); $coursecontext = \context_course::instance($course->id); $user = $this->get_user(); $communication = $this->create_room( component: 'core_course', itemtype: 'coursecommunication', itemid: $course->id, roomname: 'sampleroom', roomtopic: 'sampltopic', roomavatar: null, members: [$user->id], context: $coursecontext, ); $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. $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( context: $coursecontext, 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( context: \core\context\course::instance($course->id), 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_MOODLE_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. * * @param null|string $component * @param null|string $itemtype * @param null|int $itemid * @param null|string $roomname * @param null|string $roomtopic * @param null|stored_file $roomavatar * @param array $members * @return api */ protected function create_room( ?string $component = 'communication_matrix', ?string $itemtype = 'example', ?int $itemid = 1, ?string $roomname = null, ?string $roomtopic = null, ?\stored_file $roomavatar = null, array $members = [], ?context $context = null, ): \core_communication\api { // Create a new room. $communication = \core_communication\api::load_by_instance( context: $context ?? \core\context\system::instance(), component: $component, instancetype: $itemtype, instanceid: $itemid, provider: 'communication_matrix', ); $communication->create_and_configure_room( communicationroomname: $roomname ?? 'Room name', avatar: $roomavatar, instance: (object) [ 'matrixroomtopic' => $roomtopic ?? 'A fun topic', ], ); $communication->add_members_to_room($members); // Run the adhoc task. $this->run_all_adhoc_tasks(); $communication->reload(); return $communication; } /** * Test if the selected provider is configured. * * @covers ::is_configured */ public function test_is_configured(): void { $course = $this->get_course(); $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id ); $this->assertTrue($communicationprocessor->get_room_provider()->is_configured()); // Unset communication_matrix settings. unset_config('matrixhomeserverurl', 'communication_matrix'); unset_config('matrixaccesstoken', 'communication_matrix'); $this->assertFalse($communicationprocessor->get_room_provider()->is_configured()); } } provider/matrix/tests/matrix_test_helper_trait.php000064400000022056152265255750016705 0ustar00. namespace communication_matrix; use core\context; use GuzzleHttp\Psr7\Response; /** * Trait matrix_helper_trait to generate initial setup for matrix mock and associated helpers. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait matrix_test_helper_trait { /** * @var string $accesstoken The token for matrix connection */ protected string $accesstoken; /** * @var string $matrixhomeserverurl The server url of matrix synapse server */ protected string $matrixhomeserverurl; /** * Initialize the mock configs in settings. * * @return void */ protected function initialise_mock_configs(): void { $this->matrixhomeserverurl = TEST_COMMUNICATION_MATRIX_MOCK_SERVER; set_config('matrixhomeserverurl', $this->matrixhomeserverurl, 'communication_matrix'); $request = $this->request(); $response = $request->post($this->matrixhomeserverurl . '/backoffice/create-admin'); $admindata = json_decode($response->getBody()); $json = [ 'identifier' => [ 'type' => 'm.id.user', 'user' => $admindata->user_id, ], 'type' => 'm.login.password', 'password' => $admindata->password, ]; $request = $this->request($json); $response = $request->post($this->matrixhomeserverurl . '/_matrix/client/r0/login'); $response = json_decode($response->getBody()); if (empty($response->access_token)) { $this->markTestSkipped( 'The matrix mock server is not responsive, can not continue the tests' ); } $this->accesstoken = $response->access_token; set_config('matrixaccesstoken', $this->accesstoken, 'communication_matrix'); } /** * Get the mock server url. * * @return string */ public function get_matrix_server_url(): string { if (empty($this->matrixhomeserverurl)) { throw new \coding_exception('Can not get this information without initializing the mock server.'); } return $this->matrixhomeserverurl; } /** * Get the matrix access token. * * @return string */ public function get_matrix_access_token(): string { if (empty($this->accesstoken)) { throw new \coding_exception('Can not get this information without initializing the mock server.'); } return $this->accesstoken; } /** * This test requires mock server to be present. * * @return void */ protected function initialise_mock_server(): void { if (!defined('TEST_COMMUNICATION_MATRIX_MOCK_SERVER')) { $this->markTestSkipped( 'The TEST_COMMUNICATION_MATRIX_MOCK_SERVER constant must be defined to run communication_matrix tests' ); } $this->reset_mock(); $this->initialise_mock_configs(); } /** * Get matrix room data from matrix server. * * @param string $roomid The id of the room * @return \stdClass */ public function get_matrix_room_data(string $roomid): \stdClass { $rooms = $this->backoffice_get_all_rooms(); foreach ($rooms as $room) { if ($room->room_id === $roomid) { return $room; } } } /** * Get matrix user data from matrix server. * * @param string $roomid The id of the room * @param string $matrixuserid The id of the user * @return \stdClass */ public function get_matrix_user_data(string $roomid, string $matrixuserid): \stdClass { $users = $this->backoffice_get_all_users(); foreach ($users as $user) { if ($user->userid === $matrixuserid) { return $user; } } } /** * A backoffice call to get all registered users from our mock server. * * @return array */ public function backoffice_get_all_users(): array { $client = new \core\http_client(); return json_decode($client->get($this->get_backoffice_uri('users'))->getBody())->users; } /** * A backoffice method to create users and rooms on our mock server. * * @param array $users * @param array $rooms */ public function backoffice_create_users_and_rooms( array $users = [], array $rooms = [], ): Response { $client = new \core\http_client(); return $client->put( $this->get_backoffice_uri('create'), [ 'json' => [ 'users' => $users, 'rooms' => $rooms, ], ], ); } /** * The http request for the api call. * * @param array $jsonarray The array of json * @param array $headers The array of headers * @return \core\http_client */ public function request(array $jsonarray = [], array $headers = []): \core\http_client { $response = new \core\http_client([ 'headers' => $headers, 'json' => $jsonarray, ]); return $response; } /** * Get the URI of a backoffice endpoint on the mock server. * * @param string $endpoint * @return string */ protected function get_backoffice_uri(string $endpoint): string { return $this->get_matrix_server_url() . '/backoffice/' . $endpoint; } /** * Fetch all rooms from the back office. * * @return array */ public function backoffice_get_all_rooms(): array { $client = new \core\http_client(); return json_decode($client->get($this->get_backoffice_uri('rooms'))->getBody())->rooms; } /** * Return the first room from the server. * * In most cases there is only one room. * @return \stdClass */ public function backoffice_get_room(): \stdClass { // Fetch the room information from the server. $rooms = $this->backoffice_get_all_rooms(); $this->assertCount(1, $rooms); $room = reset($rooms); return $room; } /** * Reset the mock server * * @return void */ public function reset_mock(): void { if (defined('TEST_COMMUNICATION_MATRIX_MOCK_SERVER')) { $request = $this->request(); $response = $request->post(TEST_COMMUNICATION_MATRIX_MOCK_SERVER . '/backoffice/reset'); $response = json_decode($response->getBody()); if (empty($response->reset)) { $this->markTestSkipped( 'The matrix mock server is not responsive, can not continue the tests' ); } } } /** * Helper to create a room. * * @param null|string $component * @param null|string $itemtype * @param null|int $itemid * @param null|string $roomname * @param null|string $roomtopic * @param null|stored_file $roomavatar * @param array $members * @return api */ protected function create_matrix_room( ?string $component = 'core_course', ?string $itemtype = 'example', ?int $itemid = 1, ?string $roomname = null, ?string $roomtopic = null, ?\stored_file $roomavatar = null, array $members = [], ?context $context = null, ): \core_communication\api { $context = $context ?? \core\context\system::instance(); // Create a new room. $communication = \core_communication\api::load_by_instance( context: $context, component: $component, instancetype: $itemtype, instanceid: $itemid, provider: 'communication_matrix', ); $communication->create_and_configure_room( communicationroomname: $roomname ?? 'Room name', avatar: $roomavatar, instance: (object) [ 'matrixroomtopic' => $roomtopic ?? 'A fun topic', ], ); $communication->add_members_to_room($members); // Run the adhoc task. $this->run_all_adhoc_tasks(); return \core_communication\api::load_by_instance( context: $context, component: $component, instancetype: $itemtype, instanceid: $itemid, ); } } provider/matrix/tests/matrix_client_test.php000064400000033377152265255750015511 0ustar00. namespace communication_matrix; use communication_matrix\local\command; use communication_matrix\local\spec\v1p7; use communication_matrix\local\spec\features; use communication_matrix\tests\fixtures\mocked_matrix_client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; use moodle_exception; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/matrix_client_test_trait.php'); /** * Tests for the matrix_client class. * * @package communication_matrix * @category test * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \communication_matrix\matrix_client * @coversDefaultClass \communication_matrix\matrix_client */ final class matrix_client_test extends \advanced_testcase { use matrix_client_test_trait; /** * Data provider for valid calls to ::instance. * @return array */ public static function instance_provider(): array { $testcases = [ 'Standard versions' => [ null, v1p7::class, ], ]; // Remove a couple of versions. $versions = self::get_current_versions(); array_pop($versions); array_pop($versions); $testcases['Older server'] = [ $versions, array_key_last($versions), ]; // Limited version compatibility, including newer than we support now. $testcases['Newer versions with crossover'] = [ [ 'v1.6', 'v1.7', 'v7.9', ], \communication_matrix\local\spec\v1p7::class, ]; return $testcases; } /** * Test that the instance method returns a valid instance for the given versions. * * @dataProvider instance_provider * @param array|null $versions * @param string $expectedversion */ public function test_instance( ?array $versions, string $expectedversion, ): void { $container = []; ['client' => $client, 'mock' => $mock] = $this->get_mocked_http_client( history: $container, ); $mock->append($this->get_mocked_version_response($versions)); mocked_matrix_client::set_client($client); $instance = mocked_matrix_client::instance( 'https://example.com', 'testtoken', ); $this->assertInstanceOf(matrix_client::class, $instance); // Only the version API has been called. $this->assertCount(1, $container); $request = reset($container); $this->assertEquals('/_matrix/client/versions', $request['request']->getUri()->getPath()); // The client should be a v1p7 client as that is the highest compatible version. $this->assertInstanceOf($expectedversion, $instance); } /** * Test that the instance method returns a valid instance for the given versions. */ public function test_instance_cached(): void { $container = []; ['client' => $client, 'mock' => $mock] = $this->get_mocked_http_client( history: $container, ); // Queue two responses. $mock->append($this->get_mocked_version_response()); $mock->append($this->get_mocked_version_response()); mocked_matrix_client::set_client($client); $instance = mocked_matrix_client::instance('https://example.com', 'testtoken'); $this->assertInstanceOf(matrix_client::class, $instance); // Only the version API has been called. $this->assertCount(1, $container); // Call the API again. It should not lead to additional fetches. $instance = mocked_matrix_client::instance('https://example.com', 'testtoken'); $instance = mocked_matrix_client::instance('https://example.com', 'testtoken'); $this->assertCount(1, $container); // But a different endpoint will. $instance = mocked_matrix_client::instance('https://example.org', 'testtoken'); $this->assertCount(2, $container); } /** * Test that the instance method throws an appropriate exception if no support is found. */ public function test_instance_no_support(): void { ['client' => $client, 'mock' => $mock] = $this->get_mocked_http_client(); $mock->append($this->get_mocked_version_response(['v99.9'])); mocked_matrix_client::set_client($client); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('No supported Matrix API versions found.'); mocked_matrix_client::instance( 'https://example.com', 'testtoken', ); } /** * Test the feature implementation check methods. * * @covers ::implements_feature * @covers ::get_supported_versions * @dataProvider implements_feature_provider * @param string $version * @param array|string $features * @param bool $expected */ public function test_implements_feature( string $version, array|string $features, bool $expected, ): void { $instance = $this->get_mocked_instance_for_version($version); $this->assertEquals($expected, $instance->implements_feature($features)); } /** * Test the feature implementation requirement methods. * * @covers ::implements_feature * @covers ::get_supported_versions * @covers ::require_feature * @dataProvider implements_feature_provider * @param string $version * @param array|string $features * @param bool $expected */ public function test_require_feature( string $version, array|string $features, bool $expected, ): void { $instance = $this->get_mocked_instance_for_version($version); if ($expected) { $this->assertEmpty($instance->require_feature($features)); } else { $this->expectException('moodle_exception'); $instance->require_feature($features); } } /** * Test the feature implementation requirement methods for a require all. * * @covers ::implements_feature * @covers ::get_supported_versions * @covers ::require_feature * @covers ::require_features * @dataProvider require_features_provider * @param string $version * @param array|string $features * @param bool $expected */ public function test_require_features( string $version, array|string $features, bool $expected, ): void { $instance = $this->get_mocked_instance_for_version($version); if ($expected) { $this->assertEmpty($instance->require_features($features)); } else { $this->expectException('moodle_exception'); $instance->require_features($features); } } /** * Data provider for feature implementation check tests. * * @return array */ public static function implements_feature_provider(): array { return [ 'Basic supported feature' => [ 'v1.7', features\matrix\media_create_v1::class, true, ], 'Basic unsupported feature' => [ 'v1.6', features\matrix\media_create_v1::class, false, ], '[supported] as array' => [ 'v1.6', [features\matrix\create_room_v3::class], true, ], '[supported, supported] as array' => [ 'v1.6', [ features\matrix\create_room_v3::class, features\matrix\update_room_avatar_v3::class, ], true, ], '[unsupported] as array' => [ 'v1.6', [ features\matrix\media_create_v1::class, ], false, ], '[unsupported, supported] as array' => [ 'v1.6', [ features\matrix\media_create_v1::class, features\matrix\update_room_avatar_v3::class, ], true, ], ]; } /** * Data provider for feature implementation check tests. * * @return array */ public static function require_features_provider(): array { // We'll just add to the standard testcases. $testcases = array_map(static function (array $testcase): array { $testcase[1] = [$testcase[1]]; return $testcase; }, self::implements_feature_provider()); $testcases['Require many supported features'] = [ 'v1.6', [ features\matrix\create_room_v3::class, features\matrix\update_room_avatar_v3::class, ], true, ]; $testcases['Require many including an unsupported feature'] = [ 'v1.6', [ features\matrix\create_room_v3::class, features\matrix\media_create_v1::class, ], false, ]; $testcases['Require many including an unsupported feature which has an alternate'] = [ 'v1.6', [ features\matrix\create_room_v3::class, [ features\matrix\media_create_v1::class, features\matrix\update_room_avatar_v3::class, ], ], true, ]; return $testcases; } /** * Test the get_version method. * * @param string $version * @param string $expectedversion * @dataProvider get_version_provider * @covers ::get_version * @covers ::get_version_from_classname */ public function test_get_version( string $version, string $expectedversion, ): void { $instance = $this->get_mocked_instance_for_version($version); $this->assertEquals($expectedversion, $instance->get_version()); } /** * Data provider for get_version tests. * * @return array */ public static function get_version_provider(): array { return [ ['v1.1', '1.1'], ['v1.7', '1.7'], ]; } /** * Tests the meets_version method. * * @param string $version The version of the API to test against * @param string $testversion The version to test * @param bool $expected Whether the version meets the requirement * @dataProvider meets_version_provider * @covers ::meets_version */ public function test_meets_version( string $version, string $testversion, bool $expected, ): void { $instance = $this->get_mocked_instance_for_version($version); $this->assertEquals($expected, $instance->meets_version($testversion)); } /** * Tests the requires_version method. * * @param string $version The version of the API to test against * @param string $testversion The version to test * @param bool $expected Whether the version meets the requirement * @dataProvider meets_version_provider * @covers ::requires_version */ public function test_requires_version( string $version, string $testversion, bool $expected, ): void { $instance = $this->get_mocked_instance_for_version($version); if ($expected) { $this->assertEmpty($instance->requires_version($testversion)); } else { $this->expectException('moodle_exception'); $instance->requires_version($testversion); } } /** * Data provider for meets_version tests. * * @return array */ public static function meets_version_provider(): array { return [ 'Same version' => ['v1.1', '1.1', true], 'Same version latest' => ['v1.7', '1.7', true], 'Newer version rejected' => ['v1.1', '1.7', false], 'Older version accepted' => ['v1.7', '1.1', true], ]; } /** * Test the execute method with a command. * * @covers ::execute */ public function test_command_is_executed(): void { $historycontainer = []; $mock = new MockHandler(); $instance = $this->get_mocked_instance_for_version('v1.6', $historycontainer, $mock); $command = new command( $instance, method: 'GET', endpoint: 'test/endpoint', params: [ 'test' => 'test', ], ); $mock->append(new Response(200)); $rc = new \ReflectionClass($instance); $rcm = $rc->getMethod('execute'); $result = $rcm->invoke($instance, $command); $this->assertEquals(200, $result->getStatusCode()); $this->assertCount(1, $historycontainer); $request = array_shift($historycontainer); $this->assertEquals('GET', $request['request']->getMethod()); $this->assertEquals('/test/endpoint', $request['request']->getUri()->getPath()); } } provider/matrix/tests/behat/matrix_room_banner.feature000064400000005601152265255750017411 0ustar00@communication @communication_matrix Feature: Display communication room status banner Show a banner depending on the room status As a teacher or admin Background: Given a Matrix mock server is configured And the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | selectedcommunication | communicationroomname | | Test course | Test course | 0 | communication_matrix | matrixroom | And the following "course enrolments" exist: | user | course | role | | teacher1 | Test course | editingteacher | | student1 | Test course | student | Scenario: I can see the room has been created and in a pending status When I am on the "Test course" "Course" page logged in as "teacher1" Then I should see "Your Matrix room will be ready soon." in the "page-content" "region" When I am on the "Test course" "Course" page logged in as "student1" # Not for students to see. Then I should not see "Your Matrix room will be ready soon." in the "page-content" "region" Scenario: I can see the room has been created and ready to access When I run all adhoc tasks And I am on the "Test course" "Course" page logged in as "teacher1" Then I should see "Your Matrix room is ready." in the "page-content" "region" # This is a one time message per user. When I reload the page Then I should not see "Your Matrix room is ready." in the "page-content" "region" # Not for students to see. When I am on the "Test course" "Course" page logged in as "student1" Then I should not see "Your Matrix room is ready." in the "page-content" "region" Scenario: Enabling or disabling the matrix plugin hides the banner accordingly Given I am on the "Test course" "Course" page logged in as "teacher1" Then I should see "Your Matrix room will be ready soon." in the "page-content" "region" When I log in as "admin" And I navigate to "Plugins > Communication > Manage communication providers" in site administration And I should see "Matrix" And I click on "Disable" "link" in the "Matrix" "table_row" And I am on the "Test course" "Course" page logged in as "teacher1" And I should not see "Your Matrix room will be ready soon." in the "page-content" "region" And I log in as "admin" And I navigate to "Plugins > Communication > Manage communication providers" in site administration And I should see "Matrix" And I click on "Enable" "link" in the "Matrix" "table_row" And I am on the "Test course" "Course" page logged in as "teacher1" Then I should see "Your Matrix room will be ready soon." in the "page-content" "region" provider/matrix/tests/behat/behat_communication_matrix.php000064400000004604152265255750020256 0ustar00. // phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace use Behat\Behat\Hook\Scope\BeforeScenarioScope; use communication_matrix\matrix_test_helper_trait; use Moodle\BehatExtension\Exception\SkippedException; require_once(__DIR__ . '/../matrix_test_helper_trait.php'); require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/../../../../tests/communication_test_helper_trait.php'); /** * Class behat_communication_matrix for behat custom steps and configuration for matrix. * * @package communication_matrix * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_communication_matrix extends \behat_base { use \core_communication\communication_test_helper_trait; use matrix_test_helper_trait; /** * BeforeScenario hook to reset the mock server. * * @BeforeScenario @communication_matrix * * @param BeforeScenarioScope $scope */ public function before_scenario(BeforeScenarioScope $scope) { if (defined('TEST_COMMUNICATION_MATRIX_MOCK_SERVER')) { $this->reset_mock(); } } /** * Setup and configure and mock server for matrix. * * @Given /^a Matrix mock server is configured$/ */ public function initialize_mock_server(): void { if (!defined('TEST_COMMUNICATION_MATRIX_MOCK_SERVER')) { throw new SkippedException( 'The TEST_COMMUNICATION_MATRIX_MOCK_SERVER constant must be defined to run communication_matrix tests' ); } $this->setup_communication_configs(); $this->initialise_mock_configs(); } } provider/matrix/tests/behat/matrix_form_fields.feature000064400000003042152265255750017376 0ustar00@communication @communication_matrix Feature: Communication matrix form field In order to create a new communication room in matrix As a teacher I can update the room the information from course Background: Make sure the mock server is initialized and a course is created for teacher Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Test course | Test course | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | Test course | editingteacher | @javascript Scenario: I can add room name and topic for matrix room Given a Matrix mock server is configured And I am on the "Test course" "Course" page logged in as "teacher1" When I navigate to "Communication" in current page administration And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected And I set the following fields to these values: | communication_matrixroomname | Sampleroomname | | matrixroomtopic | Sampleroomtopic | And I should see "Room name" And I should see "Room topic" And I press "Save changes" And I navigate to "Communication" in current page administration Then the field "Room name" matches value "Sampleroomname" And the field "Room topic" matches value "Sampleroomtopic" provider/matrix/tests/matrix_user_manager_test.php000064400000020200152265255750016660 0ustar00. namespace communication_matrix; use moodle_exception; /** * Class matrix_user_manager_test to test the matrix user manager. * * @package communication_matrix * @category test * @copyright 2023 Stevani Andolo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \communication_matrix\matrix_user_manager */ final class matrix_user_manager_test extends \advanced_testcase { /** * Test fetcihing a users matrix userid from Moodle. */ public function test_get_matrixid_from_moodle_without_field(): void { $user = get_admin(); $this->assertNull(matrix_user_manager::get_matrixid_from_moodle($user->id)); } /** * Test fetching a user's matrix userid from Moodle. */ public function test_get_matrixid_from_moodle(): void { $this->resetAfterTest(); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Add user ids to both users. matrix_user_manager::set_matrix_userid_in_moodle( $user1->id, '@someexampleuser:matrix.moodle.org', ); matrix_user_manager::set_matrix_userid_in_moodle( $user2->id, '@someotherexampleuser:matrix.moodle.org', ); // And confirm that they're fetched back. $this->assertEquals( '@someexampleuser:matrix.moodle.org', matrix_user_manager::get_matrixid_from_moodle($user1->id), ); $this->assertEquals( '@someotherexampleuser:matrix.moodle.org', matrix_user_manager::get_matrixid_from_moodle($user2->id), ); } /** * Test fetching a formatted matrix userid from Moodle when no server is set. */ public function test_get_formatted_matrix_userid_unset(): void { $this->expectException(moodle_exception::class); matrix_user_manager::get_formatted_matrix_userid('No value'); } /** * Test fetch of a formatted matrix userid. * * @dataProvider get_formatted_matrix_userid_provider * @param string $server * @param string $username The moodle username to turn into a Matrix username * @param string $expecteduserid The expected matrix user id */ public function test_get_formatted_matrix_userid( ?string $servername, string $server, string $username, string $expecteduserid, ): void { $this->resetAfterTest(); set_config('matrixhomeservername', $servername, 'communication_matrix'); set_config('matrixhomeserverurl', $server, 'communication_matrix'); $this->assertEquals( $expecteduserid, matrix_user_manager::get_formatted_matrix_userid($username), ); } /** * Data provider for get_formatted_matrix_userid. * * @return array */ public static function get_formatted_matrix_userid_provider(): array { return [ 'servername' => [ 'example.org', 'https://matrix.example.org', 'user', '@user:example.org', ], 'servername empty string' => [ '', 'https://matrix.example.org', 'user', '@user:matrix.example.org', ], 'alphanumeric' => [ null, 'https://matrix.example.org', 'alphabet1', '@alphabet1:matrix.example.org', ], 'chara' => [ null, 'https://matrix.example.org', 'asdf#$%^&*()+{}|<>?!,asdf', '@asdf.................asdf:matrix.example.org', ], 'local server' => [ null, 'https://synapse', 'colin.creavey', '@colin.creavey:synapse', ], 'server with port' => [ null, 'https://matrix.example.org:8448', 'colin.creavey', '@colin.creavey:matrix.example.org', ], 'numeric username' => [ null, 'https://matrix.example.org', '123456', '@' . matrix_user_manager::MATRIX_USER_PREFIX . '123456:matrix.example.org', ], ]; } /** * Data provider for set_matrix_userid_in_moodle. * * @return array */ public static function set_matrix_userid_in_moodle_provider(): array { return array_combine( array_keys(self::get_formatted_matrix_userid_provider()), array_map( fn($value) => [$value[2]], self::get_formatted_matrix_userid_provider(), ), ); } /** * Test setting of a user's matrix userid in Moodle. * * @dataProvider set_matrix_userid_in_moodle_provider * @param string $expectedusername */ public function test_set_matrix_userid_in_moodle( string $expectedusername, ): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); matrix_user_manager::set_matrix_userid_in_moodle($user->id, $expectedusername); // Get created matrixuserid from moodle. $this->assertEquals( $expectedusername, matrix_user_manager::get_matrixid_from_moodle($user->id), ); } /** * Test for getting a formatted matrix home server id. * * @dataProvider get_formatted_matrix_home_server_provider * @param string $input * @param string $expectedoutput */ public function test_get_formatted_matrix_home_server( string $input, string $expectedoutput ): void { $this->resetAfterTest(); set_config( 'matrixhomeserverurl', $input, 'communication_matrix', ); $this->assertEquals( $expectedoutput, matrix_user_manager::get_formatted_matrix_home_server(), ); } /** * Data provider for get_formatted_matrix_home_server. * * @return array */ public static function get_formatted_matrix_home_server_provider(): array { return [ 'www is removed' => [ 'https://www.example.org', 'example.org', ], 'www is not removed if it is not at the beginning' => [ 'https://matrix.www.example.org', 'matrix.www.example.org', ], 'others are not removed' => [ 'https://matrix.example.org', 'matrix.example.org', ], ]; } /** * Test creation of matrix user profile fields. */ public function test_create_matrix_user_profile_fields(): void { global $CFG; require_once("{$CFG->dirroot}/user/profile/lib.php"); $this->resetAfterTest(); $matrixprofilefield = get_config('communication_matrix', 'matrixuserid_field'); $this->assertFalse($matrixprofilefield); $this->assertIsString(matrix_user_manager::create_matrix_user_profile_fields()); $matrixprofilefield = get_config('communication_matrix', 'matrixuserid_field'); $this->assertNotFalse($matrixprofilefield); $user = $this->getDataGenerator()->create_user(); $this->assertObjectHasProperty($matrixprofilefield, profile_user_record($user->id)); } } provider/matrix/settings.php000064400000004014152265255750012270 0ustar00. /** * Matrix communication plugin settings. * * @package communication_matrix * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; if ($hassiteconfig) { // Home server URL. $name = new lang_string('matrixhomeserverurl', 'communication_matrix'); $desc = new lang_string('matrixhomeserverurl_desc', 'communication_matrix'); $settings->add(new admin_setting_configtext('communication_matrix/matrixhomeserverurl', $name, $desc, '')); // Home server name. $name = new lang_string('matrixhomeservername', 'communication_matrix'); $desc = new lang_string('matrixhomeservername_desc', 'communication_matrix'); $settings->add(new admin_setting_configtext('communication_matrix/matrixhomeservername', $name, $desc, '')); // Access token. $name = new lang_string('matrixaccesstoken', 'communication_matrix'); $desc = new lang_string('matrixaccesstoken_desc', 'communication_matrix'); $settings->add(new admin_setting_configpasswordunmask('communication_matrix/matrixaccesstoken', $name, $desc, '')); // Element web URL. $name = new lang_string('matrixelementurl', 'communication_matrix'); $settings->add(new admin_setting_configtext('communication_matrix/matrixelementurl', $name, '', '')); } amd/src/providerchooser.js000064400000002760152265255750011672 0ustar00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Communication provider selection handler. * * @module core_communication/communicationchooser * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors = { fields: { selector: '[data-communicationchooser-field="selector"]', updateButton: '[data-communicationchooser-field="updateButton"]', }, }; /** * Initialise the format chooser. */ export const init = () => { document.querySelector(Selectors.fields.selector).addEventListener('change', e => { const form = e.target.closest('form'); const updateButton = form.querySelector(Selectors.fields.updateButton); const url = new URL(form.action); form.action = url.toString(); updateButton.click(); }); }; amd/build/providerchooser.min.js000064400000001561152265255750012762 0ustar00define("core_communication/providerchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * Communication provider selection handler. * * @module core_communication/communicationchooser * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors_fields={selector:'[data-communicationchooser-field="selector"]',updateButton:'[data-communicationchooser-field="updateButton"]'};_exports.init=()=>{document.querySelector(Selectors_fields.selector).addEventListener("change",(e=>{const form=e.target.closest("form"),updateButton=form.querySelector(Selectors_fields.updateButton),url=new URL(form.action);form.action=url.toString(),updateButton.click()}))}})); //# sourceMappingURL=providerchooser.min.js.mapamd/build/providerchooser.min.js.map000064400000004036152265255750013536 0ustar00{"version":3,"file":"providerchooser.min.js","sources":["../src/providerchooser.js"],"sourcesContent":["\n// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Communication provider selection handler.\n *\n * @module core_communication/communicationchooser\n * @copyright 2023 Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-communicationchooser-field=\"selector\"]',\n updateButton: '[data-communicationchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the format chooser.\n */\nexport const init = () => {\n document.querySelector(Selectors.fields.selector).addEventListener('change', e => {\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n const url = new URL(form.action);\n\n form.action = url.toString();\n updateButton.click();\n });\n};\n"],"names":["Selectors","selector","updateButton","document","querySelector","addEventListener","e","form","target","closest","url","URL","action","toString","click"],"mappings":";;;;;;;;MAuBMA,iBACM,CACJC,SAAU,+CACVC,aAAc,kEAOF,KAChBC,SAASC,cAAcJ,iBAAiBC,UAAUI,iBAAiB,UAAUC,UACnEC,KAAOD,EAAEE,OAAOC,QAAQ,QACxBP,aAAeK,KAAKH,cAAcJ,iBAAiBE,cACnDQ,IAAM,IAAIC,IAAIJ,KAAKK,QAEzBL,KAAKK,OAASF,IAAIG,WAClBX,aAAaY"}UPGRADING.md000064400000000460152265255750006424 0ustar00# core_communication (subsystem) Upgrade notes ## 4.5 ### Changed - The `\core_communication\helper::get_enrolled_users_for_course()` method now accepts an additional argument that can filter only active enrolments. For more information see [MDL-81951](https://tracker.moodle.org/browse/MDL-81951) tests/helper_test.php000064400000017501152265255750010757 0ustar00. namespace core_communication; use communication_matrix\matrix_test_helper_trait; use core_communication\processor as communication_processor; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); require_once(__DIR__ . '/communication_test_helper_trait.php'); /** * Test communication helper methods. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_communication\helper */ final class helper_test extends \advanced_testcase { use communication_test_helper_trait; use matrix_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); $this->initialise_mock_server(); } /** * Test load_by_group. */ public function test_load_by_group(): void { // As communication is created by default. $course = $this->get_course( extrafields: ['groupmode' => SEPARATEGROUPS], ); $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $context = \context_course::instance(courseid: $course->id); $groupcommunication = helper::load_by_group( groupid: $group->id, context: $context, ); $this->assertInstanceOf( expected: communication_processor::class, actual: $groupcommunication->get_processor(), ); } /** * Test load_by_course. */ public function test_load_by_course(): void { // As communication is created by default. $course = $this->get_course(); $coursecontext = \context_course::instance(courseid: $course->id); $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $this->assertInstanceOf( expected: communication_processor::class, actual: $coursecommunication->get_processor(), ); } /** * Test get_access_to_all_group_cap_users. */ public function test_get_users_has_access_to_all_groups(): void { global $DB; // Set up the data with course, group, user etc. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $coursecontext = \context_course::instance(courseid: $course->id); // Enrol user1 as teacher. $teacherrole = $DB->get_record( table: 'role', conditions: ['shortname' => 'manager'], ); $this->getDataGenerator()->enrol_user( userid: $user1->id, courseid: $course->id, ); role_assign( roleid: $teacherrole->id, userid: $user1->id, contextid: $coursecontext->id, ); // Enrol user2 as student. $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user( userid: $user2->id, courseid: $course->id, ); role_assign( roleid: $studentrole->id, userid: $user2->id, contextid: $coursecontext->id, ); $allgroupaccessusers = helper::get_users_has_access_to_all_groups( userids: [$user1->id, $user2->id], courseid: $course->id, ); $this->assertContains( needle: $user1->id, haystack: $allgroupaccessusers, ); $this->assertNotContains( needle: $user2->id, haystack: $allgroupaccessusers, ); } /** * Test update_communication_room_membership. */ public function test_update_communication_room_membership(): void { global $DB; // Set up the data with course, group, user etc. $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $coursecontext = \context_course::instance(courseid: $course->id); $teacherrole = $DB->get_record( table: 'role', conditions: ['shortname' => 'manager'], ); $this->getDataGenerator()->enrol_user( userid: $user->id, courseid: $course->id, ); role_assign( roleid: $teacherrole->id, userid:$user->id, contextid: $coursecontext->id, ); // Now remove members from room. helper::update_course_communication_room_membership( course: $course, userids: [$user->id], memberaction: 'remove_members_from_room', ); // Now test that there is communication instances for the course and the user removed from that instance. $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); // Check the user is added for course communication instance. $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); $courseusers = reset($courseusers); $this->assertEquals( expected: $user->id, actual: $courseusers, ); // Now add members to room. helper::update_course_communication_room_membership( course: $course, userids: [$user->id], memberaction: 'add_members_to_room', ); $coursecommunication->reload(); // Check the user is added for course communication instance. $courseusers = $coursecommunication->get_processor()->get_instance_userids(); $courseusers = reset($courseusers); $this->assertEquals( expected: $user->id, actual: $courseusers, ); // Now update membership. helper::update_course_communication_room_membership( course: $course, userids: [$user->id], memberaction: 'update_room_membership', ); $coursecommunication->reload(); // Check the user is added for course communication instance. $courseusers = $coursecommunication->get_processor()->get_instance_userids(); $courseusers = reset($courseusers); $this->assertEquals( expected: $user->id, actual: $courseusers, ); // Now try using invalid action. $this->expectException('coding_exception'); $this->expectExceptionMessage('Invalid action provided.'); helper::update_course_communication_room_membership( course: $course, userids: [$user->id], memberaction: 'a_funny_action', ); } /** * Test format_group_room_name. */ public function test_format_group_room_name(): void { $baseroomname = 'Course A'; $groupname = 'Group 1'; $formattedroomname = helper::format_group_room_name($baseroomname, $groupname); // Check the room name is formatted as expected. $this->assertEquals('Group 1 (Course A)', $formattedroomname); } } tests/fixtures/moodle_logo.jpg000064400000225570152265255750012611 0ustar00JFIF``CC&" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?((((((((((((((((((((((((((((((((((((((((((((((((((((f_w?<-__Q/;?Wk5Nlj_qw PeEW'EP+B?J|k_Ǐ9ҟE/1_xZ}xp!sn>,Ӯ_n>,Ӯ_x}&edg?EQ_Q@<e%[~_ag_A%_ӏJ~i AEWI2QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE3?Q/;?Wk( Wĵ'c/WMG@2+@(C !_s>5 OEWOk¿ MfxJ>i^gYL9cşIZzcşIZ<>|2g2s 3ɍͿ(݂(a? SMH?g/3D~ycUOw%?؇4Յ + ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( (?( WĵKNzU煫Z |`o]yxC&? xCTxQE QE!Ǐ9ҟ~'"+Ƶa_?KLG&ֳ|aG9gEWEP_ag_$X~iW_?cIn<W?CQE Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@KNzU煫Zk %'wC|-07.<q?̣Y̜Bcxoh+s`(g/3D~K,? 4+߯1$+uSqO!?aH(?B((((((((((((((((((((((+Ğ+4x@mƱMcNд8-CT/7o?W?&p4Oqi\IJ|Y`?o«?_GrkgYվV+g_!^[[7Ӽ;_ o? ~^?`p[/>xۤp]xžF-=vo#[Q|U3>uD1h'I(09o,ho_ "N/|qXx2"Wb8K$)~&_126_)Iᥲ;۵$z6t^*u$YeQd]]_\^yyw4WwSIqssq3%yYi]Fgw%$r? M?&~g!>h6k3 fRzLe] >4ߴ׈ݧo»w[OůA!. +{UŖN?T0l_M^ >!xKNޱ "~i 64xt%5뢧k\2ͿB}U߈~8u 5ZFC:o1y95Q@Q@Q@9UY* % $W~߳ƯڛvwOɛ]BSrE޷/ihR &d\ݣ|Sÿh!|t-Rӧͯ~|!T,ѯN7,g/Xwi46x[E_`J7[G>:~!MV6C6_X:J$?}Ocoxswtm'Fa (gᦾԯy;((((((((((((((((((((((((((((((((((((((((P_|K_mD(ׯ^xZ%;&}ǟ'?j<3?AQ_Q@H xß)~W+B?J|k_p_}ok3~UO?dG/,NM~BG/,NM~B9_џLompQE~~QE{%ohoA<e%[?N?)9)Q_'QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE|sY߿GE~/BK>#3{KYZbk_O"i0@>Ư"|vOk~:&{ˈ6L7w.jwN:0?m/:?7VI࿆3Z_m7p;ڇ<>-Y#3Yao?ߋ0|oWƗoziER42bVmAaTuK~:c߅~ ~ _ !dC@~!2Rv$Q.PoZoį!u/67]g/Mfi׼YaxE!qƢ EQEQEQEQEW?IFuOi(Dx㗉䱾6yw]I,߉#dޅ,ni|lG?w9`|4ڳsxoَgޛ-`my/H 4&AZ׆u>_\<=G𯄴-ighz&a [XNCocivvCoH@)~şw1x4aw1ְ`>"|Dmce][~'5̏-Ě~c \Šl3̒}EQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQED(ׯ^xZ%_w?<-_k_c5Š(O($W~<?ֿl+? !_s>5 a_b8/7?}*ze2C #|Y]&!k #|Y]&!kM ?/&76֋8 (?v (=x7?M4J ? SMIKqWU?ߧ bV(d(((((((((((((((yjC į3ⷅ~Y`I1,W|/yO6YL 7I2} _PO/$?ozݜ$|Z]#6kW']N͍âޟQ}ο>$k+{ Xƶz^k[;y1mO¿#2Ş#1߅Zgo|FFo>+'|e+5[ԼG=j@j֯sw2uq+U@B(sG)= ϳ;Ԏ?Ά߅~}!?oxORL6~-WEx&|YIk'V:oumkVu07]3ns\QEQEQEQEQEQEQEm3^5MGC4[=WFִ{۝3VuM>; KLl(l-f8Dn>+I_o-6Sg|35I- e៊A~0Ŷ-5ʾWET x+)<[6 |+ƚד4v:'?:4~Ӽ Zӵ/e|5V0# 8 AK_$oi;ofItvmڳj]&].EAssoeo=quus*Aomo 4OE)/~T/^ʺ;:Xľ'(H΃n%r:à+slnH'/૿!f[|[ytYĀk"2cIʃ\- GVb'?\<G3JtOk.` ~>gIĽ nc>ۿײ|oj=^=#7/8%/kd ?QҟM3i9VwE%WV^GIeM]oS_ g]n*=וFx-՘(&-Ǐ>Fr:t!ҵipz>%$$@侊 .pY oA5ZYS꽿? 5gBoPɸ2>_U3\$G>K5b1{mo#Ai2 K}cFX5=.fX.44X}Wٖ_UfY~.`1qxLM'tPPJ5`kkt,.2n#.080TtX~:zZiviQ]QEQEQEQ^?N_O[s@~+Քd)^yZ5u9t(H r_#Oo*ŏ=ޗHe9x>2<>:2IMoA!oo|6g3GpIf9SUiardԡjTD~ׁ$xr2Y˕u9Ef87 TjlM i)_ i0VG-B{{t8k@e<ϊ,z6[1S~67ּkxVuҫRO;C8 ;!UQUTb8ؚ)pwdV0g،VozUX|L NS }xk N^05YJx\0ZOiӽy x ZЅ -dž>xYU$#뚎=qc?T9oசۦ~_Cb; m'7E~CWx猜Oeox{cF++bl^}?\<* W&;xʞ3vAyYh~y[ÌOh$tg2ʺE>d`;d@Ϩ+ҏjR\wVOXWx^$>|~d4vW/^./iSvt3(( &Ĥ@8|GOqzlYKMcE6?鞐䁜g寪 ^͟<5w‹}9ULԤ ?LF!rgO)WK,pwp^Kg1o[嵕dC NY.aļ;i%OC4E0KQ.|IȪȌWFRUVRAH4J~_M |='t).<57͗sU[(ӥwJ|l0Q!R,O> Ruyn8_;W\Q\izI+b"!Pp^+Cp6gY YRVJΰ: r`r4"1q Ď[óq[IJn9]9Sۜ\EJK-fڂJ-xG^gH>.MWEB!ws !.m'XW"qXZq8lE8VԅjԊ:jӔԧ8(Ne[LWaJت؜=IѯRjӓJUTjS 'q$Ԓh(((((H/\x4 hVS{܈mOA%}"4{B.>/VӕlN/^ yկ(RN YT8+Vфb,bƎ WV)PF3VޑN6vյwZZ\D/$JQUG$`T~J8_$uS>ki!mֵ}1u`.(˟-]{?'UoLc!k<3p1ǟLofl aqqRr̊3+4ѭ_gj`NStVixĴxdgrRUCxs6*aSM/#?YFt 7Uy<3mZ̤{wjY[wğUZ{I|ĎA,&1Jƥ~Xp-Ǟ4у0PQr=i6gRݲ)at>Y΄a{VQ#eceOz /4G_I?óV1cNJ ,ӫŘl {px~f?R; F b[`89לis,f5^-3O,} /j^Xcn['9fH`''Y55M Uu2nt]F圳[9#kѮ|R|eOj@կ4 ;x%[kF+ÊY qejc8w1PZR*f8|MK˸mJ ?⟡C^J9]]'*x>"a<5Iܾ]_ NT4q*/M~5rzH1/9 t:b<;88,]p _c5m!7Դ-eǙ8Oat1cxu㇇>'o<<Χfю]Q4u'Kq֭\I5*EJux#LMBeRaTr=*!W*|fx|jΜ֧QE(QEQEQEQEQ\G$gO/lnDos0RNI}_:iWLp0$Ѿx:6K¾5N|J \FȿXԋT]hCE_qgC8 ΟT*/<Z*8 8BiK™'Og1M`r\,S1rQVjS>jK$~ϳ*+;TPYUUQfcI~?πX|SWvu@ftMN&Y- 3hO_e!|G6i3:1m;ñ>ҖCޖVUPfjWT|MRM,)_ers }i:uxύ:/kc=9<5SuzR*)챥'h I1[Aq=\TUjxS O=T'Q5ڿ*+O/:s`,6GZpxR5ߝ?U}  V6IYta9y˰Dzi`6?tq<-HW7ol׮xgPo^H`6/$eyPe^@?ۗ}4XTRl0Uqj0Jtj#8KYeul%G4bi)tMy++Roxg^SS znh7nt˛A`pNm_|3oZռ?[ڦiZd{c419IWa_}|vy-m>,j2,^ eT ?\K՚M~ZC eۂ\'գn8bsq2<"q8uC a/P*i~B+)VO`8Rrʊ39%,>!帚O_+]E|?DY^l?̣Y̜Bcxoh+s`(g/3D~K,? 4+߯1$+uSqO!?aH(?B(+~(fccD 'n;{Z}[U!Gѭ}WTrEa ᢏ.8 /pX5a2ܿ Mc8L&ԯNPM9]{vevac0vWY.0ձxEG:zVMvMuIQY܅DEHI~&|c2]i_j tyȼgO6LږO~iGܲ/)\3j/e؜iB=t؀1L rl' 3.6n>l%玎xU1Ul8iy.W/ xTV 2u+5/Xa{ʖ/4biЩB/bQ:>ibOZ[f$`e>M_R[Ts_TٛFgM,|AiRBKV 8$=>'=:!Mp?CQxKzIı5i :xOa녛Úx>uϨD?#<;Bw 4iլ/$>$ =>Byw,ԫ^bܞy~;?/]9qDPqfskJ>"'8bBxCdCk[KЮ oǛk ɪ: f&4iԴ=JVwya=ŻAd<z5_}I5xc*WPhd!GOH!n0?pӏTO/'̨SfSzXl|z8zktұg}v*T.329SLmS9Mj \ⷄާ7E=__<ċ-/⮃H为H|=aPatS^Жah7wN$,ׯ?8m$Ύ (ΞYV[UZƣ|Rc15b:4%~7zuJ;|I/^jQk~&dXuǡ趷^:Li_~!C/Zxլ-|GmUz1J %>c/oLCf x]𿊼5Fvږ}eg֗v-y?2((((((+Yh 5u1V4~ѦaMcQ|ɹ4{ i/(|rˉxO/2<DJ |V&C zԅ8')#~θ>xoU1k B WׯR40jJuXB9ԛQ>p~Rk_t4-h>j ]dkh -t\\͢'rƿJuV? g?U2?ò|˝G uAJ4]'z>Ot]LҴ(66PF,pĊ,ē_W!3Jy>a_(p9VNJ_OhUَJ4nS|:8IaK *-𺗆mOͳӍ§֥U2KTa⹣ ap®*5Bmk^j {Mk}=^5mk&MA.I6V'5Eoqq㸛16nɹG )I`(9J8\Iƍ7Z^ZC8/<58 xv^'qQXKjJiЧJ (T((((OcV>,/O N}ڞrl,%4SU?\*yd_(1ͦ=4 |LʴuXύ8m#ET5pq=vFWFdt`J+ e A~Oc.k NI/%Z2͸kVo+iN}f*9 \a a?%Wg*;CP(\KMf}M]8T;n_(r p>+`:]oze[s?KͥSHs?Xt$˭=uGJJ|pwSN' Wc2}(u8J'tQM TץR_7q>?0Uis<g䰹S P*kB)(U(>((:a{귶n\_jXZD] (4qNr!sj1b&ҌbmIT#:!Ns!JrJ1RnRi$mkF.0*я%i,N)OAJjftW7Ct[^_ŋ[B!uZ#ɆD]Zx85K1pY| ǜ7?`c!J_Ԧ?p ˊ1`J+Ź/r .zP|]X*EWEPEPEPEP]|[k'~xk\k[GryLQ%,\E,. E|-z8-jlN+PP:5V%:uhզRJsJP%FIJ-4z1Tj4ib0s_^+QF\*RJ:A(v?x?e(yt|zO׊1Y^>cψ'88uF 곶/c٠])?C+U 9pE~w4I<:|_{$x5I~qH<<d`@|Wם|U#ZTOqQpI=#8MνS,Oi V)xS 1*rۃh)N:؞๧<ބp{訠a+{ exeA$SC,eHFWDfGF K_JiM4kT4֍5?Ϧm4MiiOtQE1Q@|Y~ |v>6LEʹ,e/bK*)mϿnh$GQ&_ځO۲J wY-^˛&{溻[dwigyfiIevgv,IO~\%/:<|󈒧U,S)UƦ-*uʪm}>q> |BZUd|ʱnjYa|g6EjѠMDi,+J~ VzG$lUԆWFRYX A~_|W6{O5q3WMgY"s4]Ӈ73 Pi?m|5oS|XȪey4s,Ӯ_n>,Ӯ_x}&edg?EQ_Q@<e%[~_ag_A%_ӏJ~i AEWI2ב"GWXY"" ,*,@SRԴOյk]7K-./Fxll"ygdyfWXY݂kng ֧U$h-_ F)#,'˸?i֔,v_x xEjgsxS2 5HGb)Ӓh9A1a*t#(œ+PUWo/%e7JwiXQ[X3X<9ƥyFS<>|Mj_)F u/|:I Qr{qO[K[q]Fb|? E$?0> k(uoxP}Zy5Ө,RV+oivxUAQ_俉0q|IN9u*[)TewQX^wO+qʶ.UXQPO8+¼8Na,ƭ(1 lis)+9{\W")C$RtYδ(.?P ( ( ( ( ;.fY Đ2K H+e`qԢeEӺiSOT֩II8)FIQj4iz4~~RZm;?(1ZY|B'FѼE Je.Ƈ0,ےp/4m7>l5 XKմ,/&xFp]YG_h'f~-'Q{|(AS/>$_x[uTaKcžEтl/pxFmõ%O Zx#N9O~gNjg xj_;}!x\ o PYݓS(噤g'e(akʦ"Es~}>5 =CJ,76eHe`Aq%ݤ͝SZE2F%|E ^+ Z' OP 1+ATZZnTҫNQ:8IJ-j(WWQԡק:5W7NԪ(ΕZU#(T8p\dMQ[Q@Q@Q@[-I%tNs|s=Oq]/M]+ ZԱپkvkS?? kq᝻e#?_(oWt+ ť&>((*œjMۊ1/Ι[Im ~W?'?m"oZhm6$$t6 /?V(((((|QK4 o4:EQ#I$H8AgwbAfbW&+OJ1O|$ֶ:֝Oiw25|7_AsD"DY 8㎸kþ/c-4ӕlV7R3  .H^4εzpՇpGq/y[ٍx8\v"~ CCvIPBj)RʿOCj%χi7qg,vI&,ueʍTRyY_GKwt#HEn nYo7kݝ6xg>nxcº=,'LKk;H'jFGgy/=Iqq$$_䗍:q9UHxO.9? J9hb:ܣҜEyqOx NWфs .Vx;+<&YJRio1naQ_Q@Q@C~-isXN|Oٴ{E*$DAkw ~yZ{7/9Yn"km]'eJfۄ7;3Tg ['fXGFS>-<[?*NQUiG ĩ]kG.êԓN.<4{\#B?Ho>:iF|?;<K=ϟ{W w@b{ ~ J̶fqk;,{M%ډͪR\%w˃3 v.o)>k UNf8\'ݻ.lfopWS۱Et(𧉼oOpFas_[v9 3/ CqY`HXzժ4j*NzΕj5iƥ*(ΝHI8dMЯGF' Z#^+PB*ѭJTեV*SZ' 8-8QEQEz+: Ε蚅Vnc,'KKAe,ɥh'[ iyc?$y'Oz^_/)[a,nZXLM5S _42,僩sN+Iz|J*65p\EJ<SWxp׌g22'lP+?0((2;R:OPO]Efd$Q=6#h d'_Lڋ_9/o+ľ-Y' մv-)Hhz1gE bThB.u*aF>4B8NZsQViYgx>!pJ4b3Z T0U''a)IgUKLԴk/WﴭNS杩ZOc}i27VQqrƎ22*~_8NN% 8N.3eFI82MJ-&ipԧ(Μp' %(2qedM4ӰQE%Q@-..nn,l!K{Kik{kY% %hdDWFVPGy xˇ 6Z»T^+k+hPC ,VZY7u|2;/^=w:W/{1_iwȌm;T{ C+=ā9HgP̡:؎̧G Lq`ynj 2y_?uԋHQUkF[8Q[ ؾgr',z9gf\GsQ^o~29ď ɝ/lwMj,V5^}Jaú bvg~5`<K8\>;.z8&*+җڧVRdexfY~++ǒ:cjR ӚҋՅW|wà [⏉:ɗF},ˮx0h4$eZXFӢh+Y+<4dn?814YnWͪx|&׭;&څ8JVrbOL,Y(1i08J*1]XСFi':o&I?x= #<<n<_oGS|C-F&k aiFyWyewIIgwv%ىff$$I4_x-$I\' e쳝ZHE< FaY99TxxM᰸hgŒ^dhgxq.m05NrJk/sj*4"pXV&s(l((=]>mxoEռA޿gu櫩]?-laf#+JTשNu+V8ӥJ%RJj08A9NrJ1ri$jӡNj!F(JZgtӂrNmFSrRm+/n1y{Z1s-0xe8-\WtکF+袊낊((o'_6{~5C&}"Y0iCۉO:}yu"yl6Ua,K$3C"K;G,R9Idu! ~E/o}ZƋ⭃VE|T(͎6ګD_+xx[V̲,p;>jܳ lFIRr|+bܥ,>HeOo/xc幎*,g.:Zt3:X|zJ1c:< j(pljpg%֗- +l%h5^۾Dvȱ:2^w}溚u]SOKZ"T[˷-亼 RM+ #_yvŢ N.bSM>i%c2Mx,t7%_>!S)%Zu2-8AfY/Ii֧C{bhQO ^wѫ(xƟZ0 p MS̱U''.tj*U14&xnj:ޥk:֧j:%qwwuq)i&Gi];3MP g9ԜTT)Ns9ɹJs)6&m(B8F:q)ӄT!A(Rc)$I$((((BiA?>ԼOIRN.jڍEaX!XQg6qI!Ծ6x$Y۾7y^%լ.leXA}*PM™|F_Ncg &5ؗkSu0Tԣ)PQi^gNJ8|¥5RMhKpSҩg8apqcY4~&Q_Wa_OPvt]JE}ψ{SLëjݲR xa+f F!`T4#-֗%FY %ré qS:IKeyM)yJQ$̵?+}7|=~J+5V)jfբ^-~Vݿ+'{{Lj]^ox`7X:n`MBIʪW'Ǐ_|4uˋ#Lj>Bgiŵ\k"}M΅L~{vd5tR?eCیcW0ahr:u%7~Z#ΧΪs1sJWq¯k[G/BIӌ1Q_Q@Q@Q@H xß)~W+B?J|k_p_}ok3~UO?dG/,NM~BG/,NM~B9_џLompQE~~QE{%ohoA<e%[?N?)9)Ww?hKm eݤw6Ҕ6Ƴ!-u&vѴyZ̗:J8(xp]¼=AS`bqSV+3Ԍ~V*qMBiah(aQ(>((LKǿ|ec,wܴ^#\jGOmL$W {ig'Vϋ,,'AEiG?1w×>ub a5DLM5d67oyR8 [x3U!V<N]lIIåFౘ*Iu (BqZg^Ùc<}JNmJTrc_ZTxL6)Ҫ5k|uts5S:3E@ XBLb3_x¯0f:v>iJjUPUTaUT`02l<yYc9RS<(]e -L4C],.>~ qy Urn &gOUP# 𪝦Dm,l -x??oz%>f鶺rDЭn104W0>$XC+/_ R|[QrGi`\Iӕ\*'ɍ/uFz5pJ?Wciz?3TyQtQ_6M*Qo5 kuPc\=( |3\j&4M U]F`,K-x nf'hxdUZ9bUudu 1 uqnX<ce9m)VN^h:zé/_|M~̖%R?.Ea\Dh')ʟ7,,E)9*}[YS4QE~~}QE-\]~ |#}i'uC{5$vb8G'uJ+pS](q ,VN4piad6iROꘪUE(((fL~(9x)ھ_J&q:Sd\z@s^-:ʷ B3Ɨ-ף%n#~:^6q( ( >&xU|u+'| 6A6xE6Su/t?ێOt,2(7~1 p)Tx lI#)[,%v8oM*+d]Wt 1q]j3yaZ-{?+ğKJiAx-7O2VѠ5Ié O` g}ٴx&\>#7C%1+|ƿhFx?=s[7=_W+?̼o*+|dx΂_et-YgǴW.l5ȓG+}L㺷t7D P]G .GHvS^_<4R|J _d1VoDKM |JMS՛v™Xdd ?kw'Woᦇї_Լawkoig]q-WZΗ1K43 Cg~_2!I<9º$ȸ&[ m_Rb =$F"p$ṣ>q8L*ź +C^\-wB6Ս|w/"Ox<-HG\hY/ 2I>ũM^voeYɳ3p8QP(N٧iBqqJcRRr3s,qc9niW'T,F7xFJTSԌQ_%?|[n~^Yb]BDK;VgUgl&gUC:$iGc_l6clܽNV^ɴ$/+g!S_o<4JU`0ejŒ1i;%Roj(3aJ1ZKW>pT:xfx\ލ%No՞w+R 6&e)4(0(+cyះ~Z(m =|iU7:km)9$pu;[o/,$[|>Ι;46%p K$Sr9c ^X_/CRqSiRQҞ30tTVE0%FE(7O_>x{\[SahU֝lV< R Ֆ/MJ2]FQW?>mXE6<0?Yso/-|K$ieԴDKL@ޗq3Zq ĿL? rυgfWϲ :9Z f.., *'[ba}!/&iN kU)2#!c+dTԔgXZ51)ae62Q{\:| =J1\F%+?(BMY|O>ɶd5+y VɊnm&QL둚+ecb-U{akBwJq[1c2]5W 0XOj|UЭMӧ9El$D6WEWGR]VVe 8 Tw70Yww4Vֶqsq;Po4M4BGQ<*"b&~&129moᧁI؜q}21<+ | fcrmψWm' )jV󔨆Ԗȏge81R`pR泮bHKok:8ZOgR{C6#ʯ#jXb aiB**SAƟx噴G:/et%b~`L9!u=NiT\9893ڼ9qfX18ӯU6)Mƕ4iQ^CPXl'ar xlP\`V\*9Trl+=@(_ ?Ļ/ȯ^,Wd2M.`OVC֏ەYl n4is`)e?? \ϨZFM\Ϛun)(d #s$^񭵳C ieiWJbZEr]iIXQD}_~ ׼]2-[þ"{;web֚r^X^F!8&M?YQfH`x;W1x7N\I!ͥZ1漦,e6GPcB/d|}W&,0׾?|TÍ Z} ]c#|3`Ѿȧ2ERUi#Nr0sL%p1k0JJ1UXѣN7j14F5,0slU<Y~.j *ժ\"9Єe9$M|_KRMqZ-yrfM5́th%g[-:I7w 48(5cx[0 ֵݤ"򭕫3GckkX'1:CxsÖim gben[s5,O[_/pDŽF\׍1#,ۈRZkT Տ6.ۧN48{lc1O:ŬGB^X᩺S5Єť|~o袊"?B(8M㗅OutxSZ|+㈋<1ɨJm, y) u(ohk3˅죈Άef\S^_mTYERo )M8ͧqCqFEoEWsc)qTIԦ|**zhS~C+C+) dA#9/5ROw5;ۓsV Z Iy.o;6g}c|7q.\سڔ_Tj߽rZ6N5i-&?'1)y f+"q^"J.1<"oM;aevJ$M3C sO|Ӿhf {ֺf#[9(w>ޟ&-Eu}iG~ M'J /5\U~'!58Ş$³pO'*qjVGv2IsRJ7Y}x"xOxyT<'jgVѨ`ŁΛvZ5dsQEIQEW?׌?h6 6]OKa˱dP.WŖ9/3ocm}{ifZ~ZOj֚nclK㵳s$7 H9iTrkg#uOBN>;bS-+ fizJmk։nOORogԸC gVNxՔFҧWU"6CB:xg/?𛄡T!uyH`J0s+ƥ<j҆D_^g zxog࿇4zuB9u-Fa׈5%u@GcmigVQ_[6 e8,6](C P ThQ#-[rwmeg^iq9ca-.Hfa#R֧RjpF'J*Ju)ԋԄ8IQm4.ZjSFU8ԥV BJPNqjP$((&h~mO6~[^ |Ǹ׼>Z[WBFɸV2\j^Vml}Ed-gW]=%͵I4LO G$r+#2$Wy pxN s{?lJ׆ńy6pi$m(]Iip~LJ*WÑP*؊ +לiW-2)?_eJHWԡS/{=%s8znu2m 0ZcBE^qQEAEPEP+B?J|k_Ǐ9ҟE/1_xZ}xp!sn>,Ӯ_n>,Ӯ_x}&edg?EQ_Q@<e%[~_ag_A%_ӏJ~i E[=2R."n/qv73HxH7G<*)'_P_.ajbbt+18VQ}~>y}8k )JOv\}oUmL{KLV7zDWUO',Ό*jg(*X|7.ZlV+җ٩F!Rˎas,3.Ч3 Z<Ա\U)PN)ΜXɣG4_׍9qZ&钜 >˨GrΪXEul]'}Rm|% CXNUyAq&GG?Ï/?4ַhoZ[, ߁~k;õdǖcM; M'eEٸCG XО-bÏj rZOEPEP^SVwi)*4~H jOB>5$v^kZѬ6iki %.%t̥4æhF((?Z ZYL`ZOSͩ<I8ϯ&ZzYkc2} _jxO8J*1 eؖ1xjg&//nR8dޯরtWiPR](B(?z@xsSnkJ(%>NIQ_(QEWbdݪĚ0^_JMb:lO;BWI_w0d!FuF?5c~$STlѺ'[f 5 _CT7`I6_c ' Ɉ_x͆ĺUb?RjeeX֡NIi350-xkVq0ͭ?w:Tk/m̝Q_Q@Q@տ+2w9#džd 5_VO=_`x[P]HaFmk[/ E1M_߲'5)}KW|RID|7hȉt_ 6瞧BR0ґ_^S*3pf]NSl7umJxl6Ko}UDgviIn2*SN3ҋj'$Of÷mY|qEW!EPS\^A-[Z$ȱC Q,#F 3P 5}w /Kgq+.T𝬚!^9|Ao"Rfa¹#kqsr*R1pQUYd 6M~ \Z96eՅ]XJF/JsRMKo?c/ׂ[Դ6;]DhxTU׮Qxܳ&ecv {We8 )2,閁C(5IbLw~;7K_A1YS|YX;Fe̪6]*vQ*(ࢊ(K \6d-." e>_.ʰm~#Jϧk||מ %Yk^ ^v?_D yGv_Gr\'*_ PӗĽI?CO7k1psdŬSߑIoFm~57OOٲ!A۪6N`'$d쬧`RFoď&_BY.%@#7Z6a##JԖ3M\K|yύY·.G݊3|DRͱ0W(vH%dPɼqj 3γFקa'K3][ (`(7 ZZ'+k eY3DM7kjij2K?YOc׾#4oij:hR+TO[d9eן/*\[ :W~GGm84i g~&܇ noB7pNO骼Z5(i+=(3 +㷋YyG{h9`E&ttvU_k|:@Eg (<+x<|7j;4}>P w:+=cN ]2j6$[Q~> o _%s| ,,<,ɜx;Ƴt 症)w  =%+ݺpdB(RkHYI^ NQҧ?S?flv5pghNYr;jS;ZcVu9Lڄ9RSNdiOmEƉKV&dvWVIֵx\_0 x58< ^C񆽭F4IS6L0hY}+U}x>=2J&.*jrUO.ij-۝TRsqoMj᪱lۍ.PljLƣI:pn^0+ ( ( o_ 4 cXH|AL]쩸 jеxjq[2U->hq"\9[r~Qa˳L'/a+a1gQZJ-Uifem'2fv+BSN2W4ڮj[Ig^i(Ķ}ĖHAq8y ~՟4X8/?f-k?]4ouOOmJi2P;$򆾻H'>-ҵm/vɜ)ZMk kY8"Wo9sIԊ\-&\h $C"xIY*r|5Xbo9)wqXEWGEPk:ğ3:VV%? UE-A#^>/h^ЭVE摧hzMcmYcg K` SgW~xNO7mg5M.I$5eC8Xbet r-xi6?xSĹg(qӧehT8+akZJXLx^yXᚹ yf#S"BueO a^p8<8'B*+Ño=]75a&/i=ſ胩 ok9u_ȷO轧胩 ok9u_ȷO轧胩 ok9u_ȷO轧胩  '/o{a.:ddx R9I'ҭ WO殿dMWăi89 Sqo|w/yK8ɞ_*yvmXx M_L֩bGڨS˩Ƣ9'uy᳥d.cf 1åRUax_[s*:|QENQ@f~;#K?ueݵf_c$/mQ[#tGGկt_J4LX:+YF9xcqEb>?d?x\Q$؋7~ x_JnQ 9QEXQEQE3?ִaW~~3?ִaWɠ܇P(GwH=EWGEP'I4J[?~3ؽIW_ӃNG zB?6Qe'T9QE\fQ@R/I[> ؿMWNOB}7?oPQEQEo3MՕJ-ɠZ{_&7eb~'_ poYqE0QEQEY~_l_ mHj//ٷ ,AX;Txb_¿ M Df>4?,/{ F'9Mi(+T˰Uds|~b*[՟EQ8Wis1bV|s^iEWAQEУ7ČFkFgox;'A.~"j|xc0X|9 뱮U'cs\R0<=hU[4WcS wUڔYV{c_Jў'7 o1Zt|ipmI.&ERI3(_8M],uU/ ^S-wDnw'a5׉|ay#Mnֿ4+G |[ut%qusxZudsm9%4 )/$BB ї ]W]JoͅQ_Q@J~ǚ~# iҠ!֙# $BAx+o~23u<7bKW|Nor*e,mf۷SnO~ b`sUZw((((hieZHk tg |aG9gEWEP_ag_$X~iW_?cIn<W?C‘~ԛXTfnP)|W4*?H"*^G]g ;>-f]_ g$ _֞'?1._8̫MkNM&|=F8l#V -P (s ( ( ( ( ( ( ( ( ( b_ed{? ?',:hc9mG`?̡)WcRa-h?n9&M+]=2>8fre_x;0%|N 314Cqw驖GvQ* \anm.jT^QEnQEQEvë_Ũg׼[ʯoq=sž%Ҡ5~4d\ _Qf;OJ𴉟I#Z2;#kr/8u_:U\뿳O-fK|Ul~J&+o E _>ۋ4{ڷwFkoH=((($$#_Ok5u|kIPf.(gE{/%7(ՆF((kZG~0+??kZG~0+xmdnCO#;$cuP+#ࢊ(ۓMG?w%-Hܟj?^ӿ$+n'#=?ӿ~((.?B(kMC-H~_j_Կ&+C?''?!?m_|7s ( ( &ku}_C?~-?Y^|[Ug2w1?BNp87Z,dh O((d/6?Ot/ktҿh D~!yc_[7H}kK=пAÏڻ>YK}^Ece|7jR/;u)ocsBy|xi WY:o\9G^#K_rI)cdә]RKs'+oʤQE QER|>\ 5?i&ydе岑E'_1'DWS,|igxB]fz<81[*JdsY^5|E˪Ta lrzF朞m'5\ru+.f4^SKRr?r+YIh(mH(((+@ui?N$|HF^=QEGB8(˧pP85M|#Kj X'j71{Enʃ\=5V]MoPͿ5V^yy\ws'$i$5C(.ӯ̳l4KktO0Fٺ_?KVyq 6]䔪4R;_ц_uE^gQErQEWgwOi~7|Bv_ lp3ز2c[޿, [o~/3iqhOT=.vї/eR[5e\KuiS8?I|w x#%/rP8SϬZh"n޹~||@8lچa, ,QڬIgW0eWS_/x+tF=yZ{4Š(O(be4/ڟgzhC'I54@;Sq7ė^ |_d 4?vsv7bePnzޗ:iΝe).,u h'\gh^O +% T:U9_*mʖmFSJɣ߅ !]>5M/X1uq~?ǧ/I |4M4ѫ?'+0(C !_s>5 OEWOk¿ MfxJ>i^gYL9cşIZzcşIZ<>|2g2s 3ɍͿ(݂(a? SMH?g/3D~ycUOw%?؇4Յ# b__zkN.1V1ט?/ƭ=1xF0|Wceq AL1̲\jeKg,&i5ѧM8;:ocY:ZGq5Q0+O ( +Xѿj=S:5|; S@]:q0XҍӦNPWcWo?|[ p ̲\|Pb{INx*z"Z0sXsSNRo~𫁳g GspVE-fPFgStO|Vĺ+u?Z_G:/Eᾴ澾%Go#hM¿+*qO9ztW:/Eᾴ澏t^ET}i}*>;GnoK5e_)7oC.E]uO7֟o?%Go#hM¿s&,5?~%_?z/]S 5ĨDmIW_ս.xEFսĺ+u?Z_G:/Eᾴ澏荣7 貯E~â_.Ok?z7mO 64T|w2$+Ϡ+*qW9O+vcG:/FήƏ茣7 ?j ʷ#\US2}eAng|'?G^yѿjomeOْ_|/iCחzZ2iwٖMTL[ |,Dn?}[2#䴲 ;q` <~8Txєa~i6 Ex_a L96Y&}R '~YEƒ*є)I+? ((;2|-~ x>/|=J}5U/-{) 6I#V'K|šoOÿB"D~7{D.Ka|N][u{[b?{ SWIK~ӿo5w-( x+W eo[CXQE QEWſP4_xVW* &ku~{?u|3e#EWbEPEPe!ɱ{_V/>+8a+kjD_^dݚO%1#̋Nm2!ɱ{^GZ?fV6}klDhLiOeXXA{ʘ!ҭ󂡇 ,8ǚnG`1ؘSukjQ)׊_%S\v&̸߉{)>X*9gROHRbjJ^aFNM$jh#O(+WBֵ/ z7{ WOִֺw H=VUtԣRj3* Sԧ%(NN3diӺ":uԣVJ'N9J)ԋ8Q[M6F{ᧃ>$hla^i?tDoi2>C{y[We%H'S N[xė|km-oxcHIXlQpGm-#/\Lux-^ >xZ_)S˸<&wd/\#KգZ0|hof?R_jf<7/%ԜU_Lj nu}ZREW'EPEƞu={/<9:mKTb$0GsV6qx-`GdS'a]zX\&\N'^iPBUkWVn0J8u*MrI6mb1L% V*,6 Bb+4УJ SVIF:pNSbiSկ 6'>*^$qD%ŽpIh.FK9uF&E粽ۮ?|EYY-m$]?z3&t)YNƘ,2Lju[4cOsǯ^#fuG2^Ԣp3㊔%g 8جqJPӡRxì"Fۘ:)Iftn G RV"kQ_Q@I?MfSyS+%<> >k;| _׃-xŞn YVxL Tװm10y(9 | 3FMO?sNBѴ}> HI[IpLoG֤dɰeg5Z?<6VTf^O/C|1j3z.w˲jN5cacTԲ?rGS?g[xK|<񎅯K"I~,k:U}ճ"εa<a/|[?xgXf]dQl.#H3]u/ kzLJug4 WPu[9$Դoku28/I'd]jdU\dI|.[BejQe<Â8jOÙvy%(qnT>ue TTsUQ_QEWKЗ %.O %m89y ڪW?}?#З+sQ|?65LJm.\ZK$ecYꌲ J1u.Q+MFU '*!4o]RUx% ^#V/Ms\F\OSQҺhXCk͍jV2wťݴJ ȒF:+Jzu)֧ 5)ԧ%:u)*P'8N-J2qZi:NXNZs:FPN8I)BpqdM4 ((.9oRD/.[O ThS@XLXB+ZmR>=NI<$Aj>6x;P_ ~\owo&Ox:l˟O2:z ZG^)a|,2x6_*|/23*eռ6QJkͯfG SJ~+.=˲.jxe*,'6Pn,N)BTuus}ss{y47wsuus3\\\H<1,+;Yrj (nRܥ)6ܛwmmճQJ1J1QbI-KDQHaEP[?~,|JE?tr%Z:民8{X-@2 W~Zkn',nOI/`#$2c_" _g{cmxO[FM |KhJĖ63&+Rri~Ms:U`թKVl.&M{/\]8:XLg ,Jm *Ylqy}'4#6VK/7R 5 [*M։\6g\pU s5)^o(!e|`?< }Ιnd}/8{oqZ|{ 34JFt_JSүmu-:W6W3ugwo"G4N9IXr+h-'Ox)-f<Ѣ*n%KښS3K`ҽޟvBp03F'9ԒֲTi\p88ޥJ8EHL 1L/?,Jx Ҏ'ISaru{+X )VNNRQҴQExaEPEPT5]SOKZ"T_˷-亼CCooJˆǵ_({ewk? uD0⎿a0hWҬ+.#Y5 a_b8/7?}*ze2C #|Y]&!k #|Y]&!kM ?/&76֋8 (?v (=x7?M4J ? SMIKqWU?ߧ bV ;xoQDV^ͷ]k]Ѽ" .cC:`4k[s66_F+D#@ݵ/_An|jOz>+SeJc| eQfTc+1rZN}89tE*?T%E~sf^|H|)`??-}c3㧄?h/$mu&.mMyyemgsu%Vچ&_Eg!U-x{82YG q~Gfn\Bebk ֳ:|Ri'(I?|0 /g&Y԰熡{**Sj*rI)6e+Z-g+O ( ( <巍,~nNFZ/gL2Dd׵ IR'@Q@Q@y'!|G;Y[$$#_Ok5qG+W eo[CXQE QEWſP4_xVW* &ku~{?u|3e#EWbEPEPe!ɱ{_B_YZjVWvo冡kqe{i: ๷6^) Tk M D:.g\ J$FY6 J2iMYܥ;)RC(-Qsc%fM4Lh~1Nt?P{ ^LKš5փx%?,ҥ Be}oеxmEQ?ٶ_ b[s6wb-~!sfXcTe֓]Z.H:C#s< LJZs~YR15e'Sk9`jſi*TF0R7ކx=tggx_&E21s^[]^ByC}>i?-ʵ+fc(;"=+9_S5WRrQ34rHa]ۚT8v6Q[G WEn|Bo^i,&%YMIyj3o#O๼ cοexc48<9BimgUׯ~ (r#wv[iQK!\G}iCߋl:>vAe>!Zdm--|-_~7}'kug}nFoFR3:s(YՄ.lN/gNǂF,,U$ ]& QT 2drygxW᷇@@U B)jα܀Xu &#gghZk|yHye,ۈSjQMiƤ}ڔ4iZ88VIǍ#C,麋(pT\ ,4 kN:LFe^[:<4¿) <z➑fS?a3_HD>6Ҡ Z ug0Elƿ#ඍ?+{iPQh HQH%ƕyj8aφ/l <pIپ9pr3-\N_)JJ9bi'`GO29q8)Kq)s#w9ؚX|1]JTn]-x#ĺ":zgkQRZU *S Fp[%(aEVFEPYqZZKytesޫ;K] }\Lqhfx&i-%K O𧋼1OG/Ğ!XjEWsry11\B߻c^ S_Qo5|-4Ώ{gk2\ڙAЯ",O\x'μ:ax_px&L* YG[aN3%Xjh aR'ƯM&;'pbkқC3c?{Q$WOR*_m?+.my>.h£^Z2$xŧ<}xXcװ'S >?է!綁˾~fx֩rʮ*Sf9} E-)ar^rWT1Uӳjf?E0F̩8g Ua̰E|-'彿`*EީYewڎs ^k x"@^Y#Af`kſV,^A rbG5/izBoLcg9 |Dƥ (xF𽳣n7j k95c%\+O4˖K Xm|FV ]z\+T5G2`p-(aD(@j#DEUTUEIY$$[$nvޭow;ट~jhn|Wie,Q#[EDTi,QKqwY̧?ͅgΉ*aԬ7~T?gpڟhwqy[HH˶U;~Ն',O*JT+S|Sc^lIY8œety/am)a/ LBzU1 I%hԴ󜮕\j/Z4:ՕAE߁EP_@ߴ7gv2CHXbY/h~nڝ!t+3ׇ3|=1+S`qyrա^vip\֣R3^Q ҩ8K\"Ts4TcXRBWMNjQNPB!ZV/<7}ŪWjZH]QqK Jذ#V3K)cW^{Rq_Aۍ+YG/ kJYjV[[iYmt:~|"x#VӾ4x?S#~ 8}D.!-x3(mU)ar[눭,`F{8-ZIf8K;_?)O[x =o⦸閗No5r+q]+Dbgߵ_i,U&Ki_ yC]Ot^בGY[`4}vSas3pXo\V'0q撣B)apќ㇡MN|Q_Q@Q@H xß)~W+B?J|k_p_}ok3~UO?dG/,NM~BG/,NM~B9_џLompQE~~QE{%ohoA<e%[?N?)9)^( ׭mRukVӵk9o# A%k*+"ZC'? o kW:ri䋍'W"WԭWë}Wp;6x<ͨ9eymu K?nf폴/&TQk>>UNyNx#8ʴF=Py(ԩ9%&0sҌ|Oo[slBհtUДUjmW%K+?`(ESKw|BE4;\T 3Qt0I)sm,[ETW^L.Wf Mf *X.+R5hWV JiTg 'u$ɏL/-pq ~Ӎ\>' *UV4:uiPZMFxD(Y6i,RD9eLdM'W%Ymlm>"e~kTOnC^;#O2Դ۝^E,,<0$]"@ |lV8POA5kLii-=vWؿ?_J<!n)?(a2m#~y9 7< [F$V5p5Jk}f)GkN"i`XM98bZ?̏4x0^d[,*19JS楈J]7b'W)ECEK (C(((((+>?d?zy'!|G;Y ?/f Mÿ=?a?( ( (?&g֑c? &g֑c? 4HNXT(H(CQb&_uR?'I4J[po"0OG&ߊ?" (Т(CP3b6_}R/I!_O/~ Š(1(-ɠZ{_iWſP4_xVWxfOм$ ֋.?( ( (?/ M D:q=пA!8+.> K4q } Ey᧊u/4(s͗(d/8Me} xfk/[tiz\iڮ@6W7q4760H%Xݕ||.p|1qfXiOʧ[*̹9TW jhK4 5fVǨy?˃8o q>_S/S٧,>/)IQ`1P`*.TkGiSuե3ˇ q/ cf6GF1u8spJ5OjSJ)UEWPQEQEQEQEQEQS[^\AiiWWSEommoqqq;P1I4HQF;*" SIɨ9JM(&m$KVڊrI$m$mIjlQK<rM4$P4,8w܅DPYPIOE?2~#OU =; /<9qu9H7j0j^7¾m/m2)|q+2XZ`6Gt~Ȟ]4oJ>Gj9,/wts1ڮOJ-њ<ֵ)8\u)ʵx|m'88xdp=Խ,VLXc1pn9XF <9QEQ@P _Z4~xE7>3%ʹ1^#`R-J>35Նi/ uz_??66~jR/>` io{Ђ\boa)S1+K_CX/ 8zp)b(ȳ*iR9eC*ӎVMYJ 8*d %-p((((((-?a}S7❅ޓh&a- H2$樅&=%y/nO8\/3Z/FQl1(8lI9jS7 zu5hѩq<9c80qJ\4S!$ZU,46O;k;;;X k[[hU!(a8EDUUZO ? K yM8yPfM^N)8lQBr,<$U\EV|n)2g'R (QE~~f/_WZW xɠ)v* ^(c5ĒDyEyyKqQ9?*Ͱ8%d+ajBnJ7jiN!/S$γ>&fN.;:8Si5)hRZJ'Nr9k<^s$6ix,RZ]B]Boyg4VIѻr(~ڿ֛Dxxq|7d7yv־,b(ֳp@XWgmSK[[ &quZ𾳩wZ]hדi[k}ayn&UWՇYJFV?_~ g~q-LxcV^\?wçXB*h*QЂUFw2d.3I#ug5'!"]*:hĒk}$dpv /@yyў;~-ͰN3f+lMWT;J[kw-|._σ <=&=+A"|^7f]G/Q%ˀ8# {x~Gwَ8 [ x:uc*5xBXKeP*E|+ /p AҩRV anh$ƿToz~|^| K/$ӴKG~E \c^x]v[[I, 0Z}?~-дxS go N4x{GAhZ摨@VΟiGqg}eq5$( 5b~MOGaO~EүSLf4|QjUjWtwZ׳)@ֿট~(o Zh!𿁟Y:#?ĺ$ZIzPMjwtX|. 1~P\[[ww<6qssq"Cooo 4O<21F$Hʑ3Sυ? 4xOyx/-g6Z5}s;ƺujVv6\I QEQEQEW|~'COB>5$v^3_"_={#TQEQEQEGL5#?l_L5#?l_?h<67! 'o?:QEpQE&;RMnO5/ihWDa}ЏMoDQE١EP_ԇ5&gKRmr?a5?/_kC}_M6/eT9Q_bQ@|[ Aex:ү࡟h?W-ɪ3;xI'W?8\24QE'QEQEV_'5u|!ɱ{_GW?BpW\9|?O9SY(fMj)Tb'_ ^xX:%:tOo S 3^R21juڍ7xTΎ35FaR*W]֛]ڍĶZ^@9n9!Y%hHu Uw~ ~ЖM?} 0~(aK&kmfiZeşd4~6fi|q}%ޥigQ?e]3Ŷ ]xjD66GĿ Wɰu8ۇ:x9t)-R28ʮ2nW#V/fҟ q,>9S!*`3D!׬~y(Q,zb7Jd9]kB7\h"ѵ]Wmz^z6\Y_C-dO:jNjsV BPNqv' %(N-5(&W?UZ+Q 5)ե(ԧRJQ'8I4(iQEQ@Q@_Q~4>fV)4:S;cZǑhr?yZC Y4zǯGen LU^IѾϬxxMmKK,lZ\'"x^8_،S:Ӗ#iʦgPՕ5Kx[m5z¿|-SB}KBFC%~ꪲ77WQ$9o<7#G%ZE%%< Ys1t<Ջ*a0 R/҇G8v~ ԫЅdꋺp1t_& V?Y5)Ԝc(d(((?&߆ x/te֣f xv:լ2C{1cZK4 ߍ^?֩XjpK2ycrKMJrm l&2ğ &Ӈ֒]FzT(V{VRҮHU->r/p%m7O<@.N,[OId"WrЌeʲTƬbp8EYb_J# 8^8*J pXoxF ^Q`Gݥէ*QPaE~|mXxGYh|YO{]'Đ/%mx%xQOe,ߘ9gᖦt /E]wir,Osn㔕5tqDž xu.+)洩%L oؖ|GRB<B~|~[j^,Jj~+Ք`Doo#*i\:~$Q"XsC?N5q|qLFmB_xZ7 G)SQ< 8^֔1\|KWxyt.C*PC)ISqL=XEݺYbT c)6`e<D[b~$WzuDMKZKmNTl7#2K|5;B9n4ȿa!+x#HaX($qEH""(TP@ J<τ((+_ڧ4'C?}% ,ửSꑆuhmtAmVF c2$:Wɫ|@𷎼7xfg@۳: mϙo pWЖ#'҇i^avV 'JGhUT+jk %'wC|-xRWĚT tSNT!Nqф!1J1RQI$G*Ujg:*p' TR9Ԝ9MS&ܛmQEQE!Ǐ9ҟ~'"+Ƶa_?KLG&ֳ|aG9gEWEP_ag_$X~iW_?cIn<W?CQE `񮁪x[Ś6k6g]YF7T )=qo$SEe^M|MX=zs^zqFpJ*PN%(N8&FkVիP +ќVZեV3R JPRM&~~ԿN|?Q5x n 7x1r|SB8յxiqiW9"9cvHVI# WGFсVVEnm_G uf7[W3}zţm7HE[UAk'#Sߡ4ϼ,aUW:ORT/lu,ߓLibzq<$bc2X\>#5RPCS4N6s#+{LvP8Ú:y>t~+f:p w&w~$šɯ‹*IQyqvWzu ݍw/ފ#DVqWqwc^_Ŝ=dXiFװ˼xs՟px.~l8Sr T&a3>LfX*TT+叩 ( ( ( +[DujV?4][mM:Un[ mfl1Lyw ;? 9)Gк灰si}xKFᅬ4Z NuNl&K31(V&uZꏐ>%Ua}+oV;:\>HdVn):NW;OO+ٿ翁~"[KI0h%|3,(_| P/m]QVk#~sjeO'[JԿu>hn>%ZxT>}֋ƚō#}e* 755Cs::CxHc|k,&8QNo]z{f oῈ< mt_ jt 7=;P1F>|K7H oZ4I<tt-KѴ_zI4( =;L~"!+x"@8DPEQEQETVַ6W^Y^A5ݥ1ZZF[\̯OSC*4r̎A($d$(]I5fz4֍=e((EFQm8kT5g GExo¢QD^eWЛ*v/?spE"7?Q|(a (M|۹s_8bp¢QD^e0yWYE`&ʿ݃>K91m DѴ NI$tN,YH2ʶPR{XZQ^*TӅ*4J8SJN"J1V1I%GRJ'VIիRNu*TRs9ɹJM&{* ((ZfiƝcih#ӵ+K{WY.-.dY$ ,l¢QD^eW'-˱LfF' Bܔ#*%&ʝ۵ۆ̳%Oi\^%6\S\c쒽T_ ?|<+&?+(r?e_n%۹s_8bp¢QD^e0yWYE`&ʿ݃>K91?Q|(a ]3Kt[}/GӬt6Y-42FK{KXⷅZGycAwf#sJ+ )ʦTy%<6 ORNqb[❮ω̳d#O\'^#4TTe%̕ڽ/QEqQ@gjFXOk^w^WtZQ&>)n!x˚(L:+XNXB::EN!48NN2e&M4˧R)­)ΝJsJu)P9BpZg%(-8iE"7?Q|(a +M|z_۹s_8bp¢QD^e0yWYE`&ʿ݃>K91?Q|(a T_ ?|<+?r?e_n%yC\w}emiv6negaam 0ZۤpA8QG@*WƜcF0"B*1bcbI$I$y)Nr99NrnR)JNRmmB($((c?+W)aK;^?~L|1xqA4? G)?nOʱF (nBʭT|Y|/ž%f}SU5WŬ V/xUd*AϹӔ:WiU~ciXZ"%0;$h; EcF0cB*0b*ьbI$R)R')JMR)IݹImQEDQ@Q@Q@Q@Q@fj.>}'_tsK]:naki be$l9֧:Ui­**S:u!%iBpqdRM;5bԩF*ҜU(ΝJr*S]8N-J2IQiϑs~^9y~78:42y]'w?C0ԒcOh:7 z}: ovǩ=#$:=6Vp2:$Jl>%'7G/pi@cO4XyqZ(SiӦ""5?~ Lakj6Wx{ !h6gjxKNzwZ@}f=L}Eo2c8*&.Go-ZW{a眺T%~y?HpU\5~%ulnZɕzo]R/c?No ~5 eS. D ;2h;؞o|. {šR#N捧Dvӭy0NdugbIf$J+88F<1E'YV2V-Yl]*K]nYMQse_lGU*[Yf-d٦eti1ج}ﰵiTϚkS]^"Nzv.u(##FNj]:gZ(GCb?o?HriLnG+:ee` 5Ep)Bz\ԩRs9ޗnEUQEQE^=E⍷~!x¾.UO.kF-<5Sz{`H݂rQ\3UfX,&aka1j85XC /)eXܷ_5^[ /^զ4ω|o|H\x^ƿ\H[zYTԝnL|_$`i4 M &(^/ݣ7<$ϫh&D8#m|տ3l9w8#uWG  (Uwϣ7уM'JqQz&_y3uӫ:QRY ܲ2MhI;6o_|XK_hv:=-kO%iev%ى' `R0Z1PѧJ hңJ0N+c18V6LV3_.z،Mj՛U+UN_ޔ (0 ( (?_;MX?W~/WZP[I[MVB?e4_/ w1ex-IXYdg9e>FJ%1E5j??ioڗXkkkᯃ xYt|y-ω|_":-M ${=Ov xeOgy'y6ٷ;ƴQ@tests/fixtures/circle.png000064400000024033152265255750011546 0ustar00PNG  IHDRssC='IDATx%cyropܳa%B0[9)̜XZZҤ[&5jFzks_+o߿?mݕnA_v<2=]]Lf:9c[;g;}oy-b \W60\+4d!YkUbRdG$X慚ׯ_畧{'wsȭ=r+ĵna:f|L=wٹm[/K[nէJ6mi UMP)5MbPi UGϟ=\9A -T:lDPI@ P;BլJRLt- (rY*?Cvtҝp6B 9ͨN%bhؠ@DUnҔMɌdXd R+ec'޺M t6+–8BPT;VHdDV,m!f@w;UxtFi<2@c5jaıhr,3yNJ'Gg ħI1Em*`!)$2,}}S>m|7IaH+1j\jw!o}g'y9\sx|1p{#7?ʸ%y_Ǒu}{=mٱ0$1-3| ̼a8$ږCUHYI4vtϩ6}X]}gǎ(mmS9ev7MLM^H$LDȤ!N@SA!82sҏ_ 9{}DQRW8]/U@c 0C5 VaWe\^z7ƛoNR@u0PT BAQ0 @KW(@yz3.<#_:ʗ~y?_xM<\ g?lxm8W$}7rW]WZ.e?G%*J"#Y^*`.wg˕b%L7ߵJ}yqӛ_C5<]bF"B`tm@>mvfo8 ("*Ud 4o0ԒbE\% ˬ_0qdv?"Y߭?1 DV$ !)B6 ț~O}WwJ:%,T ^ z h~|Ʋ}*B?SqQis%/Sdu$ DVBd{2qޤ/>v z;'&FR BBDTٌS!EL@QfVʲDsiEQeWmKcۙ@\l7[훧ݺm= H@evB+a(Tu=H_!=*\"y_mM~68 fL`r'{3Q:}D"$@V~#a4z*K!z$${u KK=޶~3q_#i#9n8'2MDӟ(_: 3SWH%U5 tP-@5kc_\Lg1D6y:!T:TUCءve_pO4ʲB|h5Vr%HBL br|Su>2)5I{7(F(i2"dc8\XC6[fwp_>[zBJ /P1f~co|֐i^[O4IHGy_7&n苊NƩ($ B#YMTJ . ozfYA&x#|O|7E# 1CrT >O8s;ķWnzAʗVfWH! SsJsXi-Y25z+l {឴"T*v8Y=/c5Y)ku_x3CfٜN90=~5~~MԚBkOb8a9:O^X͊unEe3}IV5o~U1:"jF K8LdD"{+ۯz;U**A(T@1$x`,-ox>=d76)8 OSCCQuN~ځ'ms3TP[U,< D(X9żq)_[ GOVn̜U!n$?jg?߿s}غm LfU PV Ɛ91Ue2)9^kIRcyMoHf#8$7V`<7^ ʕcwn9*?#)$! Qj~rv2,-Χ|b6}E%'R43qKAVc|ߨZ,(I *) kA&" [\ZtzTe3*|^8ӐH=ZɇW$ZFH9x߾}MT!0(&TP@ 1 mmVteYOkWp"ST6,!b>A' l^ԚEU q)RrUͅptUO{T9c77z2)hb)FT13YL#7MmJYPCV6aنN9*Op9HkQ%7 33)_ؗ9|dXZgy sfsYdW0:2CrU[Cj];pFbg3Ju:%rY~x"d6ǟ(aDa8!lvu@'eE;}ر=B$!ERE", 2 بd/֋o}%cm$R s`5=OF[5 *DE\d 2PjecteJ}S^x$DJ|>.wDɍ|Z2OIPH@(:@]%`FQhY<\;*<[Bٻ|N."-Zxd@b0Ag%s>@VQqFqP$E/7NOI/WF'l"z=Ҋ`*U4.ˋ(\=HP N@$co^͓}]!2B!-NJT2̣v/8Nm ʒJ&"B =3\8i~2}KÓ4ܼb`uغGWZݝX RMD P*PA$ӊLMH# ! i!6G1@,';n!i A"UEH-y|5 t!9Ʒr[LIK6<^^lT~ @  BEH-6fѯ9c&'l,'Q:,8U 2w,Ok$X $|`7Lۺvr׸K/ Mi6\fɀ\W5oP'bFaE`P@DDD̤i`~>S\vFغɆyiٍbLi4K~dCS FOA:';i݅!a5f,N _]@n| #>φ#fyrOwy-+vlPaҦ̃)3lN7%6kqs -|6K(qT?9i- ebGTa0 a9nGMޯCBRj7BZ>kyW'x:eEs *ԊA3SYYڤnޤ2mp-'p3p"cw"Ʉ#5ʊؙNj}2s&QC0ҰP,l. x Ԛ'ӣшE}kW&b*QeE3; )Y:] ~H 0JALl'ckueE jj 2/z|mLQ=;# Oƃћ񯩪\m{"f()ŠH̼ЇVm~A{Di{l 6VO^X5/rLD>v+efVa^yf.NΆX͇7EB8#_A~ cLd{ߍh6z" aAJ:BXl-{$#eJ0!;xuYY̍,)WWQ_K7{ɦr`'0\'8xg츎Vc M ̠dJXJYr1mp0 ez:C1̿EΏH$2L옐ETE6Z Q^'K7ikR"m~F0ոVxքnH>鰄#5Wwy@6'dŶ\J.J+I+J!all,2BBeF^z l *ALx l 7!0kS]KE2B0>wHfvm^^o!GʶHGLXkk% CS,mv(a }E+dO`EMtGQfU}ciA\x/E%Tu7XϦ`4rIsco JWGuMOY^YzC]fifD?hNٜj*G\+޲;"V-rxCwCWhE{łF `u l6?,k)ۃFQe6H.p*+{N! Anp JG Bc㾇^hh(dr63Ƿ͈^ͿEXd{еy*?[7sqtݻ@H\_!=-SXJ[gF"J3o,o^wHcaYd?6[9\$P ׾wᾭ^ G!](+"%t±qA"]INS#C#q^R'%a}Zn$?eG\F2' t7 S?]NQjb>2`DhRt;5`"cɓF;nUc>Ug$e:qFC)@E^snz_ W SZV %WzR ՈFWY~fk:60ڊ+Q PG7k02q!"#> _0hl2?WU7ďԕl&+#/_f;/yYoHETDAhX誕}UD G"L3pu@JG/'g'ovv۫KKl[hÿrWimhk+]{x{C~5Gyɾ|BTL *"Q*¯8ʞn0+́Ǡofqlۿ?{Y VZ%'a)ēQ>{M~OX7' $YWZP(BGZ}"R']E" Qs5Z7tb@ Bq*ͭOF6}Hv\G}k߻p݋33 =+uWUIZeWRz%_ZBcP 88=1}-_%mNƉmvkD'rcYjӮSk@ $dʲ% ݚb,M`<VXQ!r3oQ i?(enΜz=5it:J"IE67Isw_ +*kxEU~ҷdD;Sfcyy6A:ecgVX0!,0 BIevԬ|'#(ibj嗺.~Y\+VAXd(We_呃GŽ;\ylc9V9skr-t]삯Ȑ7q !w]!_i?O|™Y_m*ΰjZ3T7kjZ+,?? ~vHO$r@)"QUɲlu_ߏǙ0>d%bAxԺs޽n찵 QR B;&\ڒXo%@azzڪNf;Pcl :Z$Xx6'''..-^\/)2t"i3n%BdDKxсo8KΖI jEZa"r5Yt8>Bo9˵]˵8rFid&~v-w=jN1#V"ujjn+{/ҡ>A4ݼfF@">짞O 3uCP8̬XX.fkN=~~7~*"SA޲(TELhNn8WI[>{/:.z!nf?;H$1Uӳ};vg^F j>0$ JuJrɅޢc8Txؿ?ӯS&8<'bz4:2Bu*J"Jfyx[^#ٕ̮ [$xD Woɏ}]r׋P# vyi?F÷2n{]ynDdĉ;vPUqPp32RJPThf""m?v^@)JBHIP Ba}"ŜZ5'q\<dV Sd{\-BF U"Pf*]8j_Q]":=hLٚ |.`U&w~kc2o:Y=nktTH+Q@*PV3aYXVmN5^c"R@Ɍ9H)XiITȌ`GQԉRP,ĮAC<ݗw: l?ۅ[x|`uD]`(ZZGW\9dPFz-~;4lmP*!"fJ{w#rus#zx$7:,7`fe+njWܟH":e Q`eiUYw Ny(˫$3߽/fP,0X j) yiIьXY&qV㗒&/LS)y\]yv-!gZچ_DN~t{_(W)jyauaJʂ'G#z|">AޏjƃeiafnmZ7t@"=xtW'YQ2PhsZB1(-:Tiۋ~w_vG-.o,1AJAAe"/-锖;9uXJD?}:Ki 4KOM>ǩ޺Ge3CQHcP2V` 4غP[ 9Dd % Zm[hD7H;7IIh} y:vBJ:MgeYc..hsicC 2\2#/V RTPBMXFZV(2.5dDl%& ))#9A&Ʋzc}#~x;?|bsKtvv!T#XHk巕^ QfYSEmHAVR+$9䜉L >3OÊapGLvR_DkS< n}ӝ,Yg PXS  J-c4D \FIR;iB|8r$}$| Ɂ1GXTk}O+2^o㏿#m0uh)Kam43+ ʰ&YmѲbG NE:@L`$h˶0)`JLLb0zM+2X3~{puiŭmJ![`P\XV`E*DզTkTb堻dӤD$NfH}r1mLT'NT8/R?|/2Mb933(1D\K`rPǙC @KIxv] 0cfBdv0ENYlknzU%PNjc.AmWͭ rfCiYEc=eHH'L%brAUX裇1UG#.yo{csW^}▬ݤ &N<>?,.LΠNcDAcFD}tLC1{ɾ~ӣ<7<sdmD.=۸v 0^OEZ \y:dyK瘯F?/o% N9v~(|[IENDB`tests/communication_test_helper_trait.php000064400000010106152265255750015101 0ustar00. namespace core_communication; /** * Trait communication_test_helper_trait to generate initial setup for communication providers. * * @package core_communication * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait communication_test_helper_trait { /** * Setup necessary configs for communication subsystem. * * @return void */ protected function setup_communication_configs(): void { set_config('enablecommunicationsubsystem', 1); } /** * Disable configs for communication subsystem. * * @return void */ protected function disable_communication_configs(): void { set_config('enablecommunicationsubsystem', 0); } /** * Get or create course if it does not exist * * @param string $roomname The room name for the communication api * @param string $provider The selected provider * @return \stdClass */ protected function get_course( string $roomname = 'Sampleroom', string $provider = 'communication_matrix', array $extrafields = [], ): \stdClass { $this->setup_communication_configs(); $records = [ 'selectedcommunication' => $provider, 'communicationroomname' => $roomname, ]; return $this->getDataGenerator()->create_course(array_merge($records, $extrafields)); } /** * Get or create user if it does not exist. * * @param string $firstname The user's firstname for the communication api * @param string $lastname The user's lastname for the communication api * @param string $username The user's username for the communication api * @return \stdClass */ protected function get_user( string $firstname = 'Samplefn', string $lastname = 'Sampleln', string $username = 'sampleun' ): \stdClass { $this->setup_communication_configs(); $records = [ 'firstname' => $firstname, 'lastname' => $lastname, 'username' => $username, ]; return $this->getDataGenerator()->create_user($records); } /** * Create a stored_file in a draft file area from a fixture file. * * @param string $filename The file name within the communication/tests/fixtures folder. * @param string $storedname The name to use in the database. * @return \stored_file */ protected function create_communication_file( string $filename, string $storedname, ): \stored_file { global $CFG; $fs = get_file_storage(); $itemid = file_get_unused_draft_itemid(); return $fs->create_file_from_pathname((object) [ 'contextid' => \context_system::instance()->id, 'component' => 'user', 'filearea' => 'draftfile', 'itemid' => $itemid, 'filepath' => '/', 'filename' => $storedname, ], "{$CFG->dirroot}/communication/tests/fixtures/{$filename}"); } /** * Helper to execute a particular task. * * @param string $task The task. */ private function execute_task(string $task): void { // Run the scheduled task. ob_start(); $task = \core\task\manager::get_scheduled_task($task); $task->execute(); ob_end_clean(); } } tests/api_test.php000064400000051417152265255750010255 0ustar00. namespace core_communication; use core_communication\task\add_members_to_room_task; use core_communication\task\create_and_configure_room_task; use communication_matrix\matrix_test_helper_trait; use core_communication\task\synchronise_provider_task; use core_communication\task\synchronise_providers_task; use core_communication\task\remove_members_from_room; use core_communication\task\update_room_task; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); require_once(__DIR__ . '/communication_test_helper_trait.php'); /** * Class api_test to test the communication public api and its associated methods. * * @package core_communication * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_communication\api */ final class api_test extends \advanced_testcase { use matrix_test_helper_trait; use communication_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); $this->initialise_mock_server(); } /** * Test set data to the instance. */ public function test_set_data(): void { $course = $this->get_course(); $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); // Sample data. $roomname = 'Sampleroom'; $provider = 'communication_matrix'; // Set the data. $communication->set_data($course); $roomnameidentifier = $communication->get_provider() . 'roomname'; // Test the set data. $this->assertEquals($roomname, $course->$roomnameidentifier); $this->assertEquals($provider, $course->selectedcommunication); } /** * Test get_current_communication_provider method. */ public function test_get_provider(): void { $course = $this->get_course(); $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals('communication_matrix', $communication->get_provider()); } /** * Test set_avatar method. */ public function test_set_avatar(): void { global $CFG; $this->setAdminUser(); $course = $this->get_course('Sampleroom', 'none'); // Sample data. $communicationroomname = 'Sampleroom'; $selectedcommunication = 'communication_matrix'; $avatar = $this->create_communication_file( 'moodle_logo.jpg', 'moodle_logo.jpg', ); // Create the room, settingthe avatar. $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: $selectedcommunication, ); $communication->create_and_configure_room($communicationroomname, $avatar); // Reload the communication processor. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); // Compare result. $this->assertEquals( $avatar->get_contenthash(), $communicationprocessor->get_avatar()->get_contenthash(), ); } /** * Test the create_and_configure_room method to add/create tasks. */ public function test_create_and_configure_room(): void { // Get the course by disabling communication so that we can create it manually calling the api. $course = $this->get_course('Sampleroom', 'none'); // Sample data. $communicationroomname = 'Sampleroom'; $selectedcommunication = 'communication_matrix'; $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: $selectedcommunication, ); $communication->create_and_configure_room($communicationroomname); // Test the tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\create_and_configure_room_task'); $this->assertCount(1, $adhoctask); $adhoctask = reset($adhoctask); $this->assertInstanceOf('\\core_communication\\task\\create_and_configure_room_task', $adhoctask); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); } /** * Test the create_and_configure_room method to add/create tasks when no communication provider selected. */ public function test_create_and_configure_room_without_communication_provider_selected(): void { // Get the course by disabling communication so that we can create it manually calling the api. $course = $this->get_course('Sampleroom', 'none'); // Test the tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\create_and_configure_room_task'); $this->assertCount(0, $adhoctask); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertNull($communicationprocessor); } /** * Test update operation. */ public function test_update_room(): void { $course = $this->get_course(); // Sample data. $communicationroomname = 'Sampleroomupdated'; $selectedcommunication = 'communication_matrix'; $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communication->update_room(processor::PROVIDER_ACTIVE, $communicationroomname); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); $this->assertTrue($communicationprocessor->is_instance_active()); $communication->update_room(processor::PROVIDER_INACTIVE, $communicationroomname); // Test updating active state. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: $selectedcommunication, ); $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); $this->assertFalse($communicationprocessor->is_instance_active()); } /** * Test delete operation. */ public function test_delete_room(): void { $course = $this->get_course(); // Sample data. $communicationroomname = 'Sampleroom'; $selectedcommunication = 'communication_matrix'; // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals($communicationroomname, $communicationprocessor->get_room_name()); $this->assertEquals($selectedcommunication, $communicationprocessor->get_provider()); $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communication->delete_room(); // Test the tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\delete_room_task'); // Should be 2 as one for create, another for update. $this->assertCount(1, $adhoctask); $adhoctask = reset($adhoctask); $this->assertInstanceOf('\\core_communication\\task\\delete_room_task', $adhoctask); } /** * Test the adding and removing of members from room. */ public function test_adding_and_removing_of_room_membership(): void { $course = $this->get_course(); $userid = $this->get_user()->id; // First test the adding members to a room. $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communication->add_members_to_room([$userid]); // Test the tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\add_members_to_room_task'); $this->assertCount(1, $adhoctask); // Now test the removing members from a room. $communication->remove_members_from_room([$userid]); // Test the tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\remove_members_from_room'); $this->assertCount(1, $adhoctask); } /** * Test the update of room membership with the change user role. */ public function test_update_room_membership_on_user_role_change(): void { global $DB; // Generate the data. $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $coursecontext = \context_course::instance($course->id); $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); $this->getDataGenerator()->enrol_user($user->id, $course->id); $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\add_members_to_room_task'); $this->assertCount(1, $adhoctask); $adhoctask = reset($adhoctask); $this->assertInstanceOf('\\core_communication\\task\\add_members_to_room_task', $adhoctask); // Test the tasks added as the role is a teacher. $adhoctask = \core\task\manager::get_adhoc_tasks('\\core_communication\\task\\update_room_membership_task'); $this->assertCount(1, $adhoctask); $adhoctask = reset($adhoctask); $this->assertInstanceOf('\\core_communication\\task\\update_room_membership_task', $adhoctask); } /** * Test sync_provider method for the sync of available provider. */ public function test_sync_provider(): void { // Generate the data. $user = $this->getDataGenerator()->create_user(); $course1 = $this->get_course(); $this->getDataGenerator()->enrol_user($user->id, $course1->id); $course2 = $this->get_course(); $this->getDataGenerator()->enrol_user($user->id, $course2->id); // Now run the task to add sync providers. $this->execute_task(synchronise_providers_task::class); $adhoctask = \core\task\manager::get_adhoc_tasks(synchronise_provider_task::class); $this->assertCount(2, $adhoctask); } /** * Test the removal of all members from the room. */ public function test_remove_all_members_from_room(): void { $course = $this->get_course(); $userid = $this->get_user()->id; $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communication->add_members_to_room([$userid]); // Now test the removing members from a room. $communication->remove_all_members_from_room(); // Test the remove members tasks added. $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); $this->assertCount(1, $adhoctask); } /** * Test the configuration of room changes as well as the membership with the change of provider. */ public function test_configure_room_and_membership_by_provider(): void { global $DB; $course = $this->get_course('Sampleroom', 'none'); $userid = $this->get_user()->id; $provider = 'communication_matrix'; $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); $communication->reload(); // Test that the task to create a room is added. $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); $this->assertCount(1, $adhoctask); // Test that no update tasks are added. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(0, $adhoctask); // Test that the task to add members to room is not added, as we are adding the user mapping not the task. $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); $this->assertCount(0, $adhoctask); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); // Now disable the provider by setting none. $communication->configure_room_and_membership_by_provider( provider: processor::PROVIDER_NONE, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); $communication->reload(); // Test that the task to delete a room is added. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); // Test that the task to remove members from room is added. $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); $this->assertCount(1, $adhoctask); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); // Now try to set the same none provider again. $communication->configure_room_and_membership_by_provider( provider: processor::PROVIDER_NONE, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); // Test that no communicaiton task is added. $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); $this->assertCount(0, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(0, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); $this->assertCount(0, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); $this->assertCount(0, $adhoctask); // Now let's change it back to matrix and test the update task is added. $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); $communication->reload(); // Test create task is not added because communication has been created in the past. $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); $this->assertCount(0, $adhoctask); // Test an update task added. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); // Test add membership task is added. $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); $this->assertCount(1, $adhoctask); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); $course->customlinkurl = $course->customlinkurl ?? 'https://moodle.org'; // Now change the provider to another one. $communication->configure_room_and_membership_by_provider( provider: 'communication_customlink', instance: $course, communicationroomname: $course->fullname, users: [$userid], ); $communication->reload(); // Remove membership and update room task for the previous provider. // Create room task for new one. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); $this->assertCount(1, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); $this->assertCount(1, $adhoctask); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); // Now disable the provider. $communication->configure_room_and_membership_by_provider( provider: processor::PROVIDER_NONE, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); $communication->reload(); // Should have one update and one remove task. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); // This provider doesn't have any membership, so no remove task. $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); $this->assertCount(0, $adhoctask); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); // Now enable the same provider again. $communication->configure_room_and_membership_by_provider( provider: $provider, instance: $course, communicationroomname: $course->fullname, users: [$userid], ); // Now it should have one update and one add task. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); $this->assertCount(1, $adhoctask); } } tests/hook_listener_test.php000064400000053213152265255750012345 0ustar00. namespace core_communication; use communication_matrix\matrix_test_helper_trait; use core_communication\task\add_members_to_room_task; use core_communication\task\create_and_configure_room_task; use core_communication\task\delete_room_task; use core_communication\task\update_room_membership_task; use core_communication\task\update_room_task; use core_communication\processor as communication_processor; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); require_once(__DIR__ . '/communication_test_helper_trait.php'); /** * Test communication hook listeners. * * @package core_communication * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_communication\hook_listener */ final class hook_listener_test extends \advanced_testcase { use communication_test_helper_trait; use matrix_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); $this->initialise_mock_server(); } /** * Test create_group_communication. */ public function test_create_update_delete_group_communication(): void { global $DB; $course = $this->get_course( roomname: 'Test room name', extrafields: ['groupmode' => SEPARATEGROUPS], ); $coursecontext = \context_course::instance(courseid: $course->id); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Enrol user1 as teacher. $teacherrole = $DB->get_record( table: 'role', conditions: ['shortname' => 'manager'], ); $this->getDataGenerator()->enrol_user( userid: $user1->id, courseid: $course->id, ); role_assign( roleid: $teacherrole->id, userid: $user1->id, contextid: $coursecontext->id, ); // Enrol user2 as student. $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user( userid: $user2->id, courseid: $course->id, ); role_assign( roleid: $studentrole->id, userid: $user2->id, contextid: $coursecontext->id, ); $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $context = \context_course::instance($course->id); $groupcommunication = helper::load_by_group( groupid: $group->id, context: $context, ); $this->assertInstanceOf( expected: communication_processor::class, actual: $groupcommunication->get_processor(), ); $this->assertEquals( expected: $group->id, actual: $groupcommunication->get_processor()->get_instance_id(), ); // Task to create room should be added. $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); $this->assertCount(1, $adhoctask); // Task to add members to room should not be there as the room is yet to be created. $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); $this->assertCount(0, $adhoctask); // Only users with access to all groups should be added to the room at this point. $groupcommunicationusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); $this->assertEquals( expected: [$user1->id], actual: $groupcommunicationusers, ); // Now delete all the ad-hoc tasks. $DB->delete_records('task_adhoc'); // Now cann the update group but don't change the group name. groups_update_group($group); // No task should be added as nothing changed. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(0, $adhoctask); // Now change the group name. $group->name = 'Changed group name'; groups_update_group($group); // Now one task should be there to update the group room name. $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); $this->assertCount(1, $adhoctask); $groupcommunication->reload(); $this->assertEquals( expected: 'Changed group name (Test room name)', actual: $groupcommunication->get_processor()->get_room_name(), ); // Now delete the group. groups_delete_group($group->id); $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); $this->assertCount(1, $adhoctask); } /** * Test inactive users are not included when being mapped to a new communication instance. */ public function test_inactive_users_are_not_mapped_to_new_communication(): void { // Create a course without a communication provider set. $course = $this->getDataGenerator()->create_course(); // Enrol some users that are both active and inactive (suspended). $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); $user4 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user( userid: $user1->id, courseid: $course->id, roleidorshortname: 'teacher', ); $this->getDataGenerator()->enrol_user( userid: $user2->id, courseid: $course->id, roleidorshortname: 'student', ); $this->getDataGenerator()->enrol_user( userid: $user3->id, courseid: $course->id, roleidorshortname: 'teacher', status: ENROL_USER_SUSPENDED, ); $this->getDataGenerator()->enrol_user( userid: $user4->id, courseid: $course->id, roleidorshortname: 'student', status: ENROL_USER_SUSPENDED, ); // Set Matrix as the communication provider and update. $course->selectedcommunication = 'communication_matrix'; $course->communication_matrixroomname = 'testroom'; update_course($course); helper::update_course_communication_instance( course: $course, changesincoursecat: false, ); // Load the communication instance and check that only the 2 active users are returned. $communication = helper::load_by_course( courseid: $course->id, context: \context_course::instance($course->id), ); $userids = $communication->get_processor()->get_all_userids_for_instance(); $this->assertEquals( expected: 2, actual: count($userids), ); $this->assertContains( needle: $user1->id, haystack: $userids, ); $this->assertContains( needle: $user2->id, haystack: $userids, ); } /** * Test inactive users are not included when being mapped to a new communication instance using groups. */ public function test_inactive_users_are_not_mapped_to_new_group_communication(): void { // Create a course without a communication provider set. $course = $this->getDataGenerator()->create_course( options: ['groupmode' => SEPARATEGROUPS], ); // Enrol some users that are both active and inactive (suspended). $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); $user4 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user( userid: $user1->id, courseid: $course->id, roleidorshortname: 'teacher', ); $this->getDataGenerator()->enrol_user( userid: $user2->id, courseid: $course->id, roleidorshortname: 'student', ); $this->getDataGenerator()->enrol_user( userid: $user3->id, courseid: $course->id, roleidorshortname: 'teacher', status: ENROL_USER_SUSPENDED, ); $this->getDataGenerator()->enrol_user( userid: $user4->id, courseid: $course->id, roleidorshortname: 'student', status: ENROL_USER_SUSPENDED, ); // Create a group and add all users to it. $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); groups_add_member( grouporid: $group, userorid: $user1, ); groups_add_member( grouporid: $group, userorid: $user2, ); groups_add_member( grouporid: $group, userorid: $user3, ); groups_add_member( grouporid: $group, userorid: $user4, ); // Set Matrix as the communication provider and update. $course->selectedcommunication = 'communication_matrix'; $course->communication_matrixroomname = 'testroom'; update_course($course); helper::update_group_communication_instances_for_course( course: $course, provider: 'communication_matrix', ); // Load the communication instance and check that only the 2 active users are returned. $communication = helper::load_by_group( groupid: $group->id, context: \context_course::instance($course->id), ); $userids = $communication->get_processor()->get_all_userids_for_instance(); $this->assertEquals( expected: 2, actual: count($userids), ); $this->assertContains( needle: $user1->id, haystack: $userids, ); $this->assertContains( needle: $user2->id, haystack: $userids, ); } /** * Test add_members_to_group_room. */ public function test_add_members_to_group_room(): void { global $DB; $course = $this->get_course( extrafields: ['groupmode' => SEPARATEGROUPS], ); $coursecontext = \context_course::instance(courseid: $course->id); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Enrol user1 as teacher. $teacherrole = $DB->get_record( table: 'role', conditions: ['shortname' => 'manager'], ); $this->getDataGenerator()->enrol_user( userid: $user1->id, courseid: $course->id, ); role_assign( roleid: $teacherrole->id, userid: $user1->id, contextid: $coursecontext->id, ); // Enrol user2 as student. $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user( userid: $user2->id, courseid: $course->id, ); role_assign( roleid: $studentrole->id, userid: $user2->id, contextid: $coursecontext->id, ); $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); // Now check if the teacher is added to the group room as the teacher has access to all groups. $context = \context_course::instance($course->id); $groupcommunication = helper::load_by_group( groupid: $group->id, context: $context, ); // Now the communication instance should not have the student added yet. $this->assertNotContains( needle: $user2->id, haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), ); groups_add_member( grouporid: $group, userorid: $user2, ); // Now it should have the student. $this->assertContains( needle: $user2->id, haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), ); } /** * Test if the course instances are created properly for course default provider. */ public function test_course_default_provider(): void { $defaultprovider = 'communication_matrix'; // Set the default communication for course. set_config( name: 'coursecommunicationprovider', value: $defaultprovider, plugin: 'moodlecourse', ); // Test that the default communication is created for course mode. $course = $this->get_course(); $coursecontext = \context_course::instance(courseid: $course->id); $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $this->assertEquals( expected: $defaultprovider, actual: $coursecommunication->get_provider(), ); $this->assertEquals( expected: 'core_course', actual: $coursecommunication->get_processor()->get_component(), ); $this->assertEquals( expected: $course->id, actual: $coursecommunication->get_processor()->get_instance_id(), ); } /** * Test update_course_communication. */ public function test_update_course_communication(): void { global $DB; // Set up the data with course, group, user etc. $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $group = $this->getDataGenerator()->create_group(record: ['courseid' => $course->id]); $coursecontext = \context_course::instance(courseid: $course->id); $teacherrole = $DB->get_record( table: 'role', conditions: ['shortname' => 'teacher'], ); $this->getDataGenerator()->enrol_user( userid: $user->id, courseid: $course->id, ); role_assign( roleid: $teacherrole->id, userid: $user->id, contextid: $coursecontext->id, ); groups_add_member( grouporid: $group->id, userorid: $user->id, ); // Now test that there is communication instances for the course and the user added for that instance. $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $this->assertInstanceOf( expected: communication_processor::class, actual: $coursecommunication->get_processor(), ); // Check the user is added for course communication instance. $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); $courseusers = reset($courseusers); $this->assertEquals( expected: $user->id, actual: $courseusers, ); // Group should not have any instance yet. $groupcommunication = helper::load_by_group( groupid: $group->id, context: $coursecontext, ); $this->assertNull(actual: $groupcommunication->get_processor()); // Now update the course. $course->groupmode = SEPARATEGROUPS; $course->selectedcommunication = 'communication_matrix'; update_course(data: $course); // Now there should be a group communication instance. $groupcommunication->reload(); $this->assertInstanceOf( expected: communication_processor::class, actual: $groupcommunication->get_processor(), ); // The course communication instance must be active. $coursecommunication->reload(); $this->assertInstanceOf( expected: communication_processor::class, actual: $coursecommunication->get_processor(), ); // All the course instance users must be marked as deleted. $coursecommunication->reload(); $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); $courseusers = reset($courseusers); $this->assertEquals( expected: $user->id, actual: $courseusers, ); // Group instance should have the user. $groupusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); $groupusers = reset($groupusers); $this->assertEquals( expected: $user->id, actual: $groupusers, ); // Now disable the communication instance for the course. $course->selectedcommunication = communication_processor::PROVIDER_NONE; update_course(data: $course); // Now both course and group instance should be disabled. $coursecommunication->reload(); $this->assertNull(actual: $coursecommunication->get_processor()); $groupcommunication->reload(); $this->assertNull(actual: $groupcommunication->get_processor()); } /** * Test create_course_communication_instance. */ public function test_create_course_communication_instance(): void { $course = $this->get_course(); $coursecontext = \context_course::instance(courseid: $course->id); $coursecommunication = helper::load_by_course( courseid: $course->id, context: $coursecontext, ); $processor = $coursecommunication->get_processor(); $this->assertEquals( expected: 'communication_matrix', actual: $processor->get_provider(), ); $this->assertEquals( expected: 'Sampleroom', actual: $processor->get_room_name(), ); } /** * Test delete_course_communication. */ public function test_delete_course_communication(): void { $course = $this->get_course(); delete_course( courseorid: $course, showfeedback: false, ); $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); $this->assertCount(1, $adhoctask); } /** * Test update of room membership when user changes occur. */ public function test_update_user_room_memberships(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $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); $coursecommunication = helper::load_by_course($course->id, $coursecontext); $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); $courseusers = reset($courseusers); $this->assertEquals($user->id, $courseusers); $user->suspended = 1; user_update_user($user, false); $coursecommunication->reload(); $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); $courseusers = reset($courseusers); $this->assertEquals($user->id, $courseusers); } /** * Test deletion of user room memberships when a user is deleted. */ public function test_delete_user_room_memberships(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $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); delete_user($user); $coursecommunication = helper::load_by_course($course->id, $coursecontext); $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); $this->assertEmpty($courseusers); } /** * Test user room membership updates with role changes in a course. */ public function test_update_user_membership_for_role_changes(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course = $this->get_course(); $coursecontext = \context_course::instance($course->id); $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); $this->getDataGenerator()->enrol_user($user->id, $course->id); $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); $this->assertCount(1, $adhoctask); role_assign($teacherrole->id, $user->id, $coursecontext->id); $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); $this->assertCount(2, $adhoctask); } } tests/processor_test.php000064400000047070152265255750011523 0ustar00. namespace core_communication; use communication_matrix\matrix_test_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); require_once(__DIR__ . '/communication_test_helper_trait.php'); /** * Class processor_test to test the communication internal api and its associated methods. * * @package core_communication * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_communication\processor */ final class processor_test extends \advanced_testcase { use matrix_test_helper_trait; use communication_test_helper_trait; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setup_communication_configs(); $this->initialise_mock_server(); } /** * Test create instance. * * @covers ::create_instance * @covers ::get_id * @covers ::get_context * @covers ::get_context_id * @covers ::get_provider * @covers ::get_room_name */ public function test_create_instance(): void { global $DB; $this->resetAfterTest(); // Sample test data. $instanceid = 10; $context = \core\context\system::instance(); $component = 'core'; $instancetype = 'mycommunication'; $selectedcommunication = 'communication_matrix'; $communicationroomname = 'communicationroom'; $communicationprocessor = processor::create_instance( $context, $selectedcommunication, $instanceid, $component, $instancetype, $communicationroomname, ); // Now test the record against the database. $communicationrecord = $DB->get_record( 'communication', ['instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype] ); // Test against the set data. $this->assertNotEmpty($communicationrecord); $this->assertEquals($context->id, $communicationrecord->contextid); $this->assertEquals($instanceid, $communicationrecord->instanceid); $this->assertEquals($component, $communicationrecord->component); $this->assertEquals($selectedcommunication, $communicationrecord->provider); $this->assertEquals($communicationroomname, $communicationrecord->roomname); $this->assertEquals($instancetype, $communicationrecord->instancetype); // Test against the object. $this->assertEquals($context->id, $communicationprocessor->get_context_id()); $this->assertEquals($context, $communicationprocessor->get_context()); $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); $this->assertEquals($communicationprocessor->get_provider(), $communicationrecord->provider); $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); } /** * Test update instance. * * @covers ::update_instance * @covers ::is_instance_active * @covers ::get_id * @covers ::get_room_name */ public function test_update_instance(): void { global $DB; $this->resetAfterTest(); // Sameple test data. $instanceid = 10; $context = \core\context\system::instance(); $component = 'core'; $instancetype = 'mycommunication'; $selectedcommunication = 'communication_matrix'; $communicationroomname = 'communicationroom'; $communicationprocessor = processor::create_instance( $context, $selectedcommunication, $instanceid, $component, $instancetype, $communicationroomname, ); $selectedcommunication = 'none'; $communicationroomname = 'communicationroomedited'; $communicationprocessor->update_instance(processor::PROVIDER_INACTIVE, $communicationroomname); // Now test the record against the database. $communicationrecord = $DB->get_record('communication', [ 'instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype, ]); // Test against the set data. $this->assertNotEmpty($communicationrecord); $this->assertEquals($context->id, $communicationrecord->contextid); $this->assertEquals($instanceid, $communicationrecord->instanceid); $this->assertEquals($component, $communicationrecord->component); $this->assertEquals(processor::PROVIDER_INACTIVE, $communicationrecord->active); $this->assertEquals($communicationroomname, $communicationrecord->roomname); $this->assertEquals($instancetype, $communicationrecord->instancetype); // Test against the object. $this->assertEquals($context->id, $communicationprocessor->get_context_id()); $this->assertEquals($context, $communicationprocessor->get_context()); $this->assertEquals($communicationprocessor->get_id(), $communicationrecord->id); $this->assertEquals($communicationprocessor->is_instance_active(), $communicationrecord->active); $this->assertEquals($communicationprocessor->get_room_name(), $communicationrecord->roomname); } /** * Test delete instance. * * @covers ::delete_instance * @covers ::create_instance * @covers ::load_by_instance */ public function test_delete_instance(): void { global $DB; $this->resetAfterTest(); // Sameple test data. $instanceid = 10; $context = \core\context\system::instance(); $component = 'core'; $instancetype = 'mycommunication'; $selectedcommunication = 'communication_matrix'; $communicationroomname = 'communicationroom'; $communicationprocessor = processor::create_instance( $context, $selectedcommunication, $instanceid, $component, $instancetype, $communicationroomname, ); $communicationprocessor->delete_instance(); // Now test the record against the database. $communicationrecord = $DB->get_record('communication', [ 'instanceid' => $instanceid, 'component' => $component, 'instancetype' => $instancetype, ]); // Test against the set data. $this->assertEmpty($communicationrecord); // Test against the object. $communicationprocessor = processor::load_by_instance( context: $context, component: $component, instancetype: $instancetype, instanceid: $instanceid, ); $this->assertNull($communicationprocessor); } /** * Test load by id. * * @covers ::load_by_instance * @covers ::get_room_provider */ public function test_load_by_instance(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertNotNull($communicationprocessor); $this->assertInstanceOf(communication_provider::class, $communicationprocessor->get_room_provider()); $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_provider()); $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); } /** * Test load by id. * * @covers ::load_by_id * @covers ::get_room_provider * @covers ::load_by_instance */ public function test_load_by_id(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $communicationprocessorbyid = processor::load_by_id($communicationprocessor->get_id()); $this->assertNotNull($communicationprocessorbyid); $this->assertInstanceOf(communication_provider::class, $communicationprocessorbyid->get_room_provider()); $this->assertInstanceOf(room_chat_provider::class, $communicationprocessorbyid->get_room_provider()); $this->assertInstanceOf(room_user_provider::class, $communicationprocessorbyid->get_room_provider()); $this->assertInstanceOf(user_provider::class, $communicationprocessorbyid->get_room_provider()); } /** * Test get component. * * @covers ::get_component * @covers ::load_by_instance */ public function test_get_component(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals('core_course', $communicationprocessor->get_component()); } /** * Test get provider. * * @covers ::get_provider * @covers ::load_by_instance */ public function test_get_provider(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists when fetching the active provider. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals('communication_matrix', $communicationprocessor->get_provider()); // Test the communication record exists when specifying the provider. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: 'communication_matrix', ); $this->assertEquals('communication_matrix', $communicationprocessor->get_provider()); // Test the communication record exists when the provider is not active. $communicationprocessor->update_instance(processor::PROVIDER_INACTIVE); $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: 'communication_matrix', ); $this->assertEquals('communication_matrix', $communicationprocessor->get_provider()); } /** * Test get room name. * * @covers ::get_room_name * @covers ::load_by_instance */ public function test_get_room_name(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals('Sampleroom', $communicationprocessor->get_room_name()); } /** * Test get room provider. * * @covers ::get_room_provider * @covers ::require_room_features * @covers ::supports_room_features * @covers ::load_by_instance */ public function test_get_room_provider(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertInstanceOf(room_chat_provider::class, $communicationprocessor->get_room_provider()); } /** * Test get user provider. * * @covers ::get_user_provider * @covers ::require_user_features * @covers ::supports_user_features * @covers ::load_by_instance */ public function test_get_user_provider(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertInstanceOf(user_provider::class, $communicationprocessor->get_room_provider()); } /** * Test get room user provider. * * @covers ::get_room_user_provider * @covers ::require_room_features * @covers ::require_room_user_features * @covers ::supports_room_user_features * @covers ::supports_room_features * @covers ::load_by_instance */ public function test_get_room_user_provider(): void { $this->resetAfterTest(); $course = $this->get_course(); $context = \core\context\course::instance($course->id); // Test the communication record exists. $communicationprocessor = processor::load_by_instance( context: $context, component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertInstanceOf(room_user_provider::class, $communicationprocessor->get_room_user_provider()); } /** * Test get avatar. * * @covers ::get_avatar * @covers ::load_by_instance * @covers ::get_avatar_filename * @covers ::set_avatar_filename * @covers ::set_avatar_synced_flag */ public function test_get_avatar(): void { $this->resetAfterTest(); $this->setAdminUser(); global $CFG; $course = $this->get_course('Sampleroom', 'none'); // Sample data. $communicationroomname = 'Sampleroom'; $selectedcommunication = 'communication_matrix'; $avatar = $this->create_communication_file( 'moodle_logo.jpg', 'moodle_logo.jpg', ); $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, provider: $selectedcommunication, ); $communication->create_and_configure_room($communicationroomname, $avatar); $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $avatar = $communicationprocessor->get_avatar(); $this->assertNotNull($avatar); $this->assertEquals($avatar->get_component(), 'core_communication'); $this->assertEquals($avatar->get_filearea(), 'avatar'); $this->assertEquals($avatar->get_itemid(), $communicationprocessor->get_id()); $this->assertEquals($avatar->get_filepath(), '/'); $this->assertEquals($avatar->get_filearea(), 'avatar'); $this->assertEquals($avatar->get_filename(), $communicationprocessor->get_avatar_filename()); // Change the avatar file name to something else and check it was set. $communicationprocessor->set_avatar_filename('newname.svg'); $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: 'core_course', instancetype: 'coursecommunication', instanceid: $course->id, ); $this->assertEquals($communicationprocessor->get_avatar_filename(), 'newname.svg'); } /** * Test if the provider is enabled and configured, or disabled. * * @covers ::is_provider_available */ public function test_is_provider_available(): void { $this->resetAfterTest(); $communicationprovider = 'communication_matrix'; $this->assertTrue(processor::is_provider_available($communicationprovider)); // Now test is disabling the plugin returns false. set_config('disabled', 1, $communicationprovider); $this->assertFalse(processor::is_provider_available($communicationprovider)); } /** * Test delete flagged user id's return correct users. * * @covers ::get_all_delete_flagged_userids */ public function test_get_all_delete_flagged_userids(): void { $this->resetAfterTest(); $course = $this->get_course('Sampleroom', 'none'); $user1 = $this->getDataGenerator()->create_user()->id; $user2 = $this->getDataGenerator()->create_user()->id; // Sample data. $communicationroomname = 'Sampleroom'; $selectedcommunication = 'communication_matrix'; $component = 'core_course'; $instancetype = 'coursecommunication'; // Load the communication api. $communication = \core_communication\api::load_by_instance( context: \core\context\course::instance($course->id), component: $component, instancetype: $instancetype, instanceid: $course->id, provider: $selectedcommunication, ); $communication->create_and_configure_room($communicationroomname); $communication->add_members_to_room([$user1, $user2]); // Now remove user1 from the room. $communication->remove_members_from_room([$user1]); // Test against the object. $communicationprocessor = processor::load_by_instance( context: \core\context\course::instance($course->id), component: $component, instancetype: $instancetype, instanceid: $course->id, ); $this->assertEquals([$user1], $communicationprocessor->get_all_delete_flagged_userids()); } } tests/behat/behat_communication.php000064400000003451152265255750013533 0ustar00. // phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/../../tests/communication_test_helper_trait.php'); /** * Class behat_communication for behat custom steps and configuration for communication api. * * @package core_communication * @category test * @copyright 2023 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_communication extends \behat_base { use \core_communication\communication_test_helper_trait; /** * Configure and enable communication experimental feature. * * @Given /^I enable communication experimental feature$/ */ public function enable_communication_experimental_feature(): void { $this->setup_communication_configs(); } /** * Disable communication experimental feature. * * @Given /^I disable communication experimental feature$/ */ public function disable_communication_experimental_feature(): void { $this->disable_communication_configs(); } } tests/behat/communication_configuration.feature000064400000015076152265255750016171 0ustar00@communication Feature: Access the communication configuration page As an editing teacher See dynamic form fields based on selected provider Background: Set up teachers and course for the communication confifiguration page Given I enable communication experimental feature And the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | teacher2 | Teacher | 2 | teacher2@example.com | And the following "courses" exist: | fullname | shortname | category | selectedcommunication | | Test course | Test course | 0 | none | And the following "course enrolments" exist: | user | course | role | | teacher1 | Test course | editingteacher | | teacher2 | Test course | teacher | Scenario: A teacher with the correct capability can access the communication configuration page Given I am on the "Test course" "Course" page logged in as "teacher1" When I navigate to "Communication" in current page administration Then I should see "Communication" Scenario: A teacher without the correct capability cannot access the communication configuration page Given I am on the "Test course" "Course" page logged in as "teacher2" Then "Communication" "link" should not exist in current page administration Scenario: I cannot see the communication link when communication provider is disabled Given I disable communication experimental feature And I am on the "Test course" "Course" page logged in as "teacher1" Then "Communication" "link" should not exist in current page administration @javascript Scenario: The communication form fields toggle dynamically when valid provider is set Given a Matrix mock server is configured And I am on the "Test course" "Course" page logged in as "teacher1" When I navigate to "Communication" in current page administration And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected Then I should see "Room name" And I should see "Room topic" @javascript Scenario: Changing the communication provider in the form fetches the correct data Given a Matrix mock server is configured And I am on the "Test course" "Course" page logged in as "teacher1" When I navigate to "Communication" in current page administration And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected And I should see "Room name" And I should see "Room topic" And I set the following fields to these values: | communication_matrixroomname | Matrix room | | matrixroomtopic | Matrix topic | And I click on "Save changes" "button" And I navigate to "Communication" in current page administration Then the field "Room name" matches value "Matrix room" And the field "Room topic" matches value "Matrix topic" And I set the following fields to these values: | selectedcommunication | communication_customlink | And I wait to be redirected And I should see "Room name" And I should not see "Room topic" And I should see "Custom link URL" And I set the following fields to these values: | communication_customlinkroomname | Custom link room | | customlinkurl | https://moodle.org | And I click on "Save changes" "button" And I navigate to "Communication" in current page administration And the field "Room name" matches value "Custom link room" And the field "Custom link URL" matches value "https://moodle.org" And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected And I should see "Room name" And I should see "Room topic" And the field "Room name" matches value "Matrix room" And the field "Room topic" matches value "Matrix topic" And I should not see "Custom link URL" And I set the following fields to these values: | selectedcommunication | communication_customlink | And I wait to be redirected And I should see "Room name" And I should see "Custom link URL" And the field "Room name" matches value "Custom link room" And the field "Custom link URL" matches value "https://moodle.org" And I should not see "Room topic" And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected And I click on "Save changes" "button" And I am on "Test course" course homepage with editing mode on And I navigate to "Settings" in current page administration And I set the following fields to these values: | Group mode | Separate groups | And I press "Save and display" And I navigate to "Communication" in current page administration And the field "Room name" matches value "Matrix room" And the field "Room topic" matches value "Matrix topic" And I press "Cancel" And I navigate to "Settings" in current page administration And I set the following fields to these values: | Group mode | Visible groups | And I navigate to "Communication" in current page administration And the field "Room name" matches value "Matrix room" And the field "Room topic" matches value "Matrix topic" @javascript Scenario: Emptying the room name field always sets course name as default Given a Matrix mock server is configured And I am on the "Test course" "Course" page logged in as "teacher1" When I navigate to "Communication" in current page administration And I set the following fields to these values: | selectedcommunication | communication_matrix | And I wait to be redirected And I should see "Room name" And I should see "Room topic" And I set the following fields to these values: | communication_matrixroomname | Matrix room | | matrixroomtopic | Matrix topic | And I click on "Save changes" "button" And I navigate to "Communication" in current page administration Then the field "Room name" matches value "Matrix room" And the field "Room topic" matches value "Matrix topic" And I set the following fields to these values: | communication_matrixroomname | | And I click on "Save changes" "button" And I navigate to "Communication" in current page administration And the field "Room name" matches value "Test course" configure.php000064400000006533152265255750007263 0ustar00. /** * Configure communication for a given instance. * * @package core_communication * @copyright 2023 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once('../config.php'); require_once('lib.php'); require_login(); $contextid = required_param('contextid', PARAM_INT); $instanceid = required_param('instanceid', PARAM_INT); $instancetype = required_param('instancetype', PARAM_TEXT); $component = required_param('component', PARAM_COMPONENT); $selectedcommunication = optional_param('selectedcommunication', null, PARAM_PLUGIN); $context = \core\context::instance_by_id($contextid); $instanceinfo = [ 'contextid' => $context->id, 'instanceid' => $instanceid, 'instancetype' => $instancetype, 'component' => $component, ]; // Requires communication to be enabled. if (!core_communication\api::is_available()) { throw new \moodle_exception('communicationdisabled', 'communication'); } // Attempt to load the communication instance with the provided params. $communication = \core_communication\api::load_by_instance( context: $context, component: $component, instancetype: $instancetype, instanceid: $instanceid, provider: $selectedcommunication, ); // No communication, no way this form can be used. if (!$communication) { throw new \moodle_exception('nocommunicationinstance', 'communication'); } // Set variables according to the component callback and use them on the page. [$instance, $context, $heading, $returnurl] = component_callback( $component, 'get_communication_instance_data', [$instanceid] ); // Set up the page. $PAGE->set_context($context); $PAGE->set_url('/communication/configure.php', $instanceinfo); $PAGE->set_title(get_string('communication', 'communication')); $PAGE->set_heading($heading); $PAGE->add_body_class('limitedwidth'); // Append the instance data before passing to form object. $instanceinfo['instancedata'] = $instance; // Get our form definitions. $form = new \core_communication\form\configure_form( context: $context, instanceid: $instanceinfo['instanceid'], instancetype: $instanceinfo['instancetype'], component: $instanceinfo['component'], selectedcommunication: $selectedcommunication, instancedata: $instanceinfo['instancedata'], ); if ($form->is_cancelled()) { redirect($returnurl); } else if ($data = $form->get_data()) { component_callback($component, 'update_communication_instance_data', [$data]); redirect($returnurl); } // Display the page contents. echo $OUTPUT->header(); echo $OUTPUT->heading(get_string('communication', 'communication'), 2); $form->display(); echo $OUTPUT->footer();