mantis-matrix-integration – Rev 1

Subversion Repositories:
Rev:
<?php

namespace MatrixPhp;

use MatrixPhp\Crypto\OlmDevice;
use MatrixPhp\Exceptions\MatrixRequestException;
use MatrixPhp\Exceptions\MatrixUnexpectedResponse;
use MatrixPhp\Exceptions\ValidationException;
use phpDocumentor\Reflection\Types\Callable_;

//TODO: port OLM bindings
define('ENCRYPTION_SUPPORT', false);

/**
 * The client API for Matrix. For the raw HTTP calls, see MatrixHttpApi.
 *
 * Examples:
 *
 *    Create a new user and send a message::
 *
 *    $client = new MatrixClient("https://matrix.org");
 *    $token = $client->registerWithPassword($username="foobar", $password="monkey");
 *    $room = $client->createRoom("myroom");
 *    $room->sendImage($fileLikeObject);
 *
 *    Send a message with an already logged in user::
 *
 *    $client = new MatrixClient("https://matrix.org", $token="foobar", $userId="@foobar:matrix.org");
 *    $client->addListener(func);  // NB: event stream callback
 *    $client->rooms[0]->addListener(func);  // NB: callbacks just for this room.
 *    $room = $client->joinRoom("#matrix:matrix.org");
 *    $response = $room->sendText("Hello!");
 *    $response = $room->kick("@bob:matrix.org");
 *
 *    Incoming event callbacks (scopes)::
 *
 *    function userCallback($user, $incomingEvent);
 *
 *    function $roomCallback($room, $incomingEvent);
 *
 *    function globalCallback($incoming_event);
 *
 * @package MatrixPhp
 */
class MatrixClient {


    /**
     * @var int
     */
    protected $cacheLevel;

    /**
     * @var bool
     */
    protected $encryption;

    /**
     * @var MatrixHttpApi
     */
    protected $api;
    /**
     * @var array
     */
    protected $listeners = [];
    protected $presenceListeners = [];
    protected $inviteListeners = [];
    protected $leftListeners = [];
    protected $ephemeralListeners = [];
    protected $deviceId;
    /**
     * @var OlmDevice
     */
    protected $olmDevice;
    protected $syncToken;
    protected $syncFilter;
    protected $syncThread;
    protected $shouldListen = false;
    /**
     * @var int Time to wait before attempting a /sync request after failing.
     */
    protected $badSyncTimeoutLimit = 3600;
    protected $rooms = [];
    /**
     * @var array A map from user ID to `User` object.
     *          It is populated automatically while tracking the membership in rooms, and
     *          shouldn't be modified directly.
     *          A `User` object in this array is shared between all `Room`
     *          objects where the corresponding user is joined.
     */
    public $users = [];
    protected $userId;
    protected $token;
    protected $hs;

    /**
     * MatrixClient constructor.
     * @param string $baseUrl The url of the HS preceding /_matrix. e.g. (ex: https://localhost:8008 )
     * @param string|null $token If you have an access token supply it here.
     * @param bool $validCertCheck Check the homeservers certificate on connections?
     * @param int $syncFilterLimit
     * @param int $cacheLevel One of Cache::NONE, Cache::SOME, or Cache::ALL
     * @param bool $encryption Optional. Whether or not to enable end-to-end encryption support
     * @param array $encryptionConf Optional. Configuration parameters for encryption.
     * @throws Exceptions\MatrixException
     * @throws Exceptions\MatrixHttpLibException
     * @throws Exceptions\MatrixRequestException
     * @throws ValidationException
     */
    public function __construct(
        string $baseUrl,
        ?string $token = null,
        bool $validCertCheck = true,
        int $syncFilterLimit = 20,
        int $cacheLevel = Cache::ALL,
        $encryption = false,
        protected $encryptionConf = [],
    ) {
        // @phpstan-ignore-next-line
        if ($encryption && ENCRYPTION_SUPPORT) {
            throw new ValidationException('Failed to enable encryption. Please make sure the olm library is available.');
        }

        $this->api = new MatrixHttpApi($baseUrl, $token);
        $this->api->validateCertificate($validCertCheck);
        $this->encryption = $encryption;
        if (!in_array($cacheLevel, Cache::$levels)) {
            throw new ValidationException('$cacheLevel must be one of Cache::NONE, Cache::SOME, Cache::ALL');
        }
        $this->cacheLevel = $cacheLevel;
        $this->syncFilter = sprintf('{ "room": { "timeline" : { "limit" : %d } } }', $syncFilterLimit);
        if ($token) {
            $response = $this->api->whoami();
            $this->userId = $response['user_id'];
            $this->sync();
        }
    }

