├── .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 |
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 |
--------------------------------------------------------------------------------