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
+2
View File
@@ -0,0 +1,2 @@
from .entry import register
from .foodgroups import *
+222
View File
@@ -0,0 +1,222 @@
import asyncio
import random
import struct
from core.backend import Backend, BackendSession, Chat, ChatSession
from core.client import Client
from core.models import LoginOption
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.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
for i, d in enumerate(bos_cookies):
for cookie in d.items():
if cookie[0] == cookie_tlv.data:
if hasattr(cookie[1], 'user') and getattr(cookie[1].user, 'uuid', None) is not None:
uuid = cookie[1].user.uuid
elif getattr(cookie[1], 'uuid', None) is not None:
uuid = cookie[1].uuid
else:
uuid = cookie[1]
self.logger.info('found BOS cookie')
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)
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)
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import struct
import settings
from core.backend import Backend
from typing import Optional, Callable
from threading import Thread
from util.misc import Logger, ProtocolRunner
from .ctrl import OSCARCtrl
from .proto.snac import SNACMessage
def register(loop: asyncio.AbstractEventLoop, backend: Backend) -> None:
backend.add_runner(ProtocolRunner('0.0.0.0', 5190, ListenerOSCAR, args=['OSCAR', backend, OSCARCtrl], service = 'OSCAR'))
class ListenerOSCAR(asyncio.Protocol):
logger: Logger
backend: Backend
controller: OSCARCtrl
transport: Optional[asyncio.WriteTransport]
buffer: bytes = b''
data_thread: Thread = None
def __init__(self,
logger_prefix: str,
backend: Backend,
controller_factory: Callable[[Logger, str, Backend], OSCARCtrl]) -> None:
super().__init__()
self.logger = Logger(logger_prefix, self)
self.backend = backend
self.controller = controller_factory(self.logger, 'direct', backend)
self.controller.close_callback = self._on_close
self.transport = None
def connection_made(self, transport: asyncio.BaseTransport) -> None:
self.controller.transport = transport
self.controller.on_connect()
self.logger.log_connect()
def connection_lost(self, exc: Optional[Exception]) -> None:
self.controller.close()
self.logger.log_disconnect()
def data_received(self, packet: bytes) -> None:
if self.backend.maintenance_mode:
self.transport.close()
return
self.buffer += packet
if self.data_thread is None or not self.data_thread.is_alive():
self.data_thread = Thread(target=self.parse_buffer)
self.data_thread.start()
def parse_buffer(self) -> None:
while True:
if self.buffer[0] != 0x2A:
break
# TODO(subpurple): Bufferize this?
frame, sequence, length = struct.unpack('>BHH', self.buffer[1:6])
if len(self.buffer) < length + 6:
break
if len(self.buffer) >= length + 6:
data = self.buffer[6:length + 6]
match frame:
# SIGNON
case 0x01:
self.controller.on_signon_frame(data)
# DATA (always contains a SNAC)
case 0x02:
if len(data) < 10:
return
msg = SNACMessage()
msg.unmarshal(data)
self.controller.on_data_frame(msg)
# ERROR
case 0x03:
self.controller.on_error_frame(data)
# SIGNOFF
case 0x04:
self.controller.on_signoff_frame(data)
# KEEP_ALIVE
case 0x05:
pass
case _:
self.logger.info('Recieved unknown frame:', str(frame), 'with data:', data.hex())
self.buffer = self.buffer[length + 6:]
if len(self.buffer) < 6:
break
def _on_close(self) -> None:
if self.transport is not None:
self.transport.close()
+10
View File
@@ -0,0 +1,10 @@
__all__ = [
'oservice',
'locate',
'buddy',
'icbm',
'bos',
'stats',
'feedbag',
'bucp'
]
+26
View File
@@ -0,0 +1,26 @@
import struct
from util.misc import Logger
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import TLV
@Foodgroup(0x0009)
class BOSFoodgroup:
logger: Logger
@Subgroup(0x0002)
def rights_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] BOS__RIGHTS_QUERY')
response_msg = SNACMessage(0x0009, 0x0003)
response_msg.write_tlvs([
TLV(0x0001, struct.pack('>H', 1000)), # max permits user is allowed
TLV(0x0002, struct.pack('>H', 1000)), # max deny entries user is allowed
TLV(0x0003, struct.pack('>H', 1000)) # max temp permits user is allowed
])
self.logger.info('[Server] BOS__RIGHTS_REPLY')
client.send_snac(response_msg)
+68
View File
@@ -0,0 +1,68 @@
from util.misc import Logger
from util.hash import gen_salt
from ..proto.backend import login, LoginError
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import unmarshal_tlvs, find_tlv
pw_change_url_format = 'http://aim.aol.com/redirects/password/change_password.adp?ScreenName={}&ccode=us&lang=en'
@Foodgroup(0x0017)
class BUCPFoodgroup:
logger: Logger
@Subgroup(0x0006)
def challenge_request(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] BUCP__CHALLENGE_REQUEST')
tlvs = unmarshal_tlvs(message.data)
screen_name_tlv = find_tlv(tlvs, 0x0001)
screen_name = screen_name_tlv.data.decode()
salt = context.backend.user_service.aim_get_md5_salt(screen_name)
if salt is None:
# screen name doesn't exist or user did not enable OSCAR
salt = gen_salt()
response_msg = SNACMessage(0x0001, 0x0007)
response_msg.write_u16(len(salt))
response_msg.write_string(salt)
self.logger.info('[Server] BUCP__CHALLENGE_RESPONSE (salt:', salt + ')')
client.send_snac(response_msg)
@Subgroup(0x0002)
def login_request(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] BUCP__LOGIN_REQUEST')
tlvs = unmarshal_tlvs(message.data)
screen_name_tlv = find_tlv(tlvs, 0x0001)
hashed_pw_tlv = find_tlv(tlvs, 0x0025)
screen_name = screen_name_tlv.data.decode()
self.logger.info('Screen Name (client-given):', screen_name)
self.logger.info('Password (hashed):', hashed_pw_tlv.data)
response_msg = SNACMessage(0x0017, 0x0003)
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:
password = context.backend.user_service.aim_get_md5_password(screen_name)
if password != hashed_pw_tlv.data:
error_code = LoginError.IncorrectPassword
self.logger.info('Incorrect password')
response_msg.write_bytes(login(self.logger, context, tlvs, uuid, error_code))
client.send_snac(response_msg)
+111
View File
@@ -0,0 +1,111 @@
import struct
import time
from core.models import Contact
from util.misc import Logger
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import TLV, unmarshal_tlvs
# should be piped to .ctrl.send_snac()
def build_presence_notif(contact: Contact) -> SNACMessage:
# Packet dump from NINA's servers when a contact goes online:
# ===
# 0000 04 64 6b 61 79 00 00 00 07 00 30 00 04 67 19 67 .dkay.....0..g.g
# 0010 4c 00 0d 00 c0 09 46 13 45 4c 7f 11 d1 82 22 44 L.....F.EL...."D
# 0020 45 53 54 00 00 09 46 01 ff 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 0030 45 53 54 00 00 74 8f 24 20 62 87 11 d1 82 22 44 EST..t.$ b...."D
# 0040 45 53 54 00 00 09 46 13 43 4c 7f 11 d1 82 22 44 EST...F.CL...."D
# 0050 45 53 54 00 00 09 46 13 41 4c 7f 11 d1 82 22 44 EST...F.AL...."D
# 0060 45 53 54 00 00 09 46 01 04 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 0070 45 53 54 00 00 09 46 01 05 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 0080 45 53 54 00 00 09 46 01 02 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 0090 45 53 54 00 00 09 46 01 03 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 00a0 45 53 54 00 00 09 46 01 01 4c 7f 11 d1 82 22 44 EST...F..L...."D
# 00b0 45 53 54 00 00 09 46 13 4a 4c 7f 11 d1 82 22 44 EST...F.JL...."D
# 00c0 45 53 54 00 00 09 46 13 46 4c 7f 11 d1 82 22 44 EST...F.FL...."D
# 00d0 45 53 54 00 00 00 1d 00 12 00 01 00 05 02 01 d2 EST.............
# 00e0 04 72 00 00 00 05 02 01 d2 04 72 00 01 00 02 00 .r........r.....
# 00f0 08 00 03 00 04 67 19 67 4b 00 0f 00 04 00 00 00 .....g.gK.......
# 0100 02 00 05 00 04 62 7c 7a cd .....b|z.
#
# Buddy length: 0x04
# Buddy name: dkay
# TLVs (7):
# - TLV 0x3000: unknown (data = 67 19 67 4C)
# - TLV 0x000D: capability list
# - TLV 0x001D: BART info (data = 00 01 00 05 02 01 D2 04 72 00 00 00 05 02 01 D2 04 72
# - TLV 0x0001: user class (data = 00 00 00 08)
# - TLV 0x0003: account signon time (value = 1729718091, in UNIX time)
# - TLV 0x000F: session length (value = 2)
# - TLV 0x0005: account creation time (value = 1652325069, in UNIX time)
#
msg = SNACMessage(0x0003, 0x000B if not contact.status.is_offlineish() else 0x000C)
msg.write_string_u8(contact.head.username)
msg.write_u16(0)
if contact.status.is_offlineish():
msg.write_tlv(TLV(0x0001, struct.pack('>L', 0x0008))) # User class (bitfield)
else:
capabilities = [
"{09461345-4C7F-11D1-8222-444553540000}", # Direct ICBM
"{094601FF-4C7F-11D1-8222-444553540000}", # Smart caps
"{748F2420-6287-11D1-8222-444553540000}", # Chat
"{09461343-4C7F-11D1-8222-444553540000}", # File transfer
"{09461341-4C7F-11D1-8222-444553540000}", # Voice chat
"{09460104-4C7F-11D1-8222-444553540000}", # RTC audio
"{09460105-4C7F-11D1-8222-444553540000}", # Unknown
"{09460102-4C7F-11D1-8222-444553540000}", # Camera
"{09460103-4C7F-11D1-8222-444553540000}", # Microphone
"{09460101-4C7F-11D1-8222-444553540000}", # RTC video
"{0946134A-4C7F-11D1-8222-444553540000}", # Games
"{09461346-4C7F-11D1-8222-444553540000}" # BART
]
capabilities_bytes = b''
for capability in capabilities:
capabilities_bytes += bytes.fromhex(capability
.lstrip('{')
.rstrip('}')
.replace('-', ''))
date_created_unix = int(time.mktime(contact.head.date_created.timetuple()))
date_login_unix = int(time.mktime(contact.head.date_login.timetuple())) if contact.head.date_login else 0
# usually would include 0x001D (BART info), but since CrossTalk doesn't support
# it rn I've decided to omit it
msg.write_tlv_block([
TLV(0x3000, struct.pack('>L', 0x6719674C)), # Unknown
TLV(0x000D, capabilities_bytes), # Capability info
TLV(0x0001, struct.pack('>H', 0x0008)), # User class (bitfield)
TLV(0x0003, struct.pack('>L', date_login_unix)), # Account signon time (unix time_t)
TLV(0x000F, struct.pack('>L', 0)), # Session length
TLV(0x0005, struct.pack('>L', date_created_unix)), # Account creation time (unix time_t)
])
print('Subgroup:', hex(msg.subgroup))
print('TLVs:', unmarshal_tlvs(msg.data))
return msg
@Foodgroup(0x0003)
class BuddyFoodgroup:
logger: Logger
@Subgroup(0x0002)
def rights_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] BUDDY_RIGHTS_QUERY')
response_msg = SNACMessage(0x0003, 0x0003)
response_msg.write_tlvs([
TLV(0x0001, struct.pack('>H', 1000)), # max buddies
TLV(0x0002, struct.pack('>H', 3000)), # max watchers
TLV(0x0004, struct.pack('>H', 160)) # max temp buddies
])
self.logger.info('[Server] BUDDY_RIGHTS_REPLY')
client.send_snac(response_msg)
+297
View File
@@ -0,0 +1,297 @@
import time
import struct
from array import array
from core.models import ContactList, Substatus
from dataclasses import dataclass
from enum import IntEnum
from typing import Optional
from util.misc import Logger
from .buddy import build_presence_notif # TODO(subpurple): move build_presence_notif out of buddy?
from ..proto.tlv import TLV
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.buffer import Buffer
# I only put the classes I need in this IntEnum
#
# For a complete list of feedbag classes, see https://wiki.nina.chat/wiki/Protocols/OSCAR/Foodgroups/FEEDBAG/Items#Class:_FEEDBAG_CLASS_IDS
class FeedbagClass(IntEnum):
Buddy = 0x0000,
Group = 0x0001,
PdInfo = 0x0004,
BuddyPrefs = 0x0005
class FeedbagAttributes(IntEnum):
Order = 0x00C8,
PdMode = 0x00CA,
WirelessPdMode = 0x00D0,
WirelessIgnoreMode = 0x00D1,
FishPdMode = 0x00D2,
FishIgnoreMode = 0x00D3,
PdMask = 0x00CB,
BuddyPrefs = 0x00C9
@dataclass
class FeedbagItem:
name: str
group_id: int
item_id: int
class_id: int
attributes: array[TLV]
def __init__(self, name: str, group_id: int, item_id: int, class_id: int, attributes: Optional[array[TLV]] = None) -> None:
self.name = name
self.group_id = group_id
self.item_id = item_id
self.class_id = class_id
self.attributes = attributes or []
def marshal(self) -> bytes:
buf = Buffer()
buf.write_string_u16(self.name)
buf.write_u16(self.group_id)
buf.write_u16(self.item_id)
buf.write_u16(self.class_id)
buf.write_tlv_l_block(self.attributes)
return buf.data
def get_items(context: OSCARContext) -> array[FeedbagItem]:
user = context.user
detail = user.detail
contacts = list({
*detail.get_contacts_by_list(ContactList.AL),
*detail.get_contacts_by_list(ContactList.FL)
})
group_feedbag = []
group_order = b''
contact_feedbag = []
for id, group in detail._groups_by_id.items():
order = b''
for contact in contacts:
user = contact.head
for grp in contact._groups.copy():
if grp.id == id:
contact_feedbag.append(FeedbagItem(user.username, int(group.id), user.id, FeedbagClass.Buddy))
order += struct.pack('>H', user.id)
group_feedbag.append(FeedbagItem(group.name, int(group.id), 0x0000, FeedbagClass.Group, [
TLV(FeedbagAttributes.Order, order)
]))
group_order += struct.pack('>H', int(group.id))
# deal with any ungrouped contacts left
if ungrouped_contacts := [contact for contact in contacts if not contact._groups]:
no_group_gid = len(group_feedbag) + 1
order = b''
for contact in ungrouped_contacts:
user = contact.head
contact_feedbag.append(FeedbagItem(user.username, no_group_gid, user.id, FeedbagClass.Buddy))
order += struct.pack('>H', user.id)
group_feedbag.append(FeedbagItem('(No Group)', no_group_gid, 0x0000, FeedbagClass.Group, [
TLV(FeedbagAttributes.Order, order)
]))
return [
FeedbagItem('', 0x0000, 0x0000, FeedbagClass.Group, [
TLV(FeedbagAttributes.Order, group_order)
]),
FeedbagItem('', 0x0000, 0x0E63, FeedbagClass.PdInfo, [
TLV(FeedbagAttributes.PdMode, struct.pack('>H', 0x0004)),
TLV(FeedbagAttributes.WirelessPdMode, struct.pack('>B', 0x0001)),
TLV(FeedbagAttributes.WirelessIgnoreMode, struct.pack('>B', 0x0001)),
TLV(FeedbagAttributes.FishPdMode, struct.pack('>B', 0x0001)),
TLV(FeedbagAttributes.FishIgnoreMode, struct.pack('>B', 0x0001)),
TLV(FeedbagAttributes.PdMask, struct.pack('>H', 0xFFFF))
]),
FeedbagItem('', 0x0000, 0x4B1D, FeedbagClass.BuddyPrefs, [
TLV(FeedbagAttributes.BuddyPrefs, struct.pack('>L', 0x00000400))
]),
*group_feedbag,
*contact_feedbag
]
def marshal_items(items: array[FeedbagItem]) -> bytes:
return b''.join([item.marshal() for item in items])
def unmarshal_items(item_bytes: bytes) -> array[FeedbagItem]:
buf = Buffer(item_bytes)
items = []
# Minimum feedbag item size (in bytes):
# Name = +2 = 2
# Group ID = +2 = 4
# Item ID = +2 = 6
# Class ID = +2 = 8
# Attributes = +2 = 10
#
while len(buf) > 10:
name = buf.read_string_u16()
group_id = buf.read_u16()
item_id = buf.read_u16()
class_id = buf.read_u16()
attributes = buf.read_tlv_l_block()
items.append(FeedbagItem(name, group_id, item_id, class_id, attributes))
return items
@Foodgroup(0x0013)
class FeedbagFoodgroup:
logger: Logger
@Subgroup(0x0002)
def rights_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__RIGHTS_QUERY')
response_msg = SNACMessage(0x0013, 0x0003)
# https://wiki.nina.chat/wiki/Protocols/OSCAR/SNAC/FEEDBAG_RIGHTS_REPLY#TLV_Class:_FEEDBAG_RIGHTS_REPLY_TAGS
response_msg.write_tlvs([
TLV(0x0002, struct.pack('>H', 254)), # max class attrs
TLV(0x0003, struct.pack('>H', 1698)), # max item attrs
# max items
TLV(0x0004, struct.pack(f'>{'H' * 67}',
1000, # max num of contacts
100, # max num of groups
1000, # max num of visible contacts
1000, # max num of invisible contacts
1, # max vis/invis bitmasks
1, # max presence info fields
150, # limit for item type 06
12, # limit for item type 07
12, # limit for item type 08
3, # limit for item type 09
50, # limit for item type 0a
50, # limit for item type 0b
0, # limit for item type 0c
128, # limit for item type 0d
1000, # max ignore list entries
20, # limit for item type 0f
200, # limit for item 10
1, # limit for item 11
100, # limit for item 12
1, # limit for item 13
25, # limit for item 14
# These values are unknown but are here in the sake of keeping response
# parity with NINA:
1, 40, 1, 10, 200, 1, 60, 200, 1, 8, 20, 1, 10000, 1000, 1000, 50, 1, 5,
500, 1, 8, 10000, 1, 1, 1, 10000, 0, 0, 1, 2000, 0, 60, 24, 10, 1, 0, 0,
0, 0, 1, 1, 1, 1, 1000, 1, 1)),
TLV(0x0005, struct.pack('>H', 100)), # max client items
TLV(0x0006, struct.pack('>H', 97)), # max item name len
TLV(0x0007, struct.pack('>H', 2000)), # max recent buddies
TLV(0x0008, struct.pack('>H', 10)), # interaction buddies
TLV(0x0009, struct.pack('>L', 432000)), # interaction half life - in 2^(-age/half_life) in seconds
TLV(0x000A, struct.pack('>L', 14)), # interaction max score
TLV(0x000B, struct.pack('>H', 0)), # unknown
TLV(0x000C, struct.pack('>H', 600)), # max buddies per group
TLV(0x000D, struct.pack('>H', 200)), # max allowed bot buddies
TLV(0x000E, struct.pack('>H', 32)) # max smart groups
])
self.logger.info('[Server] FEEDBAG__RIGHTS_REPLY')
client.send_snac(response_msg)
@Subgroup(0x0004)
def query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__QUERY')
items = get_items(context)
response_msg = SNACMessage(0x0013, 0x0006, 0x0000, message.request_id)
response_msg.write_u8(0) # feedbag protocol version - always 0
response_msg.write_u16(len(items)) # no. of feedbag items
response_msg.write_bytes(marshal_items(items)) # list of feedbag items
response_msg.write_u32(int(time.time())) # feedbag last change time - TODO: should pull from db
self.logger.info('[Server] FEEDBAG__REPLY')
client.send_snac(response_msg)
@Subgroup(0x0005)
def query_if_modified(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__QUERY_IF_MODIFIED (not implemented)')
# 66 51 29 47 00 0D
#
# 66 51 29 47 - u32 for unix timestamp of cached client-side feedbag
# 00 0D - u16 for number of items in cached client-side feedbag
cached_feedbag_timestamp = message.read_u32()
cached_feedbag_num_items = message.read_u16()
self.logger.info('[Client] Cached feedbag timestamp:', cached_feedbag_timestamp)
self.logger.info('[Client] Cached feedbag items num:', cached_feedbag_num_items)
# TODO(subpurple): do check
self.query(client, context, message)
@Subgroup(0x0007)
def use(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__USE')
# set our status to Online
context.bs.me_update({
'substatus': Substatus.Online
})
# notify the client if any contacts are online
user = context.user
detail = user.detail
contacts = list({
*detail.get_contacts_by_list(ContactList.AL)
})
for contact in contacts:
if not contact.status.is_offlineish():
client.send_snac(build_presence_notif(contact))
# stubs because I cba to implement more of feedbag rn
@Subgroup(0x0011)
def start_cluster(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__START_CLUSTER (not implemented)')
@Subgroup(0x0012)
def end_cluster(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__END_CLUSTER (not implemented)')
@Subgroup(0x0008)
def insert_item(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__INSERT_ITEM (not implemented)')
self.logger.info('[Client]', unmarshal_items(message.data))
@Subgroup(0x0009)
def update_item(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__UPDATE_ITEM (not implemented)')
self.logger.info('[Client]', unmarshal_items(message.data))
@Subgroup(0x000A)
def delete_item(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] FEEDBAG__DELETE_ITEM (not implemented)')
self.logger.info('[Client]', unmarshal_items(message.data))
+143
View File
@@ -0,0 +1,143 @@
import time, struct
from enum import IntEnum
from util.misc import Logger
from core.models import MessageData, MessageType, User
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import unmarshal_tlvs, marshal_tlvs, TLV
class ICBMChannel(IntEnum):
AOLIM = 0x0001,
Rendezvous = 0x0002,
Mime = 0x0003,
ICQ = 0x0004,
CoBrowser = 0x0005
@Foodgroup(0x0004)
class ICBMFoodgroup:
logger: Logger
@Subgroup(0x0002)
def add_paramenters(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] ICBM__ADD_PARAMENTERS (not implemented)')
self.logger.info('[Client]', message.data.hex())
@Subgroup(0x0004)
def parameter_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] ICBM__PARAMENTER_QUERY')
response_msg = SNACMessage(0x0004, 0x0005)
# Response from NINA's servers:
# ==
# 0000 2a 02 00 0a 00 1a 00 04 00 05 00 00 00 00 00 04 *...............
# [FLAP Header....] [SNAC Header................]
# 0010 00 05 00 00 00 03 02 00 03 84 03 e7 00 00 03 e8 ................
#
# The response data are not TLVs and are instead WORD/DWORDs so I cannot fit
# the names below the hex data. See https://wiki.nina.chat/wiki/Protocols/OSCAR/SNAC/ICBM_PARAMETER_REPLY and
# https://wiki.nina.chat/wiki/Protocols/OSCAR/SNAC/ICBM_ADD_PARAMETERS for more information.
response_msg.write_u16(5) # maxSlots
response_msg.write_u32(0x00003) # icbmFlags (default)
response_msg.write_u16(512) # maxIncomingICBMLen
response_msg.write_u16(999) # maxSourceEvil
response_msg.write_u16(999) # maxDestinationEvil
response_msg.write_u32(1000) # minInterICBMInterval
self.logger.info('[Server] ICBM__PARAMENTER_REPLY')
client.send_snac(response_msg)
@Subgroup(0x0006)
def channel_msg_tohost(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
# Client packet:
# ===
# 0000 44 42 38 35 30 35 00 00 00 01 04 74 65 73 74 00 DB8505.....test.
# 0010 02 00 3F 05 01 00 03 01 01 02 01 01 00 34 00 00 ..?..........4..
# 0020 00 00 3C 48 54 4D 4C 3E 3C 42 4F 44 59 20 42 47 ..<HTML><BODY BG
# 0030 43 4F 4C 4F 52 3D 22 23 66 66 66 66 66 66 22 3E COLOR="#ffffff">
# 0040 74 65 73 74 3C 2F 42 4F 44 59 3E 3C 2F 48 54 4D test</BODY></HTM
# 0050 4C 3E 00 03 00 00 L>....
#
# [OSCAR] (ID: c245) [Client] ICBM__CHANNEL_MSG_TOHOST
# [OSCAR] (ID: c245) [Client] Cookie: b'E67FA1\x00\x00'
# [OSCAR] (ID: c245) [Client] Channel: 0x1
# [OSCAR] (ID: c245) [Client] Message reciever: test
# [OSCAR] (ID: c245) [Server] TLV 2 - b'\x05\x01\x00\x03\x01\x01\x02\x01\x01\x006\x00\x00\x00\x00<HTML><BODY BGCOLOR="#ffffff">123456</BODY></HTML>'
cookie = message.read_bytes(8)
channel = message.read_u16()
reciever = message.read_string_u8()
tlvs = unmarshal_tlvs(message.data)
self.logger.info('[Client] ICBM__CHANNEL_MSG_TOHOST')
self.logger.info('[Client] Cookie:', cookie)
self.logger.info('[Client] Channel:', hex(channel))
self.logger.info('[Client] Message reciever:', reciever)
for tlv in tlvs:
self.logger.info('[Client] TLV', hex(tlv.type), '-', tlv.data)
def messagedata_to_icbm(cookie: bytes, data: MessageData, user: User):
# THIS SHIT DOESN'T WORK. WHY??? HAS I EVER!?!?!?
if 'icbm' not in data.front_cache:
type = data.type
text = data.text
sender = data.sender
if type == MessageType.Chat:
msg = SNACMessage(0x0004, 0x0007)
msg.write_bytes(cookie)
msg.write_u16(ICBMChannel.AOLIM)
msg.write_string_u8(user.username)
msg.write_u16(0)
# we should not hardcode these
capabilities = [
"{09461345-4C7F-11D1-8222-444553540000}", # Direct ICBM
"{094601FF-4C7F-11D1-8222-444553540000}", # Smart caps
"{748F2420-6287-11D1-8222-444553540000}", # Chat
"{09461343-4C7F-11D1-8222-444553540000}", # File transfer
"{09461341-4C7F-11D1-8222-444553540000}", # Voice chat
"{09460104-4C7F-11D1-8222-444553540000}", # RTC audio
"{09460105-4C7F-11D1-8222-444553540000}", # Unknown
"{09460102-4C7F-11D1-8222-444553540000}", # Camera
"{09460103-4C7F-11D1-8222-444553540000}", # Microphone
"{09460101-4C7F-11D1-8222-444553540000}", # RTC video
"{0946134A-4C7F-11D1-8222-444553540000}", # Games
"{09461346-4C7F-11D1-8222-444553540000}" # BART
]
capabilities_bytes = b''
for capability in capabilities:
capabilities_bytes += bytes.fromhex(capability
.lstrip('{')
.rstrip('}')
.replace('-', ''))
date_created_unix = int(time.mktime(user.date_created.timetuple()))
date_login_unix = int(time.mktime(user.date_login.timetuple())) if user.date_login else 0
# usually would include 0x001D (BART info), but CrossTalk doesn't support that yet
pre = marshal_tlvs([
TLV(0x3000, struct.pack('>L', 0x6719674C)), # Unknown
TLV(0x000D, capabilities_bytes), # Capability info
TLV(0x0001, struct.pack('>H', 0x0008)), # User class (bitfield)
TLV(0x0003, struct.pack('>L', date_login_unix)), # Account signon time (unix time_t)
TLV(0x000F, struct.pack('>L', 0)), # Session length
TLV(0x0005, struct.pack('>L', date_created_unix)), # Account creation time (unix time_t)
])
im_text = data.text.encode('ascii')
im_tag = 0x0101
im_len = 2 + 2 + len(im_text)
imdata_bytes = struct.pack('>HHHH', im_tag, im_len, 0, 0) + im_text
msg.write_tlv_block([
*unmarshal_tlvs(pre),
TLV(0x0501, struct.pack('>L', 1)),
TLV(0x0101, imdata_bytes)
])
return msg
+72
View File
@@ -0,0 +1,72 @@
import struct
from util.misc import Logger
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import TLV
@Foodgroup(0x0002)
class LocateFoodgroup:
logger: Logger
@Subgroup(0x0002)
def client_versions(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] LOCATE__RIGHTS_QUERY')
response_msg = SNACMessage(0x0002, 0x0003)
# Response from NINA's servers:
# ==
# 0000 2a 02 00 07 00 28 00 02 00 03 00 00 00 00 00 02 *....(..........
# [FLAP Header....] [SNAC Header................]
# 0010 00 01 00 02 10 00 00 02 00 02 00 80 00 03 00 02 ................
# [TLV 0x0001.....] [TLV 0x0002.....] [TLV 0x0003
# 0020 00 1e 00 04 00 02 10 00 00 05 00 02 00 80 ..............
# ....] [TLV 0x0004.....] [TLV 0x0005.....]
#
# TLV 0x0001: client max profile len (data is 0x1000 = 256)
# TLV 0x0002: max capabilities (CLSIDs) (data is 0x0080 = 128)
# TLV 0x0003: unknown (data is 0x001E = 30)
# TLV 0x0004: unknown (data is 0x1000 = 256)
# TLV 0x0005: unknown (data is 0x0080 = 128)
#
response_msg.write_tlvs([
TLV(0x0001, struct.pack('>H', 4096)), # client max profile len
TLV(0x0002, struct.pack('>H', 128)), # max capabilities (CLSIDs)
TLV(0x0003, struct.pack('>H', 0x001E)), # unknown
TLV(0x0004, struct.pack('>H', 0x1000)), # unknown
TLV(0x0005, struct.pack('>H', 0x0080)) # unknown
])
self.logger.info('[Server] LOCATE__RIGHTS_REPLY')
client.send_snac(response_msg)
@Subgroup(0x0004)
def set_info(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] LOCATE__SET_INFO (not implemented)')
self.logger.info('[Client]', message.data.hex())
@Subgroup(0x000B)
def get_dir_info(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] LOCATE__GET_DIR_INFO')
screen_name = message.read_string_u8()
self.logger.info('[Client] Getting info for:', screen_name)
# Response from NINA's servers:
# ==
# 0000 2A 02 00 0F 00 1A 00 02 00 0C 00 00 00 01 00 0B *...............
# [FLAP Header....] [SNAC Header................]
# 0010 00 01 00 01 00 1C 00 08 75 73 2D 61 73 63 69 69 ........us-ascii
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#
# I have no idea what any of the values in the SNAC body mean, and the lack of documentation on NINA's wiki
# don't help either: https://wiki.nina.chat/wiki/Protocols/OSCAR/SNAC/LOCATE_GET_DIR_REPLY
response_msg = SNACMessage(0x0002, 0x000C)
response_msg.write_u16(0x0001)
response_msg.write_u16(0x0001)
response_msg.write_u16(0x001C)
response_msg.write_string_u16('us-ascii')
self.logger.info('[Server] LOCATE__GET_DIR_REPLY')
client.send_snac(response_msg)
+194
View File
@@ -0,0 +1,194 @@
import os
import calendar
import struct
import settings
from datetime import datetime, timezone
from util.misc import Logger
from ..proto.backend import FOODGROUP_VERSIONS, bos_cookies
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import TLV, unmarshal_tlvs
@Foodgroup(0x0001)
class OSERVICEFoodgroup:
logger: Logger
@Subgroup(0x0017)
def client_versions(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__CLIENT_VERSIONS')
response_msg = SNACMessage(0x0001, 0x0018, 0x0000, 0x0000)
for foodgroup, version in FOODGROUP_VERSIONS.items():
response_msg.write_u16(foodgroup)
response_msg.write_u16(version)
self.logger.info('[Server] OSERVICE__HOST_VERSIONS')
client.send_snac(response_msg)
@Subgroup(0x0006)
def rate_params_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__RATE_PARAMS_QUERY')
hex = """
00 05 00 01 00 00 00 50 00 00 09 C4 00 00 07 D0
00 00 05 DC 00 00 03 20 00 00 0D 69 00 00 17 70
00 00 00 00 00 00 02 00 00 00 50 00 00 0B B8 00
00 07 D0 00 00 05 DC 00 00 03 E8 00 00 17 70 00
00 17 70 00 00 F9 0B 00 00 03 00 00 00 14 00 00
13 EC 00 00 13 88 00 00 0F A0 00 00 0B B8 00 00
11 47 00 00 17 70 00 00 5C D8 00 00 04 00 00 00
14 00 00 15 7C 00 00 14 B4 00 00 10 68 00 00 0B
B8 00 00 17 70 00 00 1F 40 00 00 F9 0B 00 00 05
00 00 00 0A 00 00 15 7C 00 00 14 B4 00 00 10 68
00 00 0B B8 00 00 17 70 00 00 1F 40 00 00 F9 0B
00 00 01 00 91 00 01 00 01 00 01 00 02 00 01 00
03 00 01 00 04 00 01 00 05 00 01 00 06 00 01 00
07 00 01 00 08 00 01 00 09 00 01 00 0A 00 01 00
0B 00 01 00 0C 00 01 00 0D 00 01 00 0E 00 01 00
0F 00 01 00 10 00 01 00 11 00 01 00 12 00 01 00
13 00 01 00 14 00 01 00 15 00 01 00 16 00 01 00
17 00 01 00 18 00 01 00 19 00 01 00 1A 00 01 00
1B 00 01 00 1C 00 01 00 1D 00 01 00 1E 00 01 00
1F 00 01 00 20 00 01 00 21 00 02 00 01 00 02 00
02 00 02 00 03 00 02 00 04 00 02 00 06 00 02 00
07 00 02 00 08 00 02 00 0A 00 02 00 0C 00 02 00
0D 00 02 00 0E 00 02 00 0F 00 02 00 10 00 02 00
11 00 02 00 12 00 02 00 13 00 02 00 14 00 02 00
15 00 03 00 01 00 03 00 02 00 03 00 03 00 03 00
06 00 03 00 07 00 03 00 08 00 03 00 09 00 03 00
0A 00 03 00 0B 00 03 00 0C 00 04 00 01 00 04 00
02 00 04 00 03 00 04 00 04 00 04 00 05 00 04 00
07 00 04 00 08 00 04 00 09 00 04 00 0A 00 04 00
0B 00 04 00 0C 00 04 00 0D 00 04 00 0E 00 04 00
0F 00 04 00 10 00 04 00 11 00 04 00 12 00 04 00
13 00 04 00 14 00 06 00 01 00 06 00 02 00 06 00
03 00 08 00 01 00 08 00 02 00 09 00 01 00 09 00
02 00 09 00 03 00 09 00 04 00 09 00 09 00 09 00
0A 00 09 00 0B 00 0A 00 01 00 0A 00 02 00 0A 00
03 00 0B 00 01 00 0B 00 02 00 0B 00 03 00 0B 00
04 00 0C 00 01 00 0C 00 02 00 0C 00 03 00 13 00
01 00 13 00 02 00 13 00 03 00 13 00 04 00 13 00
05 00 13 00 06 00 13 00 07 00 13 00 08 00 13 00
09 00 13 00 0A 00 13 00 0B 00 13 00 0C 00 13 00
0D 00 13 00 0E 00 13 00 0F 00 13 00 10 00 13 00
11 00 13 00 12 00 13 00 13 00 13 00 14 00 13 00
15 00 13 00 16 00 13 00 17 00 13 00 18 00 13 00
19 00 13 00 1A 00 13 00 1B 00 13 00 1C 00 13 00
1D 00 13 00 1E 00 13 00 1F 00 13 00 20 00 13 00
21 00 13 00 22 00 13 00 23 00 13 00 24 00 13 00
25 00 13 00 26 00 13 00 27 00 13 00 28 00 15 00
01 00 15 00 02 00 15 00 03 00 02 00 06 00 03 00
04 00 03 00 05 00 09 00 05 00 09 00 06 00 09 00
07 00 09 00 08 00 03 00 02 00 02 00 05 00 04 00
06 00 04 00 02 00 02 00 09 00 02 00 0B 00 05 00
00
"""
cleaned_hex = (hex
.strip()
.replace(" ", "")
.replace("\r\n", "\n")
.replace("\n", ""))
response_msg = SNACMessage(0x0001, 0x0007, 0x0000, 0x0000, bytes.fromhex(cleaned_hex))
self.logger.info('[Server] OSERVICE__RATE_PARAMS_REPLY')
client.send_snac(response_msg)
@Subgroup(0x0008)
def rate_params_sub_add(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__RATE_PARAMS_SUB_ADD (not implemented)')
# since i don't have rate limits properly implemented *yet*, i won't do anything here
@Subgroup(0x000E)
def user_info_query(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__USER_INFO_QUERY')
response_msg = SNACMessage(0x0001, 0x000F)
response_msg.write_string_u8(context.bs.user.username) # Screen name
response_msg.write_u16(0) # Warning level
date_created = context.bs.user.date_created
date_login = context.bs.user.date_login
now = datetime.now(tz=timezone.utc)
date_created_unix = int(calendar.timegm(date_created.timetuple()))
date_login_unix = int(calendar.timegm(date_login.timetuple())) if date_login else 0
session_len = int(now.timestamp()) - date_login_unix if date_login else 0
self.logger.info('Date created:', date_created)
self.logger.info('Date created (UNIX time):', date_created_unix)
self.logger.info('Date logged in:', date_login or 'Never')
self.logger.info('Date logged in (UNIX time):', date_login_unix)
self.logger.info('Current time:', int(now.timestamp()))
self.logger.info('Session length:', session_len)
self.logger.info('Client IP:', client.get_ip())
response_msg.write_tlv_block([
TLV(0x0001, struct.pack('>L', 0x0100)), # User class (bitfield)
TLV(0x0003, struct.pack('>L', date_login_unix)), # Account signon time (unix time_t)
TLV(0x0005, struct.pack('>L', date_created_unix)), # Accout creation time (unix time_t)
TLV(0x000F, struct.pack('>L', session_len)) # Session length
])
self.logger.info('[Server] OSERVICE__USER_INFO_UPDATE')
client.send_snac(response_msg)
#if settings.OSCAR_MOTD_ENABLED:
# self.logger.info('[Server] OSERVICE__MOTD')
# motd = SNACMessage(0x0001, 0x0013, 0x0000, 0x0000)
# motd.write_u8(4)
# motd.write_u8(0) # filler
# motd.write_tlv(TLV(0x000B, "Welcome to CrossTalk! OSCAR support is currently in pre-alpha. You should use MSN Messenger or Yahoo Messenger for now."))
# client.send_snac(motd)
@Subgroup(0x0011)
def idle_notification(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__IDLE_NOTIFICATION')
idle_time = message.read_u32()
if idle_time == 0:
self.logger.info(context.bs.user.username, 'is no longer idle')
else:
self.logger.info(context.bs.user.username, 'has been idle for', str(idle_time), 'seconds')
# This SNAC as seen from NINA's servers:
# ==
# 0000 2a 02 00 0e 00 0e 00 0b 00 02 00 00 e7 19 67 3c *.............g<
# [FLAP Header....] [SNAC Header................]
# 0010 00 01 00 00 ....
# ^^^^^^^^^^^
msg = SNACMessage(0x000B, 0x0002)
msg.write_u32(0x010000)
self.logger.info('[Server] STATS__SET_MIN_REPORT_INTERVAL')
client.send_snac(msg)
@Subgroup(0x0004)
def service_request(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] OSERVICE__SERVICE_REQUEST')
foodgroup = message.read_u16()
self.logger.info('[Client] Foodgroup:', hex(foodgroup))
# generate BOS cookie and add it to array
bos_cookie = os.urandom(256)
bos_cookies.append({
bos_cookie: context.bs
})
response_msg = SNACMessage(0x0001, 0x0005, 0x0000, message.request_id)
response_msg.write_tlvs([
TLV(0x0001, struct.pack('>H', 3)),
TLV(0x0005, f'{settings.TARGET_IP}:5190'),
TLV(0x0006, bos_cookie),
TLV(0x000D, struct.pack('>H', foodgroup)),
TLV(0x008E, struct.pack('>B', 0))
])
client.send_snac(response_msg)
+17
View File
@@ -0,0 +1,17 @@
import struct
from typing import Optional
from util.misc import Logger
from ..proto.common import system_message
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
from ..proto.tlv import TLV, unmarshal_tlvs
def popup_display(self, alert_url: Optional[str], alert_txt: str):
response_msg = SNACMessage(0x0008, 0x0002, 0x0000, 0x00000000)
response_msg.write_bytes(system_message(alert_url, alert_txt))
# TODO(HIDEN): pipe to ctrl.send_snac()
print('TLVs:', unmarshal_tlvs(response_msg.data))
#client.send_snac(response_msg)
return response_msg
+17
View File
@@ -0,0 +1,17 @@
from util.misc import Logger
from ..proto.snac import OSCARClient, OSCARContext, SNACMessage, Foodgroup, Subgroup
@Foodgroup(0x000B)
class StatsFoodgroup:
logger: Logger
@Subgroup(0x0003)
def report_events(self, client: OSCARClient, context: OSCARContext, message: SNACMessage) -> None:
self.logger.info('[Client] STATS__REPORT_EVENTS')
# we don't really care for the info in the SNAC message's body for now, so just send
# a STATS__REPORT_ACK
self.logger.info('[Server] STATS__REPORT_ACK')
client.send_snac(SNACMessage(0x000B, 0x0004))
+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