    /**
     * Register a guest account on this HS.
     *
     * Note: HS must have guest registration enabled.
     *
     * @return string|null Access Token
     * @throws Exceptions\MatrixException
     */
    public function registerAsGuest(): ?string {
        $response = $this->api->register([], 'guest');

        return $this->postRegistration($response);
    }

    /**
     * Register for a new account on this HS.
     *
     * @param string $username Account username
     * @param string $password Account password
     * @return string|null Access Token
     * @throws Exceptions\MatrixException
     */
    public function registerWithPassword(string $username, string $password): ?string {
        $auth = ['type' => 'm.login.dummy'];
        $response = $this->api->register($auth, 'user', false, $username, $password);

        return $this->postRegistration($response);
    }

    protected function postRegistration(array $response) {
        $this->userId = array_get($response, 'user_id');
        $this->token = array_get($response, 'access_token');
        $this->hs = array_get($response, 'home_server');
        $this->api->setToken($this->token);
        $this->sync();

        return $this->token;
    }

    public function login(string $username, string $password, bool $sync = true,
                          int $limit = 10, ?string $deviceId = null): ?string {
        $response = $this->api->login('m.login.password', [
            'identifier' => [
                'type' => 'm.id.user',
                'user' => $username,
            ],
            'user' => $username,
            'password' => $password,
            'device_id' => $deviceId
        ]);

        return $this->finalizeLogin($response, $sync, $limit);
    }

    /**
     * Log in with a JWT.
     *
     * @param string $token JWT token.
     * @param bool $refreshToken Whether to request a refresh token.
     * @param bool $sync Indicator whether to sync.
     * @param int $limit Sync limit.
     *
     * @return string Access token.
     *
     * @throws \MatrixPhp\Exceptions\MatrixException
     */
    public function jwtLogin(string $token, bool $refreshToken = false, bool $sync = true, int $limit = 10): ?string {
        $response = $this->api->login(
            'org.matrix.login.jwt',
            [
                'token' => $token,
                'refresh_token' => $refreshToken,
            ]
        );

        return $this->finalizeLogin($response, $sync, $limit);
    }

    /**
     * Finalize login, e.g. after password or JWT login.
     *
     * @param array $response Login response array.
     * @param bool $sync Sync flag.
     * @param int $limit Sync limit.
     *
     * @return string Access token.
     *
     * @throws \MatrixPhp\Exceptions\MatrixException
     * @throws \MatrixPhp\Exceptions\MatrixRequestException
     */
    protected function finalizeLogin(array $response, bool $sync, int $limit): string {
        $this->userId = array_get($response, 'user_id');
        $this->token = array_get($response, 'access_token');
        $this->hs = array_get($response, 'home_server');
        $this->api->setToken($this->token);
        $this->deviceId = array_get($response, 'device_id');

        if ($this->encryption) {
            $this->olmDevice = new OlmDevice($this->api, $this->userId, $this->deviceId, $this->encryptionConf);
            $this->olmDevice->uploadIdentityKeys();
            $this->olmDevice->uploadOneTimeKeys();
        }

        if ($sync) {
            $this->syncFilter = sprintf('{ "room": { "timeline" : { "limit" : %d } } }', $limit);
            $this->sync();
        }

        return $this->token;
    }

    /**
     * Logout from the homeserver.
     *
     * @throws Exceptions\MatrixException
     */
    public function logout() {
        $this->stopListenerThread();
        $this->api->logout();
    }

    /**
     * Create a new room on the homeserver.
     * TODO: move room creation/joining to User class for future application service usage
     * NOTE: we may want to leave thin wrappers here for convenience
     *
     * @param string|null $alias The canonical_alias of the room.
     * @param bool $isPublic The public/private visibility of the room.
     * @param array $invitees A set of user ids to invite into the room.
     * @return Room
     * @throws Exceptions\MatrixException
     */
    public function createRoom(?string $alias = null, bool $isPublic = false, array $invitees = []): Room {
        $response = $this->api->createRoom($alias, null, $isPublic, $invitees);

        return $this->mkRoom($response['room_id']);
    }

    /**
     * Join a room.
     *
     * @param string $roomIdOrAlias Room ID or an alias.
     * @return Room
     * @throws Exceptions\MatrixException
     */
    public function joinRoom(string $roomIdOrAlias): Room {
        $response = $this->api->joinRoom($roomIdOrAlias);
        $roomId = array_get($response, 'room_id', $roomIdOrAlias);

        return $this->mkRoom($roomId);
    }

    public function getRooms(): array {
        return $this->rooms;
    }

