Browse Source

Make bridge work

Tulir Asokan 4 years ago
parent
commit
47932ad16a

+ 1 - 1
mauigpapi/errors/__init__.py

@@ -4,5 +4,5 @@ from .state import IGUserIDNotFoundError, IGCookieNotFoundError, IGNoCheckpointE
 from .response import (IGResponseError, IGActionSpamError, IGNotFoundError, IGRateLimitError,
                        IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
                        IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
-                       IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
+                       IGLoginTwoFactorRequiredError, IGLoginBadPasswordError, IGBad2FACodeError,
                        IGLoginInvalidUserError, IGNotLoggedInError)

+ 5 - 1
mauigpapi/errors/response.py

@@ -32,7 +32,7 @@ class IGResponseError(IGError):
         if "message" in json:
             message = json["message"]
         type_hint = get_type_hints(type(self)).get("body", JSON)
-        if type_hint is not JSON and isinstance(type_hint, Serializable):
+        if type_hint is not JSON and issubclass(type_hint, Serializable):
             self.body = type_hint.deserialize(json)
         super().__init__(f"{prefix}: {self._message_override or message}")
 
@@ -103,3 +103,7 @@ class IGLoginBadPasswordError(IGLoginError):
 
 class IGLoginInvalidUserError(IGLoginError):
     pass
+
+
+class IGBad2FACodeError(IGResponseError):
+    pass

+ 3 - 2
mauigpapi/http/base.py

@@ -14,7 +14,6 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import Optional, Dict, Any, TypeVar, Type
-import asyncio
 import random
 import time
 import json
@@ -26,7 +25,7 @@ from mautrix.types import JSON, Serializable
 from ..state import AndroidState
 from ..errors import (IGActionSpamError, IGNotFoundError, IGRateLimitError, IGCheckpointError,
                       IGUserHasLoggedOutError, IGLoginRequiredError, IGPrivateUserError,
-                      IGSentryBlockError, IGInactiveUserError, IGResponseError,
+                      IGSentryBlockError, IGInactiveUserError, IGResponseError, IGBad2FACodeError,
                       IGLoginBadPasswordError, IGLoginInvalidUserError,
                       IGLoginTwoFactorRequiredError)
 
@@ -169,6 +168,8 @@ class BaseAndroidAPI:
             raise IGLoginBadPasswordError(resp, data)
         elif error_type == "invalid_user":
             raise IGLoginInvalidUserError(resp, data)
+        elif error_type == "sms_code_validation_code_invalid":
+            raise IGBad2FACodeError(resp, data)
 
         raise IGResponseError(resp, data)
 

+ 4 - 4
mauigpapi/http/login.py

@@ -67,8 +67,8 @@ class LoginAPI(BaseAndroidAPI):
                                         response_type=LoginResponse)
 
     async def two_factor_login(self, username: str, code: str, identifier: str,
-                               trust_device: bool = True, method: Optional[str] = "1"
-                               ) -> LoginResponseUser:
+                               trust_device: bool = True, is_totp: bool = True,
+                               ) -> LoginResponse:
         req = {
             "verification_code": code,
             "_csrftoken": self.state.cookies.csrf_token,
@@ -77,10 +77,10 @@ class LoginAPI(BaseAndroidAPI):
             "trust_this_device": "1" if trust_device else "0",
             "guid": self.state.device.uuid,
             "device_id": self.state.device.id,
-            "verification_method": method,
+            "verification_method": "0" if is_totp else "1",
         }
         return await self.std_http_post("/api/v1/accounts/two_factor_login/", data=req,
-                                        response_type=LoginResponseUser)
+                                        response_type=LoginResponse)
 
     async def logout(self, one_tap_app_login: Optional[bool] = None) -> LogoutResponse:
         req = {

+ 0 - 13
mauigpapi/mqtt/__init__.py

@@ -1,16 +1,3 @@
 from .subscription import SkywalkerSubscription, GraphQLSubscription
-from .types import (RealtimeTopic, ThreadItemType, ThreadAction, ReactionStatus, TypingStatus,
-                    CommandResponse, CommandResponsePayload, Operation, IrisPayload, ImageVersions,
-                    IrisPayloadData, ViewMode, CreativeConfig, CreateModeAttribution, ImageVersion,
-                    VideoVersion, MediaType,RegularMediaItem, FriendshipStatus, MinimalUser, User,
-                    Caption, MediaShareItem, ReplayableMediaItem, VisualMedia, AudioInfo,
-                    VoiceMediaData, VoiceMediaItem, AnimatedMediaItem, AnimatedMediaImage,
-                    AnimatedMediaImages, MessageSyncEvent, MessageSyncMessage, PubsubPayloadData,
-                    PubsubBasePayload, PubsubPublishMetadata, PubsubPayload, PubsubEvent,
-                    ActivityIndicatorData, AppPresenceEventPayload, AppPresenceEvent,
-                    RealtimeZeroProvisionPayload, ZeroProductProvisioningEvent, RealtimeDirectData,
-                    RealtimeDirectEvent, ClientConfigUpdatePayload, ClientConfigUpdateEvent,
-                    LiveVideoCommentPayload, LiveVideoCommentUser, LiveVideoCommentEvent,
-                    LiveVideoComment, LiveVideoSystemComment)
 from .events import Connect, Disconnect
 from .conn import AndroidMQTT

+ 9 - 5
mauigpapi/types/error.py

@@ -71,7 +71,7 @@ class LoginPhoneVerificationSettings(SerializableAttrs['LoginPhoneVerificationSe
     max_sms_count: int
     resend_sms_delay_sec: int
     robocall_count_down_time_sec: int
-    robocall_max_after_sms: bool
+    robocall_after_max_sms: bool
 
 
 @dataclass
@@ -84,6 +84,10 @@ class LoginTwoFactorInfo(SerializableAttrs['LoginTwoFactorInfo']):
     show_messenger_code_option: bool
     show_new_login_screen: bool
     show_trusted_device_option: bool
+    should_opt_in_trusted_device_option: bool
+    pending_trusted_notification: bool
+    # TODO type
+    # sms_not_allowed_reason: Any
     phone_verification_settings: Optional[LoginPhoneVerificationSettings] = None
 
 
@@ -92,9 +96,9 @@ class LoginErrorResponse(SerializableAttrs['LoginErrorResponse']):
     message: str
     status: str
     error_type: str
-    error_title: str
-    buttons: List[LoginErrorResponseButton]
-    invalid_credentials: bool
-    two_factor_required: bool
+    error_title: Optional[str] = None
+    buttons: Optional[List[LoginErrorResponseButton]] = None
+    invalid_credentials: Optional[bool] = None
+    two_factor_required: Optional[bool] = None
     two_factor_info: Optional[LoginTwoFactorInfo] = None
     phone_verification_settings: Optional[LoginPhoneVerificationSettings] = None

+ 2 - 2
mauigpapi/types/thread.py

@@ -40,7 +40,7 @@ class ThreadUserLastSeenAt(SerializableAttrs['UserLastSeenAt']):
     shh_seen_state: Dict[str, Any]
 
 
-@dataclass
+@dataclass(kw_only=True)
 class Thread(SerializableAttrs['Thread']):
     thread_id: str
     thread_v2_id: str
@@ -80,7 +80,7 @@ class Thread(SerializableAttrs['Thread']):
 
     newest_cursor: str
     oldest_cursor: str
-    next_cursor: str
+    next_cursor: Optional[str] = None
     prev_cursor: str
     last_permanent_item: ThreadItem
     items: List[ThreadItem]

+ 46 - 31
mautrix_instagram/commands/auth.py

@@ -13,12 +13,13 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from mautrix.bridge.commands import HelpSection, command_handler
 from mauigpapi.state import AndroidState
 from mauigpapi.http import AndroidAPI
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGLoginError)
+                              IGLoginInvalidUserError, IGBad2FACodeError)
+from mauigpapi.types import BaseResponseUser
 
-from mautrix.bridge.commands import HelpSection, command_handler
 from .typehint import CommandEvent
 
 SECTION_AUTH = HelpSection("Authentication", 10, "")
@@ -35,47 +36,48 @@ async def login(evt: CommandEvent) -> None:
         return
     username = evt.args[0]
     password = " ".join(evt.args[1:])
-    state = AndroidState()
-    state.device.generate(username)
-    api = AndroidAPI(state)
-    await api.simulate_pre_login_flow()
+    if evt.sender.command_status and evt.sender.command_status["action"] == "Login":
+        api: AndroidAPI = evt.sender.command_status["api"]
+        state: AndroidState = evt.sender.command_status["state"]
+    else:
+        evt.log.trace(f"Generating new device for {username}")
+        state = AndroidState()
+        state.device.generate(username)
+        api = AndroidAPI(state)
+        await api.simulate_pre_login_flow()
+        evt.sender.command_status = {
+            "action": "Login",
+            "room_id": evt.room_id,
+            "state": state,
+            "api": api,
+        }
     try:
         resp = await api.login(username, password)
     except IGLoginTwoFactorRequiredError as e:
         tfa_info = e.body.two_factor_info
         msg = "Username and password accepted, but you have two-factor authentication enabled.\n"
-        if tfa_info.sms_two_factor_on:
-            if tfa_info.totp_two_factor_on:
-                msg += (f"Send the code from your authenticator app "
-                        f"or one sent to {tfa_info.obfuscated_phone_number} here.")
-            else:
-                msg += f"Send the code sent to {tfa_info.obfuscated_phone_number} here."
-        elif tfa_info.totp_two_factor_on:
+        if tfa_info.totp_two_factor_on:
             msg += "Send the code from your authenticator app here."
+        elif tfa_info.sms_two_factor_on:
+            msg += f"Send the code sent to {tfa_info.obfuscated_phone_number} here."
         else:
             msg += ("Unfortunately, none of your two-factor authentication methods are currently "
                     "supported by the bridge.")
             return
         evt.sender.command_status = {
-            "action": "Login",
-            "room_id": evt.room_id,
+            **evt.sender.command_status,
             "next": enter_login_2fa,
-
             "username": tfa_info.username,
+            "is_totp": tfa_info.totp_two_factor_on,
             "2fa_identifier": tfa_info.two_factor_identifier,
-            "state": state,
-            "api": api,
         }
+        await evt.reply(msg)
     except IGLoginInvalidUserError:
         await evt.reply("Invalid username")
     except IGLoginBadPasswordError:
         await evt.reply("Incorrect password")
     else:
-        evt.sender.state = state
-        user = resp.logged_in_user
-        await evt.reply(f"Successfully logged in as {user.full_name} ([@{user.username}]"
-                        f"(https://instagram.com/{user.username}), user ID: {user.pk})")
-        await evt.sender.try_connect()
+        await _post_login(evt, api, state, resp.logged_in_user)
 
 
 async def enter_login_2fa(evt: CommandEvent) -> None:
@@ -83,19 +85,32 @@ async def enter_login_2fa(evt: CommandEvent) -> None:
     state: AndroidState = evt.sender.command_status["state"]
     identifier = evt.sender.command_status["2fa_identifier"]
     username = evt.sender.command_status["username"]
-    evt.sender.command_status = None
+    is_totp = evt.sender.command_status["is_totp"]
     try:
-        user = await api.two_factor_login(username, code="".join(evt.args), identifier=identifier)
-    except IGLoginError as e:
-        await evt.reply(f"Failed to log in: {e.body.message}")
+        resp = await api.two_factor_login(username, code="".join(evt.args), identifier=identifier,
+                                          is_totp=is_totp)
+    except IGBad2FACodeError:
+        await evt.reply("Invalid 2-factor authentication code. Please try again "
+                        "or use `$cmdprefix+sp cancel` to cancel.")
     except Exception as e:
         await evt.reply(f"Failed to log in: {e}")
         evt.log.exception("Failed to log in")
+        evt.sender.command_status = None
     else:
-        evt.sender.state = state
-        await evt.reply(f"Successfully logged in as {user.full_name} ([@{user.username}]"
-                        f"(https://instagram.com/{user.username}), user ID: {user.pk})")
-        await evt.sender.try_connect()
+        evt.sender.command_status = None
+        await _post_login(evt, api, state, resp.logged_in_user)
+
+
+async def _post_login(evt: CommandEvent, api: AndroidAPI, state: AndroidState,
+                      user: BaseResponseUser) -> None:
+    await api.simulate_post_login_flow()
+    evt.sender.state = state
+    pl = state.device.payload
+    manufacturer, model = pl["manufacturer"], pl["model"]
+    await evt.reply(f"Successfully logged in as {user.full_name} ([@{user.username}]"
+                    f"(https://instagram.com/{user.username}), user ID: {user.pk}).\n\n"
+                    f"The bridge will show up on Instagram as {manufacturer} {model}.")
+    await evt.sender.try_connect()
 
 
 @command_handler(needs_auth=True, help_section=SECTION_AUTH, help_text="Disconnect the bridge from"

+ 8 - 5
mautrix_instagram/db/puppet.py

@@ -44,13 +44,17 @@ class Puppet:
     next_batch: Optional[SyncToken]
     base_url: Optional[URL]
 
+    @property
+    def _base_url_str(self) -> Optional[str]:
+        return str(self.base_url) if self.base_url else None
+
     async def insert(self) -> None:
         q = ("INSERT INTO puppet (pk, name, username, photo_id, photo_mxc, name_set, avatar_set,"
              "                    is_registered, custom_mxid, access_token, next_batch, base_url) "
              "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
         await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
-                              self.is_registered, self.custom_mxid, self.access_token,
-                              self.next_batch, str(self.base_url) if self.base_url else None)
+                              self.name_set, self.avatar_set, self.is_registered, self.custom_mxid,
+                              self.access_token, self.next_batch, self._base_url_str)
 
     async def update(self) -> None:
         q = ("UPDATE puppet SET name=$2, username=$3, photo_id=$4, photo_mxc=$5, name_set=$6,"
@@ -59,8 +63,7 @@ class Puppet:
              "WHERE pk=$1")
         await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
                               self.name_set, self.avatar_set, self.is_registered, self.custom_mxid,
-                              self.access_token, self.next_batch,
-                              str(self.base_url) if self.base_url else None)
+                              self.access_token, self.next_batch, self._base_url_str)
 
     @classmethod
     def _from_row(cls, row: asyncpg.Record) -> 'Puppet':
@@ -73,7 +76,7 @@ class Puppet:
     async def get_by_pk(cls, pk: int) -> Optional['Puppet']:
         q = ("SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
              "       custom_mxid, access_token, next_batch, base_url "
-             "FROM puppet WHERE igpk=$1")
+             "FROM puppet WHERE pk=$1")
         row = await cls.db.fetchrow(q, pk)
         if not row:
             return None

+ 2 - 2
mautrix_instagram/db/reaction.py

@@ -40,10 +40,10 @@ class Reaction:
         await self.db.execute(q, self.mxid, self.mx_room, self.ig_item_id, self.ig_receiver,
                               self.ig_sender, self.reaction)
 
-    async def edit(self, mx_room: RoomID, mxid: EventID, reaction: ReactionKey) -> None:
+    async def edit(self, mx_room: RoomID, mxid: EventID, reaction: str) -> None:
         await self.db.execute("UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$3 "
                               "WHERE ig_item_id=$4 AND ig_receiver=$5 AND ig_sender=$6",
-                              mxid, mx_room, reaction.value, self.ig_item_id, self.ig_receiver,
+                              mxid, mx_room, reaction, self.ig_item_id, self.ig_receiver,
                               self.ig_sender)
 
     async def delete(self) -> None:

+ 3 - 3
mautrix_instagram/db/user.py

@@ -38,12 +38,12 @@ class User:
         q = ('INSERT INTO "user" (mxid, igpk, state, notice_room) '
              'VALUES ($1, $2, $3, $4)')
         await self.db.execute(q, self.mxid, self.igpk,
-                              self.state.serialize() if self.state else None, self.notice_room)
+                              self.state.json() if self.state else None, self.notice_room)
 
     async def update(self) -> None:
         await self.db.execute('UPDATE "user" SET igpk=$2, state=$3, notice_room=$4 '
                               'WHERE mxid=$1', self.mxid, self.igpk,
-                              self.state.serialize() if self.state else None, self.notice_room)
+                              self.state.json() if self.state else None, self.notice_room)
 
     @classmethod
     def _from_row(cls, row: asyncpg.Record) -> 'User':
@@ -69,7 +69,7 @@ class User:
 
     @classmethod
     async def all_logged_in(cls) -> List['User']:
-        q = ("SELECT mxid, igp, state, notice_room "
+        q = ("SELECT mxid, igpk, state, notice_room "
              'FROM "user" WHERE igpk IS NOT NULL AND state IS NOT NULL')
         rows = await cls.db.fetch(q)
         return [cls._from_row(row) for row in rows]

+ 4 - 3
mautrix_instagram/portal.py

@@ -117,7 +117,7 @@ class Portal(DBPortal, BasePortal):
             self.log.debug(f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}"
                            f" (message: {message.mxid})")
             await intent.redact(existing.mx_room, existing.mxid)
-            await existing.edit(reaction=reaction, mxid=mxid, mx_room=message.mx_room)
+            await existing.edit(emoji=reaction, mxid=mxid, mx_room=message.mx_room)
         else:
             self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})")
             await DBReaction(mxid=mxid, mx_room=message.mx_room, ig_item_id=message.item_id,
@@ -240,8 +240,8 @@ class Portal(DBPortal, BasePortal):
             event_id = None
             if item.text:
                 content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text)
-                # TODO timestamp is probably not milliseconds
-                event_id = await self._send_message(intent, content, timestamp=item.timestamp)
+                event_id = await self._send_message(intent, content,
+                                                    timestamp=item.timestamp // 1000)
             # TODO handle attachments and reactions
             if event_id:
                 await DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
@@ -275,6 +275,7 @@ class Portal(DBPortal, BasePortal):
         # Make sure puppets who should be here are here
         for user in users:
             puppet = await p.Puppet.get_by_pk(user.pk)
+            await puppet.update_info(user)
             await puppet.intent_for(self).ensure_joined(self.mxid)
 
         # Kick puppets who shouldn't be here

+ 9 - 6
mautrix_instagram/puppet.py

@@ -45,12 +45,13 @@ class Puppet(DBPuppet, BasePuppet):
 
     def __init__(self, pk: int, name: Optional[str] = None, username: Optional[str] = None,
                  photo_id: Optional[str] = None, photo_mxc: Optional[ContentURI] = None,
-                 is_registered: bool = False, custom_mxid: Optional[UserID] = None,
-                 access_token: Optional[str] = None, next_batch: Optional[SyncToken] = None,
-                 base_url: Optional[URL] = None) -> None:
-        super().__init__(pk=pk, name=name, username=username, photo_id=photo_id, photo_mxc=photo_mxc,
-                         is_registered=is_registered, custom_mxid=custom_mxid,
-                         access_token=access_token, next_batch=next_batch, base_url=base_url)
+                 name_set: bool = False, avatar_set: bool = False, is_registered: bool = False,
+                 custom_mxid: Optional[UserID] = None, access_token: Optional[str] = None,
+                 next_batch: Optional[SyncToken] = None, base_url: Optional[URL] = None) -> None:
+        super().__init__(pk=pk, name=name, username=username, photo_id=photo_id, name_set=name_set,
+                         photo_mxc=photo_mxc, avatar_set=avatar_set, is_registered=is_registered,
+                         custom_mxid=custom_mxid, access_token=access_token, next_batch=next_batch,
+                         base_url=base_url)
         self.log = self.log.getChild(str(pk))
 
         self.default_mxid = self.get_mxid_from_id(pk)
@@ -110,6 +111,8 @@ class Puppet(DBPuppet, BasePuppet):
         if info.profile_pic_id != self.photo_id or not self.avatar_set:
             self.photo_id = info.profile_pic_id
             if info.profile_pic_id:
+                # TODO if info.has_anonymous_profile_picture, we might need auth to get it
+                #      ...and we should probably download it with the device headers anyway
                 async with ClientSession() as sess, sess.get(info.profile_pic_url) as resp:
                     content_type = resp.headers["Content-Type"]
                     resp_data = await resp.read()

+ 5 - 0
mautrix_instagram/user.py

@@ -76,6 +76,7 @@ class User(DBUser, BaseUser):
         self._metric_value = defaultdict(lambda: False)
         self._is_logged_in = False
         self._listen_task = None
+        self.command_status = None
 
     @classmethod
     def init_cls(cls, bridge: 'InstagramBridge') -> AsyncIterable[Awaitable[None]]:
@@ -107,6 +108,7 @@ class User(DBUser, BaseUser):
             await self.send_bridge_notice("You have been logged out of Instagram")
             return
         self.client = client
+        self._is_logged_in = True
         self.igpk = resp.user.pk
         self.username = resp.user.username
         self._track_metric(METRIC_LOGGED_IN, True)
@@ -234,6 +236,9 @@ class User(DBUser, BaseUser):
 
     @async_time(METRIC_MESSAGE)
     async def handle_message(self, evt: MessageSyncEvent) -> None:
+        # We don't care about messages with no sender
+        if not evt.message.user_id:
+            return
         portal = await po.Portal.get_by_thread_id(evt.message.thread_id, receiver=self.igpk)
         if not portal.mxid:
             # TODO try to find the thread?

+ 1 - 0
mautrix_instagram/util/__init__.py

@@ -0,0 +1 @@
+from .color_log import ColorFormatter

+ 30 - 0
mautrix_instagram/util/color_log.py

@@ -0,0 +1,30 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from mautrix.util.logging.color import (ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR,
+                                        RESET)
+
+MAUIGPAPI_COLOR = PREFIX + "35;1m"  # magenta
+
+
+class ColorFormatter(BaseColorFormatter):
+    def _color_name(self, module: str) -> str:
+        if module.startswith("mauigpapi"):
+            return MAUIGPAPI_COLOR + module + RESET
+        elif module.startswith("mau.instagram"):
+            mau, instagram, subtype, user_id = module.split(".", 3)
+            return (MAUIGPAPI_COLOR + f"{mau}.{instagram}.{subtype}" + RESET
+                    + "." + MXID_COLOR + user_id + RESET)
+        return super()._color_name(module)