From 2d8347e2bc191f00f0342f16749ebc2f76d3b3a9 Mon Sep 17 00:00:00 2001 From: Michael Hagen Date: Sun, 5 May 2024 08:41:36 -0400 Subject: [PATCH 1/2] Feat: Add Spaces Support The rate at which messages populate seems slow, but that might be an issue with my machine. More testing to be done --- mautrix_googlechat/commands/__init__.py | 2 +- mautrix_googlechat/commands/spaces.py | 60 +++++++++++++++++++ mautrix_googlechat/config.py | 2 + mautrix_googlechat/db/puppet.py | 6 ++ mautrix_googlechat/db/upgrade/__init__.py | 1 + .../db/upgrade/v11_add_space_mxid_to_user.py | 30 ++++++++++ mautrix_googlechat/db/user.py | 14 +++-- mautrix_googlechat/example-config.yaml | 9 ++- mautrix_googlechat/portal.py | 27 +++++++++ mautrix_googlechat/user.py | 60 ++++++++++++++++++- 10 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 mautrix_googlechat/commands/spaces.py create mode 100644 mautrix_googlechat/db/upgrade/v11_add_space_mxid_to_user.py diff --git a/mautrix_googlechat/commands/__init__.py b/mautrix_googlechat/commands/__init__.py index 25d9ef7..e0f58e9 100644 --- a/mautrix_googlechat/commands/__init__.py +++ b/mautrix_googlechat/commands/__init__.py @@ -1 +1 @@ -from . import auth +from . import auth, spaces \ No newline at end of file diff --git a/mautrix_googlechat/commands/spaces.py b/mautrix_googlechat/commands/spaces.py new file mode 100644 index 0000000..2674419 --- /dev/null +++ b/mautrix_googlechat/commands/spaces.py @@ -0,0 +1,60 @@ +import logging + +from mautrix.bridge.commands import CommandEvent,HelpSection, command_handler +from mautrix.types import EventType + +from .. import puppet as pu +from .. import portal as po + + +SECTION_SPACES = HelpSection("Miscellaneous", 30, "") + +@command_handler( + needs_auth=True, + management_only=False, + help_section=SECTION_SPACES, + help_text="Synchronize your personal filtering space", +) +async def sync_space(evt: CommandEvent): + if not evt.bridge.config["bridge.space_support.enable"]: + await evt.reply("Spaces are not enabled on this instance of the bridge") + return + + await evt.sender.create_or_update_space() + + if not evt.sender.space_mxid: + await evt.reply("Failed to create or update space") + return + + async for portal in po.Portal.all(): + # Print the portal id and number of peer_type=chat portals + if not portal.mxid: + logging.debug(f"Portal {portal} has no mxid") + continue + + logging.debug(f"Adding chat {portal.mxid} to user's space ({evt.sender.space_mxid})") + try: + await evt.bridge.az.intent.send_state_event( + evt.sender.space_mxid, + EventType.SPACE_CHILD, + {"via": [evt.bridge.config["homeserver.domain"]], "suggested": True}, + state_key=str(portal.mxid), + ) + except Exception: + logging.warning( + f"Failed to add chat {portal.mxid} to user's space ({evt.sender.space_mxid})" + ) + # This will probably not work with gchat + #if portal.peer_type not in ("chat", "channel"): + # logging.debug(f"Adding puppet {portal.tgid} to user's space") + # puppet = await pu.Puppet.get_by_tgid(portal.tgid, create=False) + # if not puppet: + # continue + # try: + # await puppet.intent.ensure_joined(evt.sender.space_mxid) + # except Exception as e: + # logging.warning( + # f"Failed to join {puppet.mxid} to user's space ({evt.sender.space_mxid}): {e}" + # ) + + await evt.reply("Synced space") \ No newline at end of file diff --git a/mautrix_googlechat/config.py b/mautrix_googlechat/config.py index e83a1f7..89f160a 100644 --- a/mautrix_googlechat/config.py +++ b/mautrix_googlechat/config.py @@ -38,6 +38,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_googlechat/db/puppet.py b/mautrix_googlechat/db/puppet.py index 4231a4b..931d8e0 100644 --- a/mautrix_googlechat/db/puppet.py +++ b/mautrix_googlechat/db/puppet.py @@ -82,6 +82,12 @@ async def get_all_with_custom_mxid(cls) -> list[Puppet]: q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''" rows = await cls.db.fetch(q) return [cls._from_row(row) for row in rows] + + @classmethod + async def all(cls) -> list[Puppet]: + q = f"SELECT {cls.columns} FROM puppet''" + rows = await cls.db.fetch(q) + return [cls._from_row(row) for row in rows] @property def _values(self): diff --git a/mautrix_googlechat/db/upgrade/__init__.py b/mautrix_googlechat/db/upgrade/__init__.py index 737892e..13f5205 100644 --- a/mautrix_googlechat/db/upgrade/__init__.py +++ b/mautrix_googlechat/db/upgrade/__init__.py @@ -13,4 +13,5 @@ v08_web_app_auth, v09_web_app_ua, v10_store_microsecond_timestamp, + v11_add_space_mxid_to_user, ) diff --git a/mautrix_googlechat/db/upgrade/v11_add_space_mxid_to_user.py b/mautrix_googlechat/db/upgrade/v11_add_space_mxid_to_user.py new file mode 100644 index 0000000..946fda4 --- /dev/null +++ b/mautrix_googlechat/db/upgrade/v11_add_space_mxid_to_user.py @@ -0,0 +1,30 @@ +# mautrix-googlechat - A Matrix-Google Chat puppeting bridge +# Copyright (C) 2023 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="Add space MXID to User") +async def upgrade_v6(conn: Connection): + create_table_queries = [ + """ + ALTER TABLE "user" ADD COLUMN space_mxid TEXT + """, + ] + + for query in create_table_queries: + await conn.execute(query) \ No newline at end of file diff --git a/mautrix_googlechat/db/user.py b/mautrix_googlechat/db/user.py index eacc3e7..b774195 100644 --- a/mautrix_googlechat/db/user.py +++ b/mautrix_googlechat/db/user.py @@ -38,6 +38,7 @@ class User: user_agent: str | None notice_room: RoomID | None revision: int | None + space_mxid: RoomID | None @classmethod def _from_row(cls, row: Record | None) -> User | None: @@ -51,7 +52,7 @@ def _from_row(cls, row: Record | None) -> User | None: @classmethod async def all_logged_in(cls) -> list[User]: q = ( - 'SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" ' + 'SELECT mxid, gcid, cookies, user_agent, notice_room, revision, space_mxid FROM "user" ' "WHERE cookies IS NOT NULL" ) rows = await cls.db.fetch(q) @@ -60,7 +61,7 @@ async def all_logged_in(cls) -> list[User]: @classmethod async def get_by_gcid(cls, gcid: str) -> User | None: q = """ - SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" WHERE gcid=$1 + SELECT mxid, gcid, cookies, user_agent, notice_room, revision, space_mxid FROM "user" WHERE gcid=$1 """ row = await cls.db.fetchrow(q, gcid) return cls._from_row(row) @@ -68,7 +69,7 @@ async def get_by_gcid(cls, gcid: str) -> User | None: @classmethod async def get_by_mxid(cls, mxid: UserID) -> User | None: q = """ - SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" WHERE mxid=$1 + SELECT mxid, gcid, cookies, user_agent, notice_room, revision, space_mxid FROM "user" WHERE mxid=$1 """ row = await cls.db.fetchrow(q, mxid) return cls._from_row(row) @@ -82,12 +83,13 @@ def _values(self): self.user_agent, self.notice_room, self.revision, + self.space_mxid, ) async def insert(self) -> None: q = ( - 'INSERT INTO "user" (mxid, gcid, cookies, user_agent, notice_room, revision) ' - "VALUES ($1, $2, $3, $4, $5, $6)" + 'INSERT INTO "user" (mxid, gcid, cookies, user_agent, notice_room, revision, space_mxid) ' + "VALUES ($1, $2, $3, $4, $5, $6, $7)" ) await self.db.execute(q, *self._values) @@ -96,7 +98,7 @@ async def delete(self) -> None: async def save(self) -> None: q = ( - 'UPDATE "user" SET gcid=$2, cookies=$3, user_agent=$4, notice_room=$5, revision=$6 ' + 'UPDATE "user" SET gcid=$2, cookies=$3, user_agent=$4, notice_room=$5, revision=$6, space_mxid=$7 ' "WHERE mxid=$1" ) await self.db.execute(q, *self._values) diff --git a/mautrix_googlechat/example-config.yaml b/mautrix_googlechat/example-config.yaml index 45b23cb..dd22d2f 100644 --- a/mautrix_googlechat/example-config.yaml +++ b/mautrix_googlechat/example-config.yaml @@ -91,6 +91,13 @@ bridge: # Displayname template for Google Chat users. # {full_name}, {first_name}, {last_name} and {email} are replaced with names. displayname_template: "{full_name} (Google Chat)" + # 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: "Google Chat" # The prefix for commands. Only required in non-management rooms. command_prefix: "!gc" @@ -172,7 +179,7 @@ bridge: # verified - Require manual per-device verification # (currently only possible by modifying the `trust` column in the `crypto_device` database table). verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix. + # Minimum level for which the bridge should send keys to when bridging messages from Google Chat to Matrix. receive: unverified # Minimum level that the bridge should accept for incoming Matrix messages. send: unverified diff --git a/mautrix_googlechat/portal.py b/mautrix_googlechat/portal.py index 8770be1..36b622a 100644 --- a/mautrix_googlechat/portal.py +++ b/mautrix_googlechat/portal.py @@ -571,6 +571,13 @@ async def _update_matrix_room(self, source: u.User, info: ChatInfo | None = None did_join = await puppet.intent.ensure_joined(self.mxid) if did_join and self.is_direct: await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + if source.space_mxid and self.mxid: + await self.az.intent.send_state_event( + source.space_mxid, + EventType.SPACE_CHILD, + {"via": [self.config["homeserver.domain"]], "suggested": True}, + state_key=str(self.mxid), + ) await self.update_info(source, info) async def update_matrix_room(self, source: u.User, info: ChatInfo | None = None) -> None: @@ -702,6 +709,18 @@ async def _create_matrix_room(self, source: u.User, info: ChatInfo | None = None 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_mxid: + self.log.debug(f"Adding chat {self.mxid} to user's space") + try: + await self.az.intent.send_state_event( + source.space_mxid, + EventType.SPACE_CHILD, + {"via": [self.config["homeserver.domain"]], "suggested": True}, + state_key=str(self.mxid), + ) + self.log.debug(f"Added chat {self.mxid} to user's space") + 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 @@ -1126,6 +1145,14 @@ async def handle_matrix_leave(self, user: u.User) -> None: " cleaning up and deleting..." ) await self.cleanup_and_delete() + + if user.space_mxid: + await self.az.intent.send_state_event( + user.space_mxid, + EventType.SPACE_CHILD, + {}, + state_key=str(self.mxid), + ) else: self.log.debug(f"{user.mxid} left portal to {self.gcid}") diff --git a/mautrix_googlechat/user.py b/mautrix_googlechat/user.py index 465f971..cce8392 100644 --- a/mautrix_googlechat/user.py +++ b/mautrix_googlechat/user.py @@ -30,7 +30,7 @@ googlechat_pb2 as googlechat, ) from mautrix.bridge import BaseUser, async_getter_lock -from mautrix.types import MessageType, RoomID, UserID +from mautrix.types import EventType, MessageType, RoomID, UserID from mautrix.util import background_task from mautrix.util.bridge_state import BridgeState, BridgeStateEvent from mautrix.util.opt_prometheus import Gauge, Histogram, async_time @@ -86,6 +86,7 @@ def __init__( cookies: Cookies | None = None, user_agent: str | None = None, notice_room: RoomID | None = None, + space_mxid: RoomID | None = None, ) -> None: super().__init__( mxid=mxid, @@ -94,6 +95,7 @@ def __init__( user_agent=user_agent, revision=revision, notice_room=notice_room, + space_mxid=space_mxid, ) BaseUser.__init__(self) self._notice_room_lock = asyncio.Lock() @@ -277,6 +279,7 @@ async def connect(self, cookies: Cookies | None = None, get_self: bool = False) self.log.exception("Failed to get own info after login") return False client_cookies = self.client.cookies + await self.create_or_update_space() if client_cookies != self.cookies: self.cookies = client_cookies await self.save() @@ -429,6 +432,61 @@ async def logout(self, is_manual: bool, error: UnexpectedStatusError | None = No self.name_future.set_exception(Exception("logged out")) self.name_future = self.loop.create_future() + # Spaces support + 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_mxid: + await self.az.intent.send_state_event( + self.space_mxid, EventType.ROOM_AVATAR, avatar_state_event_content + ) + await self.az.intent.send_state_event( + self.space_mxid, EventType.ROOM_NAME, name_state_event_content + ) + else: + self.log.debug( + f"Creating space for {self.gcid}, 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, + }, + ], + ) + # Allow room creation in space + #await self.az.intent.send_state_event( + # room, EventType.ROOM_POWER_LEVELS, {"users_default": 100, "events_default": 0} + #) + + # Add space_mxid to self + self.space_mxid = 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}") + # Ensure that the user is invited and joined to the space. + try: + puppet = await pu.Puppet.get_by_custom_mxid(self.mxid) + if puppet and puppet.is_real_user: + await puppet.intent.ensure_joined(self.space_mxid) + except Exception: + self.log.warning(f"Failed to add user to the space {self.space_mxid}") + async def on_connect(self) -> None: self.connected = True if not self._skip_on_connect: From 9b105c7d9c5322baaff219e4d9777b7e4a7e0752 Mon Sep 17 00:00:00 2001 From: Michael Hagen Date: Sun, 5 May 2024 08:43:56 -0400 Subject: [PATCH 2/2] Create docker-publish.yml --- .github/workflows/docker-publish.yml | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..f45a9ae --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,81 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 + with: + cosign-release: 'v2.1.1' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max