    /**
     * Add a listener that will send a callback when the client recieves an event.
     *
     * @param callable $callback Callback called when an event arrives.
     * @param string $eventType The event_type to filter for.
     * @return string Unique id of the listener, can be used to identify the listener.
     */
    public function addListener(callable $callback, string $eventType) {
        $listenerId = uniqid();
        $this->listeners[] = [
            'uid' => $listenerId,
            'callback' => $callback,
            'event_type' => $eventType,
        ];

        return $listenerId;
    }

    /**
     * Remove listener with given uid.
     *
     * @param string $uid Unique id of the listener to remove.
     */
    public function removeListener(string $uid) {
        $this->listeners = array_filter($this->listeners, function (array $a) use ($uid) {
            return $a['uid'] != $uid;
        });
    }

    /**
     * Add a presence listener that will send a callback when the client receives a presence update.
     *
     * @param callable $callback Callback called when a presence update arrives.
     * @return string Unique id of the listener, can be used to identify the listener.
     */
    public function addPresenceListener(callable $callback) {
        $listenerId = uniqid();
        $this->presenceListeners[$listenerId] = $callback;

        return $listenerId;
    }

    /**
     * Remove presence listener with given uid
     *
     * @param string $uid Unique id of the listener to remove
     */
    public function removePresenceListener(string $uid) {
        unset($this->presenceListeners[$uid]);
    }

    /**
     * Add an ephemeral listener that will send a callback when the client recieves an ephemeral event.
     *
     * @param callable $callback Callback called when an ephemeral event arrives.
     * @param string|null $eventType Optional. The event_type to filter for.
     * @return string Unique id of the listener, can be used to identify the listener.
     */
    public function addEphemeralListener(callable $callback, ?string $eventType = null) {
        $listenerId = uniqid();
        $this->ephemeralListeners[] = [
            'uid' => $listenerId,
            'callback' => $callback,
            'event_type' => $eventType,
        ];

        return $listenerId;
    }

    /**
     * Remove ephemeral listener with given uid.
     *
     * @param string $uid Unique id of the listener to remove.
     */
    public function removeEphemeralListener(string $uid) {
        $this->ephemeralListeners = array_filter($this->ephemeralListeners, function (array $a) use ($uid) {
            return $a['uid'] != $uid;
        });
    }

    /**
     * Add a listener that will send a callback when the client receives an invite.
     * @param callable $callback Callback called when an invite arrives.
     */
    public function addInviteListener(callable $callback) {
        $this->inviteListeners[] = $callback;
    }

    /**
     * Add a listener that will send a callback when the client has left a room.
     *
     * @param callable $callback Callback called when the client has left a room.
     */
    public function addLeaveListener(callable $callback) {
        $this->leftListeners[] = $callback;
    }

    public function listenForever(int $timeoutMs = 30000, ?callable $exceptionHandler = null, int $badSyncTimeout = 5) {
        $tempBadSyncTimeout = $badSyncTimeout;
        $this->shouldListen = true;
        // @phpstan-ignore-next-line
        while ($this->shouldListen) {
            try {
                $this->sync($timeoutMs);
                $tempBadSyncTimeout = $badSyncTimeout;
            } catch (MatrixRequestException $e) {
                // TODO: log error
                if ($e->getHttpCode() >= 500) {
                    sleep($badSyncTimeout);
                    $tempBadSyncTimeout = min($tempBadSyncTimeout * 2, $this->badSyncTimeoutLimit);
                } elseif (is_callable($exceptionHandler)) {
                    $exceptionHandler($e);
                } else {
                    throw $e;
                }
            } catch (\Exception $e) {
                if (is_callable($exceptionHandler)) {
                    $exceptionHandler($e);
                } else {
                    throw $e;
                }
            }
            // TODO: we should also handle MatrixHttpLibException for retry in case no response
        }
    }

    public function startListenerThread(int $timeoutMs = 30000, ?callable $exceptionHandler = null) {
        // Just no
    }

    public function stopListenerThread() {
        if ($this->syncThread) {
            $this->shouldListen = false;
        }
    }

    /**
     * Upload content to the home server and recieve a MXC url.
     * TODO: move to User class. Consider creating lightweight Media class.
     *
     * @param mixed $content The data of the content.
     * @param string $contentType The mimetype of the content.
     * @param string|null $filename Optional. Filename of the content.
     * @return mixed
     * @throws Exceptions\MatrixException
     * @throws Exceptions\MatrixHttpLibException
     * @throws MatrixRequestException If the upload failed for some reason.
     * @throws MatrixUnexpectedResponse If the homeserver gave a strange response
     */
    public function upload($content, string $contentType, ?string $filename = null) {
        try {
            $response = $this->api->mediaUpload($content, $contentType, $filename);
            if (array_key_exists('content_uri', $response)) {
                return $response['content_uri'];
            }

            throw new MatrixUnexpectedResponse('The upload was successful, but content_uri wasn\'t found.');
        } catch (MatrixRequestException $e) {
            throw new MatrixRequestException($e->getHttpCode(), 'Upload failed: ' . $e->getMessage());
        }
    }

