Proyectos de Subversion Moodle

Rev

Rev 1 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |

<?php
// 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 <http://www.gnu.org/licenses/>.

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 <safat.shahin@moodle.com>
 * @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();
    }
}