├── .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 | ![Languages](https://img.shields.io/github/languages/top/mautrix/instagram.svg) 3 | [![License](https://img.shields.io/github/license/mautrix/instagram.svg)](LICENSE) 4 | [![Release](https://img.shields.io/github/release/mautrix/instagram/all.svg)](https://github.com/mautrix/instagram/releases) 5 | [![GitLab CI](https://mau.dev/mautrix/instagram/badges/master/pipeline.svg)](https://mau.dev/mautrix/instagram/container_registry) 6 | [![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 8 | 9 | A Matrix-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 | --------------------------------------------------------------------------------