    /**
     * @param string $roomId
     * @return Room
     * @throws Exceptions\MatrixException
     * @throws MatrixRequestException
     */
    private function mkRoom(string $roomId): Room {
        $room = new Room($this, $roomId);
        if ($this->encryption) {
            try {
                $event = $this->api->getStateEvent($roomId, "m.room.encryption");
                if ($event['algorithm'] === "m.megolm.v1.aes-sha2") {
                    $room->enableEncryption();
                }
            } catch (MatrixRequestException $e) {
                if ($e->getHttpCode() != 404) {
                    throw $e;
                }
            }
        }
        $this->rooms[$roomId] = $room;

        return $room;
    }

    /**
     * TODO better handling of the blocking I/O caused by update_one_time_key_counts
     *
     * @param int $timeoutMs
     * @throws Exceptions\MatrixException
     * @throws MatrixRequestException
     */
    public function sync(int $timeoutMs = 30000) {
        $response = $this->api->sync($this->syncToken, $timeoutMs, $this->syncFilter);
        $this->syncToken = $response['next_batch'];

        foreach (array_get($response, 'presence.events', []) as $presenceUpdate) {
            foreach ($this->presenceListeners as $cb) {
                $cb($presenceUpdate);
            }
        }
        foreach (array_get($response, 'rooms.invite', []) as $roomId => $inviteRoom) {
            foreach ($this->inviteListeners as $cb) {
                $cb($roomId, $inviteRoom['invite_state']);
            }
        }
        foreach (array_get($response, 'rooms.leave', []) as $roomId => $leftRoom) {
            foreach ($this->leftListeners as $cb) {
                $cb($roomId, $leftRoom);
            }
            if (array_key_exists($roomId, $this->rooms)) {
                unset($this->rooms[$roomId]);
            }
        }
        if ($this->encryption && array_key_exists('device_one_time_keys_count', $response)) {
            $this->olmDevice->updateOneTimeKeysCounts($response['device_one_time_keys_count']);
        }
        foreach (array_get($response, 'rooms.join', []) as $roomId => $syncRoom) {
            if (!empty($inviteRoom)) {
                foreach ($this->inviteListeners as $cb) {
                    $cb($roomId, $inviteRoom['invite_state']);
                }
            }
            if (!array_key_exists($roomId, $this->rooms)) {
                $this->mkRoom($roomId);
            }
            $room = $this->rooms[$roomId];
            // TODO: the rest of this for loop should be in room object method
            $room->prevBatch = $syncRoom["timeline"]["prev_batch"];
            foreach (array_get($syncRoom, "state.events", []) as $event) {
                $event['room_id'] = $roomId;
                $room->processStateEvent($event);
            }
            foreach (array_get($syncRoom, "timeline.events", []) as $event) {
                $event['room_id'] = $roomId;
                $room->putEvent($event);

                // TODO: global listeners can still exist but work by each
                // $room.listeners[$uuid] having reference to global listener

                // Dispatch for client (global) listeners
                foreach ($this->listeners as $listener) {
                    if ($listener['event_type'] == null || $listener['event_type'] == $event['type']) {
                        $listener['callback']($event);
                    }
                }
            }
            foreach (array_get($syncRoom, "ephemeral.events", []) as $event) {
                $event['room_id'] = $roomId;
                $room->putEphemeralEvent($event);

                // Dispatch for client (global) listeners
                foreach ($this->ephemeralListeners as $listener) {
                    if ($listener['event_type'] == null || $listener['event_type'] == $event['type']) {
                        $listener['callback']($event);
                    }
                }
            }
        }
    }

    /**
     * Remove mapping of an alias
     *
     * @param string $roomAlias The alias to be removed.
     * @return bool True if the alias is removed, false otherwise.
     * @throws Exceptions\MatrixException
     * @throws Exceptions\MatrixHttpLibException
     */
    public function removeRoomAlias(string $roomAlias): bool {
        try {
            $this->api->removeRoomAlias($roomAlias);
        } catch (MatrixRequestException $e) {
            return false;
        }

        return true;
    }

    public function api(): MatrixHttpApi {
        return $this->api;
    }

    public function userId():?string {
        return $this->userId;
    }

    public function cacheLevel() {
        return $this->cacheLevel;
    }

}