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: