Files
azul/front/oscar/ctrl.py
T
Athena Funderburg 4b463a3432 init
2026-05-25 07:05:17 +00:00

240 lines
7.4 KiB
Python

import asyncio
import random
import struct
from core import error
from core.backend import Backend, BackendSession, Chat, ChatSession
from core.client import Client
from core.models import LoginOption, ContactList
from itertools import cycle
from typing import Optional, Callable, Tuple
from util.misc import Logger
from .proto.backend import BackendEventHandler, FOODGROUP_VERSIONS, login, LoginError, bos_cookies
from .proto.chat import ChatEventHandler
from .proto.snac import OSCARClient, OSCARContext, SNACMessage, foodgroups
from .proto.tlv import unmarshal_tlvs, find_tlv
ROASTING_CHARS = b'\xF3\x26\x81\xC4\x39\x86\xDB\x92\x71\xA3\xB9\xE6\x53\x7A\x95\x7C'
def roast(password: bytes) -> bytes:
chars = cycle(ROASTING_CHARS)
return bytes(byte ^ next(chars) for byte in password)
# TODO(subpurple): as the foodgroups are no longer handled just in this file, might want to combine this and entry
class OSCARCtrl:
logger: Logger
transport: Optional[asyncio.WriteTransport]
close_callback: Optional[Callable[[], None]]
closed: bool
backend: Backend
bs: Optional[BackendSession]
client: Client
oscarClient: OSCARClient
context: OSCARContext
sequence: int = random.randint(0x0000, 0xFFFF)
def __init__(self, logger: Logger, via: str, backend: Backend) -> None:
self.logger = logger
self.transport = None
self.close_callback = None
self.closed = False
self.backend = backend
self.bs = None
self.client = Client('aim', '?', via)
self.context = OSCARContext(self.backend, self.client)
self.oscarClient = OSCARClient(self)
def send_specific_frame(self, frame: int, data: bytes) -> None:
if self.sequence == 0xFFFF:
self.sequence = 0x0000
else:
self.sequence += 1
packet = b''.join([
struct.pack('>BBHH', 0x2A, frame, self.sequence, len(data)),
data
])
self.transport.write(packet)
def send_snac(self, msg: SNACMessage) -> None:
self.send_specific_frame(0x02, msg.marshal())
def on_connect(self) -> None:
self.send_specific_frame(0x01, struct.pack('>L', 1))
def on_signon_frame(self, data: bytes) -> None:
if len(data) > 4:
tlvs = unmarshal_tlvs(data[4:])
if (cookie_tlv := find_tlv(tlvs, 0x0006)) is not None:
found = False
is_service_connection = False
for i, d in enumerate(bos_cookies):
for cookie in d.items():
if cookie[0] == cookie_tlv.data:
self.logger.info('found BOS cookie')
if hasattr(cookie[1], 'user') and getattr(cookie[1].user, 'uuid', None) is not None:
# cookie maps to an existing BackendSession (placed there by OSERVICE__SERVICE_REQUEST)
# reuse it directly so the main session is not booted and its substatus is preserved
self.context.bs = cookie[1]
self.context.user = cookie[1].user
self.bs = cookie[1]
is_service_connection = True
else:
if isinstance(cookie[1], dict):
uuid = cookie[1].get('uuid')
version = cookie[1].get('version', '?')
self.client = Client('aim', version, self.client.via)
self.context.client = self.client
elif getattr(cookie[1], 'uuid', None) is not None:
uuid = cookie[1].uuid
else:
uuid = cookie[1]
self.context.bs = self.backend.login(uuid,
self.client,
BackendEventHandler(self),
option=LoginOption.BootOthers)
self.context.user = self.context.bs.user
self.bs = self.context.bs
bos_cookies.pop(i)
found = True
break
if found:
break
if found:
# NINA also sends OSERVICE__WELL_KNOWN_URLS right after (should we?)
self.logger.info('[Server] OSERVICE__HOST_ONLINE')
msg = SNACMessage(0x0001, 0x0003)
for foodgroup in FOODGROUP_VERSIONS.keys():
msg.write_u16(foodgroup)
self.send_snac(msg)
if not is_service_connection:
# only initialize these keys on the primary BOS connection
# so other service connections don't wipe the chat state built up so far
self.bs.front_data['oscar0'] = True
self.bs.front_data['oscar0_chats'] = {}
else:
self.logger.info('invalid BOS cookie given')
else:
self.logger.info('Using FLAP-level authentication')
screen_name_tlv = find_tlv(tlvs, 0x0001)
roasted_pw_tlv = find_tlv(tlvs, 0x0002)
screen_name = screen_name_tlv.data.decode()
roasted_pw = roasted_pw_tlv.data
self.logger.info('Screen Name (client-given):', screen_name)
self.logger.info('Password (roasted):', roasted_pw.hex())
context = self.context
error_code = None
if (uuid := context.backend.util_get_uuid_from_username(screen_name)) is None:
error_code = LoginError.UnregisteredScreenname
self.logger.info('Unregistered screenname')
else:
unroasted_pw = roast(roasted_pw)
if context.backend.user_service.login_with_username(screen_name, unroasted_pw.decode()) is None:
error_code = LoginError.IncorrectPassword
self.logger.info('Incorrect password')
self.bs.front_data['oscar0'] = True
self.bs.front_data['oscar0_chats'] = {}
self.send_specific_frame(0x04, login(self.logger, self.context, tlvs, uuid, error_code))
def on_data_frame(self, message: SNACMessage) -> None:
found = False
# kick client off if we haven't authenticated and client is trying to access something outside of BUCP
if message.foodgroup != 0x0017 and self.bs is None:
self.close()
return
for value, cls in foodgroups.items():
if value == message.foodgroup:
if not hasattr(cls, 'logger'):
cls.logger = self.logger
if message.subgroup in cls.subgroups:
found = True
func = cls.subgroups[message.subgroup]
func(cls, self.oscarClient, self.context, message)
if not found:
self.logger.info(f'[Client] Unknown SNAC({hex(message.foodgroup)},{hex(message.subgroup)})')
self.logger.info('[Client]', message.data.hex())
def on_error_frame(self, data: bytes) -> None:
self.logger.info('[Client] Recieved error frame with:', data.hex())
def on_signoff_frame(self, data: bytes) -> None:
self.logger.info('[Client] Recieved signoff frame')
def close(self) -> None:
if self.closed:
return
self.closed = True
if self.close_callback:
self.close_callback()
if self.bs:
self.bs.close()
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['oscar0_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['oscar0'] = True
# `user` joins
evt = ChatEventHandler(self.backend.loop, self, bs)
cs = chat.join('icbm', bs, evt)
bs.front_data['oscar0_chats'][other_user_uuid] = (cs, evt)
cs.invite(other_user)
elif other_user.status.is_offlineish():
raise error.ContactNotOnline()
return bs.front_data['oscar0_chats'].get(other_user_uuid)