├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── python-lint.yml ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── ROADMAP.md ├── dev-requirements.txt ├── docker-run.sh ├── maugclib ├── README.md ├── __init__.py ├── channel.py ├── client.py ├── event.py ├── exceptions.py ├── googlechat.proto ├── googlechat_pb2.py ├── googlechat_pb2.pyi ├── http_utils.py ├── parsers.py └── pblite.py ├── mautrix_googlechat ├── __init__.py ├── __main__.py ├── commands │ ├── __init__.py │ ├── auth.py │ └── typehint.py ├── config.py ├── db │ ├── __init__.py │ ├── message.py │ ├── portal.py │ ├── puppet.py │ ├── reaction.py │ ├── upgrade │ │ ├── __init__.py │ │ ├── v00_latest_revision.py │ │ ├── v02_reactions.py │ │ ├── v03_store_gc_revision.py │ │ ├── v04_store_photo_hash.py │ │ ├── v05_rename_thread_columns.py │ │ ├── v06_space_description.py │ │ ├── v07_puppet_contact_info_set.py │ │ ├── v08_web_app_auth.py │ │ ├── v09_web_app_ua.py │ │ └── v10_store_microsecond_timestamp.py │ └── user.py ├── example-config.yaml ├── formatter │ ├── __init__.py │ ├── from_googlechat.py │ ├── from_matrix │ │ ├── __init__.py │ │ ├── gc_message.py │ │ └── parser.py │ ├── gc_url_preview.py │ └── util.py ├── get_version.py ├── matrix.py ├── portal.py ├── puppet.py ├── user.py ├── util │ ├── __init__.py │ └── color_log.py ├── version.py └── web │ ├── __init__.py │ └── auth.py ├── optional-requirements.txt ├── pyproject.toml ├── requirements.txt └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .codeclimate.yml 3 | *.png 4 | *.md 5 | .venv 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | max_line_length = 99 13 | indent_style = space 14 | 15 | [*.{yaml, yml}] 16 | indent_style = space 17 | 18 | [.pre-commit-config.yaml] 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | name: Python lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-python@v2 11 | with: 12 | python-version: "3.11" 13 | - uses: isort/isort-action@master 14 | with: 15 | sortPaths: "./maugclib ./mautrix_googlechat" 16 | - uses: psf/black@stable 17 | with: 18 | src: "./maugclib ./mautrix_googlechat" 19 | version: "23.1.0" 20 | - name: pre-commit 21 | run: | 22 | pip install pre-commit 23 | pre-commit run -av trailing-whitespace 24 | pre-commit run -av end-of-file-fixer 25 | pre-commit run -av check-yaml 26 | pre-commit run -av check-added-large-files 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | *.pyc 3 | *.egg-info 4 | /build 5 | /dist 6 | 7 | *.yaml 8 | !mautrix_googlechat/example-config.yaml 9 | !.pre-commit-config.yaml 10 | 11 | *.log 12 | *.log.* 13 | 14 | *.db* 15 | *.pickle 16 | *.bak 17 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/python.yml' 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - repo: https://github.com/psf/black 11 | rev: 23.1.0 12 | hooks: 13 | - id: black 14 | language_version: python3 15 | files: ^(maugclib|mautrix_googlechat)/.*\.pyi?$ 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 5.12.0 18 | hooks: 19 | - id: isort 20 | files: ^(maugclib|mautrix_googlechat)/.*\.pyi?$ 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.5.2 (2024-07-16) 2 | 3 | * Added support for authenticated media downloads. 4 | 5 | # v0.5.1 (2023-10-03) 6 | 7 | * Added support for double puppeting with arbitrary `as_token`s. 8 | See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info. 9 | * Added support for replies. 10 | * Fixed bridge disconnecting for no reason after 14 days of uptime. 11 | 12 | # v0.5.0 (2023-06-16) 13 | 14 | * Switched to web app API to make authentication work again. 15 | **This will require all users to relogin.** 16 | * Allowed thread bridging in non-thread-only chats. 17 | * Improved handling of getting logged out remotely. 18 | * Added options to automatically ratchet/delete megolm sessions to minimize 19 | access to old messages. 20 | * Added option to not set room name/avatar even in encrypted rooms. 21 | * Implemented appservice pinging using MSC2659. 22 | * Updated Docker image to Alpine 3.18. 23 | 24 | # v0.4.0 (2022-11-15) 25 | 26 | * Added support for bridging room mentions in both directions. 27 | * Updated formatter to insert Matrix displayname into mentions when bridging 28 | from Google Chat. This ensures that the Matrix user gets mentioned correctly. 29 | * Fixed images from Google Chat not being bridged with full resolution. 30 | * Added SQLite support (thanks to [@durin42] in [#74]). 31 | * Updated Docker image to Alpine 3.16. 32 | * Enabled appservice ephemeral events by default for new installations. 33 | * Existing bridges can turn it on by enabling `ephemeral_events` and disabling 34 | `sync_with_custom_puppets` in the config, then regenerating the registration 35 | file. 36 | * Added options to make encryption more secure. 37 | * The `encryption` -> `verification_levels` config options can be used to 38 | make the bridge require encrypted messages to come from cross-signed 39 | devices, with trust-on-first-use validation of the cross-signing master 40 | key. 41 | * The `encryption` -> `require` option can be used to make the bridge ignore 42 | any unencrypted messages. 43 | * Key rotation settings can be configured with the `encryption` -> `rotation` 44 | config. 45 | 46 | [@durin42]: https://github.com/durin42 47 | [#74]: https://github.com/mautrix/googlechat/pull/74 48 | 49 | # v0.3.3 (2022-06-03) 50 | 51 | * Switched to using native Matrix threads for bridging Google Chat threads. 52 | * Removed web login interface and added support for logging in inside Matrix. 53 | * The provisioning API is still available, but it has moved from `/login/api` 54 | to `/_matrix/provision/v1`. 55 | * Added error messages and optionally custom status events to detect when 56 | a message fails to bridge. 57 | 58 | # v0.3.2 (2022-04-19) 59 | 60 | **N.B.** This release drops support for old homeservers which don't support the 61 | new `/v3` API endpoints. Synapse 1.48+, Dendrite 0.6.5+ and Conduit 0.4.0+ are 62 | supported. Legacy `r0` API support can be temporarily re-enabled with `pip install mautrix==0.16.0`. 63 | However, this option will not be available in future releases. 64 | 65 | * Added option to use [MSC2246] async media uploads. 66 | * Added support for syncing read state from Google Chat after backfilling. 67 | * Updated user avatar sync to store hashes and check them before updating 68 | avatar on Matrix (thanks to [@kpfleming] in [#66]). 69 | * Usually avatar URLs are stable, but it seems that they aren't in some cases. 70 | This change should prevent unnecessary avatar change events on Matrix. 71 | * Changed event handling to work synchronously to make sure incoming messages 72 | are bridged in the correct order. 73 | * Fixed bug where messages being sent while the bridge is reconnecting to 74 | Google Chat would fail completely. 75 | * Removed unnecessary warning log about `COMPASS` cookies. 76 | 77 | [@kpfleming]: https://github.com/kpfleming 78 | [#66]: https://github.com/mautrix/googlechat/pull/66 79 | [MSC2246]: https://github.com/matrix-org/matrix-spec-proposals/pull/2246 80 | 81 | # v0.3.1 (2022-03-16) 82 | 83 | * Ignored errors getting `COMPASS` cookie as Google appears to have changed 84 | something. 85 | * Improved attachment bridging support. 86 | * Drive and YouTube links will be bridged even when they're sent as 87 | attachments (rather than text messages). 88 | * Bridging big files uses less memory now 89 | (only ~1-2x file size rather than 2-4x). 90 | * Link preview metadata is now included in the Matrix events 91 | (in a custom field). 92 | * Disabled file logging in Docker image by default. 93 | * If you want to enable it, set the `filename` in the file log handler to 94 | a path that is writable, then add `"file"` back to `logging.root.handlers`. 95 | * Formatted all code using [black](https://github.com/psf/black) 96 | and [isort](https://github.com/PyCQA/isort). 97 | 98 | # v0.3.0 (2021-12-18) 99 | 100 | Initial stable-ish Google Chat release 101 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:3.18 2 | 3 | RUN apk add --no-cache \ 4 | python3 py3-pip py3-setuptools py3-wheel \ 5 | py3-aiohttp \ 6 | py3-magic \ 7 | py3-ruamel.yaml \ 8 | py3-commonmark \ 9 | #py3-prometheus-client \ 10 | py3-protobuf \ 11 | py3-idna \ 12 | # encryption 13 | py3-olm \ 14 | py3-cffi \ 15 | py3-pycryptodome \ 16 | py3-unpaddedbase64 \ 17 | py3-future \ 18 | # proxy support 19 | py3-pysocks \ 20 | py3-aiohttp-socks \ 21 | # Other dependencies 22 | ca-certificates \ 23 | su-exec \ 24 | bash \ 25 | curl \ 26 | jq \ 27 | yq 28 | 29 | COPY requirements.txt /opt/mautrix-googlechat/requirements.txt 30 | COPY optional-requirements.txt /opt/mautrix-googlechat/optional-requirements.txt 31 | WORKDIR /opt/mautrix-googlechat 32 | RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \ 33 | && pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \ 34 | && apk del .build-deps 35 | 36 | COPY . /opt/mautrix-googlechat 37 | RUN apk add git && pip3 install --no-cache-dir .[all] && apk del git \ 38 | # This doesn't make the image smaller, but it's needed so that the `version` command works properly 39 | && cp mautrix_googlechat/example-config.yaml . && rm -rf mautrix_googlechat .git build 40 | 41 | ENV UID=1337 GID=1337 42 | VOLUME /data 43 | 44 | CMD ["/opt/mautrix-googlechat/docker-run.sh"] 45 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGELOG.md 3 | include LICENSE 4 | include requirements.txt 5 | include optional-requirements.txt 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat 2 | ![Languages](https://img.shields.io/github/languages/top/mautrix/googlechat.svg) 3 | [![License](https://img.shields.io/github/license/mautrix/googlechat.svg)](LICENSE) 4 | [![Release](https://img.shields.io/github/release/mautrix/googlechat/all.svg)](https://github.com/mautrix/googlechat/releases) 5 | [![GitLab CI](https://mau.dev/mautrix/googlechat/badges/master/pipeline.svg)](https://mau.dev/mautrix/googlechat/container_registry) 6 | [![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 8 | 9 | A Matrix-Google Chat puppeting bridge. 10 | 11 | ## Documentation 12 | All setup and usage instructions are located on 13 | [docs.mau.fi](https://docs.mau.fi/bridges/python/googlechat/index.html). 14 | Some quick links: 15 | 16 | * [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=googlechat) 17 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=googlechat)) 18 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/python/googlechat/authentication.html) 19 | 20 | ### Features & Roadmap 21 | [ROADMAP.md](https://github.com/mautrix/googlechat/blob/master/ROADMAP.md) 22 | contains a general overview of what is supported by the bridge. 23 | 24 | ## Discussion 25 | Matrix room: [`#googlechat:maunium.net`](https://matrix.to/#/#googlechat:maunium.net) 26 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | 3 | * Matrix → Google Chat 4 | * [ ] Message content 5 | * [x] Text 6 | * [ ] Media 7 | * [ ] Stickers 8 | * [x] Files 9 | * [ ] Voice messages 10 | * [x] Videos 11 | * [x] Images 12 | * [ ] Locations 13 | * [x] Formatting 14 | * [x] Threads 15 | * [x] Replies 16 | * [x] Message redactions 17 | * [x] Message reactions 18 | * [x] Message editing (text only) 19 | * [ ] Presence 20 | * [x] Typing notifications 21 | * [x] Read receipts 22 | * [ ] Membership actions 23 | * [ ] Invite 24 | * [ ] Join (accept invite) 25 | * [ ] Kick 26 | * [ ] Leave 27 | * [ ] Room metadata changes 28 | * [ ] Name 29 | * Google Chat → Matrix 30 | * [x] Message content 31 | * [x] Text 32 | * [x] Media 33 | * [x] Files 34 | * [x] Videos 35 | * [x] Images 36 | * [x] Google Drive file links 37 | * [x] Formatting 38 | * [x] Threads 39 | * [x] Replies 40 | * [x] Message deletions 41 | * [x] Message reactions 42 | * [x] Message editing (text only) 43 | * [x] Message history 44 | * [ ] Presence 45 | * [ ] Typing notifications 46 | * [x] Read receipts 47 | * [ ] Membership actions 48 | * [ ] Invite 49 | * [ ] Accept invite 50 | * [ ] Join via link 51 | * [ ] Remove member 52 | * [ ] Leave 53 | * [x] Chat metadata (title) changes 54 | * [x] Initial chat metadata (title) 55 | * [x] Initial user metadata 56 | * [x] Name 57 | * [x] Avatar 58 | * Misc 59 | * [x] Multi-user support 60 | * [x] Shared group chat portals 61 | * [x] Automatic portal creation 62 | * [x] At startup 63 | * [ ] When invited to chat 64 | * [x] When receiving message 65 | * [ ] Private chat creation by inviting Matrix puppet of Google Chat user to new room 66 | * [x] Option to use own Matrix account for messages sent from other Google Chat clients 67 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit>=2.10.1,<3 2 | isort>=5.10.1,<6 3 | black>=23,<24 4 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Define functions. 4 | function fixperms { 5 | chown -R $UID:$GID /data 6 | 7 | # /opt/mautrix-googlechat is read-only, so disable file logging if it's pointing there. 8 | if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-googlechat.log" ]]; then 9 | yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml 10 | yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml 11 | fi 12 | } 13 | 14 | cd /opt/mautrix-googlechat 15 | 16 | if [ ! -f /data/config.yaml ]; then 17 | cp example-config.yaml /data/config.yaml 18 | echo "Didn't find a config file." 19 | echo "Copied default config file to /data/config.yaml" 20 | echo "Modify that config file to your liking." 21 | echo "Start the container again after that to generate the registration file." 22 | fixperms 23 | exit 24 | fi 25 | 26 | if [ ! -f /data/registration.yaml ]; then 27 | python3 -m mautrix_googlechat -g -c /data/config.yaml -r /data/registration.yaml || exit $? 28 | echo "Didn't find a registration file." 29 | echo "Generated one for you." 30 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." 31 | fixperms 32 | exit 33 | fi 34 | 35 | fixperms 36 | exec su-exec $UID:$GID python3 -m mautrix_googlechat -c /data/config.yaml 37 | -------------------------------------------------------------------------------- /maugclib/README.md: -------------------------------------------------------------------------------- 1 | # maugclib 2 | This is a "fork" of [tdryer/hangups](https://github.com/tdryer/hangups) that uses Google Chat instead of Hangouts. 3 | All the extra bloat is removed since the bridge doesn't use it. 4 | 5 | ## Authentication 6 | 7 | To authenticate you need to login to your google account. You should start this 8 | process from https://chat.google.com. This will automatically redirect you to 9 | the login if necessary. Once logged in, open the developer tools in your 10 | browser. From here we need to fill out the following JSON object with the 11 | values of a number of cookies. See the browser specific documentation below on 12 | how to get the value of these cookies from your browser. 13 | 14 | ``` 15 | { 16 | "/": { 17 | "COMPASS": "", 18 | "SSID": "", 19 | "SID": "", 20 | "OSID": "", 21 | "HSID": "" 22 | }, 23 | "/u/0/webchannel/": { 24 | "COMPASS": "" 25 | } 26 | } 27 | ``` 28 | 29 | To actually log in with this, it will need to be converted to a Python 30 | dictionary and then passed into `maugclib.auth.TokenManager.from_cookies`. You 31 | can then pass the `TokenManager` instance into `maugclib.client.Client()` as 32 | normal and everything should kick off. 33 | 34 | ### Chrome 35 | 36 | Click on `Application` in the top of the `DevTools` window. Then find `Storage` 37 | on the left side of the screen. Scroll down to `Cookies` and expand it. Select 38 | `https://chat.google.com`. 39 | 40 | From here, you'll have to double click on the value 41 | cell for each cookie to get into an `edit` mode. From here you can copy the 42 | value via keyboard or the right-click context menu. 43 | 44 | > Note that you will need to enter the `COMPASS` cookie twice in the JSON 45 | object. 46 | 47 | ### Firefox 48 | 49 | Click on `Storage` at the top of the `Developer Tools` window. Then find 50 | `Cookies` on the left side of the screen and expand it. Select 51 | `https://chat.google.com`. 52 | 53 | From here, you'll have to double click on the value 54 | cell for each cookie to get into an `edit` mode. From here you can copy the 55 | value via keyboard or the right-click context menu. 56 | 57 | > Note: There are two distinct cookies named `COMPASS`. One of them has a path 58 | of `/` and the other has a path of `/u/0/webchannel/` and they should be 59 | entered in the appropriate section in the JSON object. 60 | -------------------------------------------------------------------------------- /maugclib/__init__.py: -------------------------------------------------------------------------------- 1 | # Import the objects here that form the public API of hangups so they may be 2 | # conveniently imported. 3 | 4 | # Keep version in a separate file so setup.py can import it separately. 5 | from .client import Client 6 | from .exceptions import ( 7 | ChannelLifetimeExpired, 8 | ConversationTypeError, 9 | FileTooLargeError, 10 | HangupsError, 11 | NetworkError, 12 | NotLoggedInError, 13 | ResponseError, 14 | ResponseNotJSONError, 15 | SIDError, 16 | SIDExpiringError, 17 | SIDInvalidError, 18 | UnexpectedResponseDataError, 19 | UnexpectedStatusError, 20 | ) 21 | from .http_utils import Cookies 22 | -------------------------------------------------------------------------------- /maugclib/channel.py: -------------------------------------------------------------------------------- 1 | """Client for Google's BrowserChannel protocol. 2 | 3 | BrowserChannel allows simulating a bidirectional socket in a web browser using 4 | long-polling requests. It is used by the Hangouts web client to receive state 5 | updates from the server. The "forward channel" sends "maps" (dictionaries) to 6 | the server. The "backwards channel" receives "arrays" (lists) from the server. 7 | 8 | Google provides a JavaScript BrowserChannel client as part of closure-library: 9 | http://google.github.io/closure-library/api/class_goog_net_BrowserChannel.html 10 | 11 | Source code is available here: 12 | https://github.com/google/closure-library/blob/master/closure/goog/net/browserchannel.js 13 | 14 | Unofficial protocol documentation is available here: 15 | https://web.archive.org/web/20121226064550/http://code.google.com/p/libevent-browserchannel-server/wiki/BrowserChannelProtocol 16 | """ 17 | from __future__ import annotations 18 | 19 | from typing import Iterator, NoReturn 20 | import asyncio 21 | import base64 22 | import codecs 23 | import json 24 | import logging 25 | import random 26 | import re 27 | import time 28 | 29 | import aiohttp 30 | import async_timeout 31 | 32 | from mautrix.util.opt_prometheus import Counter 33 | 34 | from . import event, exceptions, googlechat_pb2, http_utils, pblite 35 | from .exceptions import SIDExpiringError, SIDInvalidError 36 | 37 | logger = logging.getLogger(__name__) 38 | Utf8IncrementalDecoder = codecs.getincrementaldecoder("utf-8") 39 | LEN_REGEX = re.compile(r"([0-9]+)\n", re.MULTILINE) 40 | CHANNEL_URL_BASE = "https://chat.google.com/u/0/webchannel/" 41 | # Long-polling requests send heartbeats every 15-30 seconds, so if we miss two 42 | # in a row, consider the connection dead. 43 | PUSH_TIMEOUT = 60 44 | MAX_READ_BYTES = 1024 * 1024 45 | 46 | LONG_POLLING_REQUESTS = Counter( 47 | name="bridge_gc_started_long_polls", 48 | documentation="Number of long polling requests started", 49 | ) 50 | LONG_POLLING_ERRORS = Counter( 51 | name="bridge_gc_long_poll_errors", 52 | documentation="Errors that stopped long polling", 53 | labelnames=["reason"], 54 | ) 55 | RECEIVED_CHUNKS = Counter( 56 | name="bridge_gc_received_chunk_bytes", 57 | documentation="Received chunks from Google Chat long polling", 58 | ) 59 | 60 | 61 | def _best_effort_decode(data_bytes): 62 | """Decode as much of data_bytes as possible as UTF-8.""" 63 | decoder = Utf8IncrementalDecoder() 64 | return decoder.decode(data_bytes) 65 | 66 | 67 | class ChunkParser: 68 | """Parse data from the backward channel into chunks. 69 | 70 | Responses from the backward channel consist of a sequence of chunks which 71 | are streamed to the client. Each chunk is prefixed with its length, 72 | followed by a newline. The length allows the client to identify when the 73 | entire chunk has been received. 74 | """ 75 | 76 | def __init__(self) -> None: 77 | # Buffer for bytes containing utf-8 text: 78 | self._buf = b"" 79 | 80 | def get_chunks(self, new_data_bytes: bytes) -> Iterator[str]: 81 | """Yield chunks generated from received data. 82 | 83 | The buffer may not be decodable as UTF-8 if there's a split multi-byte 84 | character at the end. To handle this, do a "best effort" decode of the 85 | buffer to decode as much of it as possible. 86 | 87 | The length is actually the length of the string as reported by 88 | JavaScript. JavaScript's string length function returns the number of 89 | code units in the string, represented in UTF-16. We can emulate this by 90 | encoding everything in UTF-16 and multiplying the reported length by 2. 91 | 92 | Note that when encoding a string in UTF-16, Python will prepend a 93 | byte-order character, so we need to remove the first two bytes. 94 | """ 95 | self._buf += new_data_bytes 96 | 97 | while True: 98 | buf_decoded = _best_effort_decode(self._buf) 99 | buf_utf16 = buf_decoded.encode("utf-16")[2:] 100 | 101 | length_str_match = LEN_REGEX.match(buf_decoded) 102 | if length_str_match is None: 103 | break 104 | else: 105 | length_str = length_str_match.group(1) 106 | # Both lengths are in number of bytes in UTF-16 encoding. 107 | # The length of the submission: 108 | length = int(length_str) * 2 109 | # The length of the submission length and newline: 110 | length_length = len((length_str + "\n").encode("utf-16")[2:]) 111 | if len(buf_utf16) - length_length < length: 112 | break 113 | 114 | submission = buf_utf16[length_length : length_length + length] 115 | yield submission.decode("utf-16") 116 | # Drop the length and the submission itself from the beginning 117 | # of the buffer. 118 | drop_length = len((length_str + "\n").encode()) + len( 119 | submission.decode("utf-16").encode() 120 | ) 121 | self._buf = self._buf[drop_length:] 122 | 123 | 124 | def _parse_sid_response(res: str) -> str: 125 | """Parse response format for request for new channel SID. 126 | 127 | Example format (after parsing JS): 128 | [ 0,["c","SID_HERE","",8,12]]] 129 | 130 | Returns SID 131 | """ 132 | res = json.loads(res) 133 | sid = res[0][1][1] 134 | return sid 135 | 136 | 137 | def _unique_id() -> str: 138 | def base36(x) -> str: 139 | keyspace = "abcdefghijklmnopqrstuvwxyz0123456789" 140 | 141 | encoded = 0 142 | quotient = x 143 | while quotient != 0: 144 | quotient, remainder = divmod(quotient, len(keyspace)) 145 | encoded = keyspace[remainder] + str(encoded) 146 | 147 | return encoded 148 | 149 | return base36(random.getrandbits(64)) 150 | 151 | 152 | class Channel: 153 | """BrowserChannel client.""" 154 | 155 | ########################################################################## 156 | # Public methods 157 | ########################################################################## 158 | 159 | def __init__( 160 | self, session: http_utils.Session, max_retries: int, retry_backoff_base: int 161 | ) -> None: 162 | """Create a new channel. 163 | 164 | Args: 165 | session (http_utils.Session): Request session. 166 | max_retries (int): Number of retries for long-polling request. 167 | retry_backoff_base (int): The base term for the long-polling 168 | exponential backoff. 169 | """ 170 | 171 | # Event fired when channel connects with arguments (): 172 | self.on_connect = event.Event("Channel.on_connect") 173 | # Event fired when channel reconnects with arguments (): 174 | self.on_reconnect = event.Event("Channel.on_reconnect") 175 | # Event fired when channel disconnects with arguments (): 176 | self.on_disconnect = event.Event("Channel.on_disconnect") 177 | # Event fired when an array is received with arguments (array): 178 | self.on_receive_array = event.Event("Channel.on_receive_array") 179 | 180 | self._max_retries = max_retries 181 | self._retry_backoff_base = retry_backoff_base 182 | 183 | # True if the channel is currently connected: 184 | self._is_connected = False 185 | # True if the on_connect event has been called at least once: 186 | self._on_connect_called = False 187 | # Parser for assembling messages: 188 | self._chunk_parser = None 189 | # Session for HTTP requests: 190 | self._session = session 191 | 192 | # Discovered parameters: 193 | self._sid_param = None 194 | self._csessionid_param = None 195 | 196 | self._aid = 0 197 | self._ofs = 0 # used to track sent events 198 | self._rid = random.randint(10000, 99999) 199 | 200 | @property 201 | def is_connected(self): 202 | """Whether the channel is currently connected.""" 203 | return self._is_connected 204 | 205 | async def listen(self, max_age: float) -> None: 206 | """Listen for messages on the backwards channel. 207 | 208 | This method only returns when the connection has been closed due to an 209 | error. 210 | """ 211 | retries = 0 # Number of retries attempted so far 212 | skip_backoff = False 213 | 214 | self._csessionid_param = await self._register() 215 | start = time.monotonic() 216 | 217 | while retries <= self._max_retries: 218 | if start + max_age < time.monotonic(): 219 | raise exceptions.ChannelLifetimeExpired() 220 | # After the first failed retry, back off exponentially longer after 221 | # each attempt. 222 | if retries > 0 and not skip_backoff: 223 | backoff_seconds = self._retry_backoff_base**retries 224 | logger.info(f"Backing off for {backoff_seconds} seconds") 225 | await asyncio.sleep(backoff_seconds) 226 | skip_backoff = False 227 | 228 | # Clear any previous push data, since if there was an error it 229 | # could contain garbage. 230 | self._chunk_parser = ChunkParser() 231 | try: 232 | await self._longpoll_request() 233 | except SIDExpiringError as err: 234 | logger.debug("Long-polling interrupted: %s", err) 235 | 236 | self._csessionid_param = await self._register() 237 | 238 | retries += 1 239 | skip_backoff = True 240 | continue 241 | except exceptions.NetworkError as err: 242 | logger.warning("Long-polling request failed: %s", err) 243 | else: 244 | # The connection closed successfully, so reset the number of 245 | # retries. 246 | retries = 0 247 | continue 248 | 249 | retries += 1 250 | logger.info("retry attempt count is now %s", retries) 251 | if self._is_connected: 252 | self._is_connected = False 253 | await self.on_disconnect.fire() 254 | 255 | # If the request ended with an error, the client must account for 256 | # messages being dropped during this time. 257 | 258 | logger.error("Ran out of retries for long-polling request") 259 | 260 | async def _register(self) -> str | None: 261 | # Previously we had to clear the cookies as it would cause issues, but 262 | # the uri has a query parameter to ignore the COMPASS/dynamite cookie. 263 | self._sid_param = None 264 | self._aid = 0 265 | self._ofs = 0 266 | 267 | # required cookies: COMPASS, SSID, SID, OSID, HSID, 268 | # new cookies after login: SIDCC 269 | 270 | headers = {"Content-Type": "application/x-protobuf"} 271 | res = await self._session.fetch_raw( 272 | "GET", CHANNEL_URL_BASE + "register?ignore_compass_cookie=1", headers=headers 273 | ) 274 | 275 | body = await res.read() 276 | 277 | if res.status != 200: 278 | logger.info( 279 | "Register request HTTP %d %s response body: %s", res.status, res.reason, body 280 | ) 281 | raise exceptions.UnexpectedStatusError( 282 | f"Register request", 283 | res.status, 284 | res.reason, 285 | body, 286 | ) 287 | 288 | morsel = self._session.get_cookie(CHANNEL_URL_BASE, "COMPASS") 289 | logger.debug("Cookies: %s", self._session._cookie_jar._cookies) 290 | logger.debug("Register response: %s", body) 291 | logger.debug("Status: %s", res.status) 292 | logger.debug("Headers: %s", res.headers) 293 | if morsel is not None: 294 | if morsel.value.startswith("dynamite-ui="): 295 | logger.info("Registered new channel successfully") 296 | return morsel.value[len("dynamite-ui=") :] 297 | else: 298 | logger.warning( 299 | "COMPASS cookie doesn't start with dynamite-ui= (value: %s)", morsel.value 300 | ) 301 | return None 302 | 303 | async def send_stream_event(self, events_request: googlechat_pb2.StreamEventsRequest): 304 | params = { 305 | "VER": 8, # channel protocol version 306 | "RID": self._rid, # request identifier 307 | "t": 1, # trial 308 | "SID": self._sid_param, # session ID 309 | "AID": self._aid, # last acknowledged id 310 | # No longer required with the web ui 311 | # "CI": 0, # 0 if streaming/chunked requests should be used 312 | } 313 | 314 | self._rid += 1 315 | 316 | # This doesn't appear to be used at all anymore. 317 | # if self._csessionid_param is not None: 318 | # params["csessionid"] = self._csessionid_param 319 | 320 | headers = { 321 | "Content-Type": "application/x-www-form-urlencoded", 322 | } 323 | 324 | body = pblite.encode(events_request) 325 | json_body = json.dumps(body) 326 | data = { 327 | "count": 1, 328 | "ofs": self._ofs, 329 | "req0_data": json_body, 330 | } 331 | self._ofs += 1 332 | 333 | res = await self._session.fetch_raw( 334 | "POST", 335 | CHANNEL_URL_BASE + "events", 336 | headers=headers, 337 | params=params, 338 | data=data, 339 | ) 340 | 341 | return res 342 | 343 | ########################################################################## 344 | # Private methods 345 | ########################################################################## 346 | 347 | async def _send_initial_ping(self): 348 | ping_event = googlechat_pb2.PingEvent( 349 | state=googlechat_pb2.PingEvent.State.ACTIVE, 350 | application_focus_state=googlechat_pb2.PingEvent.ApplicationFocusState.FOCUS_STATE_FOREGROUND, 351 | client_interactive_state=googlechat_pb2.PingEvent.ClientInteractiveState.INTERACTIVE, 352 | client_notifications_enabled=True, 353 | ) 354 | 355 | logger.info("Sending initial ping request") 356 | return await self.send_stream_event( 357 | googlechat_pb2.StreamEventsRequest( 358 | ping_event=ping_event, 359 | ) 360 | ) 361 | 362 | async def _longpoll_request(self) -> None: 363 | """Open a long-polling request and receive arrays. 364 | 365 | This method uses keep-alive to make re-opening the request faster, but 366 | the remote server will set the "Connection: close" header once an hour. 367 | 368 | Raises hangups.NetworkError or ChannelSessionError. 369 | """ 370 | params = { 371 | "VER": 8, # channel protocol version 372 | "RID": self._rid, 373 | "SID": self._sid_param, 374 | "t": 1, # trial, sometimes seen as 2 375 | "zx": _unique_id(), 376 | } 377 | 378 | if self._sid_param is None: 379 | params.update( 380 | { 381 | "CVER": 22, 382 | "$req": "count=1&ofs=0&req0_data=%5B%5D", 383 | "SID": "null", 384 | } 385 | ) 386 | 387 | self._rid += 1 388 | else: 389 | params.update({"CI": 0, "TYPE": "xmlhttp", "RID": "rpc", "AID": self._aid}) 390 | 391 | headers = { 392 | "referer": "https://chat.google.com/", 393 | } 394 | 395 | logger.debug("Opening new long-polling request") 396 | LONG_POLLING_REQUESTS.inc() 397 | try: 398 | res: aiohttp.ClientResponse 399 | async with self._session.fetch_raw_ctx( 400 | "GET", CHANNEL_URL_BASE + "events", params=params, headers=headers 401 | ) as res: 402 | if res.status != 200: 403 | text = await res.text() 404 | logger.info( 405 | "Long poll HTTP %d %s response body: %s", res.status, res.reason, text 406 | ) 407 | LONG_POLLING_ERRORS.labels(reason=f"http {res.status}").inc() 408 | if res.status == 400: 409 | if res.reason == "Unknown SID" or "Unknown SID" in text: 410 | LONG_POLLING_ERRORS.labels(reason="sid invalid").inc() 411 | raise SIDInvalidError() 412 | raise exceptions.UnexpectedStatusError( 413 | f"Long poll request", 414 | res.status, 415 | res.reason, 416 | text, 417 | ) 418 | 419 | initial_response = res.headers.get("X-HTTP-Initial-Response", None) 420 | if initial_response: 421 | sid = _parse_sid_response(initial_response) 422 | if self._sid_param != sid: 423 | self._sid_param = sid 424 | self._aid = 0 425 | self._ofs = 0 426 | 427 | # Tell the server we got the sid. I'm not sure what else 428 | # this could be, but it does seem to be required. 429 | params = { 430 | "VER": 8, 431 | "RID": "rpc", 432 | "SID": self._sid_param, 433 | "AID": self._aid, 434 | "CI": 0, 435 | "TYPE": "xmlhttp", 436 | "zx": _unique_id(), 437 | "t": 1, 438 | } 439 | 440 | await self._session.fetch_raw( 441 | "GET", CHANNEL_URL_BASE + "events", params=params 442 | ) 443 | 444 | # Finally send the initial ping 445 | await self._send_initial_ping() 446 | 447 | while True: 448 | async with async_timeout.timeout(PUSH_TIMEOUT): 449 | chunk = await res.content.read(MAX_READ_BYTES) 450 | if not chunk: 451 | break 452 | 453 | await self._on_push_data(chunk) 454 | 455 | except asyncio.TimeoutError: 456 | LONG_POLLING_ERRORS.labels(reason="timeout").inc() 457 | raise exceptions.NetworkError("Long poll request timed out") 458 | except aiohttp.ServerDisconnectedError as err: 459 | LONG_POLLING_ERRORS.labels(reason="server disconnected").inc() 460 | raise exceptions.NetworkError(f"Server disconnected error: {err}") 461 | except aiohttp.ClientPayloadError: 462 | LONG_POLLING_ERRORS.labels(reason="sid expiry").inc() 463 | raise SIDExpiringError() 464 | except aiohttp.ClientError as err: 465 | LONG_POLLING_ERRORS.labels(reason="connection error").inc() 466 | raise exceptions.NetworkError(f"Long poll request connection error: {err}") 467 | LONG_POLLING_ERRORS.labels(reason="clean exit").inc() 468 | 469 | async def _on_push_data(self, data_bytes: bytes) -> None: 470 | """Parse push data and trigger events.""" 471 | logger.debug("Received chunk:\n{}".format(data_bytes)) 472 | RECEIVED_CHUNKS.inc(len(data_bytes)) 473 | for chunk in self._chunk_parser.get_chunks(data_bytes): 474 | # Consider the channel connected once the first chunk is received. 475 | if not self._is_connected: 476 | if self._on_connect_called: 477 | self._is_connected = True 478 | await self.on_reconnect.fire() 479 | else: 480 | self._on_connect_called = True 481 | self._is_connected = True 482 | await self.on_connect.fire() 483 | 484 | # chunk contains a container array 485 | container_array = json.loads(chunk) 486 | # container array is an array of inner arrays 487 | for inner_array in container_array: 488 | # inner_array always contains 2 elements, the array_id and the 489 | # data_array. 490 | array_id, data_array = inner_array 491 | logger.debug("Chunk contains data array with id %r:\n%r", array_id, data_array) 492 | await self.on_receive_array.fire(data_array) 493 | 494 | # update our last array id after we're done processing it 495 | self._aid = array_id 496 | -------------------------------------------------------------------------------- /maugclib/event.py: -------------------------------------------------------------------------------- 1 | """Simple event observer system supporting asyncio. 2 | 3 | Observers must be removed to avoid memory leaks. 4 | """ 5 | 6 | import asyncio 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Event: 13 | """An event that can notify subscribers with arguments when fired. 14 | 15 | Args: 16 | name (str): Name of the new event. 17 | """ 18 | 19 | def __init__(self, name: str) -> None: 20 | self._name = str(name) 21 | self._observers = [] 22 | 23 | def add_observer(self, callback) -> None: 24 | """Add an observer to this event. 25 | 26 | Args: 27 | callback: A function or coroutine callback to call when the event 28 | is fired. 29 | 30 | Raises: 31 | ValueError: If the callback has already been added. 32 | """ 33 | if callback in self._observers: 34 | raise ValueError("{} is already an observer of {}".format(callback, self)) 35 | self._observers.append(callback) 36 | 37 | def remove_observer(self, callback) -> None: 38 | """Remove an observer from this event. 39 | 40 | Args: 41 | callback: A function or coroutine callback to remove from this 42 | event. 43 | 44 | Raises: 45 | ValueError: If the callback is not an observer of this event. 46 | """ 47 | if callback not in self._observers: 48 | raise ValueError("{} is not an observer of {}".format(callback, self)) 49 | self._observers.remove(callback) 50 | 51 | async def fire(self, *args, **kwargs) -> None: 52 | """Fire this event, calling all observers with the same arguments.""" 53 | logger.debug("Fired {}".format(self)) 54 | for observer in self._observers: 55 | gen = observer(*args, **kwargs) 56 | if asyncio.iscoroutinefunction(observer): 57 | await gen 58 | 59 | def __repr__(self) -> str: 60 | return "Event('{}')".format(self._name) 61 | -------------------------------------------------------------------------------- /maugclib/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | import json 5 | 6 | 7 | class HangupsError(Exception): 8 | """An ambiguous error occurred.""" 9 | 10 | 11 | class NetworkError(HangupsError): 12 | """A network error occurred.""" 13 | 14 | 15 | class ConversationTypeError(HangupsError): 16 | """An action was performed on a conversation that doesn't support it.""" 17 | 18 | 19 | class ChannelLifetimeExpired(HangupsError): 20 | pass 21 | 22 | 23 | class SIDError(HangupsError): 24 | pass 25 | 26 | 27 | class SIDExpiringError(SIDError): 28 | def __init__(self) -> None: 29 | super().__init__("SID is about to expire") 30 | 31 | 32 | class SIDInvalidError(SIDError): 33 | def __init__(self) -> None: 34 | super().__init__("SID became invalid") 35 | 36 | 37 | class FileTooLargeError(HangupsError): 38 | pass 39 | 40 | 41 | class NotLoggedInError(HangupsError): 42 | pass 43 | 44 | 45 | class ResponseError(HangupsError): 46 | body: Any 47 | 48 | def __init__(self, message: str, body: Any) -> None: 49 | super().__init__(message) 50 | self.body = body 51 | 52 | 53 | class ResponseNotJSONError(ResponseError): 54 | def __init__(self, request_name: str, body: Any) -> None: 55 | super().__init__(f"{request_name} returned non-JSON body", body) 56 | 57 | 58 | class UnexpectedResponseDataError(ResponseError): 59 | pass 60 | 61 | 62 | class UnexpectedStatusError(ResponseError): 63 | error_code: str | None 64 | error_desc: str | None 65 | status: int 66 | reason: str 67 | 68 | def __init__(self, request: str, status: int, reason: str, body: Any) -> None: 69 | self.status = status 70 | self.reason = reason 71 | message = f"{request} failed with HTTP {status} {reason}" 72 | if isinstance(body, str): 73 | try: 74 | body = json.loads(body) 75 | except json.JSONDecodeError: 76 | pass 77 | self.status = status 78 | if isinstance(body, dict) and "error" in body: 79 | self.error_code = body.get("error", "") 80 | self.error_desc = body.get("error_description", "") 81 | message += f": {self.error_code}: {self.error_desc}" 82 | else: 83 | self.error_code = None 84 | self.error_desc = None 85 | super().__init__(message, body) 86 | -------------------------------------------------------------------------------- /maugclib/http_utils.py: -------------------------------------------------------------------------------- 1 | """HTTP request session.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any, AsyncIterator, NamedTuple, cast 5 | from contextlib import asynccontextmanager 6 | from http.cookies import Morsel, SimpleCookie 7 | import asyncio 8 | import logging 9 | import re 10 | 11 | from yarl import URL 12 | import aiohttp 13 | import async_timeout 14 | 15 | from . import exceptions 16 | 17 | logger = logging.getLogger(__name__) 18 | CONNECT_TIMEOUT = 30 19 | REQUEST_TIMEOUT = 30 20 | MAX_RETRIES = 3 21 | ORIGIN_URL = "https://chat.google.com" 22 | 23 | LATEST_CHROME_VERSION = "114" 24 | LATEST_FIREFOX_VERSION = "114" 25 | DEFAULT_USER_AGENT = ( 26 | f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " 27 | f"Chrome/{LATEST_CHROME_VERSION}.0.0.0 Safari/537.36" 28 | ) 29 | chrome_version_regex = re.compile(r"Chrome/\d+\.\d+\.\d+\.\d+") 30 | firefox_version_regex = re.compile(r"Firefox/\d+.\d+") 31 | 32 | 33 | class FetchResponse(NamedTuple): 34 | code: int 35 | headers: dict[str, str] 36 | body: bytes 37 | 38 | 39 | class Cookies(NamedTuple): 40 | compass: str 41 | ssid: str 42 | sid: str 43 | osid: str 44 | hsid: str 45 | 46 | 47 | chat_google_com = URL("https://chat.google.com/") 48 | 49 | 50 | class Session: 51 | """Session for making HTTP requests to Google. 52 | 53 | Args: 54 | cookies (dict): Cookies to authenticate requests with. 55 | proxy (str): (optional) HTTP proxy URL to use for requests. 56 | """ 57 | 58 | def __init__(self, cookies: Cookies, proxy: str = None, user_agent: str = None) -> None: 59 | self._proxy = proxy 60 | 61 | # The server does not support quoting cookie values (see #498). 62 | self._cookie_jar = aiohttp.CookieJar(quote_cookie=False) 63 | cookie = SimpleCookie() 64 | for key, value in cookies._asdict().items(): 65 | cookie[key.upper()] = value 66 | cookie[key.upper()].update({"domain": "chat.google.com", "path": "/"}) 67 | self._cookie_jar.update_cookies(cookie, chat_google_com) 68 | 69 | if user_agent: 70 | user_agent = chrome_version_regex.sub( 71 | f"Chrome/{LATEST_CHROME_VERSION}.0.0.0", user_agent 72 | ) 73 | user_agent = firefox_version_regex.sub( 74 | f"Firefox/{LATEST_FIREFOX_VERSION}.0", user_agent 75 | ) 76 | else: 77 | user_agent = DEFAULT_USER_AGENT 78 | 79 | timeout = aiohttp.ClientTimeout(connect=CONNECT_TIMEOUT) 80 | self._session = aiohttp.ClientSession( 81 | cookie_jar=self._cookie_jar, 82 | timeout=timeout, 83 | trust_env=True, 84 | headers={"User-Agent": user_agent}, 85 | ) 86 | 87 | def get_auth_cookies(self) -> Cookies: 88 | vals = {} 89 | cookie = self._cookie_jar.filter_cookies(chat_google_com) 90 | for field in Cookies._fields: 91 | vals[field] = cookie[field.upper()].value 92 | return Cookies(**vals) 93 | 94 | def get_cookie(self, url: URL | str, name: str) -> Morsel[str]: 95 | filtered = self._cookie_jar.filter_cookies(url) 96 | 97 | return cast(Morsel, filtered.get(name, None)) 98 | 99 | async def fetch( 100 | self, 101 | method: str, 102 | url: URL | str, 103 | params: dict[str, str] | None = None, 104 | headers: dict[str, str] | None = None, 105 | allow_redirects: bool = True, 106 | data: Any = None, 107 | ) -> FetchResponse: 108 | """Make an HTTP request. 109 | 110 | Automatically uses configured HTTP proxy, and adds Google authorization 111 | header and cookies. 112 | 113 | Failures will be retried MAX_RETRIES times before raising NetworkError. 114 | 115 | Args: 116 | method (str): Request method. 117 | url (str): Request URL. 118 | params (dict): (optional) Request query string parameters. 119 | headers (dict): (optional) Request headers. 120 | allow_redirects (bool): Should redirects be followed automatically? 121 | data: (str): (optional) Request body data. 122 | 123 | Returns: 124 | FetchResponse: Response data. 125 | 126 | Raises: 127 | NetworkError: If the request fails. 128 | """ 129 | logger.debug( 130 | "Sending request %s %s:\n%r", 131 | method, 132 | url, 133 | data if not data or len(data) < 64 * 1024 else f"{len(data)} bytes", 134 | ) 135 | error_msg = "Request not attempted" 136 | for retry_num in range(MAX_RETRIES): 137 | try: 138 | async with self.fetch_raw_ctx( 139 | method, 140 | url, 141 | params=params, 142 | headers=headers, 143 | allow_redirects=allow_redirects, 144 | data=data, 145 | ) as res: 146 | async with async_timeout.timeout(REQUEST_TIMEOUT): 147 | body = await res.read() 148 | log_body = body 149 | if isinstance(url, str) and "/u/0/mole/world" in url: 150 | log_body = "" 151 | logger.debug("Received response %d %s:\n%r", res.status, res.reason, log_body) 152 | except asyncio.TimeoutError: 153 | error_msg = "Request timed out" 154 | except aiohttp.ServerDisconnectedError as err: 155 | error_msg = "Server disconnected error: {}".format(err) 156 | except (aiohttp.ClientError, ValueError) as err: 157 | error_msg = "Request connection error: {}".format(err) 158 | else: 159 | break 160 | logger.info("Request attempt %d failed: %s", retry_num, error_msg) 161 | else: 162 | logger.info("Request failed after %d attempts", MAX_RETRIES) 163 | raise exceptions.NetworkError(error_msg) 164 | 165 | if res.status != 200: 166 | logger.info( 167 | "Request to %s returned unexpected status: %d %s", url, res.status, res.reason 168 | ) 169 | raise exceptions.NetworkError( 170 | "Request return unexpected status: {}: {}".format(res.status, res.reason) 171 | ) 172 | 173 | return FetchResponse(res.status, res.headers, body) 174 | 175 | async def fetch_raw( 176 | self, 177 | method: str, 178 | url: URL | str, 179 | params: dict[str, str] | None = None, 180 | headers: dict[str, str] | None = None, 181 | allow_redirects: bool = True, 182 | data: Any = None, 183 | ) -> aiohttp.ClientResponse: 184 | """Make an HTTP request using aiohttp directly. 185 | 186 | Automatically uses configured HTTP proxy, and adds Google authorization 187 | header and cookies. 188 | 189 | Args: 190 | method (str): Request method. 191 | url (str): Request URL. 192 | params (dict): (optional) Request query string parameters. 193 | headers (dict): (optional) Request headers. 194 | allow_redirects (bool): Should redirects be followed automatically? 195 | data: (str): (optional) Request body data. 196 | 197 | Returns: 198 | aiohttp.ClientResponse: The HTTP response 199 | 200 | Raises: 201 | See ``aiohttp.ClientSession.request``. 202 | """ 203 | resp = await self._fetch_raw(method, url, params, headers, allow_redirects, data) 204 | return await resp 205 | 206 | @asynccontextmanager 207 | async def fetch_raw_ctx( 208 | self, 209 | method: str, 210 | url: URL | str, 211 | params: dict[str, str] | None = None, 212 | headers: dict[str, str] | None = None, 213 | allow_redirects: bool = True, 214 | data: Any = None, 215 | ) -> AsyncIterator[aiohttp.ClientResponse, None]: 216 | """Make an HTTP request using aiohttp directly. 217 | 218 | Automatically uses configured HTTP proxy, and adds Google authorization 219 | header and cookies. 220 | 221 | Args: 222 | method (str): Request method. 223 | url (str): Request URL. 224 | params (dict): (optional) Request query string parameters. 225 | headers (dict): (optional) Request headers. 226 | allow_redirects (bool): Should redirects be followed automatically? 227 | data: (str): (optional) Request body data. 228 | 229 | Yields: 230 | aiohttp.ClientResponse: The HTTP response 231 | 232 | Raises: 233 | See ``aiohttp.ClientSession.request``. 234 | """ 235 | async with await self._fetch_raw( 236 | method, url, params, headers, allow_redirects, data 237 | ) as resp: 238 | yield resp 239 | 240 | async def _fetch_raw( 241 | self, 242 | method: str, 243 | url: URL | str, 244 | params: dict[str, str] | None = None, 245 | headers: dict[str, str] | None = None, 246 | allow_redirects: bool = True, 247 | data: Any = None, 248 | ): 249 | # Ensure we don't accidentally send the authorization header to a 250 | # non-Google domain: 251 | if not URL(url).host.endswith(".google.com"): 252 | raise Exception("expected google.com domain") 253 | 254 | headers = headers or {} 255 | headers["Connection"] = "Keep-Alive" 256 | return self._session.request( 257 | method, 258 | url, 259 | params=params, 260 | headers=headers, 261 | allow_redirects=allow_redirects, 262 | data=data, 263 | proxy=self._proxy, 264 | ssl=False, 265 | ) 266 | 267 | async def close(self) -> None: 268 | """Close the underlying aiohttp.ClientSession.""" 269 | await self._session.close() 270 | 271 | @property 272 | def closed(self) -> bool: 273 | return self._session.closed 274 | -------------------------------------------------------------------------------- /maugclib/parsers.py: -------------------------------------------------------------------------------- 1 | """Parsing helper functions.""" 2 | 3 | import datetime 4 | 5 | from . import googlechat_pb2 6 | 7 | ############################################################################## 8 | # Message parsing utils 9 | ############################################################################## 10 | 11 | 12 | def from_timestamp(microsecond_timestamp): 13 | """Convert a microsecond timestamp to a UTC datetime instance.""" 14 | # Create datetime without losing precision from floating point (yes, this 15 | # is actually needed): 16 | return datetime.datetime.fromtimestamp( 17 | microsecond_timestamp // 1000000, datetime.timezone.utc 18 | ).replace(microsecond=(microsecond_timestamp % 1000000)) 19 | 20 | 21 | def to_timestamp(datetime_timestamp): 22 | """Convert UTC datetime to microsecond timestamp used by Hangouts.""" 23 | return int(datetime_timestamp.timestamp() * 1000000) 24 | 25 | 26 | def id_from_group_id(group_id: googlechat_pb2.GroupId) -> str: 27 | if group_id.HasField("dm_id"): 28 | return f"dm:{group_id.dm_id.dm_id}" 29 | elif group_id.HasField("space_id"): 30 | return f"space:{group_id.space_id.space_id}" 31 | else: 32 | return "" 33 | 34 | 35 | def group_id_from_id(conversation_id: str) -> googlechat_pb2.GroupId: 36 | if conversation_id.startswith("dm:"): 37 | return googlechat_pb2.GroupId( 38 | dm_id=googlechat_pb2.DmId( 39 | dm_id=conversation_id[len("dm:") :], 40 | ) 41 | ) 42 | elif conversation_id.startswith("space:"): 43 | return googlechat_pb2.GroupId( 44 | space_id=googlechat_pb2.SpaceId( 45 | space_id=conversation_id[len("space:") :], 46 | ) 47 | ) 48 | else: 49 | raise ValueError("Invalid conversation ID") 50 | -------------------------------------------------------------------------------- /maugclib/pblite.py: -------------------------------------------------------------------------------- 1 | """Decoder and encoder for the pblite format. 2 | 3 | pblite (sometimes also known as protojson) is a format for serializing Protocol 4 | Buffers into JavaScript objects. Messages are represented as arrays where the 5 | tag number of a value is given by its position in the array. 6 | 7 | Methods in this module assume encoding/decoding to JavaScript strings is done 8 | separately. 9 | 10 | Google's implementation for JavaScript is available in closure-library: 11 | https://github.com/google/closure-library/tree/master/closure/goog/proto2 12 | """ 13 | 14 | import base64 15 | import itertools 16 | import json 17 | import logging 18 | 19 | # pylint: disable=no-name-in-module,import-error 20 | from google.protobuf.descriptor import FieldDescriptor 21 | 22 | # pylint: enable=no-name-in-module,import-error 23 | 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def _decode_field(message, field, value): 29 | """Decode optional or required field.""" 30 | if field.type == FieldDescriptor.TYPE_MESSAGE: 31 | decode(getattr(message, field.name), value) 32 | else: 33 | try: 34 | if field.type == FieldDescriptor.TYPE_BYTES: 35 | value = base64.b64decode(value) 36 | elif field.type == FieldDescriptor.TYPE_INT64: 37 | value = int(value) 38 | setattr(message, field.name, value) 39 | except (ValueError, TypeError) as e: 40 | # ValueError: invalid enum value, negative unsigned int value, or 41 | # invalid base64 42 | # TypeError: mismatched type 43 | logger.warning( 44 | "Message %r ignoring field %s: %s", message.__class__.__name__, field.name, e 45 | ) 46 | 47 | 48 | def _decode_repeated_field(message, field, value_list): 49 | """Decode repeated field.""" 50 | if field.type == FieldDescriptor.TYPE_MESSAGE: 51 | for value in value_list: 52 | decode(getattr(message, field.name).add(), value) 53 | else: 54 | try: 55 | for value in value_list: 56 | if field.type == FieldDescriptor.TYPE_BYTES: 57 | value = base64.b64decode(value) 58 | getattr(message, field.name).append(value) 59 | except (ValueError, TypeError) as e: 60 | # ValueError: invalid enum value, negative unsigned int value, or 61 | # invalid base64 62 | # TypeError: mismatched type 63 | logger.warning( 64 | "Message %r ignoring repeated field %s: %s", 65 | message.__class__.__name__, 66 | field.name, 67 | e, 68 | ) 69 | # Ignore any values already decoded by clearing list 70 | message.ClearField(field.name) 71 | 72 | 73 | def decode(message, pblite, ignore_first_item=False): 74 | """Decode pblite to Protocol Buffer message. 75 | 76 | This method is permissive of decoding errors and will log them as warnings 77 | and continue decoding where possible. 78 | 79 | The first element of the outer pblite list must often be ignored using the 80 | ignore_first_item parameter because it contains an abbreviation of the name 81 | of the protobuf message (eg. cscmrp for ClientSendChatMessageResponseP) 82 | that's not part of the protobuf. 83 | 84 | Args: 85 | message: protocol buffer message instance to decode into. 86 | pblite: list representing a pblite-serialized message. 87 | ignore_first_item: If True, ignore the item at index 0 in the pblite 88 | list, making the item at index 1 correspond to field 1 in the 89 | message. 90 | """ 91 | if not isinstance(pblite, list): 92 | logger.warning("Ignoring invalid message: expected list, got %r", type(pblite)) 93 | return 94 | if ignore_first_item: 95 | pblite = pblite[1:] 96 | # If the last item of the list is a dict, use it as additional field/value 97 | # mappings. This seems to be an optimization added for dealing with really 98 | # high field numbers. 99 | if pblite and isinstance(pblite[-1], dict): 100 | extra_fields = {int(field_number): value for field_number, value in pblite[-1].items()} 101 | pblite = pblite[:-1] 102 | else: 103 | extra_fields = {} 104 | fields_values = itertools.chain(enumerate(pblite, start=1), extra_fields.items()) 105 | for field_number, value in fields_values: 106 | if value is None: 107 | continue 108 | try: 109 | field = message.DESCRIPTOR.fields_by_number[field_number] 110 | except KeyError: 111 | # If the tag number is unknown and the value is non-trivial, log a 112 | # message to aid reverse-engineering the missing field in the 113 | # message. 114 | if value not in [[], "", 0]: 115 | logger.debug( 116 | "Message %r contains unknown field %s with value " "%r", 117 | message.__class__.__name__, 118 | field_number, 119 | value, 120 | ) 121 | continue 122 | if field.label == FieldDescriptor.LABEL_REPEATED: 123 | _decode_repeated_field(message, field, value) 124 | else: 125 | _decode_field(message, field, value) 126 | 127 | 128 | def _encode_field(message, field): 129 | ret = None 130 | 131 | if field.type == FieldDescriptor.TYPE_MESSAGE: 132 | nested = getattr(message, field.name) 133 | ret = encode(nested) 134 | else: 135 | ret = getattr(message, field.name) 136 | 137 | return ret 138 | 139 | 140 | def encode(message): 141 | """Encode Protocol Buffer message to pblite. 142 | 143 | Args: 144 | message: protocol buffer message to encode. 145 | 146 | Raises: 147 | ValueError: one or more required fields in message are not set. 148 | 149 | Returns: 150 | list representing a pblite-serialized message. 151 | """ 152 | if not message.IsInitialized(): 153 | raise ValueError("Can not encode message: one or more required fields " "are not set") 154 | pblite = [] 155 | # ListFields only returns fields that are set, so use this to only encode 156 | # necessary fields 157 | for field_descriptor, field_value in message.ListFields(): 158 | if field_descriptor.label == FieldDescriptor.LABEL_REPEATED: 159 | if field_descriptor.type == FieldDescriptor.TYPE_MESSAGE: 160 | encoded_value = [encode(item) for item in field_value] 161 | elif field_descriptor.type == FieldDescriptor.TYPE_BYTES: 162 | encoded_value = [base64.b64encode(val).decode() for val in field_value] 163 | else: 164 | encoded_value = list(field_value) 165 | else: 166 | if field_descriptor.type == FieldDescriptor.TYPE_MESSAGE: 167 | encoded_value = encode(field_value) 168 | elif field_descriptor.type == FieldDescriptor.TYPE_BYTES: 169 | encoded_value = base64.b64encode(field_value).decode() 170 | else: 171 | encoded_value = field_value 172 | # Add any necessary padding to the list 173 | required_padding = max(field_descriptor.number - len(pblite), 0) 174 | pblite.extend([None] * required_padding) 175 | pblite[field_descriptor.number - 1] = encoded_value 176 | return pblite 177 | -------------------------------------------------------------------------------- /mautrix_googlechat/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.2" 2 | __author__ = "Tulir Asokan " 3 | -------------------------------------------------------------------------------- /mautrix_googlechat/__main__.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any 19 | 20 | from mautrix.bridge import Bridge 21 | from mautrix.types import RoomID, UserID 22 | 23 | from . import commands as _ 24 | from .config import Config 25 | from .db import init as init_db, upgrade_table 26 | from .matrix import MatrixHandler 27 | from .portal import Portal 28 | from .puppet import Puppet 29 | from .user import User 30 | from .version import linkified_version, version 31 | from .web import GoogleChatAuthServer 32 | 33 | 34 | class GoogleChatBridge(Bridge): 35 | name = "mautrix-googlechat" 36 | module = "mautrix_googlechat" 37 | beeper_service_name = "googlechat" 38 | beeper_network_name = "googlechat" 39 | command = "python -m mautrix-googlechat" 40 | description = "A Matrix-Google Chat puppeting bridge." 41 | repo_url = "https://github.com/mautrix/googlechat" 42 | version = version 43 | markdown_version = linkified_version 44 | config_class = Config 45 | matrix_class = MatrixHandler 46 | upgrade_table = upgrade_table 47 | 48 | config: Config 49 | matrix: MatrixHandler 50 | auth_server: GoogleChatAuthServer 51 | 52 | def prepare_db(self) -> None: 53 | super().prepare_db() 54 | init_db(self.db) 55 | 56 | def prepare_bridge(self) -> None: 57 | super().prepare_bridge() 58 | self.auth_server = GoogleChatAuthServer( 59 | self.config["bridge.provisioning.shared_secret"], 60 | ) 61 | self.az.app.add_subapp("/login", self.auth_server.legacy_app) 62 | self.az.app.add_subapp(self.config["bridge.provisioning.prefix"], self.auth_server.app) 63 | 64 | async def resend_bridge_info(self) -> None: 65 | self.config["bridge.resend_bridge_info"] = False 66 | self.config.save() 67 | self.log.info("Re-sending bridge info state event to all portals") 68 | async for portal in Portal.all(): 69 | await portal.update_bridge_info() 70 | self.log.info("Finished re-sending bridge info state events") 71 | 72 | def prepare_stop(self) -> None: 73 | self.add_shutdown_actions(user.stop() for user in User.by_mxid.values()) 74 | self.log.debug("Stopping puppet syncers") 75 | for puppet in Puppet.by_custom_mxid.values(): 76 | puppet.stop() 77 | 78 | async def start(self) -> None: 79 | self.add_startup_actions(User.init_cls(self)) 80 | self.add_startup_actions(Puppet.init_cls(self)) 81 | Portal.init_cls(self) 82 | if self.config["bridge.resend_bridge_info"]: 83 | self.add_startup_actions(self.resend_bridge_info()) 84 | await super().start() 85 | 86 | async def get_portal(self, room_id: RoomID) -> Portal: 87 | return await Portal.get_by_mxid(room_id) 88 | 89 | async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet: 90 | return await Puppet.get_by_mxid(user_id, create=create) 91 | 92 | async def get_double_puppet(self, user_id: UserID) -> Puppet: 93 | return await Puppet.get_by_custom_mxid(user_id) 94 | 95 | async def get_user(self, user_id: UserID, create: bool = True) -> User: 96 | return await User.get_by_mxid(user_id, create=create) 97 | 98 | def is_bridge_ghost(self, user_id: UserID) -> bool: 99 | return bool(Puppet.get_id_from_mxid(user_id)) 100 | 101 | async def count_logged_in_users(self) -> int: 102 | return len([user for user in User.by_mxid.values() if user.gcid]) 103 | 104 | async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]: 105 | return { 106 | **await super().manhole_global_namespace(user_id), 107 | "User": User, 108 | "Portal": Portal, 109 | "Puppet": Puppet, 110 | } 111 | 112 | 113 | GoogleChatBridge().run() 114 | -------------------------------------------------------------------------------- /mautrix_googlechat/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from . import auth 2 | -------------------------------------------------------------------------------- /mautrix_googlechat/commands/auth.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | import json 17 | 18 | from maugclib import Cookies, NotLoggedInError 19 | from mautrix.bridge.commands import HelpSection, command_handler 20 | from mautrix.errors import MForbidden 21 | from mautrix.types import EventID 22 | 23 | from .. import puppet as pu 24 | from .typehint import CommandEvent 25 | 26 | SECTION_AUTH = HelpSection("Authentication", 10, "") 27 | 28 | 29 | @command_handler( 30 | needs_auth=True, 31 | management_only=True, 32 | help_section=SECTION_AUTH, 33 | help_text="Log out from Google Chat", 34 | ) 35 | async def logout(evt: CommandEvent) -> None: 36 | puppet = await pu.Puppet.get_by_gcid(evt.sender.gcid) 37 | await evt.sender.logout(is_manual=True) 38 | if puppet and puppet.is_real_user: 39 | await puppet.switch_mxid(None, None) 40 | await evt.reply("Successfully logged out") 41 | 42 | 43 | @command_handler( 44 | needs_auth=True, 45 | management_only=True, 46 | help_section=SECTION_AUTH, 47 | help_text="Check if you're logged into Google Chat", 48 | ) 49 | async def ping(evt: CommandEvent) -> None: 50 | try: 51 | self_info = await evt.sender.get_self() 52 | except Exception as e: 53 | evt.log.exception("Failed to get user info", exc_info=True) 54 | await evt.reply(f"Failed to get user info: {e}") 55 | return 56 | name = self_info.name 57 | email = f" <{self_info.email}>" if self_info.email else "" 58 | id = self_info.user_id.id 59 | await evt.reply(f"You're logged in as {name}{email} ({id})", allow_html=False) 60 | 61 | 62 | @command_handler( 63 | needs_auth=False, 64 | management_only=True, 65 | help_section=SECTION_AUTH, 66 | help_text="Mark this room as your bridge notice room", 67 | ) 68 | async def set_notice_room(evt: CommandEvent) -> None: 69 | evt.sender.notice_room = evt.room_id 70 | await evt.sender.save() 71 | await evt.reply("This room has been marked as your bridge notice room") 72 | 73 | 74 | @command_handler( 75 | needs_auth=False, 76 | management_only=True, 77 | help_section=SECTION_AUTH, 78 | help_text=( 79 | "Set the cookies required for auth. See https://docs.mau.fi/bridges/python/googlechat/authentication.html for instructions" 80 | ), 81 | ) 82 | async def login_cookie(evt: CommandEvent) -> EventID: 83 | if len(evt.args) == 0: 84 | return await evt.reply("Please enter a JSON object with the cookies.") 85 | 86 | try: 87 | await evt.az.intent.redact(evt.room_id, evt.event_id) 88 | except MForbidden as e: 89 | evt.log.warning(f"Failed to redact cookies during login: {e}") 90 | 91 | try: 92 | data = json.loads(" ".join(evt.args)) 93 | except Exception as e: 94 | return await evt.reply(f"Invalid JSON: {e}") 95 | 96 | try: 97 | await evt.sender.connect(Cookies(**{k.lower(): v for k, v in data.items()})) 98 | except NotLoggedInError: 99 | return await evt.reply("Those cookies don't seem to be valid") 100 | await evt.sender.name_future 101 | return await evt.reply( 102 | f"Successfully logged in as {evt.sender.name} <{evt.sender.email}> " 103 | f"({evt.sender.gcid})" 104 | ) 105 | -------------------------------------------------------------------------------- /mautrix_googlechat/commands/typehint.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from mautrix.bridge.commands import CommandEvent as BaseCommandEvent 4 | 5 | if TYPE_CHECKING: 6 | from ..__main__ import GoogleChatBridge 7 | from ..user import User 8 | 9 | 10 | class CommandEvent(BaseCommandEvent): 11 | bridge: "GoogleChatBridge" 12 | sender: "User" 13 | -------------------------------------------------------------------------------- /mautrix_googlechat/config.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from mautrix.bridge.config import BaseBridgeConfig, ConfigUpdateHelper 19 | from mautrix.types import UserID 20 | 21 | 22 | class Config(BaseBridgeConfig): 23 | def do_update(self, helper: ConfigUpdateHelper) -> None: 24 | super().do_update(helper) 25 | 26 | copy, copy_dict, base = helper 27 | 28 | copy("metrics.enabled") 29 | copy("metrics.listen_port") 30 | 31 | copy("bridge.username_template") 32 | copy("bridge.displayname_template") 33 | copy("bridge.command_prefix") 34 | 35 | copy("bridge.initial_chat_sync") 36 | copy("bridge.invite_own_puppet_to_pm") 37 | copy("bridge.sync_with_custom_puppets") 38 | copy("bridge.sync_direct_chat_list") 39 | copy("bridge.double_puppet_server_map") 40 | copy("bridge.double_puppet_allow_discovery") 41 | if "bridge.login_shared_secret" in self: 42 | base["bridge.login_shared_secret_map"] = { 43 | base["homeserver.domain"]: self["bridge.login_shared_secret"] 44 | } 45 | else: 46 | copy("bridge.login_shared_secret_map") 47 | copy("bridge.update_avatar_initial_sync") 48 | copy("bridge.delivery_receipts") 49 | copy("bridge.delivery_error_reports") 50 | copy("bridge.message_status_events") 51 | copy("bridge.federate_rooms") 52 | copy("bridge.backfill.invite_own_puppet") 53 | copy("bridge.backfill.initial_thread_limit") 54 | copy("bridge.backfill.initial_thread_reply_limit") 55 | copy("bridge.backfill.initial_nonthread_limit") 56 | copy("bridge.backfill.missed_event_limit") 57 | copy("bridge.backfill.missed_event_page_size") 58 | copy("bridge.backfill.disable_notifications") 59 | copy("bridge.resend_bridge_info") 60 | copy("bridge.unimportant_bridge_notices") 61 | copy("bridge.disable_bridge_notices") 62 | copy("bridge.private_chat_portal_meta") 63 | if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"): 64 | base["bridge.private_chat_portal_meta"] = "default" 65 | 66 | copy("bridge.provisioning.prefix") 67 | if "bridge.web.auth.shared_secret" in self: 68 | base["bridge.provisioning.shared_secret"] = self["bridge.web.auth.shared_secret"] 69 | if self["bridge.provisioning.shared_secret"] == "generate": 70 | base["bridge.provisioning.shared_secret"] = self._new_token() 71 | else: 72 | copy("bridge.provisioning.shared_secret") 73 | 74 | copy_dict("bridge.permissions") 75 | 76 | def _get_permissions(self, key: str) -> tuple[bool, bool, str]: 77 | level = self["bridge.permissions"].get(key, "") 78 | admin = level == "admin" 79 | user = level == "user" or admin 80 | return user, admin, level 81 | 82 | def get_permissions(self, mxid: UserID) -> tuple[bool, bool, str]: 83 | permissions = self["bridge.permissions"] or {} 84 | if mxid in permissions: 85 | return self._get_permissions(mxid) 86 | 87 | homeserver = mxid[mxid.index(":") + 1 :] 88 | if homeserver in permissions: 89 | return self._get_permissions(homeserver) 90 | 91 | return self._get_permissions("*") 92 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/__init__.py: -------------------------------------------------------------------------------- 1 | from mautrix.util.async_db import Database 2 | 3 | from .message import Message 4 | from .portal import Portal 5 | from .puppet import Puppet 6 | from .reaction import Reaction 7 | from .upgrade import upgrade_table 8 | from .user import User 9 | 10 | 11 | def init(db: Database) -> None: 12 | for table in (Portal, Message, Reaction, User, Puppet): 13 | table.db = db 14 | 15 | 16 | __all__ = ["upgrade_table", "init", "Message", "Reaction", "Portal", "User", "Puppet"] 17 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/message.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.types import EventID, RoomID 24 | from mautrix.util.async_db import Database 25 | 26 | fake_db = Database.create("") if TYPE_CHECKING else None 27 | 28 | 29 | @dataclass 30 | class Message: 31 | db: ClassVar[Database] = fake_db 32 | 33 | mxid: EventID 34 | mx_room: RoomID 35 | gcid: str 36 | gc_chat: str 37 | gc_receiver: str 38 | gc_parent_id: str | None 39 | index: int 40 | timestamp: int 41 | msgtype: str | None 42 | gc_sender: str | None 43 | 44 | @classmethod 45 | def _from_row(cls, row: Record | None) -> Message | None: 46 | if row is None: 47 | return None 48 | return cls(**row) 49 | 50 | columns = ( 51 | "mxid, mx_room, gcid, gc_chat, gc_receiver, gc_parent_id, " 52 | '"index", timestamp, msgtype, gc_sender' 53 | ) 54 | 55 | @classmethod 56 | async def get_all_by_gcid(cls, gcid: str, gc_chat: str, gc_receiver: str) -> list[Message]: 57 | q = f"SELECT {cls.columns} FROM message WHERE gcid=$1 AND gc_chat=$2 AND gc_receiver=$3" 58 | rows = await cls.db.fetch(q, gcid, gc_chat, gc_receiver) 59 | return [cls._from_row(row) for row in rows] 60 | 61 | @classmethod 62 | async def get_by_gcid( 63 | cls, gcid: str, gc_chat: str, gc_receiver: str, index: int = 0 64 | ) -> Message | None: 65 | q = ( 66 | f"SELECT {cls.columns} FROM message" 67 | ' WHERE gcid=$1 AND gc_chat=$2 AND gc_receiver=$3 AND "index"=$4' 68 | ) 69 | row = await cls.db.fetchrow(q, gcid, gc_chat, gc_receiver, index) 70 | return cls._from_row(row) 71 | 72 | @classmethod 73 | async def get_last_in_thread( 74 | cls, gc_parent_id: str, gc_chat: str, gc_receiver: str 75 | ) -> Message | None: 76 | q = ( 77 | f"SELECT {cls.columns} FROM message" 78 | " WHERE (gc_parent_id=$1 OR gcid=$1) AND gc_chat=$2 AND gc_receiver=$3" 79 | ' ORDER BY timestamp DESC, "index" DESC LIMIT 1' 80 | ) 81 | row = await cls.db.fetchrow(q, gc_parent_id, gc_chat, gc_receiver) 82 | return cls._from_row(row) 83 | 84 | @classmethod 85 | async def delete_all_by_room(cls, room_id: RoomID) -> None: 86 | await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id) 87 | 88 | @classmethod 89 | async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Message | None: 90 | if not mxid: 91 | return None 92 | q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2" 93 | row = await cls.db.fetchrow(q, mxid, mx_room) 94 | return cls._from_row(row) 95 | 96 | @classmethod 97 | async def get_most_recent(cls, gc_chat: str, gc_receiver: str) -> Message | None: 98 | q = ( 99 | f"SELECT {cls.columns} FROM message" 100 | " WHERE gc_chat=$1 AND gc_receiver=$2 ORDER BY timestamp DESC LIMIT 1" 101 | ) 102 | row = await cls.db.fetchrow(q, gc_chat, gc_receiver) 103 | return cls._from_row(row) 104 | 105 | @classmethod 106 | async def get_closest_before( 107 | cls, gc_chat: str, gc_receiver: str, timestamp: int 108 | ) -> Message | None: 109 | q = ( 110 | f"SELECT {cls.columns} FROM message" 111 | " WHERE gc_chat=$1 AND gc_receiver=$2 AND timestamp<=$3" 112 | ' ORDER BY timestamp DESC, "index" DESC LIMIT 1' 113 | ) 114 | row = await cls.db.fetchrow(q, gc_chat, gc_receiver, timestamp) 115 | return cls._from_row(row) 116 | 117 | async def insert(self) -> None: 118 | q = ( 119 | "INSERT INTO message (mxid, mx_room, gcid, gc_chat, gc_receiver, gc_parent_id, " 120 | ' "index", timestamp, msgtype, gc_sender) ' 121 | "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)" 122 | ) 123 | await self.db.execute( 124 | q, 125 | self.mxid, 126 | self.mx_room, 127 | self.gcid, 128 | self.gc_chat, 129 | self.gc_receiver, 130 | self.gc_parent_id, 131 | self.index, 132 | self.timestamp, 133 | self.msgtype, 134 | self.gc_sender, 135 | ) 136 | 137 | async def delete(self) -> None: 138 | q = 'DELETE FROM message WHERE gcid=$1 AND gc_receiver=$2 AND "index"=$3' 139 | await self.db.execute(q, self.gcid, self.gc_receiver, self.index) 140 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/portal.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.types import ContentURI, RoomID 24 | from mautrix.util.async_db import Database 25 | 26 | fake_db = Database.create("") if TYPE_CHECKING else None 27 | 28 | 29 | @dataclass 30 | class Portal: 31 | db: ClassVar[Database] = fake_db 32 | 33 | gcid: str 34 | gc_receiver: str 35 | other_user_id: str | None 36 | mxid: RoomID | None 37 | name: str | None 38 | avatar_mxc: ContentURI | None 39 | description: str | None 40 | name_set: bool 41 | avatar_set: bool 42 | description_set: bool 43 | encrypted: bool 44 | revision: int | None 45 | threads_only: bool | None 46 | threads_enabled: bool | None 47 | 48 | @classmethod 49 | def _from_row(cls, row: Record | None) -> Portal | None: 50 | if row is None: 51 | return None 52 | return cls(**row) 53 | 54 | columns = ( 55 | "gcid, gc_receiver, other_user_id, mxid, name, avatar_mxc, description, " 56 | "name_set, avatar_set, description_set, encrypted, revision, threads_only, threads_enabled" 57 | ) 58 | 59 | @classmethod 60 | async def get_by_gcid(cls, gcid: str, gc_receiver: str) -> Portal | None: 61 | q = f"SELECT {cls.columns} FROM portal WHERE gcid=$1 AND gc_receiver=$2" 62 | row = await cls.db.fetchrow(q, gcid, gc_receiver) 63 | return cls._from_row(row) 64 | 65 | @classmethod 66 | async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: 67 | q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1" 68 | row = await cls.db.fetchrow(q, mxid) 69 | return cls._from_row(row) 70 | 71 | @classmethod 72 | async def get_all_by_receiver(cls, gc_receiver: str) -> list[Portal]: 73 | q = f"SELECT {cls.columns} FROM portal WHERE gc_receiver=$1 AND gcid LIKE 'dm:%'" 74 | rows = await cls.db.fetch(q, gc_receiver) 75 | return [cls._from_row(row) for row in rows] 76 | 77 | @classmethod 78 | async def all(cls) -> list[Portal]: 79 | q = f"SELECT {cls.columns} FROM portal" 80 | rows = await cls.db.fetch(q) 81 | return [cls._from_row(row) for row in rows] 82 | 83 | @property 84 | def _values(self): 85 | return ( 86 | self.gcid, 87 | self.gc_receiver, 88 | self.other_user_id, 89 | self.mxid, 90 | self.name, 91 | self.avatar_mxc, 92 | self.description, 93 | self.name_set, 94 | self.avatar_set, 95 | self.description_set, 96 | self.encrypted, 97 | self.revision, 98 | self.threads_only, 99 | self.threads_enabled, 100 | ) 101 | 102 | async def insert(self) -> None: 103 | q = """ 104 | INSERT INTO portal ( 105 | gcid, gc_receiver, other_user_id, mxid, name, avatar_mxc, description, 106 | name_set, avatar_set, description_set, encrypted, 107 | revision, threads_only, threads_enabled 108 | ) 109 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 110 | """ 111 | await self.db.execute(q, *self._values) 112 | 113 | async def delete(self) -> None: 114 | q = "DELETE FROM portal WHERE gcid=$1 AND gc_receiver=$2" 115 | await self.db.execute(q, self.gcid, self.gc_receiver) 116 | 117 | async def save(self) -> None: 118 | q = """ 119 | UPDATE portal 120 | SET other_user_id=$3, mxid=$4, name=$5, avatar_mxc=$6, description=$7, 121 | name_set=$8, avatar_set=$9, description_set=$10, encrypted=$11, 122 | revision=$12, threads_only=$13, threads_enabled=$14 123 | WHERE gcid=$1 AND gc_receiver=$2 124 | """ 125 | await self.db.execute(q, *self._values) 126 | 127 | async def set_revision(self, revision: int) -> None: 128 | if self.revision and self.revision >= revision > 0: 129 | return 130 | self.revision = revision 131 | q = ( 132 | "UPDATE portal SET revision=$1 " 133 | "WHERE gcid=$2 AND gc_receiver=$3 AND (revision IS NULL OR revision<$1)" 134 | ) 135 | await self.db.execute(q, self.revision, self.gcid, self.gc_receiver) 136 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/puppet.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | from yarl import URL 23 | 24 | from mautrix.types import ContentURI, SyncToken, UserID 25 | from mautrix.util.async_db import Database 26 | 27 | fake_db = Database.create("") if TYPE_CHECKING else None 28 | 29 | 30 | @dataclass 31 | class Puppet: 32 | db: ClassVar[Database] = fake_db 33 | 34 | gcid: str 35 | name: str | None 36 | photo_id: str | None 37 | photo_mxc: ContentURI | None 38 | photo_hash: str | None 39 | name_set: bool 40 | avatar_set: bool 41 | contact_info_set: bool 42 | is_registered: bool 43 | 44 | custom_mxid: UserID | None 45 | access_token: str | None 46 | next_batch: SyncToken | None 47 | base_url: URL | None 48 | 49 | @classmethod 50 | def _from_row(cls, row: Record | None) -> Puppet | None: 51 | if row is None: 52 | return None 53 | data = {**row} 54 | base_url = data.pop("base_url", None) 55 | return cls(**data, base_url=URL(base_url) if base_url else None) 56 | 57 | columns = ( 58 | "gcid, name, photo_id, photo_mxc, name_set, avatar_set, contact_info_set, is_registered, " 59 | "custom_mxid, access_token, next_batch, base_url, photo_hash" 60 | ) 61 | 62 | @classmethod 63 | async def get_by_gcid(cls, gcid: str) -> Puppet | None: 64 | q = f"SELECT {cls.columns} FROM puppet WHERE gcid=$1" 65 | row = await cls.db.fetchrow(q, gcid) 66 | return cls._from_row(row) 67 | 68 | @classmethod 69 | async def get_by_name(cls, name: str) -> Puppet | None: 70 | q = f"SELECT {cls.columns} FROM puppet WHERE name=$1" 71 | row = await cls.db.fetchrow(q, name) 72 | return cls._from_row(row) 73 | 74 | @classmethod 75 | async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: 76 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1" 77 | row = await cls.db.fetchrow(q, mxid) 78 | return cls._from_row(row) 79 | 80 | @classmethod 81 | async def get_all_with_custom_mxid(cls) -> list[Puppet]: 82 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''" 83 | rows = await cls.db.fetch(q) 84 | return [cls._from_row(row) for row in rows] 85 | 86 | @property 87 | def _values(self): 88 | return ( 89 | self.gcid, 90 | self.name, 91 | self.photo_id, 92 | self.photo_mxc, 93 | self.name_set, 94 | self.avatar_set, 95 | self.contact_info_set, 96 | self.is_registered, 97 | self.custom_mxid, 98 | self.access_token, 99 | self.next_batch, 100 | str(self.base_url) if self.base_url else None, 101 | self.photo_hash, 102 | ) 103 | 104 | async def insert(self) -> None: 105 | q = f""" 106 | INSERT INTO puppet ({self.columns}) 107 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 108 | """ 109 | await self.db.execute(q, *self._values) 110 | 111 | async def delete(self) -> None: 112 | q = "DELETE FROM puppet WHERE gcid=$1" 113 | await self.db.execute(q, self.gcid) 114 | 115 | async def save(self) -> None: 116 | q = """ 117 | UPDATE puppet 118 | SET name=$2, photo_id=$3, photo_mxc=$4, name_set=$5, avatar_set=$6, 119 | contact_info_set=$7, is_registered=$8, custom_mxid=$9, access_token=$10, 120 | next_batch=$11, base_url=$12, photo_hash=$13 121 | WHERE gcid=$1 122 | """ 123 | await self.db.execute(q, *self._values) 124 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/reaction.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.types import EventID, RoomID 24 | from mautrix.util.async_db import Database 25 | 26 | fake_db = Database.create("") if TYPE_CHECKING else None 27 | 28 | 29 | @dataclass 30 | class Reaction: 31 | db: ClassVar[Database] = fake_db 32 | 33 | mxid: EventID 34 | mx_room: RoomID 35 | emoji: str 36 | gc_sender: str 37 | gc_msgid: str 38 | gc_chat: str 39 | gc_receiver: str 40 | timestamp: int 41 | 42 | @classmethod 43 | def _from_row(cls, row: Record | None) -> Reaction | None: 44 | if row is None: 45 | return None 46 | return cls(**row) 47 | 48 | columns = "mxid, mx_room, emoji, gc_sender, gc_msgid, gc_chat, gc_receiver, timestamp" 49 | 50 | @classmethod 51 | async def get_all_by_gcid(cls, gcid: str, gc_receiver: str) -> list[Reaction]: 52 | q = f"SELECT {cls.columns} FROM message WHERE gcid=$1 AND gc_receiver=$2" 53 | rows = await cls.db.fetch(q, gcid, gc_receiver) 54 | return [cls._from_row(row) for row in rows] 55 | 56 | @classmethod 57 | async def get_by_gcid( 58 | cls, emoji: str, gc_sender: str, gc_msgid: str, gc_chat: str, gc_receiver: str 59 | ) -> Reaction | None: 60 | q = ( 61 | f"SELECT {cls.columns} FROM reaction " 62 | f"WHERE emoji=$1 AND gc_sender=$2 AND gc_msgid=$3 AND gc_chat=$4 AND gc_receiver=$5" 63 | ) 64 | row = await cls.db.fetchrow(q, emoji, gc_sender, gc_msgid, gc_chat, gc_receiver) 65 | return cls._from_row(row) 66 | 67 | @classmethod 68 | async def delete_all_by_room(cls, room_id: RoomID) -> None: 69 | await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id) 70 | 71 | @classmethod 72 | async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None: 73 | q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2" 74 | row = await cls.db.fetchrow(q, mxid, mx_room) 75 | return cls._from_row(row) 76 | 77 | async def insert(self) -> None: 78 | q = ( 79 | "INSERT INTO reaction (mxid, mx_room, emoji, gc_sender, gc_msgid, gc_chat," 80 | " gc_receiver, timestamp) " 81 | "VALUES ($1, $2, $3, $4, $5, $6, $7, $8)" 82 | ) 83 | await self.db.execute( 84 | q, 85 | self.mxid, 86 | self.mx_room, 87 | self.emoji, 88 | self.gc_sender, 89 | self.gc_msgid, 90 | self.gc_chat, 91 | self.gc_receiver, 92 | self.timestamp, 93 | ) 94 | 95 | async def delete(self) -> None: 96 | q = ( 97 | "DELETE FROM reaction WHERE emoji=$1 AND gc_sender=$2 AND gc_msgid=$3" 98 | " AND gc_chat=$4 AND gc_receiver=$5" 99 | ) 100 | await self.db.execute( 101 | q, self.emoji, self.gc_sender, self.gc_msgid, self.gc_chat, self.gc_receiver 102 | ) 103 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/__init__.py: -------------------------------------------------------------------------------- 1 | from mautrix.util.async_db import UpgradeTable 2 | 3 | upgrade_table = UpgradeTable() 4 | 5 | from . import ( 6 | v00_latest_revision, 7 | v02_reactions, 8 | v03_store_gc_revision, 9 | v04_store_photo_hash, 10 | v05_rename_thread_columns, 11 | v06_space_description, 12 | v07_puppet_contact_info_set, 13 | v08_web_app_auth, 14 | v09_web_app_ua, 15 | v10_store_microsecond_timestamp, 16 | ) 17 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v00_latest_revision.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Latest revision", upgrades_to=10) 22 | async def upgrade_latest(conn: Connection) -> None: 23 | await conn.execute( 24 | """CREATE TABLE "user" ( 25 | mxid TEXT PRIMARY KEY, 26 | gcid TEXT UNIQUE, 27 | cookies TEXT, 28 | user_agent TEXT, 29 | notice_room TEXT, 30 | revision BIGINT 31 | )""" 32 | ) 33 | await conn.execute( 34 | """CREATE TABLE portal ( 35 | gcid TEXT, 36 | gc_receiver TEXT, 37 | other_user_id TEXT, 38 | mxid TEXT UNIQUE, 39 | name TEXT, 40 | avatar_mxc TEXT, 41 | description TEXT, 42 | name_set BOOLEAN NOT NULL DEFAULT false, 43 | avatar_set BOOLEAN NOT NULL DEFAULT false, 44 | description_set BOOLEAN NOT NULL DEFAULT false, 45 | encrypted BOOLEAN NOT NULL DEFAULT false, 46 | revision BIGINT, 47 | threads_only BOOLEAN, 48 | threads_enabled BOOLEAN, 49 | PRIMARY KEY (gcid, gc_receiver) 50 | )""" 51 | ) 52 | await conn.execute( 53 | """CREATE TABLE puppet ( 54 | gcid TEXT PRIMARY KEY, 55 | name TEXT, 56 | photo_id TEXT, 57 | photo_mxc TEXT, 58 | photo_hash TEXT, 59 | 60 | name_set BOOLEAN NOT NULL DEFAULT false, 61 | avatar_set BOOLEAN NOT NULL DEFAULT false, 62 | is_registered BOOLEAN NOT NULL DEFAULT false, 63 | 64 | contact_info_set BOOLEAN NOT NULL DEFAULT false, 65 | 66 | custom_mxid TEXT, 67 | access_token TEXT, 68 | next_batch TEXT, 69 | base_url TEXT 70 | )""" 71 | ) 72 | await conn.execute( 73 | """CREATE TABLE "message" ( 74 | mxid TEXT NOT NULL, 75 | mx_room TEXT NOT NULL, 76 | gcid TEXT, 77 | gc_chat TEXT NOT NULL, 78 | gc_receiver TEXT, 79 | gc_parent_id TEXT, 80 | gc_sender TEXT, 81 | "index" SMALLINT NOT NULL, 82 | timestamp BIGINT NOT NULL, 83 | msgtype TEXT, 84 | PRIMARY KEY (gcid, gc_chat, gc_receiver, "index"), 85 | FOREIGN KEY (gc_chat, gc_receiver) REFERENCES portal(gcid, gc_receiver) 86 | ON UPDATE CASCADE ON DELETE CASCADE, 87 | UNIQUE (mxid, mx_room) 88 | )""" 89 | ) 90 | await conn.execute( 91 | """CREATE TABLE reaction ( 92 | mxid TEXT NOT NULL, 93 | mx_room TEXT NOT NULL, 94 | emoji TEXT, 95 | gc_sender TEXT, 96 | gc_msgid TEXT, 97 | gc_chat TEXT, 98 | gc_receiver TEXT, 99 | timestamp BIGINT NOT NULL, 100 | _index SMALLINT DEFAULT 0, 101 | PRIMARY KEY (emoji, gc_sender, gc_msgid, gc_chat, gc_receiver), 102 | FOREIGN KEY (gc_chat, gc_receiver) 103 | REFERENCES portal(gcid, gc_receiver) 104 | ON UPDATE CASCADE ON DELETE CASCADE, 105 | FOREIGN KEY (gc_msgid, gc_chat, gc_receiver, _index) 106 | REFERENCES message(gcid, gc_chat, gc_receiver, "index") 107 | ON UPDATE CASCADE ON DELETE CASCADE, 108 | UNIQUE (mxid, mx_room) 109 | )""" 110 | ) 111 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v02_reactions.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection, Scheme 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add reaction table and update message table") 22 | async def upgrade_v2(conn: Connection, scheme: Scheme) -> None: 23 | if scheme != Scheme.SQLITE: 24 | # This change was backported to the v1 db schema before SQLite support was added 25 | await conn.execute( 26 | "ALTER TABLE message" 27 | " DROP CONSTRAINT message_pkey," 28 | ' ADD PRIMARY KEY (gcid, gc_chat, gc_receiver, "index")' 29 | ) 30 | await conn.execute("ALTER TABLE message ADD COLUMN msgtype TEXT") 31 | await conn.execute("ALTER TABLE message ADD COLUMN gc_sender TEXT") 32 | await conn.execute( 33 | """CREATE TABLE reaction ( 34 | mxid TEXT NOT NULL, 35 | mx_room TEXT NOT NULL, 36 | emoji TEXT, 37 | gc_sender TEXT, 38 | gc_msgid TEXT, 39 | gc_chat TEXT, 40 | gc_receiver TEXT, 41 | timestamp BIGINT NOT NULL, 42 | _index SMALLINT DEFAULT 0, 43 | PRIMARY KEY (emoji, gc_sender, gc_msgid, gc_chat, gc_receiver), 44 | FOREIGN KEY (gc_chat, gc_receiver) 45 | REFERENCES portal(gcid, gc_receiver) 46 | ON UPDATE CASCADE ON DELETE CASCADE, 47 | FOREIGN KEY (gc_msgid, gc_chat, gc_receiver, _index) 48 | REFERENCES message(gcid, gc_chat, gc_receiver, "index") 49 | ON UPDATE CASCADE ON DELETE CASCADE, 50 | UNIQUE (mxid, mx_room) 51 | )""" 52 | ) 53 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v03_store_gc_revision.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add revision column for users and portals") 22 | async def upgrade_v3(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal ADD COLUMN revision BIGINT") 24 | await conn.execute("ALTER TABLE portal ADD COLUMN is_threaded BOOLEAN") 25 | await conn.execute('ALTER TABLE "user" ADD COLUMN revision BIGINT') 26 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v04_store_photo_hash.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add photo_hash column for puppets") 22 | async def upgrade_v4(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE puppet ADD COLUMN photo_hash TEXT") 24 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v05_rename_thread_columns.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Update storing thread enabled status") 22 | async def upgrade_v5(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal RENAME COLUMN is_threaded TO threads_only") 24 | await conn.execute("ALTER TABLE portal ADD COLUMN threads_enabled BOOLEAN") 25 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v06_space_description.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store chat description in portal table") 22 | async def upgrade_v6(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal ADD COLUMN description TEXT") 24 | await conn.execute( 25 | "ALTER TABLE portal ADD COLUMN description_set BOOLEAN NOT NULL DEFAULT false" 26 | ) 27 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v07_puppet_contact_info_set.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add contact_info_set column to puppet table") 22 | async def upgrade_v07(conn: Connection) -> None: 23 | await conn.execute( 24 | "ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false" 25 | ) 26 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v08_web_app_auth.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Drop old access tokens and rename auth data column") 22 | async def upgrade_v08(conn: Connection) -> None: 23 | await conn.execute('ALTER TABLE "user" RENAME COLUMN refresh_token TO cookies') 24 | await conn.execute('UPDATE "user" SET cookies=null') 25 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v09_web_app_ua.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store user agent for users") 22 | async def upgrade_v09(conn: Connection) -> None: 23 | await conn.execute('ALTER TABLE "user" ADD COLUMN user_agent TEXT') 24 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/upgrade/v10_store_microsecond_timestamp.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store message timestamps in microseconds") 22 | async def upgrade_v10(conn: Connection) -> None: 23 | await conn.execute('UPDATE "message" SET timestamp=timestamp*1000') 24 | await conn.execute('UPDATE "reaction" SET timestamp=timestamp*1000') 25 | -------------------------------------------------------------------------------- /mautrix_googlechat/db/user.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | import json 20 | 21 | from asyncpg import Record 22 | from attr import dataclass 23 | 24 | from maugclib import Cookies 25 | from mautrix.types import RoomID, UserID 26 | from mautrix.util.async_db import Database 27 | 28 | fake_db = Database.create("") if TYPE_CHECKING else None 29 | 30 | 31 | @dataclass 32 | class User: 33 | db: ClassVar[Database] = fake_db 34 | 35 | mxid: UserID 36 | gcid: str | None 37 | cookies: Cookies | None 38 | user_agent: str | None 39 | notice_room: RoomID | None 40 | revision: int | None 41 | 42 | @classmethod 43 | def _from_row(cls, row: Record | None) -> User | None: 44 | if row is None: 45 | return None 46 | data = {**row} 47 | cookies_raw = data.pop("cookies") 48 | cookies = Cookies(**json.loads(cookies_raw)) if cookies_raw else None 49 | return cls(**data, cookies=cookies) 50 | 51 | @classmethod 52 | async def all_logged_in(cls) -> list[User]: 53 | q = ( 54 | 'SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" ' 55 | "WHERE cookies IS NOT NULL" 56 | ) 57 | rows = await cls.db.fetch(q) 58 | return [cls._from_row(row) for row in rows] 59 | 60 | @classmethod 61 | async def get_by_gcid(cls, gcid: str) -> User | None: 62 | q = """ 63 | SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" WHERE gcid=$1 64 | """ 65 | row = await cls.db.fetchrow(q, gcid) 66 | return cls._from_row(row) 67 | 68 | @classmethod 69 | async def get_by_mxid(cls, mxid: UserID) -> User | None: 70 | q = """ 71 | SELECT mxid, gcid, cookies, user_agent, notice_room, revision FROM "user" WHERE mxid=$1 72 | """ 73 | row = await cls.db.fetchrow(q, mxid) 74 | return cls._from_row(row) 75 | 76 | @property 77 | def _values(self): 78 | return ( 79 | self.mxid, 80 | self.gcid, 81 | json.dumps(self.cookies._asdict()) if self.cookies else None, 82 | self.user_agent, 83 | self.notice_room, 84 | self.revision, 85 | ) 86 | 87 | async def insert(self) -> None: 88 | q = ( 89 | 'INSERT INTO "user" (mxid, gcid, cookies, user_agent, notice_room, revision) ' 90 | "VALUES ($1, $2, $3, $4, $5, $6)" 91 | ) 92 | await self.db.execute(q, *self._values) 93 | 94 | async def delete(self) -> None: 95 | await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid) 96 | 97 | async def save(self) -> None: 98 | q = ( 99 | 'UPDATE "user" SET gcid=$2, cookies=$3, user_agent=$4, notice_room=$5, revision=$6 ' 100 | "WHERE mxid=$1" 101 | ) 102 | await self.db.execute(q, *self._values) 103 | 104 | async def set_revision(self, revision: int) -> None: 105 | if self.revision and self.revision >= revision > 0: 106 | return 107 | self.revision = revision 108 | q = 'UPDATE "user" SET revision=$1 WHERE mxid=$2 AND (revision IS NULL OR revision<$1)' 109 | await self.db.execute(q, self.revision, self.mxid) 110 | -------------------------------------------------------------------------------- /mautrix_googlechat/example-config.yaml: -------------------------------------------------------------------------------- 1 | # Homeserver details 2 | homeserver: 3 | # The address that this appservice can use to connect to the homeserver. 4 | address: https://example.com 5 | # The domain of the homeserver (for MXIDs, etc). 6 | domain: example.com 7 | # Whether or not to verify the SSL certificate of the homeserver. 8 | # Only applies if address starts with https:// 9 | verify_ssl: true 10 | # What software is the homeserver running? 11 | # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. 12 | software: standard 13 | # Number of retries for all HTTP requests if the homeserver isn't reachable. 14 | http_retry_count: 4 15 | # The URL to push real-time bridge status to. 16 | # If set, the bridge will make POST requests to this URL whenever a user's Google Chat connection state changes. 17 | # The bridge will use the appservice as_token to authorize requests. 18 | status_endpoint: null 19 | # Endpoint for reporting per-message status. 20 | message_send_checkpoint_endpoint: null 21 | # Whether asynchronous uploads via MSC2246 should be enabled for media. 22 | # Requires a media repo that supports MSC2246. 23 | async_media: false 24 | 25 | # Application service host/registration related details 26 | # Changing these values requires regeneration of the registration. 27 | appservice: 28 | # The address that the homeserver can use to connect to this appservice. 29 | address: http://localhost:29320 30 | 31 | # The hostname and port where this appservice should listen. 32 | hostname: 0.0.0.0 33 | port: 29320 34 | # The maximum body size of appservice API requests (from the homeserver) in mebibytes 35 | # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s 36 | max_body_size: 1 37 | 38 | # The full URI to the database. SQLite and Postgres are supported. 39 | # Format examples: 40 | # SQLite: sqlite:filename.db 41 | # Postgres: postgres://username:password@hostname/dbname 42 | database: postgres://username:password@hostname/db 43 | # Additional arguments for asyncpg.create_pool() or sqlite3.connect() 44 | # https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool 45 | # https://docs.python.org/3/library/sqlite3.html#sqlite3.connect 46 | # For sqlite, min_size is used as the connection thread pool size and max_size is ignored. 47 | # Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs). 48 | database_opts: 49 | min_size: 1 50 | max_size: 10 51 | 52 | # The unique ID of this appservice. 53 | id: googlechat 54 | # Username of the appservice bot. 55 | bot_username: googlechatbot 56 | # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty 57 | # to leave display name/avatar as-is. 58 | bot_displayname: Google Chat bridge bot 59 | bot_avatar: mxc://maunium.net/BDIWAQcbpPGASPUUBuEGWXnQ 60 | 61 | # Whether or not to receive ephemeral events via appservice transactions. 62 | # Requires MSC2409 support (i.e. Synapse 1.22+). 63 | # You should disable bridge -> sync_with_custom_puppets when this is enabled. 64 | ephemeral_events: true 65 | 66 | # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. 67 | as_token: "This value is generated when generating the registration" 68 | hs_token: "This value is generated when generating the registration" 69 | 70 | # Prometheus telemetry config. Requires prometheus-client to be installed. 71 | metrics: 72 | enabled: false 73 | listen_port: 8000 74 | 75 | # Manhole config. 76 | manhole: 77 | # Whether or not opening the manhole is allowed. 78 | enabled: false 79 | # The path for the unix socket. 80 | path: /var/tmp/mautrix-googlechat.manhole 81 | # The list of UIDs who can be added to the whitelist. 82 | # If empty, any UIDs can be specified in the open-manhole command. 83 | whitelist: 84 | - 0 85 | 86 | # Bridge config 87 | bridge: 88 | # Localpart template of MXIDs for Google Chat users. 89 | # {userid} is replaced with the user ID of the Google Chat user. 90 | username_template: "googlechat_{userid}" 91 | # Displayname template for Google Chat users. 92 | # {full_name}, {first_name}, {last_name} and {email} are replaced with names. 93 | displayname_template: "{full_name} (Google Chat)" 94 | 95 | # The prefix for commands. Only required in non-management rooms. 96 | command_prefix: "!gc" 97 | 98 | # Number of chats to sync (and create portals for) on startup/login. 99 | # Set 0 to disable automatic syncing. 100 | initial_chat_sync: 10 101 | # Whether or not the Google Chat users of logged in Matrix users should be 102 | # invited to private chats when the user sends a message from another client. 103 | invite_own_puppet_to_pm: false 104 | # Whether or not to use /sync to get presence, read receipts and typing notifications 105 | # when double puppeting is enabled 106 | sync_with_custom_puppets: false 107 | # Whether or not to update the m.direct account data event when double puppeting is enabled. 108 | # Note that updating the m.direct event is not atomic (except with mautrix-asmux) 109 | # and is therefore prone to race conditions. 110 | sync_direct_chat_list: false 111 | # Servers to always allow double puppeting from 112 | double_puppet_server_map: 113 | example.com: https://example.com 114 | # Allow using double puppeting from any server with a valid client .well-known file. 115 | double_puppet_allow_discovery: false 116 | # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth 117 | # 118 | # If set, custom puppets will be enabled automatically for local users 119 | # instead of users having to find an access token and run `login-matrix` 120 | # manually. 121 | # If using this for other servers than the bridge's server, 122 | # you must also set the URL in the double_puppet_server_map. 123 | login_shared_secret_map: 124 | example.com: foobar 125 | # Whether or not to update avatars when syncing all contacts at startup. 126 | update_avatar_initial_sync: true 127 | # End-to-bridge encryption support options. 128 | # 129 | # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. 130 | encryption: 131 | # Allow encryption, work in group chat rooms with e2ee enabled 132 | allow: false 133 | # Default to encryption, force-enable encryption in all portals the bridge creates 134 | # This will cause the bridge bot to be in private chats for the encryption to work properly. 135 | default: false 136 | # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. 137 | appservice: false 138 | # Require encryption, drop any unencrypted messages. 139 | require: false 140 | # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. 141 | # You must use a client that supports requesting keys from other users to use this feature. 142 | allow_key_sharing: false 143 | # Options for deleting megolm sessions from the bridge. 144 | delete_keys: 145 | # Beeper-specific: delete outbound sessions when hungryserv confirms 146 | # that the user has uploaded the key to key backup. 147 | delete_outbound_on_ack: false 148 | # Don't store outbound sessions in the inbound table. 149 | dont_store_outbound: false 150 | # Ratchet megolm sessions forward after decrypting messages. 151 | ratchet_on_decrypt: false 152 | # Delete fully used keys (index >= max_messages) after decrypting messages. 153 | delete_fully_used_on_decrypt: false 154 | # Delete previous megolm sessions from same device when receiving a new one. 155 | delete_prev_on_new_session: false 156 | # Delete megolm sessions received from a device when the device is deleted. 157 | delete_on_device_delete: false 158 | # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. 159 | periodically_delete_expired: false 160 | # Delete inbound megolm sessions that don't have the received_at field used for 161 | # automatic ratcheting and expired session deletion. This is meant as a migration 162 | # to delete old keys prior to the bridge update. 163 | delete_outdated_inbound: false 164 | # What level of device verification should be required from users? 165 | # 166 | # Valid levels: 167 | # unverified - Send keys to all device in the room. 168 | # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. 169 | # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). 170 | # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. 171 | # Note that creating user signatures from the bridge bot is not currently possible. 172 | # verified - Require manual per-device verification 173 | # (currently only possible by modifying the `trust` column in the `crypto_device` database table). 174 | verification_levels: 175 | # Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix. 176 | receive: unverified 177 | # Minimum level that the bridge should accept for incoming Matrix messages. 178 | send: unverified 179 | # Minimum level that the bridge should require for accepting key requests. 180 | share: cross-signed-tofu 181 | # Options for Megolm room key rotation. These options allow you to 182 | # configure the m.room.encryption event content. See: 183 | # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for 184 | # more information about that event. 185 | rotation: 186 | # Enable custom Megolm room key rotation settings. Note that these 187 | # settings will only apply to rooms created after this option is 188 | # set. 189 | enable_custom: false 190 | # The maximum number of milliseconds a session should be used 191 | # before changing it. The Matrix spec recommends 604800000 (a week) 192 | # as the default. 193 | milliseconds: 604800000 194 | # The maximum number of messages that should be sent with a given a 195 | # session before changing it. The Matrix spec recommends 100 as the 196 | # default. 197 | messages: 100 198 | 199 | # Disable rotating keys when a user's devices change? 200 | # You should not enable this option unless you understand all the implications. 201 | disable_device_change_key_rotation: false 202 | 203 | # Whether or not the bridge should send a read receipt from the bridge bot when a message has 204 | # been sent to Google Chat. 205 | delivery_receipts: false 206 | # Whether or not delivery errors should be reported as messages in the Matrix room. 207 | delivery_error_reports: true 208 | # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. 209 | message_status_events: false 210 | # Whether or not created rooms should have federation enabled. 211 | # If false, created portal rooms will never be federated. 212 | federate_rooms: true 213 | # Settings for backfilling messages from Google Chat. 214 | backfill: 215 | # Whether or not the Google Chat users of logged in Matrix users should be 216 | # invited to private chats when backfilling history from Google Chat. This is 217 | # usually needed to prevent rate limits and to allow timestamp massaging. 218 | invite_own_puppet: true 219 | # Number of threads to backfill in threaded spaces in initial backfill. 220 | initial_thread_limit: 10 221 | # Number of replies to backfill in each thread in initial backfill. 222 | initial_thread_reply_limit: 500 223 | # Number of messages to backfill in non-threaded spaces and DMs in initial backfill. 224 | initial_nonthread_limit: 100 225 | # Number of events to backfill in catchup backfill. 226 | missed_event_limit: 5000 227 | # How many events to request from Google Chat at once in catchup backfill? 228 | missed_event_page_size: 100 229 | # If using double puppeting, should notifications be disabled 230 | # while the initial backfill is in progress? 231 | disable_notifications: false 232 | 233 | # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. 234 | # This field will automatically be changed back to false after it, 235 | # except if the config file is not writable. 236 | resend_bridge_info: false 237 | # Whether or not unimportant bridge notices should be sent to the bridge notice room. 238 | unimportant_bridge_notices: false 239 | # Whether or not bridge notices should be disabled entirely. 240 | disable_bridge_notices: false 241 | # Whether to explicitly set the avatar and room name for private chat portal rooms. 242 | # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. 243 | # If set to `always`, all DM rooms will have explicit names and avatars set. 244 | # If set to `never`, DM rooms will never have names and avatars set. 245 | private_chat_portal_meta: default 246 | 247 | provisioning: 248 | # Internal prefix in the appservice web server for the login endpoints. 249 | prefix: /_matrix/provision 250 | # Shared secret for integration managers such as mautrix-manager. 251 | # If set to "generate", a random string will be generated on the next startup. 252 | # If null, integration manager access to the API will not be possible. 253 | shared_secret: generate 254 | 255 | # Permissions for using the bridge. 256 | # Permitted values: 257 | # user - Use the bridge with puppeting. 258 | # admin - Use and administrate the bridge. 259 | # Permitted keys: 260 | # * - All Matrix users 261 | # domain - All users on that homeserver 262 | # mxid - Specific user 263 | permissions: 264 | "example.com": "user" 265 | "@admin:example.com": "admin" 266 | 267 | # Python logging configuration. 268 | # 269 | # See section 16.7.2 of the Python documentation for more info: 270 | # https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema 271 | logging: 272 | version: 1 273 | formatters: 274 | colored: 275 | (): mautrix_googlechat.util.ColorFormatter 276 | format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" 277 | normal: 278 | format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" 279 | handlers: 280 | file: 281 | class: logging.handlers.RotatingFileHandler 282 | formatter: normal 283 | filename: ./mautrix-googlechat.log 284 | maxBytes: 10485760 285 | backupCount: 10 286 | console: 287 | class: logging.StreamHandler 288 | formatter: colored 289 | loggers: 290 | mau: 291 | level: DEBUG 292 | maugclib: 293 | level: INFO 294 | aiohttp: 295 | level: INFO 296 | root: 297 | level: DEBUG 298 | handlers: [file, console] 299 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/__init__.py: -------------------------------------------------------------------------------- 1 | from .from_googlechat import googlechat_to_matrix 2 | from .from_matrix import matrix_to_googlechat 3 | from .gc_url_preview import DRIVE_OPEN_URL, YOUTUBE_URL 4 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/from_googlechat.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from html import escape 19 | 20 | from maugclib import googlechat_pb2 as googlechat 21 | from mautrix.types import Format, MessageType, TextMessageEventContent 22 | from mautrix.util.formatter import parse_html 23 | 24 | from .. import portal as po, puppet as pu, user as u 25 | from .gc_url_preview import gc_previews_to_beeper 26 | from .util import FormatError, add_surrogate, del_surrogate 27 | 28 | 29 | async def googlechat_to_matrix( 30 | source: u.User, 31 | evt: googlechat.Message, 32 | portal: po.Portal, 33 | ) -> TextMessageEventContent: 34 | content = TextMessageEventContent( 35 | msgtype=MessageType.TEXT, 36 | body=add_surrogate(evt.text_body), 37 | ) 38 | content["com.beeper.linkpreviews"] = await gc_previews_to_beeper( 39 | source, 40 | content.body, 41 | evt.annotations or [], 42 | encrypt=portal.encrypted, 43 | async_upload=portal.config["homeserver.async_media"], 44 | ) 45 | if annotations: 46 | content.format = Format.HTML 47 | content.formatted_body = await _gc_annotations_to_matrix_catch( 48 | portal, content.body, evt.annotations 49 | ) 50 | 51 | if content.formatted_body: 52 | content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "
")) 53 | content.body = await parse_html(content.formatted_body) 54 | else: 55 | content.body = del_surrogate(content.body) 56 | 57 | return content 58 | 59 | 60 | async def _gc_annotations_to_matrix_catch( 61 | portal: po.Portal, text: str, annotations: list[googlechat.Annotation] 62 | ) -> str: 63 | try: 64 | return await _gc_annotations_to_matrix(portal, text, annotations) 65 | except Exception as e: 66 | raise FormatError("Failed to convert Google Chat format") from e 67 | 68 | 69 | def _annotation_key(item: googlechat.Annotation) -> tuple[int, int, int]: 70 | # Lowest offset sorts first 71 | offset_key = item.start_index 72 | type_key = 2 73 | # Bulleted lists sort before bulleted list items and those sort before other things 74 | if item.format_metadata.format_type == googlechat.FormatMetadata.BULLETED_LIST_ITEM: 75 | type_key = 1 76 | if item.format_metadata.format_type == googlechat.FormatMetadata.BULLETED_LIST: 77 | type_key = 0 78 | # Finally sort highest length first 79 | length_key = -item.length 80 | return offset_key, type_key, length_key 81 | 82 | 83 | # Make sure annotations nested inside other annotations end before the next annotation starts 84 | def _normalize_annotations( 85 | annotations: list[googlechat.Annotation], 86 | ) -> list[googlechat.Annotation]: 87 | i = 0 88 | insert_annotations = [] 89 | # We want to sort so lowest index comes first and highest length with same index comes first 90 | annotations = sorted(annotations, key=lambda item: (item.start_index, -item.length)) 91 | while i < len(annotations): 92 | cur = annotations[i] 93 | end = cur.start_index + cur.length 94 | for i2, annotation in enumerate(annotations[i + 1 :]): 95 | if annotation.start_index >= end: 96 | # Annotation is after current one, no need to modify it, 97 | # just insert the split-up annotations here and move on to the next one. 98 | i += 1 + i2 99 | annotations[i:i] = insert_annotations 100 | insert_annotations = [] 101 | break 102 | elif annotation.start_index + annotation.length > end: 103 | # The annotation continues past this one, so split it into two 104 | annotation_copy = googlechat.Annotation() 105 | annotation_copy.CopyFrom(annotation) 106 | annotation.length = end - annotation.start_index 107 | annotation_copy.start_index += annotation.length 108 | annotation_copy.length -= annotation.length 109 | insert_annotations.append(annotation_copy) 110 | else: 111 | i += 1 112 | annotations[i:i] = insert_annotations 113 | return annotations 114 | 115 | 116 | async def _gc_annotations_to_matrix( 117 | portal: po.Portal, 118 | text: str, 119 | annotations: list[googlechat.Annotation], 120 | offset: int = 0, 121 | length: int = None, 122 | ) -> str: 123 | if not annotations: 124 | return escape(text) 125 | if length is None: 126 | length = len(text) 127 | html = [] 128 | last_offset = 0 129 | annotations = _normalize_annotations(annotations) 130 | for i, annotation in enumerate(annotations): 131 | if annotation.start_index >= offset + length: 132 | break 133 | elif annotation.chip_render_type != googlechat.Annotation.DO_NOT_RENDER: 134 | # Annotations with the "RENDER" type are rendered separately, so they're not formatting 135 | continue 136 | # Overlapping annotations should be removed by _normalize_annotations 137 | assert annotation.start_index + annotation.length <= offset + length 138 | relative_offset = annotation.start_index - offset 139 | if relative_offset > last_offset: 140 | html.append(escape(text[last_offset:relative_offset])) 141 | elif relative_offset < last_offset: 142 | continue 143 | 144 | skip_entity = False 145 | entity_text = await _gc_annotations_to_matrix( 146 | portal=portal, 147 | text=text[relative_offset : relative_offset + annotation.length], 148 | annotations=annotations[i + 1 :], 149 | offset=annotation.start_index, 150 | length=annotation.length, 151 | ) 152 | 153 | if annotation.HasField("format_metadata"): 154 | type = annotation.format_metadata.format_type 155 | if type == googlechat.FormatMetadata.HIDDEN: 156 | # Don't append the text 157 | pass 158 | elif type == googlechat.FormatMetadata.BOLD: 159 | html.append(f"{entity_text}") 160 | elif type == googlechat.FormatMetadata.ITALIC: 161 | html.append(f"{entity_text}") 162 | elif type == googlechat.FormatMetadata.UNDERLINE: 163 | html.append(f"{entity_text}") 164 | elif type == googlechat.FormatMetadata.STRIKE: 165 | html.append(f"{entity_text}") 166 | elif type == googlechat.FormatMetadata.MONOSPACE: 167 | html.append(f"{entity_text}") 168 | elif type == googlechat.FormatMetadata.MONOSPACE_BLOCK: 169 | html.append(f"
{entity_text}
") 170 | elif type == googlechat.FormatMetadata.FONT_COLOR: 171 | rgb_int = annotation.format_metadata.font_color 172 | color = (rgb_int + 2**31) & 0xFFFFFF 173 | html.append(f"{entity_text}") 174 | elif type == googlechat.FormatMetadata.BULLETED_LIST_ITEM: 175 | html.append(f"
  • {entity_text}
  • ") 176 | elif type == googlechat.FormatMetadata.BULLETED_LIST: 177 | html.append(f"
      {entity_text}
    ") 178 | else: 179 | skip_entity = True 180 | elif annotation.HasField("url_metadata"): 181 | html.append(f"{entity_text}") 182 | elif annotation.HasField("user_mention_metadata"): 183 | mention_type = annotation.user_mention_metadata.type 184 | if mention_type == googlechat.UserMentionMetadata.MENTION_ALL: 185 | html.append("@room") 186 | else: 187 | gcid = annotation.user_mention_metadata.id.id 188 | user: u.User = await u.User.get_by_gcid(gcid) 189 | mxid = user.mxid if user else pu.Puppet.get_mxid_from_id(gcid) 190 | mention_text = entity_text 191 | if user: 192 | member = await portal.bridge.state_store.get_member(portal.mxid, user.mxid) 193 | if member and member.displayname: 194 | mention_text = member.displayname 195 | html.append(f"{mention_text}") 196 | else: 197 | skip_entity = True 198 | last_offset = relative_offset + (0 if skip_entity else annotation.length) 199 | html.append(escape(text[last_offset:])) 200 | 201 | return "".join(html) 202 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/from_matrix/__init__.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | import html 19 | 20 | from maugclib import googlechat_pb2 as googlechat 21 | from mautrix.types import Format, TextMessageEventContent 22 | 23 | from ..util import FormatError, add_surrogate, del_surrogate 24 | from .parser import MX_ROOM_MENTION, parse_html 25 | 26 | 27 | async def matrix_to_googlechat( 28 | content: TextMessageEventContent, 29 | ) -> tuple[str, list[googlechat.Annotation] | None]: 30 | if content.format != Format.HTML or not content.formatted_body: 31 | if MX_ROOM_MENTION in content.body: 32 | content.formatted_body = html.escape(content.body) 33 | else: 34 | return content.body, None 35 | try: 36 | text, entities = await parse_html(add_surrogate(content.formatted_body)) 37 | return del_surrogate(text), entities 38 | except Exception as e: 39 | raise FormatError(f"Failed to convert Matrix format") from e 40 | 41 | 42 | __all__ = ["matrix_to_googlechat"] 43 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/from_matrix/gc_message.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from typing import Any, Dict, List, Optional, Union 17 | from enum import Enum, auto 18 | 19 | from maugclib import googlechat_pb2 as googlechat 20 | from mautrix.util.formatter import EntityString, SemiAbstractEntity 21 | 22 | 23 | class GCFormatType(Enum): 24 | BOLD = googlechat.FormatMetadata.BOLD 25 | ITALIC = googlechat.FormatMetadata.ITALIC 26 | STRIKE = googlechat.FormatMetadata.STRIKE 27 | SOURCE_CODE = googlechat.FormatMetadata.SOURCE_CODE 28 | MONOSPACE = googlechat.FormatMetadata.MONOSPACE 29 | HIDDEN = googlechat.FormatMetadata.HIDDEN 30 | MONOSPACE_BLOCK = googlechat.FormatMetadata.MONOSPACE_BLOCK 31 | UNDERLINE = googlechat.FormatMetadata.UNDERLINE 32 | FONT_COLOR = googlechat.FormatMetadata.FONT_COLOR 33 | BULLETED_LIST = googlechat.FormatMetadata.BULLETED_LIST 34 | BULLETED_LIST_ITEM = googlechat.FormatMetadata.BULLETED_LIST_ITEM 35 | CLIENT_HIDDEN = googlechat.FormatMetadata.CLIENT_HIDDEN 36 | 37 | 38 | class GCUserMentionType(Enum): 39 | INVITE = googlechat.UserMentionMetadata.INVITE 40 | UNINVITE = googlechat.UserMentionMetadata.UNINVITE 41 | MENTION = googlechat.UserMentionMetadata.MENTION 42 | MENTION_ALL = googlechat.UserMentionMetadata.MENTION_ALL 43 | FAILED_TO_ADD = googlechat.UserMentionMetadata.FAILED_TO_ADD 44 | 45 | 46 | class GCEntityType(Enum): 47 | """EntityType is a Matrix formatting entity type.""" 48 | 49 | BOLD = GCFormatType.BOLD 50 | ITALIC = GCFormatType.ITALIC 51 | STRIKETHROUGH = GCFormatType.STRIKE 52 | UNDERLINE = GCFormatType.UNDERLINE 53 | URL = auto() 54 | EMAIL = auto() 55 | USER_MENTION = GCUserMentionType.MENTION 56 | MENTION_ALL = GCUserMentionType.MENTION_ALL 57 | PREFORMATTED = GCFormatType.MONOSPACE_BLOCK 58 | INLINE_CODE = GCFormatType.MONOSPACE 59 | COLOR = GCFormatType.FONT_COLOR 60 | 61 | # Google Chat specific types, not present in mautrix-python's EntityType 62 | LIST = GCFormatType.BULLETED_LIST 63 | LIST_ITEM = GCFormatType.BULLETED_LIST_ITEM 64 | HIDDEN = GCFormatType.HIDDEN 65 | 66 | 67 | class GCEntity(SemiAbstractEntity): 68 | internal: googlechat.Annotation 69 | type: GCEntityType 70 | 71 | def __init__( 72 | self, 73 | type: Union[GCEntityType, GCFormatType, GCUserMentionType], 74 | offset: int, 75 | length: int, 76 | extra_info: Dict[str, Any], 77 | ) -> None: 78 | if isinstance(type, GCEntityType): 79 | gc_type = type.value 80 | self.type = type 81 | else: 82 | gc_type = type 83 | self.type = GCEntityType(type) 84 | if isinstance(gc_type, GCFormatType): 85 | self.internal = googlechat.Annotation( 86 | type=googlechat.FORMAT_DATA, 87 | chip_render_type=googlechat.Annotation.DO_NOT_RENDER, 88 | start_index=offset, 89 | length=length, 90 | format_metadata=googlechat.FormatMetadata( 91 | format_type=gc_type.value, 92 | font_color=extra_info.get("font_color"), 93 | ), 94 | ) 95 | elif isinstance(gc_type, GCUserMentionType): 96 | if gc_type == GCUserMentionType.MENTION: 97 | mention_meta = googlechat.UserMentionMetadata( 98 | type=gc_type.value, 99 | id=googlechat.UserId(id=extra_info["user_id"]), 100 | display_name=extra_info.get("displayname"), 101 | ) 102 | else: 103 | mention_meta = googlechat.UserMentionMetadata(type=gc_type.value) 104 | self.internal = googlechat.Annotation( 105 | type=googlechat.USER_MENTION, 106 | chip_render_type=googlechat.Annotation.DO_NOT_RENDER, 107 | start_index=offset, 108 | length=length, 109 | user_mention_metadata=mention_meta, 110 | ) 111 | elif self.type == GCEntityType.URL: 112 | self.internal = googlechat.Annotation( 113 | type=googlechat.URL, 114 | chip_render_type=googlechat.Annotation.DO_NOT_RENDER, 115 | start_index=offset, 116 | length=length, 117 | url_metadata=googlechat.UrlMetadata( 118 | url=googlechat.Url(url=extra_info["url"]), 119 | ), 120 | ) 121 | else: 122 | raise ValueError(f"Can't create Entity with unknown entity type {type}") 123 | 124 | def copy(self) -> Optional["GCEntity"]: 125 | extra_info = {} 126 | if self.type == GCEntityType.COLOR: 127 | extra_info["font_color"] = self.internal.format_metadata.font_color 128 | elif self.type == GCEntityType.USER_MENTION: 129 | extra_info["user_id"] = self.internal.user_mention_metadata.id.id 130 | extra_info["displayname"] = self.internal.user_mention_metadata.display_name 131 | elif self.type == GCEntityType.URL: 132 | extra_info["url"] = self.internal.url_metadata.url.url 133 | return GCEntity(self.type, offset=self.offset, length=self.length, extra_info=extra_info) 134 | 135 | def __repr__(self) -> str: 136 | return str(self.internal) 137 | 138 | @property 139 | def offset(self) -> int: 140 | return self.internal.start_index 141 | 142 | @offset.setter 143 | def offset(self, value: int) -> None: 144 | self.internal.start_index = value 145 | 146 | @property 147 | def length(self) -> int: 148 | return self.internal.length 149 | 150 | @length.setter 151 | def length(self, value: int) -> None: 152 | self.internal.length = value 153 | 154 | 155 | class GCMessage(EntityString[GCEntity, GCEntityType]): 156 | entity_class = GCEntity 157 | 158 | @property 159 | def googlechat_entities(self) -> List[googlechat.Annotation]: 160 | return [ 161 | entity.internal 162 | for entity in self.entities 163 | if entity.internal.type != googlechat.ANNOTATION_TYPE_UNKNOWN 164 | ] 165 | 166 | def format( 167 | self, entity_type: GCEntityType, offset: int = None, length: int = None, **kwargs 168 | ) -> EntityString: 169 | if entity_type == GCEntityType.EMAIL: 170 | return self 171 | return super().format(entity_type, offset, length, **kwargs) 172 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/from_matrix/parser.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from maugclib import googlechat_pb2 as googlechat 19 | from mautrix.types import RoomID, UserID 20 | from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext 21 | from mautrix.util.formatter.html_reader import HTMLNode 22 | 23 | from ... import puppet as pu 24 | from .gc_message import GCEntityType, GCMessage 25 | 26 | 27 | async def parse_html(input_html: str) -> tuple[str, list[googlechat.Annotation] | None]: 28 | msg = await MatrixParser().parse(input_html) 29 | return msg.text, msg.googlechat_entities 30 | 31 | 32 | MX_ROOM_MENTION = "@room" 33 | GC_ROOM_MENTION = "@all" 34 | 35 | 36 | class MatrixParser(BaseMatrixParser[GCMessage]): 37 | e = GCEntityType 38 | fs = GCMessage 39 | 40 | async def color_to_fstring(self, msg: GCMessage, color: str) -> GCMessage: 41 | try: 42 | rgb_int = int(color.lstrip("#"), 16) 43 | except ValueError: 44 | return msg 45 | # I have no idea what's happening here but it works 46 | rgb_int = (rgb_int | 0x7F000000) - 2**31 47 | return msg.format(GCEntityType.COLOR, font_color=rgb_int) 48 | 49 | async def user_pill_to_fstring(self, msg: GCMessage, user_id: UserID) -> GCMessage: 50 | # TODO remove potential Google Chat suffix from displayname 51 | # TODO convert Matrix mentions of Google Chat users to GC mentions 52 | gcid = pu.Puppet.get_id_from_mxid(user_id) 53 | return msg.format(GCEntityType.USER_MENTION, user_id=gcid) 54 | 55 | async def room_pill_to_fstring(self, msg: GCMessage, room_id: RoomID) -> GCMessage | None: 56 | # TODO are room mentions supported at all? 57 | return None 58 | 59 | async def spoiler_to_fstring(self, msg: GCMessage, reason: str) -> GCMessage: 60 | return msg 61 | 62 | async def list_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> GCMessage: 63 | if node.tag == "ol": 64 | return await super().list_to_fstring(node, ctx) 65 | tagged_children = await self.node_to_tagged_fstrings(node, ctx) 66 | children = [] 67 | for child, tag in tagged_children: 68 | if tag != "li": 69 | continue 70 | children.append(child.format(GCEntityType.LIST_ITEM)) 71 | return self.fs.join(children, "\n").format(GCEntityType.LIST) 72 | 73 | async def header_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> GCMessage: 74 | children = await cls.node_to_fstrings(node, ctx) 75 | length = int(node.tag[1]) 76 | prefix = "#" * length + " " 77 | return GCMessage.join(children, "").prepend(prefix).format(GCEntityType.BOLD) 78 | 79 | async def blockquote_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> GCMessage: 80 | msg = await self.tag_aware_parse_node(node, ctx) 81 | children = msg.trim().split("\n") 82 | children = [child.prepend("> ") for child in children] 83 | return GCMessage.join(children, "\n") 84 | 85 | async def text_to_fstring( 86 | self, text: str, ctx: RecursionContext, strip_leading_whitespace: bool = False 87 | ) -> GCMessage: 88 | if MX_ROOM_MENTION in text and not ctx.preserve_whitespace: 89 | idx = text.index(MX_ROOM_MENTION) 90 | prefix, suffix = text[:idx], text[idx + len(MX_ROOM_MENTION) :] 91 | return self.fs.concat( 92 | await self.text_to_fstring(prefix, ctx, strip_leading_whitespace), 93 | self.fs(GC_ROOM_MENTION).format(GCEntityType.MENTION_ALL), 94 | await self.text_to_fstring(suffix, ctx, strip_leading_whitespace), 95 | ) 96 | return await super(MatrixParser, self).text_to_fstring( 97 | text, ctx, strip_leading_whitespace=strip_leading_whitespace 98 | ) 99 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/gc_url_preview.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any 19 | import json 20 | import logging 21 | 22 | from yarl import URL 23 | import aiohttp 24 | 25 | from maugclib import googlechat_pb2 as googlechat 26 | from mautrix.util import magic 27 | 28 | from .. import portal as po, user as u 29 | 30 | try: 31 | from mautrix.crypto.attachments import async_inplace_encrypt_attachment 32 | except ImportError: 33 | decrypt_attachment = async_inplace_encrypt_attachment = None 34 | 35 | log = logging.getLogger("mau.gc_url_preview") 36 | 37 | _upload_cache: dict[str, dict] = {} 38 | _oembed_cache: dict[str, dict] = {} 39 | DRIVE_OPEN_URL = URL("https://drive.google.com/open") 40 | DRIVE_THUMBNAIL_URL = URL("https://drive.google.com/thumbnail") 41 | YOUTUBE_URL = URL("https://www.youtube.com/watch") 42 | YOUTUBE_THUMBNAIL_URL = "https://i.ytimg.com/vi/{id}/hqdefault.jpg" 43 | YOUTUBE_OEMBED_URL = URL("https://www.youtube.com/oembed") 44 | bot_hdrs = {"User-Agent": "mautrix oembed bot +https://github.com/mautrix/googlechat"} 45 | 46 | 47 | async def _reupload_preview( 48 | source: u.User | None, url: str, encrypt: bool, async_upload: bool 49 | ) -> dict: 50 | try: 51 | return _upload_cache[url] 52 | except KeyError: 53 | pass 54 | 55 | max_size = po.Portal.matrix.media_config.upload_size 56 | bot = po.Portal.bridge.az.intent 57 | 58 | try: 59 | if source: 60 | data, mime, _ = await source.client.download_attachment(url, max_size=max_size) 61 | else: 62 | async with aiohttp.ClientSession() as sess, sess.get(url) as resp: 63 | data = bytearray(await resp.read()) 64 | mime = resp.headers.get("Content-Type") or magic.mimetype(data) 65 | except aiohttp.ClientError: 66 | return {} 67 | output = { 68 | "og:image:type": mime, 69 | "matrix:image:size": len(data), 70 | } 71 | file = None 72 | if encrypt: 73 | file = await async_inplace_encrypt_attachment(data) 74 | output["beeper:image:encryption"] = file.serialize() 75 | mime = "application/octet-stream" 76 | mxc = await bot.upload_media(data, mime_type=mime, async_upload=async_upload) 77 | if file: 78 | output["beeper:image:encryption"]["url"] = mxc 79 | else: 80 | output["og:image"] = mxc 81 | _upload_cache[url] = output 82 | return output 83 | 84 | 85 | def _has_matching_drive_annotation(annotations: list[googlechat.Annotation], url: str) -> bool: 86 | for ann in annotations: 87 | if ann.drive_metadata.id and ann.drive_metadata.id in url: 88 | return True 89 | return False 90 | 91 | 92 | async def gc_previews_to_beeper( 93 | source: u.User, 94 | text: str, 95 | annotations: list[googlechat.Annotation], 96 | encrypt: bool = False, 97 | async_upload: bool = False, 98 | ) -> list[dict[str, Any]]: 99 | url_previews = [] 100 | for ann in annotations: 101 | if ann.url_metadata.should_not_render: 102 | continue 103 | url = text[ann.start_index : ann.start_index + ann.length] 104 | if ( 105 | ann.HasField("url_metadata") 106 | and ann.url_metadata.title 107 | and not _has_matching_drive_annotation(annotations, ann.url_metadata.url.url) 108 | ): 109 | preview = await gc_url_to_beeper(source, url, ann.url_metadata, encrypt, async_upload) 110 | elif ann.HasField("drive_metadata") and ann.drive_metadata.title: 111 | preview = await gc_drive_to_beeper( 112 | source, url, ann.drive_metadata, encrypt, async_upload 113 | ) 114 | elif ann.HasField("youtube_metadata"): 115 | preview = await gc_youtube_to_beeper( 116 | source, url, ann.youtube_metadata, encrypt, async_upload 117 | ) 118 | else: 119 | continue 120 | url_previews.append({k: v for k, v in preview.items() if v}) 121 | return [p for p in url_previews if p] 122 | 123 | 124 | async def gc_url_to_beeper( 125 | source: u.User, 126 | matched_url: str, 127 | meta: googlechat.UrlMetadata, 128 | encrypt: bool, 129 | async_upload: bool, 130 | ) -> dict[str, Any]: 131 | preview = { 132 | "matched_url": matched_url, 133 | "og:url": meta.url.url, 134 | "og:title": meta.title, 135 | "og:description": meta.snippet, 136 | } 137 | if meta.image_url: 138 | preview.update(await _reupload_preview(source, meta.image_url, encrypt, async_upload)) 139 | preview["og:image:width"] = meta.int_image_width 140 | preview["og:image:height"] = meta.int_image_height 141 | return preview 142 | 143 | 144 | async def gc_drive_to_beeper( 145 | source: u.User, 146 | matched_url: str, 147 | meta: googlechat.DriveMetadata, 148 | encrypt: bool, 149 | async_upload: bool = False, 150 | ) -> dict[str, Any]: 151 | open_url = str(DRIVE_OPEN_URL.with_query({"id": meta.id})) 152 | preview = { 153 | "matched_url": matched_url or open_url, 154 | "og:url": open_url, 155 | "og:title": meta.title, 156 | } 157 | if meta.thumbnail_width: 158 | if not meta.thumbnail_url: 159 | meta.thumbnail_url = str( 160 | DRIVE_THUMBNAIL_URL.with_query( 161 | { 162 | "sz": f"w{meta.thumbnail_width}", 163 | "id": meta.id, 164 | } 165 | ) 166 | ) 167 | preview.update(await _reupload_preview(source, meta.thumbnail_url, encrypt, async_upload)) 168 | preview["og:image:width"] = meta.thumbnail_width 169 | preview["og:image:height"] = meta.thumbnail_height 170 | return preview 171 | 172 | 173 | async def _fetch_youtube_oembed(url: str) -> dict[str, Any]: 174 | try: 175 | return _oembed_cache[url] 176 | except KeyError: 177 | pass 178 | oembed_url = YOUTUBE_OEMBED_URL.with_query( 179 | { 180 | "format": "json", 181 | "url": url, 182 | } 183 | ) 184 | try: 185 | async with aiohttp.ClientSession(headers=bot_hdrs) as sess, sess.get(oembed_url) as resp: 186 | if resp.status == 404: 187 | log.debug(f"Didn't find oEmbed info for {url}") 188 | data = {} 189 | else: 190 | resp.raise_for_status() 191 | data = await resp.json() 192 | except (aiohttp.ClientError, json.JSONDecodeError) as e: 193 | log.warning(f"Failed to fetch oEmbed info from {oembed_url}: {e}") 194 | data = {} 195 | _oembed_cache[url] = data 196 | return data 197 | 198 | 199 | async def gc_youtube_to_beeper( 200 | source: u.User, 201 | matched_url: str, 202 | meta: googlechat.YoutubeMetadata, 203 | encrypt: bool, 204 | async_upload: bool = False, 205 | ) -> dict[str, Any] | None: 206 | open_url = str(YOUTUBE_URL.with_query({"v": meta.id})) 207 | preview_meta = await _fetch_youtube_oembed(open_url) 208 | thumbnail_url = preview_meta.get("thumbnail_url") or YOUTUBE_THUMBNAIL_URL.format(id=meta.id) 209 | preview = { 210 | "matched_url": matched_url or open_url, 211 | "og:url": open_url, 212 | "og:title": preview_meta.get("title", "YouTube video"), 213 | "og:type": "video.other", 214 | "og:video": open_url, 215 | "og:video:width": preview_meta.get("width"), 216 | "og:video:height": preview_meta.get("height"), 217 | **await _reupload_preview(source, thumbnail_url, encrypt, async_upload), 218 | "og:image:width": preview_meta.get("thumbnail_width"), 219 | "og:image:height": preview_meta.get("thumbnail_height"), 220 | } 221 | return preview 222 | -------------------------------------------------------------------------------- /mautrix_googlechat/formatter/util.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | 4 | # add_surrogate and del_surrogate are from 5 | # https://github.com/LonamiWebs/Telethon/blob/master/telethon/helpers.py 6 | def add_surrogate(text: str) -> str: 7 | return "".join( 8 | "".join(chr(y) for y in struct.unpack(" str: 16 | return text.encode("utf-16", "surrogatepass").decode("utf-16") 17 | 18 | 19 | class FormatError(Exception): 20 | pass 21 | -------------------------------------------------------------------------------- /mautrix_googlechat/get_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | from . import __version__ 6 | 7 | cmd_env = { 8 | "PATH": os.environ["PATH"], 9 | "HOME": os.environ["HOME"], 10 | "LANG": "C", 11 | "LC_ALL": "C", 12 | } 13 | 14 | 15 | def run(cmd): 16 | return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env) 17 | 18 | 19 | if os.path.exists(".git") and shutil.which("git"): 20 | try: 21 | git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") 22 | git_revision_url = f"https://github.com/mautrix/googlechat/commit/{git_revision}" 23 | git_revision = git_revision[:8] 24 | except (subprocess.SubprocessError, OSError): 25 | git_revision = "unknown" 26 | git_revision_url = None 27 | 28 | try: 29 | git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") 30 | except (subprocess.SubprocessError, OSError): 31 | git_tag = None 32 | elif os.environ.get("CI_SERVER", "no") == "yes": 33 | git_revision = os.environ["CI_COMMIT_SHA"] 34 | git_revision_url = f"https://github.com/mautrix/googlechat/commit/{git_revision}" 35 | git_revision = git_revision[:8] 36 | git_tag = os.environ.get("CI_COMMIT_TAG", None) 37 | else: 38 | git_revision = "unknown" 39 | git_revision_url = None 40 | git_tag = None 41 | 42 | git_tag_url = f"https://github.com/mautrix/googlechat/releases/tag/{git_tag}" if git_tag else None 43 | 44 | if git_tag and __version__ == git_tag[1:].replace("-", ""): 45 | version = __version__ 46 | linkified_version = f"[{version}]({git_tag_url})" 47 | else: 48 | if not __version__.endswith("+dev"): 49 | __version__ += "+dev" 50 | version = f"{__version__}.{git_revision}" 51 | if git_revision_url: 52 | linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})" 53 | else: 54 | linkified_version = version 55 | -------------------------------------------------------------------------------- /mautrix_googlechat/matrix.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING 19 | 20 | from mautrix.bridge import BaseMatrixHandler 21 | from mautrix.types import ( 22 | Event, 23 | EventID, 24 | EventType, 25 | PresenceEventContent, 26 | ReactionEvent, 27 | ReactionEventContent, 28 | RedactionEvent, 29 | RelationType, 30 | RoomID, 31 | SingleReceiptEventContent, 32 | UserID, 33 | ) 34 | 35 | from . import portal as po, user as u 36 | 37 | if TYPE_CHECKING: 38 | from .__main__ import GoogleChatBridge 39 | 40 | 41 | class MatrixHandler(BaseMatrixHandler): 42 | def __init__(self, bridge: "GoogleChatBridge") -> None: 43 | prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":") 44 | homeserver = bridge.config["homeserver.domain"] 45 | self.user_id_prefix = f"@{prefix}" 46 | self.user_id_suffix = f"{suffix}:{homeserver}" 47 | 48 | super().__init__(bridge=bridge) 49 | 50 | async def send_welcome_message(self, room_id: RoomID, inviter: u.User) -> None: 51 | await super().send_welcome_message(room_id, inviter) 52 | if not inviter.notice_room: 53 | inviter.notice_room = room_id 54 | await inviter.save() 55 | await self.az.intent.send_notice( 56 | room_id, "This room has been marked as your Google Chat bridge notice room." 57 | ) 58 | 59 | async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: 60 | user = await u.User.get_by_mxid(user_id) 61 | 62 | portal = await po.Portal.get_by_mxid(room_id) 63 | if not portal: 64 | return 65 | 66 | if not user.is_whitelisted: 67 | await portal.main_intent.kick_user( 68 | room_id, user.mxid, "You are not whitelisted on this Google Chat bridge." 69 | ) 70 | return 71 | # elif not await user.is_logged_in(): 72 | # await portal.main_intent.kick_user(room_id, user.mxid, "You are not logged in to this " 73 | # "Google Chat bridge.") 74 | # return 75 | 76 | self.log.debug(f"{user.mxid} joined {room_id}") 77 | # await portal.join_matrix(user, event_id) 78 | 79 | async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: 80 | portal = await po.Portal.get_by_mxid(room_id) 81 | if not portal: 82 | return 83 | 84 | user = await u.User.get_by_mxid(user_id, create=False) 85 | if not user: 86 | return 87 | 88 | await portal.handle_matrix_leave(user) 89 | 90 | async def handle_presence(self, user_id: UserID, info: PresenceEventContent) -> None: 91 | if not self.config["bridge.presence"]: 92 | return 93 | # user = await u.User.get_by_mxid(user_id, create=False) 94 | # if user: 95 | # if info.presence == PresenceState.ONLINE: 96 | # await user.client.set_active() 97 | 98 | @staticmethod 99 | async def handle_typing(room_id: RoomID, typing: list[UserID]) -> None: 100 | portal = await po.Portal.get_by_mxid(room_id) 101 | if not portal: 102 | return 103 | 104 | await portal.handle_matrix_typing(set(typing)) 105 | 106 | async def handle_read_receipt( 107 | self, 108 | user: u.User, 109 | portal: po.Portal, 110 | event_id: EventID, 111 | data: SingleReceiptEventContent, 112 | ) -> None: 113 | await user.mark_read(portal.gcid, data.ts) 114 | 115 | async def handle_ephemeral_event(self, evt: Event) -> None: 116 | if evt.type == EventType.PRESENCE: 117 | await self.handle_presence(evt.sender, evt.content) 118 | elif evt.type == EventType.TYPING: 119 | await self.handle_typing(evt.room_id, evt.content.user_ids) 120 | else: 121 | await super().handle_ephemeral_event(evt) 122 | 123 | @classmethod 124 | async def handle_reaction( 125 | cls, room_id: RoomID, user_id: UserID, event_id: EventID, content: ReactionEventContent 126 | ) -> None: 127 | if content.relates_to.rel_type != RelationType.ANNOTATION: 128 | cls.log.debug( 129 | f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected " 130 | f"relation type {content.relates_to.rel_type}" 131 | ) 132 | return 133 | user = await u.User.get_by_mxid(user_id) 134 | if not user: 135 | return 136 | 137 | portal = await po.Portal.get_by_mxid(room_id) 138 | if not portal: 139 | return 140 | 141 | await portal.handle_matrix_reaction( 142 | user, event_id, content.relates_to.event_id, content.relates_to.key 143 | ) 144 | 145 | @staticmethod 146 | async def handle_redaction( 147 | room_id: RoomID, user_id: UserID, event_id: EventID, redaction_event_id: EventID 148 | ) -> None: 149 | user = await u.User.get_by_mxid(user_id) 150 | if not user: 151 | return 152 | 153 | portal = await po.Portal.get_by_mxid(room_id) 154 | if not portal: 155 | return 156 | 157 | await portal.handle_matrix_redaction(user, event_id, redaction_event_id) 158 | 159 | async def handle_event(self, evt: Event) -> None: 160 | portal = await po.Portal.get_by_mxid(evt.room_id) 161 | if not portal: 162 | return 163 | 164 | if evt.type == EventType.REACTION: 165 | evt: ReactionEvent 166 | await self.handle_reaction( 167 | room_id=evt.room_id, user_id=evt.sender, event_id=evt.event_id, content=evt.content 168 | ) 169 | elif evt.type == EventType.ROOM_REDACTION: 170 | evt: RedactionEvent 171 | await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id) 172 | -------------------------------------------------------------------------------- /mautrix_googlechat/puppet.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, AsyncIterable, Awaitable, cast 19 | from hashlib import sha256 20 | import asyncio 21 | 22 | from yarl import URL 23 | import aiohttp 24 | 25 | from maugclib import googlechat_pb2 as googlechat 26 | from mautrix.appservice import IntentAPI 27 | from mautrix.bridge import BasePuppet, async_getter_lock 28 | from mautrix.types import ContentURI, RoomID, SyncToken, UserID 29 | from mautrix.util import magic 30 | from mautrix.util.simple_template import SimpleTemplate 31 | 32 | from . import portal as p, user as u 33 | from .config import Config 34 | from .db import Puppet as DBPuppet 35 | 36 | if TYPE_CHECKING: 37 | from .__main__ import GoogleChatBridge 38 | 39 | 40 | class Puppet(DBPuppet, BasePuppet): 41 | bridge: GoogleChatBridge 42 | config: Config 43 | hs_domain: str 44 | mxid_template: SimpleTemplate[str] 45 | 46 | by_gcid: dict[str, Puppet] = {} 47 | by_custom_mxid: dict[UserID, Puppet] = {} 48 | 49 | def __init__( 50 | self, 51 | gcid: str, 52 | name: str | None = None, 53 | photo_id: str | None = None, 54 | photo_mxc: ContentURI | None = None, 55 | name_set: bool = False, 56 | avatar_set: bool = False, 57 | contact_info_set: bool = False, 58 | is_registered: bool = False, 59 | custom_mxid: UserID | None = None, 60 | access_token: str | None = None, 61 | next_batch: SyncToken | None = None, 62 | base_url: URL | None = None, 63 | photo_hash: str | None = None, 64 | ) -> None: 65 | super().__init__( 66 | gcid=gcid, 67 | name=name, 68 | photo_id=photo_id, 69 | photo_mxc=photo_mxc, 70 | name_set=name_set, 71 | avatar_set=avatar_set, 72 | contact_info_set=contact_info_set, 73 | is_registered=is_registered, 74 | custom_mxid=custom_mxid, 75 | access_token=access_token, 76 | next_batch=next_batch, 77 | base_url=base_url, 78 | photo_hash=photo_hash, 79 | ) 80 | 81 | self.default_mxid = self.get_mxid_from_id(gcid) 82 | self.default_mxid_intent = self.az.intent.user(self.default_mxid) 83 | self.intent = self._fresh_intent() 84 | 85 | self.log = self.log.getChild(self.gcid) 86 | 87 | def _add_to_cache(self) -> None: 88 | self.by_gcid[self.gcid] = self 89 | if self.custom_mxid: 90 | self.by_custom_mxid[self.custom_mxid] = self 91 | 92 | @classmethod 93 | def init_cls(cls, bridge: "GoogleChatBridge") -> AsyncIterable[Awaitable[None]]: 94 | cls.bridge = bridge 95 | cls.config = bridge.config 96 | cls.loop = bridge.loop 97 | cls.mx = bridge.matrix 98 | cls.az = bridge.az 99 | cls.hs_domain = cls.config["homeserver"]["domain"] 100 | cls.mxid_template = SimpleTemplate( 101 | cls.config["bridge.username_template"], 102 | "userid", 103 | prefix="@", 104 | suffix=f":{cls.hs_domain}", 105 | type=str, 106 | ) 107 | cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"] 108 | cls.homeserver_url_map = { 109 | server: URL(url) 110 | for server, url in cls.config["bridge.double_puppet_server_map"].items() 111 | } 112 | cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"] 113 | cls.login_shared_secret_map = { 114 | server: secret.encode("utf-8") 115 | for server, secret in cls.config["bridge.login_shared_secret_map"].items() 116 | } 117 | cls.login_device_name = "Google Chat Bridge" 118 | 119 | return (puppet.try_start() async for puppet in Puppet.get_all_with_custom_mxid()) 120 | 121 | async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: 122 | portal = await p.Portal.get_by_mxid(room_id) 123 | return portal and portal.other_user_id != self.gcid 124 | 125 | async def _leave_rooms_with_default_user(self) -> None: 126 | await super()._leave_rooms_with_default_user() 127 | # Make the user join all private chat portals. 128 | await asyncio.gather( 129 | *[ 130 | self.intent.ensure_joined(portal.mxid) 131 | async for portal in p.Portal.get_all_by_receiver(self.gcid) 132 | if portal.mxid 133 | ] 134 | ) 135 | 136 | def intent_for(self, portal: p.Portal) -> IntentAPI: 137 | if portal.other_user_id == self.gcid or ( 138 | portal.backfill_lock.locked and self.config["bridge.backfill.invite_own_puppet"] 139 | ): 140 | return self.default_mxid_intent 141 | return self.intent 142 | 143 | # region User info updating 144 | 145 | async def update_info( 146 | self, source: u.User, info: googlechat.User | None = None, update_avatar: bool = True 147 | ) -> None: 148 | if info is None: 149 | info = (await source.get_users([self.gcid]))[0] 150 | changed = await self._update_contact_info(info) 151 | changed = await self._update_name(info) or changed 152 | if update_avatar: 153 | changed = await self._update_photo(info.avatar_url) or changed 154 | if changed: 155 | await self.save() 156 | 157 | async def _update_contact_info(self, info: googlechat.User) -> bool: 158 | if not self.bridge.homeserver_software.is_hungry: 159 | return False 160 | 161 | if self.contact_info_set: 162 | return False 163 | 164 | try: 165 | await self.default_mxid_intent.beeper_update_profile( 166 | { 167 | "com.beeper.bridge.identifiers": [f"mailto:{info.email}"], 168 | "com.beeper.bridge.remote_id": self.gcid, 169 | "com.beeper.bridge.service": self.bridge.beeper_service_name, 170 | "com.beeper.bridge.network": self.bridge.beeper_network_name, 171 | } 172 | ) 173 | self.contact_info_set = True 174 | except Exception: 175 | self.log.exception("Error updating contact info") 176 | self.contact_info_set = False 177 | return True 178 | 179 | @classmethod 180 | def get_name_from_info(cls, info: googlechat.User) -> str | None: 181 | full = info.name 182 | first = info.first_name 183 | last = info.last_name 184 | if not full: 185 | if info.first_name or info.last_name: 186 | # No full name, but have first and/or last name, use those as fallback 187 | full = " ".join(item for item in (info.first_name, info.last_name) if item) 188 | elif info.email: 189 | # No names at all, use email as fallback 190 | full = info.email 191 | else: 192 | # There's nothing to show at all, return 193 | return None 194 | elif not first: 195 | first = full 196 | # Try to find the actual first name if possible 197 | if last and first.endswith(last): 198 | first = first[: -len(last)].rstrip() 199 | return cls.config["bridge.displayname_template"].format( 200 | first_name=first, full_name=full, last_name=last, email=info.email 201 | ) 202 | 203 | async def _update_name(self, info: googlechat.User) -> bool: 204 | name = self.get_name_from_info(info) 205 | if not name: 206 | self.log.warning(f"Got user info with no name: {info}") 207 | return False 208 | if name != self.name or not self.name_set: 209 | self.name = name 210 | try: 211 | await self.default_mxid_intent.set_displayname(self.name) 212 | self.name_set = True 213 | except Exception: 214 | self.log.exception("Failed to set displayname") 215 | self.name_set = False 216 | return True 217 | return False 218 | 219 | async def _update_photo(self, photo_url: str) -> bool: 220 | if photo_url != self.photo_id or not self.avatar_set: 221 | if photo_url != self.photo_id: 222 | if photo_url: 223 | photo_mxc, photo_hash = await self._reupload_gc_photo( 224 | photo_url, self.default_mxid_intent 225 | ) 226 | if not photo_hash: 227 | # photo did not change, but the URL did 228 | self.photo_id = photo_url 229 | return True 230 | else: 231 | self.photo_mxc = photo_mxc 232 | self.photo_hash = photo_hash 233 | else: 234 | self.photo_mxc = ContentURI("") 235 | self.photo_id = photo_url 236 | try: 237 | await self.default_mxid_intent.set_avatar_url(self.photo_mxc) 238 | self.avatar_set = True 239 | except Exception: 240 | self.log.exception("Failed to set avatar") 241 | self.avatar_set = False 242 | return True 243 | return False 244 | 245 | async def _reupload_gc_photo( 246 | self, url: str, intent: IntentAPI, filename: str | None = None 247 | ) -> tuple[ContentURI, str | None]: 248 | async with aiohttp.ClientSession() as session: 249 | resp = await session.get(URL(url).with_scheme("https")) 250 | data = await resp.read() 251 | hasher = sha256() 252 | hasher.update(data) 253 | photo_hash = hasher.hexdigest() 254 | if self.photo_hash == photo_hash: 255 | # photo has not changed 256 | return ContentURI(""), None 257 | mime = magic.mimetype(data) 258 | return ( 259 | await intent.upload_media( 260 | data, 261 | mime_type=mime, 262 | filename=filename, 263 | async_upload=self.config["homeserver.async_media"], 264 | ), 265 | photo_hash, 266 | ) 267 | 268 | # endregion 269 | # region Getters 270 | 271 | @classmethod 272 | @async_getter_lock 273 | async def get_by_gcid(cls, gcid: str, create: bool = True) -> Puppet | None: 274 | if not gcid: 275 | return None 276 | try: 277 | return cls.by_gcid[gcid] 278 | except KeyError: 279 | pass 280 | 281 | puppet = cast(Puppet, await super().get_by_gcid(gcid)) 282 | if puppet: 283 | puppet._add_to_cache() 284 | return puppet 285 | 286 | if create: 287 | puppet = cls(gcid) 288 | await puppet.insert() 289 | puppet._add_to_cache() 290 | return puppet 291 | 292 | return None 293 | 294 | @classmethod 295 | @async_getter_lock 296 | async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None: 297 | gcid = cls.get_id_from_mxid(mxid) 298 | if gcid: 299 | return await cls.get_by_gcid(gcid, create) 300 | 301 | return None 302 | 303 | @classmethod 304 | @async_getter_lock 305 | async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: 306 | try: 307 | return cls.by_custom_mxid[mxid] 308 | except KeyError: 309 | pass 310 | 311 | puppet = cast(Puppet, await super().get_by_custom_mxid(mxid)) 312 | if puppet: 313 | puppet._add_to_cache() 314 | return puppet 315 | 316 | return None 317 | 318 | @classmethod 319 | def get_id_from_mxid(cls, mxid: UserID) -> str | None: 320 | if mxid == cls.az.bot_mxid: 321 | return None 322 | return cls.mxid_template.parse(mxid) 323 | 324 | @classmethod 325 | def get_mxid_from_id(cls, gcid: str) -> UserID: 326 | return UserID(cls.mxid_template.format_full(gcid)) 327 | 328 | @classmethod 329 | async def get_all_with_custom_mxid(cls) -> AsyncIterable[Puppet]: 330 | puppets = await super().get_all_with_custom_mxid() 331 | puppet: cls 332 | for puppet in puppets: 333 | try: 334 | yield cls.by_gcid[puppet.gcid] 335 | except KeyError: 336 | puppet._add_to_cache() 337 | yield puppet 338 | 339 | # endregion 340 | -------------------------------------------------------------------------------- /mautrix_googlechat/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .color_log import ColorFormatter 2 | -------------------------------------------------------------------------------- /mautrix_googlechat/util/color_log.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.logging.color import PREFIX, RESET, ColorFormatter as BaseColorFormatter 17 | 18 | HANGUPS_COLOR = PREFIX + "35;1m" # magenta 19 | 20 | 21 | class ColorFormatter(BaseColorFormatter): 22 | def _color_name(self, module: str) -> str: 23 | if module.startswith("hang") or module.startswith("maugclib"): 24 | return HANGUPS_COLOR + module + RESET 25 | return super()._color_name(module) 26 | -------------------------------------------------------------------------------- /mautrix_googlechat/version.py: -------------------------------------------------------------------------------- 1 | from .get_version import git_revision, git_tag, linkified_version, version 2 | -------------------------------------------------------------------------------- /mautrix_googlechat/web/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import GoogleChatAuthServer 2 | -------------------------------------------------------------------------------- /mautrix_googlechat/web/auth.py: -------------------------------------------------------------------------------- 1 | # mautrix-googlechat - A Matrix-Google Chat puppeting bridge 2 | # Copyright (C) 2023 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any 19 | import asyncio 20 | import logging 21 | 22 | from aiohttp import web 23 | 24 | from maugclib import Cookies 25 | from maugclib.exceptions import NotLoggedInError, ResponseError 26 | from mautrix.types import UserID 27 | 28 | from .. import user as u 29 | 30 | 31 | class ErrorResponse(Exception): 32 | def __init__( 33 | self, 34 | status_code: int, 35 | error: str, 36 | errcode: str, 37 | extra_data: dict[str, Any] | None = None, 38 | ) -> None: 39 | super().__init__(error) 40 | self.status_code = status_code 41 | self.message = error 42 | self.error = error 43 | self.errcode = errcode 44 | self.payload = {**(extra_data or {}), "error": self.error, "errcode": self.errcode} 45 | 46 | 47 | @web.middleware 48 | async def error_middleware(request: web.Request, handler) -> web.Response: 49 | try: 50 | return await handler(request) 51 | except ErrorResponse as e: 52 | return web.json_response(status=e.status_code, data=e.payload) 53 | 54 | 55 | log = logging.getLogger("mau.gc.auth") 56 | 57 | LOGIN_TIMEOUT = 10 * 60 58 | 59 | 60 | class GoogleChatAuthServer: 61 | app: web.Application 62 | shared_secret: str | None 63 | 64 | def __init__(self, shared_secret: str | None) -> None: 65 | self.ongoing = {} 66 | self.shared_secret = shared_secret 67 | 68 | self.app = web.Application(middlewares=[error_middleware]) 69 | self.app.router.add_get("/v1/whoami", self.whoami) 70 | self.app.router.add_post("/v1/login", self.login) 71 | self.app.router.add_post("/v1/logout", self.logout) 72 | self.app.router.add_post("/v1/reconnect", self.reconnect) 73 | 74 | self.legacy_app = web.Application(middlewares=[error_middleware]) 75 | self.legacy_app.router.add_post("/api/verify", self.verify) 76 | self.legacy_app.router.add_post("/api/logout", self.logout) 77 | self.legacy_app.router.add_post("/api/authorization", self.login) 78 | self.legacy_app.router.add_post("/api/reconnect", self.reconnect) 79 | self.legacy_app.router.add_get("/api/whoami", self.whoami) 80 | 81 | def verify_token(self, request: web.Request) -> UserID | None: 82 | try: 83 | token = request.headers["Authorization"] 84 | except KeyError: 85 | raise ErrorResponse(401, "Missing access token", "M_MISSING_TOKEN") 86 | if not token.startswith("Bearer "): 87 | raise ErrorResponse(401, "Invalid authorization header content", "M_MISSING_TOKEN") 88 | token = token[len("Bearer ") :] 89 | if not self.shared_secret or token != self.shared_secret: 90 | raise ErrorResponse(401, "Invalid access token", "M_UNKNOWN_TOKEN") 91 | try: 92 | return UserID(request.query["user_id"]) 93 | except KeyError: 94 | raise ErrorResponse(400, "Missing user_id query parameter", "M_MISSING_PARAM") 95 | 96 | async def verify(self, request: web.Request) -> web.Response: 97 | return web.json_response( 98 | { 99 | "user_id": self.verify_token(request), 100 | } 101 | ) 102 | 103 | async def logout(self, request: web.Request) -> web.Response: 104 | user_id = self.verify_token(request) 105 | user = await u.User.get_by_mxid(user_id) 106 | await user.logout(is_manual=True) 107 | return web.json_response({}) 108 | 109 | async def whoami(self, request: web.Request) -> web.Response: 110 | user_id = self.verify_token(request) 111 | user = await u.User.get_by_mxid(user_id) 112 | return web.json_response( 113 | { 114 | "permissions": user.level, 115 | "mxid": user.mxid, 116 | "googlechat": { 117 | "name": user.name, 118 | "email": user.email, 119 | "id": user.gcid, 120 | "connected": user.connected, 121 | } 122 | if user.client 123 | else None, 124 | } 125 | ) 126 | 127 | async def reconnect(self, request: web.Request) -> web.Response: 128 | user_id = self.verify_token(request) 129 | user = await u.User.get_by_mxid(user_id) 130 | user.reconnect() 131 | return web.json_response({}) 132 | 133 | async def login(self, request: web.Request) -> web.Response: 134 | user_id = self.verify_token(request) 135 | user = await u.User.get_by_mxid(user_id) 136 | if user.client: 137 | await user.name_future 138 | return web.json_response( 139 | { 140 | "status": "success", 141 | "name": user.name, 142 | "email": user.email, 143 | } 144 | ) 145 | data = await request.json() 146 | if not data: 147 | raise ErrorResponse(400, "Body is not JSON", "M_NOT_JSON") 148 | try: 149 | cookies = Cookies(**{k.lower(): v for k, v in data["cookies"].items()}) 150 | except TypeError: 151 | raise ErrorResponse( 152 | 400, "Request body did not contain the required fields", "M_BAD_REQUEST" 153 | ) 154 | user.user_agent = data.get("user_agent", None) 155 | 156 | user.log.debug("Trying to log in with cookies") 157 | try: 158 | if not await user.connect(cookies, get_self=True): 159 | return web.json_response( 160 | { 161 | "status": "fail", 162 | "error": "Failed to get own info after login", 163 | } 164 | ) 165 | except (ResponseError, NotLoggedInError) as e: 166 | log.exception(f"Login for {user.mxid} failed") 167 | return web.json_response( 168 | { 169 | "status": "fail", 170 | "error": str(e), 171 | } 172 | ) 173 | except Exception: 174 | log.exception(f"Login for {user.mxid} errored") 175 | return web.json_response( 176 | { 177 | "status": "fail", 178 | "error": "internal error", 179 | }, 180 | status=500, 181 | ) 182 | else: 183 | await asyncio.wait_for(asyncio.shield(user.name_future), 20) 184 | return web.json_response( 185 | { 186 | "status": "success", 187 | "name": user.name, 188 | "email": user.email, 189 | } 190 | ) 191 | -------------------------------------------------------------------------------- /optional-requirements.txt: -------------------------------------------------------------------------------- 1 | # Format: #/name defines a new extras_require group called name 2 | # Uncommented lines after the group definition insert things into that group. 3 | 4 | #/e2be 5 | python-olm>=3,<4 6 | pycryptodome>=3,<4 7 | unpaddedbase64>=1,<3 8 | 9 | #/metrics 10 | prometheus_client>=0.6,<0.18 11 | 12 | #/sqlite 13 | aiosqlite>=0.16,<0.20 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | force_to_top = "typing" 4 | from_first = true 5 | combine_as_imports = true 6 | known_first_party = "mautrix" 7 | line_length = 99 8 | skip = ["maugclib/googlechat_pb2.py"] 9 | 10 | [tool.black] 11 | line-length = 99 12 | target-version = ["py38"] 13 | force-exclude = "maugclib/googlechat_pb2.py" 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3,<4 2 | yarl>=1,<2 3 | asyncpg>=0.20,<0.30 4 | ruamel.yaml>=0.15.94,<0.18 5 | commonmark>=0.8,<0.10 6 | python-magic>=0.4,<0.5 7 | protobuf>=4,<5 8 | mautrix>=0.20.6,<0.21 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | from mautrix_googlechat.get_version import git_tag, git_revision, version, linkified_version 4 | 5 | try: 6 | long_desc = open("README.md").read() 7 | except IOError: 8 | long_desc = "Failed to read README.md" 9 | 10 | with open("requirements.txt") as reqs: 11 | install_requires = reqs.read().splitlines() 12 | 13 | with open("optional-requirements.txt") as reqs: 14 | extras_require = {} 15 | current = [] 16 | for line in reqs.read().splitlines(): 17 | if line.startswith("#/"): 18 | extras_require[line[2:]] = current = [] 19 | elif not line or line.startswith("#"): 20 | continue 21 | else: 22 | current.append(line) 23 | 24 | extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps}) 25 | 26 | with open("mautrix_googlechat/version.py", "w") as version_file: 27 | version_file.write(f"""# Generated in setup.py 28 | 29 | git_tag = {git_tag!r} 30 | git_revision = {git_revision!r} 31 | version = {version!r} 32 | linkified_version = {linkified_version!r} 33 | """) 34 | 35 | setuptools.setup( 36 | name="mautrix-googlechat", 37 | version=version, 38 | url="https://github.com/mautrix/googlechat", 39 | project_urls={ 40 | "Changelog": "https://github.com/mautrix/googlechat/blob/master/CHANGELOG.md", 41 | }, 42 | 43 | author="Tulir Asokan", 44 | author_email="tulir@maunium.net", 45 | 46 | description="A Matrix-Google Chat puppeting bridge.", 47 | long_description=long_desc, 48 | long_description_content_type="text/markdown", 49 | 50 | packages=setuptools.find_packages(), 51 | 52 | install_requires=install_requires, 53 | extras_require=extras_require, 54 | python_requires="~=3.9", 55 | 56 | classifiers=[ 57 | "Development Status :: 4 - Beta", 58 | "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", 59 | "Topic :: Communications :: Chat", 60 | "Framework :: AsyncIO", 61 | "Programming Language :: Python", 62 | "Programming Language :: Python :: 3", 63 | "Programming Language :: Python :: 3.9", 64 | "Programming Language :: Python :: 3.10", 65 | ], 66 | package_data={"mautrix_googlechat": [ 67 | "web/static/*.png", "web/static/*.css", "web/static/*.html", "web/static/*.js", 68 | "example-config.yaml" 69 | ]}, 70 | data_files=[ 71 | (".", ["mautrix_googlechat/example-config.yaml"]), 72 | ], 73 | ) 74 | --------------------------------------------------------------------------------