├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── enhancement.md └── workflows │ ├── go.yml │ └── stale.yml ├── .gitignore ├── .gitlab-ci.yml ├── .idea └── icon.svg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.ci ├── LICENSE ├── LICENSE.exceptions ├── README.md ├── ROADMAP.md ├── build.sh ├── cmd └── mautrix-gmessages │ ├── legacymigrate.go │ ├── legacymigrate.sql │ ├── legacyprovision.go │ └── main.go ├── docker-run.sh ├── go.mod ├── go.sum └── pkg ├── connector ├── backfill.go ├── bridgestate.go ├── capabilities.go ├── chatinfo.go ├── chatsync.go ├── client.go ├── config.go ├── connector.go ├── dbmeta.go ├── errors.go ├── example-config.yaml ├── gmdb │ ├── 00-latest-schema.sql │ └── database.go ├── handlegmessages.go ├── handlematrix.go ├── id.go ├── login.go ├── messagestatus.go ├── push.go └── startchat.go └── libgm ├── client.go ├── crypto ├── aesctr.go ├── aesgcm.go ├── ecdsa.go └── generate.go ├── event_handler.go ├── events ├── qr.go ├── ready.go ├── ready_test.go └── useralerts.go ├── gmproto ├── authentication.pb.go ├── authentication.proto ├── client.pb.go ├── client.proto ├── config-extra.go ├── config.pb.go ├── config.proto ├── conversations.pb.go ├── conversations.proto ├── emojitype.go ├── events.pb.go ├── events.proto ├── rpc.pb.go ├── rpc.proto ├── settings.pb.go ├── settings.proto ├── ukey.pb.go ├── ukey.proto ├── util.pb.go ├── util.proto └── vendor │ └── pblite.proto ├── gmtest ├── .gitignore └── main.go ├── http.go ├── longpoll.go ├── manualdecrypt ├── README.md └── main.go ├── media.go ├── methods.go ├── pair.go ├── pair_google.go ├── pblitedecode └── main.go ├── session_handler.go └── util ├── config.go ├── constants.go ├── func.go └── paths.go /.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 | [*.{yaml,yml,sql,proto}] 12 | indent_style = space 13 | 14 | [.gitlab-ci.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | pkg/libgm/gmproto/*.pb.go linguist-generated=true 2 | pkg/libgm/gmproto/*.pb.raw binary linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: If something is definitely wrong in the bridge (rather than just a setup issue), 4 | file a bug report. Remember to include relevant logs. 5 | labels: bug 6 | 7 | --- 8 | 9 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Troubleshooting docs & FAQ 3 | url: https://docs.mau.fi/bridges/general/troubleshooting.html 4 | about: Check this first if you're having problems setting up the bridge. 5 | - name: Support room 6 | url: https://matrix.to/#/#gmessages:maunium.net 7 | about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Submit a feature request or other suggestion 4 | labels: enhancement 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GOTOOLCHAIN: local 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go-version: ["1.23", "1.24"] 15 | name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | cache: true 25 | 26 | - name: Install libolm 27 | run: sudo apt-get install libolm-dev libolm3 28 | 29 | - name: Install dependencies 30 | run: | 31 | go install golang.org/x/tools/cmd/goimports@latest 32 | go install honnef.co/go/tools/cmd/staticcheck@latest 33 | export PATH="$HOME/go/bin:$PATH" 34 | 35 | - name: Run pre-commit 36 | uses: pre-commit/action@v3.0.1 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock old issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 4 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | # pull-requests: write 11 | # discussions: write 12 | 13 | concurrency: 14 | group: lock-threads 15 | 16 | jobs: 17 | lock-stale: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | id: lock 22 | with: 23 | issue-inactive-days: 90 24 | process-only: issues 25 | - name: Log processed threads 26 | run: | 27 | if [ '${{ steps.lock.outputs.issues }}' ]; then 28 | echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' 29 | fi 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | *.yaml 4 | !.pre-commit-config.yaml 5 | !example-config.yaml 6 | 7 | *.session 8 | *.json 9 | *.db 10 | *.log 11 | *.log.gz 12 | *.bak 13 | 14 | /mautrix-gmessages 15 | /start 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/gov2-as-default.yml' 4 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.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 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | args: 16 | - "-local" 17 | - "go.mau.fi/mautrix-gmessages" 18 | - "-w" 19 | - id: go-vet-repo-mod 20 | - id: go-staticcheck-repo-mod 21 | 22 | - repo: https://github.com/beeper/pre-commit-go 23 | rev: v0.4.2 24 | hooks: 25 | - id: zerolog-ban-msgf 26 | - id: zerolog-use-stringer 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.6.2 (2025-05-16) 2 | 3 | * Fixed cookie login not working properly by pasting a cURL command. 4 | * Fixed unnecessary edits being bridged when sending media. 5 | * Fixed file names in incoming voice messages. 6 | * Stopped bridging own SIM change messages. 7 | 8 | # v0.6.1 (2025-03-16) 9 | 10 | * Bumped minimum Go version to 1.23. 11 | * Added support for signaling supported features to clients using the 12 | `com.beeper.room_features` state event. 13 | 14 | # v0.6.0 (2024-12-16) 15 | 16 | * Added support for re-authenticating expired Google logins without having to 17 | re-pair to the phone. 18 | * Stopped bridging theme change messages. 19 | * Updated Docker image to Alpine 3.21. 20 | 21 | # v0.5.2 (2024-11-16) 22 | 23 | * Fixed room names not being automatically fixed in cases where Google Messages 24 | reuses an existing chat ID for a different chat. 25 | 26 | # v0.5.1 (2024-10-16) 27 | 28 | * Fixed some cases of not receiving messages after a brief disconnection. 29 | 30 | # v0.5.0 (2024-09-16) 31 | 32 | * Bumped minimum Go version to 1.22. 33 | * Rewrote bridge using bridgev2 architecture. 34 | * It is recommended to check the config file after upgrading. If you have 35 | prevented the bridge from writing to the config, you should update it 36 | manually. 37 | 38 | # v0.4.3 (2024-07-16) 39 | 40 | * Added support for new protocol version in Google account pairing. 41 | * Added support for handling messages being modified, e.g. full-res media 42 | arriving later than the thumbnail. 43 | * This may or may not cover actual RCS edits if/when those are rolled out. 44 | 45 | # v0.4.2 (2024-06-16) 46 | 47 | * Added error message if phone doesn't send echo for outgoing message in 48 | time. 49 | * Added better error messages for some message send failures. 50 | * Added logging for RPC request and response IDs. 51 | * Fixed sending messages incorrectly forcing RCS in some cases causing failures 52 | (e.g. when using dual SIM and sending from a SIM with RCS disabled). 53 | * Fixed ping loop getting stuck (and therefore not keeping the connection 54 | alive) if the first ping never responds. 55 | * Removed unnecessary sleep after Google account pairing. 56 | 57 | # v0.4.1 (2024-05-16) 58 | 59 | * Added support for sending captions. 60 | * Note that RCS doesn't support captions yet, so sending captions in RCS 61 | chats will cause weirdness. Captions should work in MMS chats. 62 | * Fixed frequent disconnections when using Google account pairing with an 63 | email containing uppercase characters. 64 | * Fixed some cases of spam messages being bridged even after Google's filter 65 | caught them. 66 | 67 | # v0.4.0 (2024-04-16) 68 | 69 | * Added automatic detection and workarounds for cases where the app stops 70 | sending new messages to the bridge. 71 | * Improved participant deduplication and extended it to cover groups too 72 | instead of only DMs. 73 | * Fixed some cases of Google account pairing not working correctly. 74 | * Fixed database errors related to ghosts after switching phones or clearing 75 | data on phone (caused by the ghost avatar fix in 0.3.0). 76 | 77 | # v0.3.0 (2024-03-16) 78 | 79 | * Bumped minimum Go version to 1.21. 80 | * Added support for pairing via Google account. 81 | * See [docs](https://docs.mau.fi/bridges/go/gmessages/authentication.html) 82 | for instructions. 83 | * There are no benefits to using this method, it still requires your phone to 84 | be online. Google Fi cloud sync is still not supported. 85 | * Added deduplication for DM participants, as Google randomly sends duplicate 86 | participant entries sometimes. 87 | * Added voice message conversion. 88 | * Changed custom image reactions to be bridged as `:custom:` instead of a UUID. 89 | Google Messages for Web doesn't support fetching the actual image yet. 90 | * Fixed sending reactions breaking for some users. 91 | * Fixed ghost user avatars not being reset properly when switching phones or 92 | clearing data on phone. 93 | 94 | # v0.2.4 (2024-01-16) 95 | 96 | * Fixed panic handling read receipts if the user isn't connected. 97 | * Fixed some error states being persisted and not being cleared properly 98 | if the user logs out and back in. 99 | 100 | # v0.2.3 (2023-12-16) 101 | 102 | * Added error notice if user switches to google account pairing. 103 | 104 | # v0.2.2 (2023-11-16) 105 | 106 | No notable changes. 107 | 108 | # v0.2.1 (2023-10-16) 109 | 110 | * Added notice messages to management room if phone stops responding. 111 | * Fixed all Matrix event handling getting blocked by read receipts in some cases. 112 | * Fixed panic if editing Matrix message fails. 113 | 114 | # v0.2.0 (2023-09-16) 115 | 116 | * Added support for double puppeting with arbitrary `as_token`s. 117 | See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info. 118 | * Switched to "tablet mode", to allow using the bridge in parallel with 119 | Messages for Web. 120 | * You can have two tablets and one web session simultaneously. The bridge 121 | will now take one tablet slot by default, but you can change the device 122 | type in the bridge config. 123 | * Existing users will have to log out and re-pair the bridge to switch to 124 | tablet mode. 125 | * Added bridging for user avatars from phone. 126 | * Fixed sending messages not working for some users with dual SIMs. 127 | * Fixed message send error status codes from phone not being handled as errors. 128 | * Fixed incoming message and conversation data sometimes going into the wrong 129 | portals. 130 | * Fixed bridge sometimes getting immediately logged out after pairing. 131 | * Fixed some cases of attachments getting stuck in the "Waiting for file" state. 132 | * Fixed reactions not being saved to the database. 133 | * Fixed various panics. 134 | * Fixed race conditions when handling messages moving between chats. 135 | * Fixed Postgres connector not being imported when bridge is compiled without 136 | encryption. 137 | 138 | # v0.1.0 (2023-08-16) 139 | 140 | Initial release. 141 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-alpine3.21 AS builder 2 | 3 | RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev 4 | 5 | COPY . /build 6 | WORKDIR /build 7 | RUN ./build.sh 8 | 9 | FROM alpine:3.21 10 | 11 | ENV UID=1337 \ 12 | GID=1337 13 | 14 | RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq-go curl 15 | 16 | COPY --from=builder /build/mautrix-gmessages /usr/bin/mautrix-gmessages 17 | COPY --from=builder /build/docker-run.sh /docker-run.sh 18 | VOLUME /data 19 | 20 | CMD ["/docker-run.sh"] 21 | -------------------------------------------------------------------------------- /Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | ENV UID=1337 \ 4 | GID=1337 5 | 6 | RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go 7 | 8 | ARG EXECUTABLE=./mautrix-gmessages 9 | COPY $EXECUTABLE /usr/bin/mautrix-gmessages 10 | COPY ./docker-run.sh /docker-run.sh 11 | ENV BRIDGEV2=1 12 | VOLUME /data 13 | WORKDIR /data 14 | 15 | CMD ["/docker-run.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE.exceptions: -------------------------------------------------------------------------------- 1 | The mautrix-gmessages developers grant the following special exceptions: 2 | 3 | * to Beeper the right to embed the program in the Beeper clients and servers, 4 | and use and distribute the collective work without applying the license to 5 | the whole. 6 | * to Element the right to distribute compiled binaries of the program as a part 7 | of the Element Server Suite and other server bundles without applying the 8 | license. 9 | 10 | All exceptions are only valid under the condition that any modifications to 11 | the source code of mautrix-gmessages remain publicly available under the terms 12 | of the GNU AGPL version 3 or later. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-gmessages 2 | A Matrix-Google Messages puppeting bridge. 3 | 4 | ## Documentation 5 | All setup and usage instructions are located on [docs.mau.fi]. Some quick links: 6 | 7 | [docs.mau.fi]: https://docs.mau.fi/bridges/go/gmessages/index.html 8 | 9 | * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=gmessages) 10 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=gmessages)) 11 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/gmessages/authentication.html) 12 | 13 | ### Features & Roadmap 14 | [ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge. 15 | 16 | ## Discussion 17 | Matrix room: [#gmessages:maunium.net](https://matrix.to/#/#gmessages:maunium.net) 18 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | * Matrix → Google Messages 3 | * [x] Message content 4 | * [x] Plain text 5 | * [x] Media/files 6 | * [x] Replies (RCS) 7 | * [x] Reactions (RCS) 8 | * [ ] Typing notifications (RCS) 9 | * [x] Read receipts (RCS) 10 | * [x] Message deletions (own device only) 11 | * Google Messages → Matrix 12 | * [x] Message content 13 | * [x] Plain text 14 | * [x] Media/files 15 | * [x] Replies (RCS) 16 | * [x] Reactions (RCS) 17 | * [ ] Typing notifications (RCS) 18 | * [x] Read receipts in 1:1 chats (RCS) 19 | * [ ] Read receipts in groups (RCS) 20 | * [x] Message deletions (own device only) 21 | * Misc 22 | * [x] Automatic portal creation 23 | * [x] After login 24 | * [x] When receiving message 25 | * [x] Private chat creation by inviting Matrix ghost of remote user to new room 26 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') 3 | GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" 4 | go build -ldflags="-s -w $GO_LDFLAGS" "$@" ./cmd/mautrix-gmessages 5 | -------------------------------------------------------------------------------- /cmd/mautrix-gmessages/legacymigrate.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package main 18 | 19 | import ( 20 | _ "embed" 21 | 22 | up "go.mau.fi/util/configupgrade" 23 | "maunium.net/go/mautrix/bridgev2/bridgeconfig" 24 | ) 25 | 26 | const legacyMigrateRenameTables = ` 27 | ALTER TABLE portal RENAME TO portal_old; 28 | ALTER TABLE puppet RENAME TO puppet_old; 29 | ALTER TABLE message RENAME TO message_old; 30 | ALTER TABLE reaction RENAME TO reaction_old; 31 | ALTER TABLE "user" RENAME TO user_old; 32 | ` 33 | 34 | //go:embed legacymigrate.sql 35 | var legacyMigrateCopyData string 36 | 37 | func migrateLegacyConfig(helper up.Helper) { 38 | helper.Set(up.Str, "go.mau.fi/mautrix-gmessages", "encryption", "pickle_key") 39 | bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "displayname_template"}, []string{"network", "displayname_template"}) 40 | bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"google_messages", "os"}, []string{"network", "device_meta", "os"}) 41 | bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"google_messages", "browser"}, []string{"network", "device_meta", "browser"}) 42 | bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"google_messages", "device"}, []string{"network", "device_meta", "type"}) 43 | bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"google_messages", "aggressive_reconnect"}, []string{"network", "aggressive_reconnect"}) 44 | bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "initial_chat_sync_count"}, []string{"network", "initial_chat_sync_count"}) 45 | } 46 | -------------------------------------------------------------------------------- /cmd/mautrix-gmessages/legacymigrate.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "user" (bridge_id, mxid, management_room, access_token) 2 | SELECT '', mxid, management_room, access_token 3 | FROM user_old; 4 | 5 | CREATE TABLE gmessages_login_prefix( 6 | -- only: postgres 7 | prefix BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, 8 | -- only: sqlite (line commented) 9 | -- prefix INTEGER PRIMARY KEY, 10 | login_id TEXT NOT NULL, 11 | 12 | CONSTRAINT gmessages_login_prefix_login_id_key UNIQUE (login_id) 13 | ); 14 | 15 | CREATE TABLE gmessages_version (version INTEGER, compat INTEGER); 16 | INSERT INTO gmessages_version (version, compat) VALUES (1, 1); 17 | 18 | INSERT INTO gmessages_login_prefix (prefix, login_id) 19 | SELECT rowid, COALESCE(phone_id, CAST(rowid AS TEXT)) 20 | FROM user_old; 21 | 22 | -- only: postgres 23 | SELECT setval('gmessages_login_prefix_prefix_seq', (SELECT MAX(prefix)+1 FROM gmessages_login_prefix), FALSE); 24 | 25 | INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, space_room, metadata) 26 | SELECT 27 | '', -- bridge_id 28 | mxid, -- user_mxid 29 | phone_id, -- id 30 | '', -- remote_name 31 | space_room, -- space_room 32 | -- only: postgres 33 | jsonb_build_object 34 | -- only: sqlite (line commented) 35 | -- json_object 36 | ( 37 | 'session', json(session), 38 | 'id_prefix', CAST(rowid AS TEXT), 39 | 'self_participant_ids', json(self_participant_ids), 40 | 'sim_metadata', json(sim_metadata), 41 | 'settings', json(settings) 42 | ) -- metadata 43 | FROM user_old WHERE phone_id<>''; 44 | 45 | INSERT INTO ghost ( 46 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 47 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 48 | ) 49 | SELECT 50 | '', -- bridge_id 51 | (CAST(receiver AS TEXT) || '.' || id), -- id 52 | name, -- name 53 | CASE WHEN avatar_hash IS NULL THEN '' ELSE 54 | -- only: postgres 55 | 'hash:' || encode(avatar_hash, 'hex') 56 | -- only: sqlite (line commented) 57 | -- 'hash:' || hex(avatar_hash) 58 | END, -- avatar_id 59 | CASE WHEN avatar_hash IS NULL THEN '' ELSE 60 | -- only: postgres 61 | encode(avatar_hash, 'hex') 62 | -- only: sqlite (line commented) 63 | -- hex(avatar_hash) 64 | END, -- avatar_hash 65 | avatar_mxc, 66 | name_set, 67 | avatar_set, 68 | contact_info_set, 69 | false, -- is_bot 70 | '[]', -- identifiers 71 | -- only: postgres 72 | jsonb_build_object 73 | -- only: sqlite (line commented) 74 | -- json_object 75 | ( 76 | 'contact_id', contact_id, 77 | 'phone', phone, 78 | 'avatar_update_ts', avatar_update_ts 79 | ) -- metadata 80 | FROM puppet_old; 81 | 82 | UPDATE ghost SET avatar_id='', avatar_hash='' WHERE avatar_hash='0000000000000000000000000000000000000000000000000000000000000000'; 83 | 84 | INSERT INTO portal ( 85 | bridge_id, id, receiver, mxid, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc, 86 | name_set, avatar_set, topic_set, in_space, room_type, metadata 87 | ) 88 | SELECT 89 | '', -- bridge_id 90 | (CAST(receiver AS TEXT) || '.' || id), -- id 91 | (SELECT login_id FROM gmessages_login_prefix WHERE prefix=portal_old.receiver), -- receiver 92 | mxid, 93 | CASE WHEN other_user IS NOT NULL THEN (CAST(receiver AS TEXT) || '.' || other_user) END, -- other_user_id 94 | name, 95 | '', -- topic 96 | '', -- avatar_id 97 | '', -- avatar_hash 98 | '', -- avatar_mxc 99 | name_set, 100 | false, -- avatar_set 101 | false, -- topic_set 102 | false, -- in_space (spaceness is stored in user_portal) 103 | CASE WHEN other_user IS NOT NULL THEN 'dm' ELSE '' END, -- room_type 104 | -- only: postgres 105 | jsonb_build_object 106 | -- only: sqlite (line commented) 107 | -- json_object 108 | ( 109 | 'type', type, 110 | 'send_mode', send_mode, 111 | 'force_rcs', force_rcs 112 | ) -- metadata 113 | FROM portal_old; 114 | -- only: sqlite 115 | UPDATE portal SET metadata=replace(replace(metadata, '"force_rcs":1', '"force_rcs":true'), '"force_rcs":0', '"force_rcs":false'); 116 | 117 | INSERT INTO user_portal ( 118 | bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred 119 | ) 120 | SELECT 121 | '', -- bridge_id 122 | (SELECT mxid FROM user_old WHERE rowid=receiver), -- user_mxid 123 | (SELECT login_id FROM gmessages_login_prefix WHERE prefix=portal_old.receiver), -- login_id 124 | (CAST(receiver AS TEXT) || '.' || id), -- portal_id 125 | (SELECT login_id FROM gmessages_login_prefix WHERE prefix=portal_old.receiver), -- portal_receiver 126 | in_space, 127 | false 128 | FROM portal_old; 129 | 130 | INSERT INTO ghost ( 131 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 132 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 133 | ) 134 | SELECT DISTINCT 135 | '', CAST(conv_receiver AS TEXT) || '.' || sender, '', '', '', '', false, false, false, false, 136 | -- only: postgres 137 | '[]'::jsonb, '{}'::jsonb 138 | -- only: sqlite (line commented) 139 | -- '[]', '{}' 140 | FROM message_old 141 | WHERE true 142 | ON CONFLICT DO NOTHING; 143 | 144 | INSERT INTO message ( 145 | bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, 146 | timestamp, edit_count, metadata 147 | ) 148 | SELECT 149 | '', -- bridge_id 150 | (CAST(conv_receiver AS TEXT) || '.' || id), -- id 151 | '', -- part_id, 152 | mxid, 153 | (CAST(conv_receiver AS TEXT) || '.' || conv_id), -- room_id 154 | (SELECT login_id FROM gmessages_login_prefix WHERE prefix=conv_receiver), -- room_receiver 155 | (CAST(conv_receiver AS TEXT) || '.' || sender), -- sender_id 156 | '', -- sender_mxid 157 | timestamp * 1000, 158 | 0, -- edit_count 159 | status -- metadata 160 | FROM message_old; 161 | -- TODO split out parts from status? 162 | 163 | INSERT INTO ghost ( 164 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 165 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 166 | ) 167 | SELECT DISTINCT 168 | '', CAST(conv_receiver AS TEXT) || '.' || sender, '', '', '', '', false, false, false, false, 169 | -- only: postgres 170 | '[]'::jsonb, '{}'::jsonb 171 | -- only: sqlite (line commented) 172 | -- '[]', '{}' 173 | FROM reaction_old 174 | WHERE true 175 | ON CONFLICT DO NOTHING; 176 | 177 | INSERT INTO reaction ( 178 | bridge_id, message_id, message_part_id, sender_id, emoji_id, 179 | room_id, room_receiver, mxid, timestamp, emoji, metadata 180 | ) 181 | SELECT 182 | '', -- bridge_id 183 | (CAST(conv_receiver AS TEXT) || '.' || msg_id), -- message_id 184 | '', -- message_part_id 185 | (CAST(conv_receiver AS TEXT) || '.' || sender), -- sender_id 186 | '', -- emoji_id 187 | (CAST(conv_receiver AS TEXT) || '.' || conv_id), -- room_id 188 | (SELECT login_id FROM gmessages_login_prefix WHERE prefix=conv_receiver), -- room_receiver 189 | mxid, 190 | (SELECT (timestamp * 1000) + 1 FROM message_old WHERE conv_receiver=reaction_old.conv_receiver and id=reaction_old.msg_id), -- timestamp 191 | reaction, -- emoji 192 | -- only: postgres 193 | '{}'::jsonb 194 | -- only: sqlite (line commented) 195 | -- '{}' 196 | FROM reaction_old 197 | WHERE EXISTS(SELECT 1 FROM message_old WHERE message_old.conv_receiver=reaction_old.conv_receiver and message_old.id=reaction_old.msg_id); 198 | 199 | DROP TABLE reaction_old; 200 | DROP TABLE message_old; 201 | DROP TABLE portal_old; 202 | DROP TABLE puppet_old; 203 | DROP TABLE user_old; 204 | -------------------------------------------------------------------------------- /cmd/mautrix-gmessages/main.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package main 18 | 19 | import ( 20 | "net/http" 21 | 22 | "maunium.net/go/mautrix/bridgev2/bridgeconfig" 23 | "maunium.net/go/mautrix/bridgev2/matrix/mxmain" 24 | 25 | "go.mau.fi/mautrix-gmessages/pkg/connector" 26 | ) 27 | 28 | var ( 29 | Tag = "unknown" 30 | Commit = "unknown" 31 | BuildTime = "unknown" 32 | ) 33 | 34 | var c = &connector.GMConnector{} 35 | var m = mxmain.BridgeMain{ 36 | Name: "mautrix-gmessages", 37 | Description: "A Matrix-Google Messages puppeting bridge", 38 | URL: "https://github.com/mautrix/gmessages", 39 | Version: "0.6.2", 40 | Connector: c, 41 | } 42 | 43 | func main() { 44 | bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig 45 | m.PostInit = func() { 46 | m.CheckLegacyDB( 47 | 10, 48 | "v0.4.3", 49 | "v0.5.0", 50 | m.LegacyMigrateSimple(legacyMigrateRenameTables, legacyMigrateCopyData, 14), 51 | true, 52 | ) 53 | } 54 | m.PostStart = func() { 55 | if m.Matrix.Provisioning != nil { 56 | m.Matrix.Provisioning.Router.HandleFunc("/v1/ping", legacyProvPing).Methods(http.MethodGet) 57 | m.Matrix.Provisioning.Router.HandleFunc("/v1/login", legacyProvQRLogin).Methods(http.MethodPost) 58 | m.Matrix.Provisioning.Router.HandleFunc("/v1/google_login/emoji", legacyProvGoogleLoginStart).Methods(http.MethodPost) 59 | m.Matrix.Provisioning.Router.HandleFunc("/v1/google_login/wait", legacyProvGoogleLoginWait).Methods(http.MethodPost) 60 | m.Matrix.Provisioning.Router.HandleFunc("/v1/logout", legacyProvLogout).Methods(http.MethodPost) 61 | m.Matrix.Provisioning.Router.HandleFunc("/v1/delete_session", legacyProvDeleteSession).Methods(http.MethodPost) 62 | m.Matrix.Provisioning.Router.HandleFunc("/v1/contacts", legacyProvListContacts).Methods(http.MethodPost) 63 | m.Matrix.Provisioning.Router.HandleFunc("/v1/start_chat", legacyProvStartChat).Methods(http.MethodPost) 64 | } 65 | } 66 | m.InitVersion(Tag, Commit, BuildTime) 67 | m.Run() 68 | } 69 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ -z "$GID" ]]; then 4 | GID="$UID" 5 | fi 6 | 7 | # Define functions. 8 | function fixperms { 9 | chown -R $UID:$GID /data 10 | 11 | # /opt/mautrix-gmessages is read-only, so disable file logging if it's pointing there. 12 | if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-gmessages.log" ]]; then 13 | yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml 14 | fi 15 | } 16 | 17 | if [[ ! -f /data/config.yaml ]]; then 18 | /usr/bin/mautrix-gmessages -c /data/config.yaml -e 19 | echo "Didn't find a config file." 20 | echo "Copied default config file to /data/config.yaml" 21 | echo "Modify that config file to your liking." 22 | echo "Start the container again after that to generate the registration file." 23 | exit 24 | fi 25 | 26 | if [[ ! -f /data/registration.yaml ]]; then 27 | /usr/bin/mautrix-gmessages -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 | exit 32 | fi 33 | 34 | cd /data 35 | fixperms 36 | exec su-exec $UID:$GID /usr/bin/mautrix-gmessages 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.mau.fi/mautrix-gmessages 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/gabriel-vasile/mimetype v1.4.9 9 | github.com/google/uuid v1.6.0 10 | github.com/rs/zerolog v1.34.0 11 | github.com/stretchr/testify v1.10.0 12 | go.mau.fi/util v0.8.7 13 | golang.org/x/crypto v0.38.0 14 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 15 | google.golang.org/protobuf v1.36.6 16 | gopkg.in/yaml.v3 v3.0.1 17 | maunium.net/go/mautrix v0.24.0 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/gorilla/mux v1.8.0 // indirect 25 | github.com/gorilla/websocket v1.5.0 // indirect 26 | github.com/kr/pretty v0.3.1 // indirect 27 | github.com/lib/pq v1.10.9 // indirect 28 | github.com/mattn/go-colorable v0.1.14 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 31 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/rogpeppe/go-internal v1.10.0 // indirect 34 | github.com/rs/xid v1.6.0 // indirect 35 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 36 | github.com/tidwall/gjson v1.18.0 // indirect 37 | github.com/tidwall/match v1.1.1 // indirect 38 | github.com/tidwall/pretty v1.2.1 // indirect 39 | github.com/tidwall/sjson v1.2.5 // indirect 40 | github.com/yuin/goldmark v1.7.11 // indirect 41 | go.mau.fi/zeroconfig v0.1.3 // indirect 42 | golang.org/x/net v0.40.0 // indirect 43 | golang.org/x/sync v0.14.0 // indirect 44 | golang.org/x/sys v0.33.0 // indirect 45 | golang.org/x/text v0.25.0 // indirect 46 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 47 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 48 | maunium.net/go/mauflag v1.0.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 4 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 5 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 6 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= 11 | github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= 12 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 18 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 19 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 20 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 21 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 22 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 23 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 24 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 25 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 29 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 30 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 31 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 32 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 33 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 34 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 36 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 37 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 38 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 39 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= 40 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 41 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 42 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 46 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 47 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 48 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 49 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 50 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 51 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 52 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 53 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 54 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 55 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 56 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 57 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 58 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 59 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 60 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 61 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 62 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 63 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 64 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 65 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 66 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 67 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 68 | go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= 69 | go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= 70 | go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= 71 | go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= 72 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 73 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 74 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 75 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 76 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 77 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 78 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 79 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 80 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 84 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 85 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 86 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 87 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 88 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 92 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 93 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 94 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 95 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 96 | maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= 97 | maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 98 | maunium.net/go/mautrix v0.24.0 h1:kBeyWhgL1W8/d8BEFlBSlgIpItPgP1l37hzF8cN3R70= 99 | maunium.net/go/mautrix v0.24.0/go.mod h1:HqA1HUutQYJkrYRPkK64itARDz79PCec1oWVEB72HVQ= 100 | -------------------------------------------------------------------------------- /pkg/connector/backfill.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "slices" 23 | "strconv" 24 | "time" 25 | 26 | "github.com/rs/zerolog" 27 | "google.golang.org/protobuf/proto" 28 | "maunium.net/go/mautrix/bridgev2" 29 | "maunium.net/go/mautrix/bridgev2/networkid" 30 | 31 | "go.mau.fi/mautrix-gmessages/pkg/libgm" 32 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 33 | ) 34 | 35 | var _ bridgev2.BackfillingNetworkAPI = (*GMClient)(nil) 36 | 37 | func makePaginationCursor(cursor *gmproto.Cursor) networkid.PaginationCursor { 38 | if cursor == nil { 39 | return "" 40 | } 41 | return networkid.PaginationCursor(fmt.Sprintf("%s:%d", cursor.LastItemID, cursor.LastItemTimestamp)) 42 | } 43 | 44 | func parsePaginationCursor(cursor networkid.PaginationCursor) (*gmproto.Cursor, error) { 45 | var id int64 46 | var ts int64 47 | _, err := fmt.Sscanf(string(cursor), "%d:%d", &id, &ts) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to parse pagination cursor: %w", err) 50 | } 51 | return &gmproto.Cursor{ 52 | LastItemID: strconv.FormatInt(id, 10), 53 | LastItemTimestamp: ts, 54 | }, nil 55 | } 56 | 57 | func (gc *GMClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { 58 | if gc.Client == nil { 59 | return nil, bridgev2.ErrNotLoggedIn 60 | } 61 | convID, err := gc.ParsePortalID(params.Portal.ID) 62 | if err != nil { 63 | return nil, err 64 | } 65 | var cursor, anchorMsgCursor *gmproto.Cursor 66 | if params.Cursor != "" { 67 | cursor, _ = parsePaginationCursor(params.Cursor) 68 | } 69 | var anchorTS time.Time 70 | var anchorMsgID string 71 | if params.AnchorMessage != nil { 72 | anchorMsgID, err = gc.ParseMessageID(params.AnchorMessage.ID) 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to parse anchor message ID: %w", err) 75 | } 76 | anchorTS = params.AnchorMessage.Timestamp 77 | if !params.Forward { 78 | tsMilli := anchorTS.UnixMilli() 79 | anchorMsgCursor = &gmproto.Cursor{ 80 | LastItemID: anchorMsgID, 81 | LastItemTimestamp: tsMilli, 82 | } 83 | if cursor == nil || tsMilli < cursor.LastItemTimestamp { 84 | cursor = anchorMsgCursor 85 | } 86 | } 87 | } 88 | resp, err := gc.Client.FetchMessages(convID, int64(params.Count), cursor) 89 | if err != nil { 90 | return nil, err 91 | } 92 | zerolog.Ctx(ctx).Debug(). 93 | Str("param_cursor", string(params.Cursor)). 94 | Str("anchor_cursor", string(makePaginationCursor(anchorMsgCursor))). 95 | Str("used_cursor", string(makePaginationCursor(cursor))). 96 | Str("response_cursor", string(makePaginationCursor(resp.Cursor))). 97 | Int("message_count", len(resp.Messages)). 98 | Int64("total_messages", resp.TotalMessages). 99 | Bool("forward", params.Forward). 100 | Msg("Google Messages fetch response") 101 | slices.Reverse(resp.Messages) 102 | fetchResp := &bridgev2.FetchMessagesResponse{ 103 | Messages: make([]*bridgev2.BackfillMessage, 0, len(resp.Messages)), 104 | Forward: cursor == nil, 105 | MarkRead: false, 106 | ApproxTotalCount: int(resp.TotalMessages), 107 | } 108 | for _, msg := range resp.Messages { 109 | msgTS := time.UnixMicro(msg.Timestamp) 110 | log := zerolog.Ctx(ctx).With().Str("message_id", msg.MessageID).Time("message_ts", msgTS).Logger() 111 | if !params.Forward && cursor != nil && msgTS.UnixMilli() >= cursor.LastItemTimestamp { 112 | log.Debug().Int64("cursor_ms", cursor.LastItemTimestamp).Msg("Ignoring message newer than cursor") 113 | continue 114 | } else if params.Forward && msgTS.Before(anchorTS) || anchorMsgID == msg.MessageID { 115 | log.Debug(). 116 | Time("anchor_ts", anchorTS). 117 | Str("anchor_message_id", anchorMsgID). 118 | Msg("Ignoring message older than anchor message") 119 | continue 120 | } 121 | ctx := log.WithContext(ctx) 122 | sender := gc.getEventSenderFromMessage(msg) 123 | intent := params.Portal.GetIntentFor(ctx, sender, gc.UserLogin, bridgev2.RemoteEventBackfill) 124 | rawData, _ := proto.Marshal(msg) 125 | backfillMsg := &bridgev2.BackfillMessage{ 126 | ConvertedMessage: gc.ConvertGoogleMessage(ctx, params.Portal, intent, &libgm.WrappedMessage{ 127 | Message: msg, 128 | Data: rawData, 129 | }, true), 130 | Sender: sender, 131 | ID: gc.MakeMessageID(msg.MessageID), 132 | TxnID: networkid.TransactionID(msg.TmpID), 133 | Timestamp: msgTS, 134 | StreamOrder: msg.Timestamp, 135 | Reactions: (&ReactionSyncEvent{Message: msg, g: gc}).GetReactions().ToBackfill(), 136 | } 137 | fetchResp.Messages = append(fetchResp.Messages, backfillMsg) 138 | } 139 | if len(fetchResp.Messages) == 0 { 140 | return fetchResp, nil 141 | } 142 | fetchResp.HasMore = true 143 | if params.Forward { 144 | fetchResp.AggressiveDeduplication = params.AnchorMessage != nil 145 | gc.conversationMetaLock.Lock() 146 | meta := gc.conversationMeta[convID] 147 | if meta != nil { 148 | lastWrappedMsg := fetchResp.Messages[len(fetchResp.Messages)-1] 149 | lastRawMsg := resp.Messages[len(resp.Messages)-1] 150 | fetchResp.MarkRead = !meta.unread || !meta.readUpToTS.Before(lastWrappedMsg.Timestamp) || meta.readUpTo == lastRawMsg.MessageID 151 | } 152 | gc.conversationMetaLock.Unlock() 153 | } else { 154 | fetchResp.Cursor = makePaginationCursor(resp.Cursor) 155 | if fetchResp.Cursor == "" && len(resp.Messages) > 0 { 156 | fetchResp.Cursor = makePaginationCursor(&gmproto.Cursor{ 157 | LastItemID: resp.Messages[0].MessageID, 158 | LastItemTimestamp: time.UnixMicro(resp.Messages[0].Timestamp).UnixMilli(), 159 | }) 160 | } 161 | } 162 | return fetchResp, nil 163 | } 164 | -------------------------------------------------------------------------------- /pkg/connector/bridgestate.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "maunium.net/go/mautrix/bridgev2/status" 21 | ) 22 | 23 | const ( 24 | GMListenError status.BridgeStateErrorCode = "gm-listen-error" 25 | GMFatalError status.BridgeStateErrorCode = "gm-listen-fatal-error" 26 | GMUnpaired status.BridgeStateErrorCode = "gm-unpaired" 27 | GMUnpaired404 status.BridgeStateErrorCode = "gm-unpaired-entity-not-found" 28 | GMLoggedOut401 status.BridgeStateErrorCode = "gm-logged-out-401-polling" 29 | GMLoggedOutInvalidCreds status.BridgeStateErrorCode = "gm-logged-out-invalid-credentials" 30 | GMLoggedOutNoEmailInConfig status.BridgeStateErrorCode = "gm-logged-out-no-email-in-config" 31 | GMNotLoggedIn status.BridgeStateErrorCode = "gm-not-logged-in" 32 | GMNotLoggedInCanReauth status.BridgeStateErrorCode = "gm-not-logged-in-can-reauth" 33 | GMConnecting status.BridgeStateErrorCode = "gm-connecting" 34 | GMConnectionFailed status.BridgeStateErrorCode = "gm-connection-failed" 35 | GMConfigFetchFailed status.BridgeStateErrorCode = "gm-config-fetch-failed" 36 | GMPingFailed status.BridgeStateErrorCode = "gm-ping-failed" 37 | GMNotDefaultSMSApp status.BridgeStateErrorCode = "gm-not-default-sms-app" 38 | GMBrowserInactive status.BridgeStateErrorCode = "gm-browser-inactive" 39 | GMBrowserInactiveTimeout status.BridgeStateErrorCode = "gm-browser-inactive-timeout" 40 | GMBrowserInactiveInactivity status.BridgeStateErrorCode = "gm-browser-inactive-inactivity" 41 | GMPhoneNotResponding status.BridgeStateErrorCode = "gm-phone-not-responding" 42 | GMSwitchedToGoogleLogin status.BridgeStateErrorCode = "gm-switched-to-google-login" 43 | ) 44 | 45 | func init() { 46 | status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ 47 | GMListenError: "Error polling messages from Google Messages server, the bridge will try to reconnect", 48 | GMFatalError: "Fatal error polling messages from Google Messages server", 49 | GMConfigFetchFailed: "Failed to initialize connection to Google Messages", 50 | GMNotLoggedIn: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 51 | GMNotLoggedInCanReauth: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 52 | GMUnpaired: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 53 | GMUnpaired404: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 54 | GMLoggedOut401: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 55 | GMLoggedOutInvalidCreds: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 56 | GMLoggedOutNoEmailInConfig: "Unpaired from Google Messages, please re-link the connection to continue using SMS/RCS", 57 | GMNotDefaultSMSApp: "Google Messages isn't set as the default SMS app. Please set the default SMS app on your Android phone to Google Messages to continue using SMS/RCS.", 58 | GMBrowserInactive: "Google Messages opened in another browser", 59 | GMBrowserInactiveTimeout: "Google Messages disconnected due to timeout", 60 | GMBrowserInactiveInactivity: "Google Messages disconnected due to inactivity", 61 | GMPhoneNotResponding: "Your Google Messages app is not responding. Open the Google Messages app on your phone, ensure messages send, and battery optimization is off. If needed, remove and re-add the connection.", 62 | GMSwitchedToGoogleLogin: "You switched to Google account pairing, please log in to continue using SMS/RCS", 63 | }) 64 | } 65 | 66 | func (gc *GMClient) FillBridgeState(state status.BridgeState) status.BridgeState { 67 | if state.Info == nil { 68 | state.Info = make(map[string]any) 69 | } 70 | if state.StateEvent == status.StateConnected { 71 | state.Info["sims"] = gc.Meta.GetSIMsForBridgeState() 72 | state.Info["settings"] = gc.Meta.Settings 73 | state.Info["battery_low"] = gc.batteryLow 74 | state.Info["mobile_data"] = gc.mobileData 75 | state.Info["browser_active"] = gc.browserInactiveType == "" 76 | state.Info["google_account_pairing"] = gc.SwitchedToGoogleLogin 77 | if !gc.ready { 78 | state.StateEvent = status.StateConnecting 79 | state.Error = GMConnecting 80 | } 81 | if !gc.PhoneResponding { 82 | state.StateEvent = status.StateBadCredentials 83 | state.Error = GMPhoneNotResponding 84 | state.UserAction = status.UserActionOpenNative 85 | } 86 | if gc.SwitchedToGoogleLogin { 87 | state.StateEvent = status.StateBadCredentials 88 | state.Error = GMSwitchedToGoogleLogin 89 | } 90 | if gc.longPollingError != nil { 91 | state.StateEvent = status.StateTransientDisconnect 92 | state.Error = GMListenError 93 | state.Info["go_error"] = gc.longPollingError.Error() 94 | } 95 | if gc.browserInactiveType != "" { 96 | if gc.Main.Config.AggressiveReconnect { 97 | state.StateEvent = status.StateTransientDisconnect 98 | } else { 99 | state.StateEvent = status.StateBadCredentials 100 | } 101 | state.Error = gc.browserInactiveType 102 | } 103 | } 104 | return state 105 | } 106 | -------------------------------------------------------------------------------- /pkg/connector/capabilities.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "time" 22 | 23 | "go.mau.fi/util/ffmpeg" 24 | "go.mau.fi/util/ptr" 25 | "maunium.net/go/mautrix/bridgev2" 26 | "maunium.net/go/mautrix/bridgev2/database" 27 | "maunium.net/go/mautrix/event" 28 | 29 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 30 | ) 31 | 32 | var generalCaps = &bridgev2.NetworkGeneralCapabilities{ 33 | DisappearingMessages: false, 34 | AggressiveUpdateInfo: false, 35 | OutgoingMessageTimeouts: &bridgev2.OutgoingTimeoutConfig{ 36 | NoEchoTimeout: 1 * time.Minute, 37 | NoEchoMessage: "phone has not confirmed message delivery", 38 | NoAckTimeout: 3 * time.Minute, 39 | NoAckMessage: "phone is not responding", 40 | CheckInterval: 1 * time.Minute, 41 | }, 42 | } 43 | 44 | func (gc *GMConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { 45 | return generalCaps 46 | } 47 | 48 | func (gc *GMConnector) GetBridgeInfoVersion() (info, caps int) { 49 | return 1, 1 50 | } 51 | 52 | const MaxFileSizeRCS = 100 * 1024 * 1024 53 | const MaxFileSizeMMS = 1 * 1024 * 1024 54 | 55 | func supportedIfFFmpeg() event.CapabilitySupportLevel { 56 | if ffmpeg.Supported() { 57 | return event.CapLevelPartialSupport 58 | } 59 | return event.CapLevelRejected 60 | } 61 | 62 | func capID(chatType string) string { 63 | base := "fi.mau.gmessages.capabilities.2025_01_10." + chatType 64 | if ffmpeg.Supported() { 65 | return base + "+ffmpeg" 66 | } 67 | return base 68 | } 69 | 70 | var imageMimes = map[string]event.CapabilitySupportLevel{ 71 | "image/png": event.CapLevelFullySupported, 72 | "image/jpeg": event.CapLevelFullySupported, 73 | "image/gif": event.CapLevelFullySupported, 74 | "image/bmp": event.CapLevelFullySupported, 75 | "image/wbmp": event.CapLevelFullySupported, 76 | "image/webp": event.CapLevelFullySupported, 77 | } 78 | 79 | var audioMimes = map[string]event.CapabilitySupportLevel{ 80 | "audio/aac": event.CapLevelFullySupported, 81 | "audio/amr": event.CapLevelFullySupported, 82 | "audio/mpeg": event.CapLevelFullySupported, 83 | "audio/mp4": event.CapLevelFullySupported, 84 | "audio/mp4-latm": event.CapLevelFullySupported, 85 | "audio/3gpp": event.CapLevelFullySupported, 86 | "audio/ogg": event.CapLevelFullySupported, 87 | } 88 | 89 | var videoMimes = map[string]event.CapabilitySupportLevel{ 90 | "video/mp4": event.CapLevelFullySupported, 91 | "video/3gpp": event.CapLevelFullySupported, 92 | "video/webm": event.CapLevelFullySupported, 93 | } 94 | 95 | var fileMimes = map[string]event.CapabilitySupportLevel{ 96 | "application/*": event.CapLevelFullySupported, 97 | "text/*": event.CapLevelFullySupported, 98 | } 99 | 100 | var voiceMimes = map[string]event.CapabilitySupportLevel{ 101 | "audio/ogg": supportedIfFFmpeg(), 102 | "audio/mp4": event.CapLevelFullySupported, 103 | } 104 | 105 | var gifMimes = map[string]event.CapabilitySupportLevel{ 106 | "image/gif": event.CapLevelFullySupported, 107 | } 108 | 109 | var rcsDMCaps = &event.RoomFeatures{ 110 | ID: capID("rcs_dm"), 111 | File: event.FileFeatureMap{ 112 | event.MsgImage: { 113 | MimeTypes: imageMimes, 114 | MaxSize: MaxFileSizeRCS, 115 | }, 116 | event.MsgAudio: { 117 | MimeTypes: audioMimes, 118 | MaxSize: MaxFileSizeRCS, 119 | }, 120 | event.MsgVideo: { 121 | MimeTypes: videoMimes, 122 | MaxSize: MaxFileSizeRCS, 123 | }, 124 | event.MsgFile: { 125 | MimeTypes: fileMimes, 126 | MaxSize: MaxFileSizeRCS, 127 | }, 128 | event.CapMsgVoice: { 129 | MimeTypes: voiceMimes, 130 | MaxSize: MaxFileSizeRCS, 131 | }, 132 | event.CapMsgGIF: { 133 | MimeTypes: gifMimes, 134 | MaxSize: MaxFileSizeRCS, 135 | }, 136 | }, 137 | Reply: event.CapLevelFullySupported, 138 | DeleteForMe: true, 139 | Reaction: event.CapLevelFullySupported, 140 | ReactionCount: 1, 141 | ReadReceipts: true, 142 | } 143 | 144 | var rcsGroupCaps *event.RoomFeatures 145 | 146 | func init() { 147 | rcsGroupCaps = ptr.Clone(rcsDMCaps) 148 | rcsGroupCaps.ID = capID("rcs_group") 149 | rcsGroupCaps.ReadReceipts = false 150 | } 151 | 152 | var smsRoomCaps = &event.RoomFeatures{ 153 | ID: capID("sms"), 154 | File: event.FileFeatureMap{ 155 | event.MsgImage: { 156 | MimeTypes: imageMimes, 157 | Caption: event.CapLevelFullySupported, 158 | MaxSize: MaxFileSizeMMS, 159 | }, 160 | event.MsgAudio: { 161 | MimeTypes: audioMimes, 162 | Caption: event.CapLevelFullySupported, 163 | MaxSize: MaxFileSizeMMS, 164 | }, 165 | event.MsgVideo: { 166 | MimeTypes: videoMimes, 167 | Caption: event.CapLevelFullySupported, 168 | MaxSize: MaxFileSizeMMS, 169 | }, 170 | event.MsgFile: { 171 | MimeTypes: fileMimes, 172 | Caption: event.CapLevelFullySupported, 173 | MaxSize: MaxFileSizeMMS, 174 | }, 175 | event.CapMsgVoice: { 176 | MimeTypes: voiceMimes, 177 | MaxSize: MaxFileSizeRCS, 178 | }, 179 | event.CapMsgGIF: { 180 | MimeTypes: gifMimes, 181 | Caption: event.CapLevelFullySupported, 182 | MaxSize: MaxFileSizeMMS, 183 | }, 184 | }, 185 | DeleteForMe: true, 186 | Reaction: event.CapLevelPartialSupport, 187 | ReactionCount: 1, 188 | ReadReceipts: true, 189 | } 190 | 191 | func (gc *GMClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { 192 | if portal.Metadata.(*PortalMetadata).Type == gmproto.ConversationType_RCS { 193 | if portal.RoomType == database.RoomTypeDM { 194 | return rcsDMCaps 195 | } else { 196 | return rcsGroupCaps 197 | } 198 | } else { 199 | return smsRoomCaps 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /pkg/connector/chatinfo.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "crypto/sha256" 22 | "fmt" 23 | "strings" 24 | "time" 25 | 26 | "github.com/rs/zerolog" 27 | "go.mau.fi/util/jsontime" 28 | "go.mau.fi/util/ptr" 29 | "maunium.net/go/mautrix/bridgev2" 30 | "maunium.net/go/mautrix/bridgev2/database" 31 | "maunium.net/go/mautrix/bridgev2/networkid" 32 | "maunium.net/go/mautrix/event" 33 | 34 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 35 | ) 36 | 37 | var _ bridgev2.PortalBridgeInfoFillingNetwork = (*GMConnector)(nil) 38 | 39 | func (gc *GMConnector) FillPortalBridgeInfo(portal *bridgev2.Portal, content *event.BridgeEventContent) { 40 | switch portal.Metadata.(*PortalMetadata).Type { 41 | case gmproto.ConversationType_SMS: 42 | content.Protocol.ID = "gmessages-sms" 43 | content.Protocol.DisplayName = "Google Messages (SMS)" 44 | case gmproto.ConversationType_RCS: 45 | content.Protocol.ID = "gmessages-rcs" 46 | content.Protocol.DisplayName = "Google Messages (RCS)" 47 | } 48 | } 49 | 50 | func (gc *GMClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { 51 | conversationID, err := gc.ParsePortalID(portal.ID) 52 | if err != nil { 53 | return nil, err 54 | } 55 | zerolog.Ctx(ctx).Info().Str("conversation_id", conversationID).Msg("Manually fetching chat info") 56 | conv, err := gc.Client.GetConversation(conversationID) 57 | if err != nil { 58 | return nil, err 59 | } 60 | switch conv.GetStatus() { 61 | case gmproto.ConversationStatus_SPAM_FOLDER, gmproto.ConversationStatus_BLOCKED_FOLDER, gmproto.ConversationStatus_DELETED: 62 | return nil, fmt.Errorf("conversation is in a blocked status: %s", conv.GetStatus()) 63 | } 64 | return gc.wrapChatInfo(ctx, conv), nil 65 | } 66 | 67 | func (gc *GMClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { 68 | return nil, nil 69 | } 70 | 71 | func (gc *GMClient) wrapChatInfo(ctx context.Context, conv *gmproto.Conversation) *bridgev2.ChatInfo { 72 | log := zerolog.Ctx(ctx) 73 | roomType := database.RoomTypeDefault 74 | if !conv.IsGroupChat { 75 | roomType = database.RoomTypeDM 76 | } 77 | var name *string 78 | if conv.IsGroupChat { 79 | name = &conv.Name 80 | } else { 81 | name = bridgev2.DefaultChatName 82 | } 83 | userLoginChanged := false 84 | eventsDefaultPL := 0 85 | if conv.ReadOnly { 86 | eventsDefaultPL = 50 87 | } 88 | members := &bridgev2.ChatMemberList{ 89 | IsFull: true, 90 | MemberMap: map[networkid.UserID]bridgev2.ChatMember{ 91 | "": {EventSender: bridgev2.EventSender{IsFromMe: true}}, 92 | }, 93 | PowerLevels: &bridgev2.PowerLevelOverrides{ 94 | Events: map[event.Type]int{ 95 | event.StateRoomName: 0, 96 | event.StateRoomAvatar: 0, 97 | event.EventReaction: eventsDefaultPL, 98 | event.EventRedaction: 0, 99 | }, 100 | UsersDefault: ptr.Ptr(0), 101 | EventsDefault: ptr.Ptr(eventsDefaultPL), 102 | StateDefault: ptr.Ptr(99), 103 | Invite: ptr.Ptr(99), 104 | Kick: ptr.Ptr(99), 105 | Ban: ptr.Ptr(99), 106 | Redact: ptr.Ptr(0), 107 | }, 108 | } 109 | for _, pcp := range conv.Participants { 110 | if pcp.IsMe { 111 | if gc.Meta.AddSelfParticipantID(pcp.ID.ParticipantID) { 112 | log.Debug().Any("participant", pcp).Msg("Added conversation participant to self participant IDs") 113 | userLoginChanged = true 114 | } 115 | } else if pcp.ID.Number == "" { 116 | log.Warn().Any("participant", pcp).Msg("No number found in non-self participant entry") 117 | } else if !pcp.IsVisible { 118 | log.Debug().Any("participant", pcp).Msg("Ignoring fake participant") 119 | } else { 120 | userID := gc.MakeUserID(pcp.ID.ParticipantID) 121 | members.MemberMap[userID] = bridgev2.ChatMember{ 122 | EventSender: bridgev2.EventSender{Sender: gc.MakeUserID(pcp.ID.ParticipantID)}, 123 | UserInfo: gc.wrapParticipantInfo(pcp), 124 | PowerLevel: ptr.Ptr(50), 125 | } 126 | } 127 | } 128 | if userLoginChanged { 129 | err := gc.UserLogin.Save(ctx) 130 | if err != nil { 131 | log.Warn().Msg("Failed to save user login") 132 | } 133 | } 134 | var tag event.RoomTag 135 | if conv.Pinned { 136 | tag = event.RoomTagFavourite 137 | } else if conv.Status == gmproto.ConversationStatus_ARCHIVED || conv.Status == gmproto.ConversationStatus_KEEP_ARCHIVED { 138 | tag = event.RoomTagLowPriority 139 | } 140 | return &bridgev2.ChatInfo{ 141 | Name: name, 142 | Members: members, 143 | Type: &roomType, 144 | UserLocal: &bridgev2.UserLocalPortalInfo{ 145 | Tag: &tag, 146 | }, 147 | CanBackfill: true, 148 | ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) { 149 | meta := portal.Metadata.(*PortalMetadata) 150 | if meta.Type != conv.Type { 151 | meta.Type = conv.Type 152 | changed = true 153 | } 154 | if meta.SendMode != conv.SendMode { 155 | meta.SendMode = conv.SendMode 156 | changed = true 157 | } 158 | if meta.OutgoingID != conv.DefaultOutgoingID { 159 | meta.OutgoingID = conv.DefaultOutgoingID 160 | changed = true 161 | } 162 | return 163 | }, 164 | } 165 | } 166 | 167 | const MinAvatarUpdateInterval = 24 * time.Hour 168 | 169 | func (gc *GMClient) updateGhostAvatar(ctx context.Context, ghost *bridgev2.Ghost) (bool, error) { 170 | meta := ghost.Metadata.(*GhostMetadata) 171 | if time.Since(meta.AvatarUpdateTS.Time) < MinAvatarUpdateInterval { 172 | return false, nil 173 | } else if meta.ContactID == "" && !phoneNumberMightHaveAvatar(meta.Phone) { 174 | return false, nil 175 | } 176 | participantID, err := gc.ParseUserID(ghost.ID) 177 | if err != nil { 178 | return false, err 179 | } 180 | resp, err := gc.Client.GetParticipantThumbnail(participantID) 181 | if err != nil { 182 | return false, fmt.Errorf("failed to get participant thumbnail: %w", err) 183 | } 184 | meta.AvatarUpdateTS = jsontime.UnixMilliNow() 185 | if len(resp.Thumbnail) == 0 || len(resp.Thumbnail[0].GetData().GetImageBuffer()) == 0 { 186 | ghost.UpdateAvatar(ctx, &bridgev2.Avatar{Remove: true}) 187 | } else { 188 | data := resp.Thumbnail[0].GetData().GetImageBuffer() 189 | ghost.UpdateAvatar(ctx, &bridgev2.Avatar{ 190 | ID: networkid.AvatarID(fmt.Sprintf("hash:%x", sha256.Sum256(data))), 191 | Get: func(ctx context.Context) ([]byte, error) { 192 | return data, nil 193 | }, 194 | }) 195 | } 196 | return true, nil 197 | } 198 | 199 | const GeminiPhoneNumber = "+18339913448" 200 | 201 | func phoneNumberMightHaveAvatar(phone string) bool { 202 | return strings.HasSuffix(phone, ".goog") || phone == GeminiPhoneNumber 203 | } 204 | 205 | func (gc *GMClient) wrapParticipantInfo(contact *gmproto.Participant) *bridgev2.UserInfo { 206 | return gc.makeUserInfo( 207 | contact.GetID().GetNumber(), 208 | contact.GetFormattedNumber(), 209 | contact.GetContactID(), 210 | contact.GetFullName(), 211 | contact.GetFirstName(), 212 | ) 213 | } 214 | 215 | func (gc *GMClient) wrapContactInfo(contact *gmproto.Contact) *bridgev2.UserInfo { 216 | return gc.makeUserInfo( 217 | contact.GetNumber().GetNumber(), 218 | contact.GetNumber().GetFormattedNumber(), 219 | contact.GetContactID(), 220 | contact.GetName(), 221 | "", 222 | ) 223 | } 224 | 225 | func (gc *GMClient) makeUserInfo(phone, formattedNumber, contactID, fullName, firstName string) *bridgev2.UserInfo { 226 | var identifiers []string 227 | if phone != "" { 228 | identifiers = append(identifiers, fmt.Sprintf("tel:%s", phone)) 229 | } 230 | return &bridgev2.UserInfo{ 231 | Identifiers: identifiers, 232 | Name: ptr.Ptr(gc.Main.Config.FormatDisplayname(formattedNumber, fullName, firstName)), 233 | IsBot: ptr.Ptr(false), 234 | ExtraUpdates: func(ctx context.Context, ghost *bridgev2.Ghost) (changed bool) { 235 | meta := ghost.Metadata.(*GhostMetadata) 236 | if meta.ContactID != contactID { 237 | changed = true 238 | meta.ContactID = contactID 239 | } 240 | if meta.Phone != phone { 241 | changed = true 242 | meta.Phone = phone 243 | } 244 | avatarChanged, err := gc.updateGhostAvatar(ctx, ghost) 245 | if err != nil { 246 | zerolog.Ctx(ctx).Err(err).Msg("Failed to update ghost avatar") 247 | } 248 | changed = changed || avatarChanged 249 | return 250 | }, 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /pkg/connector/chatsync.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "time" 22 | 23 | "github.com/rs/zerolog" 24 | "google.golang.org/protobuf/proto" 25 | "maunium.net/go/mautrix/bridgev2" 26 | "maunium.net/go/mautrix/bridgev2/database" 27 | "maunium.net/go/mautrix/bridgev2/networkid" 28 | "maunium.net/go/mautrix/bridgev2/simplevent" 29 | 30 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 31 | ) 32 | 33 | func (gc *GMClient) SyncConversations(ctx context.Context, lastDataReceived time.Time, minimalSync bool) { 34 | log := zerolog.Ctx(ctx) 35 | log.Info().Msg("Fetching conversation list") 36 | resp, err := gc.Client.ListConversations(gc.Main.Config.InitialChatSyncCount, gmproto.ListConversationsRequest_INBOX) 37 | if err != nil { 38 | log.Err(err).Msg("Failed to get conversation list") 39 | return 40 | } 41 | log.Info().Int("count", len(resp.GetConversations())).Msg("Syncing conversations") 42 | if !lastDataReceived.IsZero() { 43 | for _, conv := range resp.GetConversations() { 44 | lastMessageTS := time.UnixMicro(conv.GetLastMessageTimestamp()) 45 | if lastMessageTS.After(lastDataReceived) { 46 | log.Warn(). 47 | Time("last_message_ts", lastMessageTS). 48 | Time("last_data_received", lastDataReceived). 49 | Msg("Conversation's last message is newer than last data received time") 50 | minimalSync = false 51 | } 52 | } 53 | } else if minimalSync { 54 | log.Warn().Msg("Minimal sync called without last data received time") 55 | } 56 | if minimalSync { 57 | log.Debug().Msg("Minimal sync with no recent messages, not syncing conversations") 58 | return 59 | } 60 | for _, conv := range resp.GetConversations() { 61 | gc.syncConversation(ctx, conv, "sync") 62 | } 63 | } 64 | 65 | func (gc *GMClient) syncConversationMeta(v *gmproto.Conversation) (meta *conversationMeta, suspiciousUnmarkedSpam bool) { 66 | gc.conversationMetaLock.Lock() 67 | defer gc.conversationMetaLock.Unlock() 68 | var ok bool 69 | meta, ok = gc.conversationMeta[v.ConversationID] 70 | if !ok { 71 | meta = &conversationMeta{} 72 | gc.conversationMeta[v.ConversationID] = meta 73 | } 74 | meta.unread = v.Unread 75 | if !v.Unread { 76 | meta.readUpTo = v.LatestMessageID 77 | meta.readUpToTS = time.UnixMicro(v.LastMessageTimestamp) 78 | } else if meta.readUpTo == v.LatestMessageID { 79 | meta.readUpTo = "" 80 | meta.readUpToTS = time.Time{} 81 | } 82 | switch v.Status { 83 | case gmproto.ConversationStatus_SPAM_FOLDER, gmproto.ConversationStatus_BLOCKED_FOLDER: 84 | if meta.markedSpamAt.IsZero() { 85 | meta.markedSpamAt = time.Now() 86 | } 87 | case gmproto.ConversationStatus_DELETED: 88 | // no-op 89 | default: 90 | suspiciousUnmarkedSpam = time.Since(meta.markedSpamAt) < 1*time.Minute 91 | } 92 | return 93 | } 94 | 95 | func (gc *GMClient) syncConversation(ctx context.Context, v *gmproto.Conversation, source string) { 96 | meta, suspiciousUnmarkedSpam := gc.syncConversationMeta(v) 97 | 98 | log := zerolog.Ctx(ctx).With(). 99 | Str("action", "sync conversation"). 100 | Str("conversation_id", v.ConversationID). 101 | Stringer("conversation_status", v.Status). 102 | Str("data_source", source). 103 | Logger() 104 | 105 | convCopy := proto.Clone(v).(*gmproto.Conversation) 106 | convCopy.LatestMessage = nil 107 | if suspiciousUnmarkedSpam { 108 | log.Debug().Any("conversation_data", convCopy). 109 | Msg("Dropping conversation update due to suspected race condition with spam flag") 110 | return 111 | } 112 | log.Debug().Any("conversation_data", convCopy).Msg("Got conversation update") 113 | evt := &GMChatResync{ 114 | g: gc, 115 | Conv: v, 116 | AllowBackfill: time.Since(time.UnixMicro(v.LastMessageTimestamp)) > 5*time.Minute, 117 | } 118 | var markReadEvt *simplevent.Receipt 119 | if !v.Unread { 120 | markReadEvt = &simplevent.Receipt{ 121 | EventMeta: simplevent.EventMeta{ 122 | Type: bridgev2.RemoteEventReadReceipt, 123 | PortalKey: gc.MakePortalKey(v.ConversationID), 124 | Sender: bridgev2.EventSender{IsFromMe: true}, 125 | }, 126 | LastTarget: gc.MakeMessageID(meta.readUpTo), 127 | ReadUpTo: meta.readUpToTS, 128 | } 129 | } 130 | gc.Main.br.QueueRemoteEvent(gc.UserLogin, evt) 131 | switch v.Status { 132 | case gmproto.ConversationStatus_SPAM_FOLDER, gmproto.ConversationStatus_BLOCKED_FOLDER, gmproto.ConversationStatus_DELETED: 133 | // Don't send read/backfill events if the chat is being deleted 134 | return 135 | } 136 | if !evt.AllowBackfill { 137 | backfillEvt := &GMChatResync{ 138 | g: gc, 139 | Conv: v, 140 | AllowBackfill: true, 141 | OnlyBackfill: true, 142 | } 143 | backfillCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) 144 | cancelPrev := meta.cancelPendingBackfill.Swap(&cancel) 145 | if cancelPrev != nil { 146 | (*cancelPrev)() 147 | } 148 | log.Debug(). 149 | Str("latest_message_id", evt.Conv.LatestMessageID). 150 | Msg("Delaying missed forward backfill as latest message is new") 151 | go func() { 152 | select { 153 | case <-time.After(15 * time.Second): 154 | case <-backfillCtx.Done(): 155 | log.Debug(). 156 | Str("latest_message_id", evt.Conv.LatestMessageID). 157 | Msg("Backfill was cancelled by a newer backfill") 158 | return 159 | } 160 | gc.Main.br.QueueRemoteEvent(gc.UserLogin, backfillEvt) 161 | if markReadEvt != nil { 162 | gc.Main.br.QueueRemoteEvent(gc.UserLogin, markReadEvt) 163 | } 164 | }() 165 | } else if markReadEvt != nil { 166 | gc.Main.br.QueueRemoteEvent(gc.UserLogin, markReadEvt) 167 | } 168 | } 169 | 170 | type GMChatResync struct { 171 | g *GMClient 172 | Conv *gmproto.Conversation 173 | 174 | AllowBackfill bool 175 | OnlyBackfill bool 176 | } 177 | 178 | var ( 179 | _ bridgev2.RemoteChatResyncWithInfo = (*GMChatResync)(nil) 180 | _ bridgev2.RemoteChatResyncBackfill = (*GMChatResync)(nil) 181 | _ bridgev2.RemoteChatDelete = (*GMChatResync)(nil) 182 | _ bridgev2.RemoteEventThatMayCreatePortal = (*GMChatResync)(nil) 183 | ) 184 | 185 | func (evt *GMChatResync) GetType() bridgev2.RemoteEventType { 186 | switch evt.Conv.GetStatus() { 187 | case gmproto.ConversationStatus_SPAM_FOLDER, gmproto.ConversationStatus_BLOCKED_FOLDER, gmproto.ConversationStatus_DELETED: 188 | return bridgev2.RemoteEventChatDelete 189 | case gmproto.ConversationStatus_ACTIVE, gmproto.ConversationStatus_ARCHIVED, gmproto.ConversationStatus_KEEP_ARCHIVED: 190 | return bridgev2.RemoteEventChatResync 191 | default: 192 | return bridgev2.RemoteEventUnknown 193 | } 194 | } 195 | 196 | func (evt *GMChatResync) ShouldCreatePortal() bool { 197 | if evt.OnlyBackfill { 198 | return false 199 | } 200 | if evt.Conv.Participants == nil { 201 | return false 202 | } 203 | switch evt.Conv.GetStatus() { 204 | case gmproto.ConversationStatus_ACTIVE, gmproto.ConversationStatus_ARCHIVED: 205 | // continue to other checks 206 | default: 207 | // Don't create portal for keep_archived/spam/blocked/deleted 208 | return false 209 | } 210 | return true 211 | } 212 | 213 | func (evt *GMChatResync) GetPortalKey() networkid.PortalKey { 214 | return evt.g.MakePortalKey(evt.Conv.ConversationID) 215 | } 216 | 217 | func (evt *GMChatResync) AddLogContext(c zerolog.Context) zerolog.Context { 218 | return c. 219 | Stringer("conversation_status", evt.Conv.GetStatus()). 220 | Bool("backfill_only_evt", evt.OnlyBackfill) 221 | } 222 | 223 | func (evt *GMChatResync) GetSender() bridgev2.EventSender { 224 | return bridgev2.EventSender{} 225 | } 226 | 227 | func (evt *GMChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { 228 | if evt.OnlyBackfill { 229 | return nil, nil 230 | } 231 | return evt.g.wrapChatInfo(ctx, evt.Conv), nil 232 | } 233 | 234 | func (evt *GMChatResync) CheckNeedsBackfill(ctx context.Context, latestMessage *database.Message) (bool, error) { 235 | if !evt.AllowBackfill { 236 | return false, nil 237 | } 238 | lastMessageTS := time.UnixMicro(evt.Conv.LastMessageTimestamp) 239 | return evt.Conv.LastMessageTimestamp != 0 && (latestMessage == nil || 240 | (lastMessageTS.After(latestMessage.Timestamp) && 241 | evt.g.MakeMessageID(evt.Conv.LatestMessageID) != latestMessage.ID)), nil 242 | } 243 | 244 | func (evt *GMChatResync) DeleteOnlyForMe() bool { 245 | // All portals are already scoped to the user, so there's never a case where we're deleting a portal someone else is in 246 | return false 247 | } 248 | -------------------------------------------------------------------------------- /pkg/connector/client.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "sync" 23 | "sync/atomic" 24 | "time" 25 | 26 | "github.com/rs/zerolog" 27 | "go.mau.fi/util/exsync" 28 | "maunium.net/go/mautrix/bridgev2" 29 | "maunium.net/go/mautrix/bridgev2/networkid" 30 | "maunium.net/go/mautrix/bridgev2/status" 31 | 32 | "go.mau.fi/mautrix-gmessages/pkg/libgm" 33 | "go.mau.fi/mautrix-gmessages/pkg/libgm/events" 34 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 35 | ) 36 | 37 | type conversationMeta struct { 38 | markedSpamAt time.Time 39 | cancelPendingBackfill atomic.Pointer[context.CancelFunc] 40 | unread bool 41 | readUpTo string 42 | readUpToTS time.Time 43 | } 44 | 45 | type GMClient struct { 46 | Main *GMConnector 47 | UserLogin *bridgev2.UserLogin 48 | Client *libgm.Client 49 | Meta *UserLoginMetadata 50 | 51 | fullMediaRequests *exsync.Set[fullMediaRequestKey] 52 | 53 | longPollingError error 54 | browserInactiveType status.BridgeStateErrorCode 55 | SwitchedToGoogleLogin bool 56 | batteryLow bool 57 | mobileData bool 58 | PhoneResponding bool 59 | ready bool 60 | sessionID string 61 | batteryLowAlertSent time.Time 62 | pollErrorAlertSent bool 63 | phoneNotRespondingAlertSent bool 64 | didHackySetActive bool 65 | noDataReceivedRecently bool 66 | lastDataReceived time.Time 67 | 68 | chatInfoCache *exsync.Map[string, *gmproto.Conversation] 69 | conversationMeta map[string]*conversationMeta 70 | conversationMetaLock sync.Mutex 71 | } 72 | 73 | var _ bridgev2.NetworkAPI = &GMClient{} 74 | 75 | func (gc *GMConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { 76 | gcli := &GMClient{ 77 | Main: gc, 78 | UserLogin: login, 79 | Meta: login.Metadata.(*UserLoginMetadata), 80 | 81 | longPollingError: errors.New("not connected"), 82 | PhoneResponding: true, 83 | fullMediaRequests: exsync.NewSet[fullMediaRequestKey](), 84 | conversationMeta: make(map[string]*conversationMeta), 85 | chatInfoCache: exsync.NewMap[string, *gmproto.Conversation](), 86 | } 87 | gcli.NewClient() 88 | login.Client = gcli 89 | return nil 90 | } 91 | 92 | func (gc *GMClient) Connect(ctx context.Context) { 93 | if gc.Client == nil { 94 | gc.UserLogin.BridgeState.Send(status.BridgeState{ 95 | StateEvent: status.StateBadCredentials, 96 | Error: GMNotLoggedIn, 97 | }) 98 | return 99 | } else if gc.Meta.Session.IsGoogleAccount() && !gc.Meta.Session.HasCookies() { 100 | gc.UserLogin.BridgeState.Send(status.BridgeState{ 101 | StateEvent: status.StateBadCredentials, 102 | Error: GMNotLoggedInCanReauth, 103 | }) 104 | return 105 | } 106 | err := gc.Client.FetchConfig(ctx) 107 | if err != nil { 108 | zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch config") 109 | /*gc.UserLogin.BridgeState.Send(status.BridgeState{ 110 | StateEvent: status.StateUnknownError, 111 | Error: GMConfigFetchFailed, 112 | Info: map[string]any{ 113 | "go_error": err.Error(), 114 | }, 115 | }) 116 | return*/ 117 | } else if gc.Meta.Session.IsGoogleAccount() && gc.Client.Config.GetDeviceInfo().GetEmail() == "" { 118 | zerolog.Ctx(ctx).Error().Msg("No email in config, invalidating session") 119 | go gc.invalidateSession(ctx, status.BridgeState{ 120 | StateEvent: status.StateBadCredentials, 121 | Error: GMLoggedOutNoEmailInConfig, 122 | }, false) 123 | return 124 | } 125 | err = gc.Client.Connect() 126 | if err != nil { 127 | if errors.Is(err, events.ErrRequestedEntityNotFound) { 128 | go gc.invalidateSession(ctx, status.BridgeState{ 129 | StateEvent: status.StateBadCredentials, 130 | Error: GMUnpaired404, 131 | Info: map[string]any{ 132 | "go_error": err.Error(), 133 | }, 134 | }, true) 135 | } else if errors.Is(err, events.ErrInvalidCredentials) { 136 | go gc.invalidateSession(ctx, status.BridgeState{ 137 | StateEvent: status.StateBadCredentials, 138 | Error: GMLoggedOutInvalidCreds, 139 | Info: map[string]any{ 140 | "go_error": err.Error(), 141 | }, 142 | }, false) 143 | } else { 144 | gc.UserLogin.BridgeState.Send(status.BridgeState{ 145 | StateEvent: status.StateUnknownError, 146 | Error: GMConnectionFailed, 147 | Info: map[string]any{ 148 | "go_error": err.Error(), 149 | }, 150 | }) 151 | } 152 | } 153 | } 154 | 155 | func (gc *GMClient) Disconnect() { 156 | gc.longPollingError = errors.New("not connected") 157 | gc.PhoneResponding = true 158 | gc.batteryLow = false 159 | gc.SwitchedToGoogleLogin = false 160 | gc.ready = false 161 | gc.browserInactiveType = "" 162 | if cli := gc.Client; cli != nil { 163 | cli.Disconnect() 164 | } 165 | } 166 | 167 | func (gc *GMClient) ResetClient() { 168 | gc.Disconnect() 169 | if cli := gc.Client; cli != nil { 170 | cli.SetEventHandler(nil) 171 | gc.Client = nil 172 | } 173 | gc.NewClient() 174 | } 175 | 176 | func (gc *GMClient) NewClient() { 177 | sess := gc.Meta.Session 178 | if sess != nil { 179 | gc.Client = libgm.NewClient(sess, gc.Meta.PublicPushKeys(), gc.UserLogin.Log.With().Str("component", "libgm").Logger()) 180 | gc.Client.SetEventHandler(gc.handleGMEvent) 181 | } 182 | } 183 | 184 | func (gc *GMClient) IsLoggedIn() bool { 185 | return gc.Client.IsLoggedIn() 186 | } 187 | 188 | func (gc *GMClient) LogoutRemote(ctx context.Context) { 189 | if cli := gc.Client; cli != nil { 190 | err := cli.Unpair() 191 | if err != nil { 192 | zerolog.Ctx(ctx).Err(err).Msg("Failed to send unpair request") 193 | } 194 | } 195 | gc.Disconnect() 196 | gc.Meta.Session = nil 197 | gc.Client = nil 198 | } 199 | 200 | func (gc *GMClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { 201 | participantID, err := gc.ParseUserID(userID) 202 | return err == nil && (participantID == "1" || gc.Meta.IsSelfParticipantID(participantID)) 203 | } 204 | 205 | func (gc *GMClient) GetSIM(portal *bridgev2.Portal) *gmproto.SIMCard { 206 | return gc.Meta.GetSIM(portal.Metadata.(*PortalMetadata).OutgoingID) 207 | } 208 | -------------------------------------------------------------------------------- /pkg/connector/config.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | _ "embed" 21 | "strings" 22 | "text/template" 23 | 24 | up "go.mau.fi/util/configupgrade" 25 | "gopkg.in/yaml.v3" 26 | ) 27 | 28 | //go:embed example-config.yaml 29 | var ExampleConfig string 30 | 31 | type DeviceMetaConfig struct { 32 | OS string `yaml:"os"` 33 | Browser string `yaml:"browser"` 34 | Type string `yaml:"type"` 35 | } 36 | 37 | type Config struct { 38 | DisplaynameTemplate string `yaml:"displayname_template"` 39 | DeviceMeta DeviceMetaConfig `yaml:"device_meta"` 40 | AggressiveReconnect bool `yaml:"aggressive_reconnect"` 41 | InitialChatSyncCount int `yaml:"initial_chat_sync_count"` 42 | 43 | displaynameTemplate *template.Template `yaml:"-"` 44 | } 45 | 46 | type umConfig Config 47 | 48 | func (c *Config) UnmarshalYAML(node *yaml.Node) error { 49 | err := node.Decode((*umConfig)(c)) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | type DisplaynameTemplateArgs struct { 62 | PhoneNumber string 63 | FullName string 64 | FirstName string 65 | } 66 | 67 | func (bc *Config) FormatDisplayname(phone, fullName, firstName string) string { 68 | var buf strings.Builder 69 | _ = bc.displaynameTemplate.Execute(&buf, DisplaynameTemplateArgs{ 70 | PhoneNumber: phone, 71 | FullName: fullName, 72 | FirstName: firstName, 73 | }) 74 | return buf.String() 75 | } 76 | 77 | func (gc *GMConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { 78 | return ExampleConfig, &gc.Config, up.SimpleUpgrader(upgradeConfig) 79 | } 80 | 81 | func upgradeConfig(helper up.Helper) { 82 | helper.Copy(up.Str, "displayname_template") 83 | helper.Copy(up.Str, "device_meta", "os") 84 | helper.Copy(up.Str, "device_meta", "browser") 85 | helper.Copy(up.Str, "device_meta", "type") 86 | helper.Copy(up.Bool, "aggressive_reconnect") 87 | helper.Copy(up.Int, "initial_chat_sync_count") 88 | } 89 | -------------------------------------------------------------------------------- /pkg/connector/connector.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | 22 | "maunium.net/go/mautrix/bridgev2" 23 | 24 | "go.mau.fi/mautrix-gmessages/pkg/connector/gmdb" 25 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 26 | "go.mau.fi/mautrix-gmessages/pkg/libgm/util" 27 | ) 28 | 29 | type GMConnector struct { 30 | br *bridgev2.Bridge 31 | DB *gmdb.GMDB 32 | Config Config 33 | } 34 | 35 | var _ bridgev2.NetworkConnector = (*GMConnector)(nil) 36 | 37 | func (gc *GMConnector) Init(bridge *bridgev2.Bridge) { 38 | gc.DB = gmdb.New(bridge.DB.Database, bridge.Log.With().Str("db_section", "gmessages").Logger()) 39 | gc.br = bridge 40 | 41 | util.BrowserDetailsMessage.OS = gc.Config.DeviceMeta.OS 42 | browserVal, ok := gmproto.BrowserType_value[gc.Config.DeviceMeta.Browser] 43 | if !ok { 44 | gc.br.Log.Error().Str("browser_value", gc.Config.DeviceMeta.Browser).Msg("Invalid browser value") 45 | } else { 46 | util.BrowserDetailsMessage.BrowserType = gmproto.BrowserType(browserVal) 47 | } 48 | deviceVal, ok := gmproto.DeviceType_value[gc.Config.DeviceMeta.Type] 49 | if !ok { 50 | gc.br.Log.Error().Str("device_type_value", gc.Config.DeviceMeta.Type).Msg("Invalid device type value") 51 | } else { 52 | util.BrowserDetailsMessage.DeviceType = gmproto.DeviceType(deviceVal) 53 | } 54 | } 55 | 56 | func (gc *GMConnector) Start(ctx context.Context) error { 57 | return gc.DB.Upgrade(ctx) 58 | } 59 | 60 | func (gc *GMConnector) GetName() bridgev2.BridgeName { 61 | return bridgev2.BridgeName{ 62 | DisplayName: "Google Messages", 63 | NetworkURL: "https://messages.google.com", 64 | NetworkIcon: "mxc://maunium.net/yGOdcrJcwqARZqdzbfuxfhzb", 65 | NetworkID: "gmessages", 66 | BeeperBridgeType: "gmessages", 67 | DefaultPort: 29336, 68 | DefaultCommandPrefix: "!gm", 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/connector/dbmeta.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "crypto/ecdh" 21 | "crypto/rand" 22 | "encoding/json" 23 | "fmt" 24 | "slices" 25 | "strings" 26 | "sync" 27 | 28 | "go.mau.fi/util/exerrors" 29 | "go.mau.fi/util/jsontime" 30 | "go.mau.fi/util/random" 31 | "golang.org/x/exp/maps" 32 | "maunium.net/go/mautrix/bridgev2/database" 33 | "maunium.net/go/mautrix/id" 34 | 35 | "go.mau.fi/mautrix-gmessages/pkg/libgm" 36 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 37 | ) 38 | 39 | func (gc *GMConnector) GetDBMetaTypes() database.MetaTypes { 40 | return database.MetaTypes{ 41 | Portal: func() any { 42 | return &PortalMetadata{} 43 | }, 44 | Ghost: func() any { 45 | return &GhostMetadata{} 46 | }, 47 | Message: func() any { 48 | return &MessageMetadata{} 49 | }, 50 | Reaction: nil, 51 | UserLogin: func() any { 52 | return &UserLoginMetadata{} 53 | }, 54 | } 55 | } 56 | 57 | type PortalMetadata struct { 58 | Type gmproto.ConversationType `json:"type"` 59 | SendMode gmproto.ConversationSendMode `json:"send_mode"` 60 | ForceRCS bool `json:"force_rcs"` 61 | 62 | OutgoingID string `json:"outgoing_id"` 63 | } 64 | 65 | type GhostMetadata struct { 66 | Phone string `json:"phone"` 67 | ContactID string `json:"contact_id"` 68 | AvatarUpdateTS jsontime.UnixMilli `json:"avatar_update_ts"` 69 | } 70 | 71 | type MessageMetadata struct { 72 | Type gmproto.MessageStatusType `json:"type,omitempty"` 73 | 74 | GlobalMediaStatus string `json:"media_status,omitempty"` 75 | GlobalPartCount int `json:"part_count,omitempty"` 76 | 77 | TextHash string `json:"text_hash,omitempty"` 78 | MediaPartID string `json:"media_part_id,omitempty"` 79 | MediaID string `json:"media_id,omitempty"` 80 | MediaPending bool `json:"media_pending,omitempty"` 81 | 82 | IsOutgoing bool `json:"is_outgoing,omitempty"` 83 | MSSSent bool `json:"mss_sent,omitempty"` 84 | MSSFailSent bool `json:"mss_fail_sent,omitempty"` 85 | MSSDeliverySent bool `json:"mss_delivery_sent,omitempty"` 86 | ReadReceiptSent bool `json:"read_receipt_sent,omitempty"` 87 | 88 | OrigMXID id.EventID `json:"orig_mxid,omitempty"` 89 | } 90 | 91 | func (m *MessageMetadata) GetOrigMXID(mxid id.EventID) id.EventID { 92 | if m.OrigMXID != "" { 93 | return m.OrigMXID 94 | } 95 | return mxid 96 | } 97 | 98 | type UserLoginMetadata struct { 99 | lock sync.RWMutex 100 | Session *libgm.AuthData 101 | selfParticipantIDs []string 102 | simMetadata map[string]*gmproto.SIMCard 103 | Settings UserSettings 104 | IDPrefix string 105 | PushKeys *PushKeys 106 | } 107 | 108 | type PushKeys struct { 109 | Token string `json:"token"` 110 | P256DH []byte `json:"p256dh"` 111 | Auth []byte `json:"auth"` 112 | Private []byte `json:"private"` 113 | } 114 | 115 | func (ulm *UserLoginMetadata) GeneratePushKeys() { 116 | privateKey := exerrors.Must(ecdh.P256().GenerateKey(rand.Reader)) 117 | ulm.PushKeys = &PushKeys{ 118 | P256DH: privateKey.Public().(*ecdh.PublicKey).Bytes(), 119 | Auth: random.Bytes(16), 120 | Private: privateKey.Bytes(), 121 | } 122 | } 123 | 124 | type UserSettings struct { 125 | SettingsReceived bool `json:"settings_received"` 126 | RCSEnabled bool `json:"rcs_enabled"` 127 | ReadReceipts bool `json:"read_receipts"` 128 | TypingNotifications bool `json:"typing_notifications"` 129 | IsDefaultSMSApp bool `json:"is_default_sms_app"` 130 | PushNotifications bool `json:"push_notifications"` 131 | } 132 | 133 | type bridgeStateSIMMeta struct { 134 | CarrierName string `json:"carrier_name"` 135 | ColorHex string `json:"color_hex"` 136 | ParticipantID string `json:"participant_id"` 137 | RCSEnabled bool `json:"rcs_enabled"` 138 | PhoneNumber string `json:"phone_number"` 139 | } 140 | 141 | type serializableUserLoginMetadata struct { 142 | Session *libgm.AuthData `json:"session"` 143 | SelfParticipantIDs []string `json:"self_participant_ids"` 144 | SimMetadata map[string]*gmproto.SIMCard `json:"sim_metadata"` 145 | Settings UserSettings `json:"settings"` 146 | IDPrefix string `json:"id_prefix"` 147 | PushKeys *PushKeys `json:"push_keys"` 148 | } 149 | 150 | func (ulm *UserLoginMetadata) CopyFrom(other any) { 151 | otherULM, ok := other.(*UserLoginMetadata) 152 | if !ok || otherULM == nil { 153 | panic(fmt.Errorf("invalid type %T provided to UserLoginMetadata.CopyFrom", other)) 154 | } 155 | ulm.Session = otherULM.Session 156 | } 157 | 158 | func (ulm *UserLoginMetadata) MarshalJSON() ([]byte, error) { 159 | ulm.lock.RLock() 160 | defer ulm.lock.RUnlock() 161 | return json.Marshal(serializableUserLoginMetadata{ 162 | Session: ulm.Session, 163 | SelfParticipantIDs: ulm.selfParticipantIDs, 164 | SimMetadata: ulm.simMetadata, 165 | Settings: ulm.Settings, 166 | IDPrefix: ulm.IDPrefix, 167 | PushKeys: ulm.PushKeys, 168 | }) 169 | } 170 | 171 | func (ulm *UserLoginMetadata) UnmarshalJSON(data []byte) error { 172 | var sulm serializableUserLoginMetadata 173 | err := json.Unmarshal(data, &sulm) 174 | if err != nil { 175 | return err 176 | } 177 | ulm.lock.Lock() 178 | defer ulm.lock.Unlock() 179 | ulm.Session = sulm.Session 180 | ulm.selfParticipantIDs = sulm.SelfParticipantIDs 181 | ulm.simMetadata = sulm.SimMetadata 182 | ulm.Settings = sulm.Settings 183 | ulm.IDPrefix = sulm.IDPrefix 184 | ulm.PushKeys = sulm.PushKeys 185 | return nil 186 | } 187 | 188 | func (ulm *UserLoginMetadata) AddSelfParticipantID(id string) bool { 189 | if id == "" { 190 | return false 191 | } 192 | ulm.lock.Lock() 193 | defer ulm.lock.Unlock() 194 | if !slices.Contains(ulm.selfParticipantIDs, id) { 195 | ulm.selfParticipantIDs = append(ulm.selfParticipantIDs, id) 196 | return true 197 | } 198 | return false 199 | } 200 | 201 | func (ulm *UserLoginMetadata) IsSelfParticipantID(id string) bool { 202 | ulm.lock.RLock() 203 | defer ulm.lock.RUnlock() 204 | return slices.Contains(ulm.selfParticipantIDs, id) 205 | } 206 | 207 | func (ulm *UserLoginMetadata) SIMCount() int { 208 | ulm.lock.RLock() 209 | defer ulm.lock.RUnlock() 210 | return len(ulm.simMetadata) 211 | } 212 | 213 | func (ulm *UserLoginMetadata) GetSIMsForBridgeState() []bridgeStateSIMMeta { 214 | ulm.lock.RLock() 215 | defer ulm.lock.RUnlock() 216 | data := make([]bridgeStateSIMMeta, 0, len(ulm.simMetadata)) 217 | for _, sim := range ulm.simMetadata { 218 | data = append(data, bridgeStateSIMMeta{ 219 | CarrierName: sim.GetSIMData().GetCarrierName(), 220 | ColorHex: sim.GetSIMData().GetColorHex(), 221 | ParticipantID: sim.GetSIMParticipant().GetID(), 222 | RCSEnabled: sim.GetRCSChats().GetEnabled(), 223 | PhoneNumber: sim.GetSIMData().GetFormattedPhoneNumber(), 224 | }) 225 | } 226 | slices.SortFunc(data, func(a, b bridgeStateSIMMeta) int { 227 | return strings.Compare(a.ParticipantID, b.ParticipantID) 228 | }) 229 | return data 230 | } 231 | 232 | func (ulm *UserLoginMetadata) GetSIMs() []*gmproto.SIMCard { 233 | ulm.lock.RLock() 234 | defer ulm.lock.RUnlock() 235 | return maps.Values(ulm.simMetadata) 236 | } 237 | 238 | func (ulm *UserLoginMetadata) GetSIM(participantID string) *gmproto.SIMCard { 239 | ulm.lock.Lock() 240 | defer ulm.lock.Unlock() 241 | return ulm.simMetadata[participantID] 242 | } 243 | 244 | func (ulm *UserLoginMetadata) SetSIMs(sims []*gmproto.SIMCard) bool { 245 | ulm.lock.Lock() 246 | defer ulm.lock.Unlock() 247 | newMap := make(map[string]*gmproto.SIMCard) 248 | participantIDsChanged := false 249 | for _, sim := range sims { 250 | participantID := sim.GetSIMParticipant().GetID() 251 | newMap[sim.GetSIMParticipant().GetID()] = sim 252 | if !slices.Contains(ulm.selfParticipantIDs, participantID) { 253 | ulm.selfParticipantIDs = append(ulm.selfParticipantIDs, participantID) 254 | participantIDsChanged = true 255 | } 256 | } 257 | oldMap := ulm.simMetadata 258 | ulm.simMetadata = newMap 259 | if participantIDsChanged || len(newMap) != len(oldMap) { 260 | return true 261 | } 262 | for participantID, sim := range newMap { 263 | existing, ok := oldMap[participantID] 264 | if !ok || !simsAreEqualish(existing, sim) { 265 | return true 266 | } 267 | } 268 | return false 269 | } 270 | 271 | func (ulm *UserLoginMetadata) PublicPushKeys() *libgm.PushKeys { 272 | if ulm == nil || ulm.PushKeys == nil { 273 | return nil 274 | } 275 | return &libgm.PushKeys{ 276 | URL: ulm.PushKeys.Token, 277 | P256DH: ulm.PushKeys.P256DH, 278 | Auth: ulm.PushKeys.Auth, 279 | } 280 | } 281 | 282 | func simsAreEqualish(a, b *gmproto.SIMCard) bool { 283 | return a.GetRCSChats().GetEnabled() == b.GetRCSChats().GetEnabled() && 284 | a.GetSIMData().GetCarrierName() == b.GetSIMData().GetCarrierName() && 285 | a.GetSIMData().GetSIMPayload().GetSIMNumber() == b.GetSIMData().GetSIMPayload().GetSIMNumber() && 286 | a.GetSIMData().GetSIMPayload().GetTwo() == b.GetSIMData().GetSIMPayload().GetTwo() 287 | } 288 | -------------------------------------------------------------------------------- /pkg/connector/errors.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "fmt" 21 | "strings" 22 | 23 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 24 | ) 25 | 26 | var () 27 | 28 | type responseStatusError gmproto.SendMessageResponse 29 | 30 | func (rse *responseStatusError) Error() string { 31 | switch rse.Status { 32 | case 0: 33 | if rse.GoogleAccountSwitch != nil && strings.ContainsRune(rse.GoogleAccountSwitch.GetAccount(), '@') { 34 | return "Switch back to QR pairing or log in with Google account to send messages" 35 | } 36 | case gmproto.SendMessageResponse_FAILURE_2: 37 | return "Unknown permanent error" 38 | case gmproto.SendMessageResponse_FAILURE_3: 39 | return "Unknown temporary error" 40 | case gmproto.SendMessageResponse_FAILURE_4: 41 | return "Google Messages is not your default SMS app" 42 | } 43 | return fmt.Sprintf("Unrecognized response status %d", rse.Status) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/connector/example-config.yaml: -------------------------------------------------------------------------------- 1 | # Displayname template for SMS users. 2 | # {{.FullName}} - Full name provided by the phone 3 | # {{.FirstName}} - First name provided by the phone 4 | # {{.PhoneNumber}} - Formatted phone number provided by the phone 5 | displayname_template: "{{or .FullName .PhoneNumber}}" 6 | # Settings for how the bridge appears to the phone. 7 | device_meta: 8 | # OS name to tell the phone. This is the name that shows up in the paired devices list. 9 | os: mautrix-gmessages 10 | # Browser type to tell the phone. This decides which icon is shown. 11 | # Valid types: OTHER, CHROME, FIREFOX, SAFARI, OPERA, IE, EDGE 12 | browser: OTHER 13 | # Device type to tell the phone. This also affects the icon, as well as how many sessions are allowed simultaneously. 14 | # One web, two tablets and one PWA should be able to connect at the same time. 15 | # Valid types: WEB, TABLET, PWA 16 | type: TABLET 17 | # Should the bridge aggressively set itself as the active device if the user opens Google Messages in a browser? 18 | # If this is disabled, the user must manually use the `set-active` command to reactivate the bridge. 19 | aggressive_reconnect: false 20 | # Number of chats to sync when connecting to Google Messages. 21 | initial_chat_sync_count: 25 22 | -------------------------------------------------------------------------------- /pkg/connector/gmdb/00-latest-schema.sql: -------------------------------------------------------------------------------- 1 | -- v0 -> v1 (compatible with v1+): Latest schema 2 | CREATE TABLE gmessages_login_prefix( 3 | -- only: postgres 4 | prefix BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, 5 | -- only: sqlite (line commented) 6 | -- prefix INTEGER PRIMARY KEY, 7 | login_id TEXT NOT NULL, 8 | 9 | CONSTRAINT gmessages_login_prefix_login_id_key UNIQUE (login_id) 10 | ); 11 | -------------------------------------------------------------------------------- /pkg/connector/gmdb/database.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package gmdb 18 | 19 | import ( 20 | "context" 21 | "embed" 22 | "strconv" 23 | 24 | "github.com/rs/zerolog" 25 | "go.mau.fi/util/dbutil" 26 | "maunium.net/go/mautrix/bridgev2/networkid" 27 | ) 28 | 29 | type GMDB struct { 30 | *dbutil.Database 31 | } 32 | 33 | var table dbutil.UpgradeTable 34 | 35 | //go:embed *.sql 36 | var upgrades embed.FS 37 | 38 | func init() { 39 | table.RegisterFS(upgrades) 40 | } 41 | 42 | func New(db *dbutil.Database, log zerolog.Logger) *GMDB { 43 | db = db.Child("gmessages_version", table, dbutil.ZeroLogger(log)) 44 | return &GMDB{ 45 | Database: db, 46 | } 47 | } 48 | 49 | func (db *GMDB) GetLoginPrefix(ctx context.Context, id networkid.UserLoginID) (string, error) { 50 | var rowID int64 51 | err := db.QueryRow(ctx, ` 52 | INSERT INTO gmessages_login_prefix (login_id) 53 | VALUES ($1) 54 | ON CONFLICT (login_id) DO UPDATE SET login_id=gmessages_login_prefix.login_id 55 | RETURNING prefix 56 | `, id).Scan(&rowID) 57 | return strconv.FormatInt(rowID, 10), err 58 | } 59 | -------------------------------------------------------------------------------- /pkg/connector/id.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "strings" 23 | 24 | "maunium.net/go/mautrix/bridgev2" 25 | "maunium.net/go/mautrix/bridgev2/networkid" 26 | ) 27 | 28 | func parseAnyID(id string) (prefix, realID string) { 29 | parts := strings.SplitN(id, ".", 2) 30 | if len(parts) == 2 { 31 | prefix = parts[0] 32 | realID = parts[1] 33 | } 34 | return 35 | } 36 | 37 | var ErrPrefixMismatch = bridgev2.WrapErrorInStatus(errors.New("internal error: account mismatch")). 38 | WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) 39 | 40 | func (gc *GMClient) parseAnyID(id string) (string, error) { 41 | prefix, realID := parseAnyID(id) 42 | if prefix != gc.Meta.IDPrefix { 43 | return "", ErrPrefixMismatch 44 | } 45 | return realID, nil 46 | } 47 | 48 | func (gc *GMClient) makeAnyID(realID string) string { 49 | return fmt.Sprintf("%s.%s", gc.Meta.IDPrefix, realID) 50 | } 51 | 52 | func (gc *GMClient) ParseUserID(id networkid.UserID) (participantID string, err error) { 53 | return gc.parseAnyID(string(id)) 54 | } 55 | 56 | func (gc *GMClient) ParseMessageID(id networkid.MessageID) (messageID string, err error) { 57 | return gc.parseAnyID(string(id)) 58 | } 59 | 60 | func (gc *GMClient) ParsePortalID(id networkid.PortalID) (participantID string, err error) { 61 | return gc.parseAnyID(string(id)) 62 | } 63 | 64 | func (gc *GMClient) MakeUserID(participantID string) networkid.UserID { 65 | return networkid.UserID(gc.makeAnyID(participantID)) 66 | } 67 | 68 | func (gc *GMClient) MakeMessageID(messageID string) networkid.MessageID { 69 | return networkid.MessageID(gc.makeAnyID(messageID)) 70 | } 71 | 72 | func (gc *GMClient) MakePortalID(conversationID string) networkid.PortalID { 73 | return networkid.PortalID(gc.makeAnyID(conversationID)) 74 | } 75 | 76 | func (gc *GMClient) MakePortalKey(conversationID string) networkid.PortalKey { 77 | return networkid.PortalKey{ 78 | ID: gc.MakePortalID(conversationID), 79 | Receiver: gc.UserLogin.ID, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/connector/messagestatus.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "errors" 21 | "strings" 22 | 23 | "maunium.net/go/mautrix/bridgev2" 24 | 25 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 26 | ) 27 | 28 | func isSuccessfullySentStatus(status gmproto.MessageStatusType) bool { 29 | switch status { 30 | case gmproto.MessageStatusType_OUTGOING_DELIVERED, gmproto.MessageStatusType_OUTGOING_COMPLETE, gmproto.MessageStatusType_OUTGOING_DISPLAYED: 31 | return true 32 | default: 33 | return false 34 | } 35 | } 36 | 37 | func getFailMessage(status gmproto.MessageStatusType) string { 38 | switch status { 39 | case gmproto.MessageStatusType_OUTGOING_FAILED_TOO_LARGE: 40 | return "too large" 41 | case gmproto.MessageStatusType_OUTGOING_FAILED_RECIPIENT_LOST_RCS: 42 | return "recipient lost RCS support" 43 | case gmproto.MessageStatusType_OUTGOING_FAILED_RECIPIENT_LOST_ENCRYPTION: 44 | return "recipient lost encryption support" 45 | case gmproto.MessageStatusType_OUTGOING_FAILED_RECIPIENT_DID_NOT_DECRYPT, 46 | gmproto.MessageStatusType_OUTGOING_FAILED_RECIPIENT_DID_NOT_DECRYPT_NO_MORE_RETRY: 47 | return "recipient failed to decrypt message" 48 | case gmproto.MessageStatusType_OUTGOING_FAILED_GENERIC: 49 | return "generic carrier error, check google messages and try again" 50 | case gmproto.MessageStatusType_OUTGOING_FAILED_NO_RETRY_NO_FALLBACK: 51 | return "no fallback error" 52 | case gmproto.MessageStatusType_OUTGOING_FAILED_EMERGENCY_NUMBER: 53 | return "emergency number error" 54 | case gmproto.MessageStatusType_OUTGOING_CANCELED: 55 | return "canceled" 56 | default: 57 | return "" 58 | } 59 | } 60 | 61 | func wrapStatusInError(status gmproto.MessageStatusType) error { 62 | errorMessage := getFailMessage(status) 63 | if errorMessage == "" { 64 | return nil 65 | } 66 | errCode := errors.New(strings.TrimPrefix(status.String(), "OUTGOING_")) 67 | return bridgev2.WrapErrorInStatus(errCode). 68 | WithMessage(errorMessage). 69 | WithSendNotice(true) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/connector/push.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2025 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/rs/zerolog" 24 | "maunium.net/go/mautrix/bridgev2" 25 | ) 26 | 27 | var ( 28 | _ bridgev2.PushableNetworkAPI = (*GMClient)(nil) 29 | _ bridgev2.BackgroundSyncingNetworkAPI = (*GMClient)(nil) 30 | ) 31 | 32 | func (gc *GMClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error { 33 | if pushType != bridgev2.PushTypeWeb { 34 | return fmt.Errorf("unsupported push type") 35 | } 36 | if gc.Meta.PushKeys == nil { 37 | gc.Meta.GeneratePushKeys() 38 | } 39 | needsUpdate := gc.Meta.PushKeys.Token != token 40 | gc.Meta.PushKeys.Token = token 41 | err := gc.Client.RegisterPush(gc.Meta.PublicPushKeys()) 42 | if err != nil { 43 | gc.Meta.PushKeys.Token = "" 44 | return err 45 | } 46 | if needsUpdate { 47 | err = gc.UserLogin.Save(ctx) 48 | if err != nil { 49 | return fmt.Errorf("failed to save user login after updating push keys: %w", err) 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | var pushCfg = &bridgev2.PushConfig{ 56 | Web: &bridgev2.WebPushConfig{ 57 | VapidKey: "BKXiqRFb-3xiLFDOB8MjGiShfSfD2mf5TEeOtL9FLiI3gGxxm5LDb4pOmrsv4cY_6n4TD_GQ67uCtblfErqu9d0", 58 | }, 59 | } 60 | 61 | func (gc *GMClient) GetPushConfigs() *bridgev2.PushConfig { 62 | return pushCfg 63 | } 64 | 65 | func (gc *GMClient) ConnectBackground(ctx context.Context, params *bridgev2.ConnectBackgroundParams) error { 66 | if gc.Client == nil { 67 | zerolog.Ctx(ctx).Warn().Msg("No client for ConnectBackground") 68 | return nil 69 | } else if gc.Meta.Session.IsGoogleAccount() && !gc.Meta.Session.HasCookies() { 70 | zerolog.Ctx(ctx).Warn().Msg("No cookies for Google account in ConnectBackground") 71 | return nil 72 | } 73 | err := gc.Client.ConnectBackground() 74 | if err != nil { 75 | zerolog.Ctx(ctx).Err(err).Msg("Error in ConnectBackground") 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/connector/startchat.go: -------------------------------------------------------------------------------- 1 | // mautrix-gmessages - A Matrix-Google Messages puppeting bridge. 2 | // Copyright (C) 2024 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 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "net/http" 24 | 25 | "github.com/rs/zerolog" 26 | "go.mau.fi/util/ptr" 27 | "google.golang.org/protobuf/proto" 28 | "maunium.net/go/mautrix" 29 | 30 | "maunium.net/go/mautrix/bridgev2" 31 | "maunium.net/go/mautrix/bridgev2/networkid" 32 | 33 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 34 | ) 35 | 36 | var ( 37 | _ bridgev2.IdentifierResolvingNetworkAPI = (*GMClient)(nil) 38 | _ bridgev2.GroupCreatingNetworkAPI = (*GMClient)(nil) 39 | _ bridgev2.ContactListingNetworkAPI = (*GMClient)(nil) 40 | _ bridgev2.IdentifierValidatingNetwork = (*GMConnector)(nil) 41 | ) 42 | 43 | func (gc *GMConnector) ValidateUserID(id networkid.UserID) bool { 44 | p1, p2 := parseAnyID(string(id)) 45 | if len(p1) == 0 || len(p2) == 0 { 46 | return false 47 | } 48 | for _, d := range p1 { 49 | if d < '0' || d > '9' { 50 | return false 51 | } 52 | } 53 | for _, d := range p2 { 54 | if d < '0' || d > '9' { 55 | return false 56 | } 57 | } 58 | return true 59 | } 60 | 61 | func (gc *GMClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { 62 | var phone string 63 | netID := networkid.UserID(identifier) 64 | if gc.Main.ValidateUserID(netID) { 65 | ghost, err := gc.Main.br.GetExistingGhostByID(ctx, netID) 66 | if err != nil { 67 | return nil, fmt.Errorf("failed to get ghost by ID: %w", err) 68 | } else if ghost != nil { 69 | prefix, _ := parseAnyID(string(ghost.ID)) 70 | if prefix != gc.Meta.IDPrefix { 71 | return nil, fmt.Errorf("%w: prefix mismatch", bridgev2.ErrResolveIdentifierTryNext) 72 | } 73 | phone = ghost.Metadata.(*GhostMetadata).Phone 74 | if phone == "" { 75 | return nil, fmt.Errorf("phone number of ghost %s not known", netID) 76 | } 77 | if !createChat { 78 | return &bridgev2.ResolveIdentifierResponse{ 79 | Ghost: ghost, 80 | UserID: ghost.ID, 81 | }, nil 82 | } 83 | } 84 | } 85 | if phone == "" { 86 | var err error 87 | phone, err = bridgev2.CleanNonInternationalPhoneNumber(identifier) 88 | if err != nil { 89 | zerolog.Ctx(ctx).Debug().Str("input_identifier", identifier).Msg("Invalid phone number passed to ResolveIdentifier") 90 | return nil, bridgev2.WrapRespErrManual(err, mautrix.MInvalidParam.ErrCode, http.StatusBadRequest) 91 | } 92 | } 93 | if !createChat { 94 | // All phone numbers are probably reachable, just return a fake response 95 | return &bridgev2.ResolveIdentifierResponse{ 96 | UserID: networkid.UserID(phone), 97 | }, nil 98 | } 99 | resp, err := gc.Client.GetOrCreateConversation(&gmproto.GetOrCreateConversationRequest{ 100 | Numbers: []*gmproto.ContactNumber{{ 101 | // This should maybe sometimes be 7 102 | MysteriousInt: 2, 103 | Number: phone, 104 | Number2: phone, 105 | }}, 106 | }) 107 | if err != nil { 108 | return nil, err 109 | } 110 | convCopy := proto.Clone(resp.Conversation).(*gmproto.Conversation) 111 | convCopy.LatestMessage = nil 112 | zerolog.Ctx(ctx).Debug().Any("conversation_data", convCopy).Msg("Got conversation data for DM") 113 | if resp.GetConversation().GetConversationID() == "" { 114 | return nil, fmt.Errorf("no conversation ID in response") 115 | } 116 | portalKey := gc.MakePortalKey(resp.Conversation.ConversationID) 117 | portalInfo := gc.wrapChatInfo(ctx, resp.Conversation) 118 | var otherUserID networkid.UserID 119 | var otherUserInfo *gmproto.Participant 120 | for _, member := range resp.Conversation.Participants { 121 | if member.IsMe || !member.IsVisible { 122 | continue 123 | } 124 | if otherUserID != "" { 125 | zerolog.Ctx(ctx).Warn(). 126 | Str("portal_id", string(portalKey.ID)). 127 | Str("previous_other_user_id", string(otherUserID)). 128 | Str("new_other_user_id", string(gc.MakeUserID(member.GetID().GetParticipantID()))). 129 | Msg("Multiple visible participants in DM") 130 | } 131 | otherUserID = gc.MakeUserID(member.GetID().GetParticipantID()) 132 | otherUserInfo = member 133 | } 134 | var ghost *bridgev2.Ghost 135 | if otherUserID == "" { 136 | zerolog.Ctx(ctx).Warn(). 137 | Str("portal_id", string(portalKey.ID)). 138 | Msg("No visible participants in DM") 139 | } else { 140 | ghost, err = gc.Main.br.GetGhostByID(ctx, otherUserID) 141 | if err != nil { 142 | return nil, fmt.Errorf("failed to get ghost: %w", err) 143 | } 144 | } 145 | return &bridgev2.ResolveIdentifierResponse{ 146 | Ghost: ghost, 147 | UserID: otherUserID, 148 | UserInfo: gc.wrapParticipantInfo(otherUserInfo), 149 | Chat: &bridgev2.CreateChatResponse{ 150 | PortalKey: portalKey, 151 | PortalInfo: portalInfo, 152 | }, 153 | }, nil 154 | } 155 | 156 | var ( 157 | ErrRCSGroupRequiresName = bridgev2.WrapRespErrManual(errors.New("RCS group creation requires a name"), "FI.MAU.GMESSAGES.RCS_REQUIRES_NAME", http.StatusBadRequest) 158 | ErrMinimumTwoUsers = bridgev2.WrapRespErr(errors.New("need at least 2 users to create a group"), mautrix.MInvalidParam) 159 | ) 160 | 161 | func (gc *GMClient) CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*bridgev2.CreateChatResponse, error) { 162 | if len(users) < 2 { 163 | return nil, ErrMinimumTwoUsers 164 | } 165 | namePtr := &name 166 | if name == "" { 167 | namePtr = nil 168 | } 169 | reqData := &gmproto.GetOrCreateConversationRequest{ 170 | Numbers: make([]*gmproto.ContactNumber, len(users)), 171 | RCSGroupName: namePtr, 172 | } 173 | for i, user := range users { 174 | var phone string 175 | _, err := gc.ParseUserID(user) 176 | if err == nil { 177 | ghost, err := gc.Main.br.GetExistingGhostByID(ctx, user) 178 | if err != nil { 179 | return nil, fmt.Errorf("failed to get ghost %s: %w", user, err) 180 | } 181 | phone = ghost.Metadata.(*GhostMetadata).Phone 182 | if phone == "" { 183 | return nil, fmt.Errorf("phone number of ghost %s not known", ghost.ID) 184 | } 185 | } else { 186 | // Hack to allow ResolveIdentifier results (raw phone numbers) here 187 | phone = string(user) 188 | } 189 | reqData.Numbers[i] = &gmproto.ContactNumber{ 190 | MysteriousInt: 2, 191 | Number: phone, 192 | Number2: phone, 193 | } 194 | } 195 | resp, err := gc.Client.GetOrCreateConversation(reqData) 196 | if resp.GetStatus() == gmproto.GetOrCreateConversationResponse_CREATE_RCS { 197 | if name == "" { 198 | reqData.RCSGroupName = ptr.Ptr("") 199 | } 200 | reqData.CreateRCSGroup = ptr.Ptr(true) 201 | resp, err = gc.Client.GetOrCreateConversation(reqData) 202 | } 203 | if err != nil { 204 | return nil, err 205 | } 206 | convCopy := proto.Clone(resp.Conversation).(*gmproto.Conversation) 207 | convCopy.LatestMessage = nil 208 | zerolog.Ctx(ctx).Debug().Any("conversation_data", convCopy).Msg("Got conversation data for new group") 209 | if resp.GetConversation().GetConversationID() == "" { 210 | return nil, fmt.Errorf("no conversation ID in response (status: %s)", resp.GetStatus()) 211 | } 212 | return &bridgev2.CreateChatResponse{ 213 | PortalKey: gc.MakePortalKey(resp.Conversation.ConversationID), 214 | PortalInfo: gc.wrapChatInfo(ctx, resp.Conversation), 215 | }, nil 216 | } 217 | 218 | func (gc *GMClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) { 219 | contacts, err := gc.Client.ListContacts() 220 | if err != nil { 221 | return nil, err 222 | } 223 | resp := make([]*bridgev2.ResolveIdentifierResponse, len(contacts.Contacts)) 224 | for i, contact := range contacts.Contacts { 225 | userID := gc.MakeUserID(contact.GetParticipantID()) 226 | ghost, err := gc.Main.br.GetGhostByID(ctx, userID) 227 | if err != nil { 228 | return nil, fmt.Errorf("failed to get ghost %s: %w", userID, err) 229 | } 230 | resp[i] = &bridgev2.ResolveIdentifierResponse{ 231 | Ghost: ghost, 232 | UserID: userID, 233 | UserInfo: gc.wrapContactInfo(contact), 234 | } 235 | } 236 | return resp, nil 237 | } 238 | -------------------------------------------------------------------------------- /pkg/libgm/crypto/aesctr.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/hmac" 7 | "crypto/rand" 8 | "crypto/sha256" 9 | "errors" 10 | "io" 11 | ) 12 | 13 | type AESCTRHelper struct { 14 | AESKey []byte `json:"aes_key"` 15 | HMACKey []byte `json:"hmac_key"` 16 | } 17 | 18 | func NewAESCTRHelper() *AESCTRHelper { 19 | return &AESCTRHelper{ 20 | AESKey: GenerateKey(32), 21 | HMACKey: GenerateKey(32), 22 | } 23 | } 24 | 25 | func (c *AESCTRHelper) Encrypt(plaintext []byte) ([]byte, error) { 26 | iv := make([]byte, aes.BlockSize) 27 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 28 | return nil, err 29 | } 30 | 31 | block, err := aes.NewCipher(c.AESKey) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | ciphertext := make([]byte, len(plaintext), len(plaintext)+len(iv)+32) 37 | stream := cipher.NewCTR(block, iv) 38 | stream.XORKeyStream(ciphertext, plaintext) 39 | 40 | ciphertext = append(ciphertext, iv...) 41 | 42 | mac := hmac.New(sha256.New, c.HMACKey) 43 | mac.Write(ciphertext) 44 | ciphertext = append(ciphertext, mac.Sum(nil)...) 45 | 46 | return ciphertext, nil 47 | } 48 | 49 | func (c *AESCTRHelper) Decrypt(encryptedData []byte) ([]byte, error) { 50 | if len(encryptedData) < 48 { 51 | return nil, errors.New("input data is too short") 52 | } 53 | 54 | hmacSignature := encryptedData[len(encryptedData)-32:] 55 | encryptedDataWithoutHMAC := encryptedData[:len(encryptedData)-32] 56 | 57 | mac := hmac.New(sha256.New, c.HMACKey) 58 | mac.Write(encryptedDataWithoutHMAC) 59 | expectedHMAC := mac.Sum(nil) 60 | 61 | if !hmac.Equal(hmacSignature, expectedHMAC) { 62 | return nil, errors.New("HMAC mismatch") 63 | } 64 | 65 | iv := encryptedDataWithoutHMAC[len(encryptedDataWithoutHMAC)-16:] 66 | encryptedDataWithoutHMAC = encryptedDataWithoutHMAC[:len(encryptedDataWithoutHMAC)-16] 67 | 68 | block, err := aes.NewCipher(c.AESKey) 69 | if err != nil { 70 | return nil, err 71 | } 72 | stream := cipher.NewCTR(block, iv) 73 | stream.XORKeyStream(encryptedDataWithoutHMAC, encryptedDataWithoutHMAC) 74 | 75 | return encryptedDataWithoutHMAC, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/libgm/crypto/aesgcm.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/binary" 8 | "fmt" 9 | "math" 10 | ) 11 | 12 | type AESGCMHelper struct { 13 | key []byte 14 | gcm cipher.AEAD 15 | } 16 | 17 | func NewAESGCMHelper(key []byte) (*AESGCMHelper, error) { 18 | if len(key) != 32 { 19 | return nil, fmt.Errorf("unsupported AES key length (got=%d expected=32)", len(key)) 20 | } 21 | block, err := aes.NewCipher(key) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | gcm, err := cipher.NewGCM(block) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &AESGCMHelper{key: key, gcm: gcm}, nil 32 | } 33 | 34 | func (c *AESGCMHelper) encryptChunk(data []byte, aad []byte) []byte { 35 | nonce := make([]byte, c.gcm.NonceSize(), c.gcm.NonceSize()+len(data)) 36 | _, err := rand.Read(nonce) 37 | if err != nil { 38 | panic(fmt.Errorf("out of randomness: %w", err)) 39 | } 40 | 41 | // Pass nonce as the dest, so we have it pre-appended to the output 42 | return c.gcm.Seal(nonce, nonce, data, aad) 43 | } 44 | 45 | func (c *AESGCMHelper) decryptChunk(data []byte, aad []byte) ([]byte, error) { 46 | if len(data) < c.gcm.NonceSize() { 47 | return nil, fmt.Errorf("invalid encrypted data length (got=%d)", len(data)) 48 | } 49 | 50 | nonce := data[:c.gcm.NonceSize()] 51 | ciphertext := data[c.gcm.NonceSize():] 52 | 53 | decrypted, err := c.gcm.Open(nil, nonce, ciphertext, aad) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return decrypted, nil 59 | } 60 | 61 | const outgoingRawChunkSize = 1 << 15 62 | 63 | func (c *AESGCMHelper) EncryptData(data []byte) ([]byte, error) { 64 | chunkOverhead := c.gcm.NonceSize() + c.gcm.Overhead() 65 | chunkSize := outgoingRawChunkSize - chunkOverhead 66 | 67 | chunkCount := int(math.Ceil(float64(len(data)) / float64(chunkSize))) 68 | encrypted := make([]byte, 2, 2+len(data)+28*chunkCount) 69 | encrypted[0] = 0 70 | encrypted[1] = byte(math.Log2(float64(outgoingRawChunkSize))) 71 | 72 | var chunkIndex uint32 73 | for i := 0; i < len(data); i += chunkSize { 74 | isLastChunk := false 75 | if i+chunkSize >= len(data) { 76 | chunkSize = len(data) - i 77 | isLastChunk = true 78 | } 79 | 80 | chunk := make([]byte, chunkSize) 81 | copy(chunk, data[i:i+chunkSize]) 82 | 83 | aad := c.calculateAAD(chunkIndex, isLastChunk) 84 | encrypted = append(encrypted, c.encryptChunk(data[i:i+chunkSize], aad)...) 85 | chunkIndex++ 86 | } 87 | 88 | return encrypted, nil 89 | } 90 | 91 | func (c *AESGCMHelper) DecryptData(encryptedData []byte) ([]byte, error) { 92 | if len(encryptedData) == 0 || len(c.key) != 32 { 93 | return encryptedData, nil 94 | } 95 | if encryptedData[0] != 0 { 96 | return nil, fmt.Errorf("invalid first-byte header signature (got=%o , expected=%o)", encryptedData[0], 0) 97 | } 98 | 99 | chunkSize := 1 << encryptedData[1] 100 | encryptedData = encryptedData[2:] 101 | 102 | var chunkIndex uint32 103 | chunkCount := int(math.Ceil(float64(len(encryptedData)) / float64(chunkSize))) 104 | decryptedData := make([]byte, 0, len(encryptedData)-28*chunkCount) 105 | 106 | for i := 0; i < len(encryptedData); i += chunkSize { 107 | isLastChunk := false 108 | if i+chunkSize >= len(encryptedData) { 109 | chunkSize = len(encryptedData) - i 110 | isLastChunk = true 111 | } 112 | 113 | chunk := make([]byte, chunkSize) 114 | copy(chunk, encryptedData[i:i+chunkSize]) 115 | 116 | aad := c.calculateAAD(chunkIndex, isLastChunk) 117 | decryptedChunk, err := c.decryptChunk(chunk, aad) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to decrypt chunk #%d: %w", chunkIndex+1, err) 120 | } 121 | decryptedData = append(decryptedData, decryptedChunk...) 122 | chunkIndex++ 123 | } 124 | 125 | return decryptedData, nil 126 | } 127 | 128 | func (c *AESGCMHelper) calculateAAD(index uint32, isLastChunk bool) []byte { 129 | aad := make([]byte, 5) 130 | binary.BigEndian.PutUint32(aad[1:5], index) 131 | if isLastChunk { 132 | aad[0] = 1 133 | } 134 | return aad 135 | } 136 | -------------------------------------------------------------------------------- /pkg/libgm/crypto/ecdsa.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "fmt" 9 | "math/big" 10 | ) 11 | 12 | type RawURLBytes []byte 13 | 14 | func (rub RawURLBytes) MarshalJSON() ([]byte, error) { 15 | out := make([]byte, 2+base64.RawURLEncoding.EncodedLen(len(rub))) 16 | out[0] = '"' 17 | base64.RawURLEncoding.Encode(out[1:], rub) 18 | out[len(out)-1] = '"' 19 | return out, nil 20 | } 21 | 22 | func (rub *RawURLBytes) UnmarshalJSON(in []byte) error { 23 | if len(in) < 2 || in[0] != '"' || in[len(in)-1] != '"' { 24 | return fmt.Errorf("invalid value for RawURLBytes: not a JSON string") 25 | } 26 | *rub = make([]byte, base64.RawURLEncoding.DecodedLen(len(in)-2)) 27 | _, err := base64.RawURLEncoding.Decode(*rub, in[1:len(in)-1]) 28 | return err 29 | } 30 | 31 | type JWK struct { 32 | KeyType string `json:"kty"` 33 | Curve string `json:"crv"` 34 | D RawURLBytes `json:"d"` 35 | X RawURLBytes `json:"x"` 36 | Y RawURLBytes `json:"y"` 37 | } 38 | 39 | func (t *JWK) GetPrivateKey() *ecdsa.PrivateKey { 40 | return &ecdsa.PrivateKey{ 41 | PublicKey: *t.GetPublicKey(), 42 | D: new(big.Int).SetBytes(t.D), 43 | } 44 | } 45 | 46 | func (t *JWK) GetPublicKey() *ecdsa.PublicKey { 47 | return &ecdsa.PublicKey{ 48 | Curve: elliptic.P256(), 49 | X: new(big.Int).SetBytes(t.X), 50 | Y: new(big.Int).SetBytes(t.Y), 51 | } 52 | } 53 | 54 | // GenerateECDSAKey generates a new ECDSA private key with P-256 curve 55 | func GenerateECDSAKey() *JWK { 56 | privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 57 | if err != nil { 58 | panic(fmt.Errorf("failed to generate ecdsa key: %w", err)) 59 | } 60 | return &JWK{ 61 | KeyType: "EC", 62 | Curve: "P-256", 63 | D: privKey.D.Bytes(), 64 | X: privKey.X.Bytes(), 65 | Y: privKey.Y.Bytes(), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/libgm/crypto/generate.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | func GenerateKey(length int) []byte { 9 | key := make([]byte, length) 10 | _, err := rand.Read(key) 11 | if err != nil { 12 | panic(fmt.Errorf("failed to read random bytes: %w", err)) 13 | } 14 | return key 15 | } 16 | -------------------------------------------------------------------------------- /pkg/libgm/events/qr.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 5 | ) 6 | 7 | type QR struct { 8 | URL string 9 | } 10 | 11 | type PairSuccessful struct { 12 | PhoneID string 13 | QRData *gmproto.PairedData 14 | } 15 | -------------------------------------------------------------------------------- /pkg/libgm/events/ready.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 9 | ) 10 | 11 | type ClientReady struct { 12 | SessionID string 13 | Conversations []*gmproto.Conversation 14 | } 15 | 16 | type AuthTokenRefreshed struct{} 17 | 18 | type GaiaLoggedOut struct{} 19 | 20 | type NoDataReceived struct{} 21 | 22 | type AccountChange struct { 23 | *gmproto.AccountChangeOrSomethingEvent 24 | IsFake bool 25 | } 26 | 27 | var ErrRequestedEntityNotFound = RequestError{ 28 | Data: &gmproto.ErrorResponse{ 29 | Type: 5, 30 | Message: "Requested entity was not found.", 31 | Class: []*gmproto.ErrorResponse_ErrorClass{{ 32 | Class: "type.googleapis.com/google.internal.communications.instantmessaging.v1.TachyonError", 33 | }}, 34 | }, 35 | } 36 | 37 | var ErrInvalidCredentials = RequestError{ 38 | Data: &gmproto.ErrorResponse{ 39 | Type: 16, 40 | Message: "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.", 41 | }, 42 | } 43 | 44 | type RequestError struct { 45 | Data *gmproto.ErrorResponse 46 | HTTP *HTTPError 47 | } 48 | 49 | func (re RequestError) Unwrap() error { 50 | if re.HTTP == nil { 51 | return nil 52 | } 53 | return *re.HTTP 54 | } 55 | 56 | func (re RequestError) Error() string { 57 | if re.HTTP == nil { 58 | return fmt.Sprintf("%d: %s", re.Data.Type, re.Data.Message) 59 | } 60 | return fmt.Sprintf("HTTP %d: %d: %s", re.HTTP.Resp.StatusCode, re.Data.Type, re.Data.Message) 61 | } 62 | 63 | func (re RequestError) Is(other error) bool { 64 | var otherRe RequestError 65 | if !errors.As(other, &otherRe) { 66 | return re.HTTP != nil && errors.Is(*re.HTTP, other) 67 | } 68 | return otherRe.Data.GetType() == re.Data.GetType() && 69 | otherRe.Data.GetMessage() == re.Data.GetMessage() 70 | // TODO check class? 71 | } 72 | 73 | type HTTPError struct { 74 | Action string 75 | Resp *http.Response 76 | Body []byte 77 | } 78 | 79 | func (he HTTPError) Error() string { 80 | if he.Action == "" { 81 | return fmt.Sprintf("unexpected http %d", he.Resp.StatusCode) 82 | } 83 | return fmt.Sprintf("http %d while %s", he.Resp.StatusCode, he.Action) 84 | } 85 | 86 | type ListenFatalError struct { 87 | Error error 88 | } 89 | 90 | type ListenTemporaryError struct { 91 | Error error 92 | } 93 | 94 | type ListenRecovered struct{} 95 | 96 | type PhoneNotResponding struct{} 97 | 98 | type PhoneRespondingAgain struct{} 99 | 100 | type PingFailed struct { 101 | Error error 102 | ErrorCount int 103 | } 104 | 105 | type HackySetActiveMayFail struct{} 106 | -------------------------------------------------------------------------------- /pkg/libgm/events/ready_test.go: -------------------------------------------------------------------------------- 1 | package events_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "go.mau.fi/util/pblite" 11 | 12 | "go.mau.fi/mautrix-gmessages/pkg/libgm/events" 13 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 14 | ) 15 | 16 | func TestRequestError_Is(t *testing.T) { 17 | dat, _ := base64.StdEncoding.DecodeString("WzUsIlJlcXVlc3RlZCBlbnRpdHkgd2FzIG5vdCBmb3VuZC4iLFtbInR5cGUuZ29vZ2xlYXBpcy5jb20vZ29vZ2xlLmludGVybmFsLmNvbW11bmljYXRpb25zLmluc3RhbnRtZXNzYWdpbmcudjEuVGFjaHlvbkVycm9yIixbMV1dXV0=") 18 | var errResp gmproto.ErrorResponse 19 | err := pblite.Unmarshal(dat, &errResp) 20 | require.NoError(t, err) 21 | assert.ErrorIs(t, events.RequestError{Data: &errResp}, events.ErrRequestedEntityNotFound) 22 | assert.ErrorIs(t, events.RequestError{Data: &errResp}, fmt.Errorf("meow: %w", events.ErrRequestedEntityNotFound)) 23 | assert.NotErrorIs(t, events.RequestError{Data: &errResp}, events.RequestError{ 24 | Data: &gmproto.ErrorResponse{ 25 | Type: 5, 26 | Message: "meow.", 27 | Class: []*gmproto.ErrorResponse_ErrorClass{{ 28 | Class: "type.googleapis.com/google.internal.communications.instantmessaging.v1.TachyonError", 29 | }}, 30 | }, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/libgm/events/useralerts.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type BrowserActive struct { 4 | SessionID string 5 | } 6 | 7 | func NewBrowserActive(sessionID string) *BrowserActive { 8 | return &BrowserActive{ 9 | SessionID: sessionID, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/authentication.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package authentication; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | import "util.proto"; 7 | import "vendor/pblite.proto"; 8 | 9 | enum BrowserType { 10 | UNKNOWN_BROWSER_TYPE = 0; 11 | OTHER = 1; 12 | CHROME = 2; 13 | FIREFOX = 3; 14 | SAFARI = 4; 15 | OPERA = 5; 16 | IE = 6; 17 | EDGE = 7; 18 | } 19 | 20 | enum DeviceType { 21 | UNKNOWN_DEVICE_TYPE = 0; 22 | WEB = 1; 23 | TABLET = 2; 24 | PWA = 3; 25 | } 26 | 27 | message BrowserDetails { 28 | string userAgent = 1; 29 | BrowserType browserType = 2; 30 | string OS = 3; 31 | DeviceType deviceType = 6; 32 | } 33 | 34 | message Device { 35 | int64 userID = 1; 36 | string sourceID = 2; 37 | string network = 3; 38 | } 39 | 40 | message ConfigVersion { 41 | int32 Year = 3; 42 | int32 Month = 4; 43 | int32 Day = 5; 44 | int32 V1 = 7; 45 | int32 V2 = 9; 46 | } 47 | 48 | message SignInGaiaRequest { 49 | message Inner { 50 | message DeviceID { 51 | int32 unknownInt1 = 1; // 3 52 | string deviceID = 2; // messages-web-{uuid without dashes} 53 | } 54 | message Data { 55 | bytes someData = 3; // maybe an encryption key? 56 | } 57 | DeviceID deviceID = 1; 58 | Data someData = 36 [(pblite.pblite_binary) = true]; 59 | } 60 | AuthMessage authMessage = 1; 61 | Inner inner = 2; 62 | int32 unknownInt3 = 3; 63 | string network = 4; 64 | } 65 | 66 | message SignInGaiaResponse { 67 | message Header { 68 | uint64 unknownInt2 = 2; 69 | int64 unknownTimestamp = 4; 70 | } 71 | message DeviceData { 72 | message DeviceWrapper { 73 | Device device = 1; 74 | } 75 | DeviceWrapper deviceWrapper = 1; 76 | repeated RPCGaiaData.UnknownContainer.Item2.Item1 unknownItems2 = 2; 77 | repeated RPCGaiaData.UnknownContainer.Item4 unknownItems3 = 3; 78 | // index 4 is some unknown field with no real data 79 | } 80 | Header header = 1; 81 | string maybeBrowserUUID = 2 [(pblite.pblite_binary) = true]; 82 | DeviceData deviceData = 3; 83 | TokenData tokenData = 4; 84 | } 85 | 86 | message GaiaPairingRequestContainer { 87 | string pairingAttemptID = 1; 88 | BrowserDetails browserDetails = 2; 89 | int64 startTimestamp = 3; 90 | bytes data = 4; 91 | int32 proposedVerificationCodeVersion = 5; 92 | int32 proposedKeyDerivationVersion = 6; 93 | } 94 | 95 | message GaiaPairingResponseContainer { 96 | int32 finishErrorType = 1; 97 | int32 finishErrorCode = 2; 98 | int32 unknownInt3 = 3; // For init, 1 99 | string sessionUUID = 4; 100 | bytes data = 5; 101 | int32 confirmedVerificationCodeVersion = 6; 102 | int32 confirmedKeyDerivationVersion = 7; 103 | } 104 | 105 | message RevokeGaiaPairingRequest { 106 | string pairingAttemptID = 1; 107 | } 108 | 109 | message RPCGaiaData { 110 | message UnknownContainer { 111 | message Item2 { 112 | message Item1 { 113 | string destOrSourceUUID = 1 [(pblite.pblite_binary) = true]; 114 | int32 unknownInt4 = 4; // 1 for destination device, 6 for local device? 115 | string languageCode = 5; 116 | uint64 unknownBigInt7 = 7; 117 | } 118 | repeated Item1 item1 = 1; 119 | } 120 | message Item4 { 121 | message Item8 { 122 | int32 unknownInt1 = 1; // present for destination device? 123 | int32 unknownTimestamp = 2; // present for destination device? 124 | bytes unknownBytes = 3; // present for local device? 125 | } 126 | string destOrSourceUUID = 1 [(pblite.pblite_binary) = true]; 127 | int32 unknownInt3 = 3; // 1 for destination device, 6 for local device? 128 | int32 unknownInt4 = 4; // always 6? 129 | int64 unknownTimestampMicroseconds = 7; // maybe device creation ts? 130 | Item8 item8 = 8 [(pblite.pblite_binary) = true]; 131 | } 132 | Item2 item2 = 2; 133 | int64 unknownTimestampMicroseconds = 3; // pairing timestamp? 134 | repeated Item4 item4 = 4; 135 | } 136 | 137 | int32 command = 1; // 9 138 | UnknownContainer maybeServerData = 108; 139 | } 140 | 141 | message AuthenticationContainer { 142 | AuthMessage authMessage = 1; 143 | BrowserDetails browserDetails = 3; 144 | 145 | oneof data { 146 | KeyData keyData = 4; 147 | CurrentDeviceData deviceData = 5; 148 | } 149 | } 150 | 151 | message AuthMessage { 152 | string requestID = 1; 153 | string network = 3; 154 | bytes tachyonAuthToken = 6; 155 | ConfigVersion configVersion = 7; 156 | } 157 | 158 | message RevokeRelayPairingRequest { 159 | AuthMessage authMessage = 1; 160 | Device browser = 2; 161 | } 162 | 163 | message RevokeRelayPairingResponse { 164 | // field 1 is an object with an unknown int64 in field 2 165 | } 166 | 167 | message RegisterRefreshRequest { 168 | message PushRegistration { 169 | string type = 1; 170 | string url = 2; 171 | string p256dh = 3; 172 | string auth = 4; 173 | } 174 | 175 | message MoreParameters { 176 | int32 three = 1; 177 | PushRegistration pushReg = 102; 178 | } 179 | 180 | message Parameters { 181 | optional util.EmptyArr emptyArr = 9; 182 | optional MoreParameters moreParameters = 23; 183 | } 184 | 185 | AuthMessage messageAuth = 1; 186 | Device currBrowserDevice = 2; 187 | int64 unixTimestamp = 3; 188 | bytes signature = 4; 189 | Parameters parameters = 13; 190 | int32 messageType = 16; 191 | } 192 | 193 | message RegisterRefreshResponse { 194 | TokenData tokenData = 2; 195 | } 196 | 197 | message RegisterPhoneRelayResponse { 198 | CoordinateMessage coordinates = 1; 199 | Device browser = 2; 200 | bytes pairingKey = 3; 201 | int64 validFor = 4; 202 | TokenData authKeyData = 5; 203 | string responseID = 6; 204 | } 205 | 206 | message CoordinateMessage { 207 | int64 coord1 = 2; 208 | } 209 | 210 | message RefreshPhoneRelayResponse { 211 | CoordinateMessage coordinates = 1; 212 | bytes pairKey = 2; 213 | int64 validFor = 3; 214 | } 215 | 216 | message WebEncryptionKeyResponse { 217 | CoordinateMessage coordinates = 1; 218 | bytes key = 2; 219 | } 220 | 221 | message ErrorResponse { 222 | int64 type = 1; // 5? 223 | string message = 2; 224 | repeated ErrorClass class = 3; 225 | 226 | message ErrorClass { 227 | string class = 1; 228 | // 2: {1: 1} 229 | } 230 | } 231 | 232 | message ECDSAKeys { 233 | int64 field1 = 1; // idk? 234 | bytes encryptedKeys = 2; 235 | } 236 | 237 | message CurrentDeviceData { 238 | authentication.Device browser = 1; 239 | } 240 | 241 | message KeyData { 242 | Device mobile = 1; 243 | ECDSAKeys ecdsaKeys = 6; 244 | WebAuthKey webAuthKeyData = 2; 245 | Device browser = 3; 246 | } 247 | 248 | message WebAuthKey { 249 | bytes webAuthKey = 1; 250 | int64 validFor = 2; 251 | } 252 | 253 | message URLData { 254 | bytes pairingKey = 1; 255 | bytes AESKey = 2; 256 | bytes HMACKey = 3; 257 | } 258 | 259 | message TokenData { 260 | bytes tachyonAuthToken = 1; 261 | int64 TTL = 2; 262 | } 263 | 264 | message PairedData { 265 | Device mobile = 1; 266 | TokenData tokenData = 2; 267 | Device browser = 3; 268 | } 269 | 270 | message RevokePairData { 271 | Device revokedDevice = 1; 272 | } 273 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/client.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package client; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | import "conversations.proto"; 7 | import "authentication.proto"; 8 | import "settings.proto"; 9 | import "util.proto"; 10 | import "events.proto"; 11 | 12 | message NotifyDittoActivityRequest { 13 | // This is not actually a boolean: after logging out, field 2 has value 2, and field 3 has value 1. 14 | bool success = 2; 15 | } 16 | 17 | message NotifyDittoActivityResponse {} 18 | 19 | message ReceiveMessagesRequest { 20 | authentication.AuthMessage auth = 1; 21 | 22 | message UnknownEmptyObject1 {} 23 | message UnknownEmptyObject2 { 24 | UnknownEmptyObject1 unknown = 2; 25 | } 26 | optional UnknownEmptyObject2 unknown = 4; 27 | } 28 | 29 | message MessageReadRequest { 30 | string conversationID = 2; 31 | string messageID = 3; 32 | } 33 | 34 | message AckMessageRequest { 35 | message Message { 36 | string requestID = 1; 37 | authentication.Device device = 2; 38 | } 39 | 40 | authentication.AuthMessage authData = 1; 41 | util.EmptyArr emptyArr = 2; 42 | repeated Message acks = 4; 43 | } 44 | 45 | message DownloadAttachmentRequest { 46 | AttachmentInfo info = 1; 47 | authentication.AuthMessage authData = 2; 48 | } 49 | 50 | message AttachmentInfo { 51 | string attachmentID = 1; 52 | bool encrypted = 2; 53 | } 54 | 55 | message StartMediaUploadRequest { 56 | int64 attachmentType = 1; 57 | authentication.AuthMessage authData = 2; 58 | authentication.Device mobile = 3; 59 | } 60 | 61 | message UploadMediaResponse { 62 | UploadedMedia media = 1; 63 | string message = 2; 64 | } 65 | 66 | message UploadedMedia { 67 | string mediaID = 1; 68 | int64 mediaNumber = 2; 69 | } 70 | 71 | message GetThumbnailRequest { 72 | repeated string identifiers = 1; 73 | } 74 | 75 | message GetThumbnailResponse { 76 | message Thumbnail { 77 | // ID depends on request, it's always the same as the input. 78 | string identifier = 1; 79 | ThumbnailData data = 2; 80 | } 81 | 82 | repeated Thumbnail thumbnail = 1; 83 | } 84 | 85 | message ThumbnailData { 86 | message MysteriousData { 87 | fixed64 maybeAHash = 13; 88 | } 89 | // 2 -> 13: 16 mysterious bytes 90 | bytes imageBuffer = 3; 91 | int32 someInt = 4; 92 | conversations.Dimensions dimensions = 5; 93 | MysteriousData mysteriousData = 2; 94 | } 95 | 96 | message Cursor { 97 | string lastItemID = 1; 98 | int64 lastItemTimestamp = 2; 99 | } 100 | 101 | message ListMessagesRequest { 102 | string conversationID = 2; 103 | int64 count = 3; 104 | 105 | Cursor cursor = 5; 106 | } 107 | 108 | message ListMessagesResponse { 109 | repeated conversations.Message messages = 2; 110 | bytes someBytes = 3; 111 | int64 totalMessages = 4; 112 | Cursor cursor = 5; 113 | } 114 | 115 | message ListContactsRequest { 116 | int32 i1 = 5; // = 1 117 | int32 i2 = 6; // = 350 118 | int32 i3 = 7; // = 50 119 | } 120 | 121 | message ListTopContactsRequest { 122 | int32 count = 1; 123 | } 124 | 125 | message ListContactsResponse { 126 | repeated conversations.Contact contacts = 2; 127 | } 128 | 129 | message ListTopContactsResponse { 130 | repeated conversations.Contact contacts = 1; 131 | } 132 | 133 | message ListConversationsRequest { 134 | enum Folder { 135 | UNKNOWN = 0; 136 | INBOX = 1; 137 | ARCHIVE = 2; 138 | SPAM_BLOCKED = 5; 139 | } 140 | 141 | int64 count = 2; 142 | Folder folder = 4; 143 | optional Cursor cursor = 5; 144 | } 145 | 146 | message ListConversationsResponse { 147 | repeated conversations.Conversation conversations = 2; 148 | optional bytes cursorBytes = 3; 149 | optional Cursor cursor = 5; 150 | } 151 | 152 | message GetOrCreateConversationRequest { 153 | repeated conversations.ContactNumber numbers = 2; 154 | optional string RCSGroupName = 3; 155 | optional bool createRCSGroup = 4; 156 | } 157 | 158 | message GetOrCreateConversationResponse { 159 | enum Status { 160 | UNKNOWN = 0; 161 | SUCCESS = 1; 162 | CREATE_RCS = 3; 163 | } 164 | conversations.Conversation conversation = 2; 165 | Status status = 3; 166 | } 167 | 168 | message DeleteMessageRequest { 169 | string messageID = 2; 170 | } 171 | 172 | message DeleteMessageResponse { 173 | bool success = 2; 174 | } 175 | 176 | message UpdateConversationRequest { 177 | UpdateConversationData data = 1; 178 | ConversationActionStatus action = 2; 179 | string conversationID = 3; 180 | ConversationAction5 action5 = 5; 181 | } 182 | 183 | message ConversationAction5 { 184 | bool field2 = 2; 185 | } 186 | 187 | message UpdateConversationData { 188 | string conversationID = 1; 189 | oneof data { 190 | conversations.ConversationStatus status = 12; 191 | ConversationMuteStatus mute = 7; 192 | } 193 | } 194 | 195 | enum ConversationActionStatus { 196 | UNKNOWN_ACTION_STATUS = 0; 197 | UNBLOCK = 2; 198 | BLOCK = 7; 199 | BLOCK_AND_REPORT = 8; 200 | } 201 | 202 | enum ConversationMuteStatus { 203 | UNMUTE = 0; 204 | MUTE = 1; 205 | } 206 | 207 | message UpdateConversationResponse { 208 | bool success = 1; 209 | /* 210 | 3 { 211 | 1 { 212 | 1 { 213 | 3: "11" 214 | } 215 | 13: 2 216 | } 217 | 3: 1 218 | } 219 | */ 220 | } 221 | 222 | message GetConversationTypeRequest { 223 | string conversationID = 2; 224 | } 225 | 226 | message GetConversationTypeResponse { 227 | string conversationID = 2; 228 | int32 type = 3; 229 | bool bool1 = 5; 230 | int32 number2 = 6; 231 | } 232 | 233 | message GetConversationRequest { 234 | string conversationID = 1; 235 | } 236 | 237 | message GetConversationResponse { 238 | conversations.Conversation conversation = 1; 239 | } 240 | 241 | message OpenConversationRequest { 242 | string conversationID = 2; 243 | } 244 | 245 | message PrepareOpenConversationRequest { 246 | int64 field2 = 2; // only seen value 1 247 | } 248 | 249 | message IsBugleDefaultResponse { 250 | bool success = 1; 251 | } 252 | 253 | message SendMessageRequest { 254 | string conversationID = 2; 255 | MessagePayload messagePayload = 3; 256 | settings.SIMPayload SIMPayload = 4; 257 | string tmpID = 5; 258 | bool forceRCS = 6; 259 | ReplyPayload reply = 8; 260 | } 261 | 262 | message ReplyPayload { 263 | string messageID = 1; 264 | } 265 | 266 | message MessagePayload { 267 | string tmpID = 1; 268 | MessagePayloadContent messagePayloadContent = 6; 269 | string conversationID = 7; 270 | string participantID = 9; 271 | repeated conversations.MessageInfo messageInfo = 10; 272 | string tmpID2 = 12; 273 | } 274 | 275 | message MessagePayloadContent { 276 | conversations.MessageContent messageContent = 1; 277 | } 278 | 279 | message SendMessageResponse { 280 | enum Status { 281 | UNKNOWN = 0; 282 | SUCCESS = 1; 283 | FAILURE_2 = 2; 284 | FAILURE_3 = 3; 285 | FAILURE_4 = 4; // not default sms app? 286 | } 287 | events.AccountChangeOrSomethingEvent googleAccountSwitch = 2; 288 | Status status = 3; 289 | } 290 | 291 | message SendReactionRequest { 292 | enum Action { 293 | UNSPECIFIED = 0; 294 | ADD = 1; 295 | REMOVE = 2; 296 | SWITCH = 3; 297 | } 298 | 299 | string messageID = 1; 300 | conversations.ReactionData reactionData = 2; 301 | Action action = 3; 302 | settings.SIMPayload SIMPayload = 4; 303 | } 304 | 305 | message SendReactionResponse { 306 | bool success = 1; 307 | } 308 | 309 | message ResendMessageRequest { 310 | string messageID = 2; 311 | } 312 | 313 | message TypingUpdateRequest { 314 | message Data { 315 | string conversationID = 1; 316 | bool typing = 3; 317 | } 318 | 319 | Data data = 2; 320 | } 321 | 322 | // TODO is this the same as the Settings message? 323 | 324 | message SettingsUpdateRequest { 325 | message PushSettings { 326 | bool enabled = 3; 327 | } 328 | 329 | PushSettings pushSettings = 3; 330 | } 331 | 332 | message GetFullSizeImageRequest { 333 | string messageID = 1; 334 | string actionMessageID = 2; 335 | } 336 | 337 | message GetFullSizeImageResponse { 338 | 339 | } 340 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/config-extra.go: -------------------------------------------------------------------------------- 1 | package gmproto 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func (c *Config) ParsedClientVersion() (*ConfigVersion, error) { 8 | version := c.ClientVersion 9 | 10 | v1 := version[0:4] 11 | v2 := version[4:6] 12 | v3 := version[6:8] 13 | 14 | if v2[0] == 48 { 15 | v2 = string(v2[1]) 16 | } 17 | if v3[0] == 48 { 18 | v3 = string(v3[1]) 19 | } 20 | 21 | first, e := strconv.Atoi(v1) 22 | if e != nil { 23 | return nil, e 24 | } 25 | 26 | second, e1 := strconv.Atoi(v2) 27 | if e1 != nil { 28 | return nil, e1 29 | } 30 | 31 | third, e2 := strconv.Atoi(v3) 32 | if e2 != nil { 33 | return nil, e2 34 | } 35 | 36 | return &ConfigVersion{ 37 | Year: int32(first), 38 | Month: int32(second), 39 | Day: int32(third), 40 | V1: 4, 41 | V2: 6, 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/config.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package config; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | message Config { 7 | message Flag { 8 | string key = 1; 9 | int32 value = 2; 10 | optional string unknownField4 = 4; 11 | optional string unknownField5 = 5; 12 | } 13 | message WrappedFlag { 14 | message Value { 15 | oneof value { 16 | int32 intVal = 2; 17 | string strVal = 3; 18 | } 19 | } 20 | string key = 1; 21 | Value value = 2; 22 | } 23 | message MoreFlags { 24 | repeated WrappedFlag wrappedFlags = 1; 25 | } 26 | message DeviceInfo { 27 | string email = 2; 28 | string zero = 3; 29 | string deviceID = 4; 30 | } 31 | string clientVersion = 1; 32 | string serverVersion = 2; 33 | repeated Flag intFlags = 3; 34 | MoreFlags moreFlags = 4; 35 | DeviceInfo deviceInfo = 5; 36 | // item 6 seems like a list of device infos without device ID? 37 | string countryCode = 7; 38 | repeated uint32 unknownInts = 8; 39 | int64 generatedAtMS = 9; 40 | } 41 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/emojitype.go: -------------------------------------------------------------------------------- 1 | package gmproto 2 | 3 | func (et EmojiType) Unicode() string { 4 | switch et { 5 | case EmojiType_LIKE: 6 | return "👍" 7 | case EmojiType_LOVE: 8 | return "😍" 9 | case EmojiType_LAUGH: 10 | return "😂" 11 | case EmojiType_SURPRISED: 12 | return "😮" 13 | case EmojiType_SAD: 14 | return "😥" 15 | case EmojiType_ANGRY: 16 | return "😠" 17 | case EmojiType_DISLIKE: 18 | return "👎" 19 | case EmojiType_QUESTIONING: 20 | return "🤔" 21 | case EmojiType_CRYING_FACE: 22 | return "😢" 23 | case EmojiType_POUTING_FACE: 24 | return "😡" 25 | case EmojiType_RED_HEART: 26 | return "❤️" 27 | default: 28 | return "" 29 | } 30 | } 31 | 32 | func UnicodeToEmojiType(emoji string) EmojiType { 33 | switch emoji { 34 | case "👍": 35 | return EmojiType_LIKE 36 | case "😍": 37 | return EmojiType_LOVE 38 | case "😂": 39 | return EmojiType_LAUGH 40 | case "😮": 41 | return EmojiType_SURPRISED 42 | case "😥": 43 | return EmojiType_SAD 44 | case "😠": 45 | return EmojiType_ANGRY 46 | case "👎": 47 | return EmojiType_DISLIKE 48 | case "🤔": 49 | return EmojiType_QUESTIONING 50 | case "😢": 51 | return EmojiType_CRYING_FACE 52 | case "😡": 53 | return EmojiType_POUTING_FACE 54 | case "❤", "❤️": 55 | return EmojiType_RED_HEART 56 | default: 57 | return EmojiType_CUSTOM 58 | } 59 | } 60 | 61 | func MakeReactionData(emoji string) *ReactionData { 62 | return &ReactionData{ 63 | Unicode: emoji, 64 | Type: UnicodeToEmojiType(emoji), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package events; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | import "conversations.proto"; 7 | import "authentication.proto"; 8 | import "settings.proto"; 9 | 10 | message UpdateEvents { 11 | oneof event { 12 | ConversationEvent conversationEvent = 2; 13 | MessageEvent messageEvent = 3; 14 | TypingEvent typingEvent = 4; 15 | settings.Settings settingsEvent = 5; 16 | UserAlertEvent userAlertEvent = 6; 17 | BrowserPresenceCheckEvent browserPresenceCheckEvent = 7; 18 | //ParticipantsEvent participantsEvent = 8; 19 | //ConversationTypeEvent conversationTypeEvent = 9; 20 | //FavoriteStickersEvent favoriteStickersEvent = 10; 21 | //RecentStickerEvent recentStickerEvent = 11; 22 | //CloudStoreInfoEvent cloudStoreInfoEvent = 12; 23 | //BlobForAttachmentProgressUpdate blobForAttachmentProgressUpdate = 13; 24 | AccountChangeOrSomethingEvent accountChange = 15; 25 | } 26 | } 27 | 28 | message EncryptedData2Container { 29 | AccountChangeOrSomethingEvent accountChange = 2; 30 | } 31 | 32 | message AccountChangeOrSomethingEvent { 33 | string account = 1; 34 | bool enabled = 2; 35 | } 36 | 37 | message ConversationEvent { 38 | repeated conversations.Conversation data = 2; 39 | } 40 | 41 | message TypingEvent { 42 | TypingData data = 2; 43 | } 44 | 45 | message MessageEvent { 46 | repeated conversations.Message data = 2; 47 | } 48 | 49 | message UserAlertEvent { 50 | AlertType alertType = 2; 51 | } 52 | 53 | message BrowserPresenceCheckEvent {} 54 | 55 | message TypingData { 56 | string conversationID = 1; 57 | User user = 2; 58 | TypingTypes type = 3; 59 | } 60 | 61 | message User { 62 | int64 field1 = 1; 63 | string number = 2; 64 | } 65 | 66 | message RPCPairData { 67 | oneof event { 68 | authentication.PairedData paired = 4; 69 | authentication.RevokePairData revoked = 5; 70 | } 71 | } 72 | 73 | enum AlertType { 74 | ALERT_TYPE_UNKNOWN = 0; 75 | BROWSER_INACTIVE = 1; // Emitted whenever browser connection becomes inactive 76 | BROWSER_ACTIVE = 2; // Emitted whenever a new browser session is created 77 | MOBILE_DATA_CONNECTION = 3; // Emitted when the paired device connects to data 78 | MOBILE_WIFI_CONNECTION = 4; // Emitted when the paired device connects to wifi 79 | MOBILE_BATTERY_LOW = 5; // Emitted if the paired device reaches low battery 80 | MOBILE_BATTERY_RESTORED = 6; // Emitted if the paired device has restored battery enough to not be considered low 81 | BROWSER_INACTIVE_FROM_TIMEOUT = 7; // Emitted whenever browser connection becomes inactive due to timeout 82 | BROWSER_INACTIVE_FROM_INACTIVITY = 8; // Emitted whenever browser connection becomes inactive due to inactivity 83 | RCS_CONNECTION = 9; // Emitted whenever RCS connection has been established successfully 84 | OBSERVER_REGISTERED = 10; // Unknown 85 | MOBILE_DATABASE_SYNCING = 11; // Emitted whenever the paired device is attempting to sync db 86 | MOBILE_DATABASE_SYNC_COMPLETE = 12; // Emitted whenever the paired device has completed the db sync 87 | MOBILE_DATABASE_SYNC_STARTED = 13; // Emitted whenever the paired device has begun syncing the db 88 | MOBILE_DATABASE_PARTIAL_SYNC_COMPLETED = 14; // Emitted whenever the paired device has successfully synced a chunk of the db 89 | MOBILE_DATABASE_PARTIAL_SYNC_STARTED = 15; // Emitted whenever the paired device has begun syncing a chunk of the db 90 | CONTACTS_REFRESH_STARTED = 16; // Emitted whenever the paired device has begun refreshing contacts 91 | CONTACTS_REFRESH_COMPLETED = 17; // Emitted whenever the paired device has successfully refreshed contacts 92 | DISCONNECTED_FROM_SATELLITE = 18; 93 | BR_MESSAGE_RESTORING = 19; 94 | BR_MESSAGE_RESTORE_COMPLETED = 20; 95 | BR_MESSAGE_RESTORE_STARTED = 21; 96 | PUSH_THROTTLING = 22; 97 | PUSH_THROTTLE_STARTED = 23; 98 | PUSH_THROTTLE_ENDED = 24; 99 | PUSH_THROTTLE_STARTED_IN_DARK_LAUNCH = 25; 100 | PUSH_THROTTLE_ENDED_IN_DARK_LAUNCH = 26; 101 | PUSH_THROTTLING_IN_DARK_LAUNCH = 27; 102 | } 103 | 104 | enum TypingTypes { 105 | STOPPED_TYPING = 0; 106 | STARTED_TYPING = 1; 107 | } 108 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/rpc.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package rpc; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | import "authentication.proto"; 7 | import "util.proto"; 8 | import "vendor/pblite.proto"; 9 | 10 | message StartAckMessage { 11 | optional int32 count = 1; 12 | } 13 | 14 | message LongPollingPayload { 15 | optional IncomingRPCMessage data = 2; 16 | optional util.EmptyArr heartbeat = 3; 17 | optional StartAckMessage ack = 4; 18 | optional util.EmptyArr startRead = 5; 19 | } 20 | 21 | message IncomingRPCMessage { 22 | string responseID = 1; 23 | BugleRoute bugleRoute = 2; 24 | uint64 startExecute = 3; 25 | 26 | MessageType messageType = 5; 27 | uint64 finishExecute = 6; 28 | uint64 microsecondsTaken = 7; 29 | authentication.Device mobile = 8; 30 | authentication.Device browser = 9; 31 | 32 | // Either a RPCMessageData or a RPCPairData encoded as bytes 33 | bytes messageData = 12; 34 | 35 | string signatureID = 17; 36 | 37 | string timestamp = 21; 38 | 39 | message GDittoSource { 40 | int32 deviceID = 2; 41 | } 42 | 43 | // Completely unsure about this, but it seems to be present for weird intermediate responses 44 | GDittoSource gdittoSource = 23; 45 | } 46 | 47 | message RPCMessageData { 48 | string sessionID = 1; 49 | int64 timestamp = 3; 50 | ActionType action = 4; 51 | bytes unencryptedData = 5; 52 | bool bool1 = 6; 53 | bool bool2 = 7; 54 | bytes encryptedData = 8; 55 | bool bool3 = 9; 56 | bytes encryptedData2 = 11; 57 | } 58 | 59 | message OutgoingRPCMessage { 60 | message Auth { 61 | string requestID = 1; 62 | 63 | bytes tachyonAuthToken = 6; 64 | authentication.ConfigVersion configVersion = 7; 65 | } 66 | 67 | message Data { 68 | string requestID = 1; 69 | BugleRoute bugleRoute = 2; 70 | 71 | // OutgoingRPCData encoded as bytes 72 | bytes messageData = 12; 73 | 74 | message Type { 75 | util.EmptyArr emptyArr = 1; 76 | MessageType messageType = 2; 77 | } 78 | 79 | Type messageTypeData = 23; 80 | } 81 | 82 | authentication.Device mobile = 1; 83 | Data data = 2; 84 | Auth auth = 3; 85 | 86 | int64 TTL = 5; 87 | 88 | repeated string destRegistrationIDs = 9 [(pblite.pblite_binary) = true]; 89 | } 90 | 91 | message OutgoingRPCData { 92 | string requestID = 1; 93 | ActionType action = 2; 94 | bytes unencryptedProtoData = 3; 95 | bytes encryptedProtoData = 5; 96 | string sessionID = 6; 97 | } 98 | 99 | message OutgoingRPCResponse { 100 | message SomeIdentifier { 101 | // 1 -> unknown 102 | string someNumber = 2; 103 | } 104 | 105 | SomeIdentifier someIdentifier = 1; 106 | // This is not present for AckMessage responses, only for SendMessage 107 | optional string timestamp = 2; 108 | } 109 | 110 | enum BugleRoute { 111 | Unknown = 0; 112 | DataEvent = 19; 113 | PairEvent = 14; 114 | GaiaEvent = 7; 115 | } 116 | 117 | enum ActionType { 118 | UNSPECIFIED = 0; 119 | LIST_CONVERSATIONS = 1; 120 | LIST_MESSAGES = 2; 121 | SEND_MESSAGE = 3; 122 | MESSAGE_UPDATES = 4; 123 | LIST_CONTACTS = 6; 124 | CONVERSATION_UPDATES = 7; 125 | GET_OR_CREATE_CONVERSATION = 9; 126 | MESSAGE_READ = 10; 127 | BROWSER_PRESENCE_CHECK = 11; 128 | TYPING_UPDATES = 12; 129 | SETTINGS_UPDATE = 13; 130 | USER_ALERT = 14; 131 | UPDATE_CONVERSATION = 15; 132 | GET_UPDATES = 16; 133 | ACK_BROWSER_PRESENCE = 17; 134 | LIST_STICKER_SETS = 18; 135 | LEAVE_RCS_GROUP = 19; 136 | ADD_PARTICIPANT_TO_RCS_GROUP = 20; 137 | GET_CONVERSATION_TYPE = 21; 138 | NOTIFY_DITTO_ACTIVITY = 22; 139 | DELETE_MESSAGE = 23; 140 | INSTALL_STICKER_SET = 24; 141 | RESEND_MESSAGE = 25; 142 | GET_CONTACT_RCS_GROUP_STATUS = 26; 143 | DOWNLOAD_MESSAGE = 27; 144 | LIST_TOP_CONTACTS = 28; 145 | GET_CONTACTS_THUMBNAIL = 29; 146 | CHANGE_PARTICIPANT_COLOR = 30; 147 | IS_BUGLE_DEFAULT = 31; 148 | STICKER_USER_CONTEXT = 32; 149 | FAVORITE_STICKER_PACKS = 33; 150 | RECENT_STICKERS = 34; 151 | UPDATE_RECENT_STICKERS = 35; 152 | GET_FULL_SIZE_IMAGE = 36; 153 | GET_PARTICIPANTS_THUMBNAIL = 37; 154 | SEND_REACTION = 38; 155 | SEND_REPLY = 39; 156 | GET_BLOB_FOR_ATTACHMENT = 40; 157 | GET_DEVICES_AVAILABLE_FOR_GAIA_PAIRING = 41; 158 | CREATE_GAIA_PAIRING = 42; 159 | GET_CONVERSATION = 43; 160 | CREATE_GAIA_PAIRING_CLIENT_INIT = 44; 161 | CREATE_GAIA_PAIRING_CLIENT_FINISHED = 45; 162 | UNPAIR_GAIA_PAIRING = 46; 163 | CANCEL_GAIA_PAIRING = 47; 164 | PREWARM = 48; 165 | CONVERSATION_GROUP_NAME_SEARCH = 49; 166 | LINK_RCS_IDENTITY = 50; 167 | UNLINK_RCS_IDENTITY = 51; 168 | } 169 | 170 | enum MessageType { 171 | UNKNOWN_MESSAGE_TYPE = 0; 172 | BUGLE_MESSAGE = 2; 173 | GAIA_1 = 3; 174 | BUGLE_ANNOTATION = 16; 175 | GAIA_2 = 20; 176 | } 177 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/settings.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package settings; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | 7 | message Settings { 8 | repeated SIMCard SIMCards = 2; 9 | SomeData opCodeData = 3; 10 | RCSSettings RCSSettings = 4; 11 | string bugleVersion = 5; 12 | bool bool1 = 7; 13 | BooleanFields2 boolFields2 = 8; 14 | bytes mysteriousBytes = 9; 15 | BooleanFields3 boolFields3 = 10; 16 | } 17 | 18 | message SIMCard { 19 | optional RCSChats RCSChats = 3; 20 | SIMData SIMData = 5; 21 | bool bool1 = 6; 22 | SIMParticipant SIMParticipant = 7; 23 | } 24 | 25 | message RCSChats { 26 | bool enabled = 1; 27 | } 28 | 29 | message BoolMsg { 30 | bool bool1 = 1; 31 | } 32 | 33 | message SIMPayload { 34 | int32 two = 1; 35 | int32 SIMNumber = 2; 36 | } 37 | 38 | message SIMData { 39 | SIMPayload SIMPayload = 1; 40 | bool bool1 = 2; // maybe isDefault? 41 | string carrierName = 3; 42 | string colorHex = 4; 43 | int64 int1 = 5; 44 | string formattedPhoneNumber = 6; 45 | } 46 | 47 | message UnknownMessage { 48 | int64 int1 = 1; 49 | int64 int2 = 2; 50 | } 51 | 52 | message SIMParticipant { 53 | string ID = 1; 54 | } 55 | 56 | message SomeData { 57 | bool pushEnabled = 3; 58 | bool field7 = 7; 59 | bool field12 = 12; 60 | repeated bytes someEmojis = 15; 61 | string jsonData = 16; 62 | string someString = 17; 63 | } 64 | 65 | message RCSSettings { 66 | bool isEnabled = 1; 67 | bool sendReadReceipts = 2; 68 | bool showTypingIndicators = 3; 69 | bool isDefaultSMSApp = 4; // uncertain, but this field seems to disappear when gmessages is un-defaulted 70 | } 71 | 72 | message BooleanFields2 { 73 | bool bool1 = 1; 74 | bool bool2 = 2; 75 | BoolMsg boolMsg1 = 3; 76 | BoolMsg boolMsg2 = 5; 77 | bool bool3 = 6; 78 | } 79 | 80 | message BooleanFields3 { 81 | bool bool1 = 1; 82 | bool bool3 = 3; 83 | bool bool4 = 4; 84 | bool bool5 = 5; 85 | bool bool6 = 6; 86 | bool bool7 = 7; 87 | bool bool8 = 8; 88 | bool bool9 = 9; 89 | } 90 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/ukey.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package ukey; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | message Ukey2Message { 7 | enum Type { 8 | UNKNOWN_DO_NOT_USE = 0; 9 | ALERT = 1; 10 | CLIENT_INIT = 2; 11 | SERVER_INIT = 3; 12 | CLIENT_FINISH = 4; 13 | } 14 | 15 | Type message_type = 1; // Identifies message type 16 | bytes message_data = 2; // Actual message, to be parsed according to message_type 17 | } 18 | 19 | message Ukey2Alert { 20 | enum AlertType { 21 | UNKNOWN_ALERT_TYPE = 0; 22 | // Framing errors 23 | BAD_MESSAGE = 1; // The message could not be deserialized 24 | BAD_MESSAGE_TYPE = 2; // message_type has an undefined value 25 | INCORRECT_MESSAGE = 3; // message_type received does not correspond to expected type at this stage of the protocol 26 | BAD_MESSAGE_DATA = 4; // Could not deserialize message_data as per value in message_type 27 | 28 | // ClientInit and ServerInit errors 29 | BAD_VERSION = 100; // version is invalid; server cannot find suitable version to speak with client. 30 | BAD_RANDOM = 101; // Random data is missing or of incorrect length 31 | BAD_HANDSHAKE_CIPHER = 102; // No suitable handshake ciphers were found 32 | BAD_NEXT_PROTOCOL = 103; // The next protocol is missing, unknown, or unsupported 33 | BAD_PUBLIC_KEY = 104; // The public key could not be parsed 34 | 35 | // Other errors 36 | INTERNAL_ERROR = 200; // An internal error has occurred. error_message may contain additional details for logging and debugging. 37 | } 38 | 39 | AlertType type = 1; 40 | string error_message = 2; 41 | } 42 | 43 | enum Ukey2HandshakeCipher { 44 | RESERVED = 0; 45 | P256_SHA512 = 100; // NIST P-256 used for ECDH, SHA512 used for commitment 46 | CURVE25519_SHA512 = 200; // Curve 25519 used for ECDH, SHA512 used for commitment 47 | } 48 | 49 | message Ukey2ClientInit { 50 | int32 version = 1; // highest supported version for rollback protection 51 | bytes random = 2; // random bytes for replay/reuse protection 52 | 53 | // One commitment (hash of ClientFinished containing public key) per supported cipher 54 | message CipherCommitment { 55 | Ukey2HandshakeCipher handshake_cipher = 1; 56 | bytes commitment = 2; 57 | } 58 | repeated CipherCommitment cipher_commitments = 3; 59 | 60 | // Next protocol that the client wants to speak. 61 | string next_protocol = 4; 62 | } 63 | 64 | message Ukey2ServerInit { 65 | int32 version = 1; // highest supported version for rollback protection 66 | bytes random = 2; // random bytes for replay/reuse protection 67 | 68 | // Selected Cipher and corresponding public key 69 | Ukey2HandshakeCipher handshake_cipher = 3; 70 | GenericPublicKey public_key = 4; 71 | } 72 | 73 | message Ukey2ClientFinished { 74 | GenericPublicKey public_key = 1; // public key matching selected handshake cipher 75 | } 76 | 77 | // A list of supported public key types 78 | enum PublicKeyType { 79 | UNKNOWN_PUBLIC_KEY_TYPE = 0; 80 | EC_P256 = 1; 81 | RSA2048 = 2; 82 | // 2048-bit MODP group 14, from RFC 3526 83 | DH2048_MODP = 3; 84 | } 85 | 86 | // A convenience proto for encoding NIST P-256 elliptic curve public keys 87 | message EcP256PublicKey { 88 | // x and y are encoded in big-endian two's complement (slightly wasteful) 89 | // Client MUST verify (x,y) is a valid point on NIST P256 90 | bytes x = 1; 91 | bytes y = 2; 92 | } 93 | 94 | // A convenience proto for encoding RSA public keys with small exponents 95 | message SimpleRsaPublicKey { 96 | // Encoded in big-endian two's complement 97 | bytes n = 1; 98 | int32 e = 2; 99 | } 100 | 101 | // A convenience proto for encoding Diffie-Hellman public keys, 102 | // for use only when Elliptic Curve based key exchanges are not possible. 103 | // (Note that the group parameters must be specified separately) 104 | message DhPublicKey { 105 | // Big-endian two's complement encoded group element 106 | bytes y = 1; 107 | } 108 | 109 | message GenericPublicKey { 110 | PublicKeyType type = 1; 111 | oneof public_key { 112 | EcP256PublicKey ec_p256_public_key = 2; 113 | SimpleRsaPublicKey rsa2048_public_key = 3; 114 | // Use only as a last resort 115 | DhPublicKey dh2048_public_key = 4; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/util.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc v3.21.12 5 | // source: util.proto 6 | 7 | package gmproto 8 | 9 | import ( 10 | reflect "reflect" 11 | sync "sync" 12 | unsafe "unsafe" 13 | 14 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 15 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 16 | ) 17 | 18 | const ( 19 | // Verify that this generated code is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 21 | // Verify that runtime/protoimpl is sufficiently up-to-date. 22 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 23 | ) 24 | 25 | type EmptyArr struct { 26 | state protoimpl.MessageState `protogen:"open.v1"` 27 | unknownFields protoimpl.UnknownFields 28 | sizeCache protoimpl.SizeCache 29 | } 30 | 31 | func (x *EmptyArr) Reset() { 32 | *x = EmptyArr{} 33 | mi := &file_util_proto_msgTypes[0] 34 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 35 | ms.StoreMessageInfo(mi) 36 | } 37 | 38 | func (x *EmptyArr) String() string { 39 | return protoimpl.X.MessageStringOf(x) 40 | } 41 | 42 | func (*EmptyArr) ProtoMessage() {} 43 | 44 | func (x *EmptyArr) ProtoReflect() protoreflect.Message { 45 | mi := &file_util_proto_msgTypes[0] 46 | if x != nil { 47 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 48 | if ms.LoadMessageInfo() == nil { 49 | ms.StoreMessageInfo(mi) 50 | } 51 | return ms 52 | } 53 | return mi.MessageOf(x) 54 | } 55 | 56 | // Deprecated: Use EmptyArr.ProtoReflect.Descriptor instead. 57 | func (*EmptyArr) Descriptor() ([]byte, []int) { 58 | return file_util_proto_rawDescGZIP(), []int{0} 59 | } 60 | 61 | var File_util_proto protoreflect.FileDescriptor 62 | 63 | const file_util_proto_rawDesc = "" + 64 | "\n" + 65 | "\n" + 66 | "util.proto\x12\x04util\"\n" + 67 | "\n" + 68 | "\bEmptyArrB\fZ\n" + 69 | "../gmprotob\x06proto3" 70 | 71 | var ( 72 | file_util_proto_rawDescOnce sync.Once 73 | file_util_proto_rawDescData []byte 74 | ) 75 | 76 | func file_util_proto_rawDescGZIP() []byte { 77 | file_util_proto_rawDescOnce.Do(func() { 78 | file_util_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_util_proto_rawDesc), len(file_util_proto_rawDesc))) 79 | }) 80 | return file_util_proto_rawDescData 81 | } 82 | 83 | var file_util_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 84 | var file_util_proto_goTypes = []any{ 85 | (*EmptyArr)(nil), // 0: util.EmptyArr 86 | } 87 | var file_util_proto_depIdxs = []int32{ 88 | 0, // [0:0] is the sub-list for method output_type 89 | 0, // [0:0] is the sub-list for method input_type 90 | 0, // [0:0] is the sub-list for extension type_name 91 | 0, // [0:0] is the sub-list for extension extendee 92 | 0, // [0:0] is the sub-list for field type_name 93 | } 94 | 95 | func init() { file_util_proto_init() } 96 | func file_util_proto_init() { 97 | if File_util_proto != nil { 98 | return 99 | } 100 | type x struct{} 101 | out := protoimpl.TypeBuilder{ 102 | File: protoimpl.DescBuilder{ 103 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 104 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_util_proto_rawDesc), len(file_util_proto_rawDesc)), 105 | NumEnums: 0, 106 | NumMessages: 1, 107 | NumExtensions: 0, 108 | NumServices: 0, 109 | }, 110 | GoTypes: file_util_proto_goTypes, 111 | DependencyIndexes: file_util_proto_depIdxs, 112 | MessageInfos: file_util_proto_msgTypes, 113 | }.Build() 114 | File_util_proto = out.File 115 | file_util_proto_goTypes = nil 116 | file_util_proto_depIdxs = nil 117 | } 118 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/util.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package util; 3 | 4 | option go_package = "../gmproto"; 5 | 6 | message EmptyArr { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /pkg/libgm/gmproto/vendor/pblite.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package pblite; 3 | 4 | option go_package = "go.mau.fi/util/pblite"; 5 | 6 | import "google/protobuf/descriptor.proto"; 7 | 8 | extend google.protobuf.FieldOptions { 9 | optional bool pblite_binary = 50000; 10 | } 11 | -------------------------------------------------------------------------------- /pkg/libgm/gmtest/.gitignore: -------------------------------------------------------------------------------- 1 | session.gob 2 | -------------------------------------------------------------------------------- /pkg/libgm/gmtest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/rs/zerolog" 16 | 17 | "go.mau.fi/mautrix-gmessages/pkg/libgm" 18 | "go.mau.fi/mautrix-gmessages/pkg/libgm/events" 19 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 20 | ) 21 | 22 | func must(err error) { 23 | if err != nil { 24 | panic(err) 25 | } 26 | } 27 | 28 | func mustReturn[T any](val T, err error) T { 29 | must(err) 30 | return val 31 | } 32 | 33 | var cli *libgm.Client 34 | var log zerolog.Logger 35 | var sess libgm.AuthData 36 | 37 | func main() { 38 | log = zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { 39 | w.Out = os.Stdout 40 | w.TimeFormat = time.Stamp 41 | })).With().Timestamp().Logger() 42 | file, err := os.Open("session.json") 43 | var doLogin bool 44 | if err != nil { 45 | if !errors.Is(err, os.ErrNotExist) { 46 | panic(err) 47 | } 48 | sess = *libgm.NewAuthData() 49 | doLogin = true 50 | cookies := mustReturn(os.Open("cookies.json")) 51 | must(json.NewDecoder(cookies).Decode(&sess.Cookies)) 52 | } else { 53 | must(json.NewDecoder(file).Decode(&sess)) 54 | log.Info().Msg("Loaded session?") 55 | } 56 | _ = file.Close() 57 | cli = libgm.NewClient(&sess, nil, log) 58 | cli.SetEventHandler(evtHandler) 59 | if doLogin { 60 | err = cli.DoGaiaPairing(context.TODO(), func(emoji string) { 61 | fmt.Println(emoji) 62 | }) 63 | if err != nil { 64 | log.Fatal().Err(err).Msg("Failed to pair") 65 | } 66 | } else { 67 | must(cli.Connect()) 68 | } 69 | 70 | c := make(chan os.Signal, 1) 71 | input := make(chan string) 72 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 73 | go func() { 74 | defer close(input) 75 | scan := bufio.NewScanner(os.Stdin) 76 | for scan.Scan() { 77 | line := strings.TrimSpace(scan.Text()) 78 | if len(line) > 0 { 79 | input <- line 80 | } 81 | } 82 | }() 83 | defer saveSession() 84 | for { 85 | select { 86 | case <-c: 87 | log.Info().Msg("Interrupt received, exiting") 88 | return 89 | case cmd := <-input: 90 | if len(cmd) == 0 { 91 | log.Info().Msg("Stdin closed, exiting") 92 | return 93 | } 94 | args := strings.Fields(cmd) 95 | cmd = args[0] 96 | args = args[1:] 97 | switch cmd { 98 | //case "getavatar": 99 | // _, err := cli.GetFullSizeImage(args) 100 | // fmt.Println(err) 101 | case "listcontacts": 102 | cli.ListContacts() 103 | case "topcontacts": 104 | cli.ListTopContacts() 105 | case "getconversation": 106 | cli.GetConversation(args[0]) 107 | } 108 | //go handleCmd(strings.ToLower(cmd), args) 109 | } 110 | } 111 | } 112 | 113 | func saveSession() { 114 | file := mustReturn(os.OpenFile("session.json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)) 115 | must(json.NewEncoder(file).Encode(&sess)) 116 | _ = file.Close() 117 | } 118 | 119 | func evtHandler(rawEvt any) { 120 | switch evt := rawEvt.(type) { 121 | case *events.ClientReady: 122 | log.Debug().Any("data", evt).Msg("Client is ready!") 123 | case *events.PairSuccessful: 124 | log.Debug().Any("data", evt).Msg("Pair successful") 125 | saveSession() 126 | log.Debug().Msg("Wrote session") 127 | case *gmproto.Message: 128 | log.Debug().Any("data", evt).Msg("Message event") 129 | case *gmproto.Conversation: 130 | log.Debug().Any("data", evt).Msg("Conversation event") 131 | case *events.BrowserActive: 132 | log.Debug().Any("data", evt).Msg("Browser active") 133 | default: 134 | log.Debug().Any("data", evt).Msg("Unknown event") 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /pkg/libgm/http.go: -------------------------------------------------------------------------------- 1 | package libgm 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha1" 7 | "encoding/base64" 8 | "fmt" 9 | "io" 10 | "mime" 11 | "net/http" 12 | "time" 13 | 14 | "github.com/rs/zerolog" 15 | "go.mau.fi/util/pblite" 16 | "google.golang.org/protobuf/proto" 17 | 18 | "go.mau.fi/mautrix-gmessages/pkg/libgm/events" 19 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 20 | "go.mau.fi/mautrix-gmessages/pkg/libgm/util" 21 | ) 22 | 23 | const ContentTypeProtobuf = "application/x-protobuf" 24 | const ContentTypePBLite = "application/json+protobuf" 25 | 26 | func (c *Client) makeProtobufHTTPRequest(url string, data proto.Message, contentType string) (*http.Response, error) { 27 | ctx := c.Logger.WithContext(context.TODO()) 28 | return c.makeProtobufHTTPRequestContext(ctx, url, data, contentType, false) 29 | } 30 | 31 | func (c *Client) makeProtobufHTTPRequestContext(ctx context.Context, url string, data proto.Message, contentType string, longPoll bool) (*http.Response, error) { 32 | var body []byte 33 | var err error 34 | switch contentType { 35 | case ContentTypeProtobuf: 36 | body, err = proto.Marshal(data) 37 | case ContentTypePBLite: 38 | body, err = pblite.Marshal(data) 39 | default: 40 | return nil, fmt.Errorf("unknown request content type %s", contentType) 41 | } 42 | if err != nil { 43 | return nil, err 44 | } 45 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) 46 | if err != nil { 47 | return nil, err 48 | } 49 | util.BuildRelayHeaders(req, contentType, "*/*") 50 | c.AuthData.AddCookiesToRequest(req) 51 | client := c.http 52 | if longPoll { 53 | client = c.lphttp 54 | } 55 | res, reqErr := client.Do(req) 56 | if reqErr != nil { 57 | return res, reqErr 58 | } 59 | c.AuthData.UpdateCookiesFromResponse(res) 60 | return res, nil 61 | } 62 | 63 | func SAPISIDHash(origin, sapisid string) string { 64 | ts := time.Now().Unix() 65 | hash := sha1.Sum([]byte(fmt.Sprintf("%d %s %s", ts, sapisid, origin))) 66 | return fmt.Sprintf("SAPISIDHASH %d_%x", ts, hash[:]) 67 | } 68 | 69 | func decodeProtoResp(body []byte, contentType string, into proto.Message) error { 70 | contentType, _, err := mime.ParseMediaType(contentType) 71 | if err != nil { 72 | return fmt.Errorf("failed to parse content-type: %w", err) 73 | } 74 | switch contentType { 75 | case ContentTypeProtobuf: 76 | return proto.Unmarshal(body, into) 77 | case ContentTypePBLite, "text/plain": 78 | return pblite.Unmarshal(body, into) 79 | default: 80 | return fmt.Errorf("unknown content type %s in response", contentType) 81 | } 82 | } 83 | 84 | func typedHTTPResponse[T proto.Message](resp *http.Response, err error) (parsed T, retErr error) { 85 | if err != nil { 86 | retErr = err 87 | return 88 | } 89 | defer resp.Body.Close() 90 | body, err := io.ReadAll(resp.Body) 91 | if err != nil { 92 | retErr = fmt.Errorf("failed to read response body: %w", err) 93 | return 94 | } 95 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 96 | logEvt := zerolog.Ctx(resp.Request.Context()).Debug(). 97 | Int("status_code", resp.StatusCode). 98 | Str("url", resp.Request.URL.String()). 99 | Str("response_body", base64.StdEncoding.EncodeToString(body)) 100 | httpErr := events.HTTPError{Resp: resp, Body: body} 101 | retErr = httpErr 102 | var errorResp gmproto.ErrorResponse 103 | errErr := decodeProtoResp(body, resp.Header.Get("Content-Type"), &errorResp) 104 | if errErr == nil && errorResp.Message != "" { 105 | logEvt = logEvt.Any("response_proto_err", &errorResp) 106 | retErr = events.RequestError{ 107 | HTTP: &httpErr, 108 | Data: &errorResp, 109 | } 110 | } else { 111 | logEvt = logEvt.AnErr("proto_parse_err", errErr) 112 | } 113 | logEvt.Msg("HTTP request to Google Messages failed") 114 | return 115 | } 116 | parsed = parsed.ProtoReflect().New().Interface().(T) 117 | retErr = decodeProtoResp(body, resp.Header.Get("Content-Type"), parsed) 118 | successEvt := zerolog.Ctx(resp.Request.Context()).Trace() 119 | if successEvt.Enabled() { 120 | successEvt. 121 | Int("status_code", resp.StatusCode). 122 | Str("url", resp.Request.URL.String()). 123 | Str("response_body", base64.StdEncoding.EncodeToString(body)). 124 | Bool("parsed_has_unknown_fields", len(parsed.ProtoReflect().GetUnknown()) > 0). 125 | Type("parsed_data_type", parsed). 126 | Any("parsed_data", parsed). 127 | Msg("HTTP request to Google Messages succeeded") 128 | } 129 | return 130 | } 131 | -------------------------------------------------------------------------------- /pkg/libgm/manualdecrypt/README.md: -------------------------------------------------------------------------------- 1 | # manualdecrypt 2 | This tool can be used to inspect requests that the messages.google.com/web app sends 3 | 4 | 0. Install [Go](https://go.dev/dl/) 1.20 or higher and `protoc` 5 | (`sudo apt install protobuf-compiler` on Debian). 6 | 1. Clone this repository (`git clone https://github.com/mautrix/gmessages.git`). 7 | 2. Enter this directory (`cd libgm/manualdecrypt`) and compile it (`go build`). 8 | 3. Run `./manualdecrypt` 9 | * On first run, it'll ask for `pr_crypto_msg_enc_key` and `pr_crypto_msg_hmac` 10 | from the localStorage of the messages for web app. 11 | 4. Find the relevant `SendMessage` HTTP request in browser devtools and copy 12 | the entire raw request body (a bunch of nested JSON arrays). 13 | 5. Paste the request body into manualdecrypt, then press Ctrl+D on a blank line 14 | to tell it to parse the pasted data. 15 | -------------------------------------------------------------------------------- /pkg/libgm/manualdecrypt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "os/exec" 13 | 14 | "go.mau.fi/util/pblite" 15 | "google.golang.org/protobuf/proto" 16 | 17 | "go.mau.fi/mautrix-gmessages/pkg/libgm/crypto" 18 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 19 | ) 20 | 21 | func must[T any](t T, err error) T { 22 | if err != nil { 23 | panic(err) 24 | } 25 | return t 26 | } 27 | 28 | func mustNoReturn(err error) { 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | 34 | var requestType = map[gmproto.ActionType]proto.Message{ 35 | gmproto.ActionType_LIST_CONVERSATIONS: &gmproto.ListConversationsRequest{}, 36 | gmproto.ActionType_NOTIFY_DITTO_ACTIVITY: &gmproto.NotifyDittoActivityRequest{}, 37 | gmproto.ActionType_GET_CONVERSATION_TYPE: &gmproto.GetConversationTypeRequest{}, 38 | gmproto.ActionType_GET_CONVERSATION: &gmproto.GetConversationRequest{}, 39 | gmproto.ActionType_LIST_MESSAGES: &gmproto.ListMessagesRequest{}, 40 | gmproto.ActionType_SEND_MESSAGE: &gmproto.SendMessageRequest{}, 41 | gmproto.ActionType_SEND_REACTION: &gmproto.SendReactionRequest{}, 42 | gmproto.ActionType_DELETE_MESSAGE: &gmproto.DeleteMessageRequest{}, 43 | gmproto.ActionType_GET_PARTICIPANTS_THUMBNAIL: &gmproto.GetThumbnailRequest{}, 44 | gmproto.ActionType_GET_CONTACTS_THUMBNAIL: &gmproto.GetThumbnailRequest{}, 45 | gmproto.ActionType_LIST_CONTACTS: &gmproto.ListContactsRequest{}, 46 | gmproto.ActionType_LIST_TOP_CONTACTS: &gmproto.ListTopContactsRequest{}, 47 | gmproto.ActionType_GET_OR_CREATE_CONVERSATION: &gmproto.GetOrCreateConversationRequest{}, 48 | gmproto.ActionType_UPDATE_CONVERSATION: &gmproto.UpdateConversationRequest{}, 49 | gmproto.ActionType_RESEND_MESSAGE: &gmproto.ResendMessageRequest{}, 50 | gmproto.ActionType_TYPING_UPDATES: &gmproto.TypingUpdateRequest{}, 51 | gmproto.ActionType_GET_FULL_SIZE_IMAGE: &gmproto.GetFullSizeImageRequest{}, 52 | gmproto.ActionType_SETTINGS_UPDATE: &gmproto.SettingsUpdateRequest{}, 53 | 54 | gmproto.ActionType_CREATE_GAIA_PAIRING_CLIENT_INIT: &gmproto.GaiaPairingRequestContainer{}, 55 | gmproto.ActionType_CREATE_GAIA_PAIRING_CLIENT_FINISHED: &gmproto.GaiaPairingRequestContainer{}, 56 | } 57 | 58 | func main() { 59 | var x crypto.AESCTRHelper 60 | file, err := os.Open("config.json") 61 | if errors.Is(err, os.ErrNotExist) { 62 | _ = file.Close() 63 | _, _ = fmt.Fprintln(os.Stderr, "config.json doesn't exist") 64 | _, _ = fmt.Fprintln(os.Stderr, "Please find pr_crypto_msg_enc_key and pr_crypto_msg_hmac from localStorage") 65 | _, _ = fmt.Fprintln(os.Stderr, "(make sure not to confuse it with pr_crypto_hmac)") 66 | stdin := bufio.NewScanner(os.Stdin) 67 | _, _ = fmt.Fprint(os.Stderr, "AES key (pr_crypto_msg_enc_key): ") 68 | stdin.Scan() 69 | x.AESKey = must(base64.StdEncoding.DecodeString(stdin.Text())) 70 | if len(x.AESKey) != 32 { 71 | _, _ = fmt.Fprintln(os.Stderr, "AES key must be 32 bytes") 72 | return 73 | } 74 | _, _ = fmt.Fprint(os.Stderr, "HMAC key (pr_crypto_msg_hmac): ") 75 | stdin.Scan() 76 | x.HMACKey = must(base64.StdEncoding.DecodeString(stdin.Text())) 77 | if len(x.HMACKey) != 32 { 78 | _, _ = fmt.Fprintln(os.Stderr, "HMAC key must be 32 bytes") 79 | return 80 | } 81 | file, err = os.OpenFile("config.json", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) 82 | if err != nil { 83 | _, _ = fmt.Fprintln(os.Stderr, "Failed to open config.json for writing") 84 | return 85 | } 86 | mustNoReturn(json.NewEncoder(file).Encode(&x)) 87 | _, _ = fmt.Fprintln(os.Stderr, "Saved keys to config.json") 88 | } else { 89 | mustNoReturn(json.NewDecoder(file).Decode(&x)) 90 | } 91 | _ = file.Close() 92 | _, _ = fmt.Fprintln(os.Stderr, "Please paste the request body, then press Ctrl+D to close stdin") 93 | d := must(io.ReadAll(os.Stdin)) 94 | var decoded []byte 95 | var typ gmproto.MessageType 96 | if json.Valid(d) { 97 | var orm gmproto.OutgoingRPCMessage 98 | mustNoReturn(pblite.Unmarshal(d, &orm)) 99 | decoded = orm.Data.MessageData 100 | typ = orm.Data.MessageTypeData.MessageType 101 | fmt.Println("DEST REGISTRATION IDS:", orm.DestRegistrationIDs) 102 | fmt.Println("MOBILE:", orm.GetMobile()) 103 | } else { 104 | decoded = must(base64.StdEncoding.DecodeString(string(d))) 105 | } 106 | outgoing := true 107 | if outgoing { 108 | var ord gmproto.OutgoingRPCData 109 | mustNoReturn(proto.Unmarshal(decoded, &ord)) 110 | _, _ = fmt.Fprintln(os.Stderr) 111 | _, _ = fmt.Fprintln(os.Stderr, "CHANNEL:", typ.String()) 112 | _, _ = fmt.Fprintln(os.Stderr, "REQUEST TYPE:", ord.Action.String()) 113 | _, _ = fmt.Fprintln(os.Stderr, "REQUEST ID:", ord.RequestID) 114 | var decrypted []byte 115 | 116 | if ord.EncryptedProtoData != nil { 117 | decrypted = must(x.Decrypt(ord.EncryptedProtoData)) 118 | } else if ord.UnencryptedProtoData != nil { 119 | decrypted = ord.UnencryptedProtoData 120 | } else { 121 | _, _ = fmt.Fprintln(os.Stderr, "No encrypted data") 122 | return 123 | } 124 | _, _ = fmt.Fprintln(os.Stderr, "------------------------------ RAW DECRYPTED DATA ------------------------------") 125 | fmt.Println(base64.StdEncoding.EncodeToString(decrypted)) 126 | _, _ = fmt.Fprintln(os.Stderr, "--------------------------------- DECODED DATA ---------------------------------") 127 | respType, ok := requestType[ord.Action] 128 | var cmd *exec.Cmd 129 | if ok { 130 | cmd = exec.Command("protoc", "--proto_path=../gmproto", "--decode", string(respType.ProtoReflect().Type().Descriptor().FullName()), "client.proto") 131 | } else { 132 | cmd = exec.Command("protoc", "--decode_raw") 133 | } 134 | cmd.Stdin = bytes.NewReader(decrypted) 135 | cmd.Stdout = os.Stderr 136 | cmd.Stderr = os.Stderr 137 | mustNoReturn(cmd.Run()) 138 | if ok { 139 | respData := respType.ProtoReflect().New().Interface() 140 | mustNoReturn(proto.Unmarshal(decrypted, respData)) 141 | _, _ = fmt.Fprintln(os.Stderr, "------------------------------ PARSED STRUCT DATA ------------------------------") 142 | _, _ = fmt.Fprintf(os.Stderr, "%+v\n", respData) 143 | } 144 | } else { 145 | var ird gmproto.RPCMessageData 146 | mustNoReturn(proto.Unmarshal(decoded, &ird)) 147 | decrypted := must(x.Decrypt(ird.EncryptedData)) 148 | _, _ = fmt.Fprintln(os.Stderr) 149 | _, _ = fmt.Fprintln(os.Stderr, "CHANNEL:", typ.String()) 150 | _, _ = fmt.Fprintln(os.Stderr, "REQUEST TYPE:", ird.Action.String()) 151 | _, _ = fmt.Fprintln(os.Stderr, "REQUEST ID:", ird.SessionID) 152 | _, _ = fmt.Fprintln(os.Stderr, "------------------------------ RAW DECRYPTED DATA ------------------------------") 153 | fmt.Println(base64.StdEncoding.EncodeToString(decrypted)) 154 | } 155 | _, _ = fmt.Fprintln(os.Stderr, "--------------------------------------------------------------------------------") 156 | } 157 | -------------------------------------------------------------------------------- /pkg/libgm/methods.go: -------------------------------------------------------------------------------- 1 | package libgm 2 | 3 | import ( 4 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 5 | ) 6 | 7 | func (c *Client) ListConversations(count int, folder gmproto.ListConversationsRequest_Folder) (*gmproto.ListConversationsResponse, error) { 8 | msgType := gmproto.MessageType_BUGLE_MESSAGE 9 | if !c.conversationsFetchedOnce { 10 | msgType = gmproto.MessageType_BUGLE_ANNOTATION 11 | c.conversationsFetchedOnce = true 12 | } 13 | return typedResponse[*gmproto.ListConversationsResponse](c.sessionHandler.sendMessageWithParams(SendMessageParams{ 14 | Action: gmproto.ActionType_LIST_CONVERSATIONS, 15 | Data: &gmproto.ListConversationsRequest{Count: int64(count), Folder: folder}, 16 | MessageType: msgType, 17 | })) 18 | } 19 | 20 | func (c *Client) ListContacts() (*gmproto.ListContactsResponse, error) { 21 | payload := &gmproto.ListContactsRequest{ 22 | I1: 1, 23 | I2: 350, 24 | I3: 50, 25 | } 26 | actionType := gmproto.ActionType_LIST_CONTACTS 27 | return typedResponse[*gmproto.ListContactsResponse](c.sessionHandler.sendMessage(actionType, payload)) 28 | } 29 | 30 | func (c *Client) ListTopContacts() (*gmproto.ListTopContactsResponse, error) { 31 | payload := &gmproto.ListTopContactsRequest{ 32 | Count: 8, 33 | } 34 | actionType := gmproto.ActionType_LIST_TOP_CONTACTS 35 | return typedResponse[*gmproto.ListTopContactsResponse](c.sessionHandler.sendMessage(actionType, payload)) 36 | } 37 | 38 | func (c *Client) GetOrCreateConversation(req *gmproto.GetOrCreateConversationRequest) (*gmproto.GetOrCreateConversationResponse, error) { 39 | actionType := gmproto.ActionType_GET_OR_CREATE_CONVERSATION 40 | return typedResponse[*gmproto.GetOrCreateConversationResponse](c.sessionHandler.sendMessage(actionType, req)) 41 | } 42 | 43 | func (c *Client) GetConversationType(conversationID string) (*gmproto.GetConversationTypeResponse, error) { 44 | payload := &gmproto.GetConversationTypeRequest{ConversationID: conversationID} 45 | actionType := gmproto.ActionType_GET_CONVERSATION_TYPE 46 | return typedResponse[*gmproto.GetConversationTypeResponse](c.sessionHandler.sendMessage(actionType, payload)) 47 | } 48 | 49 | func (c *Client) GetConversation(conversationID string) (*gmproto.Conversation, error) { 50 | payload := &gmproto.GetConversationRequest{ConversationID: conversationID} 51 | actionType := gmproto.ActionType_GET_CONVERSATION 52 | resp, err := typedResponse[*gmproto.GetConversationResponse](c.sessionHandler.sendMessage(actionType, payload)) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return resp.GetConversation(), nil 57 | } 58 | 59 | func (c *Client) FetchMessages(conversationID string, count int64, cursor *gmproto.Cursor) (*gmproto.ListMessagesResponse, error) { 60 | payload := &gmproto.ListMessagesRequest{ConversationID: conversationID, Count: count, Cursor: cursor} 61 | actionType := gmproto.ActionType_LIST_MESSAGES 62 | return typedResponse[*gmproto.ListMessagesResponse](c.sessionHandler.sendMessage(actionType, payload)) 63 | } 64 | 65 | func (c *Client) SendMessage(payload *gmproto.SendMessageRequest) (*gmproto.SendMessageResponse, error) { 66 | actionType := gmproto.ActionType_SEND_MESSAGE 67 | return typedResponse[*gmproto.SendMessageResponse](c.sessionHandler.sendMessage(actionType, payload)) 68 | } 69 | 70 | func (c *Client) GetParticipantThumbnail(participantIDs ...string) (*gmproto.GetThumbnailResponse, error) { 71 | payload := &gmproto.GetThumbnailRequest{Identifiers: participantIDs} 72 | actionType := gmproto.ActionType_GET_PARTICIPANTS_THUMBNAIL 73 | return typedResponse[*gmproto.GetThumbnailResponse](c.sessionHandler.sendMessage(actionType, payload)) 74 | } 75 | 76 | func (c *Client) GetContactThumbnail(contactIDs ...string) (*gmproto.GetThumbnailResponse, error) { 77 | payload := &gmproto.GetThumbnailRequest{Identifiers: contactIDs} 78 | actionType := gmproto.ActionType_GET_CONTACTS_THUMBNAIL 79 | return typedResponse[*gmproto.GetThumbnailResponse](c.sessionHandler.sendMessage(actionType, payload)) 80 | } 81 | 82 | func (c *Client) UpdateConversation(payload *gmproto.UpdateConversationRequest) (*gmproto.UpdateConversationResponse, error) { 83 | actionType := gmproto.ActionType_UPDATE_CONVERSATION 84 | return typedResponse[*gmproto.UpdateConversationResponse](c.sessionHandler.sendMessage(actionType, payload)) 85 | } 86 | 87 | func (c *Client) SendReaction(payload *gmproto.SendReactionRequest) (*gmproto.SendReactionResponse, error) { 88 | actionType := gmproto.ActionType_SEND_REACTION 89 | return typedResponse[*gmproto.SendReactionResponse](c.sessionHandler.sendMessage(actionType, payload)) 90 | } 91 | 92 | func (c *Client) DeleteMessage(messageID string) (*gmproto.DeleteMessageResponse, error) { 93 | payload := &gmproto.DeleteMessageRequest{MessageID: messageID} 94 | actionType := gmproto.ActionType_DELETE_MESSAGE 95 | 96 | return typedResponse[*gmproto.DeleteMessageResponse](c.sessionHandler.sendMessage(actionType, payload)) 97 | } 98 | 99 | func (c *Client) MarkRead(conversationID, messageID string) error { 100 | payload := &gmproto.MessageReadRequest{ConversationID: conversationID, MessageID: messageID} 101 | actionType := gmproto.ActionType_MESSAGE_READ 102 | 103 | _, err := c.sessionHandler.sendMessage(actionType, payload) 104 | return err 105 | } 106 | 107 | func (c *Client) SetTyping(convID string) error { 108 | payload := &gmproto.TypingUpdateRequest{ 109 | Data: &gmproto.TypingUpdateRequest_Data{ConversationID: convID, Typing: true}, 110 | } 111 | actionType := gmproto.ActionType_TYPING_UPDATES 112 | 113 | _, err := c.sessionHandler.sendMessage(actionType, payload) 114 | return err 115 | } 116 | 117 | func (c *Client) UpdateSettings(payload *gmproto.SettingsUpdateRequest) error { 118 | return c.sessionHandler.sendMessageNoResponse(SendMessageParams{ 119 | Action: gmproto.ActionType_SETTINGS_UPDATE, 120 | Data: payload, 121 | }) 122 | } 123 | 124 | func (c *Client) SetActiveSession() error { 125 | c.sessionHandler.ResetSessionID() 126 | return c.sessionHandler.sendMessageNoResponse(SendMessageParams{ 127 | Action: gmproto.ActionType_GET_UPDATES, 128 | OmitTTL: true, 129 | RequestID: c.sessionHandler.sessionID, 130 | }) 131 | } 132 | 133 | func (c *Client) IsBugleDefault() (*gmproto.IsBugleDefaultResponse, error) { 134 | actionType := gmproto.ActionType_IS_BUGLE_DEFAULT 135 | return typedResponse[*gmproto.IsBugleDefaultResponse](c.sessionHandler.sendMessage(actionType, nil)) 136 | } 137 | 138 | func (c *Client) NotifyDittoActivity() (<-chan *IncomingRPCMessage, error) { 139 | return c.sessionHandler.sendAsyncMessage(SendMessageParams{ 140 | Action: gmproto.ActionType_NOTIFY_DITTO_ACTIVITY, 141 | Data: &gmproto.NotifyDittoActivityRequest{Success: true}, 142 | }) 143 | } 144 | 145 | func (c *Client) GetFullSizeImage(messageID, actionMessageID string) (*gmproto.GetFullSizeImageResponse, error) { 146 | payload := &gmproto.GetFullSizeImageRequest{MessageID: messageID, ActionMessageID: actionMessageID} 147 | actionType := gmproto.ActionType_GET_FULL_SIZE_IMAGE 148 | 149 | return typedResponse[*gmproto.GetFullSizeImageResponse](c.sessionHandler.sendMessage(actionType, payload)) 150 | } 151 | -------------------------------------------------------------------------------- /pkg/libgm/pair.go: -------------------------------------------------------------------------------- 1 | package libgm 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/base64" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "google.golang.org/protobuf/proto" 11 | 12 | "go.mau.fi/mautrix-gmessages/pkg/libgm/events" 13 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 14 | "go.mau.fi/mautrix-gmessages/pkg/libgm/util" 15 | ) 16 | 17 | func (c *Client) StartLogin() (string, error) { 18 | registered, err := c.RegisterPhoneRelay() 19 | if err != nil { 20 | return "", err 21 | } 22 | c.updateTachyonAuthToken(registered.GetAuthKeyData()) 23 | go c.doLongPoll(false, false, nil) 24 | qr, err := c.GenerateQRCodeData(registered.GetPairingKey()) 25 | if err != nil { 26 | return "", fmt.Errorf("failed to generate QR code: %w", err) 27 | } 28 | return qr, nil 29 | } 30 | 31 | func (c *Client) GenerateQRCodeData(pairingKey []byte) (string, error) { 32 | urlData := &gmproto.URLData{ 33 | PairingKey: pairingKey, 34 | AESKey: c.AuthData.RequestCrypto.AESKey, 35 | HMACKey: c.AuthData.RequestCrypto.HMACKey, 36 | } 37 | encodedURLData, err := proto.Marshal(urlData) 38 | if err != nil { 39 | return "", err 40 | } 41 | cData := base64.StdEncoding.EncodeToString(encodedURLData) 42 | return util.QRCodeURLBase + cData, nil 43 | } 44 | 45 | func (c *Client) handlePairingEvent(msg *IncomingRPCMessage) { 46 | switch evt := msg.Pair.Event.(type) { 47 | case *gmproto.RPCPairData_Paired: 48 | c.completePairing(evt.Paired) 49 | case *gmproto.RPCPairData_Revoked: 50 | c.triggerEvent(evt.Revoked) 51 | default: 52 | c.Logger.Debug().Any("evt", evt).Msg("Unknown pair event type") 53 | } 54 | } 55 | 56 | func (c *Client) completePairing(data *gmproto.PairedData) { 57 | c.updateTachyonAuthToken(data.GetTokenData()) 58 | c.AuthData.Mobile = data.Mobile 59 | c.AuthData.Browser = data.Browser 60 | 61 | if cb := c.PairCallback.Load(); cb != nil { 62 | (*cb)(data) 63 | } else { 64 | c.triggerEvent(&events.PairSuccessful{PhoneID: data.GetMobile().GetSourceID(), QRData: data}) 65 | 66 | go func() { 67 | // Sleep for a bit to let the phone save the pair data. If we reconnect too quickly, 68 | // the phone won't recognize the session the bridge will get unpaired. 69 | time.Sleep(2 * time.Second) 70 | 71 | err := c.Reconnect() 72 | if err != nil { 73 | c.triggerEvent(&events.ListenFatalError{Error: fmt.Errorf("failed to reconnect after pair success: %w", err)}) 74 | } 75 | }() 76 | } 77 | } 78 | 79 | func (c *Client) RegisterPhoneRelay() (*gmproto.RegisterPhoneRelayResponse, error) { 80 | key, err := x509.MarshalPKIXPublicKey(c.AuthData.RefreshKey.GetPublicKey()) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | payload := &gmproto.AuthenticationContainer{ 86 | AuthMessage: &gmproto.AuthMessage{ 87 | RequestID: uuid.NewString(), 88 | Network: util.QRNetwork, 89 | ConfigVersion: util.ConfigMessage, 90 | }, 91 | BrowserDetails: util.BrowserDetailsMessage, 92 | Data: &gmproto.AuthenticationContainer_KeyData{ 93 | KeyData: &gmproto.KeyData{ 94 | EcdsaKeys: &gmproto.ECDSAKeys{ 95 | Field1: 2, 96 | EncryptedKeys: key, 97 | }, 98 | }, 99 | }, 100 | } 101 | return typedHTTPResponse[*gmproto.RegisterPhoneRelayResponse]( 102 | c.makeProtobufHTTPRequest(util.RegisterPhoneRelayURL, payload, ContentTypeProtobuf), 103 | ) 104 | } 105 | 106 | func (c *Client) RefreshPhoneRelay() (string, error) { 107 | payload := &gmproto.AuthenticationContainer{ 108 | AuthMessage: &gmproto.AuthMessage{ 109 | RequestID: uuid.NewString(), 110 | Network: util.QRNetwork, 111 | TachyonAuthToken: c.AuthData.TachyonAuthToken, 112 | ConfigVersion: util.ConfigMessage, 113 | }, 114 | } 115 | res, err := typedHTTPResponse[*gmproto.RefreshPhoneRelayResponse]( 116 | c.makeProtobufHTTPRequest(util.RefreshPhoneRelayURL, payload, ContentTypeProtobuf), 117 | ) 118 | if err != nil { 119 | return "", err 120 | } 121 | qr, err := c.GenerateQRCodeData(res.GetPairKey()) 122 | if err != nil { 123 | return "", err 124 | } 125 | return qr, nil 126 | } 127 | 128 | func (c *Client) GetWebEncryptionKey() (*gmproto.WebEncryptionKeyResponse, error) { 129 | payload := &gmproto.AuthenticationContainer{ 130 | AuthMessage: &gmproto.AuthMessage{ 131 | RequestID: uuid.NewString(), 132 | TachyonAuthToken: c.AuthData.TachyonAuthToken, 133 | ConfigVersion: util.ConfigMessage, 134 | }, 135 | } 136 | return typedHTTPResponse[*gmproto.WebEncryptionKeyResponse]( 137 | c.makeProtobufHTTPRequest(util.GetWebEncryptionKeyURL, payload, ContentTypeProtobuf), 138 | ) 139 | } 140 | 141 | func (c *Client) UnpairBugle() (*gmproto.RevokeRelayPairingResponse, error) { 142 | if c.AuthData.TachyonAuthToken == nil || c.AuthData.Browser == nil { 143 | return nil, nil 144 | } 145 | payload := &gmproto.RevokeRelayPairingRequest{ 146 | AuthMessage: &gmproto.AuthMessage{ 147 | RequestID: uuid.NewString(), 148 | TachyonAuthToken: c.AuthData.TachyonAuthToken, 149 | ConfigVersion: util.ConfigMessage, 150 | }, 151 | Browser: c.AuthData.Browser, 152 | } 153 | return typedHTTPResponse[*gmproto.RevokeRelayPairingResponse]( 154 | c.makeProtobufHTTPRequest(util.RevokeRelayPairingURL, payload, ContentTypeProtobuf), 155 | ) 156 | } 157 | 158 | func (c *Client) Unpair() (err error) { 159 | if c.AuthData.HasCookies() { 160 | err = c.UnpairGaia() 161 | } else { 162 | _, err = c.UnpairBugle() 163 | } 164 | return 165 | } 166 | -------------------------------------------------------------------------------- /pkg/libgm/pblitedecode/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "go.mau.fi/util/pblite" 9 | "google.golang.org/protobuf/encoding/prototext" 10 | "google.golang.org/protobuf/reflect/protoreflect" 11 | "google.golang.org/protobuf/types/dynamicpb" 12 | 13 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 14 | ) 15 | 16 | func must[T any](t T, err error) T { 17 | if err != nil { 18 | panic(err) 19 | } 20 | return t 21 | } 22 | 23 | func main() { 24 | files := []protoreflect.FileDescriptor{ 25 | gmproto.File_authentication_proto, 26 | gmproto.File_config_proto, 27 | gmproto.File_client_proto, 28 | gmproto.File_conversations_proto, 29 | gmproto.File_events_proto, 30 | gmproto.File_rpc_proto, 31 | gmproto.File_settings_proto, 32 | gmproto.File_util_proto, 33 | gmproto.File_ukey_proto, 34 | } 35 | var msgDesc protoreflect.MessageDescriptor 36 | for _, file := range files { 37 | msgDesc = file.Messages().ByName(protoreflect.Name(os.Args[1])) 38 | if msgDesc != nil { 39 | break 40 | } 41 | } 42 | if msgDesc == nil { 43 | fmt.Println("Message not found") 44 | os.Exit(1) 45 | } 46 | msg := dynamicpb.NewMessage(msgDesc) 47 | 48 | err := pblite.Unmarshal(must(io.ReadAll(os.Stdin)), msg) 49 | if err != nil { 50 | fmt.Println(err) 51 | os.Exit(2) 52 | } 53 | fmt.Println(prototext.Format(msg)) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/libgm/session_handler.go: -------------------------------------------------------------------------------- 1 | package libgm 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/rs/zerolog" 11 | "golang.org/x/exp/slices" 12 | "google.golang.org/protobuf/proto" 13 | 14 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 15 | "go.mau.fi/mautrix-gmessages/pkg/libgm/util" 16 | ) 17 | 18 | type SessionHandler struct { 19 | client *Client 20 | 21 | responseWaiters map[string]chan<- *IncomingRPCMessage 22 | responseWaitersLock sync.Mutex 23 | 24 | ackMapLock sync.Mutex 25 | ackMap []string 26 | ackTicker *time.Ticker 27 | 28 | sessionID string 29 | } 30 | 31 | func (s *SessionHandler) ResetSessionID() { 32 | s.sessionID = uuid.NewString() 33 | } 34 | 35 | func (s *SessionHandler) sendMessageNoResponse(params SendMessageParams) error { 36 | requestID, payload, err := s.buildMessage(params) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | url := util.SendMessageURL 42 | if s.client.AuthData.HasCookies() { 43 | url = util.SendMessageURLGoogle 44 | } 45 | s.client.Logger.Debug(). 46 | Stringer("message_action", params.Action). 47 | Str("message_id", requestID). 48 | Msg("Sending request to phone (not expecting response)") 49 | _, err = typedHTTPResponse[*gmproto.OutgoingRPCResponse]( 50 | s.client.makeProtobufHTTPRequest(url, payload, ContentTypePBLite), 51 | ) 52 | return err 53 | } 54 | 55 | func (s *SessionHandler) sendAsyncMessage(params SendMessageParams) (<-chan *IncomingRPCMessage, error) { 56 | requestID, payload, err := s.buildMessage(params) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | ch := s.waitResponse(requestID) 62 | url := util.SendMessageURL 63 | if s.client.AuthData.HasCookies() { 64 | url = util.SendMessageURLGoogle 65 | } 66 | s.client.Logger.Debug(). 67 | Stringer("message_action", params.Action). 68 | Str("message_id", requestID). 69 | Msg("Sending request to phone") 70 | _, err = typedHTTPResponse[*gmproto.OutgoingRPCResponse]( 71 | s.client.makeProtobufHTTPRequest(url, payload, ContentTypePBLite), 72 | ) 73 | if err != nil { 74 | s.cancelResponse(requestID, ch) 75 | return nil, err 76 | } 77 | return ch, nil 78 | } 79 | 80 | func typedResponse[T proto.Message](resp *IncomingRPCMessage, err error) (casted T, retErr error) { 81 | if err != nil { 82 | retErr = err 83 | return 84 | } 85 | var ok bool 86 | casted, ok = resp.DecryptedMessage.(T) 87 | if !ok { 88 | retErr = fmt.Errorf("unexpected response type %T for %s, expected %T", resp.DecryptedMessage, resp.ResponseID, casted) 89 | } 90 | return 91 | } 92 | 93 | func (s *SessionHandler) waitResponse(requestID string) chan *IncomingRPCMessage { 94 | ch := make(chan *IncomingRPCMessage, 1) 95 | s.responseWaitersLock.Lock() 96 | s.responseWaiters[requestID] = ch 97 | s.responseWaitersLock.Unlock() 98 | return ch 99 | } 100 | 101 | func (s *SessionHandler) cancelResponse(requestID string, ch chan *IncomingRPCMessage) { 102 | s.responseWaitersLock.Lock() 103 | close(ch) 104 | delete(s.responseWaiters, requestID) 105 | s.responseWaitersLock.Unlock() 106 | } 107 | 108 | func (s *SessionHandler) receiveResponse(msg *IncomingRPCMessage) bool { 109 | if msg.Message == nil { 110 | return false 111 | } 112 | if s.client.AuthData.HasCookies() { 113 | switch msg.Message.Action { 114 | case gmproto.ActionType_CREATE_GAIA_PAIRING_CLIENT_INIT, gmproto.ActionType_CREATE_GAIA_PAIRING_CLIENT_FINISHED: 115 | default: 116 | // Very hacky way to ignore weird messages that come before real responses 117 | // TODO figure out how to properly handle these 118 | if msg.Message.UnencryptedData != nil && msg.Message.EncryptedData == nil { 119 | return false 120 | } 121 | } 122 | } 123 | requestID := msg.Message.SessionID 124 | s.responseWaitersLock.Lock() 125 | ch, ok := s.responseWaiters[requestID] 126 | if !ok { 127 | s.responseWaitersLock.Unlock() 128 | return false 129 | } 130 | delete(s.responseWaiters, requestID) 131 | s.responseWaitersLock.Unlock() 132 | evt := s.client.Logger.Debug(). 133 | Str("request_message_id", requestID). 134 | Str("response_message_id", msg.ResponseID) 135 | if msg.Message != nil { 136 | evt.Stringer("message_action", msg.Message.Action) 137 | } 138 | if s.client.Logger.GetLevel() == zerolog.TraceLevel { 139 | if msg.DecryptedData != nil { 140 | evt.Str("data", base64.StdEncoding.EncodeToString(msg.DecryptedData)) 141 | } 142 | if msg.DecryptedMessage != nil { 143 | evt.Str("proto_name", string(msg.DecryptedMessage.ProtoReflect().Descriptor().FullName())) 144 | } 145 | } 146 | evt.Msg("Received response") 147 | ch <- msg 148 | return true 149 | } 150 | 151 | func (s *SessionHandler) sendMessageWithParams(params SendMessageParams) (*IncomingRPCMessage, error) { 152 | ch, err := s.sendAsyncMessage(params) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | select { 158 | case resp := <-ch: 159 | return resp, nil 160 | case <-time.After(5 * time.Second): 161 | // Notify the pinger in order to trigger an event that the phone isn't responding 162 | select { 163 | case s.client.pingShortCircuit <- struct{}{}: 164 | default: 165 | } 166 | } 167 | // TODO hard timeout? 168 | return <-ch, nil 169 | } 170 | 171 | func (s *SessionHandler) sendMessage(actionType gmproto.ActionType, encryptedData proto.Message) (*IncomingRPCMessage, error) { 172 | return s.sendMessageWithParams(SendMessageParams{ 173 | Action: actionType, 174 | Data: encryptedData, 175 | }) 176 | } 177 | 178 | type SendMessageParams struct { 179 | Action gmproto.ActionType 180 | Data proto.Message 181 | 182 | RequestID string 183 | OmitTTL bool 184 | CustomTTL int64 185 | DontEncrypt bool 186 | MessageType gmproto.MessageType 187 | } 188 | 189 | func (s *SessionHandler) buildMessage(params SendMessageParams) (string, proto.Message, error) { 190 | var err error 191 | sessionID := s.client.sessionHandler.sessionID 192 | 193 | requestID := params.RequestID 194 | if requestID == "" { 195 | requestID = uuid.NewString() 196 | } 197 | 198 | if params.MessageType == 0 { 199 | params.MessageType = gmproto.MessageType_BUGLE_MESSAGE 200 | } 201 | 202 | message := &gmproto.OutgoingRPCMessage{ 203 | Mobile: s.client.AuthData.Mobile, 204 | Data: &gmproto.OutgoingRPCMessage_Data{ 205 | RequestID: requestID, 206 | BugleRoute: gmproto.BugleRoute_DataEvent, 207 | MessageTypeData: &gmproto.OutgoingRPCMessage_Data_Type{ 208 | EmptyArr: &gmproto.EmptyArr{}, 209 | MessageType: params.MessageType, 210 | }, 211 | }, 212 | Auth: &gmproto.OutgoingRPCMessage_Auth{ 213 | RequestID: requestID, 214 | TachyonAuthToken: s.client.AuthData.TachyonAuthToken, 215 | ConfigVersion: util.ConfigMessage, 216 | }, 217 | DestRegistrationIDs: []string{}, 218 | } 219 | if s.client.AuthData != nil && s.client.AuthData.DestRegID != uuid.Nil { 220 | message.DestRegistrationIDs = append(message.DestRegistrationIDs, s.client.AuthData.DestRegID.String()) 221 | } 222 | if params.CustomTTL != 0 { 223 | message.TTL = params.CustomTTL 224 | } else if !params.OmitTTL { 225 | message.TTL = s.client.AuthData.TachyonTTL 226 | } 227 | var encryptedData, unencryptedData []byte 228 | if params.Data != nil { 229 | var serializedData []byte 230 | serializedData, err = proto.Marshal(params.Data) 231 | if err != nil { 232 | return "", nil, err 233 | } 234 | if params.DontEncrypt { 235 | unencryptedData = serializedData 236 | } else { 237 | encryptedData, err = s.client.AuthData.RequestCrypto.Encrypt(serializedData) 238 | if err != nil { 239 | return "", nil, err 240 | } 241 | } 242 | } 243 | message.Data.MessageData, err = proto.Marshal(&gmproto.OutgoingRPCData{ 244 | RequestID: requestID, 245 | Action: params.Action, 246 | UnencryptedProtoData: unencryptedData, 247 | EncryptedProtoData: encryptedData, 248 | SessionID: sessionID, 249 | }) 250 | if err != nil { 251 | return "", nil, err 252 | } 253 | 254 | return requestID, message, err 255 | } 256 | 257 | func (s *SessionHandler) queueMessageAck(messageID string) { 258 | s.ackMapLock.Lock() 259 | defer s.ackMapLock.Unlock() 260 | if !slices.Contains(s.ackMap, messageID) { 261 | s.ackMap = append(s.ackMap, messageID) 262 | s.client.Logger.Trace().Any("message_id", messageID).Msg("Queued ack for message") 263 | } else { 264 | s.client.Logger.Trace().Any("message_id", messageID).Msg("Ack for message was already queued") 265 | } 266 | } 267 | 268 | func (s *SessionHandler) startAckInterval() { 269 | if s.ackTicker != nil { 270 | return 271 | } 272 | ticker := time.NewTicker(5 * time.Second) 273 | s.ackTicker = ticker 274 | go func() { 275 | for range ticker.C { 276 | s.sendAckRequest() 277 | } 278 | }() 279 | } 280 | 281 | func (s *SessionHandler) sendAckRequest() { 282 | s.ackMapLock.Lock() 283 | dataToAck := s.ackMap 284 | s.ackMap = nil 285 | s.ackMapLock.Unlock() 286 | if len(dataToAck) == 0 { 287 | return 288 | } 289 | ackMessages := make([]*gmproto.AckMessageRequest_Message, len(dataToAck)) 290 | for i, reqID := range dataToAck { 291 | ackMessages[i] = &gmproto.AckMessageRequest_Message{ 292 | RequestID: reqID, 293 | Device: s.client.AuthData.Browser, 294 | } 295 | } 296 | payload := &gmproto.AckMessageRequest{ 297 | AuthData: &gmproto.AuthMessage{ 298 | RequestID: uuid.NewString(), 299 | TachyonAuthToken: s.client.AuthData.TachyonAuthToken, 300 | Network: s.client.AuthData.AuthNetwork(), 301 | ConfigVersion: util.ConfigMessage, 302 | }, 303 | EmptyArr: &gmproto.EmptyArr{}, 304 | Acks: ackMessages, 305 | } 306 | url := util.AckMessagesURL 307 | if s.client.AuthData.HasCookies() { 308 | url = util.AckMessagesURLGoogle 309 | } 310 | _, err := typedHTTPResponse[*gmproto.OutgoingRPCResponse]( 311 | s.client.makeProtobufHTTPRequest(url, payload, ContentTypePBLite), 312 | ) 313 | if err != nil { 314 | // TODO retry? 315 | s.client.Logger.Err(err).Strs("message_ids", dataToAck).Msg("Failed to send acks") 316 | } else { 317 | s.client.Logger.Trace().Strs("message_ids", dataToAck).Msg("Sent acks") 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /pkg/libgm/util/config.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" 5 | ) 6 | 7 | var ConfigMessage = &gmproto.ConfigVersion{ 8 | Year: 2025, 9 | Month: 4, 10 | Day: 24, 11 | V1: 4, 12 | V2: 6, 13 | } 14 | 15 | const QRNetwork = "Bugle" 16 | const GoogleNetwork = "GDitto" 17 | 18 | var BrowserDetailsMessage = &gmproto.BrowserDetails{ 19 | UserAgent: UserAgent, 20 | BrowserType: gmproto.BrowserType_OTHER, 21 | OS: "libgm", 22 | DeviceType: gmproto.DeviceType_TABLET, 23 | } 24 | -------------------------------------------------------------------------------- /pkg/libgm/util/constants.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | const GoogleAPIKey = "AIzaSyCA4RsOZUFrm9whhtGosPlJLmVPnfSHKz8" 4 | const UserAgent = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" 5 | const SecUA = `"Google Chrome";v="135", "Chromium";v="135", "Not-A.Brand";v="24"` 6 | const UAPlatform = "Android" 7 | const XUserAgent = "grpc-web-javascript/0.1" 8 | const QRCodeURLBase = "https://support.google.com/messages/?p=web_computer#?c=" 9 | const SecUAMobile = "?1" 10 | -------------------------------------------------------------------------------- /pkg/libgm/util/func.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func GenerateTmpID() string { 11 | src := rand.NewSource(time.Now().UnixNano()) 12 | r := rand.New(src) 13 | randNum := r.Int63n(1e12) 14 | return fmt.Sprintf("tmp_%012d", randNum) 15 | } 16 | 17 | func BuildRelayHeaders(req *http.Request, contentType string, accept string) { 18 | //req.Header.Set("host", "instantmessaging-pa.googleapis.com") 19 | req.Header.Set("sec-ch-ua", SecUA) 20 | req.Header.Set("x-user-agent", XUserAgent) 21 | req.Header.Set("x-goog-api-key", GoogleAPIKey) 22 | if len(contentType) > 0 { 23 | req.Header.Set("content-type", contentType) 24 | } 25 | req.Header.Set("sec-ch-ua-mobile", SecUAMobile) 26 | req.Header.Set("user-agent", UserAgent) 27 | req.Header.Set("sec-ch-ua-platform", "\""+UAPlatform+"\"") 28 | req.Header.Set("accept", accept) 29 | req.Header.Set("origin", "https://messages.google.com") 30 | req.Header.Set("sec-fetch-site", "cross-site") 31 | req.Header.Set("sec-fetch-mode", "cors") 32 | req.Header.Set("sec-fetch-dest", "empty") 33 | req.Header.Set("referer", "https://messages.google.com/") 34 | req.Header.Set("accept-language", "en-US,en;q=0.9") 35 | } 36 | 37 | func BuildUploadHeaders(req *http.Request, metadata string) { 38 | //req.Header.Set("host", "instantmessaging-pa.googleapis.com") 39 | req.Header.Set("x-goog-download-metadata", metadata) 40 | req.Header.Set("sec-ch-ua", SecUA) 41 | req.Header.Set("sec-ch-ua-mobile", SecUAMobile) 42 | req.Header.Set("user-agent", UserAgent) 43 | req.Header.Set("sec-ch-ua-platform", "\""+UAPlatform+"\"") 44 | req.Header.Set("accept", "*/*") 45 | req.Header.Set("origin", "https://messages.google.com") 46 | req.Header.Set("sec-fetch-site", "cross-site") 47 | req.Header.Set("sec-fetch-mode", "cors") 48 | req.Header.Set("sec-fetch-dest", "empty") 49 | req.Header.Set("referer", "https://messages.google.com/") 50 | req.Header.Set("accept-encoding", "gzip, deflate, br") 51 | req.Header.Set("accept-language", "en-US,en;q=0.9") 52 | } 53 | 54 | func NewMediaUploadHeaders(imageSize string, command string, uploadOffset string, imageContentType string, protocol string) *http.Header { 55 | headers := &http.Header{} 56 | 57 | //headers.Set("host", "instantmessaging-pa.googleapis.com") 58 | headers.Set("sec-ch-ua", SecUA) 59 | if protocol != "" { 60 | headers.Set("x-goog-upload-protocol", protocol) 61 | } 62 | headers.Set("x-goog-upload-header-content-length", imageSize) 63 | headers.Set("sec-ch-ua-mobile", SecUAMobile) 64 | headers.Set("user-agent", UserAgent) 65 | if imageContentType != "" { 66 | headers.Set("x-goog-upload-header-content-type", imageContentType) 67 | } 68 | headers.Set("content-type", "application/x-www-form-urlencoded;charset=UTF-8") 69 | if command != "" { 70 | headers.Set("x-goog-upload-command", command) 71 | } 72 | if uploadOffset != "" { 73 | headers.Set("x-goog-upload-offset", uploadOffset) 74 | } 75 | headers.Set("sec-ch-ua-platform", "\""+UAPlatform+"\"") 76 | headers.Set("accept", "*/*") 77 | headers.Set("origin", "https://messages.google.com") 78 | headers.Set("sec-fetch-site", "cross-site") 79 | headers.Set("sec-fetch-mode", "cors") 80 | headers.Set("sec-fetch-dest", "empty") 81 | headers.Set("referer", "https://messages.google.com/") 82 | headers.Set("accept-encoding", "gzip, deflate, br") 83 | headers.Set("accept-language", "en-US,en;q=0.9") 84 | return headers 85 | } 86 | -------------------------------------------------------------------------------- /pkg/libgm/util/paths.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | const MessagesBaseURL = "https://messages.google.com" 4 | 5 | const GoogleAuthenticationURL = MessagesBaseURL + "/web/authentication" 6 | const GoogleTimesourceURL = MessagesBaseURL + "/web/timesource" 7 | 8 | const instantMessagingBaseURL = "https://instantmessaging-pa.googleapis.com" 9 | const instantMessagingBaseURLGoogle = "https://instantmessaging-pa.clients6.google.com" 10 | 11 | const UploadMediaURL = instantMessagingBaseURL + "/upload" 12 | 13 | const pairingBaseURL = instantMessagingBaseURL + "/$rpc/google.internal.communications.instantmessaging.v1.Pairing" 14 | const RegisterPhoneRelayURL = pairingBaseURL + "/RegisterPhoneRelay" 15 | const RefreshPhoneRelayURL = pairingBaseURL + "/RefreshPhoneRelay" 16 | const GetWebEncryptionKeyURL = pairingBaseURL + "/GetWebEncryptionKey" 17 | const RevokeRelayPairingURL = pairingBaseURL + "/RevokeRelayPairing" 18 | 19 | const messagingBaseURL = instantMessagingBaseURL + "/$rpc/google.internal.communications.instantmessaging.v1.Messaging" 20 | const messagingBaseURLGoogle = instantMessagingBaseURLGoogle + "/$rpc/google.internal.communications.instantmessaging.v1.Messaging" 21 | const ReceiveMessagesURL = messagingBaseURL + "/ReceiveMessages" 22 | const SendMessageURL = messagingBaseURL + "/SendMessage" 23 | const AckMessagesURL = messagingBaseURL + "/AckMessages" 24 | const ReceiveMessagesURLGoogle = messagingBaseURLGoogle + "/ReceiveMessages" 25 | const SendMessageURLGoogle = messagingBaseURLGoogle + "/SendMessage" 26 | const AckMessagesURLGoogle = messagingBaseURLGoogle + "/AckMessages" 27 | 28 | const registrationBaseURL = instantMessagingBaseURLGoogle + "/$rpc/google.internal.communications.instantmessaging.v1.Registration" 29 | const SignInGaiaURL = registrationBaseURL + "/SignInGaia" 30 | const RegisterRefreshURL = registrationBaseURL + "/RegisterRefresh" 31 | 32 | const ConfigURL = "https://messages.google.com/web/config" 33 | --------------------------------------------------------------------------------