diff --git a/alembic/versions/fcb4ea0fce29_create_invite_table.py b/alembic/versions/fcb4ea0fce29_create_invite_table.py new file mode 100644 index 0000000..17a4415 --- /dev/null +++ b/alembic/versions/fcb4ea0fce29_create_invite_table.py @@ -0,0 +1,29 @@ +"""create invite table + +Revision ID: fcb4ea0fce29 +Revises: 90aa88820eab +Create Date: 2022-01-18 02:16:53.954662 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fcb4ea0fce29' +down_revision = '90aa88820eab' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('invite', + sa.Column('client', sa.String(255), sa.ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True), + sa.Column('room', sa.String(255), nullable=False, primary_key=True), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('inviter', sa.String(255), nullable=False) + ) + + +def downgrade(): + op.drop_table('invite') diff --git a/maubot/client.py b/maubot/client.py index 5495c5b..3ecfa18 100644 --- a/maubot/client.py +++ b/maubot/client.py @@ -13,21 +13,22 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Iterable, Optional, Set, Callable, Any, Awaitable, Union, TYPE_CHECKING +from typing import Dict, Iterable, List, Optional, Set, Callable, Any, Awaitable, Union, TYPE_CHECKING import asyncio import logging +from datetime import datetime from aiohttp import ClientSession from mautrix.errors import MatrixInvalidToken from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership, StateEvent, EventType, Filter, RoomFilter, RoomEventFilter, EventFilter, - PresenceState, StateFilter, DeviceID) + PresenceState, StateFilter, DeviceID, RoomID) from mautrix.client import InternalEventType from mautrix.client.state_store.sqlalchemy import SQLStateStore as BaseSQLStateStore from .lib.store_proxy import SyncStoreProxy -from .db import DBClient +from .db import DBClient, DBInvite from .matrix import MaubotMatrixClient try: @@ -65,9 +66,11 @@ class Client: remote_displayname: Optional[str] remote_avatar_url: Optional[ContentURI] + remote_rooms: Optional[List[RoomID]] def __init__(self, db_instance: DBClient) -> None: self.db_instance = db_instance + self.db_invites = DBInvite.get(self.id) self.cache[self.id] = self self.log = log.getChild(self.id) self.references = set() @@ -75,6 +78,7 @@ class Client: self.sync_ok = True self.remote_displayname = None self.remote_avatar_url = None + self.remote_rooms = None self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver, token=self.access_token, client_session=self.http_client, log=self.log, loop=self.loop, device_id=self.device_id, @@ -88,8 +92,7 @@ class Client: self.client.ignore_initial_sync = True self.client.ignore_first_sync = True self.client.presence = PresenceState.ONLINE if self.online else PresenceState.OFFLINE - if self.autojoin: - self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) + self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) self.client.add_event_handler(EventType.ROOM_TOMBSTONE, self._handle_tombstone) self.client.add_event_handler(InternalEventType.SYNC_ERRORED, self._set_sync_ok(False)) self.client.add_event_handler(InternalEventType.SYNC_SUCCESSFUL, self._set_sync_ok(True)) @@ -253,6 +256,13 @@ class Client: "avatar_url": self.avatar_url, "remote_displayname": self.remote_displayname, "remote_avatar_url": self.remote_avatar_url, + "invites": [{ + "client": i.client, + "room": i.room, + "date": i.date.timestamp(), + "inviter": i.inviter + } for i in self.invites], + "rooms": self.remote_rooms, "instances": [instance.to_dict() for instance in self.references], } @@ -275,11 +285,23 @@ class Client: self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring") return _, server = self.client.parse_user_id(evt.sender) + DBInvite.update_tombstone(user.id, evt.room_id, evt.content.replacement_room) await self.client.join_room(evt.content.replacement_room, servers=[server]) async def _handle_invite(self, evt: StrippedStateEvent) -> None: - if evt.state_key == self.id and evt.content.membership == Membership.INVITE: + if evt.state_key != self.id or evt.content.membership != Membership.INVITE: + return + if self.autojoin: await self.client.join_room(evt.room_id) + await self._update_remote_profile() + else: + self.log.debug('Inserting invite into database for later handling') + DBInvite( + client=self.id, + room=evt.room_id, + date=datetime.fromtimestamp(evt.timestamp//1000), + inviter=evt.sender + ).upsert() async def update_started(self, started: bool) -> None: if started is None or started == self.started: @@ -307,6 +329,26 @@ class Client: else: await self._update_remote_profile() + async def join_room(self, room: RoomID) -> None: + if room is None: + return + await self.client.join_room(room) + DBInvite(client=self.id, room=room).delete() + await self._update_remote_profile() + + async def leave_room(self, room: RoomID) -> None: + if room is None: + return + await self.client.leave_room(room) + DBInvite(client=self.id, room=room).delete() + await self._update_remote_profile() + + async def ignore_invite(self, room: RoomID) -> None: + if room is None: + return + DBInvite(client=self.id, room=room).delete() + await self._update_remote_profile() + async def update_access_details(self, access_token: Optional[str], homeserver: Optional[str], device_id: Optional[str] = None) -> None: if not access_token and not homeserver: @@ -354,6 +396,7 @@ class Client: async def _update_remote_profile(self) -> None: profile = await self.client.get_profile(self.id) self.remote_displayname, self.remote_avatar_url = profile.displayname, profile.avatar_url + self.remote_rooms = await self.client.get_joined_rooms() # region Properties @@ -412,12 +455,12 @@ class Client: def autojoin(self, value: bool) -> None: if value == self.db_instance.autojoin: return - if value: - self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) - else: - self.client.remove_event_handler(EventType.ROOM_MEMBER, self._handle_invite) self.db_instance.autojoin = value + @property + def invites(self) -> List[DBInvite]: + return DBInvite.get(self.id) + @property def online(self) -> bool: return self.db_instance.online diff --git a/maubot/db.py b/maubot/db.py index 3817882..35fe172 100644 --- a/maubot/db.py +++ b/maubot/db.py @@ -16,12 +16,13 @@ from typing import Iterable, Optional import logging import sys +from datetime import datetime -from sqlalchemy import Column, String, Boolean, ForeignKey, Text +from sqlalchemy import Column, String, Boolean, ForeignKey, Text, DateTime from sqlalchemy.engine.base import Engine import sqlalchemy as sql -from mautrix.types import UserID, FilterID, DeviceID, SyncToken, ContentURI +from mautrix.types import UserID, RoomID, FilterID, DeviceID, SyncToken, ContentURI from mautrix.util.db import Base from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile @@ -76,11 +77,42 @@ class DBClient(Base): return cls._select_one_or_none(cls.c.id == id) +class DBInvite(Base): + __tablename__ = "invite" + + client: UserID = Column(String(255), ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True) + room: RoomID = Column(String(255), nullable=False, primary_key=True) + date: datetime = Column(DateTime(), nullable=False) + inviter: UserID = Column(String(255), nullable=False) + + @classmethod + def all(cls) -> Iterable['DBInvite']: + return cls._select_all() + + @classmethod + def get(cls, client: UserID, room: RoomID) -> Optional['DBInvite']: + return cls._select_one_or_none(cls.c.client == client, cls.c.room == room) + + @classmethod + def get(cls, client: UserID) -> Iterable['DBInvite']: + return cls._select_all(cls.c.client == client) + + @classmethod + def update_tombstone(cls, client: UserID, old: RoomID, new: RoomID) -> Optional['DBInvite']: + invite = cls.get(client=client, room=old) + if not invite: + return None + invite.delete() + invite.room = new + invite.insert() + return invite + + def init(config: Config) -> Engine: db = sql.create_engine(config["database"]) Base.metadata.bind = db - for table in (DBPlugin, DBClient, RoomState, UserProfile): + for table in (DBPlugin, DBClient, RoomState, UserProfile, DBInvite): table.bind(db) if not db.has_table("alembic_version"): diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py index c74f9e9..5240397 100644 --- a/maubot/management/api/client.py +++ b/maubot/management/api/client.py @@ -89,11 +89,7 @@ async def _update_client(client: Client, data: dict, is_login: bool = False) -> except MatrixConnectionError: return resp.bad_client_connection_details except ValueError as e: - str_err = str(e) - if str_err.startswith("MXID mismatch"): - return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):]) - elif str_err.startswith("Device ID mismatch"): - return resp.device_id_mismatch(str(e)[len("Device ID mismatch: "):]) + return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):]) with client.db_instance.edit_mode(): await client.update_avatar_url(data.get("avatar_url", None)) await client.update_displayname(data.get("displayname", None)) @@ -155,3 +151,30 @@ async def clear_client_cache(request: web.Request) -> web.Response: return resp.client_not_found client.clear_cache() return resp.ok + + +@routes.post("/client/{id}/room/{room}/join") +async def join_room(request: web.Request) -> web.Response: + user_id = request.match_info.get("id", None) + room_id = request.match_info.get("room", None) + client = Client.get(user_id, None) + await client.join_room(room_id) + return resp.updated(client.to_dict(), is_login=False) + + +@routes.post("/client/{id}/room/{room}/leave") +async def leave_room(request: web.Request) -> web.Response: + user_id = request.match_info.get("id", None) + room_id = request.match_info.get("room", None) + client = Client.get(user_id, None) + await client.leave_room(room_id) + return resp.updated(client.to_dict(), is_login=False) + + +@routes.post("/client/{id}/room/{room}/ignore") +async def leave_room(request: web.Request) -> web.Response: + user_id = request.match_info.get("id", None) + room_id = request.match_info.get("room", None) + client = Client.get(user_id, None) + await client.ignore_invite(room_id) + return resp.updated(client.to_dict(), is_login=False) diff --git a/maubot/management/api/spec.yaml b/maubot/management/api/spec.yaml index 8529599..4432a0e 100644 --- a/maubot/management/api/spec.yaml +++ b/maubot/management/api/spec.yaml @@ -425,6 +425,93 @@ paths: $ref: '#/components/responses/Unauthorized' 404: $ref: '#/components/responses/ClientNotFound' + '/client/{id}/room/{room}/join': + parameters: + - name: id + in: path + description: The Matrix user ID of the client to join a room with + required: true + schema: + type: string + - name: room + in: path + description: The Matrix room to join + required: true + schema: + type: string + post: + operationId: join_room + summary: Join a Matrix room or accept an invite + tags: [Clients] + responses: + 200: + description: Joined the room + content: + application/json: + schema: + $ref: '#/components/schemas/MatrixClient' + 401: + $ref: '#/components/responses/Unauthorized' + 404: + $ref: '#/components/responses/ClientNotFound' + '/client/{id}/room/{room}/leave': + parameters: + - name: id + in: path + description: The Matrix user ID of the client to leave a room with + required: true + schema: + type: string + - name: id + in: path + description: The Matrix room to leave or reject + required: true + schema: + type: string + post: + operationId: leave_room + summary: Leave a Matrix room or reject an invite + tags: [Clients] + responses: + 200: + description: Left the room + content: + application/json: + schema: + $ref: '#/components/schemas/MatrixClient' + 401: + $ref: '#/components/responses/Unauthorized' + 404: + $ref: '#/components/responses/ClientNotFound' + '/client/{id}/room/{room}/ignore': + parameters: + - name: id + in: path + description: The Matrix user ID of the client to remove the invite from + required: true + schema: + type: string + - name: id + in: path + description: The Matrix room whose invite to remove + required: true + schema: + type: string + post: + operationId: ignore_invite + summary: Ignore a room invitation (do not answer it, but remove from the database) + tags: [Clients] + responses: + 200: + description: Removed the invitation. + content: + application/json: + schema: + $ref: '#/components/schemas/MatrixClient' + 401: + $ref: '#/components/responses/Unauthorized' + 404: + $ref: '#/components/responses/ClientNotFound' /client/auth/servers: get: operationId: get_client_auth_servers @@ -710,6 +797,36 @@ components: type: string example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU' description: The content URI of the avatar for this client. + rooms: + type: array + readOnly: true + description: List of room IDs of currently joined room. + items: + type: string + example: '!foobar:example.org' + invites: + type: array + readOnly: true + description: List of pending invites. + items: + type: object + properties: + client: + type: string + example: '@foobar:example.org' + description: The client that was invited to a room. + room: + type: string + example: '!foobar:example.org' + description: The room the client was invited to. + date: + type: integer + example: 1642546523 + description: UNIX timestamp when the invite was created. + inviter: + type: string + example: '@bar:example.org' + description: The user who invited this client to the room. instances: type: array readOnly: true diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js index d6a30dd..d797989 100644 --- a/maubot/management/frontend/src/api.js +++ b/maubot/management/frontend/src/api.js @@ -233,6 +233,30 @@ export async function clearClientCache(id) { return await resp.json() } +export async function joinRoom(id, room) { + const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/join`, { + headers: getHeaders(), + method: "POST", + }) + return await resp.json() +} + +export async function leaveRoom(id, room) { + const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/leave`, { + headers: getHeaders(), + method: "POST", + }) + return await resp.json() +} + +export async function ignoreInvite(id, room) { + const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/ignore`, { + headers: getHeaders(), + method: "POST", + }) + return await resp.json() +} + export const getClientAuthServers = () => defaultGet("/client/auth/servers") export async function doClientAuth(server, type, username, password) { @@ -253,5 +277,5 @@ export default { getInstanceDatabase, queryInstanceDatabase, getPlugins, getPlugin, uploadPlugin, deletePlugin, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, clearClientCache, - getClientAuthServers, doClientAuth, + joinRoom, leaveRoom, ignoreInvite, getClientAuthServers, doClientAuth, } diff --git a/maubot/management/frontend/src/pages/dashboard/Client.js b/maubot/management/frontend/src/pages/dashboard/Client.js index 806e112..5cbb4b6 100644 --- a/maubot/management/frontend/src/pages/dashboard/Client.js +++ b/maubot/management/frontend/src/pages/dashboard/Client.js @@ -64,7 +64,7 @@ class Client extends BaseMainView { get entryKeys() { return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id", - "sync", "autojoin", "online", "enabled", "started"] + "sync", "autojoin", "online", "enabled", "started", "rooms", "invites"] } get initialState() { @@ -81,6 +81,11 @@ class Client extends BaseMainView { enabled: true, online: true, started: false, + rooms: [], + invites: [], + joiningRooms: [], + ignoringRooms: [], + leavingRooms: [], instances: [], @@ -102,6 +107,9 @@ class Client extends BaseMainView { delete client.clearingCache delete client.error delete client.instances + delete client.joiningRooms + delete client.ignoringRooms + delete client.leavingRooms return client } @@ -188,6 +196,75 @@ class Client extends BaseMainView { } } + joinRoom = async room => { + if (this.state.joiningRooms.includes(room)) { + return + } + this.setState({ + joiningRooms: this.state.joiningRooms.concat([room]), + }) + const resp = await api.joinRoom(this.state.id, room) + if (resp.id) { + this.setState({ + joiningRooms: this.state.joiningRooms.filter(jr => jr !== room), + invites: resp.invites, + rooms: resp.rooms, + error: "", + }) + } else { + this.setState({ + joiningRooms: this.state.joiningRooms.filter(jr => jr !== room), + error: resp.error, + }) + } + } + + leaveRoom = async room => { + if (this.state.leavingRooms.includes(room)) { + return + } + this.setState({ + leavingRooms: this.state.leavingRooms.concat([room]), + }) + const resp = await api.leaveRoom(this.state.id, room) + if (resp.id) { + this.setState({ + leavingRooms: this.state.leavingRooms.filter(jr => jr !== room), + invites: resp.invites, + rooms: resp.rooms, + error: "", + }) + } else { + this.setState({ + leavingRooms: this.state.leavingRooms.filter(jr => jr !== room), + error: resp.error, + }) + } + } + + ignoreInvite = async room => { + if (this.state.ignoringRooms.includes(room)) { + return + } + this.setState({ + ignoringRooms: this.state.ignoringRooms.concat([room]), + }) + const resp = await api.ignoreInvite(this.state.id, room) + if (resp.id) { + this.setState({ + ignoringRooms: this.state.ignoringRooms.filter(jr => jr !== room), + invites: resp.invites, + rooms: resp.rooms, + error: "", + }) + } else { + this.setState({ + ignoringRooms: this.state.ignoringRooms.filter(jr => jr !== room), + error: resp.error, + }) + } + } + get loading() { return this.state.saving || this.state.startingOrStopping || this.clearingCache || this.state.deleting @@ -325,6 +402,54 @@ class Client extends BaseMainView {
{this.state.error}
+ renderRoomsAndInvites = () => <> +
+

Joined Rooms

+ + {this.state.rooms.map((room, i) => + + + + , + )} +
{room} + +
+ {this.state.rooms.length === 0 ? Not in any rooms. : ""} +

Pending Invites

+ + {this.state.invites.map((invite, i) => + + + + , + )} +
{invite.room} + + + +
+ {this.state.invites.length === 0 ? + No pending invites. : ""} +
+ + render() { return <>
@@ -333,6 +458,7 @@ class Client extends BaseMainView { {this.renderPreferences()} {this.renderPrefButtons()} {this.renderInstances()} + {this.renderRoomsAndInvites()}
diff --git a/maubot/management/frontend/src/style/base/elements.sass b/maubot/management/frontend/src/style/base/elements.sass index 4e671f4..6eef6fa 100644 --- a/maubot/management/frontend/src/style/base/elements.sass +++ b/maubot/management/frontend/src/style/base/elements.sass @@ -50,12 +50,38 @@ background-color: $background-dark !important color: $text-color +=error-color-button() + background-color: $error-light + color: $inverted-text-color + &:hover:not(:disabled) + background-color: $error + + &:disabled.disabled-bg + background-color: $background-dark !important + color: $text-color + +=warning-color-button() + background-color: $warning + color: $inverted-text-color + &:hover:not(:disabled) + background-color: $warning-dark + + &:disabled.disabled-bg + background-color: $background-dark !important + color: $text-color + .button +button &.main-color +main-color-button + &.error-color + +error-color-button + + &.warning-color + +warning-color-button + =button-group() width: 100% display: flex diff --git a/maubot/management/frontend/src/style/base/vars.sass b/maubot/management/frontend/src/style/base/vars.sass index 70a7e50..897aecc 100644 --- a/maubot/management/frontend/src/style/base/vars.sass +++ b/maubot/management/frontend/src/style/base/vars.sass @@ -24,6 +24,7 @@ $error: #B71C1C $error-dark: #7F0000 $error-light: #F05545 $warning: orange +$warning-dark: #bf7b00 $border-color: #DDD $text-color: #212121 diff --git a/maubot/management/frontend/src/style/index.sass b/maubot/management/frontend/src/style/index.sass index 87ad984..69b7e51 100644 --- a/maubot/management/frontend/src/style/index.sass +++ b/maubot/management/frontend/src/style/index.sass @@ -26,6 +26,7 @@ @import pages/mixins/upload-container @import pages/mixins/instancelist +@import pages/mixins/roomlist @import pages/login @import pages/dashboard diff --git a/maubot/management/frontend/src/style/pages/client/index.sass b/maubot/management/frontend/src/style/pages/client/index.sass index 54e0c67..c656377 100644 --- a/maubot/management/frontend/src/style/pages/client/index.sass +++ b/maubot/management/frontend/src/style/pages/client/index.sass @@ -36,6 +36,9 @@ > div.instances +instancelist + > div.rooms + +roomlist + input.fingerprint font-family: "Fira Code", monospace font-size: 0.8em diff --git a/maubot/management/frontend/src/style/pages/mixins/roomlist.sass b/maubot/management/frontend/src/style/pages/mixins/roomlist.sass new file mode 100644 index 0000000..e33753b --- /dev/null +++ b/maubot/management/frontend/src/style/pages/mixins/roomlist.sass @@ -0,0 +1,37 @@ +=roomlist() + margin: 1rem 0 + + > h3 + margin: .5rem + width: 100% + + > span.no-rooms + margin: .5rem + font-size: .875rem + font-weight: lighter + + > table.rooms-list + width: 100% + margin: .5rem + border-collapse: collapse + text-align: left + + td.right + display: flex + justify-content: flex-end + align-content: flex-end + flex-direction: row + display: flex + + .button + margin: .1rem + height: 31px + width: 48px + + > .spinner + +thick-spinner + +white-spinner + width: 16px + + > tr + border-top: 1px solid $border-color