diff --git a/.gitignore b/.gitignore index 969523a..61060c0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ Makefile* object_script* /Attorney_Online_remake_resource.rc /attorney_online_remake_plugin_import.cpp + +server/__pycache__ diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/aoprotocol.py b/server/aoprotocol.py new file mode 100644 index 0000000..e0c35e8 --- /dev/null +++ b/server/aoprotocol.py @@ -0,0 +1,641 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +import re +from time import localtime, strftime +from enum import Enum + +from . import commands +from . import logger +from .exceptions import ClientError, AreaError, ArgumentError, ServerError +from .fantacrypt import fanta_decrypt +from .evidence import EvidenceList +from .websocket import WebSocket + + +class AOProtocol(asyncio.Protocol): + """ + The main class that deals with the AO protocol. + """ + + class ArgType(Enum): + STR = 1, + STR_OR_EMPTY = 2, + INT = 3 + + def __init__(self, server): + super().__init__() + self.server = server + self.client = None + self.buffer = '' + self.ping_timeout = None + self.websocket = None + + def data_received(self, data): + """ Handles any data received from the network. + + Receives data, parses them into a command and passes it + to the command handler. + + :param data: bytes of data + """ + + + if self.websocket is None: + self.websocket = WebSocket(self.client, self) + if not self.websocket.handshake(data): + self.websocket = False + else: + self.client.websocket = self.websocket + + buf = data + + if not self.client.is_checked and self.server.ban_manager.is_banned(self.client.ipid): + self.client.transport.close() + else: + self.client.is_checked = True + + if self.websocket: + buf = self.websocket.handle(data) + + if buf is None: + buf = b'' + + if not isinstance(buf, str): + # try to decode as utf-8, ignore any erroneous characters + self.buffer += buf.decode('utf-8', 'ignore') + else: + self.buffer = buf + + if len(self.buffer) > 8192: + self.client.disconnect() + for msg in self.get_messages(): + if len(msg) < 2: + self.client.disconnect() + return + # general netcode structure is not great + if msg[0] in ('#', '3', '4'): + if msg[0] == '#': + msg = msg[1:] + spl = msg.split('#', 1) + msg = '#'.join([fanta_decrypt(spl[0])] + spl[1:]) + logger.log_debug('[INC][RAW]{}'.format(msg), self.client) + try: + cmd, *args = msg.split('#') + self.net_cmd_dispatcher[cmd](self, args) + except KeyError: + return + + def connection_made(self, transport): + """ Called upon a new client connecting + + :param transport: the transport object + """ + self.client = self.server.new_client(transport) + self.ping_timeout = asyncio.get_event_loop().call_later(self.server.config['timeout'], self.client.disconnect) + asyncio.get_event_loop().call_later(0.25, self.client.send_command, 'decryptor', 34) # just fantacrypt things) + + def connection_lost(self, exc): + """ User disconnected + + :param exc: reason + """ + self.server.remove_client(self.client) + self.ping_timeout.cancel() + + def get_messages(self): + """ Parses out full messages from the buffer. + + :return: yields messages + """ + while '#%' in self.buffer: + spl = self.buffer.split('#%', 1) + self.buffer = spl[1] + yield spl[0] + # exception because bad netcode + askchar2 = '#615810BC07D12A5A#' + if self.buffer == askchar2: + self.buffer = '' + yield askchar2 + + def validate_net_cmd(self, args, *types, needs_auth=True): + """ Makes sure the net command's arguments match expectations. + + :param args: actual arguments to the net command + :param types: what kind of data types are expected + :param needs_auth: whether you need to have chosen a character + :return: returns True if message was validated + """ + if needs_auth and self.client.char_id == -1: + return False + if len(args) != len(types): + return False + for i, arg in enumerate(args): + if len(arg) == 0 and types[i] != self.ArgType.STR_OR_EMPTY: + return False + if types[i] == self.ArgType.INT: + try: + args[i] = int(arg) + except ValueError: + return False + return True + + def net_cmd_hi(self, args): + """ Handshake. + + HI##% + + :param args: a list containing all the arguments + """ + if not self.validate_net_cmd(args, self.ArgType.STR, needs_auth=False): + return + self.client.hdid = args[0] + if self.client.hdid not in self.client.server.hdid_list: + self.client.server.hdid_list[self.client.hdid] = [] + if self.client.ipid not in self.client.server.hdid_list[self.client.hdid]: + self.client.server.hdid_list[self.client.hdid].append(self.client.ipid) + self.client.server.dump_hdids() + for ipid in self.client.server.hdid_list[self.client.hdid]: + if self.server.ban_manager.is_banned(ipid): + self.client.disconnect() + return + logger.log_server('Connected. HDID: {}.'.format(self.client.hdid), self.client) + self.client.send_command('ID', self.client.id, self.server.software, self.server.get_version_string()) + self.client.send_command('PN', self.server.get_player_count() - 1, self.server.config['playerlimit']) + + def net_cmd_id(self, args): + """ Client version and PV + + ID####% + + """ + + self.client.is_ao2 = False + + if len(args) < 2: + return + + version_list = args[1].split('.') + + if len(version_list) < 3: + return + + release = int(version_list[0]) + major = int(version_list[1]) + minor = int(version_list[2]) + + if args[0] != 'AO2': + return + if release < 2: + return + elif release == 2: + if major < 2: + return + elif major == 2: + if minor < 5: + return + + self.client.is_ao2 = True + + self.client.send_command('FL', 'yellowtext', 'customobjections', 'flipping', 'fastloading', 'noencryption', 'deskmod', 'evidence') + + def net_cmd_ch(self, _): + """ Periodically checks the connection. + + CHECK#% + + """ + self.client.send_command('CHECK') + self.ping_timeout.cancel() + self.ping_timeout = asyncio.get_event_loop().call_later(self.server.config['timeout'], self.client.disconnect) + + def net_cmd_askchaa(self, _): + """ Ask for the counts of characters/evidence/music + + askchaa#% + + """ + char_cnt = len(self.server.char_list) + evi_cnt = 0 + music_cnt = sum([len(x) for x in self.server.music_pages_ao1]) + self.client.send_command('SI', char_cnt, evi_cnt, music_cnt) + + def net_cmd_askchar2(self, _): + """ Asks for the character list. + + askchar2#% + + """ + self.client.send_command('CI', *self.server.char_pages_ao1[0]) + + def net_cmd_an(self, args): + """ Asks for specific pages of the character list. + + AN##% + + """ + if not self.validate_net_cmd(args, self.ArgType.INT, needs_auth=False): + return + if len(self.server.char_pages_ao1) > args[0] >= 0: + self.client.send_command('CI', *self.server.char_pages_ao1[args[0]]) + else: + self.client.send_command('EM', *self.server.music_pages_ao1[0]) + + def net_cmd_ae(self, _): + """ Asks for specific pages of the evidence list. + + AE##% + + """ + pass # todo evidence maybe later + + def net_cmd_am(self, args): + """ Asks for specific pages of the music list. + + AM##% + + """ + if not self.validate_net_cmd(args, self.ArgType.INT, needs_auth=False): + return + if len(self.server.music_pages_ao1) > args[0] >= 0: + self.client.send_command('EM', *self.server.music_pages_ao1[args[0]]) + else: + self.client.send_done() + self.client.send_area_list() + self.client.send_motd() + + def net_cmd_rc(self, _): + """ Asks for the whole character list(AO2) + + AC#% + + """ + + self.client.send_command('SC', *self.server.char_list) + + def net_cmd_rm(self, _): + """ Asks for the whole music list(AO2) + + AM#% + + """ + + self.client.send_command('SM', *self.server.music_list_ao2) + + + def net_cmd_rd(self, _): + """ Asks for server metadata(charscheck, motd etc.) and a DONE#% signal(also best packet) + + RD#% + + """ + + self.client.send_done() + self.client.send_area_list() + self.client.send_motd() + + def net_cmd_cc(self, args): + """ Character selection. + + CC####% + + """ + if not self.validate_net_cmd(args, self.ArgType.INT, self.ArgType.INT, self.ArgType.STR, needs_auth=False): + return + cid = args[1] + try: + self.client.change_character(cid) + except ClientError: + return + + def net_cmd_ms(self, args): + """ IC message. + + Refer to the implementation for details. + + """ + if self.client.is_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + if not self.client.area.can_send_message(self.client): + return + if self.validate_net_cmd(args, self.ArgType.STR, self.ArgType.STR_OR_EMPTY, self.ArgType.STR, + self.ArgType.STR, + self.ArgType.STR, self.ArgType.STR, self.ArgType.STR, self.ArgType.INT, + self.ArgType.INT, self.ArgType.INT, self.ArgType.INT, self.ArgType.INT, + self.ArgType.INT, self.ArgType.INT, self.ArgType.INT): + msg_type, pre, folder, anim, text, pos, sfx, anim_type, cid, sfx_delay, button, evidence, flip, ding, color = args + showname = self.client.get_char_name() + elif self.validate_net_cmd(args, self.ArgType.STR, self.ArgType.STR_OR_EMPTY, self.ArgType.STR, + self.ArgType.STR, + self.ArgType.STR, self.ArgType.STR, self.ArgType.STR, self.ArgType.INT, + self.ArgType.INT, self.ArgType.INT, self.ArgType.INT, self.ArgType.INT, + self.ArgType.INT, self.ArgType.INT, self.ArgType.INT, self.ArgType.STR): + msg_type, pre, folder, anim, text, pos, sfx, anim_type, cid, sfx_delay, button, evidence, flip, ding, color, showname = args + if len(showname) > 0 and not self.client.area.showname_changes_allowed == "true": + self.client.send_host_message("Showname changes are forbidden in this area!") + return + else: + return + msg_type, pre, folder, anim, text, pos, sfx, anim_type, cid, sfx_delay, button, evidence, flip, ding, color = args + if self.client.area.is_iniswap(self.client, pre, anim, folder) and folder != self.client.get_char_name(): + self.client.send_host_message("Iniswap is blocked in this area") + return + if msg_type not in ('chat', '0', '1'): + return + if anim_type not in (0, 1, 2, 5, 6): + return + if cid != self.client.char_id: + return + if sfx_delay < 0: + return + if button not in (0, 1, 2, 3, 4): + return + if evidence < 0: + return + if ding not in (0, 1): + return + if color not in (0, 1, 2, 3, 4, 5, 6, 7, 8): + return + if len(showname) > 15: + self.client.send_host_message("Your IC showname is way too long!") + return + if not self.client.area.shouts_allowed: + # Old clients communicate the objecting in anim_type. + if anim_type == 2: + anim_type = 1 + elif anim_type == 6: + anim_type = 5 + # New clients do it in a specific objection message area. + button = 0 + # Turn off the ding. + ding = 0 + if color == 2 and not self.client.is_mod: + color = 0 + if color == 6: + text = re.sub(r'[^\x00-\x7F]+',' ', text) #remove all unicode to prevent redtext abuse + if len(text.strip( ' ' )) == 1: + color = 0 + else: + if text.strip( ' ' ) in ('', '', '', ''): + color = 0 + if self.client.pos: + pos = self.client.pos + else: + if pos not in ('def', 'pro', 'hld', 'hlp', 'jud', 'wit'): + return + msg = text[:256] + if self.client.disemvowel: + msg = self.client.disemvowel_message(msg) + self.client.pos = pos + if evidence: + if self.client.area.evi_list.evidences[self.client.evi_list[evidence] - 1].pos != 'all': + self.client.area.evi_list.evidences[self.client.evi_list[evidence] - 1].pos = 'all' + self.client.area.broadcast_evidence_list() + self.client.area.send_command('MS', msg_type, pre, folder, anim, msg, pos, sfx, anim_type, cid, + sfx_delay, button, self.client.evi_list[evidence], flip, ding, color, showname) + self.client.area.set_next_msg_delay(len(msg)) + logger.log_server('[IC][{}][{}]{}'.format(self.client.area.id, self.client.get_char_name(), msg), self.client) + + if (self.client.area.is_recording): + self.client.area.recorded_messages.append(args) + + def net_cmd_ct(self, args): + """ OOC Message + + CT###% + + """ + if self.client.is_ooc_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + if not self.validate_net_cmd(args, self.ArgType.STR, self.ArgType.STR): + return + if self.client.name != args[0] and self.client.fake_name != args[0]: + if self.client.is_valid_name(args[0]): + self.client.name = args[0] + self.client.fake_name = args[0] + else: + self.client.fake_name = args[0] + if self.client.name == '': + self.client.send_host_message('You must insert a name with at least one letter') + return + if len(self.client.name) > 30: + self.client.send_host_message('Your OOC name is too long! Limit it to 30 characters.') + return + if self.client.name.startswith(self.server.config['hostname']) or self.client.name.startswith('G'): + self.client.send_host_message('That name is reserved!') + return + if args[1].startswith('/'): + spl = args[1][1:].split(' ', 1) + cmd = spl[0] + arg = '' + if len(spl) == 2: + arg = spl[1][:256] + try: + called_function = 'ooc_cmd_{}'.format(cmd) + getattr(commands, called_function)(self.client, arg) + except AttributeError: + print('Attribute error with ' + called_function) + self.client.send_host_message('Invalid command.') + except (ClientError, AreaError, ArgumentError, ServerError) as ex: + self.client.send_host_message(ex) + else: + if self.client.disemvowel: + args[1] = self.client.disemvowel_message(args[1]) + self.client.area.send_command('CT', self.client.name, args[1]) + logger.log_server( + '[OOC][{}][{}][{}]{}'.format(self.client.area.id, self.client.get_char_name(), self.client.name, + args[1]), self.client) + + def net_cmd_mc(self, args): + """ Play music. + + MC###% + + """ + try: + area = self.server.area_manager.get_area_by_name(args[0]) + self.client.change_area(area) + except AreaError: + if self.client.is_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + if not self.client.is_dj: + self.client.send_host_message('You were blockdj\'d by a moderator.') + return + if not self.validate_net_cmd(args, self.ArgType.STR, self.ArgType.INT): + return + if args[1] != self.client.char_id: + return + if self.client.change_music_cd(): + self.client.send_host_message('You changed song too many times. Please try again after {} seconds.'.format(int(self.client.change_music_cd()))) + return + try: + name, length = self.server.get_song_data(args[0]) + self.client.area.play_music(name, self.client.char_id, length) + self.client.area.add_music_playing(self.client, name) + logger.log_server('[{}][{}]Changed music to {}.' + .format(self.client.area.id, self.client.get_char_name(), name), self.client) + except ServerError: + return + except ClientError as ex: + self.client.send_host_message(ex) + + def net_cmd_rt(self, args): + """ Plays the Testimony/CE animation. + + RT##% + + """ + if self.client.is_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + if not self.client.can_wtce: + self.client.send_host_message('You were blocked from using judge signs by a moderator.') + return + if not self.validate_net_cmd(args, self.ArgType.STR): + return + if args[0] == 'testimony1': + sign = 'WT' + elif args[0] == 'testimony2': + sign = 'CE' + else: + return + if self.client.wtce_mute(): + self.client.send_host_message('You used witness testimony/cross examination signs too many times. Please try again after {} seconds.'.format(int(self.client.wtce_mute()))) + return + self.client.area.send_command('RT', args[0]) + self.client.area.add_to_judgelog(self.client, 'used {}'.format(sign)) + logger.log_server("[{}]{} Used WT/CE".format(self.client.area.id, self.client.get_char_name()), self.client) + + def net_cmd_hp(self, args): + """ Sets the penalty bar. + + HP###% + + """ + if self.client.is_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + if not self.validate_net_cmd(args, self.ArgType.INT, self.ArgType.INT): + return + try: + self.client.area.change_hp(args[0], args[1]) + self.client.area.add_to_judgelog(self.client, 'changed the penalties') + logger.log_server('[{}]{} changed HP ({}) to {}' + .format(self.client.area.id, self.client.get_char_name(), args[0], args[1]), self.client) + except AreaError: + return + + def net_cmd_pe(self, args): + """ Adds a piece of evidence. + + PE####% + + """ + if len(args) < 3: + return + #evi = Evidence(args[0], args[1], args[2], self.client.pos) + self.client.area.evi_list.add_evidence(self.client, args[0], args[1], args[2], 'all') + self.client.area.broadcast_evidence_list() + + def net_cmd_de(self, args): + """ Deletes a piece of evidence. + + DE##% + + """ + + self.client.area.evi_list.del_evidence(self.client, self.client.evi_list[int(args[0])]) + self.client.area.broadcast_evidence_list() + + def net_cmd_ee(self, args): + """ Edits a piece of evidence. + + EE#####% + + """ + + if len(args) < 4: + return + + evi = (args[1], args[2], args[3], 'all') + + self.client.area.evi_list.edit_evidence(self.client, self.client.evi_list[int(args[0])], evi) + self.client.area.broadcast_evidence_list() + + + def net_cmd_zz(self, args): + """ Sent on mod call. + + """ + if self.client.is_muted: # Checks to see if the client has been muted by a mod + self.client.send_host_message("You have been muted by a moderator") + return + + if not self.client.can_call_mod(): + self.client.send_host_message("You must wait 30 seconds between mod calls.") + return + + current_time = strftime("%H:%M", localtime()) + + if len(args) < 1: + self.server.send_all_cmd_pred('ZZ', '[{}] {} ({}) in {} ({}) without reason (not using the Case Café client?)' + .format(current_time, self.client.get_char_name(), self.client.get_ip(), self.client.area.name, + self.client.area.id), pred=lambda c: c.is_mod) + self.client.set_mod_call_delay() + logger.log_server('[{}][{}]{} called a moderator.'.format(self.client.get_ip(), self.client.area.id, self.client.get_char_name())) + else: + self.server.send_all_cmd_pred('ZZ', '[{}] {} ({}) in {} ({}) with reason: {}' + .format(current_time, self.client.get_char_name(), self.client.get_ip(), self.client.area.name, + self.client.area.id, args[0]), pred=lambda c: c.is_mod) + self.client.set_mod_call_delay() + logger.log_server('[{}][{}]{} called a moderator: {}.'.format(self.client.get_ip(), self.client.area.id, self.client.get_char_name(), args[0])) + + def net_cmd_opKICK(self, args): + self.net_cmd_ct(['opkick', '/kick {}'.format(args[0])]) + + def net_cmd_opBAN(self, args): + self.net_cmd_ct(['opban', '/ban {}'.format(args[0])]) + + net_cmd_dispatcher = { + 'HI': net_cmd_hi, # handshake + 'ID': net_cmd_id, # client version + 'CH': net_cmd_ch, # keepalive + 'askchaa': net_cmd_askchaa, # ask for list lengths + 'askchar2': net_cmd_askchar2, # ask for list of characters + 'AN': net_cmd_an, # character list + 'AE': net_cmd_ae, # evidence list + 'AM': net_cmd_am, # music list + 'RC': net_cmd_rc, # AO2 character list + 'RM': net_cmd_rm, # AO2 music list + 'RD': net_cmd_rd, # AO2 done request, charscheck etc. + 'CC': net_cmd_cc, # select character + 'MS': net_cmd_ms, # IC message + 'CT': net_cmd_ct, # OOC message + 'MC': net_cmd_mc, # play song + 'RT': net_cmd_rt, # WT/CE buttons + 'HP': net_cmd_hp, # penalties + 'PE': net_cmd_pe, # add evidence + 'DE': net_cmd_de, # delete evidence + 'EE': net_cmd_ee, # edit evidence + 'ZZ': net_cmd_zz, # call mod button + 'opKICK': net_cmd_opKICK, # /kick with guard on + 'opBAN': net_cmd_opBAN, # /ban with guard on + } diff --git a/server/area_manager.py b/server/area_manager.py new file mode 100644 index 0000000..6b6c939 --- /dev/null +++ b/server/area_manager.py @@ -0,0 +1,210 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import asyncio +import random + +import time +import yaml + +from server.exceptions import AreaError +from server.evidence import EvidenceList + + +class AreaManager: + class Area: + def __init__(self, area_id, server, name, background, bg_lock, evidence_mod = 'FFA', locking_allowed = False, iniswap_allowed = True, showname_changes_allowed = False, shouts_allowed = True): + self.iniswap_allowed = iniswap_allowed + self.clients = set() + self.invite_list = {} + self.id = area_id + self.name = name + self.background = background + self.bg_lock = bg_lock + self.server = server + self.music_looper = None + self.next_message_time = 0 + self.hp_def = 10 + self.hp_pro = 10 + self.doc = 'No document.' + self.status = 'IDLE' + self.judgelog = [] + self.current_music = '' + self.current_music_player = '' + self.evi_list = EvidenceList() + self.is_recording = False + self.recorded_messages = [] + self.evidence_mod = evidence_mod + self.locking_allowed = locking_allowed + self.owned = False + self.cards = dict() + + """ + #debug + self.evidence_list.append(Evidence("WOW", "desc", "1.png")) + self.evidence_list.append(Evidence("wewz", "desc2", "2.png")) + self.evidence_list.append(Evidence("weeeeeew", "desc3", "3.png")) + """ + + self.is_locked = False + + def new_client(self, client): + self.clients.add(client) + + def remove_client(self, client): + self.clients.remove(client) + if client.is_cm: + client.is_cm = False + self.owned = False + if self.is_locked: + self.unlock() + + def unlock(self): + self.is_locked = False + self.invite_list = {} + self.send_host_message('This area is open now.') + + def is_char_available(self, char_id): + return char_id not in [x.char_id for x in self.clients] + + def get_rand_avail_char_id(self): + avail_set = set(range(len(self.server.char_list))) - set([x.char_id for x in self.clients]) + if len(avail_set) == 0: + raise AreaError('No available characters.') + return random.choice(tuple(avail_set)) + + def send_command(self, cmd, *args): + for c in self.clients: + c.send_command(cmd, *args) + + def send_host_message(self, msg): + self.send_command('CT', self.server.config['hostname'], msg) + + def set_next_msg_delay(self, msg_length): + delay = min(3000, 100 + 60 * msg_length) + self.next_message_time = round(time.time() * 1000.0 + delay) + + def is_iniswap(self, client, anim1, anim2, char): + if self.iniswap_allowed: + return False + if '..' in anim1 or '..' in anim2: + return True + for char_link in self.server.allowed_iniswaps: + if client.get_char_name() in char_link and char in char_link: + return False + return True + + def play_music(self, name, cid, length=-1): + self.send_command('MC', name, cid) + if self.music_looper: + self.music_looper.cancel() + if length > 0: + self.music_looper = asyncio.get_event_loop().call_later(length, + lambda: self.play_music(name, -1, length)) + + + def can_send_message(self, client): + if self.is_locked and not client.is_mod and not client.ipid in self.invite_list: + client.send_host_message('This is a locked area - ask the CM to speak.') + return False + return (time.time() * 1000.0 - self.next_message_time) > 0 + + def change_hp(self, side, val): + if not 0 <= val <= 10: + raise AreaError('Invalid penalty value.') + if not 1 <= side <= 2: + raise AreaError('Invalid penalty side.') + if side == 1: + self.hp_def = val + elif side == 2: + self.hp_pro = val + self.send_command('HP', side, val) + + def change_background(self, bg): + if bg.lower() not in (name.lower() for name in self.server.backgrounds): + raise AreaError('Invalid background name.') + self.background = bg + self.send_command('BN', self.background) + + def change_status(self, value): + allowed_values = ('idle', 'building-open', 'building-full', 'casing-open', 'casing-full', 'recess') + if value.lower() not in allowed_values: + raise AreaError('Invalid status. Possible values: {}'.format(', '.join(allowed_values))) + self.status = value.upper() + + def change_doc(self, doc='No document.'): + self.doc = doc + + def add_to_judgelog(self, client, msg): + if len(self.judgelog) >= 10: + self.judgelog = self.judgelog[1:] + self.judgelog.append('{} ({}) {}.'.format(client.get_char_name(), client.get_ip(), msg)) + + def add_music_playing(self, client, name): + self.current_music_player = client.get_char_name() + self.current_music = name + + def get_evidence_list(self, client): + client.evi_list, evi_list = self.evi_list.create_evi_list(client) + return evi_list + + def broadcast_evidence_list(self): + """ + LE#&&# + + """ + for client in self.clients: + client.send_command('LE', *self.get_evidence_list(client)) + + + def __init__(self, server): + self.server = server + self.cur_id = 0 + self.areas = [] + self.load_areas() + + def load_areas(self): + with open('config/areas.yaml', 'r') as chars: + areas = yaml.load(chars) + for item in areas: + if 'evidence_mod' not in item: + item['evidence_mod'] = 'FFA' + if 'locking_allowed' not in item: + item['locking_allowed'] = False + if 'iniswap_allowed' not in item: + item['iniswap_allowed'] = True + if 'showname_changes_allowed' not in item: + item['showname_changes_allowed'] = False + if 'shouts_allowed' not in item: + item['shouts_allowed'] = True + self.areas.append( + self.Area(self.cur_id, self.server, item['area'], item['background'], item['bglock'], item['evidence_mod'], item['locking_allowed'], item['iniswap_allowed'], item['showname_changes_allowed'], item['shouts_allowed'])) + self.cur_id += 1 + + def default_area(self): + return self.areas[0] + + def get_area_by_name(self, name): + for area in self.areas: + if area.name == name: + return area + raise AreaError('Area not found.') + + def get_area_by_id(self, num): + for area in self.areas: + if area.id == num: + return area + raise AreaError('Area not found.') diff --git a/server/ban_manager.py b/server/ban_manager.py new file mode 100644 index 0000000..24518b2 --- /dev/null +++ b/server/ban_manager.py @@ -0,0 +1,54 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json + +from server.exceptions import ServerError + + +class BanManager: + def __init__(self): + self.bans = [] + self.load_banlist() + + def load_banlist(self): + try: + with open('storage/banlist.json', 'r') as banlist_file: + self.bans = json.load(banlist_file) + except FileNotFoundError: + return + + def write_banlist(self): + with open('storage/banlist.json', 'w') as banlist_file: + json.dump(self.bans, banlist_file) + + def add_ban(self, ip): + if ip not in self.bans: + self.bans.append(ip) + else: + raise ServerError('This IPID is already banned.') + self.write_banlist() + + def remove_ban(self, ip): + if ip in self.bans: + self.bans.remove(ip) + else: + raise ServerError('This IPID is not banned.') + self.write_banlist() + + def is_banned(self, ipid): + return (ipid in self.bans) diff --git a/server/client_manager.py b/server/client_manager.py new file mode 100644 index 0000000..6857269 --- /dev/null +++ b/server/client_manager.py @@ -0,0 +1,380 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from server import fantacrypt +from server import logger +from server.exceptions import ClientError, AreaError +from enum import Enum +from server.constants import TargetType +from heapq import heappop, heappush + +import time +import re + + + +class ClientManager: + class Client: + def __init__(self, server, transport, user_id, ipid): + self.is_checked = False + self.transport = transport + self.hdid = '' + self.pm_mute = False + self.id = user_id + self.char_id = -1 + self.area = server.area_manager.default_area() + self.server = server + self.name = '' + self.fake_name = '' + self.is_mod = False + self.is_dj = True + self.can_wtce = True + self.pos = '' + self.is_cm = False + self.evi_list = [] + self.disemvowel = False + self.muted_global = False + self.muted_adverts = False + self.is_muted = False + self.is_ooc_muted = False + self.pm_mute = False + self.mod_call_time = 0 + self.in_rp = False + self.ipid = ipid + self.websocket = None + + #flood-guard stuff + self.mus_counter = 0 + self.mus_mute_time = 0 + self.mus_change_time = [x * self.server.config['music_change_floodguard']['interval_length'] for x in range(self.server.config['music_change_floodguard']['times_per_interval'])] + self.wtce_counter = 0 + self.wtce_mute_time = 0 + self.wtce_time = [x * self.server.config['wtce_floodguard']['interval_length'] for x in range(self.server.config['wtce_floodguard']['times_per_interval'])] + + def send_raw_message(self, msg): + if self.websocket: + self.websocket.send_text(msg.encode('utf-8')) + else: + self.transport.write(msg.encode('utf-8')) + + def send_command(self, command, *args): + if args: + if command == 'MS': + for evi_num in range(len(self.evi_list)): + if self.evi_list[evi_num] == args[11]: + lst = list(args) + lst[11] = evi_num + args = tuple(lst) + break + self.send_raw_message('{}#{}#%'.format(command, '#'.join([str(x) for x in args]))) + else: + self.send_raw_message('{}#%'.format(command)) + + def send_host_message(self, msg): + self.send_command('CT', self.server.config['hostname'], msg) + + def send_motd(self): + self.send_host_message('=== MOTD ===\r\n{}\r\n============='.format(self.server.config['motd'])) + + def send_player_count(self): + self.send_host_message('{}/{} players online.'.format( + self.server.get_player_count(), + self.server.config['playerlimit'])) + + def is_valid_name(self, name): + name_ws = name.replace(' ', '') + if not name_ws or name_ws.isdigit(): + return False + for client in self.server.client_manager.clients: + print(client.name == name) + if client.name == name: + return False + return True + + def disconnect(self): + self.transport.close() + + def change_character(self, char_id, force=False): + if not self.server.is_valid_char_id(char_id): + raise ClientError('Invalid Character ID.') + if not self.area.is_char_available(char_id): + if force: + for client in self.area.clients: + if client.char_id == char_id: + client.char_select() + else: + raise ClientError('Character not available.') + old_char = self.get_char_name() + self.char_id = char_id + self.pos = '' + self.send_command('PV', self.id, 'CID', self.char_id) + logger.log_server('[{}]Changed character from {} to {}.' + .format(self.area.id, old_char, self.get_char_name()), self) + + def change_music_cd(self): + if self.is_mod or self.is_cm: + return 0 + if self.mus_mute_time: + if time.time() - self.mus_mute_time < self.server.config['music_change_floodguard']['mute_length']: + return self.server.config['music_change_floodguard']['mute_length'] - (time.time() - self.mus_mute_time) + else: + self.mus_mute_time = 0 + times_per_interval = self.server.config['music_change_floodguard']['times_per_interval'] + interval_length = self.server.config['music_change_floodguard']['interval_length'] + if time.time() - self.mus_change_time[(self.mus_counter - times_per_interval + 1) % times_per_interval] < interval_length: + self.mus_mute_time = time.time() + return self.server.config['music_change_floodguard']['mute_length'] + self.mus_counter = (self.mus_counter + 1) % times_per_interval + self.mus_change_time[self.mus_counter] = time.time() + return 0 + + def wtce_mute(self): + if self.is_mod or self.is_cm: + return 0 + if self.wtce_mute_time: + if time.time() - self.wtce_mute_time < self.server.config['wtce_floodguard']['mute_length']: + return self.server.config['wtce_floodguard']['mute_length'] - (time.time() - self.wtce_mute_time) + else: + self.wtce_mute_time = 0 + times_per_interval = self.server.config['wtce_floodguard']['times_per_interval'] + interval_length = self.server.config['wtce_floodguard']['interval_length'] + if time.time() - self.wtce_time[(self.wtce_counter - times_per_interval + 1) % times_per_interval] < interval_length: + self.wtce_mute_time = time.time() + return self.server.config['music_change_floodguard']['mute_length'] + self.wtce_counter = (self.wtce_counter + 1) % times_per_interval + self.wtce_time[self.wtce_counter] = time.time() + return 0 + + def reload_character(self): + try: + self.change_character(self.char_id, True) + except ClientError: + raise + + def change_area(self, area): + if self.area == area: + raise ClientError('User already in specified area.') + if area.is_locked and not self.is_mod and not self.ipid in area.invite_list: + #self.send_host_message('This area is locked - you will be unable to send messages ICly.') + raise ClientError("That area is locked!") + old_area = self.area + if not area.is_char_available(self.char_id): + try: + new_char_id = area.get_rand_avail_char_id() + except AreaError: + raise ClientError('No available characters in that area.') + + self.change_character(new_char_id) + self.send_host_message('Character taken, switched to {}.'.format(self.get_char_name())) + + self.area.remove_client(self) + self.area = area + area.new_client(self) + + self.send_host_message('Changed area to {}.[{}]'.format(area.name, self.area.status)) + logger.log_server( + '[{}]Changed area from {} ({}) to {} ({}).'.format(self.get_char_name(), old_area.name, old_area.id, + self.area.name, self.area.id), self) + self.send_command('HP', 1, self.area.hp_def) + self.send_command('HP', 2, self.area.hp_pro) + self.send_command('BN', self.area.background) + self.send_command('LE', *self.area.get_evidence_list(self)) + + def send_area_list(self): + msg = '=== Areas ===' + lock = {True: '[LOCKED]', False: ''} + for i, area in enumerate(self.server.area_manager.areas): + owner = 'FREE' + if area.owned: + for client in [x for x in area.clients if x.is_cm]: + owner = 'MASTER: {}'.format(client.get_char_name()) + break + msg += '\r\nArea {}: {} (users: {}) [{}][{}]{}'.format(i, area.name, len(area.clients), area.status, owner, lock[area.is_locked]) + if self.area == area: + msg += ' [*]' + self.send_host_message(msg) + + def get_area_info(self, area_id, mods): + info = '' + try: + area = self.server.area_manager.get_area_by_id(area_id) + except AreaError: + raise + info += '= Area {}: {} =='.format(area.id, area.name) + sorted_clients = [] + for client in area.clients: + if (not mods) or client.is_mod: + sorted_clients.append(client) + sorted_clients = sorted(sorted_clients, key=lambda x: x.get_char_name()) + for c in sorted_clients: + info += '\r\n[{}] {}'.format(c.id, c.get_char_name()) + if self.is_mod: + info += ' ({})'.format(c.ipid) + info += ': {}'.format(c.name) + + return info + + def send_area_info(self, area_id, mods): + #if area_id is -1 then return all areas. If mods is True then return only mods + info = '' + if area_id == -1: + # all areas info + cnt = 0 + info = '\n== Area List ==' + for i in range(len(self.server.area_manager.areas)): + if len(self.server.area_manager.areas[i].clients) > 0: + cnt += len(self.server.area_manager.areas[i].clients) + info += '\r\n{}'.format(self.get_area_info(i, mods)) + info = 'Current online: {}'.format(cnt) + info + else: + try: + info = 'People in this area: {}\n'.format(len(self.server.area_manager.areas[area_id].clients)) + self.get_area_info(area_id, mods) + except AreaError: + raise + self.send_host_message(info) + + def send_area_hdid(self, area_id): + try: + info = self.get_area_hdid(area_id) + except AreaError: + raise + self.send_host_message(info) + + def send_all_area_hdid(self): + info = '== HDID List ==' + for i in range (len(self.server.area_manager.areas)): + if len(self.server.area_manager.areas[i].clients) > 0: + info += '\r\n{}'.format(self.get_area_hdid(i)) + self.send_host_message(info) + + def send_all_area_ip(self): + info = '== IP List ==' + for i in range (len(self.server.area_manager.areas)): + if len(self.server.area_manager.areas[i].clients) > 0: + info += '\r\n{}'.format(self.get_area_ip(i)) + self.send_host_message(info) + + def send_done(self): + avail_char_ids = set(range(len(self.server.char_list))) - set([x.char_id for x in self.area.clients]) + char_list = [-1] * len(self.server.char_list) + for x in avail_char_ids: + char_list[x] = 0 + self.send_command('CharsCheck', *char_list) + self.send_command('HP', 1, self.area.hp_def) + self.send_command('HP', 2, self.area.hp_pro) + self.send_command('BN', self.area.background) + self.send_command('LE', *self.area.get_evidence_list(self)) + self.send_command('MM', 1) + self.send_command('DONE') + + def char_select(self): + self.char_id = -1 + self.send_done() + + def auth_mod(self, password): + if self.is_mod: + raise ClientError('Already logged in.') + if password == self.server.config['modpass']: + self.is_mod = True + else: + raise ClientError('Invalid password.') + + def get_ip(self): + return self.ipid + + + + def get_char_name(self): + if self.char_id == -1: + return 'CHAR_SELECT' + return self.server.char_list[self.char_id] + + def change_position(self, pos=''): + if pos not in ('', 'def', 'pro', 'hld', 'hlp', 'jud', 'wit'): + raise ClientError('Invalid position. Possible values: def, pro, hld, hlp, jud, wit.') + self.pos = pos + + def set_mod_call_delay(self): + self.mod_call_time = round(time.time() * 1000.0 + 30000) + + def can_call_mod(self): + return (time.time() * 1000.0 - self.mod_call_time) > 0 + + def disemvowel_message(self, message): + message = re.sub("[aeiou]", "", message, flags=re.IGNORECASE) + return re.sub(r"\s+", " ", message) + + def __init__(self, server): + self.clients = set() + self.server = server + self.cur_id = [i for i in range(self.server.config['playerlimit'])] + self.clients_list = [] + + def new_client(self, transport): + c = self.Client(self.server, transport, heappop(self.cur_id), self.server.get_ipid(transport.get_extra_info('peername')[0])) + self.clients.add(c) + return c + + + def remove_client(self, client): + heappush(self.cur_id, client.id) + self.clients.remove(client) + + def get_targets(self, client, key, value, local = False): + #possible keys: ip, OOC, id, cname, ipid, hdid + areas = None + if local: + areas = [client.area] + else: + areas = client.server.area_manager.areas + targets = [] + if key == TargetType.ALL: + for nkey in range(6): + targets += self.get_targets(client, nkey, value, local) + for area in areas: + for client in area.clients: + if key == TargetType.IP: + if value.lower().startswith(client.get_ip().lower()): + targets.append(client) + elif key == TargetType.OOC_NAME: + if value.lower().startswith(client.name.lower()) and client.name: + targets.append(client) + elif key == TargetType.CHAR_NAME: + if value.lower().startswith(client.get_char_name().lower()): + targets.append(client) + elif key == TargetType.ID: + if client.id == value: + targets.append(client) + elif key == TargetType.IPID: + if client.ipid == value: + targets.append(client) + return targets + + + def get_muted_clients(self): + clients = [] + for client in self.clients: + if client.is_muted: + clients.append(client) + return clients + + def get_ooc_muted_clients(self): + clients = [] + for client in self.clients: + if client.is_ooc_muted: + clients.append(client) + return clients diff --git a/server/commands.py b/server/commands.py new file mode 100644 index 0000000..efcfe38 --- /dev/null +++ b/server/commands.py @@ -0,0 +1,848 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +#possible keys: ip, OOC, id, cname, ipid, hdid +import random +import hashlib +import string +from server.constants import TargetType + +from server import logger +from server.exceptions import ClientError, ServerError, ArgumentError, AreaError + + +def ooc_cmd_switch(client, arg): + if len(arg) == 0: + raise ArgumentError('You must specify a character name.') + try: + cid = client.server.get_char_id_by_name(arg) + except ServerError: + raise + try: + client.change_character(cid, client.is_mod) + except ClientError: + raise + client.send_host_message('Character changed.') + +def ooc_cmd_bg(client, arg): + if len(arg) == 0: + raise ArgumentError('You must specify a name. Use /bg .') + if not client.is_mod and client.area.bg_lock == "true": + raise AreaError("This area's background is locked") + try: + client.area.change_background(arg) + except AreaError: + raise + client.area.send_host_message('{} changed the background to {}.'.format(client.get_char_name(), arg)) + logger.log_server('[{}][{}]Changed background to {}'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_bglock(client,arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + if client.area.bg_lock == "true": + client.area.bg_lock = "false" + else: + client.area.bg_lock = "true" + client.area.send_host_message('A mod has set the background lock to {}.'.format(client.area.bg_lock)) + logger.log_server('[{}][{}]Changed bglock to {}'.format(client.area.id, client.get_char_name(), client.area.bg_lock), client) + +def ooc_cmd_evidence_mod(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if not arg: + client.send_host_message('current evidence mod: {}'.format(client.area.evidence_mod)) + return + if arg in ['FFA', 'Mods', 'CM', 'HiddenCM']: + if arg == client.area.evidence_mod: + client.send_host_message('current evidence mod: {}'.format(client.area.evidence_mod)) + return + if client.area.evidence_mod == 'HiddenCM': + for i in range(len(client.area.evi_list.evidences)): + client.area.evi_list.evidences[i].pos = 'all' + client.area.evidence_mod = arg + client.send_host_message('current evidence mod: {}'.format(client.area.evidence_mod)) + return + else: + raise ArgumentError('Wrong Argument. Use /evidence_mod . Possible values: FFA, CM, Mods, HiddenCM') + return + +def ooc_cmd_allow_iniswap(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + client.area.iniswap_allowed = not client.area.iniswap_allowed + answer = {True: 'allowed', False: 'forbidden'} + client.send_host_message('iniswap is {}.'.format(answer[client.area.iniswap_allowed])) + return + + + +def ooc_cmd_roll(client, arg): + roll_max = 11037 + if len(arg) != 0: + try: + val = list(map(int, arg.split(' '))) + if not 1 <= val[0] <= roll_max: + raise ArgumentError('Roll value must be between 1 and {}.'.format(roll_max)) + except ValueError: + raise ArgumentError('Wrong argument. Use /roll [] []') + else: + val = [6] + if len(val) == 1: + val.append(1) + if len(val) > 2: + raise ArgumentError('Too many arguments. Use /roll [] []') + if val[1] > 20 or val[1] < 1: + raise ArgumentError('Num of rolls must be between 1 and 20') + roll = '' + for i in range(val[1]): + roll += str(random.randint(1, val[0])) + ', ' + roll = roll[:-2] + if val[1] > 1: + roll = '(' + roll + ')' + client.area.send_host_message('{} rolled {} out of {}.'.format(client.get_char_name(), roll, val[0])) + logger.log_server( + '[{}][{}]Used /roll and got {} out of {}.'.format(client.area.id, client.get_char_name(), roll, val[0])) + +def ooc_cmd_rollp(client, arg): + roll_max = 11037 + if len(arg) != 0: + try: + val = list(map(int, arg.split(' '))) + if not 1 <= val[0] <= roll_max: + raise ArgumentError('Roll value must be between 1 and {}.'.format(roll_max)) + except ValueError: + raise ArgumentError('Wrong argument. Use /roll [] []') + else: + val = [6] + if len(val) == 1: + val.append(1) + if len(val) > 2: + raise ArgumentError('Too many arguments. Use /roll [] []') + if val[1] > 20 or val[1] < 1: + raise ArgumentError('Num of rolls must be between 1 and 20') + roll = '' + for i in range(val[1]): + roll += str(random.randint(1, val[0])) + ', ' + roll = roll[:-2] + if val[1] > 1: + roll = '(' + roll + ')' + client.send_host_message('{} rolled {} out of {}.'.format(client.get_char_name(), roll, val[0])) + client.area.send_host_message('{} rolled.'.format(client.get_char_name(), roll, val[0])) + SALT = ''.join(random.choices(string.ascii_uppercase + string.digits, k=16)) + logger.log_server( + '[{}][{}]Used /roll and got {} out of {}.'.format(client.area.id, client.get_char_name(), hashlib.sha1((str(roll) + SALT).encode('utf-8')).hexdigest() + '|' + SALT, val[0])) + +def ooc_cmd_currentmusic(client, arg): + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + if client.area.current_music == '': + raise ClientError('There is no music currently playing.') + client.send_host_message('The current music is {} and was played by {}.'.format(client.area.current_music, + client.area.current_music_player)) + +def ooc_cmd_coinflip(client, arg): + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + coin = ['heads', 'tails'] + flip = random.choice(coin) + client.area.send_host_message('{} flipped a coin and got {}.'.format(client.get_char_name(), flip)) + logger.log_server( + '[{}][{}]Used /coinflip and got {}.'.format(client.area.id, client.get_char_name(), flip)) + +def ooc_cmd_motd(client, arg): + if len(arg) != 0: + raise ArgumentError("This command doesn't take any arguments") + client.send_motd() + +def ooc_cmd_pos(client, arg): + if len(arg) == 0: + client.change_position() + client.send_host_message('Position reset.') + else: + try: + client.change_position(arg) + except ClientError: + raise + client.area.broadcast_evidence_list() + client.send_host_message('Position changed.') + +def ooc_cmd_forcepos(client, arg): + if not client.is_cm and not client.is_mod: + raise ClientError('You must be authorized to do that.') + + args = arg.split() + + if len(args) < 1: + raise ArgumentError( + 'Not enough arguments. Use /forcepos . Target should be ID, OOC-name or char-name. Use /getarea for getting info like "[ID] char-name".') + + targets = [] + + pos = args[0] + if len(args) > 1: + targets = client.server.client_manager.get_targets( + client, TargetType.CHAR_NAME, " ".join(args[1:]), True) + if len(targets) == 0 and args[1].isdigit(): + targets = client.server.client_manager.get_targets( + client, TargetType.ID, int(arg[1]), True) + if len(targets) == 0: + targets = client.server.client_manager.get_targets( + client, TargetType.OOC_NAME, " ".join(args[1:]), True) + if len(targets) == 0: + raise ArgumentError('No targets found.') + else: + for c in client.area.clients: + targets.append(c) + + + + for t in targets: + try: + t.change_position(pos) + t.area.broadcast_evidence_list() + t.send_host_message('Forced into /pos {}.'.format(pos)) + except ClientError: + raise + + client.area.send_host_message( + '{} forced {} client(s) into /pos {}.'.format(client.get_char_name(), len(targets), pos)) + logger.log_server( + '[{}][{}]Used /forcepos {} for {} client(s).'.format(client.area.id, client.get_char_name(), pos, len(targets))) + +def ooc_cmd_help(client, arg): + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + help_url = 'https://github.com/AttorneyOnline/tsuserver3/blob/master/README.md' + help_msg = 'Available commands, source code and issues can be found here: {}'.format(help_url) + client.send_host_message(help_msg) + +def ooc_cmd_kick(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /kick .') + targets = client.server.client_manager.get_targets(client, TargetType.IPID, int(arg), False) + if targets: + for c in targets: + logger.log_server('Kicked {}.'.format(c.ipid), client) + client.send_host_message("{} was kicked.".format(c.get_char_name())) + c.disconnect() + else: + client.send_host_message("No targets found.") + +def ooc_cmd_ban(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + try: + ipid = int(arg.strip()) + except: + raise ClientError('You must specify ipid') + try: + client.server.ban_manager.add_ban(ipid) + except ServerError: + raise + if ipid != None: + targets = client.server.client_manager.get_targets(client, TargetType.IPID, ipid, False) + if targets: + for c in targets: + c.disconnect() + client.send_host_message('{} clients was kicked.'.format(len(targets))) + client.send_host_message('{} was banned.'.format(ipid)) + logger.log_server('Banned {}.'.format(ipid), client) + +def ooc_cmd_unban(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + try: + client.server.ban_manager.remove_ban(int(arg.strip())) + except: + raise ClientError('You must specify \'hdid\'') + logger.log_server('Unbanned {}.'.format(arg), client) + client.send_host_message('Unbanned {}'.format(arg)) + + +def ooc_cmd_play(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a song.') + client.area.play_music(arg, client.char_id, -1) + client.area.add_music_playing(client, arg) + logger.log_server('[{}][{}]Changed music to {}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_mute(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target.') + try: + c = client.server.client_manager.get_targets(client, TargetType.IPID, int(arg), False)[0] + c.is_muted = True + client.send_host_message('{} existing client(s).'.format(c.get_char_name())) + except: + client.send_host_message("No targets found. Use /mute for mute") + +def ooc_cmd_unmute(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target.') + try: + c = client.server.client_manager.get_targets(client, TargetType.IPID, int(arg), False)[0] + c.is_muted = False + client.send_host_message('{} existing client(s).'.format(c.get_char_name())) + except: + client.send_host_message("No targets found. Use /mute for mute") + +def ooc_cmd_login(client, arg): + if len(arg) == 0: + raise ArgumentError('You must specify the password.') + try: + client.auth_mod(arg) + except ClientError: + raise + if client.area.evidence_mod == 'HiddenCM': + client.area.broadcast_evidence_list() + client.send_host_message('Logged in as a moderator.') + logger.log_server('Logged in as moderator.', client) + +def ooc_cmd_g(client, arg): + if client.muted_global: + raise ClientError('Global chat toggled off.') + if len(arg) == 0: + raise ArgumentError("You can't send an empty message.") + client.server.broadcast_global(client, arg) + logger.log_server('[{}][{}][GLOBAL]{}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_gm(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if client.muted_global: + raise ClientError('You have the global chat muted.') + if len(arg) == 0: + raise ArgumentError("Can't send an empty message.") + client.server.broadcast_global(client, arg, True) + logger.log_server('[{}][{}][GLOBAL-MOD]{}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_lm(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError("Can't send an empty message.") + client.area.send_command('CT', '{}[MOD][{}]' + .format(client.server.config['hostname'], client.get_char_name()), arg) + logger.log_server('[{}][{}][LOCAL-MOD]{}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_announce(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError("Can't send an empty message.") + client.server.send_all_cmd_pred('CT', '{}'.format(client.server.config['hostname']), + '=== Announcement ===\r\n{}\r\n=================='.format(arg)) + logger.log_server('[{}][{}][ANNOUNCEMENT]{}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_toggleglobal(client, arg): + if len(arg) != 0: + raise ArgumentError("This command doesn't take any arguments") + client.muted_global = not client.muted_global + glob_stat = 'on' + if client.muted_global: + glob_stat = 'off' + client.send_host_message('Global chat turned {}.'.format(glob_stat)) + + +def ooc_cmd_need(client, arg): + if client.muted_adverts: + raise ClientError('You have advertisements muted.') + if len(arg) == 0: + raise ArgumentError("You must specify what you need.") + client.server.broadcast_need(client, arg) + logger.log_server('[{}][{}][NEED]{}.'.format(client.area.id, client.get_char_name(), arg), client) + +def ooc_cmd_toggleadverts(client, arg): + if len(arg) != 0: + raise ArgumentError("This command doesn't take any arguments") + client.muted_adverts = not client.muted_adverts + adv_stat = 'on' + if client.muted_adverts: + adv_stat = 'off' + client.send_host_message('Advertisements turned {}.'.format(adv_stat)) + +def ooc_cmd_doc(client, arg): + if len(arg) == 0: + client.send_host_message('Document: {}'.format(client.area.doc)) + logger.log_server( + '[{}][{}]Requested document. Link: {}'.format(client.area.id, client.get_char_name(), client.area.doc)) + else: + client.area.change_doc(arg) + client.area.send_host_message('{} changed the doc link.'.format(client.get_char_name())) + logger.log_server('[{}][{}]Changed document to: {}'.format(client.area.id, client.get_char_name(), arg)) + + +def ooc_cmd_cleardoc(client, arg): + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + client.area.send_host_message('{} cleared the doc link.'.format(client.get_char_name())) + logger.log_server('[{}][{}]Cleared document. Old link: {}' + .format(client.area.id, client.get_char_name(), client.area.doc)) + client.area.change_doc() + + +def ooc_cmd_status(client, arg): + if len(arg) == 0: + client.send_host_message('Current status: {}'.format(client.area.status)) + else: + try: + client.area.change_status(arg) + client.area.send_host_message('{} changed status to {}.'.format(client.get_char_name(), client.area.status)) + logger.log_server( + '[{}][{}]Changed status to {}'.format(client.area.id, client.get_char_name(), client.area.status)) + except AreaError: + raise + + +def ooc_cmd_online(client, _): + client.send_player_count() + + +def ooc_cmd_area(client, arg): + args = arg.split() + if len(args) == 0: + client.send_area_list() + elif len(args) == 1: + try: + area = client.server.area_manager.get_area_by_id(int(args[0])) + client.change_area(area) + except ValueError: + raise ArgumentError('Area ID must be a number.') + except (AreaError, ClientError): + raise + else: + raise ArgumentError('Too many arguments. Use /area .') + +def ooc_cmd_pm(client, arg): + args = arg.split() + key = '' + msg = None + if len(args) < 2: + raise ArgumentError('Not enough arguments. use /pm . Target should be ID, OOC-name or char-name. Use /getarea for getting info like "[ID] char-name".') + targets = client.server.client_manager.get_targets(client, TargetType.CHAR_NAME, arg, True) + key = TargetType.CHAR_NAME + if len(targets) == 0 and args[0].isdigit(): + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(args[0]), False) + key = TargetType.ID + if len(targets) == 0: + targets = client.server.client_manager.get_targets(client, TargetType.OOC_NAME, arg, True) + key = TargetType.OOC_NAME + if len(targets) == 0: + raise ArgumentError('No targets found.') + try: + if key == TargetType.ID: + msg = ' '.join(args[1:]) + else: + if key == TargetType.CHAR_NAME: + msg = arg[len(targets[0].get_char_name()) + 1:] + if key == TargetType.OOC_NAME: + msg = arg[len(targets[0].name) + 1:] + except: + raise ArgumentError('Not enough arguments. Use /pm .') + c = targets[0] + if c.pm_mute: + raise ClientError('This user muted all pm conversation') + else: + c.send_host_message('PM from {} in {} ({}): {}'.format(client.name, client.area.name, client.get_char_name(), msg)) + client.send_host_message('PM sent to {}. Message: {}'.format(args[0], msg)) + +def ooc_cmd_mutepm(client, arg): + if len(arg) != 0: + raise ArgumentError("This command doesn't take any arguments") + client.pm_mute = not client.pm_mute + client.send_host_message({True:'You stopped receiving PMs', False:'You are now receiving PMs'}[client.pm_mute]) + +def ooc_cmd_charselect(client, arg): + if not arg: + client.char_select() + else: + if client.is_mod: + try: + client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False)[0].char_select() + except: + raise ArgumentError('Wrong arguments. Use /charselect ') + +def ooc_cmd_reload(client, arg): + if len(arg) != 0: + raise ArgumentError("This command doesn't take any arguments") + try: + client.reload_character() + except ClientError: + raise + client.send_host_message('Character reloaded.') + +def ooc_cmd_randomchar(client, arg): + if len(arg) != 0: + raise ArgumentError('This command has no arguments.') + try: + free_id = client.area.get_rand_avail_char_id() + except AreaError: + raise + try: + client.change_character(free_id) + except ClientError: + raise + client.send_host_message('Randomly switched to {}'.format(client.get_char_name())) + +def ooc_cmd_getarea(client, arg): + client.send_area_info(client.area.id, False) + +def ooc_cmd_getareas(client, arg): + client.send_area_info(-1, False) + +def ooc_cmd_mods(client, arg): + client.send_area_info(-1, True) + +def ooc_cmd_evi_swap(client, arg): + args = list(arg.split(' ')) + if len(args) != 2: + raise ClientError("you must specify 2 numbers") + try: + client.area.evi_list.evidence_swap(client, int(args[0]), int(args[1])) + client.area.broadcast_evidence_list() + except: + raise ClientError("you must specify 2 numbers") + +def ooc_cmd_cm(client, arg): + if 'CM' not in client.area.evidence_mod: + raise ClientError('You can\'t become a CM in this area') + if client.area.owned == False: + client.area.owned = True + client.is_cm = True + if client.area.evidence_mod == 'HiddenCM': + client.area.broadcast_evidence_list() + client.area.send_host_message('{} is CM in this area now.'.format(client.get_char_name())) + +def ooc_cmd_unmod(client, arg): + client.is_mod = False + if client.area.evidence_mod == 'HiddenCM': + client.area.broadcast_evidence_list() + client.send_host_message('you\'re not a mod now') + +def ooc_cmd_area_lock(client, arg): + if not client.area.locking_allowed: + client.send_host_message('Area locking is disabled in this area.') + return + if client.area.is_locked: + client.send_host_message('Area is already locked.') + if client.is_cm: + client.area.is_locked = True + client.area.send_host_message('Area is locked.') + for i in client.area.clients: + client.area.invite_list[i.ipid] = None + return + else: + raise ClientError('Only CM can lock the area.') + +def ooc_cmd_area_unlock(client, arg): + if not client.area.is_locked: + raise ClientError('Area is already unlocked.') + if not client.is_cm: + raise ClientError('Only CM can unlock area.') + client.area.unlock() + client.send_host_message('Area is unlocked.') + +def ooc_cmd_invite(client, arg): + if not arg: + raise ClientError('You must specify a target. Use /invite ') + if not client.area.is_locked: + raise ClientError('Area isn\'t locked.') + if not client.is_cm and not client.is_mod: + raise ClientError('You must be authorized to do that.') + try: + c = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False)[0] + client.area.invite_list[c.ipid] = None + client.send_host_message('{} is invited to your area.'.format(c.get_char_name())) + c.send_host_message('You were invited and given access to area {}.'.format(client.area.id)) + except: + raise ClientError('You must specify a target. Use /invite ') + +def ooc_cmd_uninvite(client, arg): + if not client.is_cm and not client.is_mod: + raise ClientError('You must be authorized to do that.') + if not client.area.is_locked and not client.is_mod: + raise ClientError('Area isn\'t locked.') + if not arg: + raise ClientError('You must specify a target. Use /uninvite ') + arg = arg.split(' ') + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg[0]), True) + if targets: + try: + for c in targets: + client.send_host_message("You have removed {} from the whitelist.".format(c.get_char_name())) + c.send_host_message("You were removed from the area whitelist.") + if client.area.is_locked: + client.area.invite_list.pop(c.ipid) + except AreaError: + raise + except ClientError: + raise + else: + client.send_host_message("No targets found.") + +def ooc_cmd_area_kick(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if not client.area.is_locked and not client.is_mod: + raise ClientError('Area isn\'t locked.') + if not arg: + raise ClientError('You must specify a target. Use /area_kick [destination #]') + arg = arg.split(' ') + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg[0]), False) + if targets: + try: + for c in targets: + if len(arg) == 1: + area = client.server.area_manager.get_area_by_id(int(0)) + output = 0 + else: + try: + area = client.server.area_manager.get_area_by_id(int(arg[1])) + output = arg[1] + except AreaError: + raise + client.send_host_message("Attempting to kick {} to area {}.".format(c.get_char_name(), output)) + c.change_area(area) + c.send_host_message("You were kicked from the area to area {}.".format(output)) + if client.area.is_locked: + client.area.invite_list.pop(c.ipid) + except AreaError: + raise + except ClientError: + raise + else: + client.send_host_message("No targets found.") + + +def ooc_cmd_ooc_mute(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /ooc_mute .') + targets = client.server.client_manager.get_targets(client, TargetType.OOC_NAME, arg, False) + if not targets: + raise ArgumentError('Targets not found. Use /ooc_mute .') + for target in targets: + target.is_ooc_muted = True + client.send_host_message('Muted {} existing client(s).'.format(len(targets))) + +def ooc_cmd_ooc_unmute(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /ooc_mute .') + targets = client.server.client_manager.get_targets(client, TargetType.ID, arg, False) + if not targets: + raise ArgumentError('Target not found. Use /ooc_mute .') + for target in targets: + target.is_ooc_muted = False + client.send_host_message('Unmuted {} existing client(s).'.format(len(targets))) + +def ooc_cmd_disemvowel(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + elif len(arg) == 0: + raise ArgumentError('You must specify a target.') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must specify a target. Use /disemvowel .') + if targets: + for c in targets: + logger.log_server('Disemvowelling {}.'.format(c.get_ip()), client) + c.disemvowel = True + client.send_host_message('Disemvowelled {} existing client(s).'.format(len(targets))) + else: + client.send_host_message('No targets found.') + +def ooc_cmd_undisemvowel(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + elif len(arg) == 0: + raise ArgumentError('You must specify a target.') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must specify a target. Use /disemvowel .') + if targets: + for c in targets: + logger.log_server('Undisemvowelling {}.'.format(c.get_ip()), client) + c.disemvowel = False + client.send_host_message('Undisemvowelled {} existing client(s).'.format(len(targets))) + else: + client.send_host_message('No targets found.') + +def ooc_cmd_blockdj(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /blockdj .') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must enter a number. Use /blockdj .') + if not targets: + raise ArgumentError('Target not found. Use /blockdj .') + for target in targets: + target.is_dj = False + target.send_host_message('A moderator muted you from changing the music.') + client.send_host_message('blockdj\'d {}.'.format(targets[0].get_char_name())) + +def ooc_cmd_unblockdj(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /unblockdj .') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must enter a number. Use /unblockdj .') + if not targets: + raise ArgumentError('Target not found. Use /blockdj .') + for target in targets: + target.is_dj = True + target.send_host_message('A moderator unmuted you from changing the music.') + client.send_host_message('Unblockdj\'d {}.'.format(targets[0].get_char_name())) + +def ooc_cmd_blockwtce(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /blockwtce .') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must enter a number. Use /blockwtce .') + if not targets: + raise ArgumentError('Target not found. Use /blockwtce .') + for target in targets: + target.can_wtce = False + target.send_host_message('A moderator blocked you from using judge signs.') + client.send_host_message('blockwtce\'d {}.'.format(targets[0].get_char_name())) + +def ooc_cmd_unblockwtce(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) == 0: + raise ArgumentError('You must specify a target. Use /unblockwtce .') + try: + targets = client.server.client_manager.get_targets(client, TargetType.ID, int(arg), False) + except: + raise ArgumentError('You must enter a number. Use /unblockwtce .') + if not targets: + raise ArgumentError('Target not found. Use /unblockwtce .') + for target in targets: + target.can_wtce = True + target.send_host_message('A moderator unblocked you from using judge signs.') + client.send_host_message('unblockwtce\'d {}.'.format(targets[0].get_char_name())) + +def ooc_cmd_notecard(client, arg): + if len(arg) == 0: + raise ArgumentError('You must specify the contents of the note card.') + client.area.cards[client.get_char_name()] = arg + client.area.send_host_message('{} wrote a note card.'.format(client.get_char_name())) + +def ooc_cmd_notecard_clear(client, arg): + try: + del client.area.cards[client.get_char_name()] + client.area.send_host_message('{} erased their note card.'.format(client.get_char_name())) + except KeyError: + raise ClientError('You do not have a note card.') + +def ooc_cmd_notecard_reveal(client, arg): + if not client.is_cm and not client.is_mod: + raise ClientError('You must be a CM or moderator to reveal cards.') + if len(client.area.cards) == 0: + raise ClientError('There are no cards to reveal in this area.') + msg = 'Note cards have been revealed.\n' + for card_owner, card_msg in client.area.cards.items(): + msg += '{}: {}\n'.format(card_owner, card_msg) + client.area.cards.clear() + client.area.send_host_message(msg) + +def ooc_cmd_rolla_reload(client, arg): + if not client.is_mod: + raise ClientError('You must be a moderator to load the ability dice configuration.') + rolla_reload(client.area) + client.send_host_message('Reloaded ability dice configuration.') + +def rolla_reload(area): + try: + import yaml + with open('config/dice.yaml', 'r') as dice: + area.ability_dice = yaml.load(dice) + except: + raise ServerError('There was an error parsing the ability dice configuration. Check your syntax.') + +def ooc_cmd_rolla_set(client, arg): + if not hasattr(client.area, 'ability_dice'): + rolla_reload(client.area) + available_sets = client.area.ability_dice.keys() + if len(arg) == 0: + raise ArgumentError('You must specify the ability set name.\nAvailable sets: {}'.format(available_sets)) + if arg in client.area.ability_dice: + client.ability_dice_set = arg + client.send_host_message("Set ability set to {}.".format(arg)) + else: + raise ArgumentError('Invalid ability set \'{}\'.\nAvailable sets: {}'.format(arg, available_sets)) + +def ooc_cmd_rolla(client, arg): + if not hasattr(client.area, 'ability_dice'): + rolla_reload(client.area) + if not hasattr(client, 'ability_dice_set'): + raise ClientError('You must set your ability set using /rolla_set .') + ability_dice = client.area.ability_dice[client.ability_dice_set] + max_roll = ability_dice['max'] if 'max' in ability_dice else 6 + roll = random.randint(1, max_roll) + ability = ability_dice[roll] if roll in ability_dice else "Nothing happens" + client.area.send_host_message( + '{} rolled a {} (out of {}): {}.'.format(client.get_char_name(), roll, max_roll, ability)) + +def ooc_cmd_refresh(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len (arg) > 0: + raise ClientError('This command does not take in any arguments!') + else: + try: + client.server.refresh() + client.send_host_message('You have reloaded the server.') + except ServerError: + raise + +def ooc_cmd_judgelog(client, arg): + if not client.is_mod: + raise ClientError('You must be authorized to do that.') + if len(arg) != 0: + raise ArgumentError('This command does not take any arguments.') + jlog = client.area.judgelog + if len(jlog) > 0: + jlog_msg = '== Judge Log ==' + for x in jlog: + jlog_msg += '\r\n{}'.format(x) + client.send_host_message(jlog_msg) + else: + raise ServerError('There have been no judge actions in this area since start of session.') diff --git a/server/constants.py b/server/constants.py new file mode 100644 index 0000000..fa07e8e --- /dev/null +++ b/server/constants.py @@ -0,0 +1,11 @@ +from enum import Enum + +class TargetType(Enum): + #possible keys: ip, OOC, id, cname, ipid, hdid + IP = 0 + OOC_NAME = 1 + ID = 2 + CHAR_NAME = 3 + IPID = 4 + HDID = 5 + ALL = 6 \ No newline at end of file diff --git a/server/districtclient.py b/server/districtclient.py new file mode 100644 index 0000000..adc29ec --- /dev/null +++ b/server/districtclient.py @@ -0,0 +1,79 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import asyncio + +from server import logger + + +class DistrictClient: + def __init__(self, server): + self.server = server + self.reader = None + self.writer = None + self.message_queue = [] + + async def connect(self): + loop = asyncio.get_event_loop() + while True: + try: + self.reader, self.writer = await asyncio.open_connection(self.server.config['district_ip'], + self.server.config['district_port'], loop=loop) + await self.handle_connection() + except (ConnectionRefusedError, TimeoutError): + pass + except (ConnectionResetError, asyncio.IncompleteReadError): + self.writer = None + self.reader = None + finally: + logger.log_debug("Couldn't connect to the district, retrying in 30 seconds.") + await asyncio.sleep(30) + + async def handle_connection(self): + logger.log_debug('District connected.') + self.send_raw_message('AUTH#{}'.format(self.server.config['district_password'])) + while True: + data = await self.reader.readuntil(b'\r\n') + if not data: + return + raw_msg = data.decode()[:-2] + logger.log_debug('[DISTRICT][INC][RAW]{}'.format(raw_msg)) + cmd, *args = raw_msg.split('#') + if cmd == 'GLOBAL': + glob_name = '{}[{}:{}][{}]'.format('G', args[1], args[2], args[3]) + if args[0] == '1': + glob_name += '[M]' + self.server.send_all_cmd_pred('CT', glob_name, args[4], pred=lambda x: not x.muted_global) + elif cmd == 'NEED': + need_msg = '=== Cross Advert ===\r\n{} at {} in {} [{}] needs {}\r\n====================' \ + .format(args[1], args[0], args[2], args[3], args[4]) + self.server.send_all_cmd_pred('CT', '{}'.format(self.server.config['hostname']), need_msg, + pred=lambda x: not x.muted_adverts) + + async def write_queue(self): + while self.message_queue: + msg = self.message_queue.pop(0) + try: + self.writer.write(msg) + await self.writer.drain() + except ConnectionResetError: + return + + def send_raw_message(self, msg): + if not self.writer: + return + self.message_queue.append('{}\r\n'.format(msg).encode()) + asyncio.ensure_future(self.write_queue(), loop=asyncio.get_event_loop()) diff --git a/server/evidence.py b/server/evidence.py new file mode 100644 index 0000000..ddd9ba3 --- /dev/null +++ b/server/evidence.py @@ -0,0 +1,91 @@ +class EvidenceList: + limit = 35 + + class Evidence: + def __init__(self, name, desc, image, pos): + self.name = name + self.desc = desc + self.image = image + self.public = False + self.pos = pos + + def set_name(self, name): + self.name = name + + def set_desc(self, desc): + self.desc = desc + + def set_image(self, image): + self.image = image + + def to_string(self): + sequence = (self.name, self.desc, self.image) + return '&'.join(sequence) + + def __init__(self): + self.evidences = [] + self.poses = {'def':['def', 'hld'], 'pro':['pro', 'hlp'], 'wit':['wit'], 'hlp':['hlp', 'pro'], 'hld':['hld', 'def'], 'jud':['jud'], 'all':['hlp', 'hld', 'wit', 'jud', 'pro', 'def', ''], 'pos':[]} + + def login(self, client): + if client.area.evidence_mod == 'FFA': + pass + if client.area.evidence_mod == 'Mods': + if not client.is_cm: + return False + if client.area.evidence_mod == 'CM': + if not client.is_cm and not client.is_mod: + return False + if client.area.evidence_mod == 'HiddenCM': + if not client.is_cm and not client.is_mod: + return False + return True + + def correct_format(self, client, desc): + if client.area.evidence_mod != 'HiddenCM': + return True + else: + #correct format: \ndesc + if desc[:9] == '\n': + return True + return False + + + def add_evidence(self, client, name, description, image, pos = 'all'): + if self.login(client): + if client.area.evidence_mod == 'HiddenCM': + pos = 'pos' + if len(self.evidences) >= self.limit: + client.send_host_message('You can\'t have more than {} evidence items at a time.'.format(self.limit)) + else: + self.evidences.append(self.Evidence(name, description, image, pos)) + + def evidence_swap(self, client, id1, id2): + if self.login(client): + self.evidences[id1], self.evidences[id2] = self.evidences[id2], self.evidences[id1] + + def create_evi_list(self, client): + evi_list = [] + nums_list = [0] + for i in range(len(self.evidences)): + if client.area.evidence_mod == 'HiddenCM' and self.login(client): + nums_list.append(i + 1) + evi = self.evidences[i] + evi_list.append(self.Evidence(evi.name, '\n{}'.format(evi.pos, evi.desc), evi.image, evi.pos).to_string()) + elif client.pos in self.poses[self.evidences[i].pos]: + nums_list.append(i + 1) + evi_list.append(self.evidences[i].to_string()) + return nums_list, evi_list + + def del_evidence(self, client, id): + if self.login(client): + self.evidences.pop(id) + + def edit_evidence(self, client, id, arg): + if self.login(client): + if client.area.evidence_mod == 'HiddenCM' and self.correct_format(client, arg[1]): + self.evidences[id] = self.Evidence(arg[0], arg[1][14:], arg[2], arg[1][9:12]) + return + if client.area.evidence_mod == 'HiddenCM': + client.send_host_message('You entered a wrong pos.') + return + self.evidences[id] = self.Evidence(arg[0], arg[1], arg[2], arg[3]) \ No newline at end of file diff --git a/server/exceptions.py b/server/exceptions.py new file mode 100644 index 0000000..d3503e9 --- /dev/null +++ b/server/exceptions.py @@ -0,0 +1,32 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class ClientError(Exception): + pass + + +class AreaError(Exception): + pass + + +class ArgumentError(Exception): + pass + + +class ServerError(Exception): + pass diff --git a/server/fantacrypt.py b/server/fantacrypt.py new file mode 100644 index 0000000..e31548e --- /dev/null +++ b/server/fantacrypt.py @@ -0,0 +1,45 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# fantacrypt was a mistake, just hardcoding some numbers is good enough + +import binascii + +CRYPT_CONST_1 = 53761 +CRYPT_CONST_2 = 32618 +CRYPT_KEY = 5 + + +def fanta_decrypt(data): + data_bytes = [int(data[x:x + 2], 16) for x in range(0, len(data), 2)] + key = CRYPT_KEY + ret = '' + for byte in data_bytes: + val = byte ^ ((key & 0xffff) >> 8) + ret += chr(val) + key = ((byte + key) * CRYPT_CONST_1) + CRYPT_CONST_2 + return ret + + +def fanta_encrypt(data): + key = CRYPT_KEY + ret = '' + for char in data: + val = ord(char) ^ ((key & 0xffff) >> 8) + ret += binascii.hexlify(val.to_bytes(1, byteorder='big')).decode().upper() + key = ((val + key) * CRYPT_CONST_1) + CRYPT_CONST_2 + return ret diff --git a/server/logger.py b/server/logger.py new file mode 100644 index 0000000..675a359 --- /dev/null +++ b/server/logger.py @@ -0,0 +1,64 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging + +import time + + +def setup_logger(debug): + logging.Formatter.converter = time.gmtime + debug_formatter = logging.Formatter('[%(asctime)s UTC]%(message)s') + srv_formatter = logging.Formatter('[%(asctime)s UTC]%(message)s') + + debug_log = logging.getLogger('debug') + debug_log.setLevel(logging.DEBUG) + + debug_handler = logging.FileHandler('logs/debug.log', encoding='utf-8') + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(debug_formatter) + debug_log.addHandler(debug_handler) + + if not debug: + debug_log.disabled = True + + server_log = logging.getLogger('server') + server_log.setLevel(logging.INFO) + + server_handler = logging.FileHandler('logs/server.log', encoding='utf-8') + server_handler.setLevel(logging.INFO) + server_handler.setFormatter(srv_formatter) + server_log.addHandler(server_handler) + + +def log_debug(msg, client=None): + msg = parse_client_info(client) + msg + logging.getLogger('debug').debug(msg) + + +def log_server(msg, client=None): + msg = parse_client_info(client) + msg + logging.getLogger('server').info(msg) + + +def parse_client_info(client): + if client is None: + return '' + info = client.get_ip() + if client.is_mod: + return '[{:<15}][{}][MOD]'.format(info, client.id) + return '[{:<15}][{}]'.format(info, client.id) diff --git a/server/masterserverclient.py b/server/masterserverclient.py new file mode 100644 index 0000000..49af043 --- /dev/null +++ b/server/masterserverclient.py @@ -0,0 +1,89 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import asyncio +import time +from server import logger + + +class MasterServerClient: + def __init__(self, server): + self.server = server + self.reader = None + self.writer = None + + async def connect(self): + loop = asyncio.get_event_loop() + while True: + try: + self.reader, self.writer = await asyncio.open_connection(self.server.config['masterserver_ip'], + self.server.config['masterserver_port'], + loop=loop) + await self.handle_connection() + except (ConnectionRefusedError, TimeoutError): + pass + except (ConnectionResetError, asyncio.IncompleteReadError): + self.writer = None + self.reader = None + finally: + logger.log_debug("Couldn't connect to the master server, retrying in 30 seconds.") + print("Couldn't connect to the master server, retrying in 30 seconds.") + await asyncio.sleep(30) + + async def handle_connection(self): + logger.log_debug('Master server connected.') + await self.send_server_info() + fl = False + lastping = time.time() - 20 + while True: + self.reader.feed_data(b'END') + full_data = await self.reader.readuntil(b'END') + full_data = full_data[:-3] + if len(full_data) > 0: + data_list = list(full_data.split(b'#%'))[:-1] + for data in data_list: + raw_msg = data.decode() + cmd, *args = raw_msg.split('#') + if cmd != 'CHECK' and cmd != 'PONG': + logger.log_debug('[MASTERSERVER][INC][RAW]{}'.format(raw_msg)) + elif cmd == 'CHECK': + await self.send_raw_message('PING#%') + elif cmd == 'PONG': + fl = False + elif cmd == 'NOSERV': + await self.send_server_info() + if time.time() - lastping > 5: + if fl: + return + lastping = time.time() + fl = True + await self.send_raw_message('PING#%') + await asyncio.sleep(1) + + async def send_server_info(self): + cfg = self.server.config + msg = 'SCC#{}#{}#{}#{}#%'.format(cfg['port'], cfg['masterserver_name'], cfg['masterserver_description'], + self.server.software) + await self.send_raw_message(msg) + + async def send_raw_message(self, msg): + try: + self.writer.write(msg.encode()) + await self.writer.drain() + except ConnectionResetError: + return diff --git a/server/tsuserver.py b/server/tsuserver.py new file mode 100644 index 0000000..14ad60b --- /dev/null +++ b/server/tsuserver.py @@ -0,0 +1,263 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2016 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +import yaml +import json + +from server import logger +from server.aoprotocol import AOProtocol +from server.area_manager import AreaManager +from server.ban_manager import BanManager +from server.client_manager import ClientManager +from server.districtclient import DistrictClient +from server.exceptions import ServerError +from server.masterserverclient import MasterServerClient + +class TsuServer3: + def __init__(self): + self.config = None + self.allowed_iniswaps = None + self.load_config() + self.load_iniswaps() + self.client_manager = ClientManager(self) + self.area_manager = AreaManager(self) + self.ban_manager = BanManager() + self.software = 'tsuserver3' + self.version = 'tsuserver3dev' + self.release = 3 + self.major_version = 1 + self.minor_version = 1 + self.ipid_list = {} + self.hdid_list = {} + self.char_list = None + self.char_pages_ao1 = None + self.music_list = None + self.music_list_ao2 = None + self.music_pages_ao1 = None + self.backgrounds = None + self.load_characters() + self.load_music() + self.load_backgrounds() + self.load_ids() + self.district_client = None + self.ms_client = None + self.rp_mode = False + logger.setup_logger(debug=self.config['debug']) + + def start(self): + loop = asyncio.get_event_loop() + + bound_ip = '0.0.0.0' + if self.config['local']: + bound_ip = '127.0.0.1' + + ao_server_crt = loop.create_server(lambda: AOProtocol(self), bound_ip, self.config['port']) + ao_server = loop.run_until_complete(ao_server_crt) + + if self.config['use_district']: + self.district_client = DistrictClient(self) + asyncio.ensure_future(self.district_client.connect(), loop=loop) + + if self.config['use_masterserver']: + self.ms_client = MasterServerClient(self) + asyncio.ensure_future(self.ms_client.connect(), loop=loop) + + logger.log_debug('Server started.') + + try: + loop.run_forever() + except KeyboardInterrupt: + pass + + logger.log_debug('Server shutting down.') + + ao_server.close() + loop.run_until_complete(ao_server.wait_closed()) + loop.close() + + def get_version_string(self): + return str(self.release) + '.' + str(self.major_version) + '.' + str(self.minor_version) + + def new_client(self, transport): + c = self.client_manager.new_client(transport) + if self.rp_mode: + c.in_rp = True + c.server = self + c.area = self.area_manager.default_area() + c.area.new_client(c) + return c + + def remove_client(self, client): + client.area.remove_client(client) + self.client_manager.remove_client(client) + + def get_player_count(self): + return len(self.client_manager.clients) + + def load_config(self): + with open('config/config.yaml', 'r', encoding = 'utf-8') as cfg: + self.config = yaml.load(cfg) + self.config['motd'] = self.config['motd'].replace('\\n', ' \n') + if 'music_change_floodguard' not in self.config: + self.config['music_change_floodguard'] = {'times_per_interval': 1, 'interval_length': 0, 'mute_length': 0} + if 'wtce_floodguard' not in self.config: + self.config['wtce_floodguard'] = {'times_per_interval': 1, 'interval_length': 0, 'mute_length': 0} + + def load_characters(self): + with open('config/characters.yaml', 'r', encoding = 'utf-8') as chars: + self.char_list = yaml.load(chars) + self.build_char_pages_ao1() + + def load_music(self): + with open('config/music.yaml', 'r', encoding = 'utf-8') as music: + self.music_list = yaml.load(music) + self.build_music_pages_ao1() + self.build_music_list_ao2() + + def load_ids(self): + self.ipid_list = {} + self.hdid_list = {} + #load ipids + try: + with open('storage/ip_ids.json', 'r', encoding = 'utf-8') as whole_list: + self.ipid_list = json.loads(whole_list.read()) + except: + logger.log_debug('Failed to load ip_ids.json from ./storage. If ip_ids.json is exist then remove it.') + #load hdids + try: + with open('storage/hd_ids.json', 'r', encoding = 'utf-8') as whole_list: + self.hdid_list = json.loads(whole_list.read()) + except: + logger.log_debug('Failed to load hd_ids.json from ./storage. If hd_ids.json is exist then remove it.') + + def dump_ipids(self): + with open('storage/ip_ids.json', 'w') as whole_list: + json.dump(self.ipid_list, whole_list) + + def dump_hdids(self): + with open('storage/hd_ids.json', 'w') as whole_list: + json.dump(self.hdid_list, whole_list) + + def get_ipid(self, ip): + if not (ip in self.ipid_list): + self.ipid_list[ip] = len(self.ipid_list) + self.dump_ipids() + return self.ipid_list[ip] + + def load_backgrounds(self): + with open('config/backgrounds.yaml', 'r', encoding = 'utf-8') as bgs: + self.backgrounds = yaml.load(bgs) + + def load_iniswaps(self): + try: + with open('config/iniswaps.yaml', 'r', encoding = 'utf-8') as iniswaps: + self.allowed_iniswaps = yaml.load(iniswaps) + except: + logger.log_debug('cannot find iniswaps.yaml') + + + def build_char_pages_ao1(self): + self.char_pages_ao1 = [self.char_list[x:x + 10] for x in range(0, len(self.char_list), 10)] + for i in range(len(self.char_list)): + self.char_pages_ao1[i // 10][i % 10] = '{}#{}&&0&&&0&'.format(i, self.char_list[i]) + + def build_music_pages_ao1(self): + self.music_pages_ao1 = [] + index = 0 + # add areas first + for area in self.area_manager.areas: + self.music_pages_ao1.append('{}#{}'.format(index, area.name)) + index += 1 + # then add music + for item in self.music_list: + self.music_pages_ao1.append('{}#{}'.format(index, item['category'])) + index += 1 + for song in item['songs']: + self.music_pages_ao1.append('{}#{}'.format(index, song['name'])) + index += 1 + self.music_pages_ao1 = [self.music_pages_ao1[x:x + 10] for x in range(0, len(self.music_pages_ao1), 10)] + + def build_music_list_ao2(self): + self.music_list_ao2 = [] + # add areas first + for area in self.area_manager.areas: + self.music_list_ao2.append(area.name) + # then add music + for item in self.music_list: + self.music_list_ao2.append(item['category']) + for song in item['songs']: + self.music_list_ao2.append(song['name']) + + def is_valid_char_id(self, char_id): + return len(self.char_list) > char_id >= 0 + + def get_char_id_by_name(self, name): + for i, ch in enumerate(self.char_list): + if ch.lower() == name.lower(): + return i + raise ServerError('Character not found.') + + def get_song_data(self, music): + for item in self.music_list: + if item['category'] == music: + return item['category'], -1 + for song in item['songs']: + if song['name'] == music: + try: + return song['name'], song['length'] + except KeyError: + return song['name'], -1 + raise ServerError('Music not found.') + + def send_all_cmd_pred(self, cmd, *args, pred=lambda x: True): + for client in self.client_manager.clients: + if pred(client): + client.send_command(cmd, *args) + + def broadcast_global(self, client, msg, as_mod=False): + char_name = client.get_char_name() + ooc_name = '{}[{}][{}]'.format('G', client.area.id, char_name) + if as_mod: + ooc_name += '[M]' + self.send_all_cmd_pred('CT', ooc_name, msg, pred=lambda x: not x.muted_global) + if self.config['use_district']: + self.district_client.send_raw_message( + 'GLOBAL#{}#{}#{}#{}'.format(int(as_mod), client.area.id, char_name, msg)) + + def broadcast_need(self, client, msg): + char_name = client.get_char_name() + area_name = client.area.name + area_id = client.area.id + self.send_all_cmd_pred('CT', '{}'.format(self.config['hostname']), + '=== Advert ===\r\n{} in {} [{}] needs {}\r\n===============' + .format(char_name, area_name, area_id, msg), pred=lambda x: not x.muted_adverts) + if self.config['use_district']: + self.district_client.send_raw_message('NEED#{}#{}#{}#{}'.format(char_name, area_name, area_id, msg)) + + def refresh(self): + with open('config/config.yaml', 'r') as cfg: + self.config['motd'] = yaml.load(cfg)['motd'].replace('\\n', ' \n') + with open('config/characters.yaml', 'r') as chars: + self.char_list = yaml.load(chars) + with open('config/music.yaml', 'r') as music: + self.music_list = yaml.load(music) + self.build_music_pages_ao1() + self.build_music_list_ao2() + with open('config/backgrounds.yaml', 'r') as bgs: + self.backgrounds = yaml.load(bgs) diff --git a/server/websocket.py b/server/websocket.py new file mode 100644 index 0000000..d77f678 --- /dev/null +++ b/server/websocket.py @@ -0,0 +1,212 @@ +# tsuserver3, an Attorney Online server +# +# Copyright (C) 2017 argoneus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Partly authored by Johan Hanssen Seferidis (MIT license): +# https://github.com/Pithikos/python-websocket-server + +import asyncio +import re +import struct +from base64 import b64encode +from hashlib import sha1 + +from server import logger + + +class Bitmasks: + FIN = 0x80 + OPCODE = 0x0f + MASKED = 0x80 + PAYLOAD_LEN = 0x7f + PAYLOAD_LEN_EXT16 = 0x7e + PAYLOAD_LEN_EXT64 = 0x7f + + +class Opcode: + CONTINUATION = 0x0 + TEXT = 0x1 + BINARY = 0x2 + CLOSE_CONN = 0x8 + PING = 0x9 + PONG = 0xA + + +class WebSocket: + """ + State data for clients that are connected via a WebSocket that wraps + over a conventional TCP connection. + """ + + def __init__(self, client, protocol): + self.client = client + self.transport = client.transport + self.protocol = protocol + self.keep_alive = True + self.handshake_done = False + self.valid = False + + def handle(self, data): + if not self.handshake_done: + return self.handshake(data) + return self.parse(data) + + def parse(self, data): + b1, b2 = 0, 0 + if len(data) >= 2: + b1, b2 = data[0], data[1] + + fin = b1 & Bitmasks.FIN + opcode = b1 & Bitmasks.OPCODE + masked = b2 & Bitmasks.MASKED + payload_length = b2 & Bitmasks.PAYLOAD_LEN + + if not b1: + # Connection closed + self.keep_alive = 0 + return + if opcode == Opcode.CLOSE_CONN: + # Connection close requested + self.keep_alive = 0 + return + if not masked: + # Client was not masked (spec violation) + logger.log_debug("ws: client was not masked.", self.client) + self.keep_alive = 0 + print(data) + return + if opcode == Opcode.CONTINUATION: + # No continuation frames supported + logger.log_debug("ws: client tried to send continuation frame.", self.client) + return + elif opcode == Opcode.BINARY: + # No binary frames supported + logger.log_debug("ws: client tried to send binary frame.", self.client) + return + elif opcode == Opcode.TEXT: + def opcode_handler(s, msg): + return msg + elif opcode == Opcode.PING: + opcode_handler = self.send_pong + elif opcode == Opcode.PONG: + opcode_handler = lambda s, msg: None + else: + # Unknown opcode + logger.log_debug("ws: unknown opcode!", self.client) + self.keep_alive = 0 + return + + if payload_length == 126: + payload_length = struct.unpack(">H", data[2:4])[0] + elif payload_length == 127: + payload_length = struct.unpack(">Q", data[2:10])[0] + + masks = data[2:6] + decoded = "" + for char in data[6:payload_length + 6]: + char ^= masks[len(decoded) % 4] + decoded += chr(char) + + return opcode_handler(self, decoded) + + def send_message(self, message): + self.send_text(message) + + def send_pong(self, message): + self.send_text(message, Opcode.PONG) + + def send_text(self, message, opcode=Opcode.TEXT): + """ + Important: Fragmented (continuation) messages are not supported since + their usage cases are limited - when we don't know the payload length. + """ + + # Validate message + if isinstance(message, bytes): + message = message.decode("utf-8") + elif isinstance(message, str): + pass + else: + raise TypeError("Message must be either str or bytes") + + header = bytearray() + payload = message.encode("utf-8") + payload_length = len(payload) + + # Normal payload + if payload_length <= 125: + header.append(Bitmasks.FIN | opcode) + header.append(payload_length) + + # Extended payload + elif payload_length >= 126 and payload_length <= 65535: + header.append(Bitmasks.FIN | opcode) + header.append(Bitmasks.PAYLOAD_LEN_EXT16) + header.extend(struct.pack(">H", payload_length)) + + # Huge extended payload + elif payload_length < (1 << 64): + header.append(Bitmasks.FIN | opcode) + header.append(Bitmasks.PAYLOAD_LEN_EXT64) + header.extend(struct.pack(">Q", payload_length)) + + else: + raise Exception("Message is too big") + + self.transport.write(header + payload) + + def handshake(self, data): + try: + message = data[0:1024].decode().strip() + except UnicodeDecodeError: + return False + + upgrade = re.search('\nupgrade[\s]*:[\s]*websocket', message.lower()) + if not upgrade: + self.keep_alive = False + return False + + key = re.search('\n[sS]ec-[wW]eb[sS]ocket-[kK]ey[\s]*:[\s]*(.*)\r\n', message) + if key: + key = key.group(1) + else: + logger.log_debug("Client tried to connect but was missing a key", self.client) + self.keep_alive = False + return False + + response = self.make_handshake_response(key) + print(response.encode()) + self.transport.write(response.encode()) + self.handshake_done = True + self.valid = True + return True + + def make_handshake_response(self, key): + return \ + 'HTTP/1.1 101 Switching Protocols\r\n'\ + 'Upgrade: websocket\r\n' \ + 'Connection: Upgrade\r\n' \ + 'Sec-WebSocket-Accept: %s\r\n' \ + '\r\n' % self.calculate_response_key(key) + + def calculate_response_key(self, key): + GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + hash = sha1(key.encode() + GUID.encode()) + response_key = b64encode(hash.digest()).strip() + return response_key.decode('ASCII') + + def finish(self): + self.protocol.connection_lost(self) \ No newline at end of file