├── .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 | 
3 | [](LICENSE)
4 | [](https://github.com/mautrix/googlechat/releases)
5 | [](https://mau.dev/mautrix/googlechat/container_registry)
6 | [](https://github.com/psf/black)
7 | [](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"")
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 |
--------------------------------------------------------------------------------