production init

This commit is contained in:
Athena Funderburg
2026-05-26 16:41:23 +00:00
commit 21f38ee3e1
680 changed files with 47071 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
import os
import struct
import settings
from array import array
from core import event
from core.backend import Chat, ChatSession, BackendSession
from core.models import Contact, Substatus, Circle, CircleRole, User, TextWithData, OIM, LoginOption
from enum import IntEnum
from util.misc import Logger
from urllib.parse import quote
from typing import Optional, Any, Dict
from ..foodgroups.buddy import build_presence_notif
from ..foodgroups.popup import popup_display
from ..proto.snac import OSCARContext
from ..proto.tlv import TLV, marshal_tlvs, find_tlv
from ..proto.chat import ChatEventHandler
# TODO(subpurple): move this to a config of some sort
FOODGROUP_VERSIONS: {int, int} = {
0x0001: 4, # OSERVICE
0x0002: 1, # LOCATE
0x0003: 1, # BUDDY
0x0004: 1, # ICBM
0x0006: 1, # INVITE
0x0008: 1, # POPUP
0x0009: 1, # BOS
0x000A: 1, # USER_LOOKUP
0x000B: 1, # STATS
0x000C: 1, # TRANSLATE
0x0013: 6, # FEEDBAG
0x0015: 2, # ICQ
0x0022: 1, # PLUGIN
0x0024: 1, # UNNAMED (possibly NACHOS?)
0x0025: 1 # MDIR
}
ERROR_URLS: {int, str} = {
0x0001: 'http://www.aim.aol.com/errors/UNREGISTERED_SCREENNAME.html', # Unregistered screen name
0x0005: 'http://www.aim.aol.com/errors/MISMATCH_PASSWD.html', # Incorrect password
0x0011: 'http://www.aim.aol.com/errors/SUSPENDED.html', # Suspended
}
PW_CHANGE_URL_FORMAT = 'http://aim.aol.com/redirects/password/change_password.adp?ScreenName={}&ccode={}&lang={}'
bos_cookies: array[{bytes, OSCARContext}] = []
class BackendEventHandler(event.BackendEventHandler):
__slots__ = ('ctrl', 'bs')
ctrl: Any
def __init__(self, ctrl: Any) -> None:
self.ctrl = ctrl
def on_maintenance_message(self, *args: Any, **kwargs: Any) -> None:
self.ctrl.logger.info("on_maintenance message dispatched")
if args[1] is not None and args[1] > 0:
message = """CrossTalk will be down for maintenance in around {} minute(s). \
Now is a good time to wrap up any conversations.""".format(args[1])
self.ctrl.send_snac(popup_display(self, "https://crosstalk.im/maintenance", message))
def on_client_alert(self, icon_url: Optional[str], url: Optional[str], message: str = '') -> None:
self.ctrl.logger.info("on_client_alert dispatched")
self.ctrl.send_snac(popup_display(self, url, message))
def on_maintenance_boot(self) -> None:
# TODO(HIDEN): can we pass an informative message to the user?
self.ctrl.close()
return
def on_presence_notification(
self, ctc: Contact, on_contact_add: bool, old_substatus: Substatus, *,
trid: Optional[str] = None, update_status: bool = True, update_info_other: bool = True,
send_status_on_bl: bool = False, sess_id: Optional[int] = None,
updated_phone_info: Optional[Dict[str, Any]] = None,
) -> None:
self.ctrl.logger.info('on_presence_notification dispatched')
self.ctrl.logger.info('update_status:', update_status)
self.ctrl.logger.info('update_info_other:', update_info_other)
self.ctrl.logger.info('status:', ctc.status.substatus.name)
# don't want any non-status updates
if not update_status:
self.ctrl.logger.info('fell into update_status check')
return
# for now, we only send presence notifications if the buddy changing status is offline(-ish) or online
if not ctc.status.is_offlineish() and ctc.status.substatus != Substatus.Online:
self.ctrl.logger.info('fell into status check')
return
self.ctrl.logger.info('sending presence notif')
self.ctrl.send_snac(build_presence_notif(ctc))
def on_presence_self_notification(self, old_substatus: Substatus, *, update_status: bool = True,
update_info: bool = True) -> None:
self.ctrl.logger.info('on_presence_self_notification')
pass
def on_chat_invite(
self, chat: Chat, inviter: User, *, circle: bool = False, inviter_id: Optional[str] = None,
invite_msg: str = '',
) -> None:
self.ctrl.logger.info('on_chat_invite')
if chat is None:
chat = self.backend.chat_create()
chat.add_id('oscar0', chat.ids['main'])
evt = ChatEventHandler(self.ctrl.backend.loop, self.ctrl, self.bs)
cs = chat.join('oscar0', self.bs, evt)
chat.send_participant_joined(cs)
self.bs.front_data['oscar0_chats'][inviter.uuid] = (cs, evt)
return
def on_declined_chat_invite(self, chat: Chat, circle: bool = False) -> None:
self.ctrl.logger.info('on_declined_chat_invite')
pass
def on_added_me(self, user: User, *, adder_id: Optional[str] = None,
message: Optional[TextWithData] = None) -> None:
self.ctrl.logger.info('on_added_me')
pass
def on_removed_me(self, user: User) -> None:
self.ctrl.logger.info('on_removed_me')
pass
def on_contact_request_denied(self, user_added: User, message: Optional[str], *,
contact_id: Optional[str] = None) -> None:
self.ctrl.logger.info('on_contact_request_denied')
pass
def on_oim_sent(self, oim: OIM) -> None:
self.ctrl.logger.info('on_oim_sent')
pass
def on_login_elsewhere(self, option: LoginOption) -> None:
self.ctrl.logger.info('on_login_elsewhere')
self.ctrl.close()
return
# circles are not a thing in AIM
def on_circle_invite_revoked(self, chat_id: str) -> None:
pass
def on_accepted_circle_invite(self, circle: Circle) -> None:
pass
def on_circle_updated(self, circle: Circle) -> None:
pass
def on_left_circle(self, circle: Circle) -> None:
pass
def on_circle_role_updated(self, chat_id: str, role: CircleRole) -> None:
pass
def on_circle_created(self, circle: Circle) -> None:
pass
def on_close(self) -> None:
self.ctrl.close()
class LoginError(IntEnum):
UnregisteredScreenname = 0x0001,
IncorrectPassword = 0x0005,
NotATester = 0x0009,
Suspended = 0x0011
def login(logger: Logger,
context: OSCARContext,
tlvs: array[TLV],
uuid: Optional[str],
error_code: Optional[int]) -> bytes:
# get user if found
user = context.backend.user_service.get(uuid) if uuid else None
# find screen name TLV and use it if not found in DB
screen_name_tlv = find_tlv(tlvs, 0x0001)
screen_name = user.username if user else screen_name_tlv.data.decode()
# additionally check if user is suspended here
if error_code is None and user.suspended:
error_code = LoginError.Suspended
logger.info(screen_name, 'tried to sign on but is suspended!')
if error_code is None and not user.is_tester:
error_code = LoginError.NotATester
logger.info(screen_name, 'tried to sign on but is not a tester!')
if error_code is None:
logger.info(screen_name, 'signed on successfully!')
# generate BOS cookie and add it to array
bos_cookie = os.urandom(256)
bos_cookies.append({
bos_cookie: uuid
})
# get user email
email = user.email
# find client's country TLV for use in password change URL
country_tlv = find_tlv(tlvs, 0x000E)
country = country_tlv.data if country_tlv else 'us'
# find client's language TLV for use in password change URL
language_tlv = find_tlv(tlvs, 0x000F)
language = language_tlv.data if language_tlv else 'en'
# format password change URL with found client country and language
pw_change_url = PW_CHANGE_URL_FORMAT.format(
quote(screen_name),
country,
language
)
return marshal_tlvs([
TLV(0x0001, screen_name), # Screen name
TLV(0x0005, f'{settings.TARGET_IP}:5190'), # BOS address
TLV(0x0006, bos_cookie), # BOS authorization cookie
TLV(0x0011, email), # User's e-mail address
TLV(0x0013, struct.pack('>H', 1)), # Registration status
TLV(0x0054, pw_change_url), # Password change URL
TLV(0x008E, struct.pack('>B', 0)) # Unknown
])
return marshal_tlvs([
TLV(0x0001, screen_name), # Screen name
TLV(0x0008, struct.pack('>H', error_code)), # Error code
TLV(0x0004, ERROR_URLS[error_code]), # Error URL
])
+249
View File
@@ -0,0 +1,249 @@
import struct
from array import array
from dataclasses import dataclass
from .tlv import TLV, marshal_tlvs, unmarshal_tlvs
@dataclass
class Buffer:
data: bytes
def __init__(self, data: bytes = b''):
self.data = data
def __len__(self) -> int:
return len(self.data)
########################
# read_xxx() functions #
########################
def read_bytes(self, length: int) -> bytes:
"""Reads the specified amount of bytes from the buffer.
Args:
length: bytes to read
Returns:
bytes: the bytes read
"""
value = self.data[:length]
self.data = self.data[length:]
return value
def read_u8(self) -> int:
"""Reads a byte (8 bits) from the buffer.
Returns:
int: The read byte from the buffer.
"""
value, = struct.unpack('>B', self.data[:1])
self.data = self.data[1:]
return value
def read_u16(self) -> int:
"""Reads an unsigned short (16 bits) from the buffer.
Returns:
int: The read unsigned short from the buffer.
"""
value, = struct.unpack('>H', self.data[:2])
self.data = self.data[2:]
return value
def read_u32(self) -> int:
"""Reads an unsigned int (32 bits) from the buffer.
Returns:
int: The read unsigned int from the buffer.
"""
value, = struct.unpack('>L', self.data[:4])
self.data = self.data[4:]
return value
def read_string(self, length: int) -> str:
"""Reads a string with the specified length from the buffer.
Args:
length: The length of the string to read.
Returns:
str: The read string from the buffer."""
str_bytes = self.data[:length]
self.data = self.data[length:]
return str_bytes.decode('utf-8')
def read_string_u8(self) -> str:
"""Reads a string prepended with a u8 showing the length of the string.
Returns:
str: The read string from the buffer.
"""
return self.read_string(self.read_u8())
def read_string_u16(self) -> str:
"""Reads a string prepended with a u16 showing the length of the string.
Returns:
str: The read string from the buffer.
"""
return self.read_string(self.read_u16())
def read_string_u32(self) -> str:
"""Reads a string prepended with a u32 showing the length of the string.
Returns:
str: The read string from the buffer.
"""
return self.read_string(self.read_u32())
def read_tlv_block(self) -> array[TLV]:
"""Reads a TLV list prepended with a u16 showing the number of TLVs in the list, from the buffer.
Returns:
array[TLV]: The TLVs found in the block.
"""
length = self.read_u16()
tlvs = []
for _ in range(length):
type, length = struct.unpack('>HH', self.data[0:4])
value = self.data[4:length + 4]
# make sure length is not too long
assert len(self) > length - 4
tlvs.append(TLV(type, value))
self.data = self.data[length + 4:]
return tlvs
def read_tlv_l_block(self) -> array[TLV]:
"""Reads a list of TLVs prepended with a u16 showing the length of the total TLV bytes, from the buffer.
Returns:
array[TLV]: The TLVs found in the block.
"""
length = self.read_u16()
tlvs = unmarshal_tlvs(self.data[:length])
self.data = self.data[length:]
return tlvs
#########################
# write_xxx() functions #
#########################
def write_bytes(self, value: bytes) -> None:
"""Writes the specified bytes into the buffer.
Args:
value: The bytes to write into the buffer.
"""
self.data += value
def write_u8(self, value: int) -> None:
"""Writes a byte (8 bits) into the buffer.
Args:
value: The byte to write into the buffer.
"""
self.data += struct.pack('>B', value)
def write_u16(self, value: int) -> None:
"""Writes a unsigned short (16 bits) into the buffer.
Args:
value: The unsigned short to write into the buffer.
"""
self.data += struct.pack('>H', value)
def write_u32(self, value: int) -> None:
"""Writes a unsigned int (32 bits) into the buffer.
Args:
value: The unsigned int to write into the buffer.
"""
self.data += struct.pack('>L', value)
def write_string(self, value: str) -> None:
"""Writes a string imto the buffer.
Args:
value: The string to write into the buffer.
"""
self.data += value.encode('utf-8')
def write_string_u8(self, value: str) -> None:
"""Writes a string prepended with a u8 showing the length of the string into the buffer.
Args:
value: The string to write into the buffer.
"""
self.write_u8(len(value))
self.write_string(value)
def write_string_u16(self, value: str) -> None:
"""Writes a string prepended with a u16 showing the length of the string into the buffer.
Args:
value: The string to write into the buffer.
"""
self.write_u16(len(value))
self.write_string(value)
def write_string_u32(self, value: str) -> None:
"""Writes a string prepended with a u32 showing the length of the string into the buffer.
Args:
value: The string to write into the buffer.
"""
self.write_u32(len(value))
self.write_string(value)
def write_tlv(self, tlv: TLV) -> None:
"""Writes the specified TLV into the buffer.
Args:
tlv: The TLV to write into the buffer.
"""
self.data += tlv.marshal()
def write_tlvs(self, tlvs: array[TLV]) -> None:
"""Writes a list of TLVs into the buffer.
Args:
tlvs: The list of TLVs to write into the buffer.
"""
self.data += marshal_tlvs(tlvs)
def write_tlv_block(self, tlvs: array[TLV]) -> None:
"""Writes a list of TLVs prepended with a u16 describing the TLV count in the list, into the buffer.
Args:
tlvs: The list of TLVs to write into the buffer.
"""
self.write_u16(len(tlvs))
self.write_tlvs(tlvs)
def write_tlv_l_block(self, tlvs: array[TLV]) -> None:
"""Writes a list of TLVs prepended with a u16 describing the length of the total TLV bytes, into the buffer.
Args:
tlvs: The list of TLVs to write into the buffers.
"""
marshalled_tlvs = marshal_tlvs(tlvs)
self.write_u16(len(marshalled_tlvs))
self.write_bytes(marshalled_tlvs)
+61
View File
@@ -0,0 +1,61 @@
import asyncio
import secrets
from typing import Any, Optional
from core import event
from core.backend import BackendSession, ChatSession, Chat
from core.models import User, Substatus, MessageData, MessageType
from ..proto.snac import SNACMessage
from ..proto.tlv import TLV
from ..proto.buffer import Buffer
from ..foodgroups.icbm import ICBMChannel, messagedata_to_icbm
class ChatEventHandler(event.ChatEventHandler):
__slots__ = ('loop', 'ctrl', 'bs', 'cs', 'cookie')
loop: asyncio.AbstractEventLoop
ctrl: Any
bs: BackendSession
cs: ChatSession
cookie: bytes
def __init__(self, loop: asyncio.AbstractEventLoop, ctrl: Any, bs: BackendSession) -> None:
self.loop = loop
self.ctrl = ctrl
self.bs = bs
self.cookie = secrets.token_bytes(8)
def on_participant_joined(self, cs_other: 'ChatSession', first_pop: bool, initial_join: bool) -> None:
self.ctrl.logger.info('on_participant_joined')
pass
def on_participant_left(self, cs_other: 'ChatSession', last_pop: bool) -> None:
self.ctrl.logger.info('on_participant_left')
pass
def on_chat_invite_declined(self, chat: 'Chat', invitee: User, *, invitee_id: Optional[str] = None,
message: Optional[str] = None, circle: bool = False) -> None:
self.ctrl.logger.info('on_chat_invite_declined')
pass
def on_chat_updated(self) -> None:
self.ctrl.logger.info('on_chat_updated')
pass
def on_chat_roster_updated(self) -> None:
self.ctrl.logger.info('on_chat_roster_updated')
pass
def on_participant_status_updated(self, cs_other: 'ChatSession', first_pop: bool, initial: bool,
old_substatus: Substatus) -> None:
self.ctrl.logger.info('on_participant_status_updated')
pass
def on_message(self, data: MessageData) -> None:
self.ctrl.logger.info('Got a message from', data.sender.username, 'saying:')
self.ctrl.logger.info(data.text.encode())
self.ctrl.send_snac(messagedata_to_icbm(self.cookie, data, self.bs.user))
+17
View File
@@ -0,0 +1,17 @@
import struct
from typing import Optional
from util.misc import Logger
from ..proto.tlv import TLV, marshal_tlvs
def system_message(alert_url: Optional[str], alert_txt: str) -> bytes:
return marshal_tlvs([
TLV(0x0003, struct.pack('>H', 0x00D9)), # message window width
TLV(0x0004, struct.pack('>H', 0x0096)), # message window length
TLV(0x0005, struct.pack('>H', 0x001E)), # autohide delay
TLV(0x0002, alert_url), # alert URL
TLV(0x0001, alert_txt), # alert text
])
+132
View File
@@ -0,0 +1,132 @@
from core.backend import Backend, BackendSession
from core.client import Client
from core.models import User
from dataclasses import dataclass
from typing import Optional, Callable, Any
from .buffer import Buffer
foodgroups = {}
@dataclass
class SNACMessage(Buffer):
__slots__ = ('foodgroup', 'subgroup', 'flags', 'request_id', 'data')
foodgroup: int
subgroup: int
flags: int
request_id: int
data: bytes
def __init__(self, foodgroup: int = 0x0000, subgroup: int = 0x0000, flags: int = 0x0000,
request_id: int = 0x00000000, data: bytes = b'') -> None:
super().__init__()
self.foodgroup = foodgroup
self.subgroup = subgroup
self.flags = flags
self.request_id = request_id
self.data = data
def marshal(self) -> bytes:
# we use a new Buffer here because even though we are a subclass of Buffer, the methods are reserved
# for SNAC data (everything after the header)
buf = Buffer()
buf.write_u16(self.foodgroup)
buf.write_u16(self.subgroup)
buf.write_u16(self.flags)
buf.write_u32(self.request_id)
buf.write_bytes(self.data)
return buf.data
def unmarshal(self, flap_data: bytes) -> None:
buf = Buffer(flap_data)
self.foodgroup = buf.read_u16()
self.subgroup = buf.read_u16()
self.flags = buf.read_u16()
self.request_id = buf.read_u32()
self.data = buf.data
class Foodgroup:
__slots__ = ('value', 'cls')
value: int
cls: Any
def __init__(self, value) -> None:
self.value = value
self.cls = None
def __call__(self, *args) -> None:
self.cls = args[0]
foodgroups[self.value] = self.cls()
class Subgroup:
__slots__ = ('value', 'mode', 'func')
value: int
mode: str
func: Optional[Callable]
def __init__(self, value) -> None:
self.value = value
self.mode = 'decorating'
self.func = None
def __call__(self, *args) -> Any:
if self.mode == 'decorating':
self.func = args[0]
self.mode = 'calling'
return self
return self.func(*args)
def __set_name__(self, owner, name) -> None:
if not hasattr(owner, 'subgroups'):
owner.subgroups = {}
owner.subgroups[self.value] = self.func
self.func.class_name = owner.__name__
setattr(owner, name, self.func)
# OSCARClient and OSCARContext
class OSCARClient:
__slots__ = 'ctrl'
ctrl: Any # Any because of circular imports - TODO(subpurple): make this less hacky
def __init__(self, ctrl: Any) -> None:
self.ctrl = ctrl
def send_snac(self, msg: SNACMessage) -> None:
self.ctrl.send_specific_frame(0x02, msg.marshal())
def get_ip(self) -> str:
ip, *_ = self.ctrl.transport.get_extra_info('peername')
return ip
@dataclass
class OSCARContext:
__slots__ = ('backend', 'bs', 'client', 'user')
backend: Backend
bs: Optional[BackendSession]
client: Client
# These slots are equivalent to .bs.* and only exist for the sake of convenience
user: Optional[User]
def __init__(self, backend: Backend, client: Client) -> None:
self.backend = backend
self.bs = None
self.client = client
self.user = None
+54
View File
@@ -0,0 +1,54 @@
from . import buffer # because of circular imports, would've prefered to do `from .buffer import ...` but oh well
from dataclasses import dataclass
from typing import Optional
@dataclass
class TLV:
__slots__ = ('type', 'data')
type: int
data: bytes
def __init__(self, type: int, data: bytes | str = b''):
self.type = type
if isinstance(data, str):
self.data = data.encode()
else:
self.data = data
def marshal(self) -> bytes:
buf = buffer.Buffer()
buf.write_u16(self.type)
buf.write_u16(len(self.data))
buf.write_bytes(self.data)
return buf.data
def marshal_tlvs(tlvs: list[TLV]) -> bytes:
return b''.join([tlv.marshal() for tlv in tlvs])
def unmarshal_tlvs(data: bytes) -> list[TLV]:
buf = buffer.Buffer(data)
tlvs = []
while len(buf) > 4:
type = buf.read_u16()
length = buf.read_u16()
value = buf.read_bytes(length)
tlvs.append(TLV(type, value))
return tlvs
def find_tlv(tlvs: list[TLV], type: int) -> Optional[TLV]:
for tlv in tlvs:
if tlv.type == type:
return tlv
return None