diff --git a/mautrix_facebook/config.py b/mautrix_facebook/config.py index 818f1d94..13749392 100644 --- a/mautrix_facebook/config.py +++ b/mautrix_facebook/config.py @@ -69,6 +69,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 126e6ab6..fcc91aad 100644 --- a/mautrix_facebook/db/upgrade/__init__.py +++ b/mautrix_facebook/db/upgrade/__init__.py @@ -14,4 +14,5 @@ v09_portal_infinite_backfill, v10_user_thread_sync_status, v11_user_thread_sync_done_flag, + v12_space_per_user, ) diff --git a/mautrix_facebook/db/upgrade/v12_space_per_user.py b/mautrix_facebook/db/upgrade/v12_space_per_user.py new file mode 100644 index 00000000..ba7b6eac --- /dev/null +++ b/mautrix_facebook/db/upgrade/v12_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_v12(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 1f37eee6..296d11e4 100644 --- a/mautrix_facebook/example-config.yaml +++ b/mautrix_facebook/example-config.yaml @@ -102,13 +102,20 @@ manhole: # The list of UIDs who can be added to the whitelist. # If empty, any UIDs can be specified in the open-manhole command. whitelist: - - 0 + - 0 # Bridge config 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. @@ -121,8 +128,8 @@ bridge: # "nickname" # "own_nickname" (user-specific!) displayname_preference: - - name - - first_name + - name + - first_name # The prefix for commands. Only required in non-management rooms. command_prefix: "!fb" @@ -358,14 +365,14 @@ bridge: # $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com) # $message - The message content message_formats: - m.text: '$sender_displayname: $message' - m.notice: '$sender_displayname: $message' - m.emote: '* $sender_displayname $message' - m.file: '$sender_displayname sent a file' - m.image: '$sender_displayname sent an image' - m.audio: '$sender_displayname sent an audio file' - m.video: '$sender_displayname sent a video' - m.location: '$sender_displayname sent a location' + m.text: "$sender_displayname: $message" + m.notice: "$sender_displayname: $message" + m.emote: "* $sender_displayname $message" + m.file: "$sender_displayname sent a file" + m.image: "$sender_displayname sent an image" + m.audio: "$sender_displayname sent an audio file" + m.video: "$sender_displayname sent a video" + m.location: "$sender_displayname sent a location" facebook: device_seed: generate diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py index 3798d6fb..33ae7e91 100644 --- a/mautrix_facebook/portal.py +++ b/mautrix_facebook/portal.py @@ -487,6 +487,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: @@ -529,6 +542,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") @@ -692,6 +713,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 @@ -711,6 +745,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) @@ -1465,6 +1513,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 8ccf1968..b86bc789 100644 --- a/mautrix_facebook/user.py +++ b/mautrix_facebook/user.py @@ -30,15 +30,17 @@ from maufbapi.mqtt import Connect, Disconnect, MQTTNotConnected, MQTTNotLoggedIn, ProxyUpdate 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, @@ -115,6 +117,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 @@ -137,6 +140,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, @@ -148,6 +152,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, @@ -531,6 +536,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. @@ -986,6 +993,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: