├── .dockerignore
├── .editorconfig
├── .github
└── workflows
│ └── python-lint.yml
├── .gitignore
├── .gitlab-ci.yml
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── README.md
├── ROADMAP.md
├── dev-requirements.txt
├── docker-run.sh
├── mauigpapi
├── __init__.py
├── errors
│ ├── __init__.py
│ ├── base.py
│ ├── mqtt.py
│ ├── response.py
│ └── state.py
├── http
│ ├── __init__.py
│ ├── account.py
│ ├── api.py
│ ├── base.py
│ ├── challenge.py
│ ├── login.py
│ ├── thread.py
│ ├── upload.py
│ └── user.py
├── mqtt
│ ├── __init__.py
│ ├── conn.py
│ ├── events.py
│ ├── otclient.py
│ ├── subscription.py
│ └── thrift
│ │ ├── __init__.py
│ │ ├── autospec.py
│ │ ├── ig_objects.py
│ │ ├── read.py
│ │ ├── type.py
│ │ └── write.py
├── scripts
│ ├── __init__.py
│ └── iglogin.py
├── state
│ ├── __init__.py
│ ├── application.py
│ ├── cookies.py
│ ├── device.py
│ ├── session.py
│ └── state.py
└── types
│ ├── __init__.py
│ ├── account.py
│ ├── challenge.py
│ ├── direct_inbox.py
│ ├── error.py
│ ├── login.py
│ ├── mqtt.py
│ ├── qe.py
│ ├── thread.py
│ ├── thread_item.py
│ ├── upload.py
│ └── user.py
├── mautrix_instagram
├── __init__.py
├── __main__.py
├── commands
│ ├── __init__.py
│ ├── auth.py
│ ├── conn.py
│ ├── misc.py
│ └── typehint.py
├── config.py
├── db
│ ├── __init__.py
│ ├── backfill_queue.py
│ ├── message.py
│ ├── portal.py
│ ├── puppet.py
│ ├── reaction.py
│ ├── upgrade
│ │ ├── __init__.py
│ │ ├── v00_latest_revision.py
│ │ ├── v02_name_avatar_set.py
│ │ ├── v03_relay_portal.py
│ │ ├── v04_message_client_content.py
│ │ ├── v05_message_ig_timestamp.py
│ │ ├── v06_hidden_events.py
│ │ ├── v07_reaction_timestamps.py
│ │ ├── v08_sync_sequence_id.py
│ │ ├── v09_backfill_queue.py
│ │ ├── v10_portal_infinite_backfill.py
│ │ ├── v11_per_user_thread_sync_status.py
│ │ ├── v12_portal_thread_image_id.py
│ │ ├── v13_fix_portal_thread_image_id.py
│ │ └── v14_puppet_contact_info_set.py
│ └── user.py
├── example-config.yaml
├── formatter.py
├── get_version.py
├── matrix.py
├── portal.py
├── puppet.py
├── user.py
├── util
│ ├── __init__.py
│ └── color_log.py
├── version.py
└── web
│ ├── __init__.py
│ ├── analytics.py
│ └── provisioning_api.py
├── optional-requirements.txt
├── pyproject.toml
├── requirements.txt
└── setup.py
/.dockerignore:
--------------------------------------------------------------------------------
1 | .editorconfig
2 | logs
3 | .venv
4 | start
5 | config.yaml
6 | registration.yaml
7 | *.db
8 | *.pickle
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.py]
12 | max_line_length = 99
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 |
17 | [*.{yaml,yml,py,md}]
18 | indent_style = space
19 |
20 | [{.gitlab-ci.yml,*.md,.pre-commit-config.yaml}]
21 | indent_size = 2
22 |
--------------------------------------------------------------------------------
/.github/workflows/python-lint.yml:
--------------------------------------------------------------------------------
1 | name: Python lint
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: actions/setup-python@v2
11 | with:
12 | python-version: "3.11"
13 | - uses: isort/isort-action@master
14 | with:
15 | sortPaths: "./mautrix_instagram ./mauigpapi"
16 | - uses: psf/black@stable
17 | with:
18 | src: "./mautrix_instagram ./mauigpapi"
19 | version: "23.1.0"
20 | - name: pre-commit
21 | run: |
22 | pip install pre-commit
23 | pre-commit run -av trailing-whitespace
24 | pre-commit run -av end-of-file-fixer
25 | pre-commit run -av check-yaml
26 | pre-commit run -av check-added-large-files
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 |
3 | /.venv
4 | /env/
5 | pip-selfcheck.json
6 | *.pyc
7 | __pycache__
8 | /build
9 | /dist
10 | /*.egg-info
11 | /.eggs
12 | /start
13 |
14 | /config.yaml
15 | /registration.yaml
16 | *.log*
17 | *.db
18 | *.pickle
19 | *.bak
20 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - project: 'mautrix/ci'
3 | file: '/python.yml'
4 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.4.0
4 | hooks:
5 | - id: trailing-whitespace
6 | exclude_types: [markdown]
7 | - id: end-of-file-fixer
8 | - id: check-yaml
9 | - id: check-added-large-files
10 | - repo: https://github.com/psf/black
11 | rev: 23.1.0
12 | hooks:
13 | - id: black
14 | language_version: python3
15 | files: ^(mauigpapi|mautrix_instagram)/.*\.pyi?$
16 | - repo: https://github.com/PyCQA/isort
17 | rev: 5.12.0
18 | hooks:
19 | - id: isort
20 | files: ^(mauigpapi|mautrix_instagram)/.*\.pyi?$
21 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # v0.3.1 (2023-09-19)
2 |
3 | * **Security:** Updated Pillow to 10.0.1.
4 | * Added support for double puppeting with arbitrary `as_token`s.
5 | See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
6 | * Updated attachment upload API as the old one was slow.
7 | * Changed error for expired media to look more like what Instagram shows and
8 | less like the bridge is broken.
9 |
10 | # v0.3.0 (2023-05-31)
11 |
12 | * Improved handling of some message types.
13 | * Added automatic retrying for sending videos to give Instagram servers more
14 | time to transcode the video.
15 | * Added support for @-mentioning users from Matrix.
16 | * Added support for bridging incoming avatar stickers.
17 | * Added automatic fetching of shared reel videos to bridge full video instead
18 | of only the thumbnail image.
19 | * Added real-time bridging of group avatar changes from Instagram.
20 | * Added option to disable sending typing notifications to Instagram.
21 | * Added notice message when receiving video calls.
22 | * Added options to automatically ratchet/delete megolm sessions to minimize
23 | access to old messages.
24 | * Added option to not set room name/avatar even in encrypted rooms.
25 | * Redid backfill system to support MSC2716.
26 | * Note that using Synapse's MSC2716 implementation is not recommended, and
27 | the bridge can still backfill messages without MSC2716.
28 | * Implemented appservice pinging using MSC2659.
29 | * Possibly improved MQTT connection handling.
30 |
31 | # v0.2.3 (2022-12-14)
32 |
33 | * Added support for "mentioned in comment" messages.
34 | * Added support for re-requesting 2FA SMS when logging in.
35 | * Updated Docker image to Alpine 3.17.
36 | * Fixed error in image bridging.
37 | * Fixed logging in with phone/email in provisioning API.
38 |
39 | # v0.2.2 (2022-11-01)
40 |
41 | * Added option to send captions in the same message using [MSC2530].
42 | * Updated app version identifiers to bridge some new message types.
43 | * Fixed race condition when backfilling chat with incoming message.
44 |
45 | [MSC2530]: https://github.com/matrix-org/matrix-spec-proposals/pull/2530
46 |
47 | # v0.2.1 (2022-09-19)
48 |
49 | * Fixed login breaking due to an Instagram API change.
50 | * Added support for SQLite as the bridge database.
51 | * Added option to use [MSC2409] and [MSC3202] for end-to-bridge encryption.
52 | However, this may not work with the Synapse implementation as it hasn't been
53 | tested yet.
54 | * The docker image now has an option to bypass the startup script by setting
55 | the `MAUTRIX_DIRECT_STARTUP` environment variable. Additionally, it will
56 | refuse to run as a non-root user if that variable is not set (and print an
57 | error message suggesting to either set the variable or use a custom command).
58 | * Moved environment variable overrides for config fields to mautrix-python.
59 | The new system also allows loading JSON values to enable overriding maps like
60 | `login_shared_secret_map`.
61 |
62 | [MSC2409]: https://github.com/matrix-org/matrix-spec-proposals/pull/2409
63 | [MSC3202]: https://github.com/matrix-org/matrix-spec-proposals/pull/3202
64 |
65 | # v0.2.0 (2022-08-26)
66 |
67 | * Added handling for rate limit errors when connecting to Instagram.
68 | * Added option to not bridge `m.notice` messages (thanks to [@bramenn] in [#55]).
69 | * Fixed bridging voice messages to Instagram (broke due to server-side changes).
70 | * Made Instagram message processing synchronous so messages are bridged in order.
71 | * Updated Docker image to Alpine 3.16.
72 | * Enabled appservice ephemeral events by default for new installations.
73 | * Existing bridges can turn it on by enabling `ephemeral_events` and disabling
74 | `sync_with_custom_puppets` in the config, then regenerating the registration
75 | file.
76 | * Added options to make encryption more secure.
77 | * The `encryption` -> `verification_levels` config options can be used to
78 | make the bridge require encrypted messages to come from cross-signed
79 | devices, with trust-on-first-use validation of the cross-signing master
80 | key.
81 | * The `encryption` -> `require` option can be used to make the bridge ignore
82 | any unencrypted messages.
83 | * Key rotation settings can be configured with the `encryption` -> `rotation`
84 | config.
85 |
86 | [@bramenn]: https://github.com/bramenn
87 | [#55]: https://github.com/mautrix/instagram/pull/55
88 |
89 | # v0.1.3 (2022-04-06)
90 |
91 | * Added support for Matrix->Instagram replies.
92 | * Added support for sending clickable links with previews to Instagram.
93 | * Added support for creating DMs from Matrix (by starting a chat with a ghost).
94 | * Added option to use [MSC2246] async media uploads.
95 | * Added support for logging in with a Facebook token in the provisioning API.
96 | * Added support for sending giphy gifs (requires client support).
97 | * Changed some fields to stop the user from showing up as online on Instagram
98 | all the time.
99 | * Fixed messages on Instagram not being marked as read if last event on Matrix
100 | is not a normal message.
101 | * Fixed incoming messages not being deduplicated properly in some cases.
102 | * Removed legacy `community_id` config option.
103 | * Stopped running as root in Docker image (default user is now `1337`).
104 | * Disabled file logging in Docker image by default.
105 | * If you want to enable it, set the `filename` in the file log handler to a
106 | path that is writable, then add `"file"` back to `logging.root.handlers`.
107 | * Dropped Python 3.7 support.
108 |
109 | [MSC2246]: https://github.com/matrix-org/matrix-spec-proposals/pull/2246
110 |
111 | # v0.1.2 (2022-01-15)
112 |
113 | * Added relay mode (see [docs](https://docs.mau.fi/bridges/general/relay-mode.html) for more info).
114 | * Added notices for unsupported incoming message types.
115 | * Added support for more message types:
116 | * "Tagged in post" messages
117 | * Reel clip shares
118 | * Profile shares
119 | * Updated Docker image to Alpine 3.15.
120 | * Formatted all code using [black](https://github.com/psf/black)
121 | and [isort](https://github.com/PyCQA/isort).
122 |
123 | # v0.1.1 (2021-08-20)
124 |
125 | **N.B.** Docker images have moved from `dock.mau.dev/tulir/mautrix-instagram`
126 | to `dock.mau.dev/mautrix/instagram`. New versions are only available at the new
127 | path.
128 |
129 | * Added retrying failed syncs when refreshing Instagram connection.
130 | * Updated displayname handling to fall back to username if user has no displayname set.
131 | * Updated Docker image to Alpine 3.14.
132 | * Fixed handling some Instagram message types.
133 |
134 | # v0.1.0 (2021-04-07)
135 |
136 | Initial tagged release.
137 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM docker.io/alpine:3.18
2 |
3 | RUN apk add --no-cache \
4 | python3 py3-pip py3-setuptools py3-wheel \
5 | #py3-pillow \
6 | py3-aiohttp \
7 | py3-magic \
8 | py3-ruamel.yaml \
9 | py3-commonmark \
10 | #py3-prometheus-client \
11 | py3-paho-mqtt \
12 | # proxy support
13 | #py3-aiohttp-socks \
14 | py3-pysocks \
15 | # Other dependencies
16 | ca-certificates \
17 | su-exec \
18 | ffmpeg \
19 | # encryption
20 | py3-olm \
21 | py3-cffi \
22 | py3-pycryptodome \
23 | py3-unpaddedbase64 \
24 | py3-future \
25 | bash \
26 | curl \
27 | jq \
28 | yq \
29 | # Temporarily install pillow from edge repo to get up-to-date version
30 | && apk add --no-cache py3-pillow --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
31 |
32 | COPY requirements.txt /opt/mautrix-instagram/requirements.txt
33 | COPY optional-requirements.txt /opt/mautrix-instagram/optional-requirements.txt
34 | WORKDIR /opt/mautrix-instagram
35 | RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
36 | && pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \
37 | && apk del .build-deps
38 |
39 | COPY . /opt/mautrix-instagram
40 | RUN apk add git && pip3 install --no-cache-dir .[all] && apk del git \
41 | # This doesn't make the image smaller, but it's needed so that the `version` command works properly
42 | && cp mautrix_instagram/example-config.yaml . && rm -rf mautrix_instagram .git build
43 |
44 | ENV UID=1337 GID=1337
45 | VOLUME /data
46 |
47 | CMD ["/opt/mautrix-instagram/docker-run.sh"]
48 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include CHANGELOG.md
3 | include LICENSE
4 | include requirements.txt
5 | include optional-requirements.txt
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mautrix-instagram
2 | 
3 | [](LICENSE)
4 | [](https://github.com/mautrix/instagram/releases)
5 | [](https://mau.dev/mautrix/instagram/container_registry)
6 | [](https://github.com/psf/black)
7 | [](https://pycqa.github.io/isort/)
8 |
9 | A Matrix-Instagram DM puppeting bridge.
10 |
11 | **This bridge is deprecated. [mautrix-meta] is recommended instead.**
12 |
13 | [mautrix-meta]: https://github.com/mautrix/meta
14 |
15 | ## Documentation
16 | All setup and usage instructions are located on
17 | [docs.mau.fi](https://docs.mau.fi/bridges/python/instagram/index.html).
18 | Some quick links:
19 |
20 | * [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=instagram)
21 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=instagram))
22 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/python/instagram/authentication.html)
23 |
24 | ### Features & Roadmap
25 | [ROADMAP.md](https://github.com/mautrix/instagram/blob/master/ROADMAP.md)
26 | contains a general overview of what is supported by the bridge.
27 |
28 | ## Discussion
29 | Matrix room: [`#instagram:maunium.net`](https://matrix.to/#/#instagram:maunium.net)
30 |
--------------------------------------------------------------------------------
/ROADMAP.md:
--------------------------------------------------------------------------------
1 | # Features & roadmap
2 |
3 | * Matrix → Instagram
4 | * [ ] Message content
5 | * [x] Text
6 | * [ ] Media
7 | * [x] Images
8 | * [x] Videos
9 | * [x] Voice messages
10 | * [ ] Locations
11 | * [ ] †Files
12 | * [x] Replies
13 | * [x] Message redactions
14 | * [x] Message reactions
15 | * [ ] Presence
16 | * [x] Typing notifications
17 | * [x] Read receipts
18 | * Instagram → Matrix
19 | * [x] Message content
20 | * [x] Text
21 | * [x] Media
22 | * [x] Images
23 | * [x] Videos
24 | * [x] Gifs
25 | * [x] Voice messages
26 | * [x] Locations
27 | * [x] Story/reel/clip share
28 | * [x] Profile share
29 | * [ ] Product share
30 | * [x] Replies
31 | * [x] Message unsend
32 | * [x] Message reactions
33 | * [x] Message history
34 | * [ ] Presence
35 | * [x] Typing notifications
36 | * [x] Read receipts
37 | * [x] User metadata
38 | * [x] Name
39 | * [x] Avatar
40 | * Misc
41 | * [x] Multi-user support
42 | * [x] Shared group chat portals
43 | * [x] Automatic portal creation
44 | * [x] At startup
45 | * [x] When receiving message
46 | * [x] Private chat creation by inviting Matrix puppet of Instagram user to new room
47 | * [x] Option to use own Matrix account for messages sent from other Instagram clients
48 | * [x] End-to-bridge encryption in Matrix rooms
49 |
50 | † Not supported on Instagram
51 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | pre-commit>=2.10.1,<3
2 | isort>=5.10.1,<6
3 | black>=23,<24
4 |
--------------------------------------------------------------------------------
/docker-run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then
3 | if [ $(id -u) == 0 ]; then
4 | echo "|------------------------------------------|"
5 | echo "| Warning: running bridge unsafely as root |"
6 | echo "|------------------------------------------|"
7 | fi
8 | exec python3 -m mautrix_instagram -c /data/config.yaml
9 | elif [ $(id -u) != 0 ]; then
10 | echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge."
11 | echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable,"
12 | echo "or just use `python3 -m mautrix_instagram -c /data/config.yaml` as the run command."
13 | echo "Note that the config and registration will not be auto-generated when bypassing the startup script."
14 | exit 1
15 | fi
16 |
17 | # Define functions.
18 | function fixperms {
19 | chown -R $UID:$GID /data
20 |
21 | # /opt/mautrix-instagram is read-only, so disable file logging if it's pointing there.
22 | if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-instagram.log" ]]; then
23 | yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml
24 | yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml
25 | fi
26 | }
27 |
28 | cd /opt/mautrix-instagram
29 |
30 | if [ ! -f /data/config.yaml ]; then
31 | cp example-config.yaml /data/config.yaml
32 | echo "Didn't find a config file."
33 | echo "Copied default config file to /data/config.yaml"
34 | echo "Modify that config file to your liking."
35 | echo "Start the container again after that to generate the registration file."
36 | fixperms
37 | exit
38 | fi
39 |
40 | if [ ! -f /data/registration.yaml ]; then
41 | python3 -m mautrix_instagram -g -c /data/config.yaml -r /data/registration.yaml || exit $?
42 | echo "Didn't find a registration file."
43 | echo "Generated one for you."
44 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
45 | fixperms
46 | exit
47 | fi
48 |
49 | fixperms
50 | exec su-exec $UID:$GID python3 -m mautrix_instagram -c /data/config.yaml
51 |
--------------------------------------------------------------------------------
/mauigpapi/__init__.py:
--------------------------------------------------------------------------------
1 | from .http import AndroidAPI
2 | from .mqtt import AndroidMQTT, GraphQLSubscription, SkywalkerSubscription
3 | from .state import AndroidState
4 |
--------------------------------------------------------------------------------
/mauigpapi/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import IGError
2 | from .mqtt import (
3 | IGMQTTError,
4 | IrisSubscribeError,
5 | MQTTConnectionUnauthorized,
6 | MQTTNotConnected,
7 | MQTTNotLoggedIn,
8 | MQTTReconnectionError,
9 | )
10 | from .response import (
11 | IG2FACodeExpiredError,
12 | IGActionSpamError,
13 | IGBad2FACodeError,
14 | IGChallengeError,
15 | IGChallengeWrongCodeError,
16 | IGCheckpointError,
17 | IGConsentRequiredError,
18 | IGFBEmailTaken,
19 | IGFBNoContactPointFoundError,
20 | IGFBSSODisabled,
21 | IGInactiveUserError,
22 | IGLoginBadPasswordError,
23 | IGLoginError,
24 | IGLoginInvalidCredentialsError,
25 | IGLoginInvalidUserError,
26 | IGLoginRequiredError,
27 | IGLoginTwoFactorRequiredError,
28 | IGLoginUnusablePasswordError,
29 | IGNotFoundError,
30 | IGNotLoggedInError,
31 | IGPrivateUserError,
32 | IGRateLimitError,
33 | IGResponseError,
34 | IGSentryBlockError,
35 | IGUnknownError,
36 | IGUserHasLoggedOutError,
37 | )
38 | from .state import IGCookieNotFoundError, IGNoChallengeError, IGUserIDNotFoundError
39 |
--------------------------------------------------------------------------------
/mauigpapi/errors/base.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 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 |
18 | class IGError(Exception):
19 | pass
20 |
--------------------------------------------------------------------------------
/mauigpapi/errors/mqtt.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from .base import IGError
17 |
18 |
19 | class IGMQTTError(IGError):
20 | pass
21 |
22 |
23 | class MQTTNotLoggedIn(IGMQTTError):
24 | pass
25 |
26 |
27 | class MQTTReconnectionError(IGMQTTError):
28 | pass
29 |
30 |
31 | class MQTTNotConnected(IGMQTTError):
32 | pass
33 |
34 |
35 | class MQTTConnectionUnauthorized(IGMQTTError):
36 | def __init__(self) -> None:
37 | super().__init__("Server refused connection with error code 5")
38 |
39 |
40 | class IrisSubscribeError(IGMQTTError):
41 | def __init__(self, type: str, message: str) -> None:
42 | super().__init__(f"{type}: {message}")
43 |
--------------------------------------------------------------------------------
/mauigpapi/errors/response.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional, get_type_hints
17 |
18 | from aiohttp import ClientResponse
19 |
20 | from mautrix.types import JSON, Serializable
21 |
22 | from ..types import (
23 | ChallengeResponse,
24 | CheckpointResponse,
25 | ConsentRequiredResponse,
26 | LoginErrorResponse,
27 | LoginRequiredResponse,
28 | SpamResponse,
29 | )
30 | from .base import IGError
31 |
32 |
33 | class IGChallengeWrongCodeError(IGError):
34 | pass
35 |
36 |
37 | class IGUnknownError(IGError):
38 | response: ClientResponse
39 |
40 | def __init__(self, response: ClientResponse) -> None:
41 | super().__init__(
42 | f"Request {response.request_info.method} {response.request_info.url.path} failed: "
43 | f"HTTP {response.status} with non-JSON body"
44 | )
45 | self.response = response
46 |
47 |
48 | class IGResponseError(IGError):
49 | response: ClientResponse
50 |
51 | def __init__(self, response: ClientResponse, json: JSON) -> None:
52 | prefix = f"Request {response.request_info.method} {response.request_info.url.path} failed"
53 | message = f"HTTP {response.status}"
54 | self.response = response
55 | if "message" in json:
56 | message = json["message"]
57 | if "error_type" in json:
58 | error_type = json["error_type"]
59 | message = f"{error_type}: {message}"
60 | type_hint = get_type_hints(type(self)).get("body", JSON)
61 | if type_hint is not JSON and issubclass(type_hint, Serializable):
62 | self.body = type_hint.deserialize(json)
63 | else:
64 | self.body = json
65 | super().__init__(f"{prefix}: {self._message_override or message}")
66 |
67 | @property
68 | def _message_override(self) -> Optional[str]:
69 | return None
70 |
71 |
72 | class IGActionSpamError(IGResponseError):
73 | body: SpamResponse
74 |
75 | @property
76 | def _message(self) -> str:
77 | return f"HTTP {self.body.message}"
78 |
79 |
80 | class IGNotFoundError(IGResponseError):
81 | pass
82 |
83 |
84 | class IGRateLimitError(IGResponseError):
85 | pass
86 |
87 |
88 | class IGCheckpointError(IGResponseError):
89 | body: CheckpointResponse
90 |
91 |
92 | class IGChallengeError(IGResponseError):
93 | body: ChallengeResponse
94 |
95 | @property
96 | def url(self) -> str:
97 | return self.body.challenge.api_path
98 |
99 |
100 | class IGConsentRequiredError(IGResponseError):
101 | body: ConsentRequiredResponse
102 |
103 |
104 | class IGNotLoggedInError(IGResponseError):
105 | body: LoginRequiredResponse
106 |
107 | @property
108 | def proper_message(self) -> str:
109 | return (
110 | f"{self.body.error_title or self.body.message} "
111 | f"(reason code: {self.body.logout_reason})"
112 | )
113 |
114 |
115 | class IGUserHasLoggedOutError(IGNotLoggedInError):
116 | pass
117 |
118 |
119 | class IGLoginRequiredError(IGNotLoggedInError):
120 | pass
121 |
122 |
123 | class IGPrivateUserError(IGResponseError):
124 | pass
125 |
126 |
127 | class IGSentryBlockError(IGResponseError):
128 | pass
129 |
130 |
131 | class IGInactiveUserError(IGResponseError):
132 | pass
133 |
134 |
135 | class IGLoginError(IGResponseError):
136 | body: LoginErrorResponse
137 |
138 |
139 | class IGLoginTwoFactorRequiredError(IGLoginError):
140 | pass
141 |
142 |
143 | class IGLoginBadPasswordError(IGLoginError):
144 | pass
145 |
146 |
147 | class IGLoginUnusablePasswordError(IGLoginError):
148 | pass
149 |
150 |
151 | class IGLoginInvalidUserError(IGLoginError):
152 | pass
153 |
154 |
155 | class IGLoginInvalidCredentialsError(IGLoginError):
156 | pass
157 |
158 |
159 | class IGBad2FACodeError(IGResponseError):
160 | pass
161 |
162 |
163 | class IG2FACodeExpiredError(IGResponseError):
164 | pass
165 |
166 |
167 | class IGFBNoContactPointFoundError(IGLoginError):
168 | pass
169 |
170 |
171 | class IGFBEmailTaken(IGLoginError):
172 | pass
173 |
174 |
175 | class IGFBSSODisabled(IGLoginError):
176 | pass
177 |
--------------------------------------------------------------------------------
/mauigpapi/errors/state.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from .base import IGError
17 |
18 |
19 | class IGUserIDNotFoundError(IGError):
20 | def __init__(self, message: str = "Could not extract userid (pk)"):
21 | super().__init__(message)
22 |
23 |
24 | class IGCookieNotFoundError(IGError):
25 | def __init__(self, key: str) -> None:
26 | super().__init__(f"Cookie '{key}' not found")
27 |
28 |
29 | class IGNoChallengeError(IGError):
30 | def __init__(self, message: str = "No challenge data available"):
31 | super().__init__(message)
32 |
--------------------------------------------------------------------------------
/mauigpapi/http/__init__.py:
--------------------------------------------------------------------------------
1 | from .api import AndroidAPI
2 |
--------------------------------------------------------------------------------
/mauigpapi/http/account.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import Type, TypeVar
19 | import json
20 |
21 | from ..types import CurrentUserResponse
22 | from .base import BaseAndroidAPI
23 |
24 | T = TypeVar("T")
25 |
26 |
27 | class AccountAPI(BaseAndroidAPI):
28 | async def current_user(self) -> CurrentUserResponse:
29 | return await self.std_http_get(
30 | f"/api/v1/accounts/current_user/",
31 | query={"edit": "true"},
32 | response_type=CurrentUserResponse,
33 | )
34 |
35 | async def set_biography(self, text: str) -> CurrentUserResponse:
36 | # TODO entities?
37 | return await self.__command("set_biography", device_id=self.state.device.id, raw_text=text)
38 |
39 | async def set_profile_picture(self, upload_id: str) -> CurrentUserResponse:
40 | return await self.__command(
41 | "change_profile_picture", use_fbuploader="true", upload_id=upload_id
42 | )
43 |
44 | async def remove_profile_picture(self) -> CurrentUserResponse:
45 | return await self.__command("remove_profile_picture")
46 |
47 | async def set_private(self, private: bool) -> CurrentUserResponse:
48 | return await self.__command("set_private" if private else "set_public")
49 |
50 | async def confirm_email(self, slug: str) -> CurrentUserResponse:
51 | # slug can contain slashes, but it shouldn't start or end with one
52 | return await self.__command(f"confirm_email/{slug}")
53 |
54 | async def send_recovery_flow_email(self, query: str):
55 | req = {
56 | "_csrftoken": self.state.cookies.csrf_token,
57 | "adid": "",
58 | "guid": self.state.device.uuid,
59 | "device_id": self.state.device.id,
60 | "query": query,
61 | }
62 | # TODO parse response content
63 | return await self.std_http_post(f"/api/v1/accounts/send_recovery_flow_email/", data=req)
64 |
65 | async def edit_profile(
66 | self,
67 | external_url: str | None = None,
68 | gender: str | None = None,
69 | phone_number: str | None = None,
70 | username: str | None = None,
71 | # TODO should there be a last_name?
72 | first_name: str | None = None,
73 | biography: str | None = None,
74 | email: str | None = None,
75 | ) -> CurrentUserResponse:
76 | return await self.__command(
77 | "edit_profile",
78 | device_id=self.state.device.id,
79 | email=email,
80 | external_url=external_url,
81 | first_name=first_name,
82 | username=username,
83 | phone_number=phone_number,
84 | gender=gender,
85 | biography=biography,
86 | )
87 |
88 | async def __command(
89 | self, command: str, response_type: Type[T] = CurrentUserResponse, **kwargs: str
90 | ) -> T:
91 | req = {
92 | "_csrftoken": self.state.cookies.csrf_token,
93 | "_uid": self.state.session.ds_user_id,
94 | "_uuid": self.state.device.uuid,
95 | **kwargs,
96 | }
97 | return await self.std_http_post(
98 | f"/api/v1/accounts/{command}/",
99 | data=req,
100 | filter_nulls=True,
101 | response_type=response_type,
102 | )
103 |
104 | async def read_msisdn_header(self, usage: str = "default"):
105 | req = {
106 | "mobile_subno_usage": usage,
107 | "device_id": self.state.device.uuid,
108 | }
109 | return await self.std_http_post("/api/v1/accounts/read_msisdn_header/", data=req)
110 |
111 | async def msisdn_header_bootstrap(self, usage: str = "default"):
112 | req = {
113 | "mobile_subno_usage": usage,
114 | "device_id": self.state.device.uuid,
115 | }
116 | return await self.std_http_post("/api/v1/accounts/msisdn_header_bootstrap/", data=req)
117 |
118 | async def contact_point_prefill(self, usage: str = "default"):
119 | req = {
120 | "mobile_subno_usage": usage,
121 | "device_id": self.state.device.uuid,
122 | }
123 | return await self.std_http_post("/api/v1/accounts/contact_point_prefill/", data=req)
124 |
125 | async def get_prefill_candidates(self):
126 | req = {
127 | "android_device_id": self.state.device.id,
128 | "usages": json.dumps(["account_recovery_omnibox"]),
129 | "device_id": self.state.device.uuid,
130 | }
131 | # TODO parse response content
132 | return await self.std_http_post("/api/v1/accounts/get_prefill_candidates/", data=req)
133 |
134 | async def process_contact_point_signals(self):
135 | req = {
136 | "phone_id": self.state.device.phone_id,
137 | "_csrftoken": self.state.cookies.csrf_token,
138 | "_uid": self.state.session.ds_user_id,
139 | "device_id": self.state.device.uuid,
140 | "_uuid": self.state.device.uuid,
141 | "google_tokens": json.dumps([]),
142 | }
143 | return await self.std_http_post(
144 | "/api/v1/accounts/process_contact_point_signals/", data=req
145 | )
146 |
--------------------------------------------------------------------------------
/mauigpapi/http/api.py:
--------------------------------------------------------------------------------
1 | from .account import AccountAPI
2 | from .challenge import ChallengeAPI
3 | from .login import LoginAPI
4 | from .thread import ThreadAPI
5 | from .upload import UploadAPI
6 | from .user import UserAPI
7 |
8 |
9 | class AndroidAPI(ThreadAPI, AccountAPI, LoginAPI, UploadAPI, ChallengeAPI, UserAPI):
10 | pass
11 |
--------------------------------------------------------------------------------
/mauigpapi/http/challenge.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from ..errors import IGChallengeWrongCodeError, IGResponseError
19 | from ..types import ChallengeStateResponse
20 | from .base import BaseAndroidAPI
21 |
22 |
23 | class ChallengeAPI(BaseAndroidAPI):
24 | @property
25 | def __path(self) -> str:
26 | return f"/api/v1{self.state.challenge_path}"
27 |
28 | async def challenge_get_state(self) -> ChallengeStateResponse:
29 | query = {
30 | "guid": self.state.device.uuid,
31 | "device_id": self.state.device.id,
32 | }
33 | if self.state.challenge_context:
34 | query["challenge_context"] = self.state.challenge_context.json()
35 | self.log.debug("Fetching current challenge state")
36 | return self.__handle_resp(
37 | await self.std_http_get(self.__path, query=query, response_type=ChallengeStateResponse)
38 | )
39 |
40 | async def challenge_select_method(
41 | self, choice: str, is_replay: bool = False
42 | ) -> ChallengeStateResponse:
43 | path = self.__path
44 | if is_replay:
45 | path = path.replace("/challenge/", "/challenge/replay/")
46 | req = {
47 | "choice": choice,
48 | "_csrftoken": self.state.cookies.csrf_token,
49 | "guid": self.state.device.uuid,
50 | "device_id": self.state.device.id,
51 | }
52 | self.log.debug(f"Selecting challenge method {choice} (replay: {is_replay})")
53 | return self.__handle_resp(
54 | await self.std_http_post(path, data=req, response_type=ChallengeStateResponse)
55 | )
56 |
57 | async def challenge_delta_review(self, was_me: bool = True) -> ChallengeStateResponse:
58 | return await self.challenge_select_method("0" if was_me else "1")
59 |
60 | async def challenge_send_phone_number(self, phone_number: str) -> ChallengeStateResponse:
61 | req = {
62 | "phone_number": phone_number,
63 | "_csrftoken": self.state.cookies.csrf_token,
64 | "guid": self.state.device.uuid,
65 | "device_id": self.state.device.id,
66 | }
67 | self.log.debug("Sending challenge phone number")
68 | return self.__handle_resp(
69 | await self.std_http_post(self.__path, data=req, response_type=ChallengeStateResponse)
70 | )
71 |
72 | async def challenge_send_security_code(self, code: str | int) -> ChallengeStateResponse:
73 | req = {
74 | "security_code": code,
75 | "_csrftoken": self.state.cookies.csrf_token,
76 | "guid": self.state.device.uuid,
77 | "device_id": self.state.device.id,
78 | }
79 | try:
80 | self.log.debug("Sending challenge security code")
81 | return self.__handle_resp(
82 | await self.std_http_post(
83 | self.__path, data=req, response_type=ChallengeStateResponse
84 | )
85 | )
86 | except IGResponseError as e:
87 | if e.response.status == 400:
88 | raise IGChallengeWrongCodeError((await e.response.json())["message"]) from e
89 | raise
90 |
91 | async def challenge_reset(self) -> ChallengeStateResponse:
92 | req = {
93 | "_csrftoken": self.state.cookies.csrf_token,
94 | "guid": self.state.device.uuid,
95 | "device_id": self.state.device.id,
96 | }
97 | self.log.debug("Resetting challenge")
98 | return self.__handle_resp(
99 | await self.std_http_post(
100 | self.__path.replace("/challenge/", "/challenge/reset/"),
101 | data=req,
102 | response_type=ChallengeStateResponse,
103 | )
104 | )
105 |
106 | async def challenge_auto(self, reset: bool = False) -> ChallengeStateResponse:
107 | if reset:
108 | await self.challenge_reset()
109 | challenge = self.state.challenge or await self.challenge_get_state()
110 | if challenge.step_name == "select_verify_method":
111 | self.log.debug(
112 | "Got select_verify_method challenge step, "
113 | f"auto-selecting {challenge.step_data.choice}"
114 | )
115 | return await self.challenge_select_method(challenge.step_data.choice)
116 | elif challenge.step_name == "delta_login_review":
117 | self.log.debug("Got delta_login_review challenge step, auto-selecting was_me=True")
118 | return await self.challenge_delta_review(was_me=True)
119 | else:
120 | self.log.debug(f"Got unknown challenge step {challenge.step_name}, not doing anything")
121 | return challenge
122 |
123 | def __handle_resp(self, resp: ChallengeStateResponse) -> ChallengeStateResponse:
124 | if resp.action == "close":
125 | self.log.debug(
126 | f"Challenge closed (step: {resp.step_name}, has user: {bool(resp.logged_in_user)})"
127 | )
128 | self.state.challenge = None
129 | self.state.challenge_context = None
130 | self.state.challenge_path = None
131 | else:
132 | self.state.challenge = resp
133 | return resp
134 |
--------------------------------------------------------------------------------
/mauigpapi/http/login.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | import base64
19 | import io
20 | import json
21 | import struct
22 | import time
23 | import uuid
24 |
25 | from Crypto.Cipher import AES, PKCS1_v1_5
26 | from Crypto.PublicKey import RSA
27 | from Crypto.Random import get_random_bytes
28 |
29 | from ..types import FacebookLoginResponse, LoginErrorResponse, LoginResponse, LogoutResponse
30 | from .base import BaseAndroidAPI
31 |
32 |
33 | class LoginAPI(BaseAndroidAPI):
34 | async def get_mobile_config(self) -> None:
35 | req = {
36 | "bool_opt_policy": "0",
37 | "mobileconfigsessionless": "",
38 | "api_version": "3",
39 | "unit_type": "1",
40 | "query_hash": "dae17f1d3276207ebfe78f7a67cc9a04d4b88ff8c88dfc17e148fafb3f655b8e",
41 | "device_id": self.state.device.id,
42 | "fetch_type": "ASYNC_FULL",
43 | "family_device_id": self.state.device.fdid.upper(),
44 | }
45 | await self.std_http_post("/api/v1/launcher/mobileconfig/", data=req)
46 |
47 | async def login(
48 | self,
49 | username: str,
50 | password: str | None = None,
51 | encrypted_password: str | None = None,
52 | ) -> LoginResponse:
53 | if password:
54 | if encrypted_password:
55 | raise ValueError("Only one of password or encrypted_password must be provided")
56 | encrypted_password = self._encrypt_password(password)
57 | elif not encrypted_password:
58 | raise ValueError("One of password or encrypted_password is required")
59 | req = {
60 | "username": username,
61 | "enc_password": encrypted_password,
62 | "guid": self.state.device.uuid,
63 | "phone_id": self.state.device.phone_id,
64 | "device_id": self.state.device.id,
65 | "adid": self.state.device.adid,
66 | "google_tokens": "[]",
67 | "login_attempt_count": "0", # TODO maybe cache this somewhere?
68 | "country_codes": json.dumps([{"country_code": "1", "source": "default"}]),
69 | "jazoest": self._jazoest,
70 | }
71 | return await self.std_http_post(
72 | "/api/v1/accounts/login/", data=req, response_type=LoginResponse
73 | )
74 |
75 | async def one_tap_app_login(self, user_id: str, nonce: str) -> LoginResponse:
76 | req = {
77 | "phone_id": self.state.device.phone_id,
78 | "user_id": user_id,
79 | "adid": self.state.device.adid,
80 | "guid": self.state.device.uuid,
81 | "device_id": self.state.device.id,
82 | "login_nonce": nonce,
83 | }
84 | return await self.std_http_post(
85 | "/api/v1/accounts/one_tap_app_login/", data=req, response_type=LoginResponse
86 | )
87 |
88 | async def send_two_factor_login_sms(
89 | self, username: str, identifier: str
90 | ) -> LoginErrorResponse:
91 | req = {
92 | "two_factor_identifier": identifier,
93 | "username": username,
94 | "guid": self.state.device.uuid,
95 | "device_id": self.state.device.id,
96 | }
97 | return await self.std_http_post(
98 | "/api/v1/accounts/send_two_factor_login_sms/",
99 | data=req,
100 | response_type=LoginErrorResponse,
101 | )
102 |
103 | async def two_factor_login(
104 | self,
105 | username: str,
106 | code: str,
107 | identifier: str,
108 | trust_device: bool = True,
109 | is_totp: bool = True,
110 | ) -> LoginResponse:
111 | req = {
112 | "verification_code": code,
113 | "two_factor_identifier": identifier,
114 | "username": username,
115 | "trust_this_device": "1" if trust_device else "0",
116 | "guid": self.state.device.uuid,
117 | "device_id": self.state.device.id,
118 | # TOTP = 3, Backup code = 2, SMS = 1
119 | "verification_method": "3" if is_totp else "1",
120 | }
121 | return await self.std_http_post(
122 | "/api/v1/accounts/two_factor_login/", data=req, response_type=LoginResponse
123 | )
124 |
125 | # async def two_factor_trusted_status(self, username: str, identifier: str, polling_nonce: str):
126 | # pass
127 |
128 | async def facebook_signup(self, fb_access_token: str) -> FacebookLoginResponse:
129 | req = {
130 | "jazoest": self._jazoest,
131 | "dryrun": "true",
132 | "fb_req_flag": "false",
133 | "phone_id": self.state.device.phone_id,
134 | "force_signup_with_fb_after_cp_claiming": "false",
135 | "adid": self.state.device.adid,
136 | "guid": self.state.device.uuid,
137 | "device_id": self.state.device.id,
138 | # "waterfall_id": uuid4(),
139 | "fb_access_token": fb_access_token,
140 | }
141 | return await self.std_http_post(
142 | "/api/v1/fb/facebook_signup/", data=req, response_type=FacebookLoginResponse
143 | )
144 |
145 | async def logout(self, one_tap_app_login: bool | None = None) -> LogoutResponse:
146 | req = {
147 | "guid": self.state.device.uuid,
148 | "phone_id": self.state.device.phone_id,
149 | "device_id": self.state.device.id,
150 | "_uuid": self.state.device.uuid,
151 | "one_tap_app_login": one_tap_app_login,
152 | }
153 | return await self.std_http_post(
154 | "/api/v1/accounts/logout/", data=req, response_type=LogoutResponse
155 | )
156 |
157 | async def change_password(self, old_password: str, new_password: str):
158 | return self.change_password_encrypted(
159 | old_password=self._encrypt_password(old_password),
160 | new_password1=self._encrypt_password(new_password),
161 | new_password2=self._encrypt_password(new_password),
162 | )
163 |
164 | async def change_password_encrypted(
165 | self, old_password: str, new_password1: str, new_password2: str
166 | ):
167 | req = {
168 | "_csrftoken": self.state.cookies.csrf_token,
169 | "_uid": self.state.session.ds_user_id,
170 | "_uuid": self.state.device.uuid,
171 | "enc_old_password": old_password,
172 | "enc_new_password1": new_password1,
173 | "enc_new_password2": new_password2,
174 | }
175 | # TODO parse response content
176 | return await self.std_http_post("/api/v1/accounts/change_password/", data=req)
177 |
178 | def _encrypt_password(self, password: str) -> str:
179 | # Key and IV for AES encryption
180 | rand_key = get_random_bytes(32)
181 | iv = get_random_bytes(12)
182 |
183 | # Encrypt AES key with Instagram's RSA public key
184 | pubkey_bytes = base64.b64decode(self.state.session.password_encryption_pubkey)
185 | pubkey = RSA.import_key(pubkey_bytes)
186 | cipher_rsa = PKCS1_v1_5.new(pubkey)
187 | encrypted_rand_key = cipher_rsa.encrypt(rand_key)
188 |
189 | cipher_aes = AES.new(rand_key, AES.MODE_GCM, nonce=iv)
190 | # Add the current time to the additional authenticated data (AAD) section
191 | current_time = int(time.time())
192 | cipher_aes.update(str(current_time).encode("utf-8"))
193 | # Encrypt the password and get the AES MAC auth tag
194 | encrypted_passwd, auth_tag = cipher_aes.encrypt_and_digest(password.encode("utf-8"))
195 |
196 | buf = io.BytesIO()
197 | # 1 is presumably the version
198 | buf.write(bytes([1, int(self.state.session.password_encryption_key_id)]))
199 | buf.write(iv)
200 | # Length of the encrypted AES key as a little-endian 16-bit int
201 | buf.write(struct.pack(" str:
210 | return f"2{sum(ord(i) for i in self.state.device.phone_id)}"
211 |
--------------------------------------------------------------------------------
/mauigpapi/http/upload.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | import random
19 | import time
20 |
21 | from yarl import URL
22 |
23 | from ..types import FacebookUploadResponse
24 | from .base import BaseAndroidAPI
25 |
26 |
27 | class UploadAPI(BaseAndroidAPI):
28 | rupload_fb = URL("https://rupload.facebook.com")
29 |
30 | def _make_rupload_headers(self, length: int, name: str, mime: str) -> dict[str, str]:
31 | return {
32 | **self._rupload_headers,
33 | "x-entity-length": str(length),
34 | "x-entity-name": name,
35 | "x-entity-type": mime,
36 | "offset": "0",
37 | "Content-Type": "application/octet-stream",
38 | "priority": "u=6, i",
39 | }
40 |
41 | async def upload(
42 | self,
43 | data: bytes,
44 | mimetype: str,
45 | upload_id: str | None = None,
46 | ) -> FacebookUploadResponse:
47 | upload_id = upload_id or str(int(time.time() * 1000))
48 | name = f"{upload_id}_0_{random.randint(1000000000, 9999999999)}"
49 | headers = self._make_rupload_headers(len(data), name, mimetype)
50 | if mimetype.startswith("image/"):
51 | path_type = "messenger_gif" if mimetype == "image/gif" else "messenger_image"
52 | headers["image_type"] = "FILE_ATTACHMENT"
53 | elif mimetype.startswith("video/"):
54 | path_type = "messenger_video"
55 | headers["video_type"] = "FILE_ATTACHMENT"
56 | elif mimetype.startswith("audio/"):
57 | path_type = "messenger_audio"
58 | headers["audio_type"] = "VOICE_MESSAGE"
59 | else:
60 | path_type = "messenger_file"
61 | headers["file_type"] = "FILE_ATTACHMENT"
62 | return await self.std_http_post(
63 | f"/{path_type}/{name}",
64 | url_override=self.rupload_fb,
65 | default_headers=False,
66 | headers=headers,
67 | data=data,
68 | raw=True,
69 | response_type=FacebookUploadResponse,
70 | )
71 |
--------------------------------------------------------------------------------
/mauigpapi/http/user.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from ..types import UserSearchResponse
17 | from .base import BaseAndroidAPI
18 |
19 |
20 | class UserAPI(BaseAndroidAPI):
21 | async def search_users(self, query: str, count: int = 30) -> UserSearchResponse:
22 | req = {
23 | "timezone_offset": self.state.device.timezone_offset,
24 | "q": query,
25 | "count": count,
26 | }
27 | return await self.std_http_get(
28 | "/api/v1/users/search/", query=req, response_type=UserSearchResponse
29 | )
30 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/__init__.py:
--------------------------------------------------------------------------------
1 | from .conn import AndroidMQTT
2 | from .events import Connect, Disconnect, NewSequenceID, ProxyUpdate
3 | from .subscription import GraphQLSubscription, SkywalkerSubscription
4 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/events.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from attr import dataclass
17 |
18 |
19 | @dataclass
20 | class Connect:
21 | pass
22 |
23 |
24 | @dataclass
25 | class Disconnect:
26 | reason: str
27 |
28 |
29 | @dataclass
30 | class NewSequenceID:
31 | seq_id: int
32 | snapshot_at_ms: int
33 |
34 |
35 | @dataclass
36 | class ProxyUpdate:
37 | pass
38 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/otclient.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | import struct
17 |
18 | import paho.mqtt.client
19 |
20 |
21 | class MQTToTClient(paho.mqtt.client.Client):
22 | # This is equivalent to the original _send_connect, except:
23 | # * the protocol ID is MQTToT.
24 | # * the client ID is sent without a length.
25 | # * all extra stuff like wills, usernames, passwords and MQTTv5 is removed.
26 | def _send_connect(self, keepalive):
27 | proto_ver = self._protocol
28 | protocol = b"MQTToT"
29 |
30 | remaining_length = 2 + len(protocol) + 1 + 1 + 2 + len(self._client_id)
31 |
32 | # Username, password, clean session
33 | connect_flags = 0x80 + 0x40 + 0x02
34 |
35 | command = paho.mqtt.client.CONNECT
36 | packet = bytearray()
37 | packet.append(command)
38 |
39 | self._pack_remaining_length(packet, remaining_length)
40 | packet.extend(
41 | struct.pack(
42 | f"!H{len(protocol)}sBBH",
43 | len(protocol),
44 | protocol,
45 | proto_ver,
46 | connect_flags,
47 | keepalive,
48 | )
49 | )
50 | packet.extend(self._client_id)
51 |
52 | self._keepalive = keepalive
53 | self._easy_log(
54 | paho.mqtt.client.MQTT_LOG_DEBUG,
55 | "Sending CONNECT",
56 | )
57 | return self._packet_queue(command, packet, 0, 0)
58 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/__init__.py:
--------------------------------------------------------------------------------
1 | from .autospec import autospec, field
2 | from .ig_objects import ForegroundStateConfig, IncomingMessage, RealtimeClientInfo, RealtimeConfig
3 | from .read import ThriftReader
4 | from .type import TType
5 | from .write import ThriftWriter
6 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/autospec.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Tuple, Union
17 |
18 | import attr
19 |
20 | from .type import TType
21 |
22 | TYPE_META = "fi.mau.instagram.thrift.type"
23 |
24 |
25 | def _get_type_class(typ):
26 | try:
27 | return typ.__origin__
28 | except AttributeError:
29 | return None
30 |
31 |
32 | Subtype = Union[None, TType, Tuple["Subtype", "Subtype"]]
33 |
34 |
35 | def _guess_type(python_type, name: str) -> Tuple[TType, Subtype]:
36 | if python_type == str or python_type == bytes:
37 | return TType.BINARY, None
38 | elif python_type == bool:
39 | return TType.BOOL, None
40 | elif python_type == int:
41 | raise ValueError(f"Ambiguous integer field {name}")
42 | elif python_type == float:
43 | return TType.DOUBLE, None
44 | elif attr.has(python_type):
45 | return TType.STRUCT, None
46 |
47 | type_class = _get_type_class(python_type)
48 | args = getattr(python_type, "__args__", None)
49 | if type_class == list:
50 | return TType.LIST, _guess_type(args[0], f"{name} item")
51 | elif type_class == dict:
52 | return TType.MAP, (
53 | _guess_type(args[0], f"{name} key"),
54 | _guess_type(args[1], f"{name} value"),
55 | )
56 | elif type_class == set:
57 | return TType.SET, _guess_type(args[0], f"{name} item")
58 |
59 | raise ValueError(f"Unknown type {python_type} for {name}")
60 |
61 |
62 | def autospec(clazz):
63 | """
64 | Automatically generate a thrift_spec dict based on attrs metadata.
65 |
66 | Args:
67 | clazz: The class to decorate.
68 |
69 | Returns:
70 | The class given as a parameter.
71 | """
72 | clazz.thrift_spec = {}
73 | index = 1
74 | for field in attr.fields(clazz):
75 | field_type, subtype = field.metadata.get(TYPE_META) or _guess_type(field.type, field.name)
76 | clazz.thrift_spec[index] = (field_type, field.name, subtype)
77 | index += 1
78 | return clazz
79 |
80 |
81 | def field(thrift_type: TType, subtype: Subtype = None, **kwargs) -> attr.Attribute:
82 | """
83 | Specify an explicit type for the :meth:`autospec` decorator.
84 |
85 | Args:
86 | thrift_type: The thrift type to use for the field.
87 | subtype: The subtype, for multi-part types like lists and maps.
88 | **kwargs: Other parameters to pass to :meth:`attr.ib`.
89 |
90 | Returns:
91 | The result of :meth:`attr.ib`
92 | """
93 | kwargs.setdefault("metadata", {})[TYPE_META] = (thrift_type, subtype)
94 | return attr.ib(**kwargs)
95 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/ig_objects.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Dict, List, Union
17 |
18 | from attr import dataclass
19 |
20 | from .autospec import autospec, field
21 | from .read import ThriftReader
22 | from .type import TType
23 | from .write import ThriftWriter
24 |
25 |
26 | @autospec
27 | @dataclass(kw_only=True)
28 | class RealtimeClientInfo:
29 | user_id: int = field(TType.I64)
30 | user_agent: str
31 | client_capabilities: int = field(TType.I64)
32 | endpoint_capabilities: int = field(TType.I64)
33 | publish_format: int = field(TType.I32)
34 | no_automatic_foreground: bool
35 | make_user_available_in_foreground: bool
36 | device_id: str
37 | is_initially_foreground: bool
38 | network_type: int = field(TType.I32)
39 | network_subtype: int = field(TType.I32)
40 | client_mqtt_session_id: int = field(TType.I64)
41 | client_ip_address: str = None
42 | subscribe_topics: List[int] = field(TType.LIST, TType.I32)
43 | client_type: str
44 | app_id: int = field(TType.I64)
45 | override_nectar_logging: bool = None
46 | connect_token_hash: str = None
47 | region_preference: str = None
48 | device_secret: str
49 | client_stack: int = field(TType.BYTE)
50 | fbns_connection_key: int = field(TType.I64, default=None)
51 | fbns_connection_secret: str = None
52 | fbns_device_id: str = None
53 | fbns_device_secret: str = None
54 | luid: int = field(TType.I64, default=None)
55 |
56 |
57 | @autospec
58 | @dataclass(kw_only=True)
59 | class RealtimeConfig:
60 | client_identifier: str
61 | will_topic: str = None
62 | will_message: str = None
63 | client_info: RealtimeClientInfo
64 | password: str
65 | get_diffs_request: List[str] = None
66 | zero_rating_token_hash: str = None
67 | app_specific_info: Dict[str, str] = None
68 |
69 | def to_thrift(self) -> bytes:
70 | buf = ThriftWriter()
71 | buf.write_struct(self)
72 | return buf.getvalue()
73 |
74 |
75 | @autospec
76 | @dataclass(kw_only=True)
77 | class ForegroundStateConfig:
78 | in_foreground_app: bool
79 | in_foreground_device: bool
80 | keep_alive_timeout: int = field(TType.I32)
81 | subscribe_topics: List[str]
82 | subscribe_generic_topics: List[str]
83 | unsubscribe_topics: List[str]
84 | unsubscribe_generic_topics: List[str]
85 | request_id: int = field(TType.I64)
86 |
87 | def to_thrift(self) -> bytes:
88 | buf = ThriftWriter()
89 | buf.write_struct(self)
90 | return buf.getvalue()
91 |
92 |
93 | @dataclass(kw_only=True)
94 | class IncomingMessage:
95 | topic: Union[str, int]
96 | payload: str
97 |
98 | @classmethod
99 | def from_thrift(cls, data: bytes) -> "IncomingMessage":
100 | buf = ThriftReader(data)
101 | topic_type = buf.read_field()
102 | if topic_type == TType.BINARY:
103 | topic = buf.read(buf.read_varint()).decode("utf-8")
104 | elif topic_type == TType.I32:
105 | topic = buf.read_small_int()
106 | else:
107 | raise ValueError(f"Unsupported topic type {topic_type}")
108 | payload_type = buf.read_field()
109 | if payload_type != TType.BINARY:
110 | raise ValueError(f"Unsupported payload type {topic_type}")
111 | payload = buf.read(buf.read_varint()).decode("utf-8")
112 | return cls(topic=topic, payload=payload)
113 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/read.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | import io
19 |
20 | from .type import TType
21 |
22 |
23 | class ThriftReader(io.BytesIO):
24 | prev_field_id: int
25 | stack: list[int]
26 |
27 | def __init__(self, *args, **kwargs) -> None:
28 | super().__init__(*args, **kwargs)
29 | self.prev_field_id = 0
30 | self.stack = []
31 |
32 | def _push_stack(self) -> None:
33 | self.stack.append(self.prev_field_id)
34 | self.prev_field_id = 0
35 |
36 | def _pop_stack(self) -> None:
37 | if self.stack:
38 | self.prev_field_id = self.stack.pop()
39 |
40 | def _read_byte(self, signed: bool = False) -> int:
41 | return int.from_bytes(self.read(1), "big", signed=signed)
42 |
43 | @staticmethod
44 | def _from_zigzag(val: int) -> int:
45 | return (val >> 1) ^ -(val & 1)
46 |
47 | def read_small_int(self) -> int:
48 | return self._from_zigzag(self.read_varint())
49 |
50 | def read_varint(self) -> int:
51 | shift = 0
52 | result = 0
53 | while True:
54 | byte = self._read_byte()
55 | result |= (byte & 0x7F) << shift
56 | if (byte & 0x80) == 0:
57 | break
58 | shift += 7
59 | return result
60 |
61 | def read_field(self) -> TType:
62 | byte = self._read_byte()
63 | if byte == 0:
64 | return TType.STOP
65 | delta = (byte & 0xF0) >> 4
66 | if delta == 0:
67 | self.prev_field_id = self._from_zigzag(self.read_varint())
68 | else:
69 | self.prev_field_id += delta
70 | return TType(byte & 0x0F)
71 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/type.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from enum import IntEnum
17 |
18 |
19 | class TType(IntEnum):
20 | STOP = 0
21 | TRUE = 1
22 | FALSE = 2
23 | BYTE = 3
24 | I16 = 4
25 | I32 = 5
26 | I64 = 6
27 | # DOUBLE = 7
28 | BINARY = 8
29 | STRING = 8
30 | LIST = 9
31 | SET = 10
32 | MAP = 11
33 | STRUCT = 12
34 |
35 | # internal
36 | BOOL = 0xA1
37 |
--------------------------------------------------------------------------------
/mauigpapi/mqtt/thrift/write.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import Any
19 | import io
20 |
21 | from .type import TType
22 |
23 |
24 | class ThriftWriter(io.BytesIO):
25 | prev_field_id: int
26 | stack: list[int]
27 |
28 | def __init__(self, *args, **kwargs) -> None:
29 | super().__init__(*args, **kwargs)
30 | self.prev_field_id = 0
31 | self.stack = []
32 |
33 | def _push_stack(self) -> None:
34 | self.stack.append(self.prev_field_id)
35 | self.prev_field_id = 0
36 |
37 | def _pop_stack(self) -> None:
38 | if self.stack:
39 | self.prev_field_id = self.stack.pop()
40 |
41 | def _write_byte(self, byte: int | TType) -> None:
42 | self.write(bytes([byte]))
43 |
44 | @staticmethod
45 | def _to_zigzag(val: int, bits: int) -> int:
46 | return (val << 1) ^ (val >> (bits - 1))
47 |
48 | def _write_varint(self, val: int) -> None:
49 | while True:
50 | byte = val & ~0x7F
51 | if byte == 0:
52 | self._write_byte(val)
53 | break
54 | elif byte == -128:
55 | self._write_byte(0)
56 | break
57 | else:
58 | self._write_byte((val & 0xFF) | 0x80)
59 | val = val >> 7
60 |
61 | def _write_word(self, val: int) -> None:
62 | self._write_varint(self._to_zigzag(val, 16))
63 |
64 | def _write_int(self, val: int) -> None:
65 | self._write_varint(self._to_zigzag(val, 32))
66 |
67 | def _write_long(self, val: int) -> None:
68 | self._write_varint(self._to_zigzag(val, 64))
69 |
70 | def write_field_begin(self, field_id: int, ttype: TType) -> None:
71 | ttype_val = ttype.value
72 | delta = field_id - self.prev_field_id
73 | if 0 < delta < 16:
74 | self._write_byte((delta << 4) | ttype_val)
75 | else:
76 | self._write_byte(ttype_val)
77 | self._write_word(field_id)
78 | self.prev_field_id = field_id
79 |
80 | def write_map(
81 | self, field_id: int, key_type: TType, value_type: TType, val: dict[Any, Any]
82 | ) -> None:
83 | self.write_field_begin(field_id, TType.MAP)
84 | if not map:
85 | self._write_byte(0)
86 | return
87 | self._write_varint(len(val))
88 | self._write_byte(((key_type.value & 0xF) << 4) | (value_type.value & 0xF))
89 | for key, value in val.items():
90 | self.write_val(None, key_type, key)
91 | self.write_val(None, value_type, value)
92 |
93 | def write_string_direct(self, val: str | bytes) -> None:
94 | if isinstance(val, str):
95 | val = val.encode("utf-8")
96 | self._write_varint(len(val))
97 | self.write(val)
98 |
99 | def write_stop(self) -> None:
100 | self._write_byte(TType.STOP.value)
101 | self._pop_stack()
102 |
103 | def write_int8(self, field_id: int, val: int) -> None:
104 | self.write_field_begin(field_id, TType.BYTE)
105 | self._write_byte(val)
106 |
107 | def write_int16(self, field_id: int, val: int) -> None:
108 | self.write_field_begin(field_id, TType.I16)
109 | self._write_word(val)
110 |
111 | def write_int32(self, field_id: int, val: int) -> None:
112 | self.write_field_begin(field_id, TType.I32)
113 | self._write_int(val)
114 |
115 | def write_int64(self, field_id: int, val: int) -> None:
116 | self.write_field_begin(field_id, TType.I64)
117 | self._write_long(val)
118 |
119 | def write_list(self, field_id: int, item_type: TType, val: list[Any]) -> None:
120 | self.write_field_begin(field_id, TType.LIST)
121 | if len(val) < 0x0F:
122 | self._write_byte((len(val) << 4) | item_type.value)
123 | else:
124 | self._write_byte(0xF0 | item_type.value)
125 | self._write_varint(len(val))
126 | for item in val:
127 | self.write_val(None, item_type, item)
128 |
129 | def write_struct_begin(self, field_id: int) -> None:
130 | self.write_field_begin(field_id, TType.STRUCT)
131 | self._push_stack()
132 |
133 | def write_val(self, field_id: int | None, ttype: TType, val: Any) -> None:
134 | if ttype == TType.BOOL:
135 | if field_id is None:
136 | raise ValueError("booleans can only be in structs")
137 | self.write_field_begin(field_id, TType.TRUE if val else TType.FALSE)
138 | return
139 | if field_id is not None:
140 | self.write_field_begin(field_id, ttype)
141 | if ttype == TType.BYTE:
142 | self._write_byte(val)
143 | elif ttype == TType.I16:
144 | self._write_word(val)
145 | elif ttype == TType.I32:
146 | self._write_int(val)
147 | elif ttype == TType.I64:
148 | self._write_long(val)
149 | elif ttype == TType.BINARY:
150 | self.write_string_direct(val)
151 | else:
152 | raise ValueError(f"{ttype} is not supported by write_val()")
153 |
154 | def write_struct(self, obj: Any) -> None:
155 | for field_id in iter(obj.thrift_spec):
156 | field_type, field_name, inner_type = obj.thrift_spec[field_id]
157 |
158 | val = getattr(obj, field_name, None)
159 | if val is None:
160 | continue
161 |
162 | start = len(self.getvalue())
163 | if field_type in (
164 | TType.BOOL,
165 | TType.BYTE,
166 | TType.I16,
167 | TType.I32,
168 | TType.I64,
169 | TType.BINARY,
170 | ):
171 | self.write_val(field_id, field_type, val)
172 | elif field_type in (TType.LIST, TType.SET):
173 | self.write_list(field_id, inner_type, val)
174 | elif field_type == TType.MAP:
175 | (key_type, _), (value_type, _) = inner_type
176 | self.write_map(field_id, key_type, value_type, val)
177 | elif field_type == TType.STRUCT:
178 | self.write_struct_begin(field_id)
179 | self.write_struct(val)
180 | self.write_stop()
181 |
--------------------------------------------------------------------------------
/mauigpapi/scripts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mautrix/instagram/d3a3a100ddda25c0a68fc72281bd8c68e0705ac1/mauigpapi/scripts/__init__.py
--------------------------------------------------------------------------------
/mauigpapi/scripts/iglogin.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import base64
3 | import datetime
4 | import getpass
5 | import logging
6 | import zlib
7 |
8 | from mauigpapi import AndroidAPI, AndroidState
9 | from mauigpapi.errors import IGChallengeError, IGLoginTwoFactorRequiredError
10 | from mautrix.util.logging import TraceLogger
11 |
12 |
13 | async def main():
14 | logging.basicConfig(level=logging.DEBUG)
15 | username = password = twofactor = None
16 | while not username:
17 | username = input("Username: ").strip()
18 | state = AndroidState()
19 | state.device.generate(username + str(datetime.date.today()))
20 | api_log: TraceLogger = logging.getLogger("api")
21 | api = AndroidAPI(state, log=api_log)
22 | try:
23 | print("Getting mobile config...")
24 | await api.get_mobile_config()
25 | while not password:
26 | password = getpass.getpass("Password: ")
27 | try:
28 | try:
29 | try:
30 | print("Logging in...")
31 | await api.login(username, password)
32 | except IGLoginTwoFactorRequiredError as e:
33 | print(e)
34 | print("Enter `r` to re-request SMS")
35 | inf = e.body.two_factor_info
36 | while not twofactor:
37 | twofactor = input("2FA code: ").lower().strip()
38 | if twofactor == "r":
39 | if inf.sms_two_factor_on:
40 | print("Re-requesting SMS code...")
41 | resp = await api.send_two_factor_login_sms(
42 | username, identifier=inf.two_factor_identifier
43 | )
44 | print("SMS code re-requested")
45 | inf = resp.two_factor_info
46 | inf.totp_two_factor_on = False
47 | else:
48 | print("You don't have SMS 2FA on 🤔")
49 | twofactor = None
50 | print("Sending 2FA code...")
51 | await api.two_factor_login(
52 | username,
53 | code=twofactor,
54 | identifier=inf.two_factor_identifier,
55 | is_totp=inf.totp_two_factor_on,
56 | )
57 | print("Fetching current user...")
58 | user = await api.current_user()
59 | except IGChallengeError as e:
60 | print(e)
61 | print("Resetting challenge...")
62 | await api.challenge_auto(reset=True)
63 | print("Fetching current user...")
64 | user = await api.current_user()
65 | except Exception as e:
66 | print("💥", e)
67 | return
68 | if not user or not user.user:
69 | print("Login failed?")
70 | return
71 | print(f"Logged in as @{user.user.username}")
72 | print()
73 | print(
74 | base64.b64encode(zlib.compress(state.json().encode("utf-8"), level=9)).decode("utf-8")
75 | )
76 | print()
77 | finally:
78 | await api.http.close()
79 |
80 |
81 | if __name__ == "__main__":
82 | asyncio.run(main())
83 |
--------------------------------------------------------------------------------
/mauigpapi/state/__init__.py:
--------------------------------------------------------------------------------
1 | from .state import AndroidState
2 |
--------------------------------------------------------------------------------
/mauigpapi/state/application.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from attr import dataclass
17 |
18 | from mautrix.types import SerializableAttrs
19 |
20 |
21 | @dataclass
22 | class AndroidApplication(SerializableAttrs):
23 | APP_VERSION: str = "256.0.0.18.105"
24 | APP_VERSION_CODE: str = "407842973"
25 | FACEBOOK_ANALYTICS_APPLICATION_ID: str = "567067343352427"
26 |
27 | BLOKS_VERSION_ID: str = "0928297a84f74885ff39fc1628f8a40da3ef1c467555d555bfd9f8fe1aaacafe"
28 | CAPABILITIES: str = "3brTv10="
29 |
--------------------------------------------------------------------------------
/mauigpapi/state/cookies.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from http.cookies import Morsel, SimpleCookie
19 |
20 | from aiohttp import CookieJar
21 | from yarl import URL
22 |
23 | from mautrix.types import JSON, Serializable
24 |
25 | from ..errors import IGCookieNotFoundError
26 |
27 | ig_url = URL("https://instagram.com")
28 |
29 |
30 | class Cookies(Serializable):
31 | jar: CookieJar
32 |
33 | def __init__(self) -> None:
34 | self.jar = CookieJar()
35 |
36 | def serialize(self) -> JSON:
37 | return {
38 | morsel.key: {
39 | **{k: v for k, v in morsel.items() if v},
40 | "value": morsel.value,
41 | }
42 | for morsel in self.jar
43 | }
44 |
45 | @classmethod
46 | def deserialize(cls, raw: JSON) -> Cookies:
47 | cookie = SimpleCookie()
48 | for key, data in raw.items():
49 | cookie[key] = data.pop("value")
50 | cookie[key].update(data)
51 | cookies = cls()
52 | cookies.jar.update_cookies(cookie, ig_url)
53 | return cookies
54 |
55 | @property
56 | def csrf_token(self) -> str:
57 | try:
58 | return self["csrftoken"]
59 | except IGCookieNotFoundError:
60 | return "missing"
61 |
62 | def get(self, key: str) -> Morsel:
63 | filtered = self.jar.filter_cookies(ig_url)
64 | return filtered.get(key)
65 |
66 | def get_value(self, key: str) -> str | None:
67 | cookie = self.get(key)
68 | return cookie.value if cookie else None
69 |
70 | def __getitem__(self, key: str) -> str:
71 | cookie = self.get(key)
72 | if not cookie:
73 | raise IGCookieNotFoundError(key)
74 | return cookie.value
75 |
--------------------------------------------------------------------------------
/mauigpapi/state/device.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional, Union
17 | from uuid import UUID
18 | import random
19 | import string
20 | import time
21 |
22 | from attr import dataclass
23 | import attr
24 |
25 | from mautrix.types import SerializableAttrs
26 |
27 |
28 | @dataclass
29 | class AndroidDevice(SerializableAttrs):
30 | id: Optional[str] = None
31 | descriptor: Optional[str] = None
32 | uuid: Optional[str] = None
33 | fdid: Optional[str] = None
34 | phone_id: Optional[str] = attr.ib(default=None, metadata={"json": "phoneId"})
35 | # Google Play advertising ID
36 | adid: Optional[str] = None
37 |
38 | language: str = "en_US"
39 | radio_type: str = "wifi-none"
40 | connection_type: str = "WIFI"
41 | timezone_offset: str = str(-time.timezone)
42 | is_layout_rtl: bool = False
43 |
44 | @property
45 | def battery_level(self) -> int:
46 | rand = random.Random(self.id)
47 | percent_time = rand.randint(200, 600)
48 | return 100 - round(time.time() / percent_time) % 100
49 |
50 | @property
51 | def is_charging(self) -> bool:
52 | rand = random.Random(f"{self.id}{round(time.time() / 10800)}")
53 | return rand.choice([True, False])
54 |
55 | @property
56 | def payload(self) -> dict:
57 | device_parts = self.descriptor.split(";")
58 | android_version, android_release, *_ = device_parts[0].split("/")
59 | manufacturer, *_ = device_parts[3].split("/")
60 | model = device_parts[4]
61 | return {
62 | "android_version": android_version,
63 | "android_release": android_release,
64 | "manufacturer": manufacturer,
65 | "model": model,
66 | }
67 |
68 | def generate(self, seed: Union[str, bytes]) -> None:
69 | rand = random.Random(seed)
70 | self.phone_id = str(UUID(int=rand.getrandbits(128), version=4))
71 | self.adid = str(UUID(int=rand.getrandbits(128), version=4))
72 | self.id = f"android-{''.join(rand.choices(string.hexdigits, k=16))}".lower()
73 | self.descriptor = "33/13; 420dpi; 1080x2219; Google/google; Pixel 6; oriole; oriole"
74 | # "33/13; 560dpi; 1440x2934; Google/google; Pixel 6 Pro; raven; raven",
75 | self.uuid = str(UUID(int=rand.getrandbits(128), version=4))
76 | self.fdid = str(UUID(int=rand.getrandbits(128), version=4))
77 |
--------------------------------------------------------------------------------
/mauigpapi/state/session.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional, Union
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 |
23 | @dataclass
24 | class AndroidSession(SerializableAttrs):
25 | ads_opt_out: bool = False
26 |
27 | ig_www_claim: Optional[str] = None
28 | authorization: Optional[str] = None
29 | password_encryption_pubkey: Optional[str] = None
30 | password_encryption_key_id: Union[None, str, int] = None
31 | region_hint: Optional[str] = None
32 |
33 | shbid: Optional[str] = None
34 | shbts: Optional[str] = None
35 | ds_user_id: Optional[str] = None
36 | rur: Optional[str] = None
37 |
--------------------------------------------------------------------------------
/mauigpapi/state/state.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2021 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional
17 | from uuid import uuid4
18 | import random
19 | import time
20 |
21 | from attr import dataclass
22 |
23 | from mautrix.types import SerializableAttrs, field
24 |
25 | from ..errors import IGNoChallengeError, IGUserIDNotFoundError
26 | from ..types import ChallengeContext, ChallengeStateResponse
27 | from .application import AndroidApplication
28 | from .cookies import Cookies
29 | from .device import AndroidDevice
30 | from .session import AndroidSession
31 |
32 |
33 | @dataclass
34 | class AndroidState(SerializableAttrs):
35 | device: AndroidDevice = field(factory=lambda: AndroidDevice())
36 | session: AndroidSession = field(factory=lambda: AndroidSession())
37 | application: AndroidApplication = field(factory=lambda: AndroidApplication(), hidden=True)
38 | challenge: Optional[ChallengeStateResponse] = None
39 | challenge_context: Optional[ChallengeContext] = None
40 | _challenge_path: Optional[str] = field(default=None, json="challenge_path")
41 | cookies: Cookies = field(factory=lambda: Cookies())
42 | login_2fa_username: Optional[str] = field(default=None, hidden=True)
43 | _pigeon_session_id: Optional[str] = field(default=None, hidden=True)
44 |
45 | def __attrs_post_init__(self) -> None:
46 | if self.application.APP_VERSION_CODE != AndroidApplication().APP_VERSION_CODE:
47 | self.application = AndroidApplication()
48 |
49 | @property
50 | def pigeon_session_id(self) -> str:
51 | if not self._pigeon_session_id:
52 | self._pigeon_session_id = str(uuid4())
53 | return self._pigeon_session_id
54 |
55 | def reset_pigeon_session_id(self) -> None:
56 | self._pigeon_session_id = None
57 |
58 | @property
59 | def user_agent(self) -> str:
60 | return (
61 | f"Instagram {self.application.APP_VERSION} Android ({self.device.descriptor}; "
62 | f"{self.device.language}; {self.application.APP_VERSION_CODE})"
63 | )
64 |
65 | @property
66 | def user_id(self) -> str:
67 | if self.session.ds_user_id:
68 | return self.session.ds_user_id
69 | elif self.challenge and self.challenge.user_id:
70 | return str(self.challenge.user_id)
71 | else:
72 | raise IGUserIDNotFoundError()
73 |
74 | @property
75 | def challenge_path(self) -> str:
76 | if not self._challenge_path:
77 | raise IGNoChallengeError()
78 | return self._challenge_path
79 |
80 | @challenge_path.setter
81 | def challenge_path(self, val: str) -> None:
82 | self._challenge_path = val
83 |
84 | @staticmethod
85 | def gen_client_context() -> str:
86 | return str((int(time.time() * 1000) << 22) + random.randint(10000, 5000000))
87 |
--------------------------------------------------------------------------------
/mauigpapi/types/__init__.py:
--------------------------------------------------------------------------------
1 | from .account import (
2 | BaseFullResponseUser,
3 | BaseResponseUser,
4 | CurrentUser,
5 | CurrentUserResponse,
6 | EntityText,
7 | FriendshipStatus,
8 | HDProfilePictureVersion,
9 | ProfileEditParams,
10 | UserIdentifier,
11 | )
12 | from .challenge import ChallengeContext, ChallengeStateData, ChallengeStateResponse
13 | from .direct_inbox import DMInbox, DMInboxCursor, DMInboxResponse, DMThreadResponse
14 | from .error import (
15 | ChallengeData,
16 | ChallengeResponse,
17 | CheckpointResponse,
18 | ConsentRequiredResponse,
19 | LoginErrorResponse,
20 | LoginErrorResponseButton,
21 | LoginPhoneVerificationSettings,
22 | LoginRequiredResponse,
23 | LoginTwoFactorInfo,
24 | SpamResponse,
25 | )
26 | from .login import (
27 | FacebookLoginResponse,
28 | LoginResponse,
29 | LoginResponseNametag,
30 | LoginResponseUser,
31 | LogoutResponse,
32 | )
33 | from .mqtt import (
34 | ActivityIndicatorData,
35 | AppPresenceEvent,
36 | AppPresenceEventPayload,
37 | ClientConfigUpdateEvent,
38 | ClientConfigUpdatePayload,
39 | CommandResponse,
40 | CommandResponsePayload,
41 | IrisPayload,
42 | IrisPayloadData,
43 | LiveVideoComment,
44 | LiveVideoCommentEvent,
45 | LiveVideoCommentPayload,
46 | LiveVideoSystemComment,
47 | MessageSyncEvent,
48 | MessageSyncMessage,
49 | Operation,
50 | PubsubBasePayload,
51 | PubsubEvent,
52 | PubsubPayload,
53 | PubsubPayloadData,
54 | PubsubPublishMetadata,
55 | ReactionStatus,
56 | RealtimeDirectData,
57 | RealtimeDirectEvent,
58 | RealtimeZeroProvisionPayload,
59 | ThreadAction,
60 | ThreadRemoveEvent,
61 | ThreadSyncEvent,
62 | TypingStatus,
63 | ZeroProductProvisioningEvent,
64 | )
65 | from .qe import AndroidExperiment, QeSyncExperiment, QeSyncExperimentParam, QeSyncResponse
66 | from .thread import Thread, ThreadTheme, ThreadUser, ThreadUserLastSeenAt
67 | from .thread_item import (
68 | AnimatedMediaImage,
69 | AnimatedMediaImages,
70 | AnimatedMediaItem,
71 | AudioInfo,
72 | Caption,
73 | CreateModeAttribution,
74 | CreativeConfig,
75 | ExpiredMediaItem,
76 | FetchedClipInfo,
77 | ImageVersion,
78 | ImageVersions,
79 | LinkContext,
80 | LinkItem,
81 | Location,
82 | MediaShareItem,
83 | MediaType,
84 | Reaction,
85 | Reactions,
86 | ReactionType,
87 | ReelMediaShareItem,
88 | ReelShareItem,
89 | ReelShareReactionInfo,
90 | ReelShareType,
91 | RegularMediaItem,
92 | ReplayableMediaItem,
93 | SharingFrictionInfo,
94 | ThreadImage,
95 | ThreadItem,
96 | ThreadItemActionLog,
97 | ThreadItemType,
98 | VideoVersion,
99 | ViewMode,
100 | VisualMedia,
101 | VoiceMediaData,
102 | VoiceMediaItem,
103 | XMAMediaShareItem,
104 | )
105 | from .upload import FacebookUploadResponse
106 | from .user import SearchResultUser, UserSearchResponse
107 |
--------------------------------------------------------------------------------
/mauigpapi/types/account.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, Dict, List, Optional
17 |
18 | from attr import dataclass
19 | import attr
20 |
21 | from mautrix.types import SerializableAttrs, SerializableEnum
22 |
23 |
24 | @dataclass(kw_only=True)
25 | class FriendshipStatus(SerializableAttrs):
26 | following: bool
27 | is_bestie: bool
28 | is_restricted: bool
29 | blocking: Optional[bool] = None
30 | incoming_request: Optional[bool] = None
31 | outgoing_request: Optional[bool] = None
32 | is_private: Optional[bool] = None
33 |
34 |
35 | @dataclass(kw_only=True)
36 | class UserIdentifier(SerializableAttrs):
37 | pk: int
38 | username: str
39 |
40 |
41 | @dataclass(kw_only=True)
42 | class BaseResponseUser(UserIdentifier, SerializableAttrs):
43 | full_name: Optional[str] = None
44 | is_private: bool = False
45 | is_verified: bool = False
46 | profile_pic_url: str
47 | profile_pic_id: Optional[str] = None
48 | has_anonymous_profile_picture: bool = False
49 | # TODO find type
50 | account_badges: Optional[List[Any]] = None
51 |
52 | # TODO enum? only present for self
53 | reel_auto_archive: Optional[str] = None
54 | # Only present for not-self
55 | # friendship_status: Optional[FriendshipStatus] = None
56 | # Not exactly sure when this is present
57 | latest_reel_media: Optional[int] = None
58 | has_highlight_reels: bool = False
59 | follow_friction_type: Optional[int] = None
60 |
61 |
62 | @dataclass(kw_only=True)
63 | class BaseFullResponseUser(BaseResponseUser, SerializableAttrs):
64 | phone_number: str
65 | country_code: Optional[int] = None
66 | national_number: Optional[int] = None
67 |
68 | # TODO enum?
69 | allowed_commenter_type: Optional[str] = None
70 |
71 | # These are at least in login and current_user, might not be in other places though
72 | # is_business: bool
73 | # TODO enum?
74 | # account_type: int
75 |
76 |
77 | @dataclass
78 | class EntityText(SerializableAttrs):
79 | raw_text: str
80 | # TODO figure out type
81 | entities: List[Any]
82 |
83 |
84 | @dataclass
85 | class HDProfilePictureVersion(SerializableAttrs):
86 | url: str
87 | width: int
88 | height: int
89 |
90 |
91 | @dataclass
92 | class ProfileEditParams(SerializableAttrs):
93 | should_show_confirmation_dialog: bool
94 | is_pending_review: bool
95 | confirmation_dialog_text: str
96 | disclaimer_text: str
97 |
98 |
99 | class Gender(SerializableEnum):
100 | MALE = 1
101 | FEMALE = 2
102 | UNSET = 3
103 | CUSTOM = 4
104 |
105 |
106 | @dataclass(kw_only=True)
107 | class CurrentUser(BaseFullResponseUser, SerializableAttrs):
108 | biography: Optional[str] = None
109 | can_link_entities_in_bio: bool = True
110 | biography_with_entities: Optional[EntityText] = None
111 | external_url: Optional[str] = None
112 | has_biography_translation: bool = False
113 | hd_profile_pic_versions: List[HDProfilePictureVersion] = attr.ib(factory=lambda: [])
114 | show_conversion_edit_entry: bool = True
115 | birthday: Optional[str] = None
116 | gender: Gender = Gender.UNSET
117 | custom_gender: Optional[str] = None
118 | email: Optional[str] = None
119 | profile_edit_params: Dict[str, ProfileEditParams] = attr.ib(factory=lambda: {})
120 |
121 |
122 | @dataclass
123 | class CurrentUserResponse(SerializableAttrs):
124 | status: str
125 | user: CurrentUser
126 |
--------------------------------------------------------------------------------
/mauigpapi/types/challenge.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional, Union
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs, field
21 |
22 | from .login import LoginResponseUser
23 |
24 |
25 | @dataclass
26 | class ChallengeStateData(SerializableAttrs):
27 | # Only for reset step
28 | choice: Optional[str] = None
29 | fb_access_token: Optional[str] = None
30 | big_blue_token: Optional[str] = None
31 | google_oauth_token: Optional[str] = None
32 | email: Optional[str] = None
33 |
34 | # Only for verify email step
35 | security_code: Optional[str] = None
36 | resend_delay: Optional[int] = None
37 | contact_point: Optional[str] = None
38 | form_type: Optional[str] = None
39 |
40 |
41 | @dataclass
42 | class ChallengeContext(SerializableAttrs):
43 | step_name: Optional[str] = None
44 | nonce_code: Optional[str] = None
45 | challenge_type_enum: Optional[str] = None
46 | cni: Optional[int] = None
47 | user_id: Optional[Union[str, int]] = None
48 | is_stateless: bool = False
49 | present_as_modal: bool = False
50 |
51 |
52 | @dataclass(kw_only=True)
53 | class ChallengeStateResponse(SerializableAttrs):
54 | # TODO enum?
55 | step_name: Optional[str] = None
56 | step_data: Optional[ChallengeStateData] = None
57 | logged_in_user: Optional[LoginResponseUser] = None
58 | user_id: Optional[int] = None
59 | nonce_code: Optional[str] = None
60 | # TODO enum?
61 | action: Optional[str] = None
62 | status: str
63 |
64 | flow_render_type: Optional[int] = None
65 | bloks_action: Optional[str] = None
66 | challenge_context_str: Optional[str] = field(default=None, json="challenge_context")
67 | challenge_type_enum_str: Optional[str] = None
68 |
69 | @property
70 | def challenge_context(self) -> ChallengeContext:
71 | return ChallengeContext.parse_json(self.challenge_context_str)
72 |
--------------------------------------------------------------------------------
/mauigpapi/types/direct_inbox.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, List, Optional
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 | from .thread import Thread, ThreadUser
23 |
24 |
25 | @dataclass
26 | class DMInboxCursor(SerializableAttrs):
27 | cursor_timestamp_seconds: int
28 | cursor_thread_v2_id: int
29 |
30 |
31 | @dataclass
32 | class DMInbox(SerializableAttrs):
33 | threads: List[Thread]
34 | has_older: bool
35 | unseen_count: int
36 | unseen_count_ts: int
37 | blended_inbox_enabled: bool
38 | prev_cursor: Optional[DMInboxCursor] = None
39 | next_cursor: Optional[DMInboxCursor] = None
40 | newest_cursor: Optional[str] = None
41 | oldest_cursor: Optional[str] = None
42 |
43 |
44 | @dataclass
45 | class DMInboxResponse(SerializableAttrs):
46 | status: str
47 | pending_requests_total: int
48 | has_pending_top_requests: bool
49 | seq_id: Optional[int] = None
50 | snapshot_at_ms: Optional[int] = None
51 | viewer: Optional[ThreadUser] = None
52 | inbox: Optional[DMInbox] = None
53 | # TODO type
54 | most_recent_inviter: Any = None
55 |
56 |
57 | @dataclass
58 | class DMThreadResponse(SerializableAttrs):
59 | thread: Thread
60 | status: str
61 |
--------------------------------------------------------------------------------
/mauigpapi/types/error.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import List, Optional
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 |
23 | @dataclass
24 | class SpamResponse(SerializableAttrs):
25 | feedback_title: str
26 | feedback_message: str
27 | feedback_url: str
28 | feedback_appeal_label: str
29 | feedback_ignore_label: str
30 | feedback_action: str
31 | message: str = "feedback_required"
32 | spam: bool = True
33 | status: str = "fail"
34 | error_type: Optional[str] = None
35 |
36 |
37 | @dataclass
38 | class ChallengeData(SerializableAttrs):
39 | url: str
40 | api_path: str
41 | hide_webview_header: bool
42 | lock: bool
43 | logout: bool
44 | native_flow: bool
45 | challenge_context: Optional[str] = None
46 |
47 |
48 | @dataclass
49 | class ChallengeResponse(SerializableAttrs):
50 | message: str # challenge_required
51 | status: str # fail
52 | challenge: ChallengeData
53 | error_type: Optional[str] = None
54 |
55 |
56 | @dataclass
57 | class CheckpointResponse(SerializableAttrs):
58 | message: str # checkpoint_required
59 | status: str # fail
60 | checkpoint_url: Optional[str] = None
61 | lock: bool = False
62 | flow_render_type: int = 0
63 |
64 |
65 | @dataclass
66 | class ConsentData(SerializableAttrs):
67 | headline: str
68 | content: str
69 | button_text: str
70 |
71 |
72 | @dataclass
73 | class ConsentRequiredResponse(SerializableAttrs):
74 | message: str # consent_required
75 | status: str # fail
76 | consent_data: ConsentData
77 |
78 |
79 | @dataclass
80 | class LoginRequiredResponse(SerializableAttrs):
81 | # TODO enum?
82 | logout_reason: int
83 | message: str # login_required or user_has_logged_out
84 | status: str # fail
85 | error_title: Optional[str] = None
86 | error_body: Optional[str] = None
87 |
88 |
89 | @dataclass
90 | class LoginErrorResponseButton(SerializableAttrs):
91 | title: str
92 | action: str
93 | username: Optional[str] = None
94 |
95 |
96 | @dataclass
97 | class LoginPhoneVerificationSettings(SerializableAttrs):
98 | max_sms_count: int
99 | resend_sms_delay_sec: int
100 | robocall_count_down_time_sec: int
101 | robocall_after_max_sms: bool
102 |
103 |
104 | @dataclass
105 | class LoginTwoFactorInfo(SerializableAttrs):
106 | username: str
107 | sms_two_factor_on: bool
108 | totp_two_factor_on: bool
109 | obfuscated_phone_number: str
110 | two_factor_identifier: str
111 | show_messenger_code_option: bool
112 | show_new_login_screen: bool
113 | should_opt_in_trusted_device_option: Optional[bool] = None
114 | pending_trusted_notification: Optional[bool] = None
115 | show_trusted_device_option: Optional[bool] = None
116 | # TODO type
117 | # sms_not_allowed_reason: Any
118 | pk: Optional[int] = None
119 | phone_verification_settings: Optional[LoginPhoneVerificationSettings] = None
120 | trusted_notification_polling_nonce: Optional[str] = None
121 |
122 |
123 | @dataclass
124 | class LoginErrorResponse(SerializableAttrs):
125 | status: str
126 | message: Optional[str] = None
127 | error_type: Optional[str] = None
128 | error_title: Optional[str] = None
129 | buttons: Optional[List[LoginErrorResponseButton]] = None
130 | invalid_credentials: Optional[bool] = None
131 | two_factor_required: Optional[bool] = None
132 | two_factor_info: Optional[LoginTwoFactorInfo] = None
133 | phone_verification_settings: Optional[LoginPhoneVerificationSettings] = None
134 |
135 | # FB login error fields
136 | account_created: Optional[bool] = None
137 | dryrun_passed: Optional[bool] = None
138 | username: Optional[str] = None
139 |
--------------------------------------------------------------------------------
/mauigpapi/types/login.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Optional
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 | from .account import BaseFullResponseUser
23 |
24 |
25 | @dataclass
26 | class LoginResponseNametag(SerializableAttrs):
27 | mode: Optional[int] = None
28 | emoji: Optional[str] = None
29 | emoji_color: Optional[str] = None
30 | selfie_sticker: Optional[str] = None
31 | gradient: Optional[str] = None
32 |
33 |
34 | @dataclass
35 | class LoginResponseUser(BaseFullResponseUser, SerializableAttrs):
36 | can_boost_post: bool
37 | can_see_organic_insights: bool
38 | show_insights_terms: bool
39 | nametag: LoginResponseNametag
40 | allow_contacts_sync: bool
41 |
42 | total_igtv_videos: int
43 | interop_messaging_user_fbid: int
44 | is_using_unified_inbox_for_direct: bool
45 |
46 |
47 | @dataclass
48 | class LoginResponse(SerializableAttrs):
49 | status: str
50 | logged_in_user: Optional[LoginResponseUser] = None
51 |
52 |
53 | @dataclass
54 | class FacebookLoginResponse(LoginResponse, SerializableAttrs):
55 | code: int = 0
56 | fb_access_token: Optional[str] = None
57 | fb_user_id: Optional[str] = None
58 | session_flush_nonce: Optional[str] = None
59 | account_created: bool = True
60 |
61 |
62 | @dataclass
63 | class LogoutResponse(SerializableAttrs):
64 | status: str
65 | login_nonce: Optional[str] = None
66 |
--------------------------------------------------------------------------------
/mauigpapi/types/mqtt.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, List, Optional, Union
17 | import json
18 |
19 | from attr import dataclass
20 | import attr
21 |
22 | from mautrix.types import JSON, SerializableAttrs, SerializableEnum
23 |
24 | from .account import BaseResponseUser
25 | from .thread import Thread
26 | from .thread_item import ThreadItem
27 |
28 |
29 | class Operation(SerializableEnum):
30 | ADD = "add"
31 | REPLACE = "replace"
32 | REMOVE = "remove"
33 |
34 |
35 | class ThreadAction(SerializableEnum):
36 | SEND_ITEM = "send_item"
37 | PROFILE = "profile"
38 | MARK_SEEN = "mark_seen"
39 | MARK_VISUAL_ITEM_SEEN = "mark_visual_item_seen"
40 | INDICATE_ACTIVITY = "indicate_activity"
41 |
42 |
43 | class ReactionStatus(SerializableEnum):
44 | CREATED = "created"
45 | DELETED = "deleted"
46 |
47 |
48 | class TypingStatus(SerializableEnum):
49 | OFF = 0
50 | TEXT = 1
51 | VISUAL = 2
52 |
53 |
54 | @dataclass(kw_only=True)
55 | class CommandResponsePayload(SerializableAttrs):
56 | client_context: Optional[str] = None
57 | item_id: Optional[str] = None
58 | timestamp: Optional[str] = None
59 | thread_id: Optional[str] = None
60 | message: Optional[str] = None
61 |
62 |
63 | @dataclass(kw_only=True)
64 | class CommandResponse(SerializableAttrs):
65 | action: Optional[str] = None
66 | status: str
67 | status_code: Optional[str] = None
68 | message: Optional[str] = None
69 | payload: Optional[CommandResponsePayload] = None
70 | exception: Optional[str] = None
71 | content: Optional[str] = None
72 |
73 | @property
74 | def error_message(self) -> Optional[str]:
75 | if self.payload and self.payload.message:
76 | return self.payload.message
77 | elif self.message:
78 | return self.message
79 | elif self.exception:
80 | if self.content:
81 | return f"{self.exception}: {self.content}"
82 | return self.exception
83 | else:
84 | return "unknown response data"
85 |
86 |
87 | @dataclass(kw_only=True)
88 | class IrisPayloadData(SerializableAttrs):
89 | op: Operation
90 | path: str
91 | value: str = "{}"
92 |
93 |
94 | @dataclass(kw_only=True)
95 | class IrisPayload(SerializableAttrs):
96 | data: List[IrisPayloadData]
97 | message_type: int
98 | seq_id: int
99 | event: str = "patch"
100 | mutation_token: Optional[str] = None
101 | realtime: Optional[bool] = None
102 | sampled: Optional[bool] = None
103 |
104 |
105 | @dataclass(kw_only=True)
106 | class MessageSyncMessage(ThreadItem, SerializableAttrs):
107 | path: str
108 | op: Operation = Operation.ADD
109 |
110 | # These come from parsing the path
111 | admin_user_id: Optional[int] = None
112 | approval_required_for_new_members: Optional[bool] = None
113 | is_thread_image: Optional[bool] = None
114 | has_seen: Optional[int] = None
115 | thread_id: Optional[str] = None
116 |
117 |
118 | @dataclass(kw_only=True)
119 | class MessageSyncEvent(SerializableAttrs):
120 | iris: IrisPayload
121 | message: MessageSyncMessage
122 |
123 |
124 | @dataclass
125 | class ThreadSyncEvent(Thread, SerializableAttrs):
126 | path: str
127 | op: Operation
128 |
129 |
130 | @dataclass
131 | class ThreadRemoveEvent(SerializableAttrs):
132 | thread_id: str
133 |
134 | path: str
135 | op: Operation
136 | data: Any = None
137 |
138 |
139 | @dataclass(kw_only=True)
140 | class PubsubPublishMetadata(SerializableAttrs):
141 | publish_time_ms: str
142 | topic_publish_id: int
143 |
144 |
145 | @dataclass(kw_only=True)
146 | class PubsubBasePayload(SerializableAttrs):
147 | lazy: Optional[bool] = False
148 | event: str = "patch"
149 | publish_metadata: Optional[PubsubPublishMetadata] = None
150 | num_endpoints: Optional[int] = None
151 |
152 |
153 | @dataclass(kw_only=True)
154 | class ActivityIndicatorData(SerializableAttrs):
155 | timestamp: Union[int, str]
156 | sender_id: str
157 | ttl: int
158 | activity_status: TypingStatus
159 |
160 | @property
161 | def timestamp_ms(self) -> int:
162 | return int(self.timestamp) // 1000
163 |
164 | @classmethod
165 | def deserialize(cls, data: JSON) -> "ActivityIndicatorData":
166 | # The ActivityIndicatorData in PubsubPayloadData is actually a string,
167 | # so we need to unmarshal it first.
168 | if isinstance(data, str):
169 | data = json.loads(data)
170 | return super().deserialize(data)
171 |
172 |
173 | @dataclass(kw_only=True)
174 | class PubsubPayloadData(SerializableAttrs):
175 | double_publish: bool = attr.ib(metadata={"json": "doublePublish"})
176 | value: ActivityIndicatorData
177 | path: str
178 | op: Operation = Operation.ADD
179 |
180 |
181 | @dataclass(kw_only=True)
182 | class PubsubPayload(PubsubBasePayload, SerializableAttrs):
183 | data: List[PubsubPayloadData] = attr.ib(factory=lambda: [])
184 |
185 |
186 | @dataclass(kw_only=True)
187 | class PubsubEvent(SerializableAttrs):
188 | base: PubsubBasePayload
189 | data: PubsubPayloadData
190 | thread_id: str
191 | activity_indicator_id: str
192 |
193 |
194 | @dataclass(kw_only=True)
195 | class AppPresenceEvent(SerializableAttrs):
196 | user_id: str
197 | is_active: bool
198 | last_activity_at_ms: str
199 | in_threads: List[Any]
200 |
201 |
202 | @dataclass(kw_only=True)
203 | class AppPresenceEventPayload(SerializableAttrs):
204 | presence_event: AppPresenceEvent
205 |
206 |
207 | @dataclass(kw_only=True)
208 | class ZeroProductProvisioningEvent(SerializableAttrs):
209 | device_id: str
210 | product_name: str
211 | zero_provisioned_time: str
212 |
213 |
214 | @dataclass(kw_only=True)
215 | class RealtimeZeroProvisionPayload(SerializableAttrs):
216 | zero_product_provisioning_event: ZeroProductProvisioningEvent
217 |
218 |
219 | @dataclass(kw_only=True)
220 | class ClientConfigUpdateEvent(SerializableAttrs):
221 | publish_id: str
222 | client_config_name: str
223 | backing: str # might be "QE"
224 | client_subscription_id: str # should be GraphQLQueryID.clientConfigUpdate
225 |
226 |
227 | @dataclass(kw_only=True)
228 | class ClientConfigUpdatePayload(SerializableAttrs):
229 | client_config_update_event: ClientConfigUpdateEvent
230 |
231 |
232 | # TODO figure out if these need to be separate
233 | RealtimeDirectData = ActivityIndicatorData
234 |
235 |
236 | @dataclass(kw_only=True)
237 | class RealtimeDirectEvent(SerializableAttrs):
238 | op: Operation
239 | path: str
240 | value: RealtimeDirectData
241 | # Comes from the parent object
242 | # TODO many places have this kind of event, it's usually "patch", might need an enum
243 | event: Optional[str] = None
244 | # Parsed from path
245 | thread_id: Optional[str] = None
246 | activity_indicator_id: Optional[str] = None
247 |
248 |
249 | @dataclass(kw_only=True)
250 | class LiveVideoSystemComment(SerializableAttrs):
251 | pk: str
252 | created_at: int
253 | text: str
254 | user_count: int
255 | user: BaseResponseUser
256 |
257 |
258 | @dataclass(kw_only=True)
259 | class LiveVideoComment(SerializableAttrs):
260 | pk: str
261 | user_id: str
262 | text: str
263 | type: int
264 | created_at: int
265 | created_at_utc: int
266 | content_type: str
267 | status: str = "Active"
268 | bit_flags: int
269 | did_report_as_spam: bool
270 | inline_composer_display_condition: str
271 | user: BaseResponseUser
272 |
273 |
274 | @dataclass(kw_only=True)
275 | class LiveVideoCommentEvent(SerializableAttrs):
276 | client_subscription_id: str
277 | live_seconds_per_comment: int
278 | comment_likes_enabled: bool
279 | comment_count: int
280 | caption: Optional[str] = None
281 | caption_is_edited: bool
282 | has_more_comments: bool
283 | has_more_headload_comments: bool
284 | media_header_display: str
285 | comment_muted: int
286 | comments: Optional[List[LiveVideoComment]] = None
287 | pinned_comment: Optional[LiveVideoComment] = None
288 | system_comments: Optional[List[LiveVideoSystemComment]] = None
289 |
290 |
291 | @dataclass(kw_only=True)
292 | class LiveVideoCommentPayload(SerializableAttrs):
293 | live_video_comment_event: LiveVideoCommentEvent
294 |
--------------------------------------------------------------------------------
/mauigpapi/types/qe.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, Dict, List, Optional
17 | import json
18 |
19 | from attr import attrib, dataclass
20 |
21 | from mautrix.types import SerializableAttrs
22 |
23 |
24 | @dataclass
25 | class QeSyncExperimentParam(SerializableAttrs):
26 | name: str
27 | value: str
28 |
29 |
30 | @dataclass
31 | class AndroidExperiment:
32 | group: str
33 | params: Dict[str, Any] = attrib(factory=lambda: {})
34 | additional: List[Any] = attrib(factory=lambda: [])
35 | logging_id: Optional[str] = None
36 |
37 |
38 | def _try_parse(val: str) -> Any:
39 | try:
40 | return json.loads(val)
41 | except json.JSONDecodeError:
42 | return val
43 |
44 |
45 | @dataclass
46 | class QeSyncExperiment(SerializableAttrs):
47 | name: str
48 | group: str
49 | additional_params: List[Any]
50 | params: List[QeSyncExperimentParam]
51 | logging_id: Optional[str] = None
52 |
53 | def parse(self) -> AndroidExperiment:
54 | return AndroidExperiment(
55 | group=self.group,
56 | additional=self.additional_params,
57 | logging_id=self.logging_id,
58 | params={param.name: _try_parse(param.value) for param in self.params},
59 | )
60 |
61 |
62 | @dataclass
63 | class QeSyncResponse(SerializableAttrs):
64 | experiments: List[QeSyncExperiment]
65 | status: str
66 |
--------------------------------------------------------------------------------
/mauigpapi/types/thread.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, Dict, List, Optional
17 |
18 | from attr import dataclass
19 | import attr
20 |
21 | from mautrix.types import SerializableAttrs
22 |
23 | from .account import BaseResponseUser
24 | from .thread_item import ThreadImage, ThreadItem
25 |
26 |
27 | @dataclass
28 | class ThreadUser(BaseResponseUser, SerializableAttrs):
29 | interop_messaging_user_fbid: int
30 | interop_user_type: Optional[int] = None
31 | is_using_unified_inbox_for_direct: Optional[bool] = None
32 |
33 |
34 | @dataclass
35 | class ThreadTheme(SerializableAttrs):
36 | id: str
37 |
38 |
39 | @dataclass
40 | class ThreadUserLastSeenAt(SerializableAttrs):
41 | timestamp: str
42 | item_id: str
43 | shh_seen_state: Dict[str, Any]
44 |
45 | @property
46 | def timestamp_ms(self) -> int:
47 | return int(self.timestamp) // 1000
48 |
49 |
50 | @dataclass(kw_only=True)
51 | class Thread(SerializableAttrs):
52 | thread_id: str
53 | thread_v2_id: str
54 |
55 | users: List[ThreadUser]
56 | # left_users: List[TODO]
57 | inviter: Optional[BaseResponseUser] = None
58 | admin_user_ids: List[int]
59 |
60 | last_activity_at: int
61 | muted: bool
62 | # This seems to be missing in some cases
63 | is_pin: bool = False
64 | named: bool
65 | canonical: bool
66 | pending: bool
67 | archived: bool
68 | # TODO enum? even groups seem to be "private"
69 | thread_type: str
70 | viewer_id: int
71 | thread_title: str
72 | # This seems to be missing in some cases
73 | folder: Optional[int] = None
74 | vc_muted: bool
75 | is_group: bool
76 | mentions_muted: bool
77 | approval_required_for_new_members: bool
78 | input_mode: int
79 | business_thread_folder: int
80 | read_state: int
81 | last_non_sender_item_at: int
82 | assigned_admin_id: int
83 | shh_mode_enabled: bool
84 | is_close_friend_thread: bool
85 | has_older: bool
86 | has_newer: bool
87 |
88 | thread_image: Optional[ThreadImage] = None
89 |
90 | theme: ThreadTheme
91 | last_seen_at: Dict[str, ThreadUserLastSeenAt] = attr.ib(factory=lambda: {})
92 |
93 | newest_cursor: Optional[str] = None
94 | oldest_cursor: Optional[str] = None
95 | next_cursor: Optional[str] = None
96 | prev_cursor: Optional[str] = None
97 | last_permanent_item: Optional[ThreadItem] = None
98 | items: List[ThreadItem]
99 |
100 | # These might only be in single thread requests and not inbox
101 | valued_request: Optional[bool] = None
102 | pending_score: Optional[bool] = None
103 |
--------------------------------------------------------------------------------
/mauigpapi/types/upload.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import Any, List, Optional
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 |
23 | @dataclass
24 | class FacebookUploadResponse(SerializableAttrs):
25 | media_id: int
26 |
--------------------------------------------------------------------------------
/mauigpapi/types/user.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from typing import List, Optional
17 |
18 | from attr import dataclass
19 |
20 | from mautrix.types import SerializableAttrs
21 |
22 | from .account import BaseResponseUser
23 |
24 |
25 | @dataclass
26 | class SearchResultUser(BaseResponseUser, SerializableAttrs):
27 | mutual_followers_count: Optional[int] = None
28 | social_context: Optional[str] = None
29 | search_social_context: Optional[str] = None
30 |
31 |
32 | @dataclass
33 | class UserSearchResponse(SerializableAttrs):
34 | num_results: int
35 | users: List[SearchResultUser]
36 | has_more: bool
37 | rank_token: str
38 | status: str
39 |
--------------------------------------------------------------------------------
/mautrix_instagram/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.3.1"
2 | __author__ = "Tulir Asokan "
3 |
--------------------------------------------------------------------------------
/mautrix_instagram/__main__.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import Any
19 | import asyncio
20 | import logging
21 |
22 | from mautrix.bridge import Bridge
23 | from mautrix.types import RoomID, UserID
24 |
25 | from . import commands
26 | from .config import Config
27 | from .db import init as init_db, upgrade_table
28 | from .matrix import MatrixHandler
29 | from .portal import Portal
30 | from .puppet import Puppet
31 | from .user import User
32 | from .version import linkified_version, version
33 | from .web import ProvisioningAPI
34 |
35 |
36 | class InstagramBridge(Bridge):
37 | name = "mautrix-instagram"
38 | module = "mautrix_instagram"
39 | beeper_service_name = "instagram"
40 | beeper_network_name = "instagram"
41 | command = "python -m mautrix-instagram"
42 | description = "A Matrix-Instagram DM puppeting bridge."
43 | repo_url = "https://github.com/mautrix/instagram"
44 | version = version
45 | markdown_version = linkified_version
46 | config_class = Config
47 | matrix_class = MatrixHandler
48 | upgrade_table = upgrade_table
49 |
50 | config: Config
51 | matrix: MatrixHandler
52 | provisioning_api: ProvisioningAPI
53 |
54 | periodic_reconnect_task: asyncio.Task | None
55 |
56 | def preinit(self) -> None:
57 | self.periodic_reconnect_task = None
58 | super().preinit()
59 |
60 | def prepare_db(self) -> None:
61 | super().prepare_db()
62 | init_db(self.db)
63 |
64 | def prepare_bridge(self) -> None:
65 | super().prepare_bridge()
66 | cfg = self.config["bridge.provisioning"]
67 | self.provisioning_api = ProvisioningAPI(
68 | shared_secret=cfg["shared_secret"],
69 | device_seed=self.config["instagram.device_seed"],
70 | analytics_host=self.config["analytics.host"],
71 | analytics_token=self.config["analytics.token"],
72 | analytics_user_id=self.config["analytics.user_id"],
73 | )
74 | self.az.app.add_subapp(cfg["prefix"], self.provisioning_api.app)
75 |
76 | async def start(self) -> None:
77 | self.add_startup_actions(User.init_cls(self))
78 | self.add_startup_actions(Puppet.init_cls(self))
79 | Portal.init_cls(self)
80 | if self.config["bridge.resend_bridge_info"]:
81 | self.add_startup_actions(self.resend_bridge_info())
82 | await super().start()
83 | self.periodic_reconnect_task = asyncio.create_task(self._try_periodic_reconnect_loop())
84 |
85 | def prepare_stop(self) -> None:
86 | if self.periodic_reconnect_task is not None and not self.periodic_reconnect_task.done():
87 | self.periodic_reconnect_task.cancel()
88 | self.add_shutdown_actions(user.stop_listen() for user in User.by_igpk.values())
89 | self.log.debug("Stopping puppet syncers")
90 | for puppet in Puppet.by_custom_mxid.values():
91 | puppet.stop()
92 |
93 | async def resend_bridge_info(self) -> None:
94 | self.config["bridge.resend_bridge_info"] = False
95 | self.config.save()
96 | self.log.info("Re-sending bridge info state event to all portals")
97 | async for portal in Portal.all_with_room():
98 | await portal.update_bridge_info()
99 | self.log.info("Finished re-sending bridge info state events")
100 |
101 | async def _try_periodic_reconnect_loop(self) -> None:
102 | try:
103 | await self._periodic_reconnect_loop()
104 | except Exception:
105 | self.log.exception("Fatal error in periodic reconnect loop")
106 |
107 | async def _periodic_reconnect_loop(self) -> None:
108 | log = logging.getLogger("mau.periodic_reconnect")
109 | always_reconnect = self.config["bridge.periodic_reconnect.always"]
110 | interval = self.config["bridge.periodic_reconnect.interval"]
111 | if interval <= 0:
112 | log.debug("Periodic reconnection is not enabled")
113 | return
114 | resync = bool(self.config["bridge.periodic_reconnect.resync"])
115 | if interval < 600:
116 | log.warning("Periodic reconnect interval is quite low (%d)", interval)
117 | log.debug("Starting periodic reconnect loop")
118 | while True:
119 | try:
120 | await asyncio.sleep(interval)
121 | except asyncio.CancelledError:
122 | log.debug("Periodic reconnect loop stopped")
123 | return
124 | log.info("Executing periodic reconnections")
125 | for user in User.by_igpk.values():
126 | if not user.client or (not user.is_connected and not always_reconnect):
127 | log.debug("Not reconnecting %s: not connected", user.mxid)
128 | continue
129 | log.debug("Executing periodic reconnect for %s", user.mxid)
130 | try:
131 | await user.refresh(resync=resync, update_proxy=True)
132 | except asyncio.CancelledError:
133 | log.debug("Periodic reconnect loop stopped")
134 | return
135 | except Exception:
136 | log.exception("Error while reconnecting %s", user.mxid)
137 |
138 | async def get_portal(self, room_id: RoomID) -> Portal:
139 | return await Portal.get_by_mxid(room_id)
140 |
141 | async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet:
142 | return await Puppet.get_by_mxid(user_id, create=create)
143 |
144 | async def get_double_puppet(self, user_id: UserID) -> Puppet:
145 | return await Puppet.get_by_custom_mxid(user_id)
146 |
147 | async def get_user(self, user_id: UserID, create: bool = True) -> User:
148 | return await User.get_by_mxid(user_id, create=create)
149 |
150 | def is_bridge_ghost(self, user_id: UserID) -> bool:
151 | return bool(Puppet.get_id_from_mxid(user_id))
152 |
153 | async def count_logged_in_users(self) -> int:
154 | return len([user for user in User.by_igpk.values() if user.igpk])
155 |
156 | async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
157 | return {
158 | **await super().manhole_global_namespace(user_id),
159 | "User": User,
160 | "Portal": Portal,
161 | "Puppet": Puppet,
162 | }
163 |
164 |
165 | InstagramBridge().run()
166 |
--------------------------------------------------------------------------------
/mautrix_instagram/commands/__init__.py:
--------------------------------------------------------------------------------
1 | from .auth import SECTION_AUTH
2 | from .conn import SECTION_CONNECTION
3 | from .misc import SECTION_MISC
4 |
--------------------------------------------------------------------------------
/mautrix_instagram/commands/conn.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mauigpapi.errors import IGNotLoggedInError
17 | from mautrix.bridge.commands import HelpSection, command_handler
18 |
19 | from .typehint import CommandEvent
20 |
21 | SECTION_CONNECTION = HelpSection("Connection management", 15, "")
22 |
23 |
24 | @command_handler(
25 | needs_auth=False,
26 | management_only=True,
27 | help_section=SECTION_CONNECTION,
28 | help_text="Mark this room as your bridge notice room",
29 | )
30 | async def set_notice_room(evt: CommandEvent) -> None:
31 | evt.sender.notice_room = evt.room_id
32 | await evt.sender.update()
33 | await evt.reply("This room has been marked as your bridge notice room")
34 |
35 |
36 | @command_handler(
37 | needs_auth=False,
38 | management_only=True,
39 | help_section=SECTION_CONNECTION,
40 | help_text="Check if you're logged into Instagram",
41 | )
42 | async def ping(evt: CommandEvent) -> None:
43 | if not await evt.sender.is_logged_in():
44 | await evt.reply("You're not logged into Instagram")
45 | return
46 | try:
47 | user_info = await evt.sender.client.current_user()
48 | except IGNotLoggedInError as e:
49 | evt.log.exception("Got error checking current user for %s", evt.sender.mxid)
50 | await evt.reply("You have been logged out")
51 | await evt.sender.logout(error=e)
52 | else:
53 | user = user_info.user
54 | await evt.reply(
55 | f"You're logged in as {user.full_name} ([@{user.username}]"
56 | f"(https://instagram.com/{user.username}), user ID: {user.pk})"
57 | )
58 | if evt.sender.is_connected:
59 | await evt.reply("MQTT connection is active")
60 | elif evt.sender.mqtt and evt.sender._listen_task:
61 | await evt.reply("MQTT connection is reconnecting")
62 | else:
63 | await evt.reply("MQTT not connected")
64 |
65 |
66 | @command_handler(
67 | needs_auth=True,
68 | management_only=False,
69 | help_section=SECTION_CONNECTION,
70 | help_text="Reconnect to Instagram and synchronize portals",
71 | aliases=["sync"],
72 | )
73 | async def refresh(evt: CommandEvent) -> None:
74 | await evt.sender.refresh()
75 | await evt.reply("Synchronization complete")
76 |
77 |
78 | @command_handler(
79 | needs_auth=True,
80 | management_only=False,
81 | help_section=SECTION_CONNECTION,
82 | help_text="Connect to Instagram",
83 | aliases=["reconnect"],
84 | )
85 | async def connect(evt: CommandEvent) -> None:
86 | if evt.sender.is_connected:
87 | await evt.reply("You're already connected to Instagram.")
88 | return
89 | await evt.sender.refresh(resync=False)
90 | await evt.reply("Restarted connection to Instagram.")
91 |
92 |
93 | @command_handler(
94 | needs_auth=True,
95 | management_only=False,
96 | help_section=SECTION_CONNECTION,
97 | help_text="Disconnect from Instagram",
98 | )
99 | async def disconnect(evt: CommandEvent) -> None:
100 | if not evt.sender.mqtt:
101 | await evt.reply("You're not connected to Instagram.")
102 | await evt.sender.stop_listen()
103 | await evt.reply("Successfully disconnected from Instagram.")
104 |
--------------------------------------------------------------------------------
/mautrix_instagram/commands/misc.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.bridge.commands import HelpSection, command_handler
17 |
18 | from .. import puppet as pu
19 | from .typehint import CommandEvent
20 |
21 | SECTION_MISC = HelpSection("Miscellaneous", 40, "")
22 |
23 |
24 | @command_handler(
25 | needs_auth=True,
26 | management_only=False,
27 | help_section=SECTION_MISC,
28 | help_text="Search for Instagram users",
29 | help_args="<_query_>",
30 | )
31 | async def search(evt: CommandEvent) -> None:
32 | if len(evt.args) < 1:
33 | await evt.reply("**Usage:** `$cmdprefix+sp search `")
34 | return
35 | resp = await evt.sender.client.search_users(" ".join(evt.args))
36 | if not resp.users:
37 | await evt.reply("No results :(")
38 | return
39 | response_list = []
40 | for user in resp.users[:10]:
41 | puppet = await pu.Puppet.get_by_pk(user.pk, create=True)
42 | await puppet.update_info(user, evt.sender)
43 | response_list.append(
44 | f"* [{puppet.name}](https://matrix.to/#/{puppet.mxid})"
45 | f" ([@{user.username}](https://instagram.com/{user.username}))"
46 | )
47 | await evt.reply("\n".join(response_list))
48 |
--------------------------------------------------------------------------------
/mautrix_instagram/commands/typehint.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from mautrix.bridge.commands import CommandEvent as BaseCommandEvent
4 |
5 | if TYPE_CHECKING:
6 | from ..__main__ import InstagramBridge
7 | from ..user import User
8 |
9 |
10 | class CommandEvent(BaseCommandEvent):
11 | bridge: "InstagramBridge"
12 | sender: "User"
13 |
--------------------------------------------------------------------------------
/mautrix_instagram/config.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import Any, NamedTuple
19 | import os
20 |
21 | from mautrix.bridge.config import BaseBridgeConfig
22 | from mautrix.client import Client
23 | from mautrix.types import UserID
24 | from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
25 |
26 | Permissions = NamedTuple("Permissions", relay=bool, user=bool, admin=bool, level=str)
27 |
28 |
29 | class Config(BaseBridgeConfig):
30 | @property
31 | def forbidden_defaults(self) -> list[ForbiddenDefault]:
32 | return [
33 | *super().forbidden_defaults,
34 | ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
35 | ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
36 | ]
37 |
38 | def do_update(self, helper: ConfigUpdateHelper) -> None:
39 | super().do_update(helper)
40 | copy, copy_dict, base = helper
41 |
42 | copy("metrics.enabled")
43 | copy("metrics.listen_port")
44 |
45 | copy("instagram.device_seed")
46 | if base["instagram.device_seed"] == "generate":
47 | base["instagram.device_seed"] = self._new_token()
48 | copy("instagram.mqtt_keepalive")
49 |
50 | copy("bridge.username_template")
51 | copy("bridge.displayname_template")
52 | copy("bridge.private_chat_name_template")
53 | copy("bridge.group_chat_name_template")
54 |
55 | copy("bridge.displayname_max_length")
56 |
57 | copy("bridge.max_startup_thread_sync_count")
58 | copy("bridge.sync_with_custom_puppets")
59 | copy("bridge.sync_direct_chat_list")
60 | copy("bridge.double_puppet_server_map")
61 | copy("bridge.double_puppet_allow_discovery")
62 | copy("bridge.login_shared_secret_map")
63 | copy("bridge.federate_rooms")
64 | copy("bridge.backfill.enable_initial")
65 | copy("bridge.backfill.enable")
66 | copy("bridge.backfill.msc2716")
67 | copy("bridge.backfill.double_puppet_backfill")
68 | if "bridge.initial_chat_sync" in self:
69 | initial_chat_sync = self["bridge.initial_chat_sync"]
70 | base["bridge.backfill.max_conversations"] = self.get(
71 | "bridge.backfill.max_conversations", initial_chat_sync
72 | )
73 | else:
74 | copy("bridge.backfill.max_conversations")
75 | copy("bridge.backfill.min_sync_thread_delay")
76 | copy("bridge.backfill.unread_hours_threshold")
77 | copy("bridge.backfill.backoff.thread_list")
78 | copy("bridge.backfill.backoff.message_history")
79 | copy("bridge.backfill.incremental.max_pages")
80 | copy("bridge.backfill.incremental.max_total_pages")
81 | copy("bridge.backfill.incremental.page_delay")
82 | copy("bridge.backfill.incremental.post_batch_delay")
83 | copy("bridge.periodic_reconnect.interval")
84 | copy("bridge.periodic_reconnect.resync")
85 | copy("bridge.periodic_reconnect.always")
86 | if isinstance(self.get("bridge.private_chat_portal_meta", "default"), bool):
87 | base["bridge.private_chat_portal_meta"] = (
88 | "always" if self["bridge.private_chat_portal_meta"] else "default"
89 | )
90 | else:
91 | copy("bridge.private_chat_portal_meta")
92 | if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"):
93 | base["bridge.private_chat_portal_meta"] = "default"
94 | copy("bridge.delivery_receipts")
95 | copy("bridge.delivery_error_reports")
96 | copy("bridge.message_status_events")
97 | copy("bridge.resend_bridge_info")
98 | copy("bridge.unimportant_bridge_notices")
99 | copy("bridge.disable_bridge_notices")
100 | copy("bridge.caption_in_message")
101 | copy("bridge.bridge_notices")
102 | copy("bridge.bridge_matrix_typing")
103 |
104 | copy("bridge.provisioning.enabled")
105 | copy("bridge.provisioning.prefix")
106 | copy("bridge.provisioning.shared_secret")
107 | if base["bridge.provisioning.shared_secret"] == "generate":
108 | base["bridge.provisioning.shared_secret"] = self._new_token()
109 |
110 | copy("analytics.host")
111 | if "appservice.provisioning.segment_key" in self:
112 | base["analytics.token"] = self["appservice.provisioning.segment_key"]
113 | else:
114 | copy("analytics.token")
115 | if "appservice.provisioning.segment_user_id" in self:
116 | base["analytics.user_id"] = self["appservice.provisioning.segment_user_id"]
117 | else:
118 | copy("analytics.user_id")
119 |
120 | copy("bridge.command_prefix")
121 |
122 | copy("bridge.get_proxy_api_url")
123 | copy("bridge.use_proxy_for_media")
124 |
125 | copy_dict("bridge.permissions")
126 |
127 | def _get_permissions(self, key: str) -> Permissions:
128 | level = self["bridge.permissions"].get(key, "")
129 | admin = level == "admin"
130 | user = level == "user" or admin
131 | relay = level == "relay" or user
132 | return Permissions(relay, user, admin, level)
133 |
134 | def get_permissions(self, mxid: UserID) -> Permissions:
135 | permissions = self["bridge.permissions"]
136 | if mxid in permissions:
137 | return self._get_permissions(mxid)
138 |
139 | _, homeserver = Client.parse_user_id(mxid)
140 | if homeserver in permissions:
141 | return self._get_permissions(homeserver)
142 |
143 | return self._get_permissions("*")
144 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/__init__.py:
--------------------------------------------------------------------------------
1 | from mautrix.util.async_db import Database
2 |
3 | from .backfill_queue import Backfill
4 | from .message import Message
5 | from .portal import Portal
6 | from .puppet import Puppet
7 | from .reaction import Reaction
8 | from .upgrade import upgrade_table
9 | from .user import User
10 |
11 |
12 | def init(db: Database) -> None:
13 | for table in (User, Puppet, Portal, Message, Reaction, Backfill):
14 | table.db = db
15 |
16 |
17 | __all__ = ["upgrade_table", "User", "Puppet", "Portal", "Message", "Reaction", "Backfill", "init"]
18 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/backfill_queue.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 | from datetime import datetime, timedelta
20 |
21 | from asyncpg import Record
22 | from attr import dataclass
23 |
24 | from mautrix.types import RoomID, UserID
25 | from mautrix.util.async_db import Database
26 |
27 | fake_db = Database.create("") if TYPE_CHECKING else None
28 |
29 |
30 | @dataclass
31 | class Backfill:
32 | db: ClassVar[Database] = fake_db
33 |
34 | queue_id: int | None
35 | user_mxid: UserID
36 | priority: int
37 | portal_thread_id: str
38 | portal_receiver: int
39 | num_pages: int
40 | page_delay: int
41 | post_batch_delay: int
42 | max_total_pages: int
43 | dispatch_time: datetime | None
44 | completed_at: datetime | None
45 | cooldown_timeout: datetime | None
46 |
47 | @staticmethod
48 | def new(
49 | user_mxid: UserID,
50 | priority: int,
51 | portal_thread_id: str,
52 | portal_receiver: int,
53 | num_pages: int,
54 | page_delay: int = 0,
55 | post_batch_delay: int = 0,
56 | max_total_pages: int = -1,
57 | ) -> "Backfill":
58 | return Backfill(
59 | queue_id=None,
60 | user_mxid=user_mxid,
61 | priority=priority,
62 | portal_thread_id=portal_thread_id,
63 | portal_receiver=portal_receiver,
64 | num_pages=num_pages,
65 | page_delay=page_delay,
66 | post_batch_delay=post_batch_delay,
67 | max_total_pages=max_total_pages,
68 | dispatch_time=None,
69 | completed_at=None,
70 | cooldown_timeout=None,
71 | )
72 |
73 | @classmethod
74 | def _from_row(cls, row: Record | None) -> Backfill | None:
75 | if row is None:
76 | return None
77 | return cls(**row)
78 |
79 | columns = [
80 | "user_mxid",
81 | "priority",
82 | "portal_thread_id",
83 | "portal_receiver",
84 | "num_pages",
85 | "page_delay",
86 | "post_batch_delay",
87 | "max_total_pages",
88 | "dispatch_time",
89 | "completed_at",
90 | "cooldown_timeout",
91 | ]
92 | columns_str = ",".join(columns)
93 |
94 | @classmethod
95 | async def get_next(cls, user_mxid: UserID) -> Backfill | None:
96 | q = f"""
97 | SELECT queue_id, {cls.columns_str}
98 | FROM backfill_queue
99 | WHERE user_mxid=$1
100 | AND (
101 | dispatch_time IS NULL
102 | OR (
103 | dispatch_time < $2
104 | AND completed_at IS NULL
105 | )
106 | )
107 | AND (
108 | cooldown_timeout IS NULL
109 | OR cooldown_timeout < current_timestamp
110 | )
111 | ORDER BY priority, queue_id
112 | LIMIT 1
113 | """
114 | return cls._from_row(
115 | await cls.db.fetchrow(q, user_mxid, datetime.now() - timedelta(minutes=15))
116 | )
117 |
118 | @classmethod
119 | async def get(
120 | cls,
121 | user_mxid: UserID,
122 | portal_thread_id: str,
123 | portal_receiver: int,
124 | ) -> Backfill | None:
125 | q = f"""
126 | SELECT queue_id, {cls.columns_str}
127 | FROM backfill_queue
128 | WHERE user_mxid=$1
129 | AND portal_thread_id=$2
130 | AND portal_receiver=$3
131 | ORDER BY priority, queue_id
132 | LIMIT 1
133 | """
134 | return cls._from_row(
135 | await cls.db.fetchrow(q, user_mxid, portal_thread_id, portal_receiver)
136 | )
137 |
138 | @classmethod
139 | async def delete_all(cls, user_mxid: UserID) -> None:
140 | await cls.db.execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
141 |
142 | @classmethod
143 | async def delete_for_portal(cls, portal_thread_id: str, portal_receiver: int) -> None:
144 | q = "DELETE FROM backfill_queue WHERE portal_thread_id=$1 AND portal_receiver=$2"
145 | await cls.db.execute(q, portal_thread_id, portal_receiver)
146 |
147 | async def insert(self) -> None:
148 | q = f"""
149 | INSERT INTO backfill_queue ({self.columns_str})
150 | VALUES ({','.join(f'${i+1}' for i in range(len(self.columns)))})
151 | RETURNING queue_id
152 | """
153 | row = await self.db.fetchrow(
154 | q,
155 | self.user_mxid,
156 | self.priority,
157 | self.portal_thread_id,
158 | self.portal_receiver,
159 | self.num_pages,
160 | self.page_delay,
161 | self.post_batch_delay,
162 | self.max_total_pages,
163 | self.dispatch_time,
164 | self.completed_at,
165 | self.cooldown_timeout,
166 | )
167 | self.queue_id = row["queue_id"]
168 |
169 | async def mark_dispatched(self) -> None:
170 | q = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
171 | await self.db.execute(q, datetime.now(), self.queue_id)
172 |
173 | async def mark_done(self) -> None:
174 | q = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
175 | await self.db.execute(q, datetime.now(), self.queue_id)
176 |
177 | async def set_cooldown_timeout(self, timeout) -> None:
178 | """
179 | Set the backfill request to cooldown for ``timeout`` seconds.
180 | """
181 | q = "UPDATE backfill_queue SET cooldown_timeout=$1 WHERE queue_id=$2"
182 | await self.db.execute(q, datetime.now() + timedelta(seconds=timeout), self.queue_id)
183 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/message.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 |
20 | from attr import dataclass
21 | import attr
22 |
23 | from mautrix.types import EventID, RoomID
24 | from mautrix.util.async_db import Database, Scheme
25 |
26 | fake_db = Database.create("") if TYPE_CHECKING else None
27 |
28 |
29 | @dataclass
30 | class Message:
31 | db: ClassVar[Database] = fake_db
32 |
33 | mxid: EventID
34 | mx_room: RoomID
35 | item_id: str
36 | client_context: str | None
37 | receiver: int
38 | sender: int
39 | ig_timestamp: int | None
40 |
41 | _columns = "mxid, mx_room, item_id, client_context, receiver, sender, ig_timestamp"
42 | _insert_query = f"INSERT INTO message ({_columns}) VALUES ($1, $2, $3, $4, $5, $6, $7)"
43 |
44 | @property
45 | def ig_timestamp_ms(self) -> int:
46 | return (self.ig_timestamp // 1000) if self.ig_timestamp else 0
47 |
48 | async def insert(self) -> None:
49 | await self.db.execute(
50 | self._insert_query,
51 | self.mxid,
52 | self.mx_room,
53 | self.item_id,
54 | self.client_context,
55 | self.receiver,
56 | self.sender,
57 | self.ig_timestamp,
58 | )
59 |
60 | @classmethod
61 | async def bulk_insert(cls, messages: list[Message]) -> None:
62 | columns = cls._columns.split(", ")
63 | records = [attr.astuple(message) for message in messages]
64 | async with cls.db.acquire() as conn, conn.transaction():
65 | if cls.db.scheme == Scheme.POSTGRES:
66 | await conn.copy_records_to_table("message", records=records, columns=columns)
67 | else:
68 | await conn.executemany(cls._insert_query, records)
69 |
70 | async def delete(self) -> None:
71 | q = "DELETE FROM message WHERE item_id=$1 AND receiver=$2"
72 | await self.db.execute(q, self.item_id, self.receiver)
73 |
74 | @classmethod
75 | async def delete_all(cls, room_id: RoomID) -> None:
76 | await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
77 |
78 | @classmethod
79 | async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Message | None:
80 | q = f"SELECT {cls._columns} FROM message WHERE mxid=$1 AND mx_room=$2"
81 | row = await cls.db.fetchrow(q, mxid, mx_room)
82 | if not row:
83 | return None
84 | return cls(**row)
85 |
86 | @classmethod
87 | async def get_last(cls, mx_room: RoomID) -> Message | None:
88 | q = f"""
89 | SELECT {cls._columns} FROM message
90 | WHERE mx_room=$1 AND ig_timestamp IS NOT NULL AND item_id NOT LIKE 'fi.mau.instagram.%'
91 | ORDER BY ig_timestamp DESC LIMIT 1
92 | """
93 | row = await cls.db.fetchrow(q, mx_room)
94 | if not row:
95 | return None
96 | return cls(**row)
97 |
98 | @classmethod
99 | async def get_closest(cls, mx_room: RoomID, before_ts: int) -> Message | None:
100 | q = f"""
101 | SELECT {cls._columns} FROM message
102 | WHERE mx_room=$1 AND ig_timestamp<=$2 AND item_id NOT LIKE 'fi.mau.instagram.%'
103 | ORDER BY ig_timestamp DESC LIMIT 1
104 | """
105 | row = await cls.db.fetchrow(q, mx_room, before_ts)
106 | if not row:
107 | return None
108 | return cls(**row)
109 |
110 | @classmethod
111 | async def get_by_item_id(cls, item_id: str, receiver: int) -> Message | None:
112 | q = f"SELECT {cls._columns} FROM message WHERE item_id=$1 AND receiver=$2"
113 | row = await cls.db.fetchrow(q, item_id, receiver)
114 | if not row:
115 | return None
116 | return cls(**row)
117 |
118 | @property
119 | def is_internal(self) -> bool:
120 | return self.item_id.startswith("fi.mau.instagram.")
121 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/portal.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 |
20 | from asyncpg import Record
21 | from attr import dataclass
22 |
23 | from mautrix.types import BatchID, ContentURI, EventID, RoomID, UserID
24 | from mautrix.util.async_db import Database
25 |
26 | fake_db = Database.create("") if TYPE_CHECKING else None
27 |
28 |
29 | @dataclass
30 | class Portal:
31 | db: ClassVar[Database] = fake_db
32 |
33 | thread_id: str
34 | receiver: int
35 | other_user_pk: int | None
36 | mxid: RoomID | None
37 | name: str | None
38 | avatar_url: ContentURI | None
39 | encrypted: bool
40 | name_set: bool
41 | avatar_set: bool
42 | relay_user_id: UserID | None
43 | first_event_id: EventID | None
44 | next_batch_id: BatchID | None
45 | historical_base_insertion_event_id: EventID | None
46 | cursor: str | None
47 | thread_image_id: int | None
48 |
49 | @property
50 | def _values(self):
51 | return (
52 | self.thread_id,
53 | self.receiver,
54 | self.other_user_pk,
55 | self.mxid,
56 | self.name,
57 | self.avatar_url,
58 | self.encrypted,
59 | self.name_set,
60 | self.avatar_set,
61 | self.relay_user_id,
62 | self.first_event_id,
63 | self.next_batch_id,
64 | self.historical_base_insertion_event_id,
65 | self.cursor,
66 | self.thread_image_id,
67 | )
68 |
69 | column_names = ",".join(
70 | (
71 | "thread_id",
72 | "receiver",
73 | "other_user_pk",
74 | "mxid",
75 | "name",
76 | "avatar_url",
77 | "encrypted",
78 | "name_set",
79 | "avatar_set",
80 | "relay_user_id",
81 | "first_event_id",
82 | "next_batch_id",
83 | "historical_base_insertion_event_id",
84 | "cursor",
85 | "thread_image_id",
86 | )
87 | )
88 |
89 | async def insert(self) -> None:
90 | q = (
91 | f"INSERT INTO portal ({self.column_names}) "
92 | "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"
93 | )
94 | await self.db.execute(q, *self._values)
95 |
96 | async def update(self) -> None:
97 | q = (
98 | "UPDATE portal SET other_user_pk=$3, mxid=$4, name=$5, avatar_url=$6, encrypted=$7,"
99 | " name_set=$8, avatar_set=$9, relay_user_id=$10, first_event_id=$11,"
100 | " next_batch_id=$12, historical_base_insertion_event_id=$13,"
101 | " cursor=$14, thread_image_id=$15 "
102 | "WHERE thread_id=$1 AND receiver=$2"
103 | )
104 | await self.db.execute(q, *self._values)
105 |
106 | @classmethod
107 | def _from_row(cls, row: Record | None) -> Portal | None:
108 | if row is None:
109 | return None
110 | return cls(**row)
111 |
112 | @classmethod
113 | async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
114 | q = f"SELECT {cls.column_names} FROM portal WHERE mxid=$1"
115 | row = await cls.db.fetchrow(q, mxid)
116 | return cls._from_row(row)
117 |
118 | @classmethod
119 | async def get_by_thread_id(
120 | cls, thread_id: str, receiver: int, rec_must_match: bool = True
121 | ) -> Portal | None:
122 | q = f"SELECT {cls.column_names} FROM portal WHERE thread_id=$1 AND receiver=$2"
123 | if not rec_must_match:
124 | q = f"""
125 | SELECT {cls.column_names}
126 | FROM portal
127 | WHERE thread_id=$1
128 | AND (receiver=$2 OR receiver=0)
129 | """
130 | row = await cls.db.fetchrow(q, thread_id, receiver)
131 | return cls._from_row(row)
132 |
133 | @classmethod
134 | async def find_private_chats_of(cls, receiver: int) -> list[Portal]:
135 | q = f"""
136 | SELECT {cls.column_names}
137 | FROM portal
138 | WHERE receiver=$1
139 | AND other_user_pk IS NOT NULL
140 | """
141 | rows = await cls.db.fetch(q, receiver)
142 | return [cls._from_row(row) for row in rows]
143 |
144 | @classmethod
145 | async def find_private_chats_with(cls, other_user: int) -> list[Portal]:
146 | q = f"""
147 | SELECT {cls.column_names}
148 | FROM portal
149 | WHERE other_user_pk=$1
150 | """
151 | rows = await cls.db.fetch(q, other_user)
152 | return [cls._from_row(row) for row in rows]
153 |
154 | @classmethod
155 | async def find_private_chat_id(cls, receiver: int, other_user: int) -> str | None:
156 | q = "SELECT thread_id FROM portal WHERE receiver=$1 AND other_user_pk=$2"
157 | return await cls.db.fetchval(q, receiver, other_user)
158 |
159 | @classmethod
160 | async def all_with_room(cls) -> list[Portal]:
161 | q = f"""
162 | SELECT {cls.column_names}
163 | FROM portal
164 | WHERE mxid IS NOT NULL
165 | """
166 | rows = await cls.db.fetch(q)
167 | return [cls._from_row(row) for row in rows]
168 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/puppet.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 |
20 | from attr import dataclass
21 | from yarl import URL
22 | import asyncpg
23 |
24 | from mautrix.types import ContentURI, SyncToken, UserID
25 | from mautrix.util.async_db import Database
26 |
27 | fake_db = Database.create("") if TYPE_CHECKING else None
28 |
29 |
30 | @dataclass
31 | class Puppet:
32 | db: ClassVar[Database] = fake_db
33 |
34 | pk: int
35 | name: str | None
36 | username: str | None
37 | photo_id: str | None
38 | photo_mxc: ContentURI | None
39 | name_set: bool
40 | avatar_set: bool
41 | contact_info_set: bool
42 |
43 | is_registered: bool
44 |
45 | custom_mxid: UserID | None
46 | access_token: str | None
47 | next_batch: SyncToken | None
48 | base_url: URL | None
49 |
50 | @property
51 | def _values(self):
52 | return (
53 | self.pk,
54 | self.name,
55 | self.username,
56 | self.photo_id,
57 | self.photo_mxc,
58 | self.name_set,
59 | self.avatar_set,
60 | self.contact_info_set,
61 | self.is_registered,
62 | self.custom_mxid,
63 | self.access_token,
64 | self.next_batch,
65 | str(self.base_url) if self.base_url else None,
66 | )
67 |
68 | columns: ClassVar[str] = (
69 | "pk, name, username, photo_id, photo_mxc, name_set, avatar_set, contact_info_set, "
70 | "is_registered, custom_mxid, access_token, next_batch, base_url"
71 | )
72 |
73 | async def insert(self) -> None:
74 | q = f"""
75 | INSERT INTO puppet ({self.columns})
76 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
77 | """
78 | await self.db.execute(q, *self._values)
79 |
80 | async def update(self) -> None:
81 | q = """
82 | UPDATE puppet
83 | SET name=$2, username=$3, photo_id=$4, photo_mxc=$5, name_set=$6, avatar_set=$7,
84 | contact_info_set=$8, is_registered=$9, custom_mxid=$10, access_token=$11,
85 | next_batch=$12, base_url=$13
86 | WHERE pk=$1
87 | """
88 | await self.db.execute(q, *self._values)
89 |
90 | @classmethod
91 | def _from_row(cls, row: asyncpg.Record) -> Puppet:
92 | data = {**row}
93 | base_url_str = data.pop("base_url")
94 | base_url = URL(base_url_str) if base_url_str is not None else None
95 | return cls(base_url=base_url, **data)
96 |
97 | @classmethod
98 | async def get_by_pk(cls, pk: int) -> Puppet | None:
99 | q = f"SELECT {cls.columns} FROM puppet WHERE pk=$1"
100 | row = await cls.db.fetchrow(q, pk)
101 | if not row:
102 | return None
103 | return cls._from_row(row)
104 |
105 | @classmethod
106 | async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
107 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
108 | row = await cls.db.fetchrow(q, mxid)
109 | if not row:
110 | return None
111 | return cls._from_row(row)
112 |
113 | @classmethod
114 | async def all_with_custom_mxid(cls) -> list[Puppet]:
115 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid IS NOT NULL"
116 | rows = await cls.db.fetch(q)
117 | return [cls._from_row(row) for row in rows]
118 |
119 | @classmethod
120 | async def get_all(cls) -> list[Puppet]:
121 | q = f"SELECT {cls.columns} FROM puppet"
122 | rows = await cls.db.fetch(q)
123 | return [cls._from_row(row) for row in rows]
124 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/reaction.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 |
20 | from attr import dataclass
21 |
22 | from mautrix.types import EventID, RoomID
23 | from mautrix.util.async_db import Database
24 |
25 | fake_db = Database.create("") if TYPE_CHECKING else None
26 |
27 |
28 | @dataclass
29 | class Reaction:
30 | db: ClassVar[Database] = fake_db
31 |
32 | mxid: EventID
33 | mx_room: RoomID
34 | ig_item_id: str
35 | ig_receiver: int
36 | ig_sender: int
37 | reaction: str
38 | mx_timestamp: int | None
39 |
40 | async def insert(self) -> None:
41 | q = """
42 | INSERT INTO reaction (mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction,
43 | mx_timestamp)
44 | VALUES ($1, $2, $3, $4, $5, $6, $7)
45 | """
46 | await self.db.execute(
47 | q,
48 | self.mxid,
49 | self.mx_room,
50 | self.ig_item_id,
51 | self.ig_receiver,
52 | self.ig_sender,
53 | self.reaction,
54 | self.mx_timestamp,
55 | )
56 |
57 | async def edit(self, mx_room: RoomID, mxid: EventID, reaction: str, mx_timestamp: int) -> None:
58 | q = """
59 | UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$3, mx_timestamp=$4
60 | WHERE ig_item_id=$5 AND ig_receiver=$6 AND ig_sender=$7
61 | """
62 | await self.db.execute(
63 | q,
64 | mxid,
65 | mx_room,
66 | reaction,
67 | mx_timestamp,
68 | self.ig_item_id,
69 | self.ig_receiver,
70 | self.ig_sender,
71 | )
72 |
73 | async def delete(self) -> None:
74 | q = "DELETE FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2 AND ig_sender=$3"
75 | await self.db.execute(q, self.ig_item_id, self.ig_receiver, self.ig_sender)
76 |
77 | _columns = "mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction, mx_timestamp"
78 |
79 | @classmethod
80 | async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
81 | q = f"SELECT {cls._columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
82 | row = await cls.db.fetchrow(q, mxid, mx_room)
83 | if not row:
84 | return None
85 | return cls(**row)
86 |
87 | @classmethod
88 | async def get_by_item_id(
89 | cls, ig_item_id: str, ig_receiver: int, ig_sender: int
90 | ) -> Reaction | None:
91 | q = (
92 | f"SELECT {cls._columns} FROM reaction"
93 | " WHERE ig_item_id=$1 AND ig_sender=$2 AND ig_receiver=$3"
94 | )
95 | row = await cls.db.fetchrow(q, ig_item_id, ig_sender, ig_receiver)
96 | if not row:
97 | return None
98 | return cls(**row)
99 |
100 | @classmethod
101 | async def count(cls, ig_item_id: str, ig_receiver: int) -> int:
102 | q = "SELECT COUNT(*) FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2"
103 | return await cls.db.fetchval(q, ig_item_id, ig_receiver)
104 |
105 | @classmethod
106 | async def get_all_by_item_id(cls, ig_item_id: str, ig_receiver: int) -> list[Reaction]:
107 | q = f"SELECT {cls._columns} FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2"
108 | rows = await cls.db.fetch(q, ig_item_id, ig_receiver)
109 | return [cls(**row) for row in rows]
110 |
111 | @classmethod
112 | async def get_closest(cls, mx_room: RoomID, before_ts: int) -> Reaction | None:
113 | q = f"""
114 | SELECT {cls._columns} FROM reaction WHERE mx_room=$1 AND mx_timestamp<=$2
115 | ORDER BY mx_timestamp DESC LIMIT 1
116 | """
117 | row = await cls.db.fetchrow(q, mx_room, before_ts)
118 | if not row:
119 | return None
120 | return cls(**row)
121 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/__init__.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import UpgradeTable
17 |
18 | upgrade_table = UpgradeTable()
19 |
20 | from . import (
21 | v00_latest_revision,
22 | v02_name_avatar_set,
23 | v03_relay_portal,
24 | v04_message_client_content,
25 | v05_message_ig_timestamp,
26 | v06_hidden_events,
27 | v07_reaction_timestamps,
28 | v08_sync_sequence_id,
29 | v09_backfill_queue,
30 | v10_portal_infinite_backfill,
31 | v11_per_user_thread_sync_status,
32 | v12_portal_thread_image_id,
33 | v13_fix_portal_thread_image_id,
34 | v14_puppet_contact_info_set,
35 | )
36 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v00_latest_revision.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection, Scheme
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Latest revision", upgrades_to=13)
22 | async def upgrade_latest(conn: Connection, scheme: Scheme) -> None:
23 | await conn.execute(
24 | """CREATE TABLE portal (
25 | thread_id TEXT,
26 | receiver BIGINT,
27 | other_user_pk BIGINT,
28 | mxid TEXT,
29 | name TEXT,
30 | avatar_url TEXT,
31 | name_set BOOLEAN NOT NULL DEFAULT false,
32 | avatar_set BOOLEAN NOT NULL DEFAULT false,
33 | encrypted BOOLEAN NOT NULL DEFAULT false,
34 | relay_user_id TEXT,
35 | first_event_id TEXT,
36 | next_batch_id TEXT,
37 | historical_base_insertion_event_id TEXT,
38 | cursor TEXT,
39 | thread_image_id BIGINT,
40 | PRIMARY KEY (thread_id, receiver)
41 | )"""
42 | )
43 | await conn.execute(
44 | """CREATE TABLE "user" (
45 | mxid TEXT PRIMARY KEY,
46 | igpk BIGINT,
47 | state jsonb,
48 | seq_id BIGINT,
49 | snapshot_at_ms BIGINT,
50 | notice_room TEXT,
51 | oldest_cursor TEXT,
52 | total_backfilled_portals INTEGER,
53 | thread_sync_completed BOOLEAN NOT NULL DEFAULT false
54 | )"""
55 | )
56 | await conn.execute(
57 | """CREATE TABLE puppet (
58 | pk BIGINT PRIMARY KEY,
59 | name TEXT,
60 | username TEXT,
61 | photo_id TEXT,
62 | photo_mxc TEXT,
63 | name_set BOOLEAN NOT NULL DEFAULT false,
64 | avatar_set BOOLEAN NOT NULL DEFAULT false,
65 | is_registered BOOLEAN NOT NULL DEFAULT false,
66 | custom_mxid TEXT,
67 | access_token TEXT,
68 | next_batch TEXT,
69 | base_url TEXT
70 | )"""
71 | )
72 | await conn.execute(
73 | """CREATE TABLE user_portal (
74 | "user" BIGINT,
75 | portal TEXT,
76 | portal_receiver BIGINT,
77 | in_community BOOLEAN NOT NULL DEFAULT false,
78 | FOREIGN KEY (portal, portal_receiver) REFERENCES portal(thread_id, receiver)
79 | ON UPDATE CASCADE ON DELETE CASCADE
80 | )"""
81 | )
82 | await conn.execute(
83 | """CREATE TABLE message (
84 | mxid TEXT,
85 | mx_room TEXT NOT NULL,
86 | item_id TEXT,
87 | receiver BIGINT,
88 | sender BIGINT NOT NULL,
89 |
90 | client_context TEXT,
91 | ig_timestamp BIGINT,
92 | PRIMARY KEY (item_id, receiver),
93 | UNIQUE (mxid, mx_room)
94 | )"""
95 | )
96 | await conn.execute(
97 | """CREATE TABLE reaction (
98 | mxid TEXT NOT NULL,
99 | mx_room TEXT NOT NULL,
100 | ig_item_id TEXT,
101 | ig_receiver BIGINT,
102 | ig_sender BIGINT,
103 | reaction TEXT NOT NULL,
104 | mx_timestamp BIGINT,
105 | PRIMARY KEY (ig_item_id, ig_receiver, ig_sender),
106 | FOREIGN KEY (ig_item_id, ig_receiver) REFERENCES message(item_id, receiver)
107 | ON DELETE CASCADE ON UPDATE CASCADE,
108 | UNIQUE (mxid, mx_room)
109 | )"""
110 | )
111 |
112 | gen = ""
113 | if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
114 | gen = "GENERATED ALWAYS AS IDENTITY"
115 | await conn.execute(
116 | f"""
117 | CREATE TABLE backfill_queue (
118 | queue_id INTEGER PRIMARY KEY {gen},
119 | user_mxid TEXT,
120 | priority INTEGER NOT NULL,
121 | portal_thread_id TEXT,
122 | portal_receiver BIGINT,
123 | num_pages INTEGER NOT NULL,
124 | page_delay INTEGER NOT NULL,
125 | post_batch_delay INTEGER NOT NULL,
126 | max_total_pages INTEGER NOT NULL,
127 | dispatch_time TIMESTAMP,
128 | completed_at TIMESTAMP,
129 | cooldown_timeout TIMESTAMP,
130 |
131 | FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
132 | FOREIGN KEY (portal_thread_id, portal_receiver)
133 | REFERENCES portal(thread_id, receiver) ON DELETE CASCADE
134 | )
135 | """
136 | )
137 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v02_name_avatar_set.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add name_set and avatar_set to portal table")
22 | async def upgrade_v2(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE portal ADD COLUMN avatar_url TEXT")
24 | await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
25 | await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
26 | await conn.execute("UPDATE portal SET name_set=true WHERE name<>''")
27 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v03_relay_portal.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add relay user field to portal table")
22 | async def upgrade_v3(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE portal ADD COLUMN relay_user_id TEXT")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v04_message_client_content.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add client context field to message table")
22 | async def upgrade_v4(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE message ADD COLUMN client_context TEXT")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v05_message_ig_timestamp.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add ig_timestamp field to message table")
22 | async def upgrade_v5(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE message ADD COLUMN ig_timestamp BIGINT")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v06_hidden_events.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Allow hidden events in message table")
22 | async def upgrade_v6(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v07_reaction_timestamps.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Store reaction timestamps")
22 | async def upgrade_v7(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE reaction ADD COLUMN mx_timestamp BIGINT")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v08_sync_sequence_id.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Store sync sequence ID in user table")
22 | async def upgrade_v8(conn: Connection) -> None:
23 | await conn.execute('ALTER TABLE "user" ADD COLUMN seq_id BIGINT')
24 | await conn.execute('ALTER TABLE "user" ADD COLUMN snapshot_at_ms BIGINT')
25 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v09_backfill_queue.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection, Scheme
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add the backfill queue table")
22 | async def upgrade_v9(conn: Connection, scheme: Scheme) -> None:
23 | gen = ""
24 | if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
25 | gen = "GENERATED ALWAYS AS IDENTITY"
26 | await conn.execute(
27 | f"""
28 | CREATE TABLE backfill_queue (
29 | queue_id INTEGER PRIMARY KEY {gen},
30 | user_mxid TEXT,
31 | priority INTEGER NOT NULL,
32 | portal_thread_id TEXT,
33 | portal_receiver BIGINT,
34 | num_pages INTEGER NOT NULL,
35 | page_delay INTEGER NOT NULL,
36 | post_batch_delay INTEGER NOT NULL,
37 | max_total_pages INTEGER NOT NULL,
38 | dispatch_time TIMESTAMP,
39 | completed_at TIMESTAMP,
40 | cooldown_timeout TIMESTAMP,
41 |
42 | FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
43 | FOREIGN KEY (portal_thread_id, portal_receiver)
44 | REFERENCES portal(thread_id, receiver) ON DELETE CASCADE
45 | )
46 | """
47 | )
48 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v10_portal_infinite_backfill.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add columns to store infinite backfill pointers for portals")
22 | async def upgrade_v10(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE portal ADD COLUMN first_event_id TEXT")
24 | await conn.execute("ALTER TABLE portal ADD COLUMN next_batch_id TEXT")
25 | await conn.execute("ALTER TABLE portal ADD COLUMN historical_base_insertion_event_id TEXT")
26 | await conn.execute("ALTER TABLE portal ADD COLUMN cursor TEXT")
27 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v11_per_user_thread_sync_status.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(
22 | description="Add columns to the user table to track thread sync status for backfill"
23 | )
24 | async def upgrade_v12(conn: Connection) -> None:
25 | await conn.execute('ALTER TABLE "user" ADD COLUMN oldest_cursor TEXT')
26 | await conn.execute('ALTER TABLE "user" ADD COLUMN total_backfilled_portals INTEGER')
27 | await conn.execute(
28 | 'ALTER TABLE "user" ADD COLUMN thread_sync_completed BOOLEAN NOT NULL DEFAULT false'
29 | )
30 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v12_portal_thread_image_id.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add column to portal to track group thread image ID")
22 | async def upgrade_v12(conn: Connection) -> None:
23 | await conn.execute("ALTER TABLE portal ADD COLUMN thread_image_id INTEGER")
24 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v13_fix_portal_thread_image_id.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection, Scheme
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Fix column to portal to track group thread image ID")
22 | async def upgrade_v13(conn: Connection, scheme: Scheme) -> None:
23 | if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
24 | await conn.execute("ALTER TABLE portal ALTER COLUMN thread_image_id TYPE BIGINT")
25 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/upgrade/v14_puppet_contact_info_set.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan, Sumner Evans
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.async_db import Connection
17 |
18 | from . import upgrade_table
19 |
20 |
21 | @upgrade_table.register(description="Add contact_info_set column to puppet table")
22 | async def upgrade_v14(conn: Connection) -> None:
23 | await conn.execute(
24 | "ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
25 | )
26 |
--------------------------------------------------------------------------------
/mautrix_instagram/db/user.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING, ClassVar
19 |
20 | from attr import dataclass
21 | import asyncpg
22 |
23 | from mauigpapi.state import AndroidState
24 | from mautrix.types import RoomID, UserID
25 | from mautrix.util.async_db import Database
26 |
27 | fake_db = Database.create("") if TYPE_CHECKING else None
28 |
29 |
30 | @dataclass
31 | class User:
32 | db: ClassVar[Database] = fake_db
33 |
34 | mxid: UserID
35 | igpk: int | None
36 | state: AndroidState | None
37 | notice_room: RoomID | None
38 | seq_id: int | None
39 | snapshot_at_ms: int | None
40 | oldest_cursor: str | None
41 | total_backfilled_portals: int | None
42 | thread_sync_completed: bool
43 |
44 | @property
45 | def _values(self):
46 | return (
47 | self.mxid,
48 | self.igpk,
49 | self.state.json() if self.state else None,
50 | self.notice_room,
51 | self.seq_id,
52 | self.snapshot_at_ms,
53 | self.oldest_cursor,
54 | self.total_backfilled_portals,
55 | self.thread_sync_completed,
56 | )
57 |
58 | _columns = ",".join(
59 | (
60 | "mxid",
61 | "igpk",
62 | "state",
63 | "notice_room",
64 | "seq_id",
65 | "snapshot_at_ms",
66 | "oldest_cursor",
67 | "total_backfilled_portals",
68 | "thread_sync_completed",
69 | )
70 | )
71 |
72 | async def insert(self) -> None:
73 | q = f"""
74 | INSERT INTO "user" ({self._columns})
75 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
76 | """
77 | await self.db.execute(q, *self._values)
78 |
79 | async def update(self) -> None:
80 | q = """
81 | UPDATE "user"
82 | SET igpk=$2, state=$3, notice_room=$4, seq_id=$5, snapshot_at_ms=$6,
83 | oldest_cursor=$7, total_backfilled_portals=$8, thread_sync_completed=$9
84 | WHERE mxid=$1
85 | """
86 | await self.db.execute(q, *self._values)
87 |
88 | async def save_seq_id(self) -> None:
89 | q = 'UPDATE "user" SET seq_id=$2, snapshot_at_ms=$3 WHERE mxid=$1'
90 | await self.db.execute(q, self.mxid, self.seq_id, self.snapshot_at_ms)
91 |
92 | @classmethod
93 | def _from_row(cls, row: asyncpg.Record) -> User:
94 | data = {**row}
95 | state_str = data.pop("state")
96 | return cls(state=AndroidState.parse_json(state_str) if state_str else None, **data)
97 |
98 | @classmethod
99 | async def get_by_mxid(cls, mxid: UserID) -> User | None:
100 | q = f'SELECT {cls._columns} FROM "user" WHERE mxid=$1'
101 | row = await cls.db.fetchrow(q, mxid)
102 | if not row:
103 | return None
104 | return cls._from_row(row)
105 |
106 | @classmethod
107 | async def get_by_igpk(cls, igpk: int) -> User | None:
108 | q = f'SELECT {cls._columns} FROM "user" WHERE igpk=$1'
109 | row = await cls.db.fetchrow(q, igpk)
110 | if not row:
111 | return None
112 | return cls._from_row(row)
113 |
114 | @classmethod
115 | async def all_logged_in(cls) -> list[User]:
116 | q = f'SELECT {cls._columns} FROM "user" WHERE igpk IS NOT NULL'
117 | rows = await cls.db.fetch(q)
118 | return [cls._from_row(row) for row in rows]
119 |
--------------------------------------------------------------------------------
/mautrix_instagram/formatter.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2023 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import NamedTuple
19 |
20 | from mautrix.types import MessageEventContent, UserID
21 | from mautrix.util.formatter import (
22 | EntityString,
23 | EntityType,
24 | MarkdownString,
25 | MatrixParser as BaseMatrixParser,
26 | SimpleEntity,
27 | )
28 |
29 | from . import puppet as pu, user as u
30 |
31 |
32 | class SendParams(NamedTuple):
33 | text: str
34 | mentions: list[int]
35 |
36 |
37 | class FacebookFormatString(EntityString[SimpleEntity, EntityType], MarkdownString):
38 | def format(self, entity_type: EntityType, **kwargs) -> FacebookFormatString:
39 | prefix = suffix = ""
40 | if entity_type == EntityType.USER_MENTION:
41 | self.entities.append(
42 | SimpleEntity(
43 | type=entity_type,
44 | offset=0,
45 | length=len(self.text),
46 | extra_info={"igpk": kwargs["igpk"]},
47 | )
48 | )
49 | return self
50 | elif entity_type == EntityType.BOLD:
51 | prefix = suffix = "*"
52 | elif entity_type == EntityType.ITALIC:
53 | prefix = suffix = "_"
54 | elif entity_type == EntityType.STRIKETHROUGH:
55 | prefix = suffix = "~"
56 | elif entity_type == EntityType.URL:
57 | if kwargs["url"] != self.text:
58 | suffix = f" ({kwargs['url']})"
59 | elif entity_type == EntityType.PREFORMATTED:
60 | prefix = f"```{kwargs['language']}\n"
61 | suffix = "\n```"
62 | elif entity_type == EntityType.INLINE_CODE:
63 | prefix = suffix = "`"
64 | elif entity_type == EntityType.BLOCKQUOTE:
65 | children = self.trim().split("\n")
66 | children = [child.prepend("> ") for child in children]
67 | return self.join(children, "\n")
68 | elif entity_type == EntityType.HEADER:
69 | prefix = "#" * kwargs["size"] + " "
70 | else:
71 | return self
72 |
73 | self._offset_entities(len(prefix))
74 | self.text = f"{prefix}{self.text}{suffix}"
75 | return self
76 |
77 |
78 | class MatrixParser(BaseMatrixParser[FacebookFormatString]):
79 | fs = FacebookFormatString
80 |
81 | async def user_pill_to_fstring(
82 | self, msg: FacebookFormatString, user_id: UserID
83 | ) -> FacebookFormatString | None:
84 | entity = await u.User.get_by_mxid(user_id, create=False)
85 | if not entity:
86 | entity = await pu.Puppet.get_by_mxid(user_id, create=False)
87 | if entity and entity.igpk and entity.username:
88 | return FacebookFormatString(f"@{entity.username}").format(
89 | EntityType.USER_MENTION, igpk=entity.igpk
90 | )
91 | return msg
92 |
93 |
94 | async def matrix_to_instagram(content: MessageEventContent) -> SendParams:
95 | parsed = await MatrixParser().parse(content["formatted_body"])
96 | return SendParams(
97 | text=parsed.text,
98 | mentions=[mention.extra_info["igpk"] for mention in parsed.entities],
99 | )
100 |
--------------------------------------------------------------------------------
/mautrix_instagram/get_version.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import subprocess
4 |
5 | from . import __version__
6 |
7 | cmd_env = {
8 | "PATH": os.environ["PATH"],
9 | "HOME": os.environ["HOME"],
10 | "LANG": "C",
11 | "LC_ALL": "C",
12 | }
13 |
14 |
15 | def run(cmd):
16 | return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
17 |
18 |
19 | if os.path.exists(".git") and shutil.which("git"):
20 | try:
21 | git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
22 | git_revision_url = f"https://github.com/mautrix/instagram/commit/{git_revision}"
23 | git_revision = git_revision[:8]
24 | except (subprocess.SubprocessError, OSError):
25 | git_revision = "unknown"
26 | git_revision_url = None
27 |
28 | try:
29 | git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
30 | except (subprocess.SubprocessError, OSError):
31 | git_tag = None
32 | else:
33 | git_revision = "unknown"
34 | git_revision_url = None
35 | git_tag = None
36 |
37 | git_tag_url = f"https://github.com/mautrix/instagram/releases/tag/{git_tag}" if git_tag else None
38 |
39 | if git_tag and __version__ == git_tag[1:].replace("-", ""):
40 | version = __version__
41 | linkified_version = f"[{version}]({git_tag_url})"
42 | else:
43 | if not __version__.endswith("+dev"):
44 | __version__ += "+dev"
45 | version = f"{__version__}.{git_revision}"
46 | if git_revision_url:
47 | linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
48 | else:
49 | linkified_version = version
50 |
--------------------------------------------------------------------------------
/mautrix_instagram/matrix.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2022 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from __future__ import annotations
17 |
18 | from typing import TYPE_CHECKING
19 | import sys
20 |
21 | from mautrix.bridge import BaseMatrixHandler
22 | from mautrix.types import (
23 | Event,
24 | EventID,
25 | EventType,
26 | PresenceEvent,
27 | ReactionEvent,
28 | ReactionEventContent,
29 | ReceiptEvent,
30 | RedactionEvent,
31 | RelationType,
32 | RoomID,
33 | SingleReceiptEventContent,
34 | TypingEvent,
35 | UserID,
36 | )
37 | from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
38 |
39 | from . import portal as po, user as u
40 | from .db import Message as DBMessage
41 |
42 | if TYPE_CHECKING:
43 | from .__main__ import InstagramBridge
44 |
45 |
46 | class MatrixHandler(BaseMatrixHandler):
47 | def __init__(self, bridge: "InstagramBridge") -> None:
48 | prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
49 | homeserver = bridge.config["homeserver.domain"]
50 | self.user_id_prefix = f"@{prefix}"
51 | self.user_id_suffix = f"{suffix}:{homeserver}"
52 |
53 | super().__init__(bridge=bridge)
54 |
55 | async def check_versions(self) -> None:
56 | await super().check_versions()
57 | if self.config["bridge.backfill.msc2716"] and not (
58 | support := self.versions.supports("org.matrix.msc2716")
59 | ):
60 | self.log.fatal(
61 | "Backfilling is enabled in bridge config, but "
62 | + (
63 | "MSC2716 batch sending is not enabled on homeserver"
64 | if support is False
65 | else "homeserver does not support MSC2716 batch sending"
66 | )
67 | )
68 | sys.exit(18)
69 |
70 | async def send_welcome_message(self, room_id: RoomID, inviter: u.User) -> None:
71 | await super().send_welcome_message(room_id, inviter)
72 | if not inviter.notice_room:
73 | inviter.notice_room = room_id
74 | await inviter.update()
75 | await self.az.intent.send_notice(
76 | room_id, "This room has been marked as your Instagram bridge notice room."
77 | )
78 |
79 | async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
80 | portal = await po.Portal.get_by_mxid(room_id)
81 | if not portal:
82 | return
83 |
84 | user = await u.User.get_by_mxid(user_id, create=False)
85 | if not user:
86 | return
87 |
88 | await portal.handle_matrix_leave(user)
89 |
90 | @staticmethod
91 | async def handle_redaction(
92 | room_id: RoomID, user_id: UserID, event_id: EventID, redaction_event_id: EventID
93 | ) -> None:
94 | user = await u.User.get_by_mxid(user_id)
95 | if not user:
96 | return
97 |
98 | portal = await po.Portal.get_by_mxid(room_id)
99 | if not portal:
100 | user.send_remote_checkpoint(
101 | MessageSendCheckpointStatus.PERM_FAILURE,
102 | event_id,
103 | room_id,
104 | EventType.ROOM_REDACTION,
105 | error=Exception("Ignoring redaction event in non-portal room"),
106 | )
107 | return
108 |
109 | await portal.handle_matrix_redaction(user, event_id, redaction_event_id)
110 |
111 | @classmethod
112 | async def handle_reaction(
113 | cls,
114 | room_id: RoomID,
115 | user_id: UserID,
116 | event_id: EventID,
117 | content: ReactionEventContent,
118 | timestamp: int,
119 | ) -> None:
120 | if content.relates_to.rel_type != RelationType.ANNOTATION:
121 | cls.log.debug(
122 | f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected "
123 | f"relation type {content.relates_to.rel_type}"
124 | )
125 | return
126 | user = await u.User.get_by_mxid(user_id)
127 | if not user:
128 | return
129 |
130 | portal = await po.Portal.get_by_mxid(room_id)
131 | if not portal:
132 | return
133 |
134 | await portal.handle_matrix_reaction(
135 | user, event_id, content.relates_to.event_id, content.relates_to.key, timestamp
136 | )
137 |
138 | async def handle_read_receipt(
139 | self,
140 | user: u.User,
141 | portal: po.Portal,
142 | event_id: EventID,
143 | data: SingleReceiptEventContent,
144 | ) -> None:
145 | message = await DBMessage.get_by_mxid(event_id, portal.mxid)
146 | if not message or message.is_internal:
147 | # Message might actually be reaction - mark all as read
148 | message = await DBMessage.get_last(portal.mxid)
149 | if not message:
150 | return
151 | # user.log.debug(f"Marking {message.item_id} in {portal.thread_id} as read")
152 | # await user.mqtt.mark_seen(portal.thread_id, message.item_id)
153 |
154 | @staticmethod
155 | async def handle_typing(room_id: RoomID, typing: list[UserID]) -> None:
156 | portal = await po.Portal.get_by_mxid(room_id)
157 | if not portal:
158 | return
159 | await portal.handle_matrix_typing(set(typing))
160 |
161 | async def handle_event(self, evt: Event) -> None:
162 | if evt.type == EventType.ROOM_REDACTION:
163 | evt: RedactionEvent
164 | await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id)
165 | elif evt.type == EventType.REACTION:
166 | evt: ReactionEvent
167 | await self.handle_reaction(
168 | evt.room_id, evt.sender, evt.event_id, evt.content, evt.timestamp
169 | )
170 |
171 | async def handle_ephemeral_event(
172 | self, evt: ReceiptEvent | PresenceEvent | TypingEvent
173 | ) -> None:
174 | if evt.type == EventType.TYPING:
175 | await self.handle_typing(evt.room_id, evt.content.user_ids)
176 | else:
177 | await super().handle_ephemeral_event(evt)
178 |
--------------------------------------------------------------------------------
/mautrix_instagram/util/__init__.py:
--------------------------------------------------------------------------------
1 | from .color_log import ColorFormatter
2 |
--------------------------------------------------------------------------------
/mautrix_instagram/util/color_log.py:
--------------------------------------------------------------------------------
1 | # mautrix-instagram - A Matrix-Instagram puppeting bridge.
2 | # Copyright (C) 2020 Tulir Asokan
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU Affero General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU Affero General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU Affero General Public License
15 | # along with this program. If not, see .
16 | from mautrix.util.logging.color import (
17 | MXID_COLOR,
18 | PREFIX,
19 | RESET,
20 | ColorFormatter as BaseColorFormatter,
21 | )
22 |
23 | MAUIGPAPI_COLOR = PREFIX + "35;1m" # magenta
24 |
25 |
26 | class ColorFormatter(BaseColorFormatter):
27 | def _color_name(self, module: str) -> str:
28 | if module.startswith("mauigpapi"):
29 | return MAUIGPAPI_COLOR + module + RESET
30 | elif module.startswith("mau.instagram"):
31 | mau, instagram, subtype, user_id = module.split(".", 3)
32 | return (
33 | MAUIGPAPI_COLOR
34 | + f"{mau}.{instagram}.{subtype}"
35 | + RESET
36 | + "."
37 | + MXID_COLOR
38 | + user_id
39 | + RESET
40 | )
41 | return super()._color_name(module)
42 |
--------------------------------------------------------------------------------
/mautrix_instagram/version.py:
--------------------------------------------------------------------------------
1 | from .get_version import git_revision, git_tag, linkified_version, version
2 |
--------------------------------------------------------------------------------
/mautrix_instagram/web/__init__.py:
--------------------------------------------------------------------------------
1 | from .provisioning_api import ProvisioningAPI
2 |
--------------------------------------------------------------------------------
/mautrix_instagram/web/analytics.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import logging
4 |
5 | from yarl import URL
6 | import aiohttp
7 |
8 | from mautrix.util import background_task
9 |
10 | from .. import user as u
11 |
12 | log = logging.getLogger("mau.web.public.analytics")
13 | http: aiohttp.ClientSession | None = None
14 | analytics_url: URL | None = None
15 | analytics_token: str | None = None
16 | analytics_user_id: str | None = None
17 |
18 |
19 | async def _track(user: u.User, event: str, properties: dict) -> None:
20 | await http.post(
21 | analytics_url,
22 | json={
23 | "userId": analytics_user_id or user.mxid,
24 | "event": event,
25 | "properties": {"bridge": "instagram", **properties},
26 | },
27 | auth=aiohttp.BasicAuth(login=analytics_token, encoding="utf-8"),
28 | )
29 | log.debug(f"Tracked {event}")
30 |
31 |
32 | def track(user: u.User, event: str, properties: dict | None = None):
33 | if analytics_token:
34 | background_task.create(_track(user, event, properties or {}))
35 |
36 |
37 | def init(host: str | None, key: str | None, user_id: str | None = None):
38 | log.debug("analytics is a go go")
39 | if not host or not key:
40 | return
41 | global analytics_url, analytics_token, analytics_user_id, http
42 | analytics_url = URL.build(scheme="https", host=host, path="/v1/track")
43 | analytics_token = key
44 | analytics_user_id = user_id
45 | http = aiohttp.ClientSession()
46 |
--------------------------------------------------------------------------------
/optional-requirements.txt:
--------------------------------------------------------------------------------
1 | # Format: #/name defines a new extras_require group called name
2 | # Uncommented lines after the group definition insert things into that group.
3 |
4 | #/e2be
5 | python-olm>=3,<4
6 | unpaddedbase64>=1,<3
7 |
8 | #/metrics
9 | prometheus_client>=0.6,<0.19
10 |
11 | #/proxy
12 | pysocks
13 | aiohttp-socks
14 |
15 | #/imageconvert
16 | pillow>=10.0.1,<11
17 |
18 | #/sqlite
19 | aiosqlite>=0.16,<0.20
20 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.isort]
2 | profile = "black"
3 | force_to_top = "typing"
4 | from_first = true
5 | combine_as_imports = true
6 | known_first_party = "mautrix"
7 | line_length = 99
8 |
9 | [tool.black]
10 | line-length = 99
11 | target-version = ["py38"]
12 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ruamel.yaml>=0.15.35,<0.19
2 | python-magic>=0.4,<0.5
3 | commonmark>=0.8,<0.10
4 | aiohttp>=3,<4
5 | yarl>=1,<2
6 | attrs>=20.1
7 | mautrix>=0.20.2,<0.21
8 | asyncpg>=0.20,<0.30
9 | pycryptodome>=3,<4
10 | paho-mqtt>=1.5,<2
11 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 |
3 | from mautrix_instagram.get_version import git_revision, git_tag, linkified_version, version
4 |
5 | with open("requirements.txt") as reqs:
6 | install_requires = reqs.read().splitlines()
7 |
8 | with open("optional-requirements.txt") as reqs:
9 | extras_require = {}
10 | current = []
11 | for line in reqs.read().splitlines():
12 | if line.startswith("#/"):
13 | extras_require[line[2:]] = current = []
14 | elif not line or line.startswith("#"):
15 | continue
16 | else:
17 | current.append(line)
18 |
19 | extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps})
20 |
21 | try:
22 | long_desc = open("README.md").read()
23 | except IOError:
24 | long_desc = "Failed to read README.md"
25 |
26 | with open("mautrix_instagram/version.py", "w") as version_file:
27 | version_file.write(f"""# Generated in setup.py
28 |
29 | git_tag = {git_tag!r}
30 | git_revision = {git_revision!r}
31 | version = {version!r}
32 | linkified_version = {linkified_version!r}
33 | """)
34 |
35 | setuptools.setup(
36 | name="mautrix-instagram",
37 | version=version,
38 | url="https://github.com/mautrix/instagram",
39 | project_urls={
40 | "Changelog": "https://github.com/mautrix/instagram/blob/master/CHANGELOG.md",
41 | },
42 |
43 | author="Tulir Asokan",
44 | author_email="tulir@maunium.net",
45 |
46 | description="A Matrix-Instagram puppeting bridge.",
47 | long_description=long_desc,
48 | long_description_content_type="text/markdown",
49 |
50 | packages=setuptools.find_packages(),
51 |
52 | install_requires=install_requires,
53 | extras_require=extras_require,
54 | python_requires="~=3.8",
55 |
56 | classifiers=[
57 | "Development Status :: 4 - Beta",
58 | "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
59 | "Topic :: Communications :: Chat",
60 | "Framework :: AsyncIO",
61 | "Programming Language :: Python",
62 | "Programming Language :: Python :: 3",
63 | "Programming Language :: Python :: 3.8",
64 | "Programming Language :: Python :: 3.9",
65 | "Programming Language :: Python :: 3.10",
66 | ],
67 | package_data={
68 | "mautrix_instagram": [
69 | "example-config.yaml",
70 | ],
71 | },
72 | data_files=[
73 | (".", ["mautrix_instagram/example-config.yaml"]),
74 | ],
75 | )
76 |
--------------------------------------------------------------------------------