matrix-php-sdk/src/Room.php
2022-06-23 21:20:33 +10:00

895 lines
29 KiB
PHP

<?php
namespace Aryess\PhpMatrixSdk;
use Aryess\PhpMatrixSdk\Exceptions\MatrixRequestException;
use function GuzzleHttp\default_ca_bundle;
use http\Exception;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
/**
* Call room-specific functions after joining a room from the client.
*
* NOTE: This should ideally be called from within the Client.
* NOTE: This does not verify the room with the Home Server.
*
* @package Aryess\PhpMatrixSdk
*/
class Room {
/** @var MatrixClient */
protected $client;
protected $roomId;
protected $listeners = [];
protected $stateListeners = [];
protected $ephemeralListeners = [];
protected $events = [];
protected $eventHistoryLimit = 20;
protected $name;
protected $canonicalAlias;
protected $aliases = [];
protected $topic;
protected $inviteOnly = false;
protected $guestAccess;
public $prevBatch;
protected $_members = [];
protected $membersDisplaynames = [
// $userId: $displayname,
];
protected $encrypted = false;
public function __construct(MatrixClient $client, string $roomId) {
Util::checkRoomId($roomId);
$this->roomId = $roomId;
$this->client = $client;
}
/**
* Set user profile within a room.
*
* This sets displayname and avatar_url for the logged in user only in a
* specific room. It does not change the user's global user profile.
*
* @param string|null $displayname
* @param string|null $avatarUrl
* @param string $reason
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function setUserProfile(?string $displayname = null, ?string $avatarUrl = null,
string $reason = "Changing room profile information") {
$member = $this->api()->getMembership($this->roomId, $this->client->userId());
if ($member['membership'] != 'join') {
throw new \Exception("Can't set profile if you have not joined the room.");
}
if (!$displayname) {
$displayname = $member["displayname"];
}
if (!$avatarUrl) {
$avatarUrl = $member["avatar_url"];
}
$this->api()->setMembership(
$this->roomId,
$this->client->userId(),
'join',
$reason,
[
"displayname" => $displayname,
"avatar_url" => $avatarUrl
]
);
}
/**
* Calculates the display name for a room.
*
* @return string
*/
public function displayName() {
if ($this->name) {
return $this->name;
} elseif ($this->canonicalAlias) {
return $this->canonicalAlias;
}
// Member display names without me
$members = array_reduce($this->getJoinedMembers(), function (array $all, User $u) {
if ($this->client->userId() != $u->userId()) {
$all[] = $u->getDisplayName($this);
}
return $all;
}, []);
sort($members);
switch (count($members)) {
case 0:
return 'Empty room';
case 1:
return $members[0];
case 2:
return sprintf("%s and %s", $members[0], $members[1]);
default:
return sprintf("%s and %d others.", $members[0], count($members));
}
}
/**
* Send a plain text message to the room.
*
* @param string $text
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendText(string $text) {
return $this->api()->sendMessage($this->roomId, $text);
}
public function getHtmlContent(string $html, ?string $body = null, string $msgType = 'm.text') {
return [
'body' => $body ?: strip_tags($html),
'msgtype' => $msgType,
'format' => "org.matrix.custom.html",
'formatted_body' => $html,
];
}
/**
* Send an html formatted message.
*
* @param string $html The html formatted message to be sent.
* @param string|null $body The unformatted body of the message to be sent.
* @param string $msgType
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendHtml(string $html, ?string $body = null, string $msgType = 'm.text') {
$content = $this->getHtmlContent($html, $body, $msgType);
return $this->api()->sendMessageEvent($this->roomId, 'm.room.message', $content);
}
/**
* @param string $type
* @param array $data
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function setAccountData(string $type, array $data) {
return $this->api()->setRoomAccountData($this->client->userId(), $this->roomId, $type, $data);
}
/**
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function getTags() {
return $this->api()->getUserTags($this->client->userId(), $this->roomId);
}
/**
* @param string $tag
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function removeTag(string $tag) {
return $this->api()->removeUserTag($this->client->userId(), $this->roomId, $tag);
}
/**
* @param string $tag
* @param float|null $order
* @param array $content
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function addTag(string $tag, ?float $order = null, array $content = []) {
return $this->api()->addUserTag($this->client->userId(), $this->roomId, $tag, $order, $content);
}
/**
* Send an emote (/me style) message to the room.
*
* @param string $text
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendEmote(string $text) {
return $this->api()->sendEmote($this->roomId, $text);
}
/**
* Send a pre-uploaded file to the room.
*
* See http://matrix.org/docs/spec/r0.4.0/client_server.html#m-file for fileinfo.
*
* @param string $url The mxc url of the file.
* @param string $name The filename of the image.
* @param array $fileinfo Extra information about the file
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendFile(string $url, string $name, array $fileinfo) {
return $this->api()->sendContent($this->roomId, $url, $name, 'm.file', $fileinfo);
}
/**
* Send a notice (from bot) message to the room.
*
* @param string $text
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendNotice(string $text) {
return $this->api()->sendNotice($this->roomId, $text);
}
/**
* Send a pre-uploaded image to the room.
*
* See http://matrix.org/docs/spec/r0.0.1/client_server.html#m-image for imageinfo
*
* @param string $url The mxc url of the image.
* @param string $name The filename of the image.
* @param array $fileinfo Extra information about the image.
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendImage(string $url, string $name, ?array $fileinfo) {
return $this->api()->sendContent($this->roomId, $url, $name, 'm.image', $fileinfo);
}
/**
* Send a location to the room.
* See http://matrix.org/docs/spec/client_server/r0.2.0.html#m-location for thumb_info
*
* @param string $geoUri The geo uri representing the location.
* @param string $name Description for the location.
* @param string|null $thumbUrl URL to the thumbnail of the location.
* @param array $thumbInfo Metadata about the thumbnail, type ImageInfo.
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendLocation(string $geoUri, string $name, ?string $thumbUrl = null, ?array $thumbInfo) {
return $this->api()->sendLocation($this->roomId, $geoUri, $name, $thumbUrl, $thumbInfo);
}
/**
* Send a pre-uploaded video to the room.
* See http://matrix.org/docs/spec/client_server/r0.2.0.html#m-video for videoinfo
*
* @param string $url The mxc url of the video.
* @param string $name The filename of the video.
* @param array $videoinfo Extra information about the video.
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendVideo(string $url, string $name, ?array $videoinfo) {
return $this->api()->sendContent($this->roomId, $url, $name, 'm.video', $videoinfo);
}
/**
* Send a pre-uploaded audio to the room.
* See http://matrix.org/docs/spec/client_server/r0.2.0.html#m-audio for audioinfo
*
* @param string $url The mxc url of the video.
* @param string $name The filename of the video.
* @param array $audioinfo Extra information about the video.
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function sendAudio(string $url, string $name, ?array $audioinfo) {
return $this->api()->sendContent($this->roomId, $url, $name, 'm.audio', $audioinfo);
}
/**
* Redacts the message with specified event_id for the given reason.
*
* See https://matrix.org/docs/spec/r0.0.1/client_server.html#id112
*
* @param string $eventId
* @param string|null $reason
* @return array|string
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws Exceptions\MatrixRequestException
*/
public function redactMessage(string $eventId, ?string $reason = null) {
return $this->api()->redactEvent($this->roomId, $eventId, $reason);
}
/**
* Add a callback handler for events going to this room.
*
* @param callable $cb (func(room, event)): Callback called when an event arrives.
* @param string|null $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 $cb, ?string $eventType = null) {
$listenerId = uniqid();
$this->listeners[] = [
'uid' => $listenerId,
'callback' => $cb,
'event_type' => $eventType,
];
return $listenerId;
}
/**
* Remove listener with given uid.
*
* @param string $uid
*/
public function removeListener(string $uid) {
$this->listeners = array_filter($this->listeners, function ($l) use ($uid) {
return $l['uid'] != $uid;
});
}
/**
* Add a callback handler for ephemeral events going to this room.
*
* @param callable $cb (func(room, event)): Callback called when an ephemeral event arrives.
* @param string|null $eventType The event_type to filter for.
* @return string Unique id of the listener, can be used to identify the listener.
*/
public function addEphemeralListener(callable $cb, ?string $eventType = null) {
$listenerId = uniqid();
$this->ephemeralListeners[] = [
'uid' => $listenerId,
'callback' => $cb,
'event_type' => $eventType,
];
return $listenerId;
}
/**
* Remove ephemeral listener with given uid.
*
* @param string $uid
*/
public function removeEphemeralListener(string $uid) {
$this->ephemeralListeners = array_filter($this->ephemeralListeners, function ($l) use ($uid) {
return $l['uid'] != $uid;
});
}
/**
* Add a callback handler for state events going to this room.
*
* @param callable $cb Callback called when an event arrives.
* @param string|null $eventType The event_type to filter for.
*/
public function addStateListener(callable $cb, ?string $eventType = null) {
$this->stateListeners[] = [
'callback' => $cb,
'event_type' => $eventType,
];
}
public function putEvent(array $event) {
$this->events[] = $event;
if (count($this->events) > $this->eventHistoryLimit) {
array_pop($this->events);
}
if (array_key_exists('state_event', $event)) {
$this->processStateEvent($event);
}
// Dispatch for room-specific listeners
foreach ($this->listeners as $l) {
if (!$l['event_type'] || $l['event_type'] == $event['event_type']) {
$l['cb']($this, $event);
}
}
}
public function putEphemeralEvent(array $event) {
// Dispatch for room-specific listeners
foreach ($this->ephemeralListeners as $l) {
if (!$l['event_type'] || $l['event_type'] == $event['event_type']) {
$l['cb']($this, $event);
}
}
}
/**
* Get the most recent events for this room.
*
* @return array
*/
public function getEvents(): array {
return $this->events;
}
/**
* Invite a user to this room.
*
* @param string $userId
* @return bool Whether invitation was sent.
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
*/
public function inviteUser(string $userId): bool {
try {
$this->api()->inviteUser($this->roomId, $userId);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Kick a user from this room.
*
* @param string $userId The matrix user id of a user.
* @param string $reason A reason for kicking the user.
* @return bool Whether user was kicked.
* @throws Exceptions\MatrixException
*/
public function kickUser(string $userId, string $reason = ''): bool {
try {
$this->api()->kickUser($this->roomId, $userId, $reason);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Ban a user from this room.
*
* @param string $userId The matrix user id of a user.
* @param string $reason A reason for banning the user.
* @return bool Whether user was banned.
* @throws Exceptions\MatrixException
*/
public function banUser(string $userId, string $reason = ''): bool {
try {
$this->api()->banUser($this->roomId, $userId, $reason);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Leave the room.
*
* @return bool Leaving the room was successful.
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
*/
public function leave() {
try {
$this->api()->leaveRoom($this->roomId);
$this->client->forgetRoom($this->roomId);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Updates $this->name and returns true if room name has changed.
* @return bool
* @throws Exceptions\MatrixException
*/
public function updateRoomName() {
try {
$response = $this->api()->getRoomName($this->roomId);
$newName = array_get($response, 'name', $this->name);
$this->name = $newName;
if ($this->name != $newName) {
$this->name = $newName;
return true;
}
} catch (MatrixRequestException $e) {
}
return false;
}
/**
* Return True if room name successfully changed.
*
* @param string $name
* @return bool
* @throws Exceptions\MatrixException
*/
public function setRoomName(string $name) {
try {
$this->api()->setRoomName($this->roomId, $name);
$this->name = $name;
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Send a state event to the room.
*
* @param string $eventType The type of event that you are sending.
* @param array $content An object with the content of the message.
* @param string $stateKey Optional. A unique key to identify the state.
* @throws Exceptions\MatrixException
*/
public function sendStateEvent(string $eventType, array $content, string $stateKey = '') {
$this->api()->sendStateEvent($this->roomId, $eventType, $content, $stateKey);
}
/**
* Updates $this->topic and returns true if room topic has changed.
*
* @return bool
* @throws Exceptions\MatrixException
*/
public function updateRoomTopic() {
try {
$response = $this->api()->getRoomTopic($this->roomId);
$oldTopic = $this->topic;
$this->topic = array_get($response, 'topic', $this->topic);
} catch (MatrixRequestException $e) {
return false;
}
return $oldTopic == $this->topic;
}
/**
* Return True if room topic successfully changed.
*
* @param string $topic
* @return bool
* @throws Exceptions\MatrixException
*/
public function setRoomTopic(string $topic) {
try {
$this->api()->setRoomTopic($this->roomId, $topic);
$this->topic = $topic;
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Get aliases information from room state.
*
* @return bool True if the aliases changed, False if not
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
*/
public function updateAliases() {
try {
$response = $this->api()->getRoomState($this->roomId);
$oldAliases = $this->aliases;
foreach ($response as $chunk) {
if ($aliases = array_get($chunk, 'content.aliases')) {
$this->aliases = $aliases;
return $this->aliases == $oldAliases;
}
}
} catch (MatrixRequestException $e) {
return false;
}
}
/**
* Add an alias to the room and return True if successful.
*
* @param string $alias
* @return bool
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
*/
public function addRoomAlias(string $alias) {
try {
$this->api()->setRoomAlias($this->roomId, $alias);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
public function getJoinedMembers() {
if ($this->_members) {
return array_values($this->_members);
}
$response = $this->api()->getRoomMembers($this->roomId);
foreach ($response['chunk'] as $event) {
if (array_get($event, 'event.membership') == 'join') {
$userId = $event['state_key'];
$this->addMember($userId, array_get($event, 'content.displayname'));
}
}
return array_values($this->_members);
}
protected function addMember(string $userId, ?string $displayname) {
if ($displayname) {
$this->membersDisplaynames[$userId] = $displayname;
}
if (array_key_exists($userId, $this->_members)) {
return;
}
if (array_key_exists($userId, $this->client->users)) {
$this->_members[$userId] = $this->client->users[$userId];
return;
}
$this->_members[$userId] = new User($this->api(), $userId, $displayname);
$this->client->users[$userId] = $this->_members[$userId];
}
/**
* Backfill handling of previous messages.
*
* @param bool $reverse When false messages will be backfilled in their original
* order (old to new), otherwise the order will be reversed (new to old).
* @param int $limit Number of messages to go back.
* @throws Exceptions\MatrixException
* @throws Exceptions\MatrixHttpLibException
* @throws MatrixRequestException
*/
public function backfillPreviousMessages(bool $reverse = false, int $limit = 10) {
$res = $this->api()->getRoomMessages($this->roomId, $this->prevBatch, 'b', $limit);
$events = $res['chunk'];
if (!$reverse) {
$events = array_reverse($events);
}
foreach ($events as $event) {
$this->putEvent($event);
}
}
/**
* Modify the power level for a subset of users
*
* @param array $users Power levels to assign to specific users, in the form
* {"@name0:host0": 10, "@name1:host1": 100, "@name3:host3", None}
* A level of None causes the user to revert to the default level
* as specified by users_default.
* @param int $userDefault Default power level for users in the room
* @return bool
* @throws Exceptions\MatrixException
*/
public function modifyUserPowerLevels(array $users = null, int $userDefault = null) {
try {
$content = $this->api()->getPowerLevels($this->roomId);
if ($userDefault) {
$content['user_default'] = $userDefault;
}
if ($users) {
if (array_key_exists('users', $content)) {
$content['users'] = array_merge($content['users'], $content);
} else {
$content['users'] = $users;
}
// Remove any keys with value null
foreach ($content['users'] as $user => $pl) {
if (!$pl) {
unset($content['users'][$user]);
}
}
}
$this->api()->setPowerLevels($this->roomId, $content);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Modifies room power level requirements.
*
* @param array $events Power levels required for sending specific event types,
* in the form {"m.room.whatever0": 60, "m.room.whatever2": None}.
* Overrides events_default and state_default for the specified
* events. A level of None causes the target event to revert to the
* default level as specified by events_default or state_default.
* @param array $extra Key/value pairs specifying the power levels required for
* various actions:
*
* - events_default(int): Default level for sending message events
* - state_default(int): Default level for sending state events
* - invite(int): Inviting a user
* - redact(int): Redacting an event
* - ban(int): Banning a user
* - kick(int): Kicking a user
* @return bool
* @throws Exceptions\MatrixException
*/
public function modifyRequiredPowerLevels(array $events = [], array $extra = []) {
try {
$content = $this->api()->getPowerLevels($this->roomId);
$content = array_merge($content, $extra);
foreach ($content as $k => $v) {
if (!$v) {
unset($content[$k]);
}
}
if ($events) {
if (array_key_exists('events', $content)) {
$content["events"] = array_merge($content["events"], $events);
} else {
$content["events"] = $events;
}
// Remove any keys with value null
foreach ($content['event'] as $event => $pl) {
if (!$pl) {
unset($content['event'][$event]);
}
}
}
$this->api()->setPowerLevels($this->roomId, $content);
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Set how the room can be joined.
*
* @param bool $inviteOnly If True, users will have to be invited to join
* the room. If False, anyone who knows the room link can join.
* @return bool True if successful, False if not
* @throws Exceptions\MatrixException
*/
public function setInviteOnly(bool $inviteOnly) {
$joinRule = $inviteOnly ? 'invite' : 'public';
try {
$this->api()->setJoinRule($this->roomId, $joinRule);
$this->inviteOnly = $inviteOnly;
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Set whether guests can join the room and return True if successful.
*
* @param bool $allowGuest
* @return bool
* @throws Exceptions\MatrixException
*/
public function setGuestAccess(bool $allowGuest) {
$guestAccess = $allowGuest ? 'can_join' : 'forbidden';
try {
$this->api()->setGuestAccess($this->roomId, $guestAccess);
$this->guestAccess = $allowGuest;
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
/**
* Enables encryption in the room.
*
* NOTE: Once enabled, encryption cannot be disabled.
*
* @return bool True if successful, False if not
* @throws Exceptions\MatrixException
*/
public function enableEncryption() {
try {
$this->sendStateEvent('m.room.encryption', ['algorithm' => 'm.megolm.v1.aes-sha2']);
$this->encrypted = true;
} catch (MatrixRequestException $e) {
return false;
}
return true;
}
public function processStateEvent(array $stateEvent) {
if (!array_key_exists('type', $stateEvent)) {
return;
}
$etype = $stateEvent['type'];
$econtent = $stateEvent['content'];
$clevel = $this->client->cacheLevel();
// Don't keep track of room state if caching turned off
if ($clevel >= Cache::SOME) {
switch ($etype) {
case 'm.room.name':
$this->name = array_get($econtent, 'name');
break;
case 'm.room.canonical_alias':
$this->canonicalAlias = array_get($econtent, 'alias');
break;
case 'm.room.topic':
$this->topic = array_get($econtent, 'topic');
break;
case 'm.room.aliases':
$this->aliases = array_get($econtent, 'aliases');
break;
case 'm.room.join_rules':
$this->inviteOnly = $econtent["join_rule"] == "invite";
break;
case 'm.room.guest_access':
$this->guestAccess = $econtent["guest_access"] == "can_join";
break;
case 'm.room.encryption':
$this->encrypted = array_get($econtent, 'algorithm') ? true : $this->encrypted;
break;
case 'm.room.member':
// tracking room members can be large e.g. #matrix:matrix.org
if ($clevel == Cache::ALL) {
if ($econtent['membership'] == 'join') {
$userId = $stateEvent['state_key'];
$this->addMember($userId, array_get($econtent, 'displayname'));
} elseif (in_array(econtent["membership"], ["leave", "kick", "invite"])) {
unset($this->_members[array_get($stateEvent, 'state_key')]);
}
}
break;
}
}
foreach ($this->stateListeners as $listener) {
if (!$listener['event_type'] || $listener['event_type'] == $stateEvent['type']) {
$listener['cb']($stateEvent);
}
}
}
public function getMembersDisplayNames(): array {
return $this->membersDisplaynames;
}
protected function api(): MatrixHttpApi {
return $this->client->api();
}
}