1<?php
2
3namespace MatrixPhp;
4
5use MatrixPhp\Crypto\OlmDevice;
6use MatrixPhp\Exceptions\MatrixRequestException;
7use MatrixPhp\Exceptions\MatrixUnexpectedResponse;
8use MatrixPhp\Exceptions\ValidationException;
9use phpDocumentor\Reflection\Types\Callable_;
10
11//TODO: port OLM bindings
12define('ENCRYPTION_SUPPORT', false);
13
14/**
15 * The client API for Matrix. For the raw HTTP calls, see MatrixHttpApi.
16 *
17 * Examples:
18 *
19 *    Create a new user and send a message::
20 *
21 *    $client = new MatrixClient("https://matrix.org");
22 *    $token = $client->registerWithPassword($username="foobar", $password="monkey");
23 *    $room = $client->createRoom("myroom");
24 *    $room->sendImage($fileLikeObject);
25 *
26 *    Send a message with an already logged in user::
27 *
28 *    $client = new MatrixClient("https://matrix.org", $token="foobar", $userId="@foobar:matrix.org");
29 *    $client->addListener(func);  // NB: event stream callback
30 *    $client->rooms[0]->addListener(func);  // NB: callbacks just for this room.
31 *    $room = $client->joinRoom("#matrix:matrix.org");
32 *    $response = $room->sendText("Hello!");
33 *    $response = $room->kick("@bob:matrix.org");
34 *
35 *    Incoming event callbacks (scopes)::
36 *
37 *    function userCallback($user, $incomingEvent);
38 *
39 *    function $roomCallback($room, $incomingEvent);
40 *
41 *    function globalCallback($incoming_event);
42 *
43 * @package MatrixPhp
44 */
45class MatrixClient {
46
47
48    /**
49     * @var int
50     */
51    protected $cacheLevel;
52
53    /**
54     * @var bool
55     */
56    protected $encryption;
57
58    /**
59     * @var null
60     */
61    protected $encryptionConf;
62
63    /**
64     * @var MatrixHttpApi
65     */
66    protected $api;
67    /**
68     * @var array
69     */
70    protected $listeners = [];
71    protected $presenceListeners = [];
72    protected $inviteListeners = [];
73    protected $leftListeners = [];
74    protected $ephemeralListeners = [];
75    protected $deviceId;
76    /**
77     * @var OlmDevice
78     */
79    protected $olmDevice;
80    protected $syncToken;
81    protected $syncFilter;
82    protected $syncThread;
83    protected $shouldListen = false;
84    /**
85     * @var int Time to wait before attempting a /sync request after failing.
86     */
87    protected $badSyncTimeoutLimit = 3600;
88    protected $rooms = [];
89    /**
90     * @var array A map from user ID to `User` object.
91     *          It is populated automatically while tracking the membership in rooms, and
92     *          shouldn't be modified directly.
93     *          A `User` object in this array is shared between all `Room`
94     *          objects where the corresponding user is joined.
95     */
96    public $users = [];
97    protected $userId;
98    protected $token;
99    protected $hs;
100
101    /**
102     * MatrixClient constructor.
103     * @param string $baseUrl The url of the HS preceding /_matrix. e.g. (ex: https://localhost:8008 )
104     * @param string|null $token If you have an access token supply it here.
105     * @param bool $validCertCheck Check the homeservers certificate on connections?
106     * @param int $syncFilterLimit
107     * @param int $cacheLevel One of Cache::NONE, Cache::SOME, or Cache::ALL
108     * @param bool $encryption Optional. Whether or not to enable end-to-end encryption support
109     * @param array $encryptionConf Optional. Configuration parameters for encryption.
110     * @throws Exceptions\MatrixException
111     * @throws Exceptions\MatrixHttpLibException
112     * @throws Exceptions\MatrixRequestException
113     * @throws ValidationException
114     */
115    public function __construct(string $baseUrl, ?string $token = null, bool $validCertCheck = true, int $syncFilterLimit = 20,
116                                int $cacheLevel = Cache::ALL, $encryption = false, $encryptionConf = []) {
117        if ($encryption && ENCRYPTION_SUPPORT) {
118            throw new ValidationException('Failed to enable encryption. Please make sure the olm library is available.');
119        }
120
121        $this->api = new MatrixHttpApi($baseUrl, $token);
122        $this->api->validateCertificate($validCertCheck);
123        $this->encryption = $encryption;
124        $this->encryptionConf = $encryptionConf;
125        if (!in_array($cacheLevel, Cache::$levels)) {
126            throw new ValidationException('$cacheLevel must be one of Cache::NONE, Cache::SOME, Cache::ALL');
127        }
128        $this->cacheLevel = $cacheLevel;
129        $this->syncFilter = sprintf('{ "room": { "timeline" : { "limit" : %d } } }', $syncFilterLimit);
130        if ($token) {
131            $response = $this->api->whoami();
132            $this->userId = $response['user_id'];
133            $this->sync();
134        }
135    }
136
137    /**
138     * Register a guest account on this HS.
139     *
140     * Note: HS must have guest registration enabled.
141     *
142     * @return string|null Access Token
143     * @throws Exceptions\MatrixException
144     */
145    public function registerAsGuest(): ?string {
146        $response = $this->api->register([], 'guest');
147
148        return $this->postRegistration($response);
149    }
150
151    /**
152     * Register for a new account on this HS.
153     *
154     * @param string $username Account username
155     * @param string $password Account password
156     * @return string|null Access Token
157     * @throws Exceptions\MatrixException
158     */
159    public function registerWithPassword(string $username, string $password): ?string {
160        $auth = ['type' => 'm.login.dummy'];
161        $response = $this->api->register($auth, 'user', false, $username, $password);
162
163        return $this->postRegistration($response);
164    }
165
166    protected function postRegistration(array $response) {
167        $this->userId = array_get($response, 'user_id');
168        $this->token = array_get($response, 'access_token');
169        $this->hs = array_get($response, 'home_server');
170        $this->api->setToken($this->token);
171        $this->sync();
172
173        return $this->token;
174    }
175
176    public function login(string $username, string $password, bool $sync = true,
177                          int $limit = 10, ?string $deviceId = null): ?string {
178        $response = $this->api->login('m.login.password', [
179            'identifier' => [
180                'type' => 'm.id.user',
181                'user' => $username,
182            ],
183            'user' => $username,
184            'password' => $password,
185            'device_id' => $deviceId
186        ]);
187
188        return $this->finalizeLogin($response, $sync, $limit);
189    }
190
191    /**
192     * Log in with a JWT.
193     *
194     * @param string $token JWT token.
195     * @param bool $refreshToken Whether to request a refresh token.
196     * @param bool $sync Indicator whether to sync.
197     * @param int $limit Sync limit.
198     *
199     * @return string Access token.
200     *
201     * @throws \MatrixPhp\Exceptions\MatrixException
202     */
203    public function jwtLogin(string $token, bool $refreshToken = false, bool $sync = true, int $limit = 10): ?string {
204        $response = $this->api->login(
205            'org.matrix.login.jwt',
206            [
207                'token' => $token,
208                'refresh_token' => $refreshToken,
209            ]
210        );
211
212        return $this->finalizeLogin($response, $sync, $limit);
213    }
214
215    /**
216     * Finalize login, e.g. after password or JWT login.
217     *
218     * @param array $response Login response array.
219     * @param bool $sync Sync flag.
220     * @param int $limit Sync limit.
221     *
222     * @return string Access token.
223     *
224     * @throws \MatrixPhp\Exceptions\MatrixException
225     * @throws \MatrixPhp\Exceptions\MatrixRequestException
226     */
227    protected function finalizeLogin(array $response, bool $sync, int $limit): string {
228        $this->userId = array_get($response, 'user_id');
229        $this->token = array_get($response, 'access_token');
230        $this->hs = array_get($response, 'home_server');
231        $this->api->setToken($this->token);
232        $this->deviceId = array_get($response, 'device_id');
233
234        if ($this->encryption) {
235            $this->olmDevice = new OlmDevice($this->api, $this->userId, $this->deviceId, $this->encryptionConf);
236            $this->olmDevice->uploadIdentityKeys();
237            $this->olmDevice->uploadOneTimeKeys();
238        }
239
240        if ($sync) {
241            $this->syncFilter = sprintf('{ "room": { "timeline" : { "limit" : %d } } }', $limit);
242            $this->sync();
243        }
244
245        return $this->token;
246    }
247
248    /**
249     * Logout from the homeserver.
250     *
251     * @throws Exceptions\MatrixException
252     */
253    public function logout() {
254        $this->stopListenerThread();
255        $this->api->logout();
256    }
257
258    /**
259     * Create a new room on the homeserver.
260     * TODO: move room creation/joining to User class for future application service usage
261     * NOTE: we may want to leave thin wrappers here for convenience
262     *
263     * @param string|null $alias The canonical_alias of the room.
264     * @param bool $isPublic The public/private visibility of the room.
265     * @param array $invitees A set of user ids to invite into the room.
266     * @return Room
267     * @throws Exceptions\MatrixException
268     */
269    public function createRoom(?string $alias = null, bool $isPublic = false, array $invitees = []): Room {
270        $response = $this->api->createRoom($alias, null, $isPublic, $invitees);
271
272        return $this->mkRoom($response['room_id']);
273    }
274
275    /**
276     * Join a room.
277     *
278     * @param string $roomIdOrAlias Room ID or an alias.
279     * @return Room
280     * @throws Exceptions\MatrixException
281     */
282    public function joinRoom(string $roomIdOrAlias): Room {
283        $response = $this->api->joinRoom($roomIdOrAlias);
284        $roomId = array_get($response, 'room_id', $roomIdOrAlias);
285
286        return $this->mkRoom($roomId);
287    }
288
289    public function getRooms(): array {
290        return $this->rooms;
291    }
292
293    /**
294     * Add a listener that will send a callback when the client recieves an event.
295     *
296     * @param callable $callback Callback called when an event arrives.
297     * @param string $eventType The event_type to filter for.
298     * @return string Unique id of the listener, can be used to identify the listener.
299     */
300    public function addListener(callable $callback, string $eventType) {
301        $listenerId = uniqid();
302        $this->listeners[] = [
303            'uid' => $listenerId,
304            'callback' => $callback,
305            'event_type' => $eventType,
306        ];
307
308        return $listenerId;
309    }
310
311    /**
312     * Remove listener with given uid.
313     *
314     * @param string $uid Unique id of the listener to remove.
315     */
316    public function removeListener(string $uid) {
317        $this->listeners = array_filter($this->listeners, function (array $a) use ($uid) {
318            return $a['uid'] != $uid;
319        });
320    }
321
322    /**
323     * Add a presence listener that will send a callback when the client receives a presence update.
324     *
325     * @param callable $callback Callback called when a presence update arrives.
326     * @return string Unique id of the listener, can be used to identify the listener.
327     */
328    public function addPresenceListener(callable $callback) {
329        $listenerId = uniqid();
330        $this->presenceListeners[$listenerId] = $callback;
331
332        return $listenerId;
333    }
334
335    /**
336     * Remove presence listener with given uid
337     *
338     * @param string $uid Unique id of the listener to remove
339     */
340    public function removePresenceListener(string $uid) {
341        unset($this->presenceListeners[$uid]);
342    }
343
344    /**
345     * Add an ephemeral listener that will send a callback when the client recieves an ephemeral event.
346     *
347     * @param callable $callback Callback called when an ephemeral event arrives.
348     * @param string|null $eventType Optional. The event_type to filter for.
349     * @return string Unique id of the listener, can be used to identify the listener.
350     */
351    public function addEphemeralListener(callable $callback, ?string $eventType = null) {
352        $listenerId = uniqid();
353        $this->ephemeralListeners[] = [
354            'uid' => $listenerId,
355            'callback' => $callback,
356            'event_type' => $eventType,
357        ];
358
359        return $listenerId;
360    }
361
362    /**
363     * Remove ephemeral listener with given uid.
364     *
365     * @param string $uid Unique id of the listener to remove.
366     */
367    public function removeEphemeralListener(string $uid) {
368        $this->ephemeralListeners = array_filter($this->ephemeralListeners, function (array $a) use ($uid) {
369            return $a['uid'] != $uid;
370        });
371    }
372
373    /**
374     * Add a listener that will send a callback when the client receives an invite.
375     * @param callable $callback Callback called when an invite arrives.
376     */
377    public function addInviteListener(callable $callback) {
378        $this->inviteListeners[] = $callback;
379    }
380
381    /**
382     * Add a listener that will send a callback when the client has left a room.
383     *
384     * @param callable $callback Callback called when the client has left a room.
385     */
386    public function addLeaveListener(callable $callback) {
387        $this->leftListeners[] = $callback;
388    }
389
390    public function listenForever(int $timeoutMs = 30000, ?callable $exceptionHandler = null, int $badSyncTimeout = 5) {
391        $tempBadSyncTimeout = $badSyncTimeout;
392        $this->shouldListen = true;
393        while ($this->shouldListen) {
394            try {
395                $this->sync($timeoutMs);
396                $tempBadSyncTimeout = $badSyncTimeout;
397            } catch (MatrixRequestException $e) {
398                // TODO: log error
399                if ($e->getHttpCode() >= 500) {
400                    sleep($badSyncTimeout);
401                    $tempBadSyncTimeout = min($tempBadSyncTimeout * 2, $this->badSyncTimeoutLimit);
402                } elseif (is_callable($exceptionHandler)) {
403                    $exceptionHandler($e);
404                } else {
405                    throw $e;
406                }
407            } catch (Exception $e) {
408                if (is_callable($exceptionHandler)) {
409                    $exceptionHandler($e);
410                } else {
411                    throw $e;
412                }
413            }
414            // TODO: we should also handle MatrixHttpLibException for retry in case no response
415        }
416    }
417
418    public function startListenerThread(int $timeoutMs = 30000, ?callable $exceptionHandler = null) {
419        // Just no
420    }
421
422    public function stopListenerThread() {
423        if ($this->syncThread) {
424            $this->shouldListen = false;
425        }
426    }
427
428    /**
429     * Upload content to the home server and recieve a MXC url.
430     * TODO: move to User class. Consider creating lightweight Media class.
431     *
432     * @param mixed $content The data of the content.
433     * @param string $contentType The mimetype of the content.
434     * @param string|null $filename Optional. Filename of the content.
435     * @return mixed
436     * @throws Exceptions\MatrixException
437     * @throws Exceptions\MatrixHttpLibException
438     * @throws MatrixRequestException If the upload failed for some reason.
439     * @throws MatrixUnexpectedResponse If the homeserver gave a strange response
440     */
441    public function upload($content, string $contentType, ?string $filename = null) {
442        try {
443            $response = $this->api->mediaUpload($content, $contentType, $filename);
444            if (array_key_exists('content_uri', $response)) {
445                return $response['content_uri'];
446            }
447
448            throw new MatrixUnexpectedResponse('The upload was successful, but content_uri wasn\'t found.');
449        } catch (MatrixRequestException $e) {
450            throw new MatrixRequestException($e->getHttpCode(), 'Upload failed: ' . $e->getMessage());
451        }
452    }
453
454    /**
455     * @param string $roomId
456     * @return Room
457     * @throws Exceptions\MatrixException
458     * @throws MatrixRequestException
459     */
460    private function mkRoom(string $roomId): Room {
461        $room = new Room($this, $roomId);
462        if ($this->encryption) {
463            try {
464                $event = $this->api->getStateEvent($roomId, "m.room.encryption");
465                if ($event['algorithm'] === "m.megolm.v1.aes-sha2") {
466                    $room->enableEncryption();
467                }
468            } catch (MatrixRequestException $e) {
469                if ($e->getHttpCode() != 404) {
470                    throw $e;
471                }
472            }
473        }
474        $this->rooms[$roomId] = $room;
475
476        return $room;
477    }
478
479    /**
480     * TODO better handling of the blocking I/O caused by update_one_time_key_counts
481     *
482     * @param int $timeoutMs
483     * @throws Exceptions\MatrixException
484     * @throws MatrixRequestException
485     */
486    public function sync(int $timeoutMs = 30000) {
487        $response = $this->api->sync($this->syncToken, $timeoutMs, $this->syncFilter);
488        $this->syncToken = $response['next_batch'];
489
490        foreach (array_get($response, 'presence.events', []) as $presenceUpdate) {
491            foreach ($this->presenceListeners as $cb) {
492                $cb($presenceUpdate);
493            }
494        }
495        foreach (array_get($response, 'rooms.invite', []) as $roomId => $inviteRoom) {
496            foreach ($this->inviteListeners as $cb) {
497                $cb($roomId, $inviteRoom['invite_state']);
498            }
499        }
500        foreach (array_get($response, 'rooms.leave', []) as $roomId => $leftRoom) {
501            foreach ($this->leftListeners as $cb) {
502                $cb($roomId, $leftRoom);
503            }
504            if (array_key_exists($roomId, $this->rooms)) {
505                unset($this->rooms[$roomId]);
506            }
507        }
508        if ($this->encryption && array_key_exists('device_one_time_keys_count', $response)) {
509            $this->olmDevice->updateOneTimeKeysCounts($response['device_one_time_keys_count']);
510        }
511        foreach (array_get($response, 'rooms.join', []) as $roomId => $syncRoom) {
512            foreach ($this->inviteListeners as $cb) {
513                $cb($roomId, $inviteRoom['invite_state']);
514            }
515            if (!array_key_exists($roomId, $this->rooms)) {
516                $this->mkRoom($roomId);
517            }
518            $room = $this->rooms[$roomId];
519            // TODO: the rest of this for loop should be in room object method
520            $room->prevBatch = $syncRoom["timeline"]["prev_batch"];
521            foreach (array_get($syncRoom, "state.events", []) as $event) {
522                $event['room_id'] = $roomId;
523                $room->processStateEvent($event);
524            }
525            foreach (array_get($syncRoom, "timeline.events", []) as $event) {
526                $event['room_id'] = $roomId;
527                $room->putEvent($event);
528
529                // TODO: global listeners can still exist but work by each
530                // $room.listeners[$uuid] having reference to global listener
531
532                // Dispatch for client (global) listeners
533                foreach ($this->listeners as $listener) {
534                    if ($listener['event_type'] == null || $listener['event_type'] == $event['type']) {
535                        $listener['callback']($event);
536                    }
537                }
538            }
539            foreach (array_get($syncRoom, "ephemeral.events", []) as $event) {
540                $event['room_id'] = $roomId;
541                $room->putEphemeralEvent($event);
542
543                // Dispatch for client (global) listeners
544                foreach ($this->ephemeralListeners as $listener) {
545                    if ($listener['event_type'] == null || $listener['event_type'] == $event['type']) {
546                        $listener['callback']($event);
547                    }
548                }
549            }
550        }
551    }
552
553    /**
554     * Remove mapping of an alias
555     *
556     * @param string $roomAlias The alias to be removed.
557     * @return bool True if the alias is removed, false otherwise.
558     * @throws Exceptions\MatrixException
559     * @throws Exceptions\MatrixHttpLibException
560     */
561    public function removeRoomAlias(string $roomAlias): bool {
562        try {
563            $this->api->removeRoomAlias($roomAlias);
564        } catch (MatrixRequestException $e) {
565            return false;
566        }
567
568        return true;
569    }
570
571    public function api(): MatrixHttpApi {
572        return $this->api;
573    }
574
575    public function userId():?string {
576        return $this->userId;
577    }
578
579    public function cacheLevel() {
580        return $this->cacheLevel;
581    }
582
583}
584