from typing import Tuple, Dict, Any, Optional, List from datetime import datetime, timezone from lxml.etree import fromstring as parse_xml, XMLSyntaxError import base64, secrets, hmac, re, binascii, struct, time, asyncio, settings, random, json from email.parser import Parser from hashlib import sha1 from urllib.parse import quote from disposable_email_domains import blocklist as disposable_emails from util.misc import Logger, gen_uuid, date_format, MultiDict from core import event, error from core.backend import Backend, BackendSession, Chat, ChatSession from core.models import ( Substatus, ContactList, NetworkID, User, Group, Circle, CircleRole, CircleState, Contact, TextWithData, MessageData, MessageType, LoginOption, OIM ) from core.client import Client from .msnp import MSNPCtrl from .misc import ( build_presence_notif, cid_format, encode_payload, decode_capabilities_capabilitiesex, decode_email_networkid, normalize_pop_id, decode_email_pop, gen_mail_data, gen_signedticket_xml, uuid_to_high_low, generate_rps_key, encrypt_with_key_and_iv_tripledes_cbc, gen_chal_response, Err, MSNStatus, MSNObj, ) MSNP_DIALECTS = ['MSNP{}'.format(d) for d in ( # Not yet supported 22, # Actually supported 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 )] class MSNPCtrlNS(MSNPCtrl): __slots__ = ( 'backend', 'dialect', 'usr_email', 'email_for_validation', 'bs', 'client', 'syn_ser', 'gcf_sent', 'syn_sent', 'iln_sent', 'is_third_party_client', 'challenge', 'rps_challenge', 'circle_authenticated', 'new_circles', 'initial_adl_sent', 'circle_adl_sent', 'password', 'cs', 'email_conflict' ) backend: Backend dialect: int usr_email: Optional[str] # user's real e-mail email_for_validation: Optional[str] # for validation etc bs: Optional[BackendSession] client: Client is_third_party_client: bool syn_ser: int syn_sent: bool gcf_sent: bool iln_sent: bool challenge: Optional[str] rps_challenge: Optional[bytes] circle_authenticated: bool new_circles: List[Circle] initial_adl_sent: bool circle_adl_sent: bool password: Optional[str] cs: Optional[ChatSession] email_conflict: bool def __init__(self, logger: Logger, via: str, backend: Backend) -> None: super().__init__(logger) self.backend = backend self.dialect = 0 self.usr_email = None self.email_for_validation = None self.bs = None self.password = None self.client = Client('msn', '?', via) self.is_third_party_client = False self.syn_ser = 0 self.syn_sent = False self.gcf_sent = False self.iln_sent = False self.challenge = None self.rps_challenge = None self.circle_authenticated = False self.new_circles = [] self.initial_adl_sent = False self.circle_adl_sent = False self.cs = None self.email_conflict = False def _on_close(self) -> None: if self.bs: self.bs.close() def on_connect(self) -> None: pass # State = Auth def _m_ver(self, trid: str, *args: str) -> None: #>>> VER trid MSNPz MSNPy MSNPx [CVR0] if self.dialect != 0: self.send_reply(Err.NotExpected, trid) self.close() return dialects = [a.upper() for a in args] try: _ = int(trid) except ValueError: self.close() d = None for d in MSNP_DIALECTS: if d in dialects: break if d not in dialects: self.send_reply('VER', trid, 0) self.close() return self.client = Client('msn', d, self.client.via) self.dialect = int(d[4:]) self.send_reply('VER', trid, d) def _m_cvr(self, trid: str, *args: str) -> None: v = args[5] client_type = args[4] if client_type not in ['MSNMSGR', 'MSMSGS', 'MSGSTRST', 'MSMSGSMARS', 'WLMSGRBETA', 'MSG80BETA', 'MSNMSGRBETAM2', 'MSNMSGRBETA', 'macmsgs']: self.is_third_party_client = True self.client = Client('msn', v, self.client.via) self.send_reply('CVR', trid, v, v, '1.0.0000', 'https://crosstalk.im/downloads', 'https://crosstalk.im/compat') def _m_inf(self, trid: str) -> None: dialect = self.dialect if self.dialect < 9: self.send_reply('INF', trid, 'MD5') else: self.close() def _m_usr(self, trid: str, authtype: str, stage: str, *args: str) -> None: dialect = self.dialect backend = self.backend machineguid: Optional[str] = None if authtype == "CTP": try: if dialect < 2: self.close() return if self.bs: self.send_reply(Err.DuplicateSession, trid) return if stage == "I": self.password = None self.password = args[1] pwd = self.password assert pwd is not None email = args[0] self.usr_email = email if "@" not in email: self.send_reply(Err.AuthFail, trid) self.close() return uuid = backend.user_service.login(email, pwd) if uuid is None and email.lower().endswith('@crosstalk.im'): username = email.lower()[:-len('@crosstalk.im')] uuid = backend.user_service.login_with_username(username, pwd) if uuid is not None: bs = backend.login( uuid, self.client, BackendEventHandler(self.backend.loop, self), option=LoginOption.BootOthers, ) token, _ = backend.login_auth_service.create_token('ns/login', [uuid, None], lifetime = 86400) self.bs = bs # Dumb workaround except UnboundLocalError: self.send_reply(Err.AuthFail, trid) self.close() else: self._auth_final_step(trid, email, token or '', None) if authtype == 'SHA': if dialect < 18: self.close() return # Used in MSNP18 (at least, for now) to validate Circle tickets # found in ABFindContactsPaged responses bs = self.bs assert bs is not None signedticket = args[0] if stage == 'A': #>>> USR trid SHA A b64_signedticket self.circle_authenticated = True self.send_reply('USR', trid, 'OK', self.backend.util_get_display_email(bs.user), 0, 0) if self.circle_authenticated: for circle in self.new_circles: self.send_reply('NFY', 'PUT', encode_payload(PAYLOAD_MSG_7, email = _encode_email_epid(self.backend.util_get_display_email(bs.user), bs.front_data.get('msn_pop_id')), chat_id = circle.chat_id, )) self.new_circles.clear() return if authtype == 'MD5': # Some early builds of MSN 5.0 (0124 and 0149 I believe) use MSNP8 but also MD5 auth if dialect >= 9: self.close() return if self.bs: self.send_reply(Err.DuplicateSession, trid) return if stage == 'I': #>>> USR trid MD5 I email@example.com email = args[0] if '@' not in email: self.send_reply(Err.AuthFail, trid) self.close() return self.usr_email = email username, domain = email.split('@', 1) if dialect == 2: uuid = backend.user_service.get_uuid_username(username) if uuid is None: uuid = backend.user_service.get_uuid(email) if uuid is None: self.send_reply(Err.AuthFail, trid) self.close() return user = backend.user_service.get(uuid) email = self.backend.util_get_display_email(user) salt = backend.user_service.msn_get_md5_salt(email) if salt is None and email.lower().endswith('@crosstalk.im'): real_uuid = backend.user_service.get_uuid_username(email.lower()[:-len('@crosstalk.im')]) if real_uuid is not None: real_user = backend.user_service.get(real_uuid) if real_user is not None: salt = backend.user_service.msn_get_md5_salt(real_user.email) email = real_user.email self.email_for_validation = email else: self.email_for_validation = self.usr_email if salt is None: # Account is not enabled for login via MD5; send `USR S` with Unix time as salt simply to # keep MSNP `USR` flow consistent (`USR I` doesn't validate existence of email, but rather # whether its format it correct, `USR S` actually does account checks) # TODO: Can we pass an informative message to user? salt = str(time.time()) self.send_reply('USR', trid, authtype, 'S', salt) return if stage == 'S': #>>> USR trid MD5 S md5_hash token = None # type: Optional[str] if backend.maintenance_mode: self.send_reply(Err.InternalServerError, trid) self.close() return md5_hash = args[0] usr_email = self.usr_email.lower() assert usr_email is not None uuid = backend.user_service.msn_login_md5(self.email_for_validation, md5_hash) if uuid is not None: self.bs = backend.login(uuid, self.client, BackendEventHandler(self.backend.loop, self), option = LoginOption.BootOthers) token, _ = backend.login_auth_service.create_token('ns/login', [uuid, None], lifetime = 86400) print(f"usr_email during MD5 stage S: {usr_email}") self._auth_final_step(trid, usr_email, token or '', None) return if authtype == 'TWN': if dialect >= 16 or dialect < 8: self.close() return if self.bs: self.send_reply(Err.InvalidUser, trid) return if stage == 'I': #>>> USR trid TWN I email@example.com self.usr_email = args[0] if '@' not in self.usr_email: self.send_reply(Err.AuthFail, trid) self.close() return if dialect >= 13: self.send_reply('GCF', 0, SHIELDS_MSNP13) self.send_reply('USR', trid, authtype, 'S', 'ct=1,rver=1,wp=FS_40SEC_0_COMPACT,lc=1,id=1') return if stage == 'S': #>>> USR trid TWN S auth_token if backend.maintenance_mode: self.send_reply(Err.InternalServerError, trid) self.close() return token = args[0] if token[0:2] == 't=': token = token[2:22] usr_email = self.usr_email assert usr_email is not None self.logger.debug(f"Token: {token}") tpl = backend.login_auth_service.get_token('ns/login', token) if tpl is not None: uuid = tpl[0] assert uuid is not None self.bs = backend.login(uuid, self.client, BackendEventHandler(self.backend.loop, self), option = LoginOption.BootOthers) self._auth_final_step(trid, usr_email, token, None) return if authtype == 'SSO': if dialect < 15: self.close() return if self.bs: self.send_reply(Err.InvalidUser, trid) return if stage == 'I': #>>> USR trid SSO I email@example.com self.usr_email = args[0] if '@' not in self.usr_email: self.send_reply(Err.AuthFail, trid) self.close() return # https://yellows111.darkok.xyz/msnp/authschemes/sso.html self.rps_challenge = base64.b64encode(secrets.token_bytes(48)) self.send_reply('GCF', 0, SHIELDS_MSNP13) self.send_reply('USR', trid, authtype, 'S', 'MBI_KEY_OLD', self.rps_challenge.decode('utf-8')) return if stage == 'S': #>>> USR trid SSO S auth_token [b64_response] [machineguid] if backend.maintenance_mode: self.send_reply(Err.InternalServerError, trid) self.close() return token = args[0] if token[0:2] == 't=': token = token[2:22] usr_email = self.usr_email assert usr_email is not None self.logger.debug(f"Token: {token}") tpl = backend.login_auth_service.get_token('ns/login', token) option = None if tpl is not None: uuid = tpl[0] assert uuid is not None response = None rps = False if dialect >= 16 or (dialect < 16 and len(args) > 1): rps = True self.logger.debug('RPS challenge:', rps) if rps: assert self.rps_challenge is not None response_b64 = args[1] try: response = base64.b64decode(response_b64) except: self.send_reply(Err.AuthFail, trid) self.close() return if len(response) < 28: self.send_reply(Err.AuthFail, trid) self.close() return if struct.unpack('= 16: machineguid = args[2] if not re.match(r'^\{?[A-Fa-f0-9]{8,8}-([A-Fa-f0-9]{4,4}-){3,3}[A-Fa-f0-9]{12,12}\}?', machineguid): self.send_reply(Err.AuthFail, trid) self.close() return user = backend._load_user_record(uuid) if user is not None: bses_self = backend.util_get_sessions_by_user(user) for bs_self in bses_self: pop_id = bs_self.front_data.get('msn_pop_id') if pop_id is not None and pop_id.lower() == normalize_pop_id(machineguid).lower(): option = LoginOption.BootOthers break if not option: option = LoginOption.NotifyOthers else: self.send_reply(Err.AuthFail, trid) self.close() return else: option = LoginOption.BootOthers self.bs = backend.login(uuid, self.client, BackendEventHandler(self.backend.loop, self), option = option) self._auth_final_step(trid, usr_email, token, machineguid) return def _auth_final_step(self, trid: str, attempted_email: str, token: str, machineguid: Optional[str]) -> None: from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa bs = self.bs dialect = self.dialect print(f"bs: {bs}") if bs is None: self.send_reply(Err.AuthFail, trid) self.close() return user = bs.user email = self.backend.util_get_display_email(user) usr_email_lower = (self.usr_email or '').lower() alias_email = '{}@crosstalk.im'.format(user.username).lower() print(f"email: {email}") print(f"attempted email: {attempted_email}") print(f"self.usr_email: {self.usr_email}") if usr_email_lower not in (user.email.lower(), alias_email, email.lower()): self.send_reply(Err.AuthFail, trid) self.close() return else: if user.alias_active and attempted_email != alias_email: #or user.alias_active and email == user.email.lower(): self.send_reply(Err.AuthFail, trid) self.close() return self.backend.util_set_sess_token(bs, token) bs.front_data['msn'] = True bs.front_data['windowslive_chats'] = {} if dialect >= 16: assert machineguid is not None bs.front_data['msn_pop_id'] = normalize_pop_id(machineguid).lower() bs.front_data['msn_circleticket_sig'] = rsa.generate_private_key( public_exponent = 65537, key_size = 2048, backend = default_backend(), # type: ignore ) now = datetime.now() if dialect < 10: args = (user.status.name,) # type: Tuple[Any, ...] else: args = () if dialect >= 6: verified = True # TODO: check for verified_to_login when email verification is finished args += ((1 if verified else 0),) if dialect >= 8: args += (0,) with open('config/restricted-emails.json', 'r') as file: restricted_emails = json.load(file) email_domain = user.email.lower().split('@')[-1] if email_domain in restricted_emails or email_domain in disposable_emails: self.send_reply(Err.InvalidDomain, trid) self.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, email = email, icon = '', url = 'https://crosstalk.im/alias-prep', msg = "Your e-mail address contains a disallowed domain and needs to be changed before you can log in. Click this alert to learn more.", )) self.close() return if user.suspended: self.send_reply(Err.MemberIsSuspended, trid) self.close() return #if not user.verified_to_login: # self.send_reply(Err.AccountNotVerified, trid) # self.close() # return else: print(f"args: {args}") self.send_reply('USR', trid, 'OK', email, *args) (high, low) = uuid_to_high_low(user.uuid) (ip, port) = self.peername if dialect >= 19: msg0 = encode_payload(PAYLOAD_MSG_0, email_address = email, endpoint_ID = '{00000000-0000-0000-0000-000000000000}', timestamp = now.isoformat()[:19] + 'Z', ) self.send_reply('NFY', 'PUT', msg0) elif dialect >= 11: self.send_reply('SBS', 0, 'null') msg1 = encode_payload(PAYLOAD_MSG_1, time = int(now.timestamp()), high = high, low = low, email = email, token = token, ip = ip, port = port, mpop = (0 if not machineguid else 1), nickname = user.status.name, server_ip = settings.TARGET_IP, sess_id = str(id(bs)) ) real_email_domain = user.email.lower().split('@')[-1] if real_email_domain in restricted_emails or real_email_domain in disposable_emails: self.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, email = email, icon = '', url = 'https://crosstalk.im/alias-prep', msg = "Your e-mail address contains a disallowed domain and needs to be changed before you can log in. Click this alert to learn more.", )) self.close() return if dialect >= 3: self.send_reply('MSG', 'Hotmail', 'Hotmail', msg1) #if self.email_conflict: # self.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, # email = user.email, icon = 'http://static.ugnet.gay/', url = 'https://crosstalk.im/alias-prep', # msg = "Your e-mail address contains the domain \"@crosstalk.im\" and needs to be changed to avoid conflicts and potential account deletion in the future. Click this alert to learn more.", # )) if settings.STRESS_TEST_ACTIVE: self.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, email = email, icon = '', url = 'https://crosstalk.im', msg = "We're currently doing a stress test of the CrossTalk service. Put load on the server, and tell your friends!", )) if 16 <= dialect < 19: # MSNP21 doesn't use this; I don't believe 19 & 20 do either # https://pastebin.com/gECWthGE # ```[01:59:46 DEBUG papyon.transport] <<< NLN NLN 1:andre-stein@hotmail.com '%20André%20Steinn 2688340284:2550273040 %3cmsnobj%20Creator%3d%22andre-stein%40hotmail.com%22%20Size%3d%225390%22%20Type%3d%223%22%20Location%3d%220%22%20Friendly%3d%22AAA%3d%22%20SHA1D%3d%22RZW585t5UbA8LqXWQVoT8nYbOYA%3d%22%20SHA1C%3d%227Ceck6FU1qeOgrWRsRxsFR8yh8g%3d%22%2f%3e``` # ```[01:59:47 DEBUG papyon.transport] <<< UBX 1:andre-stein@hotmail.com 1300``` # # This seems to suggest data *was* sent in the initial UBX at some point (along with a status right before) - possibly related to MPoP. If we had more info to work with this could be possibly implemented. if dialect >= 18: rst = ('1:' + email,) else: rst = (email, '1') self.send_reply('UBX', *rst, b'') # State = Live def _m_syn(self, trid: str, *extra: str) -> None: bs = self.bs dialect = self.dialect assert bs is not None user = bs.user user_settings = user.settings detail = user.detail assert detail is not None contacts = detail.contacts self.syn_sent = True if dialect < 10: self.syn_ser = int(extra[0]) ser = self._ser() if dialect < 7: self.send_reply('SYN', trid, ser) self.send_reply('GTC', trid, ser, user_settings.get('GTC', 'A')) self.send_reply('BLP', trid, ser, user_settings.get('BLP', 'AL')) if dialect >= 5: for prp_setting in ('PHH','PHW','PHM','MOB','MBE'): prp_value = user_settings.get(prp_setting) if prp_value: self.send_reply('PRP', ser, prp_setting, prp_value) for lst in (ContactList.FL, ContactList.AL, ContactList.BL, ContactList.RL): cs = [c for c in contacts.values() if c.lists & lst] if cs: for i, c in enumerate(cs): self.send_reply('LST', trid, lst.name, ser, i + 1, len(cs), self.backend.util_get_display_email(c.head), c.status.name) if dialect >= 5: for bpr_setting in ('PHH','PHM','PHW','MOB'): bpr_value = c.head.settings.get(bpr_setting) if bpr_value: self.send_reply('BPR', ser, bpr_setting, bpr_value) else: self.send_reply('LST', trid, lst.name, ser, 0, 0) elif dialect == 7: self.send_reply('SYN', trid, ser) self.send_reply('GTC', trid, ser, user_settings.get('GTC', 'A')) self.send_reply('BLP', trid, ser, user_settings.get('BLP', 'AL')) for prp_setting in ('PHH','PHW','PHM','MOB','MBE'): prp_value = user_settings.get(prp_setting) if prp_value: self.send_reply('PRP', ser, prp_setting, prp_value) num_groups = len(detail._groups_by_id.values()) + 1 self.send_reply('LSG', trid, ser, 1, num_groups, '0', "Other Contacts", 0) for i, g in enumerate(detail._groups_by_id.values()): self.send_reply('LSG', trid, ser, i + 2, num_groups, g.id, g.name, 0) for lst in (ContactList.FL, ContactList.AL, ContactList.BL, ContactList.RL): cs = [c for c in contacts.values() if c.lists & lst] if cs: for i, c in enumerate(cs): gs = ((','.join([group.id for group in c._groups.copy()]) or '0') if lst == ContactList.FL else None) self.send_reply('LST', trid, lst.name, ser, i + 1, len(cs), self.backend.util_get_display_email(c.head), c.status.name or self.backend.util_get_display_email(c.head), gs) for bpr_setting in ('PHH','PHM','PHW','MOB'): bpr_value = c.head.settings.get(bpr_setting) if bpr_value: self.send_reply('BPR', ser, bpr_setting, bpr_value) else: self.send_reply('LST', trid, lst.name, ser, 0, 0) else: num_groups = len(detail._groups_by_id.values()) + 1 self.send_reply('SYN', trid, ser, len(contacts), num_groups) self.send_reply('GTC', user_settings.get('GTC', 'A')) self.send_reply('BLP', user_settings.get('BLP', 'AL')) for prp_setting in ('PHH','PHW','PHM','MOB','MBE'): prp_value = user_settings.get(prp_setting) if prp_value: self.send_reply('PRP', prp_setting, prp_value) self.send_reply('LSG', '0', "Other Contacts", 0) for g in detail._groups_by_id.values(): self.send_reply('LSG', g.id, g.name, 0) for c in contacts.values(): self.send_reply( 'LST', self.backend.util_get_display_email(c.head), c.status.name or self.backend.util_get_display_email(c.head), int(c.lists), ','.join([group.id for group in c._groups.copy()]) or '0', ) for bpr_setting in ('PHH','PHM','PHW','MOB'): bpr_value = c.head.settings.get(bpr_setting) if bpr_value: self.send_reply('BPR', bpr_setting, bpr_value) elif 10 <= self.dialect <= 12: self.send_reply('SYN', trid, TIMESTAMP, TIMESTAMP, len(contacts), len(detail._groups_by_id.values())) self.send_reply('GTC', user_settings.get('GTC', 'A')) self.send_reply('BLP', user_settings.get('BLP', 'AL')) self.send_reply('PRP', 'MFN', user.status.name) for prp_setting in ('PHH','PHW','PHM','MOB','MBE'): prp_value = user_settings.get(prp_setting) if prp_value: self.send_reply('PRP', prp_setting, prp_value) for g in detail._groups_by_id.values(): self.send_reply('LSG', g.name, g.uuid) for c in contacts.values(): lists = c.lists if c.pending: # Forge `PL` to lists for pending contacts lists |= ContactList.PL if lists & ContactList.RL: lists &= ~ContactList.RL self.send_reply('LST', 'N={}'.format(self.backend.util_get_display_email(c.head)), 'F={}'.format(c.status.name or self.backend.util_get_display_email(c.head)), ('C={}'.format(c.head.uuid) if c.lists & ContactList.FL else None), int(lists), (None if dialect < 12 else '1'), ','.join([group.uuid for group in c._groups.copy()]) ) for bpr_setting in ('PHH','PHM','PHW','MOB'): bpr_value = c.head.settings.get(bpr_setting) if bpr_value: self.send_reply('BPR', bpr_setting, bpr_value) else: self.send_reply(Err.CommandDisabled, trid) return def _m_gcf(self, trid: str, filename: str) -> None: if self.dialect < 11: self.close() return if self.dialect < 13 and not self.syn_sent: self.send_reply(Err.NotExpected, trid) return self.send_reply('GCF', trid, filename, SHIELDS) def _m_png(self) -> None: if self.bs is None: self.close() return self.send_reply('QNG', (60 if self.dialect >= 9 else None)) def _m_uux(self, trid: str, data: bytes) -> None: bs = self.bs assert bs is not None user = bs.user elm = parse_xml(data.decode('utf-8')) ed = elm.find('EndpointData') if ed: capabilities = ed.find('Capabilities').text capabilities_lst = decode_capabilities_capabilitiesex(capabilities) if capabilities_lst: bs.front_data['msn_capabilities'] = capabilities_lst[0] or 0 bs.front_data['msn_capabilitiesex'] = capabilities_lst[1] or 0 ped = elm.find('PrivateEndpointData') endpoint_name = elm.find('EpName') if endpoint_name is not None: bs.front_data['msn_epname'] = endpoint_name.text idle = elm.find('Idle') if idle is not None: bs.front_data['msn_endpoint_idle'] = (True if idle.text == 'true' else False) client_type = elm.find('ClientType') if client_type is not None: bs.front_data['msn_client_type'] = client_type.text state = elm.find('State') if state is not None: try: bs.front_data['msn_ep_state'] = getattr(MSNStatus, state.text).name except: self.close() return psm = elm.find('PSM') cm = elm.find('CurrentMedia') mg = elm.find('MachineGuid') if mg is not None and mg.text is not None: bs.front_data['msn_machineguid'] = mg.text ddp = elm.find('DDP') if ddp is not None: bs.front_data['msn_msnobj_ddp'] = ddp.text sigsound = elm.find('SignatureSound') if sigsound is not None: bs.front_data['msn_sigsound'] = sigsound.text scene = elm.find('Scene') if scene is not None: bs.front_data['msn_msnobj_scene'] = scene.text colorscheme = elm.find('ColorScheme') if colorscheme is not None: bs.front_data['msn_colorscheme'] = colorscheme.text bs.me_update({ 'message': ((psm.text or '') if psm is not None else None), 'media': ((cm.text or '') if cm is not None else None), 'needs_notify': (True if ddp is not None or sigsound is not None or scene is not None or colorscheme is not None or user.status.substatus is not Substatus.Offline else False), 'notify_self': (True if self.dialect >= 16 and user.status.substatus is not Substatus.Offline else False), 'notify_info': True, }) self.send_reply('UUX', trid, 0) def _m_url(self, trid: str, site: str, ignored: Optional[str] = None) -> None: if site == 'CHAT': self.send_reply('URL', trid, 'chatrooms.msgrsvcs.ctsrv.gay', 'chatrooms.msgrsvcs.ctsrv.gay', 1) elif site == 'ADDRBOOK': self.send_reply('URL', trid, '/', 'addressbook.ugnet.gay', 1) elif site == 'COMPOSE': self.send_reply('URL', trid, '/', 'compose.mail.ugnet.gay', 1) elif site == 'INBOX': self.send_reply('URL', trid, '/', 'inbox.mail.ugnet.gay', 1) elif site == 'PROFILE': self.send_reply('URL', trid, '/', 'members.ugnet.gay') else: self.send_reply('URL', trid, 'NOT_AVAILABLE', 'NOT_AVAILABLE', 1) def _m_adg(self, trid: str, name: str, ignored: Optional[str] = None) -> None: #>>> ADG 276 New Group bs = self.bs assert bs is not None try: group = bs.me_group_add(name) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return self.send_reply('ADG', trid, self._ser(), name, (group.id if self.dialect < 10 else group.uuid), 0) def _m_rmg(self, trid: str, group_id: str) -> None: #>>> RMG 250 00000000-0000-0000-0001-000000000001 bs = self.bs assert bs is not None detail = bs.user.detail assert detail is not None g = None # type: Optional[Group] if group_id == 'New%20Group': # Bug: MSN 7.0 sends name instead of id in a particular scenario for g in detail._groups_by_id.values(): if g.name != 'New Group': continue group_id = (g.id if self.dialect < 10 else g.uuid) break else: if self.dialect < 10: g = detail._groups_by_id.get(group_id) else: g = detail._groups_by_uuid.get(group_id) if g is None: self.send_reply(Err.GroupInvalid, trid) return try: bs.me_group_remove(group_id) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return self.send_reply('RMG', trid, self._ser() or 1, group_id) def _m_reg(self, trid: str, group_id: str, name: str, ignored: Optional[str] = None) -> None: #>>> REG 275 00000000-0000-0000-0001-000000000001 newname bs = self.bs assert bs is not None detail = bs.user.detail assert detail is not None if self.dialect < 10: g = detail._groups_by_id.get(group_id) else: g = detail._groups_by_uuid.get(group_id) if g is None: self.send_reply(Err.GroupInvalid, trid) return try: bs.me_group_edit(group_id, new_name = name) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return if self.dialect < 10: self.send_reply('REG', trid, self._ser(), group_id, name, 0) else: self.send_reply('REG', trid, 1, name, group_id, 0) def _m_adl(self, trid: str, data: bytes) -> None: # This code fucking sucks lmao if self.dialect < 13: self.close() return backend = self.backend bs = self.bs assert bs is not None user = bs.user detail = user.detail assert detail is not None c_nids = [] # type: List[NetworkID] chat_id = None circle_mode = False try: adl_xml = parse_xml(data.decode('utf-8')) l = adl_xml.get('l') initial = (l == '1') d_els = adl_xml.findall('d') for d_el in d_els: domains = [] # type: List[str] if len(d_el.getchildren()) == 0: self.send_reply(Err.XXLEmptyDomain, trid) self.close() return else: domain = d_el.get('n') if domain in domains or domain is None: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return domains.append(domain) for i, d_el in enumerate(d_els): domain = d_el.get('n') c_els = d_el.findall('c') if i == 0: try: c_nids = [NetworkID(int(c_el.get('t'))) for c_el in c_els] if NetworkID.CIRCLE in c_nids: if ( NetworkID.WINDOWS_LIVE in c_nids or NetworkID.OFFICE_COMMUNICATOR in c_nids or NetworkID.TELEPHONE in c_nids or NetworkID.MNI in c_nids or NetworkID.SMTP in c_nids or NetworkID.YAHOO in c_nids ): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return if domain == 'live.com': d_els_rest = d_els[1:] if d_els_rest: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return circle_mode = True except ValueError: self.send_reply(Err.InvalidNetworkID, trid) self.close() return if initial and not circle_mode: # core handles syncing contact lists; ignore request self.send_reply('ADL', trid, 'OK') if not self.initial_adl_sent: self.initial_adl_sent = True return if circle_mode: if not self.circle_authenticated: self.send_reply(Err.InvalidCircleMembership, trid) return for c_el in c_els: lsts = None if self.dialect >= 19: s_els = c_el.findall('s') for s_el in s_els: if s_el is not None and s_el.get('n') == 'IM': try: lsts = ContactList(int(s_el.get('l'))) except ValueError: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return if lsts is None: continue else: try: lsts = ContactList(int(c_el.get('l'))) except ValueError: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return if lsts & (ContactList.RL | ContactList.PL): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return username = c_el.get('n') if circle_mode: if username is not None and username.startswith('00000000-0000-0000-0009-'): try: chat_id = username[-12:] except: self.send_reply(Err.InvalidCircleMembership, trid) return else: self.send_reply(Err.InvalidCircleMembership, trid) return if circle_mode and (self.initial_adl_sent and not self.circle_adl_sent): self.circle_adl_sent = True if circle_mode: circle = backend.user_service.get_circle(chat_id or '') if circle is None and self.dialect < 20: # hack until i figure out how to make WLM 2011 not dc when you send it 933 self.send_reply(Err.InvalidCircleMembership, trid) return membership = circle.memberships.get(user.uuid) if membership is None or (membership is not None and membership.state != CircleState.Accepted) and self.dialect < 20: # hack until i figure out how to make WLM 2011 not dc when you send it 933 self.send_reply(Err.InvalidCircleMembership, trid) return if not circle_mode: email = '{}@{}'.format(username, domain) contact_uuid = backend.util_get_uuid_from_email(email) if contact_uuid is None: self.send_reply(Err.InvalidUser2, trid) return if circle_mode and self.initial_adl_sent and not self.circle_adl_sent: self.circle_adl_sent = True for d_el in d_els: domain = d_el.get('n') for c_el in c_els: ctc = None lsts = None username = c_el.get('n') if self.dialect >= 19: s_els = c_el.findall('s') for s_el in s_els: if s_el is not None and s_el.get('n') == 'IM': lsts = ContactList(int(s_el.get('l'))) if lsts is None: continue else: lsts = ContactList(int(c_el.get('l'))) if circle_mode: on_unblock = False chat_id = username[-12:] circle = backend.user_service.get_circle(chat_id) if circle is None: continue if lsts & ContactList.FL or lsts & ContactList.AL: cs = None if circle.memberships[user.uuid].blocking and not lsts & ContactList.BL: on_unblock = True if on_unblock: try: bs.me_unblock_circle(circle) except: pass try: cs = backend.join_circle(chat_id, 'msn', bs, ChatEventHandler(self.backend.loop, self, bs), pop_id = bs.front_data.get('msn_pop_id')) except: if on_unblock: cs = backend.get_circle_cs(chat_id, bs) raise Exception("Circle bad") if cs is None: continue chat = cs.chat bs.evt.msn_on_notify_circle_ab(chat_id) chat.send_participant_joined(cs, initial_join = (on_unblock is False)) if lsts & ContactList.BL: try: bs.me_block_circle(circle) except: pass else: email = '{}@{}'.format(username, domain) contact_uuid = backend.util_get_uuid_from_email(email) if contact_uuid is not None: try: ctc, _ = bs.me_contact_add(contact_uuid, lsts, name = email) except error.ContactListIsFull: self.send_reply(Err.ContactListLimitReached, trid) return except Exception: pass if lsts & ContactList.FL and not initial: if ctc is not None: bs.evt.on_presence_notification(ctc, False, Substatus.Offline, trid = trid) except Exception as ex: if isinstance(ex, XMLSyntaxError): self.send_reply(Err.XXLInvalidPayload, trid) self.close() else: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return self.send_reply('ADL', trid, 'OK') def _m_rml(self, trid: str, data: bytes) -> None: if self.dialect < 13: self.close() return backend = self.backend bs = self.bs assert bs is not None d_el = None c_nids = [] # type: List[NetworkID] circle_mode = False try: rml_xml = parse_xml(data.decode('utf-8')) d_els = rml_xml.findall('d') for d_el in d_els: if len(d_el.getchildren()) == 0: self.send_reply(Err.XXLEmptyDomain, trid) self.close() return for d_el in d_els: domain = d_el.get('n') c_els = d_el.findall('c') for c_el in c_els: lsts = None if self.dialect >= 19: s_els = c_el.findall('s') for s_el in s_els: if s_el is not None and s_el.get('n') == 'IM': try: lsts = ContactList(int(s_el.get('l'))) except ValueError: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return else: try: lsts = ContactList(int(c_el.get('l'))) except ValueError: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return try: c_nids = [NetworkID(int(c_el.get('t'))) for c_el in c_els] if NetworkID.CIRCLE in c_nids: if ( NetworkID.WINDOWS_LIVE in c_nids or NetworkID.OFFICE_COMMUNICATOR in c_nids or NetworkID.TELEPHONE in c_nids or NetworkID.MNI in c_nids or NetworkID.SMTP in c_nids or NetworkID.YAHOO in c_nids ): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return circle_mode = True except ValueError: self.send_reply(Err.InvalidNetworkID, trid) self.close() return if lsts & (ContactList.RL | ContactList.PL): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return username = c_el.get('n') if circle_mode: if username is not None and username.startswith('00000000-0000-0000-0009-'): try: chat_id = username[-12:] except: self.send_reply(Err.InvalidCircleMembership, trid) return else: self.send_reply(Err.InvalidCircleMembership, trid) return if circle_mode: if self.dialect < 19: # hack until i figure out how to make WLM 2011 not dc when you send it 933 if backend.user_service.get_circle(chat_id or '') is None: self.send_reply(Err.InvalidCircleMembership, trid) return chat = backend.chat_get('persistent', chat_id) if chat is None: self.send_reply(Err.InvalidCircleMembership, trid) return else: pass for c_el in c_els: if self.dialect < 20: s_els = c_el.findall('s') for s_el in s_els: lsts = ContactList(int(s_el.get('l'))) else: if self.dialect == 21: s_els = c_el.findall('s') for s_el in s_els: if s_el is not None and s_el.get('n') == 'IM': lsts = ContactList(int(s_el.get('l'))) if lsts is None: continue if not circle_mode: username = c_el.get('n') email = '{}@{}'.format(username, domain) if self.dialect == 21: s_els = c_el.findall('s') for s_el in s_els: if s_el is not None and s_el.get('n') == 'IM': lsts = ContactList(int(s_el.get('l'))) if lsts is None: continue contact_uuid = backend.util_get_uuid_from_email(email) if contact_uuid is not None: try: bs.me_contact_remove(contact_uuid, lsts) except Exception: pass except Exception as ex: if isinstance(ex, XMLSyntaxError): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return else: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return self.send_reply('RML', trid, 'OK') def _m_adc(self, trid: str, lst_name: str, arg1: str, arg2: Optional[str] = None) -> None: if self.dialect < 10: self.close() return if arg1.startswith('N='): #>>> ADC 249 BL N=bob1@hotmail.com #>>> ADC 278 AL N=foo@hotmail.com #>>> ADC 277 FL N=foo@hotmail.com F=foo@hotmail.com email = arg1[2:] if '@' not in email: self.send_reply(Err.InvalidParameter, trid) return if lst_name == 'RL': self.send_reply(Err.InvalidParameter, trid) return contact_uuid = self.backend.util_get_uuid_from_email(email) group_id = None name = (arg2[2:] if arg2 else None) else: # Add C= to group #>>> ADC 246 FL C=00000000-0000-0000-0002-000000000002 00000000-0000-0000-0001-000000000003 contact_uuid = arg1[2:] group_id = arg2 name = None self._add_common(trid, lst_name, contact_uuid, name, group_id) def _m_add(self, trid: str, lst_name: str, email: str, name: Optional[str] = None, group_id: Optional[str] = None) -> None: #>>> ADD 122 FL email name group if self.dialect >= 10: self.close() return if '@' not in email: self.send_reply(Err.InvalidParameter, trid) return contact_uuid = self.backend.util_get_uuid_from_email(email) self._add_common(trid, lst_name, contact_uuid, name, group_id) def _add_common( self, trid: str, lst_name: str, contact_uuid: Optional[str], name: Optional[str] = None, group_id: Optional[str] = None, ) -> None: dialect = self.dialect bs = self.bs assert bs is not None user = bs.user detail = user.detail assert detail is not None send_bpr_info = False if contact_uuid is None: if dialect >= 10: self.send_reply(Err.InvalidUser2, trid) else: self.send_reply(Err.InvalidUser, trid) return ctc_old = detail.contacts.get(contact_uuid) ctc_old_lists = None lst = getattr(ContactList, lst_name) if lst == ContactList.RL and (dialect < 11 or (ctc_old is not None and not ctc_old.pending)): self.close() return if ctc_old is not None: ctc_old_lists = ctc_old.lists try: ctc, ctc_head = bs.me_contact_add(contact_uuid, lst, name = name, group_id = group_id) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return ser = self._ser() ctc_head_email = self.backend.util_get_display_email(ctc_head) if dialect >= 10: if lst == ContactList.FL: if group_id: self.send_reply('ADC', trid, lst_name, 'C={}'.format(ctc_head.uuid), group_id) else: self.send_reply( 'ADC', trid, lst_name, 'N={}'.format(ctc_head_email), ('F={}'.format(ctc.status.name) if ctc.status.name else None), 'C={}'.format(ctc_head.uuid), ) else: self.send_reply('ADC', trid, lst_name, 'N={}'.format(ctc_head_email)) else: self.send_reply('ADD', trid, lst_name, ser, ctc_head_email, name, group_id) if lst == ContactList.FL and (ctc_old_lists is not None and not ctc_old_lists & ContactList.FL): if self.syn_sent and dialect >= 5: ctc_detail = ctc_head.detail if ctc_detail is not None: ctc_me = ctc_detail.contacts.get(user.uuid) if ctc_me is not None: if ctc_me.lists & ContactList.AL: send_bpr_info = True self.send_reply('BPR', ser, ctc_head_email, 'PHH', ctc_head.settings.get('PHH') if send_bpr_info else None) self.send_reply('BPR', ser, ctc_head_email, 'PHW', ctc_head.settings.get('PHW') if send_bpr_info else None) self.send_reply('BPR', ser, ctc_head_email, 'PHM', ctc_head.settings.get('PHM') if send_bpr_info else None) self.send_reply('BPR', ser, ctc_head_email, 'MOB', ctc_head.settings.get('MOB', 'N') if send_bpr_info else 'N') bs.evt.on_presence_notification(ctc, False, Substatus.Offline, trid = trid, updated_phone_info = { 'PHH': ctc_head.settings.get('PHH'), 'PHW': ctc_head.settings.get('PHW'), 'PHM': ctc_head.settings.get('PHM'), 'MOB': ctc_head.settings.get('MOB'), }) def _m_rem(self, trid: str, lst_name: str, usr: str, group_id: Optional[str] = None) -> None: bs = self.bs assert bs is not None lst = getattr(ContactList, lst_name) if lst is ContactList.RL: bs.close() return if lst is ContactList.FL: #>>> REM 279 FL 00000000-0000-0000-0002-000000000001 #>>> REM 247 FL 00000000-0000-0000-0002-000000000002 00000000-0000-0000-0001-000000000002 if self.dialect < 10: contact_uuid = self.backend.util_get_uuid_from_email(usr) else: if not re.match(r'^[A-Fa-f0-9]{8,8}-([A-Fa-f0-9]{4,4}-){3,3}[A-Fa-f0-9]{12,12}', usr): self.send_reply(Err.ContactListError, trid) return contact_uuid = usr else: #>>> REM 248 AL bob1@hotmail.com contact_uuid = self.backend.util_get_uuid_from_email(usr) if contact_uuid is None: self.send_reply(Err.InvalidUser, trid) return try: bs.me_contact_remove(contact_uuid, lst, group_id = group_id) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return self.send_reply('REM', trid, lst_name, self._ser(), usr, group_id) def _m_gtc(self, trid: str, value: str) -> None: if self.dialect >= 13: self.close() return # "Alert me when other people add me ..." Y/N #>>> GTC 152 N bs = self.bs assert bs is not None user = bs.user if value not in ('A','N'): self.close() return if user.settings.get('GTC') == value: self.send_reply(Err.AlreadyInMode, trid) return bs.me_update({ 'gtc': value }) self.send_reply('GTC', trid, self._ser(), value) def _m_blp(self, trid: str, value: str) -> None: # Check "Only people on my Allow ContactList ..." AL/BL #>>> BLP 143 BL bs = self.bs assert bs is not None user = bs.user if value not in ('AL','BL'): self.close() return # dialect here is 12 instead of 13 as a workaround for msn-pecan, because for some reason it sends BLP both during syncing and immediately after logging in and thus triggers this error if user.settings.get('BLP') == value and self.dialect < 12: self.send_reply(Err.AlreadyInMode, trid) return bs.me_update({ 'blp': value }) self.send_reply('BLP', trid, self._ser(), value) def _m_chg(self, trid: str, sts_name: str, capabilities: Optional[str] = None, msnobj: Optional[str] = None) -> None: # Change your status or display picture #>>> CHG 120 BSY 1073791020 dialect = self.dialect backend = self.backend bs = self.bs user = bs.user assert bs is not None capabilities_msn = None # type: Optional[str] capabilities_msn_ex = None # type: Optional[str] if settings.FORCE_HOSTS_UPDATE: self.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, email = self.backend.util_get_display_email(user), icon = '', url = 'http://diet.crosstalk.im/moving-hosts', msg = "Your patches need to be updated before you can log in. Click this alert for more info." )) self.backend.loop.call_later(5, self.close) return else: try: msn_substatus = MSNStatus.ToSubstatus(getattr(MSNStatus, sts_name)) except: self.close() return if msn_substatus is Substatus.Offline: self.send_reply(Err.InvalidParameter, trid) return if dialect >= 9: if capabilities is None: return if dialect >= 16 and capabilities.find(':') > 0: capabilities_msn, capabilities_msn_ex = capabilities.split(':', 1) else: try: capabilities_msn = str(int(capabilities)) except ValueError: return bs.front_data['msn_capabilities'] = capabilities_msn or 0 bs.front_data['msn_capabilitiesex'] = capabilities_msn_ex or 0 if msnobj == capabilities: bs.front_data['msn_msnobj'] = None bs.front_data['msn_msnobj_ddp'] = None else: bs.front_data['msn_msnobj'] = msnobj bs.me_update({ 'substatus': msn_substatus, }) extra = () # type: Tuple[Any, ...] if dialect >= 9: extra = (MSNObj(msnobj),) self.send_reply('CHG', trid, sts_name, capabilities, *extra) # Send ILNs (and system messages, if any) if not self.iln_sent: self.iln_sent = True user = bs.user detail = user.detail assert detail is not None dialect = self.dialect for ctc in detail.contacts.values(): if ctc.lists & ContactList.FL: for m in build_presence_notif(trid, None, ctc.head, user, dialect, self.backend, self.iln_sent, True): self.send_reply(*m) if dialect >= 6 and not self.is_third_party_client: self._send_chl(trid) if dialect >= 11: msg2 = encode_payload(PAYLOAD_MSG_2, ct = 'text/x-msmsgsinitialmdatanotification', md = gen_mail_data(user, backend), ) self.send_reply('MSG', 'Hotmail', 'Hotmail', msg2) if self.backend.notify_maintenance: bs.evt.on_maintenance_message(1, self.backend.maintenance_mins) if dialect >= 16: bs.me_update({ 'notify_self': True, 'notify_status': True, }) def _m_qry(self, trid: str, client_id: str, response: bytes) -> None: challenge = self.challenge id_key, max_dialect = _QRY_KEYPAIRS[client_id] if self.dialect > max_dialect: self.send_reply(Err.ChallengeResponseFailed, trid) self.close() return server_response = gen_chal_response(challenge, client_id, id_key, msnp11 = (self.dialect >= 11)) if settings.DEBUG_FULL: print(f"Server challenge response: {server_response}") if response.decode() != server_response: self.send_reply(Err.ChallengeResponseFailed, trid) self.close() return self.challenge = None self.send_reply('QRY', trid) def _m_put(self, trid: str, data: bytes) -> None: if self.dialect < 18: self.close() return backend = self.backend bs = self.bs assert bs is not None user = bs.user detail = user.detail assert detail is not None chat_id = None presence = False i = data.index(b'\r\n\r\n') + 4 headers = Parser().parsestr(data[:i].decode('utf-8')) to = _split_email_epid(str(headers['To'])) from_email = _split_email_epid(str(headers['From'])) if to[1] is NetworkID.CIRCLE: if not to[0].endswith('@live.com'): self.send_reply(Err.InvalidParameter, trid) return email_end = to[0].rfind('@live.com') circle_id = to[0][:email_end] if not (circle_id.startswith('00000000-0000-0000-0009-') and len(circle_id[24:]) == 12): self.send_reply(Err.InvalidParameter, trid) return chat_id = circle_id[-12:] nfy_1_index = data.index(b'\r\n\r\n', i) + 4 # TODO: `PUT` ACK nfy_actual = data[nfy_1_index:] payload_index = nfy_actual.index(b'\r\n\r\n') + 4 nfy_headers = Parser().parsestr(nfy_actual[:payload_index].decode('utf-8')) payload = nfy_actual[payload_index:] if nfy_headers.get('Content-Type') == 'application/circles+xml': if not self.circle_adl_sent: self.send_reply(Err.InvalidParameter, trid) return if chat_id is None: return circle = backend.user_service.get_circle(chat_id) if circle is None: self.send_reply(Err.InvalidParameter, trid) return chat = backend.chat_get('persistent', chat_id) if chat is None: return elm = parse_xml(payload) email_elm = elm.find('roster/user/id') if email_elm is not None: email = email_elm.text if not email.startswith('1:'): self.send_reply(Err.InvalidParameter, trid) return email = email.split('1:', 1)[1] user_display_email = self.backend.util_get_display_email(user) if email.lower() in (user.email.lower(), user_display_email.lower()): cs = backend.get_circle_cs(chat_id, bs) if cs is None: self.send_reply(Err.InvalidParameter, trid) return cs.chat.send_participant_status_updated(cs, Substatus.Offline, initial = True) else: self.send_reply(Err.InvalidParameter, trid) return presence_elm = elm.find('props/presence') if presence_elm is not None: cs = backend.get_circle_cs(chat_id, bs) if cs is None: self.send_reply(Err.InvalidParameter, trid) return psm_elm = presence_elm.find('Data/PSM') if psm_elm is not None: chat.front_data['msn_circle_psm'] = psm_elm.text cm_elm = presence_elm.find('Data/CurrentMedia') if cm_elm is not None: chat.front_data['msn_circle_cm'] = cm_elm.text chat.send_update() elif nfy_headers.get('Content-Type') == 'application/user+xml': presence = True try: payload_xml = parse_xml(payload) user_display_email = self.backend.util_get_display_email(user) if not (to[1] is NetworkID.WINDOWS_LIVE and to[0].lower() in (user.email.lower(), user_display_email.lower()) and to[2] is None): return name = None # type: Optional[str] psm = None # type: Optional[str] substatus = None # type: Optional[Substatus] currentmedia = None # type: Optional[str] capabilities = None # type: Optional[str] capabilities_ex = None # type: Optional[str] pe_capabilities = None # type: Optional[str] pe_capabilitiesex = None # type: Optional[str] #TODO: Better notification flag criteria s_els = payload_xml.findall('s') for s_el in s_els: if s_el.get('n') == 'IM': substatus_elm = s_el.find('Status') if substatus_elm is not None: try: substatus = MSNStatus.ToSubstatus(getattr(MSNStatus, substatus_elm.text)) except ValueError: self.close() return currentmedia_elm = s_el.find('CurrentMedia') if currentmedia_elm is not None: currentmedia = currentmedia_elm.text if s_el.get('n') == 'PE': name_elm = s_el.find('FriendlyName') if name_elm is not None: name = name_elm.text psm_elm = s_el.find('PSM') if psm_elm is not None: psm = psm_elm.text utl_el = s_el.find('UserTileLocation') if utl_el is not None: bs.front_data['msn_msnobj'] = utl_el.text ddp = s_el.find('DDP') if ddp is not None: bs.front_data['msn_msnobj_ddp'] = ddp.text scene = s_el.find('Scene') if scene is not None: bs.front_data['msn_msnobj_scene'] = scene.text colorscheme = s_el.find('ColorScheme') if colorscheme is not None: bs.front_data['msn_colorscheme'] = colorscheme.text sep_elms = payload_xml.findall('sep') for sep_elm in sep_elms: if sep_elm.get('n') == 'IM': capabilities_elm = sep_elm.find('Capabilities') if capabilities_elm is not None: if ':' in capabilities_elm.text: capabilities, capabilitiesex = capabilities_elm.text.split(':', 1) try: if capabilities is not None: capabilities = str(int(capabilities)) if capabilitiesex is not None: capabilitiesex = str(int(capabilitiesex)) except ValueError: self.close() return bs.front_data['msn_capabilities'] = capabilities or 0 bs.front_data['msn_capabilitiesex'] = capabilitiesex or 0 if sep_elm.get('n') == 'PD': client_type = sep_elm.find('ClientType') if client_type is not None: bs.front_data['msn_client_type'] = client_type.text or None epname = sep_elm.find('EpName') if epname is not None: bs.front_data['msn_epname'] = epname.text or None idle = sep_elm.find('Idle') if idle is not None: bs.front_data['msn_endpoint_idle'] = (True if idle.text == 'true' else False) state = sep_elm.find('State') if state is not None: try: bs.front_data['msn_ep_state'] = getattr(MSNStatus, state.text).name except: self.close() return if sep_elm.get('n') == 'PE': bs.front_data['msn_PE'] = True ver = sep_elm.find('VER') if ver is not None: bs.front_data['msn_PE_VER'] = ver.text typ = sep_elm.find('TYP') if typ is not None: bs.front_data['msn_PE_TYP'] = typ.text pe_capabilities_elm = sep_elm.find('Capabilities') if pe_capabilities_elm is not None: if ':' in pe_capabilities_elm.text: pe_capabilities, pe_capabilitiesex = pe_capabilities_elm.text.split(':', 1) try: if pe_capabilities is not None: pe_capabilities = str(int(pe_capabilities)) if pe_capabilitiesex is not None: pe_capabilitiesex = str(int(pe_capabilitiesex)) except ValueError: self.close() return bs.front_data['msn_PE_capabilities'] = pe_capabilities or 0 bs.front_data['msn_PE_capabilitiesex'] = pe_capabilitiesex or 0 #TODO: Presence is a bit wonky bs.me_update({ 'name': name or user_display_email, 'message': psm, 'substatus': substatus, 'media': currentmedia, 'needs_notify': (False if user.status.substatus is Substatus.Offline and substatus is None else True), 'notify_self': True, }) if not self.iln_sent: self.iln_sent = True for ctc in detail.contacts.values(): for m in build_presence_notif(None, None, ctc.head, user, self.dialect, self.backend, self.iln_sent, True): self.send_reply(*m) self.send_reply('PUT', trid, 'OK', b'') return except XMLSyntaxError: self.close() return self.send_reply('PUT', trid, 'OK', b'') return def _m_sdg(self, trid: str, data: bytes) -> None: # Send a message (Circles and MSNP21). Pretty much a SB replacement. bs = self.bs if bs is None: return user = bs.user if user is None or user.detail is None: return detail = user.detail circle_mode = False chat_id = None cs = None try: i = data.index(b'\r\n\r\n') + 4 headers = Parser().parsestr(data[:i].decode('utf-8')) to = _split_email_sdg(str(headers['To'])) from_email = _split_email_epid(str(headers['From'])) except (KeyError, ValueError): self.send_reply(Err.InvalidParameter, trid) return if to[1] == NetworkID.CIRCLE and to[0].endswith('@live.com'): email_end = to[0].rfind('@live.com') circle_id = to[0][:email_end] if circle_id.startswith('00000000-0000-0000-0009-') and len(circle_id[24:]) == 12: circle_mode = True chat_id = circle_id[-12:] else: circle_mode = False if circle_mode: cs = self.backend.get_circle_cs(chat_id, bs) if cs is None: self.send_reply(Err.InvalidParameter, trid) return try: cs.send_message_to_everyone(messagedata_from_sdg(cs.user, bs.front_data.get('msn_pop_id'), data, i)) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) else: if self.dialect > 18: to_str = str(to[0]).strip() contact_uuid = self.backend.util_get_uuid_from_email(to_str) if contact_uuid is None: return try: cs, evt = self._get_private_chat_with(contact_uuid) if None not in (cs, evt): evt._send_when_user_joins(contact_uuid, messagedata_from_sdg(cs.user, bs.front_data.get('msn_pop_id'), data, i)) except error.ContactNotOnline: contact_user = self.backend._load_user_record(contact_uuid) if contact_user is None: return contact_detail = self.backend._load_detail(contact_user) if contact_detail is None: return ctc_self = contact_detail.contacts.get(user.uuid) if ctc_self is not None: if ctc_self.lists & ContactList.BL: return elif ctc_self is None and contact_user.settings.get('BLP', 'AL') == 'BL': return md = messagedata_from_sdg(contact_user, bs.front_data.get('msn_pop_id'), data, i) if md.type is MessageType.Chat: (ip, _) = self.peername from_user_id = to_str self.backend.user_service.save_oim( bs, contact_uuid, gen_uuid(), ip, md.text or '', True, from_user_id = from_user_id, ) except Exception as ex: self.send_reply(Err.GetCodeForException(ex, self.dialect), trid) return else: pass def _m_rea(self, trid: str, email: str, name: str) -> None: # Set a display name (MSNP10 and earlier) if self.dialect >= 10: self.send_reply(Err.CommandDisabled, trid) return bs = self.bs assert bs is not None user_display_email = self.backend.util_get_display_email(bs.user) if email.lower() in (bs.user.email.lower(), user_display_email.lower()): bs.me_update({ 'name': name }) self.send_reply('REA', trid, self._ser(), email, name) def _m_snd(self, trid: str, email: str, lcid: Optional[str] = None, *rest: Optional[str]) -> None: # Send an email inviting a user to CrossTalk. bs = self.bs assert bs is not None if self.dialect >= 5: self.send_reply(Err.CommandDisabled, trid) sender_email = self.backend.util_get_display_email(bs.user) bs.send_invitation_email(email, sender_email, None, None) self.send_reply('SND', trid, 'OK') def _m_sdc(self, trid: str, email: str, lcid: str, arg4: str, arg5: str, arg6: str, arg7: str, name: str, message: Optional[bytes] = None) -> None: # Send an email inviting a user to CrossTalk. bs = self.bs assert bs is not None if self.dialect < 5: self.close() return sender_email = self.backend.util_get_display_email(bs.user) bs.send_invitation_email(email, sender_email, name, message) self.send_reply('SDC', trid, 'OK') def _m_vas(self, trid: str, email: str, arg3: str, arg4: str, data: bytes) -> None: # "Vote as spam". Don't know how to respond. if self.dialect < 18: self.close() return self.send_reply(Err.CommandDisabled, trid) return def _m_del(self, trid: str, data: bytes) -> None: # OUT sends presence updates on logout, ignore for now until we have multiparties implemented pass def _m_prp(self, trid: str, key: str, value: Optional[str] = None, *rest: Optional[str]) -> None: # Set a display name dialect = self.dialect if dialect < 5: self.close() return bs = self.bs assert bs is not None user = bs.user if key == 'MFN': if dialect < 10: self.send_reply(Err.NotExpected, trid) return bs.me_update({ 'name': value }) elif key.startswith('PH'): if len(key) > 3: self.close() return elif len(key) < 3: self.send_reply(Err.NotExpected, trid) return if key.endswith('H'): phone_type = 'home_phone' elif key.endswith('W'): phone_type = 'work_phone' elif key.endswith('M'): phone_type = 'mobile_phone' else: self.send_reply(Err.NotExpected, trid) return if value is not None and len(value) > 95: self.close() return bs.me_update({ phone_type: value }) elif key == 'MOB': if user.settings['MBE'] == 'N': value = 'N' else: if value not in ('Y','N'): bs.me_update({ 'mob': 'N' }) else: bs.me_update({ 'mob': value }) elif key == 'MBE': if value not in ('Y','N'): bs.me_update({ 'mbe': 'N' }) else: bs.me_update({ 'mbe': value }) # TODO: Save other settings? self.send_reply('PRP', trid, self._ser(), key, value) def _m_sbp(self, trid: str, uuid: str, key: str, value: str) -> None: #>>> SBP 153 00000000-0000-0000-0002-000000000002 MFN Bob%201%20New # Can be ignored: core handles syncing contact names if self.dialect >= 13 or self.dialect < 10: self.close() return self.send_reply('SBP', trid, uuid, key, value) def _m_xfr(self, trid: str, dest: str) -> None: # Used for connecting to SB to start a chat session, or NS redirection bs = self.bs if dest == 'SB': assert bs is not None if not self.iln_sent or (MSNStatus.FromSubstatus(bs.user.status.substatus) is MSNStatus.HDN and self.dialect < 13): self.send_reply(Err.NotAllowedWhileHDN, trid) return dialect = self.dialect token, _ = self.backend.auth_service.create_token('sb/xfr', (bs, dialect), lifetime = 120) extra = () # type: Tuple[Any, ...] if dialect >= 13: extra = ('U', 'messenger.msn.com') if dialect >= 14: extra += (1,) self.send_reply('XFR', trid, dest, f'{settings.TARGET_IP}:1865', 'CKI', token, *extra) elif dest == 'NS': self.send_reply('XFR', trid, dest, f'{settings.TARGET_IP}:1864') else: self.send_reply(Err.InvalidParameter, trid) return def _m_ims(self, trid: str, value: str) -> None: #>>> IMS 28 ON/OFF # Only used in WebTV clients; toggles whether `RNG`s can be received and `XFR`s can be sent bs = self.bs assert bs is not None if value == 'ON': bs.chat_enabled = True elif value == 'OFF': bs.chat_enabled = False else: # TODO: Proper response to bad `IMS`? self.send_reply(Err.NotExpected, trid) return self.send_reply('IMS', trid, '0', value) def _m_fqy(self, trid: str, data: bytes) -> None: # "Federated query; Query contact's network types" # https://yellows111.darkok.xyz/msnp/commands/fqy.html d_els = None domain = None username = None contact_uuid = None if self.dialect < 14: self.close() return try: fqy_xml = parse_xml(data.decode('utf-8')) d_els = fqy_xml.findall('d') if len(d_els) == 1: d_el = d_els[0] if len(d_el.getchildren()) == 0: self.send_reply(Err.XXLEmptyDomain, trid) self.close() return elif len(d_el.getchildren()) > 1: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return else: self.send_reply(Err.XXLInvalidPayload, trid) self.close() return domain = d_el.get('n') c_el = d_el.find('c') username = c_el.get('n') email = '{}@{}'.format(username, domain) contact_uuid = self.backend.util_get_uuid_from_email(email) except Exception as ex: if isinstance(ex, XMLSyntaxError): self.send_reply(Err.XXLInvalidPayload, trid) self.close() return self.send_reply('FQY', trid, ''.format( domain, username, ).encode('utf-8')) def _m_uun(self, trid: str, email: str, type: str, data: Optional[bytes] = None) -> None: # "Send sharing invitation or reply to invitation" # https://yellows111.darkok.xyz/msnp/commands/uun.html if self.dialect < 13: self.close() return bs = self.bs assert bs is not None user = bs.user pop_id_self = None (email, pop_id) = decode_email_pop(email) uuid = self.backend.util_get_uuid_from_email(email) if uuid is None: return ctc_head = self.backend._load_user_record(uuid) if ctc_head is None: return if ctc_head.status.is_offlineish(): self.send_reply(Err.PrincipalNotOnline, trid) return ctc_detail = self.backend._load_detail(ctc_head) assert ctc_detail is not None ctc_me = ctc_detail.contacts.get(user.uuid) if ctc_me is not None: if ctc_me.lists & ContactList.BL: self.send_reply(Err.PrincipalNotOnline, trid) return else: if ctc_head.settings.get('BLP', 'AL') == 'BL': self.send_reply(Err.PrincipalNotOnline, trid) return try: uun_type = int(type) except ValueError: return if uun_type is None: return pop_id_self = bs.front_data.get('msn_pop_id') for sess_notify in self.backend.util_get_sessions_by_user(ctc_head): sess_notify.evt.msn_on_uun_sent(bs.user, uun_type, data, pop_id_sender = pop_id_self, pop_id = pop_id) self.send_reply('UUN', trid, 'OK') def _m_uum(self, trid: str, email: str, networkid: str, type: str, data: bytes) -> None: # For federated messaging (with Yahoo!); also used in MSNP18+ for OIMs if self.dialect < 14: self.close() return bs = self.bs assert bs is not None user = bs.user nid = None # type: Optional[NetworkID] message = None if type not in ('1','2','3','4'): self.close() return try: nid = NetworkID(int(networkid)) except ValueError: self.close() return assert nid is not None if nid is NetworkID.WINDOWS_LIVE and self.dialect < 18: self.close() return if nid is not NetworkID.WINDOWS_LIVE: return if type != '1': self.close() return contact_uuid = self.backend.util_get_uuid_from_email(email) if contact_uuid is None: return ctc_head = self.backend._load_user_record(contact_uuid or '') assert ctc_head is not None if not ctc_head.status.is_offlineish(): return ctc_detail = self.backend._load_detail(ctc_head) assert ctc_detail is not None ctc_me = ctc_detail.contacts.get(user.uuid) if ctc_me is not None: if ctc_me.lists & ContactList.BL: return else: if ctc_head.settings.get('BLP', 'AL') == 'BL': return try: message_mime = Parser().parsestr(data.decode('utf-8')) except: pass assert message_mime is not None if message_mime.get('Content-Type') is None or str(message_mime.get('Content-Type')).split(';')[0] != 'text/plain': return if message_mime.get('Dest-Agent') != 'client': return try: i = data.index(b'\r\n\r\n') + 4 message = data[i:].decode('utf-8') (ip, _) = self.peername self.backend.user_service.save_oim( bs, ctc_head.uuid, gen_uuid(), ip, message, True, from_friendly = user.status.name, oim_proxy = 'MSNMSGR', ) except: return def _send_chl(self, trid: str) -> None: backend = self.backend self.challenge = str(secrets.randbelow(89999999999999999999) + 10000000000000000000) backend.loop.create_task(self._check_qry_sent(trid)) self.send_reply('CHL', 0, self.challenge) async def _check_qry_sent(self, trid: str) -> None: await asyncio.sleep(50) if not self.is_third_party_client: if self.challenge: self.send_reply(Err.ChallengeResponseFailed, trid) self.close() return def _ser(self) -> Optional[int]: if self.dialect >= 10: return None self.syn_ser += 1 return self.syn_ser def _get_private_chat_with(self, other_user_uuid: str) -> Tuple[ChatSession, 'ChatEventHandler']: bs = self.bs assert bs is not None user = bs.user detail = user.detail assert detail is not None other_user = self.backend._load_user_record(other_user_uuid) if other_user is None: raise error.ContactNotOnContactList() other_user_ctc = detail.contacts.get(other_user.uuid) if other_user_ctc is not None and other_user_ctc.lists & ContactList.BL: raise error.ContactNotOnContactList() if other_user_uuid not in bs.front_data['windowslive_chats']: other_user_detail = self.backend._load_detail(other_user) if other_user_detail is None: raise error.ContactNotOnline() ctc_self = other_user_detail.contacts.get(user.uuid) if ctc_self is not None: if ctc_self.lists & ContactList.BL: raise error.ContactNotOnline() chat = self.backend.chat_create() chat.front_data['windowslive'] = True # `user` joins evt = ChatEventHandler(self.backend.loop, self, bs) cs = chat.join('msn', bs, evt) bs.front_data['windowslive_chats'][other_user_uuid] = (cs, evt) cs.invite(other_user) elif other_user.status.is_offlineish(): raise error.ContactNotOnline() return bs.front_data['windowslive_chats'].get(other_user_uuid) class BackendEventHandler(event.BackendEventHandler): __slots__ = ('ctrl', 'loop') loop: asyncio.AbstractEventLoop ctrl: MSNPCtrlNS def __init__(self, loop: asyncio.AbstractEventLoop, ctrl: MSNPCtrlNS) -> None: self.ctrl = ctrl self.loop = loop def on_client_alert(self, icon_url: Optional[str], url: Optional[str], message: str = '') -> None: bs = self.ctrl.bs assert bs is not None user = bs.user self.ctrl.send_reply('NOT', encode_payload(PAYLOAD_MSG_10, email = self.ctrl.backend.util_get_display_email(user), icon = icon_url, url = url, msg = message, )) def on_maintenance_message(self, *args: Any, **kwargs: Any) -> None: if args[0] == 1 and args[1] < 0: return data = [ 'MIME-Version: 1.0', 'Content-Type: application/x-msmsgssystemmessage', '', 'Type: {}'.format(args[0]), ] + [ 'Arg{}: {}'.format(i+1, a) for i, a in enumerate(args[1:]) ] self.ctrl.send_reply('MSG', 'Hotmail', 'Hotmail', ('\r\n'.join(data) + '\r\n').encode('utf-8')) def on_maintenance_boot(self) -> None: self.on_close(maintenance = True) 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: bs = self.ctrl.bs assert bs is not None user = bs.user if send_status_on_bl and not update_status: return if 5 <= self.ctrl.dialect < 13 and updated_phone_info and self.ctrl.syn_sent: for phone_type, value in updated_phone_info.items(): if value is not None: self.ctrl.send_reply('BPR', self.ctrl._ser(), self.ctrl.backend.util_get_display_email(ctc.head), phone_type, None if send_status_on_bl else value) if update_status or update_info_other: for m in build_presence_notif( trid, old_substatus, ctc.head, user, self.ctrl.dialect, self.ctrl.backend, self.ctrl.iln_sent, update_info_other, update_status = update_status, ): self.ctrl.send_reply(*m) return def on_presence_self_notification(self, old_substatus: Substatus, *, update_status: bool = True, update_info: bool = True) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user if update_status or update_info: for m in build_presence_notif( None, old_substatus, user, user, self.ctrl.dialect, self.ctrl.backend, self.ctrl.iln_sent, update_info, self_presence = True, ): self.ctrl.send_reply(*m) return def on_chat_invite( self, chat: Chat, inviter: User, *, circle: bool = False, inviter_id: Optional[str] = None, invite_msg: str = '', ) -> None: if circle and self.ctrl.circle_authenticated: self.msn_on_notify_ab() else: if self.ctrl.dialect >= 19: if chat is None: chat = self.backend.chat_create() chat.add_id('windowslive', chat.ids['main']) evt = ChatEventHandler(self.loop, self.ctrl, self.bs) cs = chat.join('msn', self.bs, evt) chat.send_participant_joined(cs) self.bs.front_data['windowslive_chats'][inviter.uuid] = (cs, evt) return else: extra = () # type: Tuple[Any, ...] dialect = self.ctrl.dialect if dialect >= 13: extra = ('U', 'messenger.hotmail.com') if dialect >= 14: extra += (1,) token, _ = self.ctrl.backend.auth_service.create_token('sb/cal', (self.ctrl.bs, dialect, chat), lifetime = 120) self.ctrl.send_reply( 'RNG', chat.ids['main'], f'{settings.TARGET_IP}:1865', 'CKI', token, self.ctrl.backend.util_get_display_email(inviter), inviter.status.name, *extra, ) def on_declined_chat_invite(self, chat: Chat, circle: bool = False) -> None: if circle and self.ctrl.circle_authenticated: self.msn_on_notify_ab() def on_added_me(self, user: User, *, adder_id: Optional[str] = None, message: Optional[TextWithData] = None) -> None: email = self.ctrl.backend.util_get_display_email(user) name = (user.status.name or email) dialect = self.ctrl.dialect bs = self.ctrl.bs assert bs is not None user_me = bs.user detail = user_me.detail assert detail is not None if dialect < 13: if dialect < 10: bs.me_contact_remove(user.uuid, ContactList.PL) m: Tuple[Any, ...] = ('ADD', 0, ContactList.RL.name, self.ctrl._ser(), email, name) else: m = ('ADC', 0, ContactList.RL.name, 'N={}'.format(email), 'F={}'.format(name)) else: username, domain = email.split('@', 1) # According to https://github.com/ifwe/digsby/blob/master/digsby/src/msn/p13/MSNP13Notification.py#L493, `ADL` # has an `f` parameter for the friendly name it seems. # Also, no `l="1"` in `ml`. adl_payload = ''.format( domain, username, int(ContactList.RL), quote(name), ) m = ('ADL', 0, adl_payload.encode('utf-8')) self.ctrl.send_reply(*m) if dialect >= 8: self.msn_on_notify_ab() def on_removed_me(self, user: User) -> None: email = self.ctrl.backend.util_get_display_email(user) dialect = self.ctrl.dialect bs = self.ctrl.bs assert bs is not None user_me = bs.user detail = user_me.detail assert detail is not None if dialect < 13: m: Tuple[Any, ...] = ('REM', 0, ContactList.RL.name, self.ctrl._ser(), email) else: username, domain = email.split('@', 1) rml_payload = ''.format( domain, username, int(ContactList.RL), ) m = ('RML', 0, rml_payload.encode('utf-8')) self.ctrl.send_reply(*m) if dialect >= 8: self.msn_on_notify_ab() def on_contact_request_denied(self, user_added: User, message: Optional[str], *, contact_id: Optional[str] = None) -> None: pass def on_oim_sent(self, oim: 'OIM') -> None: assert self.ctrl.bs is not None if self.ctrl.iln_sent and self.ctrl.dialect >= 11: self.ctrl.send_reply('MSG', 'Hotmail', 'Hotmail', encode_payload(PAYLOAD_MSG_2, ct = 'text/x-msmsgsoimnotification', md = gen_mail_data( self.ctrl.bs.user, self.ctrl.backend, oim = oim, just_sent = True, e_node = False, q_node = False, ), )) def msn_on_oim_deletion(self, oims_deleted: int) -> None: if self.ctrl.iln_sent and self.ctrl.dialect >= 11: self.ctrl.send_reply('MSG', 'Hotmail', 'Hotmail', encode_payload(PAYLOAD_MSG_3, oims_deleted = str(oims_deleted))) def msn_on_uun_sent( self, sender: User, type: int, data: Optional[bytes], *, pop_id_sender: Optional[str] = None, pop_id: Optional[str] = None, ) -> None: ctrl = self.ctrl bs = ctrl.bs assert bs is not None if ctrl.dialect < 13: return if pop_id is not None and 'msn_pop_id' in bs.front_data: pop_id_self = bs.front_data.get('msn_pop_id') or '' if normalize_pop_id(pop_id).lower() != pop_id_self.lower(): return if pop_id_sender is not None and pop_id is not None and ctrl.dialect >= 16: email = '{};{}'.format(self.ctrl.backend.util_get_display_email(sender), '{' + pop_id_sender + '}') else: email = self.ctrl.backend.util_get_display_email(sender) self.ctrl.send_reply('UBN', email, type, data) def msn_on_notify_ab(self) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user id_bits = uuid_to_high_low(user.uuid) self.ctrl.send_reply('NOT', encode_payload(PAYLOAD_MSG_4, member_low = binascii.hexlify(struct.pack('!I', id_bits[1])).decode('utf-8'), member_high = binascii.hexlify(struct.pack('!I', id_bits[0])).decode('utf-8'), email = self.ctrl.backend.util_get_display_email(user), cid = cid_format(user.uuid, decimal = True), now = date_format(datetime.utcnow()), )) def msn_on_notify_circle_ab(self, chat_id: str) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user id_bits = uuid_to_high_low(user.uuid) self.ctrl.send_reply('NOT', encode_payload(PAYLOAD_MSG_8, member_low = binascii.hexlify(struct.pack('!I', id_bits[1])).decode('utf-8'), member_high = binascii.hexlify(struct.pack('!I', id_bits[0])).decode('utf-8'), email = self.ctrl.backend.util_get_display_email(user), chat_id = chat_id, )) def on_circle_created(self, circle: Circle) -> None: ctrl = self.ctrl if ctrl.dialect >= 18: ctrl.new_circles.append(circle) self.msn_on_notify_ab() def on_circle_updated(self, circle: Circle) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user if self.ctrl.circle_authenticated: membership = circle.memberships.get(user.uuid) assert membership is not None self.msn_on_notify_circle_ab(circle.chat_id) def on_left_circle(self, circle: Circle) -> None: ctrl = self.ctrl if ctrl.dialect >= 18: try: self.ctrl.new_circles.remove(circle) except: pass self.msn_on_notify_ab() def on_circle_invite_revoked(self, chat_id: str) -> None: if self.ctrl.circle_authenticated: self.msn_on_notify_ab() def on_accepted_circle_invite(self, circle: Circle) -> None: if self.ctrl.circle_authenticated: self.ctrl.new_circles.append(circle) self.msn_on_notify_ab() def on_circle_role_updated(self, chat_id: str, role: CircleRole) -> None: if self.ctrl.circle_authenticated: self.msn_on_notify_ab() def ymsg_on_p2p_msg_request(self, sess_id: int, yahoo_data: MultiDict[bytes, bytes]) -> None: pass def ymsg_on_xfer_init(self, sess_id: int, yahoo_data: MultiDict[bytes, bytes]) -> None: pass def ymsg_on_upload_file_ft(self, recipient: str, message: str) -> None: pass def ymsg_on_sent_ft_http(self, yahoo_id_sender: str, url_path: str, upload_time: float, message: str) -> None: # TODO: Pass file transfer message to any chats with Yahoo! user (might be impossible until MSNP21 is completed) pass def on_login_elsewhere(self, option: LoginOption) -> None: if option is LoginOption.BootOthers: self.ctrl.send_reply('OUT', 'OTH') self.ctrl.close() elif option is LoginOption.NotifyOthers: if not self.ctrl.dialect >= 16: self.ctrl.send_reply('OUT', 'OTH') self.ctrl.close() else: # TODO: What do? pass def on_close(self, *, maintenance: bool = False) -> None: bs = self.bs assert bs is not None if maintenance: self.ctrl.send_reply('OUT', 'SSD') self.ctrl.close() class ChatEventHandler(event.ChatEventHandler): __slots__ = ('loop', 'ctrl', 'bs', 'cs') loop: asyncio.AbstractEventLoop ctrl: MSNPCtrlNS bs: Optional[BackendSession] cs: ChatSession def __init__(self, loop: asyncio.AbstractEventLoop, ctrl: MSNPCtrlNS, bs: BackendSession) -> None: self.loop = loop self.ctrl = ctrl self.bs = bs def on_close(self) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user chat = self.cs.chat circle = chat.circle membership = circle.memberships[user.uuid] if membership.state == CircleState.Empty: for cs_other in chat.get_roster(): self.ctrl.send_reply('NFY', 'DEL', encode_payload(PAYLOAD_MSG_9, to_email = self.ctrl.backend.util_get_display_email(user), nid = str(int(NetworkID.CIRCLE)), uuid = '00000000-0000-0000-0009-{}'.format(circle.chat_id), from_email = self.ctrl.backend.util_get_display_email(cs_other.user), )) def on_participant_joined(self, cs_other: ChatSession, first_pop: bool, initial_join: bool) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user cs = self.cs chat = cs.chat circle = chat.circle if circle: if (cs_other.user.status.substatus is Substatus.Invisible and cs_other.user is user) or not cs_other.user.status.is_offlineish(): if cs_other.user.uuid == circle.owner_uuid: for m in build_presence_notif( None, Substatus.Offline, cs_other.user, user, self.ctrl.dialect, self.ctrl.backend, self.ctrl.iln_sent, False, circle = circle, circle_owner = True, ): self.ctrl.send_reply(*m) for m in build_presence_notif( None, Substatus.Offline, cs_other.user, user, self.ctrl.dialect, self.ctrl.backend, self.ctrl.iln_sent, True, circle = circle, ): self.ctrl.send_reply(*m) if not first_pop: return self.on_chat_roster_updated() def on_participant_left(self, cs_other: ChatSession, last_pop: bool) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user cs = self.cs chat = cs.chat circle = chat.circle if last_pop: if circle: membership = circle.memberships[cs_other.user.uuid] if membership.state == CircleState.Empty: self.ctrl.send_reply('NFY', 'DEL', encode_payload(PAYLOAD_MSG_9, to_email = self.ctrl.backend.util_get_display_email(user), nid = str(int(NetworkID.CIRCLE)), uuid = '00000000-0000-0000-0009-{}'.format(circle.chat_id), from_email = self.ctrl.backend.util_get_display_email(cs_other.user), )) def on_chat_invite_declined( self, chat: Chat, invitee: User, *, invitee_id: Optional[str] = None, message: Optional[str] = None, circle: bool = False, ) -> None: bs = self.ctrl.bs assert bs is not None if circle and self.ctrl.circle_authenticated: circle = chat.circle assert circle is not None self.on_chat_roster_updated() bs.evt.msn_on_notify_circle_ab(circle.chat_id) def on_chat_updated(self) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user chat = self.cs.chat circle = chat.circle presence = CIRCLE_PROPS.format( friendly = circle.name, psm = chat.front_data.get('msn_circle_psm') or '', cm = chat.front_data.get('msn_circle_cm') or '', ) result = CIRCLE.format(presence) self.ctrl.send_reply('NFY', 'PUT', encode_payload(PAYLOAD_MSG_6, email = _encode_email_epid(self.ctrl.backend.util_get_display_email(user), bs.front_data.get('msn_pop_id')), chat_id = circle.chat_id, cl = len(result), payload = result, )) def on_chat_roster_updated(self) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user cs = self.cs chat = cs.chat circle = chat.circle users = _get_circle_roster(self, chat, cs) roster = CIRCLE_ROSTER.format(users = users) result = CIRCLE.format(roster) self.ctrl.send_reply('NFY', 'PUT', encode_payload(PAYLOAD_MSG_5, email = self.ctrl.backend.util_get_display_email(user), chat_id = circle.chat_id, cl = len(result), payload = result, )) def on_participant_status_updated(self, cs_other: ChatSession, first_pop: bool, initial: bool, old_substatus: Substatus) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user cs = self.cs chat = cs.chat circle = chat.circle membership = circle.memberships[cs_other.user.uuid] if membership.state == CircleState.Empty: return if not (initial and cs_other.user.status.is_offlineish()): for m in build_presence_notif( None, old_substatus, cs_other.user, user, self.ctrl.dialect, self.ctrl.backend, self.ctrl.iln_sent, True, circle = circle, ): self.ctrl.send_reply(*m) if not cs_other.user.status.is_offlineish(): self.on_chat_roster_updated() def on_invite_declined(self, invited_user: User, *, invited_id: Optional[str] = None, message: str = '') -> None: pass def on_message(self, data: MessageData) -> None: bs = self.ctrl.bs assert bs is not None user = bs.user cs = self.cs chat = cs.chat circle = chat.circle if data.type is not MessageType.TypingDone: if data.sender is bs.user and data.sender_pop_id == bs.front_data.get('msn_pop_id'): self.ctrl.send_reply('SDG', 0, messagedata_to_sdg(self, data, user)) else: self.ctrl.send_reply('SDG', 0, messagedata_to_sdg(self, data, user, circle = circle)) def _send_when_user_joins(self, user_uuid: str, data: MessageData) -> None: # Send to everyone currently in chat self.cs.send_message_to_everyone(data) if self._user_in_chat(user_uuid): return # If `user_uuid` hasn't joined yet, send it later self.loop.create_task(self._send_delayed(user_uuid, data)) async def _send_delayed(self, user_uuid: str, data: MessageData) -> None: delay = 0.1 for _ in range(3): await asyncio.sleep(delay) delay *= 3 if self._user_in_chat(user_uuid): self.cs.send_message_to_user(user_uuid, data) return def _user_in_chat(self, user_uuid: str) -> bool: for cs_other in self.cs.chat.get_roster(): if cs_other.user.uuid == user_uuid: return True return False def _get_circle_roster(self, chat: Chat, cs: ChatSession) -> str: circle = chat.circle assert circle is not None users = ''.join(CIRCLE_USER.format(email=self.ctrl.backend.util_get_display_email(cs1.user)) for cs1 in chat.get_roster_single() if not (cs1.user.status.is_offlineish() or circle.memberships[cs1.user.uuid].blocking) or cs1.user is not cs.user) return users def _split_email_epid(email: str) -> Tuple[str, NetworkID, Optional[str]]: email_epid = email.split(';', 1) networkid, email = decode_email_networkid(email_epid[0]) epid = normalize_pop_id(email_epid[1][5:]) if len(email_epid) > 1 and email_epid[1].startswith('epid=') else None return email, networkid, epid def _split_email_sdg(email: str) -> Tuple[str, NetworkID, Optional[str]]: email_path = email.split(';', 1) networkid, email = decode_email_networkid(email_path[0]) sdg_path = email_path[1][5:] if len(email_path) > 1 and email_path[1].startswith('path=') else None return email, networkid, sdg_path def _encode_email_epid(email: str, pop_id: Optional[str]) -> str: return f'{email};epid={{{pop_id}}}' if pop_id else email def messagedata_from_sdg(sender: User, sender_pop_id: Optional[str], data: bytes, i: int) -> Optional[MessageData]: try: j = data.index(b'\r\n\r\n', i) + 4 sdg_messaging = data[j:] n = sdg_messaging.index(b'\r\n\r\n') + 4 headers = Parser().parsestr(sdg_messaging[:n].decode('utf-8')) body_raw = sdg_messaging[n:] content_length = headers.get('Content-Length') if content_length is None: return None message_length = int(str(content_length)) if len(body_raw) < message_length: return None body_raw = body_raw[:message_length] message_type_str = str(headers.get('Message-Type')) message_subtype = str(headers.get('Message-Subtype', '')) type_mapping = { 'Text': MessageType.Chat, 'Nudge': MessageType.Nudge, 'Control': MessageType.Typing if message_subtype == 'Typing' else MessageType.Chat, 'Control/Typing': MessageType.Typing, 'Data': MessageType.MSNP2P, 'Signal/P2P': MessageType.MSNP2PInvite } type = type_mapping.get(message_type_str, MessageType.Chat) text = body_raw.decode('utf-8') if type == MessageType.Chat or MessageType.MSNP2P else '' except Exception: type = MessageType.Chat text = "CrossTalk System Message: This message cannot be viewed on the client you're currently using." message = MessageData(sender=sender, sender_pop_id=sender_pop_id, type=type, text=text) message.front_cache['msnp_sdg'] = data return message def messagedata_to_sdg(self, data: MessageData, user: User, *, circle: Optional[Circle] = None) -> bytes: if 'msnp_sdg' not in data.front_cache: text = data.text or (b'' if data.type == MessageType.MSNP2P else '') if data.type == MessageType.Typing: if circle: s = ( f'Content-Length: 2\r\nContent-Type: text/x-msmsgscontrol\r\n' f'Content-Transfer-Encoding: 7bit\r\nMessage-Type: Control\r\n' f'Message-Subtype: Typing\r\nMIME-Version: 1.0\r\nTypingUser: {self.ctrl.backend.util_get_display_email(data.sender)}\r\n\r\n\r\n' ).encode('utf-8') else: s = 'Message-Type: Control/Typing\r\nContent-Length: 0\r\n\r\n'.encode('utf-8') elif data.type == MessageType.Nudge: s = ( 'Content-Length: 9\r\nContent-Type: text/plain; charset=UTF-8\r\n' 'Content-Transfer-Encoding: 7bit\r\nMessage-Type: Nudge\r\n' 'MIME-Version: 1.0\r\n\r\nID: 1\r\n\r\n' ).encode('utf-8') elif data.type == MessageType.Chat: s = ( f'Content-Length: {len(text)}\r\nContent-Type: text/plain; charset=UTF-8\r\n' f'Content-Transfer-Encoding: 7bit\r\nMessage-Type: Text\r\n' f'MIME-Version: 1.0\r\n\r\n' ).encode('utf-8') + text.encode('utf-8') elif data.type == MessageType.MSNP2P: s = ( b'Bridging-Offsets: 0\r\n' + f'Content-Length: {len(text)}\r\n'.encode() + b'Content-Transfer-Encoding: binary\r\n' + b'Content-Type: application/x-msnmsgrp2p\r\n' + b'Message-Type: Data\r\n\r\n' + text ) elif data.type == MessageType.MSNP2PInvite: s = ( f'Content-Length: {len(text)}\r\n'.encode() + b'Content-Type: text/plain; charset=UTF-8\r\n' + b'Message-Type: Signal/P2P\r\n\r\n' ).encode('utf-8') + text.encode('utf-8') else: raise ValueError("Unknown message type", data.type) sender_display_email = self.ctrl.backend.util_get_display_email(data.sender) user_display_email = self.ctrl.backend.util_get_display_email(user) to = f'9:00000000-0000-0000-0009-{circle.chat_id}@live.com;path=IM' if circle else f'1:{_encode_email_epid(user_display_email, self.bs.front_data.get('msn_pop_id'))}' pre = f'Routing: 1.0\r\nTo: {to}\r\n' if data.type == MessageType.MSNP2P: pre += 'Options: 0\r\nService-Channel: PE\r\n' else: pre += 'Service-Channel: IM/Online\r\n' timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' pre += f'Original-Arrival-Time: {timestamp}\r\n' pre += f'From: 1:{_encode_email_epid(sender_display_email, data.sender_pop_id)}\r\n\r\n' pre = pre.encode('utf-8') r = 'Reliability: 1.0' if circle: r += '\r\nStream: 0\r\nSegment: 0' r += '\r\n\r\n' r += 'Messaging: 2.0\r\n' r = r.encode('utf-8') data.front_cache['msnp_sdg'] = pre + r + s return data.front_cache['msnp_sdg'] PAYLOAD_MSG_0 = '''Routing: 1.0 To: 1:{email_address};epid={endpoint_ID} From: 1:{email_address} Reliability: 1.0 Notification: 1.0 NotifNum: 0 Uri: /user NotifType: Partial Content-Type: application/user+xml Content-Length: 53 ''' PAYLOAD_MSG_1 = '''MIME-Version: 1.0 Content-Type: text/x-msmsgsprofile; charset=UTF-8 LoginTime: {time} EmailEnabled: 1 MemberIdHigh: {high} MemberIdLow: {low} lang_preference: 1033 preferredEmail: {email} country: PostalCode: Gender: Kid: 0 Age: BDayPre: Birthday: Wallet: Flags: 536872513 sid: 507 MSPAuth: {token}Y6+H31sTUOFkqjNTDYqAAFLr5Ote7BMrMnUIzpg860jh084QMgs5djRQLLQP0TVOFkKdWDwAJdEWcfsI9YL8otN9kSfhTaPHR1njHmG0H98O2NE/Ck6zrog3UJFmYlCnHidZk1g3AzUNVXmjZoyMSyVvoHLjQSzoGRpgHg3hHdi7zrFhcYKWD8XeNYdoz9wfA2YAAAgZIgF9kFvsy2AC0Fl/ezc/fSo6YgB9TwmXyoK0wm0F9nz5EfhHQLu2xxgsvMOiXUSFSpN1cZaNzEk/KGVa3Z33Mcu0qJqvXoLyv2VjQyI0VLH6YlW5E+GMwWcQurXB9hT/DnddM5Ggzk3nX8uMSV4kV+AgF1EWpiCdLViRI6DmwwYDtUJU6W6wQXsfyTm6CNMv0eE0wFXmZvoKaL24fggkp99dX+m1vgMQJ39JblVH9cmnnkBQcKkV8lnQJ003fd6iIFzGpgPBW5Z3T1Bp7uzSGMWnHmrEw8eOpKC5ny4x8uoViXDmA2UId23xYSoJ/GQrMjqB+NslqnuVsOBE1oWpNrmfSKhGU1X0kR4Eves56t5i5n3XU+7ne0MkcUzlrMi89n2j8aouf0zeuD7o+ngqvfRCsOqjaU71XWtuD4ogu2X7/Ajtwkxg/UJDFGAnCxFTTd4dqrrEpKyMK8eWBMaartFxwwrH39HMpx1T9JgknJ1hFWELzG8b302sKy64nCseOTGaZrdH63pjGkT7vzyIxVH/b+yJwDRmy/PlLz7fmUj6zpTBNmCtl1EGFOEFdtI2R04EprIkLXbtpoIPA7m0TPZURpnWufCSsDtD91ChxR8j/FnQ/gOOyKg/EJrTcHvM1e50PMRmoRZGlltBRRwBV+ArPO64On6zygr5zud5o/aADF1laBjkuYkjvUVsXwgnaIKbTLN2+sr/WjogxT1Yins79jPa1+3dDenxZtE/rHA/6qsdJmo5BJZqNYQUFrnpkU428LryMnBaNp2BW51JRsWXPAA7yCi0wDlHzEDxpqaOnhI4Ol87ra+VAg==&p= ClientIP: {ip} ClientPort: {port} ABCHMigrated: 1 MPOPEnabled: {mpop} Nickname: {nickname} RouteInfo: msnp://{server_ip}/{sess_id} ''' # OIMs PAYLOAD_MSG_2 = '''MIME-Version: 1.0 Content-Type: {ct}; charset=UTF-8 Mail-Data: {md} ''' PAYLOAD_MSG_3 = '''MIME-Version: 1.0 Content-Type: text/x-msmsgsactivemailnotification; charset=UTF-8 Src-Folder: .!!OIM Dest-Folder: .!!trAsH Message-Delta: {oims_deleted} ''' PAYLOAD_MSG_4 = ''' <NotificationData xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <Service>ABCHInternal</Service> <CID>{cid}</CID> <LastModifiedDate>{now}</LastModifiedDate> <HasNewItem>false</HasNewItem> </NotificationData> ''' PAYLOAD_MSG_5 = '''Routing: 1.0 To: 1:{email} From: 9:00000000-0000-0000-0009-{chat_id}@live.com Reliability: 1.0 Stream: 0 Publication: 1.0 Uri: /circle NotifType: Partial Content-Type: application/circles+xml Content-Length: {cl} {payload}''' PAYLOAD_MSG_6 = '''Routing: 1.0 To: 1:{email} From: 9:00000000-0000-0000-0009-{chat_id}@live.com Reliability: 1.0 Stream: 1 Segment: 0 Publication: 1.0 Uri: /circle NotifType: Full Content-Type: application/circles+xml Content-Length: {cl} {payload}''' PAYLOAD_MSG_7 = '''Routing: 1.0 To: 1:{email} From: 9:00000000-0000-0000-0009-{chat_id}@live.com Reliability: 1.0 Stream: 0 Notification: 1.0 NotifNum: 0 Uri: /circle NotifType: Full Content-Type: application/circles+xml Content-Length: 0 ''' PAYLOAD_MSG_8 = ''' <NotificationData xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <HasNewItem>true</HasNewItem> <CircleId>00000000-0000-0000-0009-{chat_id}</CircleId> </NotificationData> ''' PAYLOAD_MSG_9 = '''Routing: 1.0 To: 1:{to_email} From: {nid}:{uuid}@live.com Reliability: 1.0 Stream: 0 Notification: 1.0 NotifNum: 2 Uri: /circle/roster(IM)/user(1:{from_email}) NotifType: Partial Content-Type: application/circles+xml Content-Length: 0 ''' PAYLOAD_MSG_10 = ''' {msg} ''' PAYLOAD_MSG_11 = '''MIME-Version: 1.0 Content-Type: text/x-msmsgsinitialemailnotification; charset=UTF-8 Inbox-Unread: {unread} Folders-Unread: 0 Inbox-URL: /cgi-bin/MaiL Folders-URL: /cgi-bin/Folders Post-URL: http://mail.ugnet.gay ''' SHIELDS = ''' '''.encode('utf-8') SHIELDS_MSNP13 = ''' \ \ \ \ 100 \ \ \ '''.encode('utf-8') CIRCLE_ROSTER = 'IM{users}' CIRCLE_USER = '1:{email}' CIRCLE = '{}' CIRCLE_PROPS = '{friendly}{psm}\ {cm}' TIMESTAMP = '2000-01-01T00:00:00.0-00:00' _QRY_KEYPAIRS = { # MSNP6 - 9 'msmsgs@msnmsgr.com': ('Q1P7W2E4J9R8U3S5', 10), 'msntv@msnmsgr.com': ('', 10), # found in MSN TV 2.7 build 16109 'macmsgr@msnmsgr.com': ('A8J3D5F7L3K2V6F4', 10), 'assist@msnmsgr.com': ('L2P3B7C6V9J4T8D5', 10), 'appshare@msnmsgr.com': ('W5N2C9D7A6P3K4J8', 10), 'pocketpc@msnmsgr.com': ('', 10), 'TEST00578M1WM}RR': ('', 10), 'PROD0038W!61ZTF9': ('VT6PX?UQTM4WM%YR', 10), 'PROD0058#7IL2{QD': ('QHDCY@7R1TB6W?5B', 10), 'PROD0061VRRZH@4F': ('JXQ6J@TUOGYV@N0M', 10), 'PROD0062I2RVG#RV': ('LPOFJ{8L6AM2N!G', 10), 'PROD0066X_86JBY8': ('%_IP#M2WDG247}@I', 10), 'PROD00678K?EPLA}': ('MP8JK{DUDLDMC{9L', 10), 'PROD0074Z}QA4HPI': ('5JHDY@F5_KLEF?3O', 10), 'PROD0075THRTM{7!': ('WLJIQ$8LDLNI_J4Q', 10), 'PROD0076ENE8*@AW': ('CEQJ8}OE0!WTSWII', 10), 'PROD0039E3VGM%GB': ('B7WRX$T9S3875{68', 10), # found in MSN Messenger 4.7.0106 'PROD00504RLUG%WL': ('I2EBK%PYNLZL5_J4', 10), 'PROD00517IFH4@RV': ('MYRED!3QTCFWG@9G', 10), # found in Windows Messenger 5.1.0701 'PROD00444_M6XYJT': ('UMJBL@QN17VEI{5L', 10), # found in Messenger for Mac 3.0 'PROD0045YI56T?TX': ('FV!WOP5UKXO8$LV$', 10), 'PROD0046K9O#QFXY': ('8{B7#LEX_V5HV@SQ', 10), # MSNP11 - 12 'PROD008955JTJ_S7': ('DHCPQ$8JI5HD3{4L', 12), 'PROD0090YUAUV{2B': ('YMM8C_H7KCQ2S_KL', 12), 'PROD00974#MT*RC2': ('LMCVO*18PQJ3H!K3', 12), 'PROD0101{0RM?UBW': ('CFHUR$52U_{VIX5T', 12), 'PROD0102LUNTP%M?': ('JD5QT%#ILEBP5?LI', 12), "PROD0104U6VVM{UJ": ('VK67B}379XYM5}$T', 12), # MSNP13 - 14 'PROD01065C%ZFN6F': ('O4BG@C7BWLYQX?5G', 14), 'PROD0112J1LW7%NB': ('RH96F{PHI8PPX_TJ', 14), # MSNP15+ 'PROD0113H11T8$X_': ('RG@XY*28Q5QHS%Q5', 21), 'PROD0114ES4Z%Q5W': ('PK}_A_0N_K%O?A9S', 21), 'PROD0116PE?TSI1': ('EXFK#_48PJR82_3G', 21), 'PROD0118R6%2WYOS': ('YIXPX@5I2P0UT*LK', 21), 'PROD0119GSJUC$18': ('ILTXC!4IXB5FB*PX', 21), # Thanks J.M. for making the tweet with this WLM 2009 ID-key combo. ^_^ 'PROD0120PW!CCV9@': ('C1BX{V4W}Q3*10SM', 21), }