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