from typing import Tuple, Optional, Iterable, List, Any, Callable, Dict import io, asyncio, settings from datetime import datetime from enum import IntEnum from util.misc import Logger from core import event from core.models import ( Contact, Substatus, User, Circle, CircleRole, TextWithData, MessageData, MessageType, LoginOption, OIM, ) from core.backend import Backend, BackendSession, Chat, ChatSession from core.client import Client class IRCCtrl: __slots__ = ( 'logger', 'reader', 'writer', 'peername', 'close_callback', 'closed', 'transport', 'backend', 'bs', 'client', 'password', 'username', 'chat_sessions' ) logger: Logger reader: 'IRCReader' writer: 'IRCWriter' peername: Tuple[str, int] close_callback: Optional[Callable[[], None]] closed: bool transport: Optional[asyncio.WriteTransport] backend: Backend bs: Optional[BackendSession] client: Client password: Optional[str] username: Optional[str] chat_sessions: Dict[Chat, ChatSession] def __init__(self, logger: Logger, via: str, backend: Backend) -> None: self.logger = logger self.reader = IRCReader(logger) self.writer = IRCWriter(logger) self.peername = ('0.0.0.0', 6667) self.close_callback = None self.closed = False self.transport = None self.backend = backend self.bs = None self.client = Client('irc', '?', via) self.password = None self.username = None self.chat_sessions = {} def _m_pass(self, pwd: str) -> None: self.password = pwd def _m_user(self, email: str, junk1: str, junk2: str, realname: str) -> None: password = self.password self.password = None assert password is not None uuid = self.backend.user_service.login(email, password) if uuid is not None: bs = self.backend.login(uuid, self.client, BackendEventHandler(self), option = LoginOption.BootOthers) else: bs = None if bs is None: self.send_numeric(Err.PasswdMismatch, ':Wrong email/password') return self.bs = bs user = bs.user if user.suspended: self.send_numeric(Err.YoureBannedCreep, ':Your CrossTalk account has been suspended. You may not connect to the service.') self.close() return self.bs.me_update({ 'substatus': Substatus.Online }) self.send_numeric(RPL.Welcome, email, ':Log on successful.') self._m_motd() def _m_ping(self, *servers: str) -> None: self.send_reply('PONG', *servers) def _m_join(self, channel: str, keys: Optional[str] = None) -> None: assert self.bs is not None email = self.bs.user.email chat = self._channel_to_chat(channel) if chat is None: chat = self.backend.chat_create() chat.add_id('irc', channel) cs = self._channel_to_chatsession(channel) if cs is None: cs = chat.join('irc', self.bs, ChatEventHandler(self)) chat.send_participant_joined(cs) self.chat_sessions[chat] = cs self.send_numeric(RPL.NamReply, email, '=', channel, ':' + ' '.join( cs.user.email for cs in chat.get_roster_single() )) # TODO: Chats created in other frontends are usually secret+private. #self.send_numeric(Err.InviteOnlyChan, email, channel, ":Cannot join channel") def _m_invite(self, user_email: str, channel: str) -> None: assert self.bs is not None cs = self._channel_to_chatsession(channel) assert cs is not None uuid = self.backend.util_get_uuid_from_email(user_email) assert uuid is not None user = self.backend.user_service.get(uuid) assert user is not None cs.invite(user) self.send_numeric(RPL.Inviting, self.bs.user.email, user_email, channel) def _m_list(self, arg1: Optional[str] = None, arg2: Optional[str] = None) -> None: assert self.bs is not None email = self.bs.user.email for chat in self.backend.get_chats_by_scope('irc'): self.send_numeric(RPL.List, email, chat.ids['irc'], str(len(list(chat.get_roster_single())))) self.send_numeric(RPL.ListEnd, email, ":End of /LIST") def _m_mode(self, channel: str) -> None: #self.send_numeric(RPL.ChannelModeIs, self.bs.user.email, channel, '+tnl', 200) pass def _m_nick(self, nickname: str) -> None: self.bs.me_update({ 'name': nickname }) def _m_userhost(self, email: str) -> None: self._reply_unsupported('USERHOST') def _m_who(self, channel: str) -> None: assert self.bs is not None cs = self._channel_to_chatsession(channel) assert cs is not None for cs_other in cs.chat.get_roster(): email = cs_other.user.email name = cs_other.user.status.name or email self.send_numeric(RPL.WhoReply, self.bs.user.email, channel, email, 'host', 'server', email, 'H', ':0 0PNE ' + name) def _m_part(self, channel: str, message: Optional[str] = None) -> None: assert self.bs is not None cs = self._channel_to_chatsession(channel) assert cs is not None cs.close() self.send_reply('PART', channel, source = self.bs.user.email) def _m_privmsg(self, dest: str, message: str) -> None: assert self.bs is not None cs = self._channel_to_chatsession(dest) assert cs is not None cs.send_message_to_everyone(MessageData(sender = self.bs.user, type = MessageType.Chat, text = message)) def _m_away(self, message: Optional[str]) -> None: assert self.bs is not None if self.bs.user.status.substatus == Substatus.Online: awaymsg = None if len(message) > 0: awaymsg = message self.bs.me_update({ 'substatus': Substatus.Away, message: awaymsg }) self.send_numeric(RPL.NowAway, ':You are now away.') else: self.bs.me_update({ 'substatus': Substatus.Online, 'message': None }) self.send_numeric(RPL.UnAway, ':You are no longer away.') def _m_motd(self) -> None: self.send_numeric(RPL.MOTDStart, ": ----- CrossTalk (BETA) -----") self.send_numeric(RPL.MOTD, ": - Welcome to CrossTalk! This frontend is undergoing a massive overhaul, so expect things to be iffy for now.") self.send_numeric(RPL.MOTD, ": - We support MSNP, YMSG, and OSCAR as well, though as of right now, only MSNP and YMSG are in a usable state.") self.send_numeric(RPL.MOTD, ": - Development is going at a faster pace, so this won't stay the case for long. Stay tuned!") #if settings.PRIVACY_POLICY_UPDATE_NOTIF: # self.send_numeric(RPL.MOTD, ": - NOTE: We have updated our Privacy Policy. Go here to take a look: https://crosstalk.im/ppolicy") #if settings.TOS_UPDATE_NOTIF: # self.send_numeric(RPL.MOTD, ": - NOTE: We have updated our Terms of Service. You must read and agree to these to continue using CrossTalk. Go here to take a look: https://crosstalk.im/tos") #if settings.REROUTE_UPDATE_NOTIF: # settings.send_numeric(RPL.MOTD, ": - NOTE for Yahoo! and MSN users: We have updated Reroute. Please download a new copy and replace your existing DLL with it. You can find that here: https://storage.ugnet.gay/crosstalk-dist/client/all/patching/reroute/reroute.dll") self.send_numeric(RPL.MOTDEnd, ": - End of the MOTD.") def _m_version(self) -> None: self.send_numeric(RPL.Version, ": - Azul (BETA) - IRC Frontend") def _m_quit(self, reason: Optional[str]) -> None: self.close() def _m_cap(self, subcommand: str, capabilities: Optional[str] = None) -> None: self._reply_unsupported('CAP') def _m_time(self) -> None: self.send_numeric(RPL.Time, ': - {}'.format(datetime.now())) def _reply_unsupported(self, cmd: str) -> None: self.send_numeric(Err.UnknownCommand, cmd, ":Not supported") def _channel_to_chatsession(self, channel: str) -> Optional[ChatSession]: chat = self._channel_to_chat(channel) assert chat is not None return self.chat_sessions.get(chat) def _channel_to_chat(self, channel: str) -> Optional[Chat]: return self.backend.chat_get('irc', channel) def data_received(self, data: bytes, *, transport: Optional[asyncio.BaseTransport] = None) -> None: if transport is None: transport = self.transport assert transport is not None self.peername = transport.get_extra_info('peername') for m in self.reader.data_received(data): try: f = getattr(self, '_m_{}'.format(m[0].lower())) f(*m[1:]) except Exception as ex: self.logger.error(ex) def send_numeric(self, n: int, *m: str, source: Optional[str] = None) -> None: self.send_reply('{:03}'.format(n), *m, source = source) def send_reply(self, *m: str, source: Optional[str] = None) -> None: if source is None: source = 'localhost' self.writer.write((':' + source,) + m) transport = self.transport if transport is not None: transport.write(self.flush()) def flush(self) -> bytes: return self.writer.flush() def close(self) -> None: if self.closed: return self.closed = True for cs in list(self.chat_sessions.values()): cs.close() if self.close_callback: self.close_callback() if self.bs: self.bs.close() class BackendEventHandler(event.BackendEventHandler): __slots__ = ('ctrl', 'bs') ctrl: IRCCtrl bs: BackendSession def __init__(self, ctrl: IRCCtrl) -> None: self.ctrl = ctrl def on_client_alert(self, icon_url: Optional[str], url: Optional[str], message: str = '') -> None: self.ctrl.send_reply('PRIVMSG', '$*', ':' + message, source = "System") def on_maintenance_message(self, *args: Any, **kwargs: Any) -> None: if args[1] is not None: self.ctrl.send_reply( 'NOTICE', '$*', ':CrossTalk is going to go down for maintenance in {} minutes. Now is a good time to wrap up any conversations.'.format(str(args[1])), source = 'System', ) def on_maintenance_boot(self) -> None: msg = ':CrossTalk is now in maintenance mode. Try connecting to the service later..' self.ctrl.send_reply( 'KILL', '$*', msg, source = 'System' ) self.ctrl.close() def on_presence_notification( self, ctc: Contact, on_contact_add: bool, old_substatus: Substatus, *, trid: Optional[str] = None, update_status: bool = True, update_info_other: bool = True, send_status_on_bl: bool = False, sess_id: Optional[int] = None, updated_phone_info: Optional[Dict[str, Any]] = None, ) -> None: if update_status: self.ctrl.send_reply('NOTICE', ":{} is now {}".format(ctc.head.email, ctc.status.substatus)) def on_presence_self_notification(self, old_substatus: Substatus, *, update_status: bool = True, update_info: bool = True) -> None: pass def on_circle_created(self, circle: Circle) -> None: pass def on_circle_updated(self, circle: Circle) -> None: pass def on_left_circle(self, circle: Circle) -> None: pass def on_accepted_circle_invite(self, circle: Circle) -> None: pass def on_circle_invite_revoked(self, chat_id: str) -> None: pass def on_circle_role_updated(self, chat_id: str, role: CircleRole) -> None: pass def on_chat_invite( self, chat: Chat, inviter: User, *, circle: bool = False, inviter_id: Optional[str] = None, invite_msg: str = '', ) -> None: if circle: return self.ctrl.send_reply('INVITE', self.bs.user.email, chat.ids['main'], source = inviter.email) def on_declined_chat_invite(self, chat: Chat, circle: bool = False) -> None: pass def on_added_me(self, user: User, *, adder_id: Optional[str] = None, message: Optional[TextWithData] = None) -> None: self.ctrl.send_reply('NOTICE', ":{} has added you to their friends list".format(user.email), source = user.email) if message: self.ctrl.send_reply('NOTICE', ":\"{}\"".format(message.text), source = user.email) def on_removed_me(self, user: User) -> None: pass def on_contact_request_denied(self, user_added: User, message: Optional[str], *, contact_id: Optional[str] = None) -> None: self.ctrl.send_reply('NOTICE', ":{} has declined your friend request".format(user_added.email), source = user_added.email) if message: self.ctrl.send_reply('NOTICE', ":\"{}\"".format(message), source = user_added.email) def on_oim_sent(self, oim: 'OIM') -> None: pass def on_login_elsewhere(self, option: LoginOption) -> None: if option is LoginOption.BootOthers: self.ctrl.send_reply('NOTICE', ":You are being booted because you are logging in from another location.") else: self.ctrl.send_reply('NOTICE', ":You are now logged in from multiple locations.") def on_close(self) -> None: self.ctrl.close() class ChatEventHandler(event.ChatEventHandler): __slots__ = ('ctrl', 'cs') ctrl: IRCCtrl cs: ChatSession def __init__(self, ctrl: IRCCtrl) -> None: self.ctrl = ctrl def on_close(self) -> None: self.ctrl.chat_sessions.pop(self.cs.chat, None) def on_participant_joined(self, cs_other: ChatSession, first_pop: bool, initial_join: bool) -> None: if first_pop: self.ctrl.send_reply('JOIN', self.cs.chat.ids['irc'], source = cs_other.user.email) def on_participant_left(self, cs_other: ChatSession, last_pop: bool) -> None: if last_pop: self.ctrl.send_reply('PART', self.cs.chat.ids['irc'], source = cs_other.user.email) def on_chat_invite_declined( self, chat: Chat, invitee: User, *, invitee_id: Optional[str] = None, message: Optional[str] = None, circle: bool = False, ) -> None: if circle: return self.ctrl.send_reply('NOTICE', ":{} declined the invitation".format(invitee.email), source = invitee.email) if message: self.ctrl.send_reply('NOTICE', ":\"{}\"".format(message), source = invitee.email) def on_chat_updated(self) -> None: pass def on_chat_roster_updated(self) -> None: pass def on_participant_status_updated(self, cs_other: ChatSession, first_pop: bool, initial: bool, old_substatus: Substatus) -> None: pass def on_message(self, data: MessageData) -> None: if data.type is not MessageType.Chat: return if data.text is None: return self.ctrl.send_reply('PRIVMSG', self.cs.chat.ids['irc'], ':' + data.text, source = data.sender.email) class IRCReader: __slots__ = ('_logger', '_data') _logger: Logger _data: bytes def __init__(self, logger: Logger) -> None: self._logger = logger self._data = b'' def data_received(self, data: bytes) -> Iterable[List[str]]: if self._data: self._data += data else: self._data = data while self._data: m = self._read() if m is None: break self._logger.debug('[Client]', *m) yield m def _read(self) -> Optional[List[str]]: try: i = self._data.index(b'\r\n') except IndexError: return None except ValueError: return None chunk = self._data[:i].decode('utf-8') self._data = self._data[i+2:] # TODO: Support @foo :bar prefixes toks = [] while True: chunk = chunk.lstrip(' ') if chunk[:1] == ':': toks.append(chunk[1:]) break k = chunk.find(' ') if k < 0: tok = chunk else: tok = chunk[:k] chunk = chunk[k:] if tok: toks.append(tok) if k < 0: break return toks class IRCWriter: __slots__ = ('_logger', '_buf') _logger: Logger _buf: io.BytesIO def __init__(self, logger: Logger) -> None: self._logger = logger self._buf = io.BytesIO() def write(self, m: Iterable[Any]) -> None: self._logger.debug('[Server]', *m) self._buf.write(' '.join(map(str, m)).encode('utf-8')) self._buf.write(b'\r\n') def flush(self) -> bytes: data = self._buf.getvalue() if data: self._buf = io.BytesIO() return data class Err(IntEnum): UnknownError = 400 UnknownCommand = 421 NoNicknameGiven = 431 NicknameInUse = 433 PasswdMismatch = 464 YoureBannedCreep = 465 InviteOnlyChan = 473 class RPL(IntEnum): Welcome = 1 List = 322 ListEnd = 323 NamReply = 353 ChannelModeIs = 324 Inviting = 341 WhoReply = 352 MOTDStart = 375 MOTD = 372 MOTDEnd = 376 UnAway = 305 NowAway = 306 Away = 301 Version = 351 Time = 391