diff --git a/mautrix_facebook/config.py b/mautrix_facebook/config.py index 2640caf6..3f940ce2 100644 --- a/mautrix_facebook/config.py +++ b/mautrix_facebook/config.py @@ -77,6 +77,8 @@ def do_update(self, helper: ConfigUpdateHelper) -> None: copy("bridge.sync_direct_chat_list") copy("bridge.double_puppet_server_map") copy("bridge.double_puppet_allow_discovery") + copy("bridge.space_support.enable") + copy("bridge.space_support.name") if "bridge.login_shared_secret" in self: base["bridge.login_shared_secret_map"] = { base["homeserver.domain"]: self["bridge.login_shared_secret"] diff --git a/mautrix_facebook/db/upgrade/__init__.py b/mautrix_facebook/db/upgrade/__init__.py index ba76c5fd..b3c33678 100644 --- a/mautrix_facebook/db/upgrade/__init__.py +++ b/mautrix_facebook/db/upgrade/__init__.py @@ -15,4 +15,5 @@ v10_user_thread_sync_status, v11_user_thread_sync_done_flag, v12_puppet_contact_info_set, + v13_space_per_user, ) diff --git a/mautrix_facebook/db/upgrade/v13_space_per_user.py b/mautrix_facebook/db/upgrade/v13_space_per_user.py new file mode 100644 index 00000000..c5b98dab --- /dev/null +++ b/mautrix_facebook/db/upgrade/v13_space_per_user.py @@ -0,0 +1,23 @@ +# mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge. +# Copyright (C) 2022 Tulir Asokan, Sumner Evans +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from mautrix.util.async_db import Connection + +from . import upgrade_table + + +@upgrade_table.register(description="Store space in user table") +async def upgrade_v13(conn: Connection) -> None: + await conn.execute('ALTER TABLE "user" ADD COLUMN space_room TEXT') diff --git a/mautrix_facebook/db/user.py b/mautrix_facebook/db/user.py index c807da7f..b7a4d680 100644 --- a/mautrix_facebook/db/user.py +++ b/mautrix_facebook/db/user.py @@ -35,6 +35,7 @@ class User: fbid: int | None state: AndroidState | None notice_room: RoomID | None + space_room: RoomID | None seq_id: int | None connect_token_hash: bytes | None oldest_backfilled_thread_ts: int | None @@ -59,6 +60,7 @@ def _from_row(cls, row: Record | None) -> User | None: "fbid", "state", "notice_room", + "space_room", "seq_id", "connect_token_hash", "oldest_backfilled_thread_ts", @@ -92,6 +94,7 @@ def _values(self): self.fbid, self._state_json, self.notice_room, + self.space_room, self.seq_id, self.connect_token_hash, self.oldest_backfilled_thread_ts, @@ -102,7 +105,7 @@ def _values(self): async def insert(self) -> None: q = f""" INSERT INTO "user" ({self._columns}) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) """ await self.db.execute(q, *self._values) @@ -112,8 +115,8 @@ async def delete(self) -> None: async def save(self) -> None: q = """ UPDATE "user" - SET fbid=$2, state=$3, notice_room=$4, seq_id=$5, connect_token_hash=$6, - oldest_backfilled_thread_ts=$7, total_backfilled_portals=$8, thread_sync_completed=$9 + SET fbid=$2, state=$3, notice_room=$4, space_room=$5, seq_id=$6, connect_token_hash=$7, + oldest_backfilled_thread_ts=$8, total_backfilled_portals=$9, thread_sync_completed=$10 WHERE mxid=$1 """ await self.db.execute(q, *self._values) diff --git a/mautrix_facebook/example-config.yaml b/mautrix_facebook/example-config.yaml index eaa6be16..cd0370c4 100644 --- a/mautrix_facebook/example-config.yaml +++ b/mautrix_facebook/example-config.yaml @@ -113,6 +113,13 @@ bridge: # Localpart template of MXIDs for Facebook users. # {userid} is replaced with the user ID of the Facebook user. username_template: "facebook_{userid}" + # Settings for creating a space for every user. + space_support: + # Whether or not to enable creating a space per user and inviting the + # user (as well as all of the puppets) to that space. + enable: false + # The name of the space + name: "Facebook" # Displayname template for Facebook users. # {displayname} is replaced with the display name of the Facebook user # as defined below in displayname_preference. diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py index 09d37f0e..a2ee381a 100644 --- a/mautrix_facebook/portal.py +++ b/mautrix_facebook/portal.py @@ -502,6 +502,19 @@ async def _update_participant( await puppet.intent_for(self).ensure_joined(self.mxid, bot=self.main_intent) if puppet.fbid in nick_map and not puppet.is_real_user: await self.sync_per_room_nick(puppet, nick_map[puppet.fbid]) + + if source.space_room: + try: + await self.az.intent.invite_user( + source.space_room, puppet.custom_mxid or puppet.mxid + ) + await puppet.intent.join_room_by_id(source.space_room) + except Exception as e: + self.log.warning( + f"Failed to invite and join puppet {puppet.fbid} to " + f"space {source.space_room}: {e}" + ) + return changed async def _update_participants(self, source: u.User, info: graphql.Thread) -> bool: @@ -544,6 +557,14 @@ async def _update_matrix_room( if did_join and self.is_direct: await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + if source.space_room and self.mxid: + await self.az.intent.send_state_event( + source.space_room, + EventType.SPACE_CHILD, + {"via": [self.config["homeserver.domain"]], "suggested": True}, + state_key=str(self.mxid), + ) + info = await self.update_info(source, info) if not info: self.log.warning("Canceling _update_matrix_room as update_info didn't return info") @@ -709,6 +730,19 @@ async def _create_matrix_room( await self.az.intent.ensure_joined(self.mxid) except Exception: self.log.warning(f"Failed to add bridge bot to new private chat {self.mxid}") + + if source.space_room: + try: + await self.az.intent.send_state_event( + source.space_room, + EventType.SPACE_CHILD, + {"via": [self.config["homeserver.domain"]], "suggested": True}, + state_key=str(self.mxid), + ) + await self.az.intent.invite_user(source.space_room, source.mxid) + except Exception: + self.log.warning(f"Failed to add chat {self.mxid} to user's space") + await self.save() self.log.debug(f"Matrix room created: {self.mxid}") self.by_mxid[self.mxid] = self @@ -728,6 +762,20 @@ async def _create_matrix_room( exc_info=True, ) + if self.is_direct and puppet: + try: + did_join = await puppet.intent.join_room_by_id(self.mxid) + if did_join: + await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + if source.space_room: + await self.az.intent.invite_user(source.space_room, puppet.custom_mxid) + await puppet.intent.join_room_by_id(source.space_room) + except MatrixError: + self.log.debug( + "Failed to join custom puppet into newly created portal", + exc_info=True, + ) + if not self.is_direct: await self._update_participants(source, info) @@ -1563,6 +1611,14 @@ async def handle_matrix_leave(self, user: u.User) -> None: f"{user.mxid} was the recipient of this portal. Cleaning up and deleting..." ) await self.cleanup_and_delete() + + if user.space_room: + await self.az.intent.send_state_event( + user.space_room, + EventType.SPACE_CHILD, + {}, + state_key=str(self.mxid), + ) else: self.log.debug(f"{user.mxid} left portal to {self.fbid}") diff --git a/mautrix_facebook/user.py b/mautrix_facebook/user.py index d8044b7a..d160b3bf 100644 --- a/mautrix_facebook/user.py +++ b/mautrix_facebook/user.py @@ -37,15 +37,17 @@ ) from maufbapi.types import graphql, mqtt as mqtt_t from maufbapi.types.graphql.responses import Message, Thread -from mautrix.bridge import BaseUser, async_getter_lock +from mautrix.bridge import BaseUser, async_getter_lock, portal from mautrix.errors import MNotFound from mautrix.types import ( EventID, + EventType, MessageType, PresenceState, PushActionType, PushRuleKind, PushRuleScope, + RoomDirectoryVisibility, RoomID, TextMessageEventContent, UserID, @@ -127,6 +129,7 @@ class User(DBUser, BaseUser): seq_id: int | None _notice_room_lock: asyncio.Lock + _space_room_lock: asyncio.Lock _notice_send_lock: asyncio.Lock is_admin: bool permission_level: str @@ -152,6 +155,7 @@ def __init__( fbid: int | None = None, state: AndroidState | None = None, notice_room: RoomID | None = None, + space_room: RoomID | None = None, seq_id: int | None = None, connect_token_hash: bytes | None = None, oldest_backfilled_thread_ts: int | None = None, @@ -163,6 +167,7 @@ def __init__( fbid=fbid, state=state, notice_room=notice_room, + space_room=space_room, seq_id=seq_id, connect_token_hash=connect_token_hash, oldest_backfilled_thread_ts=oldest_backfilled_thread_ts, @@ -537,6 +542,8 @@ async def post_login(self, is_startup: bool, from_login: bool = False) -> None: except Exception: self.log.exception("Failed to automatically enable custom puppet") + await self._create_or_update_space() + # Backfill requests are handled synchronously so as not to overload the homeserver. # Users can configure their backfill stages to be more or less aggressive with backfilling # to try and avoid getting banned. @@ -997,6 +1004,45 @@ async def get_portal_with(self, puppet: pu.Puppet, create: bool = True) -> po.Po puppet.fbid, fb_receiver=self.fbid, create=create, fb_type=ThreadType.USER ) + async def _create_or_update_space(self): + if not self.config["bridge.space_support.enable"]: + return + + avatar_state_event_content = {"url": self.config["appservice.bot_avatar"]} + name_state_event_content = {"name": self.config["bridge.space_support.name"]} + + if self.space_room: + await self.az.intent.send_state_event( + self.space_room, EventType.ROOM_AVATAR, avatar_state_event_content + ) + await self.az.intent.send_state_event( + self.space_room, EventType.ROOM_NAME, name_state_event_content + ) + else: + self.log.debug(f"Creating space for {self.fbid}, inviting {self.mxid}") + room = await self.az.intent.create_room( + is_direct=False, + invitees=[self.mxid], + creation_content={"type": "m.space"}, + initial_state=[ + { + "type": str(EventType.ROOM_NAME), + "content": name_state_event_content, + }, + { + "type": str(EventType.ROOM_AVATAR), + "content": avatar_state_event_content, + }, + ], + ) + self.space_room = room + await self.save() + self.log.debug(f"Created space {room}") + try: + await self.az.intent.ensure_joined(room) + except Exception: + self.log.warning(f"Failed to add bridge bot to new space {room}") + # region Facebook event handling def start_listen(self) -> None: