|
|
|
@@ -0,0 +1,614 @@
|
|
|
|
|
import asyncio
|
|
|
|
|
import base64
|
|
|
|
|
import io
|
|
|
|
|
import random
|
|
|
|
|
import secrets
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
from core import event
|
|
|
|
|
from core.backend import Backend, BackendSession, Chat
|
|
|
|
|
from core.client import Client
|
|
|
|
|
from core.models import LoginOption, Contact, Substatus, User, TextWithData, OIM, Circle, CircleRole, List
|
|
|
|
|
from settings import TARGET_HOST
|
|
|
|
|
from util.misc import Logger
|
|
|
|
|
from typing import Optional, Callable, Dict, Iterable, Any
|
|
|
|
|
from arc4 import ARC4
|
|
|
|
|
|
|
|
|
|
from .misc import get_grouped_contacts, unmarshal_msim_dict, CommandBitFlag, get_contact_groups
|
|
|
|
|
|
|
|
|
|
last_sesskey = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MSIMCtrl:
|
|
|
|
|
__slots__ = (
|
|
|
|
|
'logger',
|
|
|
|
|
'reader',
|
|
|
|
|
'writer',
|
|
|
|
|
'close_callback',
|
|
|
|
|
'closed',
|
|
|
|
|
'transport',
|
|
|
|
|
'backend',
|
|
|
|
|
'bs',
|
|
|
|
|
'client',
|
|
|
|
|
'nonce',
|
|
|
|
|
'sesskey',
|
|
|
|
|
'keep_alive_task'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Escargot-specific
|
|
|
|
|
logger: Logger
|
|
|
|
|
reader: 'MSIMReader'
|
|
|
|
|
writer: 'MSIMWriter'
|
|
|
|
|
close_callback: Optional[Callable[[], None]]
|
|
|
|
|
closed: bool
|
|
|
|
|
transport: Optional[asyncio.WriteTransport]
|
|
|
|
|
backend: Backend
|
|
|
|
|
bs: Optional[BackendSession]
|
|
|
|
|
client: Client
|
|
|
|
|
|
|
|
|
|
# Frontend-specific
|
|
|
|
|
nonce: bytes
|
|
|
|
|
sesskey: int
|
|
|
|
|
keep_alive_task: Optional[asyncio.Task]
|
|
|
|
|
|
|
|
|
|
def __init__(self, logger: Logger, via: str, backend: Backend) -> None:
|
|
|
|
|
self.logger = logger
|
|
|
|
|
self.reader = MSIMReader(logger)
|
|
|
|
|
self.writer = MSIMWriter(logger)
|
|
|
|
|
self.close_callback = None
|
|
|
|
|
self.closed = False
|
|
|
|
|
self.transport = None
|
|
|
|
|
|
|
|
|
|
self.backend = backend
|
|
|
|
|
self.bs = None
|
|
|
|
|
self.client = Client('msim', '?', via)
|
|
|
|
|
|
|
|
|
|
global last_sesskey
|
|
|
|
|
last_sesskey += 1
|
|
|
|
|
|
|
|
|
|
self.nonce = secrets.token_bytes(64)
|
|
|
|
|
self.sesskey = last_sesskey
|
|
|
|
|
self.keep_alive_task = None
|
|
|
|
|
|
|
|
|
|
def _m_login2(self, data_pairs: Dict[str, str]) -> None:
|
|
|
|
|
email = data_pairs['username']
|
|
|
|
|
response = base64.b64decode(data_pairs['response'])
|
|
|
|
|
clientver = data_pairs['clientver']
|
|
|
|
|
|
|
|
|
|
valid = False
|
|
|
|
|
|
|
|
|
|
# get nc1 & nc2 (first/last 0x20 bytes respectively)
|
|
|
|
|
nc1, nc2 = self.nonce[:32], self.nonce[32:]
|
|
|
|
|
|
|
|
|
|
# log what we got
|
|
|
|
|
self.logger.info('Packet email:', email)
|
|
|
|
|
self.logger.info('Packet response:', response)
|
|
|
|
|
self.logger.info('Packet clientver:', clientver)
|
|
|
|
|
|
|
|
|
|
if (uuid := self.backend.util_get_uuid_from_email(email)) is None:
|
|
|
|
|
self.logger.info('E-mail not found!')
|
|
|
|
|
else:
|
|
|
|
|
# get our hashed password
|
|
|
|
|
hashed_pwd = self.backend.user_service.msim_get_sha1_password(email)
|
|
|
|
|
|
|
|
|
|
if hashed_pwd is None:
|
|
|
|
|
self.logger.info('MySpaceIM frontend-specific password not found!')
|
|
|
|
|
else:
|
|
|
|
|
final_hash = hashlib.sha1(b''.join([hashed_pwd, nc2])).digest()
|
|
|
|
|
rc4_key = final_hash[:16] # we only need the first 128 bits of the result
|
|
|
|
|
|
|
|
|
|
self.logger.info('Hashed password:', hashed_pwd.hex(' '))
|
|
|
|
|
self.logger.info('Full hash:', final_hash.hex(' '))
|
|
|
|
|
self.logger.info('RC4 key:', rc4_key.hex(' '))
|
|
|
|
|
|
|
|
|
|
arc4 = ARC4(rc4_key)
|
|
|
|
|
blob = arc4.decrypt(response)
|
|
|
|
|
|
|
|
|
|
self.logger.info('RC4 blob data:', blob)
|
|
|
|
|
|
|
|
|
|
seperator = blob.find(bytes(4))
|
|
|
|
|
if seperator != -1:
|
|
|
|
|
blob_nc1 = blob[:32]
|
|
|
|
|
blob_email = blob[32:seperator]
|
|
|
|
|
|
|
|
|
|
self.logger.info('nc1 (context):', nc1)
|
|
|
|
|
self.logger.info('nc1 (request):', blob_nc1)
|
|
|
|
|
self.logger.info('E-mail (blob):', blob_email)
|
|
|
|
|
|
|
|
|
|
if nc1 == blob_nc1 and email == blob_email.decode():
|
|
|
|
|
valid = True
|
|
|
|
|
|
|
|
|
|
# allow user in if valid flag was set
|
|
|
|
|
if valid:
|
|
|
|
|
# set client to use `clientver`
|
|
|
|
|
self.client = Client('msim', f'1.0.{clientver}.0', self.client.via)
|
|
|
|
|
|
|
|
|
|
# set BackendSession
|
|
|
|
|
self.bs = self.backend.login(uuid, self.client, BackendEventHandler(self), option=LoginOption.BootOthers)
|
|
|
|
|
|
|
|
|
|
# log that they successfully signed in and send reply giving sesskey / user id / username / etc
|
|
|
|
|
self.logger.info(email, "successfully signed-in!")
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'lc': 2,
|
|
|
|
|
'sesskey': self.sesskey,
|
|
|
|
|
'proof': random.randint(0x00000, 0xFFFFF),
|
|
|
|
|
'userid': self.bs.user.id,
|
|
|
|
|
'profileid': self.bs.user.id,
|
|
|
|
|
'uniquenick': self.bs.user.username,
|
|
|
|
|
'id': 1
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# start 180-second timer to send keep-alives periodically
|
|
|
|
|
self.keep_alive_task = asyncio.create_task(self.send_keep_alive_periodically())
|
|
|
|
|
else:
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'error': True,
|
|
|
|
|
'errmsg': 'The password provided is incorrect.',
|
|
|
|
|
'err': 260,
|
|
|
|
|
'fatal': True
|
|
|
|
|
})
|
|
|
|
|
self.close()
|
|
|
|
|
|
|
|
|
|
def _m_persist(self, data_pairs: Dict[str, str]) -> None:
|
|
|
|
|
bs = self.bs
|
|
|
|
|
user = bs.user
|
|
|
|
|
detail = user.detail
|
|
|
|
|
|
|
|
|
|
# get cmd, dsn, lid (command type/family/subcode respectively)
|
|
|
|
|
cmd = int(data_pairs['cmd']) # i.e. 1 - Get, 2 - Action, 3 - Delete
|
|
|
|
|
dsn = int(data_pairs['dsn'])
|
|
|
|
|
lid = int(data_pairs['lid'])
|
|
|
|
|
|
|
|
|
|
# get request/response ID
|
|
|
|
|
rid = int(data_pairs['rid'])
|
|
|
|
|
|
|
|
|
|
# get body
|
|
|
|
|
body = data_pairs['body']
|
|
|
|
|
|
|
|
|
|
def get_user_by_id(id: int) -> Optional[User]:
|
|
|
|
|
if (uuid := self.backend.util_get_uuid_from_user_id(id)) is None:
|
|
|
|
|
self.logger.info('User ID', id, 'not found!')
|
|
|
|
|
self.send_persist_error(cmd, dsn, lid, rid, f'User ID {id} not found')
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
found_user = self.backend._load_user_record(uuid)
|
|
|
|
|
if found_user is None:
|
|
|
|
|
self.logger.info('Unable to find uuid')
|
|
|
|
|
self.send_persist_error(cmd, dsn, lid, rid, 'Unable to find uuid')
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return found_user
|
|
|
|
|
|
|
|
|
|
match (cmd, dsn, lid):
|
|
|
|
|
# 1;0;1 - list all contacts
|
|
|
|
|
case (1, 0, 1):
|
|
|
|
|
msim_contacts = []
|
|
|
|
|
grouped_contacts = get_grouped_contacts(self.bs)
|
|
|
|
|
pos = 0
|
|
|
|
|
|
|
|
|
|
for group, contacts in grouped_contacts.items():
|
|
|
|
|
for contact in contacts:
|
|
|
|
|
head = contact.head
|
|
|
|
|
|
|
|
|
|
pos += 1
|
|
|
|
|
|
|
|
|
|
# For some reason, the MySpaceIM devs made it UNIX time in nano-seconds.
|
|
|
|
|
# *Ahem* Kill me.
|
|
|
|
|
last_login = int(head.date_login.timestamp() * 1e9)
|
|
|
|
|
|
|
|
|
|
msim_contacts.append({
|
|
|
|
|
'ContactID': head.id,
|
|
|
|
|
'Headline': '?', # Status
|
|
|
|
|
'Position': pos,
|
|
|
|
|
'GroupName': group,
|
|
|
|
|
'Visibility': 1,
|
|
|
|
|
'AvatarUrl': '',
|
|
|
|
|
'ShowAvatar': False, # No avatar yet xd
|
|
|
|
|
'LastLogin': last_login,
|
|
|
|
|
'IMName': head.username,
|
|
|
|
|
'NickName': head.username,
|
|
|
|
|
'NameSelect': 0, # 0 = nickname, 1 = email, 2 = email address
|
|
|
|
|
'OfflineMsg': '',
|
|
|
|
|
'SkyStatus': 0
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, msim_contacts)
|
|
|
|
|
|
|
|
|
|
# 1;2;6 - list all contact groups
|
|
|
|
|
case (1, 2, 6):
|
|
|
|
|
msim_groups = []
|
|
|
|
|
id = 0
|
|
|
|
|
|
|
|
|
|
for group in get_contact_groups(bs):
|
|
|
|
|
id += 1
|
|
|
|
|
|
|
|
|
|
msim_groups.append({
|
|
|
|
|
'GroupID': id,
|
|
|
|
|
'GroupName': group,
|
|
|
|
|
'Position': id,
|
|
|
|
|
'GroupFlag': 131073 # unclear what GroupFlag does, TODO(subpurple): figure it out
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# self.send_persist_reply(cmd, dsn, lid, rid, msim_groups)
|
|
|
|
|
|
|
|
|
|
# 1;1;4 or 1;1;7 - look-up MySpaceIM-specific user info about yourself or by user ID
|
|
|
|
|
#
|
|
|
|
|
# 1;1;4 - should lookup self
|
|
|
|
|
# 1;1;7 - should lookup by given user ID
|
|
|
|
|
# - contains `UserID` field that 1;1;7 does not
|
|
|
|
|
case (1, 1, 4) | (1, 1, 7):
|
|
|
|
|
# default to our user id unless we are given a 1;1;4 persist message
|
|
|
|
|
user_id = self.bs.user.id
|
|
|
|
|
|
|
|
|
|
if lid == 7:
|
|
|
|
|
body_dict = unmarshal_msim_dict(body)
|
|
|
|
|
user_id = body_dict['UserID']
|
|
|
|
|
|
|
|
|
|
if (user := get_user_by_id(user_id)) is None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, {
|
|
|
|
|
'UserID': user.id,
|
|
|
|
|
'Sound': True,
|
|
|
|
|
'!PrivacyMode': 0, # 0 = Anyone, 1 = Only people on my Contact List
|
|
|
|
|
'!ShowOnlyToList': False, # False = Anyone, True = Only people on my Contact List.
|
|
|
|
|
'!OfflineMessageMode': 0, # 0 = Everyone, 1 = Only people on my Contact List, 2 = No one.
|
|
|
|
|
'Headline': '',
|
|
|
|
|
'Avatarurl': '',
|
|
|
|
|
'Alert': '',
|
|
|
|
|
'!ShowAvatar': False,
|
|
|
|
|
'IMName': '', # No real way to get this right now so default
|
|
|
|
|
'!ClientVersion': 0, # No real way to get this right now so default
|
|
|
|
|
'!AllowBrowse': True,
|
|
|
|
|
'IMLang': 'English',
|
|
|
|
|
'LangID': 8192
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 1;4;3 or 1;4;5 - look-up MySpace user info by user ID
|
|
|
|
|
#
|
|
|
|
|
# 1;4;3 - should lookup by given user ID
|
|
|
|
|
# 1;4;5 - should lookup self
|
|
|
|
|
case (1, 4, 3) | (1, 4, 5):
|
|
|
|
|
body_dict = unmarshal_msim_dict(body)
|
|
|
|
|
user_id = body_dict['UserID']
|
|
|
|
|
|
|
|
|
|
if (user := get_user_by_id(user_id)) is None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Since CrossTalk doesn't have a social media platform unlike the original MySpaceIM platform, we simply
|
|
|
|
|
# give default values to some of the keys here (e.g. BandName, SongName, Age, Gender, and Location).
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, {
|
|
|
|
|
'UserName': user.username,
|
|
|
|
|
'Email': user.email,
|
|
|
|
|
'UserID': user.id,
|
|
|
|
|
'ImageURL': '',
|
|
|
|
|
'DisplayName': user.username,
|
|
|
|
|
'BandName': '',
|
|
|
|
|
'SongName': '',
|
|
|
|
|
'Age': 0,
|
|
|
|
|
'Gender': 'M',
|
|
|
|
|
'Location': '',
|
|
|
|
|
'!TotalFriends': len(user.detail.contacts)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 1;7;18 - query for social media (MySpace) notifications
|
|
|
|
|
case (1, 7, 18):
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, {
|
|
|
|
|
# 'Mail': 'On',
|
|
|
|
|
# 'BlogComment': 'Off',
|
|
|
|
|
# 'ProfileComment': 'Off',
|
|
|
|
|
# 'FriendRequest': 'Off',
|
|
|
|
|
# 'PictureComment': 'Off'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 1;6;11 - network hyperlink request
|
|
|
|
|
case (1, 6, 11):
|
|
|
|
|
# e.g. Target=now.FriendID=1
|
|
|
|
|
#
|
|
|
|
|
# i'm not certain what the appropriate URLs to `Target` would be, but for now we send
|
|
|
|
|
# `TARGET_HOST`
|
|
|
|
|
body_dict = unmarshal_msim_dict(body)
|
|
|
|
|
target = body_dict['Target']
|
|
|
|
|
|
|
|
|
|
self.logger.info('Target:', target)
|
|
|
|
|
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, {
|
|
|
|
|
'WebTicket': TARGET_HOST
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 514;0;9 (2^512;0;9) - update contact info
|
|
|
|
|
case (514, 0, 9):
|
|
|
|
|
self.logger.info('Update contact info')
|
|
|
|
|
|
|
|
|
|
# 2;1;16 - undocumented
|
|
|
|
|
#
|
|
|
|
|
# however, based off of the command type (2 - reply), and client packet logs, it appears to be
|
|
|
|
|
# update group info
|
|
|
|
|
case (2, 2, 16):
|
|
|
|
|
self.send_persist_reply(cmd, dsn, lid, rid, '')
|
|
|
|
|
|
|
|
|
|
case _:
|
|
|
|
|
self.logger.info(f'Unknown persist command: {cmd};{dsn};{lid}')
|
|
|
|
|
|
|
|
|
|
def on_connect(self) -> None:
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'lc': 1,
|
|
|
|
|
'nc': self.nonce,
|
|
|
|
|
'id': 1
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def on_data_recieved(self, data: bytes, *, transport: Optional[asyncio.BaseTransport] = None) -> None:
|
|
|
|
|
if transport is None:
|
|
|
|
|
transport = self.transport
|
|
|
|
|
|
|
|
|
|
assert transport is not None
|
|
|
|
|
|
|
|
|
|
for m in self.reader.data_recieved(data):
|
|
|
|
|
# get command, which is always the first key
|
|
|
|
|
command = next(iter(m))
|
|
|
|
|
|
|
|
|
|
# run `_m_xxx` where xxx is the command if found in class, otherwise log that we didn't find it
|
|
|
|
|
try:
|
|
|
|
|
f = getattr(self, f'_m_{command}')
|
|
|
|
|
f(m)
|
|
|
|
|
except AttributeError:
|
|
|
|
|
self.logger.info('Invalid command:', command)
|
|
|
|
|
|
|
|
|
|
def send_reply(self, data_pairs: Dict[str, int | str | bytes | bool]) -> None:
|
|
|
|
|
self.writer.write(data_pairs)
|
|
|
|
|
|
|
|
|
|
transport = self.transport
|
|
|
|
|
if transport is not None:
|
|
|
|
|
transport.write(self.flush())
|
|
|
|
|
|
|
|
|
|
def send_persist_reply(self, cmd: int, dsn: int, lid: int, rid: int, body: Any) -> None:
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'persistr': True,
|
|
|
|
|
'uid': self.bs.user.id,
|
|
|
|
|
'cmd': cmd ^ CommandBitFlag.CallbackReply,
|
|
|
|
|
'dsn': dsn,
|
|
|
|
|
'lid': lid,
|
|
|
|
|
'rid': rid,
|
|
|
|
|
'body': body
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def send_persist_error(self, cmd: int, dsn: int, lid: int, rid: int, error_msg: str) -> None:
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'persistr': True,
|
|
|
|
|
'cmd': cmd ^ CommandBitFlag.CallbackError,
|
|
|
|
|
'dsn': dsn,
|
|
|
|
|
'uid': self.bs.user.id,
|
|
|
|
|
'lid': lid,
|
|
|
|
|
'rid': rid,
|
|
|
|
|
'ErrorMessage': error_msg
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async def send_keep_alive_periodically(self):
|
|
|
|
|
while True:
|
|
|
|
|
await asyncio.sleep(180)
|
|
|
|
|
|
|
|
|
|
self.send_reply({
|
|
|
|
|
'ka': True
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def flush(self) -> bytes:
|
|
|
|
|
return self.writer.flush()
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
if self.closed:
|
|
|
|
|
return
|
|
|
|
|
self.closed = True
|
|
|
|
|
|
|
|
|
|
if self.close_callback:
|
|
|
|
|
self.close_callback()
|
|
|
|
|
if self.bs:
|
|
|
|
|
self.bs.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BackendEventHandler(event.BackendEventHandler):
|
|
|
|
|
__slots__ = ('ctrl', 'bs')
|
|
|
|
|
|
|
|
|
|
ctrl: MSIMCtrl
|
|
|
|
|
|
|
|
|
|
def __init__(self, ctrl: MSIMCtrl) -> None:
|
|
|
|
|
self.ctrl = ctrl
|
|
|
|
|
|
|
|
|
|
def on_maintenance_message(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_maintenance message')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_maintenance_boot(self) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_maintenance_boot')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_presence_notification(
|
|
|
|
|
self, ctc: Contact, on_contact_add: bool, old_substatus: Substatus, *,
|
|
|
|
|
trid: Optional[str] = None, update_status: bool = True, update_info_other: bool = True,
|
|
|
|
|
send_status_on_bl: bool = False, sess_id: Optional[int] = None,
|
|
|
|
|
updated_phone_info: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_presence_notification')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_presence_self_notification(self, old_substatus: Substatus, *, update_status: bool = True,
|
|
|
|
|
update_info: bool = True) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_presence_self_notification')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_chat_invite(
|
|
|
|
|
self, chat: Chat, inviter: User, *, circle: bool = False, inviter_id: Optional[str] = None,
|
|
|
|
|
invite_msg: str = '',
|
|
|
|
|
) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_chat_invite')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_declined_chat_invite(self, chat: Chat, circle: bool = False) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_declined_chat_invite')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_added_me(self, user: User, *, adder_id: Optional[str] = None,
|
|
|
|
|
message: Optional[TextWithData] = None) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_added_me')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_removed_me(self, user: User) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_removed_me')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_contact_request_denied(self, user_added: User, message: Optional[str], *,
|
|
|
|
|
contact_id: Optional[str] = None) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_contact_request_denied')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_oim_sent(self, oim: OIM) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_oim_sent')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_login_elsewhere(self, option: LoginOption) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_login_elsewhere')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_circle_invite_revoked(self, chat_id: str) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_circle_invite_revoked')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_accepted_circle_invite(self, circle: Circle) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_accepted_circle_invite')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_circle_updated(self, circle: Circle) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_circle_updated')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_left_circle(self, circle: Circle) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_left_circle')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_circle_role_updated(self, chat_id: str, role: CircleRole) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_circle_role_updated')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_circle_created(self, circle: Circle) -> None:
|
|
|
|
|
self.ctrl.logger.info('on_circle_created')
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def on_close(self) -> None:
|
|
|
|
|
self.ctrl.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MSIMReader:
|
|
|
|
|
__slots__ = ('_logger', '_buf')
|
|
|
|
|
|
|
|
|
|
_logger: Logger
|
|
|
|
|
_buf: bytes
|
|
|
|
|
|
|
|
|
|
def __init__(self, logger: Logger) -> None:
|
|
|
|
|
self._logger = logger
|
|
|
|
|
self._buf = b''
|
|
|
|
|
|
|
|
|
|
def data_recieved(self, data: bytes) -> Iterable[Dict[str, str]]:
|
|
|
|
|
self._buf += data
|
|
|
|
|
|
|
|
|
|
while self._buf:
|
|
|
|
|
m = self._read()
|
|
|
|
|
if m is None:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
yield m
|
|
|
|
|
|
|
|
|
|
def _read(self) -> Optional[Dict[str, str]]:
|
|
|
|
|
# e.g.
|
|
|
|
|
# >>> \lc\1\nc\b'YFKltYx6p9Ve2YE+jX9aJjLxynwgzkdi+nOZqZZufXE='\id\1\final\
|
|
|
|
|
# <<< \login2\196610\username\toxidation@msn.com\response\bFKdo4E01WHpwimji5wdSgnjifhV7zv1mGg+ZzHSKqaO4bjV0uq9MzMU9Nk0UmzTFN+nI/u2KUf7fuM=\clientver\673\reconn\0\status\100\id\1\final\
|
|
|
|
|
#
|
|
|
|
|
# each message is terminated with \final\
|
|
|
|
|
try:
|
|
|
|
|
i = self._buf.index(b'\\final\\')
|
|
|
|
|
except (IndexError, ValueError):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# extract message up to \final\`
|
|
|
|
|
data = self._buf[:i + len(b'\\final\\')].decode('utf-8')
|
|
|
|
|
|
|
|
|
|
# advance buffer to exclude `data`
|
|
|
|
|
self._buf = self._buf[i + len(b'\\final\\'):]
|
|
|
|
|
|
|
|
|
|
# log what we got
|
|
|
|
|
self._logger.debug('[Client]', data)
|
|
|
|
|
|
|
|
|
|
# split string by `\` and skip first member due to leading backslash
|
|
|
|
|
parts = data.split('\\')[1:]
|
|
|
|
|
|
|
|
|
|
# convert `parts` into dict by treating first member as key, second member as value, and so on
|
|
|
|
|
data_pairs = {}
|
|
|
|
|
|
|
|
|
|
for i in range(0, len(parts), 2):
|
|
|
|
|
key = parts[i]
|
|
|
|
|
value = parts[i + 1] if i + 1 < len(parts) else ""
|
|
|
|
|
|
|
|
|
|
data_pairs[key] = value
|
|
|
|
|
|
|
|
|
|
return data_pairs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MSIMWriter:
|
|
|
|
|
__slots__ = ('_logger', '_buf')
|
|
|
|
|
|
|
|
|
|
_logger: Logger
|
|
|
|
|
_buf: io.BytesIO
|
|
|
|
|
|
|
|
|
|
def __init__(self, logger: Logger) -> None:
|
|
|
|
|
self._logger = logger
|
|
|
|
|
self._buf = io.BytesIO()
|
|
|
|
|
|
|
|
|
|
def write(self, data_pairs: Dict[str, Any]) -> None:
|
|
|
|
|
m = ''
|
|
|
|
|
|
|
|
|
|
for key, value in data_pairs.items():
|
|
|
|
|
m += f'\\{key}'
|
|
|
|
|
|
|
|
|
|
# skip over if value is bool and it isn't True
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
if not value:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# otherwise, add \1 as the value pair
|
|
|
|
|
m += '\\1'
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# MySpaceIM dictionaries are e.g.
|
|
|
|
|
# k1=v2\x1ck2=v3
|
|
|
|
|
#
|
|
|
|
|
# which then deserialize to:
|
|
|
|
|
# k1: v2
|
|
|
|
|
# k2: v3
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
m += '\\'
|
|
|
|
|
|
|
|
|
|
for d in value:
|
|
|
|
|
if isinstance(d, dict):
|
|
|
|
|
m += '\x1c'.join(f'{key}={value}' for key, value in d.items())
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
m += '\\'
|
|
|
|
|
m += '\x1c'.join(f'{key}={value}' for key, value in value.items())
|
|
|
|
|
|
|
|
|
|
# encode `value` in base64 and add that as its value
|
|
|
|
|
elif isinstance(value, bytes):
|
|
|
|
|
b = base64.b64encode(value)
|
|
|
|
|
m += f'\\{b.decode('ascii')}'
|
|
|
|
|
|
|
|
|
|
# treat any other type (e.g. int or str) not seen above as a string
|
|
|
|
|
else:
|
|
|
|
|
m += f'\\{value}'
|
|
|
|
|
|
|
|
|
|
m += '\\final\\'
|
|
|
|
|
|
|
|
|
|
self._logger.debug('[Server]', m)
|
|
|
|
|
self._buf.write(m.encode('utf-8'))
|
|
|
|
|
|
|
|
|
|
def flush(self) -> bytes:
|
|
|
|
|
data = self._buf.getvalue()
|
|
|
|
|
if data:
|
|
|
|
|
self._buf = io.BytesIO()
|
|
|
|
|
|
|
|
|
|
return data
|