mirror of
https://git.ugnet.gay/CrossTalk/azul.git
synced 2026-05-27 22:59:49 +00:00
production init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from .entry import register
|
||||
@@ -0,0 +1,469 @@
|
||||
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
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Optional, Callable
|
||||
|
||||
import asyncio, settings
|
||||
|
||||
from core.backend import Backend
|
||||
from util.misc import Logger
|
||||
|
||||
from .ctrl import IRCCtrl
|
||||
|
||||
def register(loop: asyncio.AbstractEventLoop, backend: Backend, *, devmode: bool = False) -> None:
|
||||
from util.misc import ProtocolRunner
|
||||
backend.add_runner(ProtocolRunner('0.0.0.0', 6667, ListenerIRC, args = ['IRC', backend, IRCCtrl], service = 'IRC'))
|
||||
if settings.ENABLE_FRONT_IRC_SSL:
|
||||
if devmode:
|
||||
from brutus import Brutus
|
||||
ssl_context = Brutus('CrossTalk').create_ssl_context()
|
||||
else:
|
||||
from core.tls import TLSContext
|
||||
ssl_context = TLSContext(settings.CERT_ROOT, settings.CERT_DIR).create_ssl_context()
|
||||
backend.add_runner(ProtocolRunner('0.0.0.0', 6697, ListenerIRC, args = ['IRC + TLS', backend, IRCCtrl], ssl_context = ssl_context, service = 'IRC/TLS'))
|
||||
|
||||
class ListenerIRC(asyncio.Protocol):
|
||||
logger: Logger
|
||||
backend: Backend
|
||||
controller: IRCCtrl
|
||||
transport: Optional[asyncio.WriteTransport]
|
||||
|
||||
def __init__(self, logger_prefix: str, backend: Backend, controller_factory: Callable[[Logger, str, Backend], IRCCtrl]) -> None:
|
||||
super().__init__()
|
||||
self.logger = Logger(logger_prefix, self)
|
||||
self.backend = backend
|
||||
self.controller = controller_factory(self.logger, 'direct', backend)
|
||||
self.controller.close_callback = self._on_close
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||
assert isinstance(transport, asyncio.WriteTransport)
|
||||
self.transport = transport
|
||||
self.logger.log_connect()
|
||||
|
||||
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||
self.controller.close()
|
||||
self.logger.log_disconnect()
|
||||
self.transport = None
|
||||
|
||||
def data_received(self, data: bytes) -> None:
|
||||
transport = self.transport
|
||||
assert transport is not None
|
||||
if self.backend.maintenance_mode:
|
||||
transport.close()
|
||||
return
|
||||
self.controller.transport = None
|
||||
if self.controller.transport is None:
|
||||
self.controller.transport = self.transport
|
||||
self.controller.data_received(data)
|
||||
transport.write(self.controller.flush())
|
||||
self.controller.transport = transport
|
||||
|
||||
def _on_close(self) -> None:
|
||||
if self.transport is None: return
|
||||
self.transport.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
https://en.wikipedia.org/wiki/List_of_Internet_Relay_Chat_commands
|
||||
https://tools.ietf.org/html/rfc1459
|
||||
https://modern.ircdocs.horse
|
||||
https://www.alien.net.au/irc/irc2numerics.html
|
||||
Reference in New Issue
Block a user