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