Split SendIRC, parse_irc_line, and get_ssl_content off into appropriate files
This commit is contained in:
parent
2c278c00fa
commit
fbe8f48d63
3 changed files with 261 additions and 253 deletions
284
irc.py
284
irc.py
|
@ -30,10 +30,10 @@ the license notice included with oyoyo source files is indented here:
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
|
||||||
import random
|
import random
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
from ssl import SSLEOFError, SSLCertVerificationError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PyQt6 import QtCore, QtGui
|
from PyQt6 import QtCore, QtGui
|
||||||
|
@ -45,6 +45,8 @@ from mood import Mood
|
||||||
from dataobjs import PesterProfile
|
from dataobjs import PesterProfile
|
||||||
from generic import PesterList
|
from generic import PesterList
|
||||||
from version import _pcVersion
|
from version import _pcVersion
|
||||||
|
from scripts.irc_protocol import SendIRC, parse_irc_line
|
||||||
|
from scripts.ssl_context import get_ssl_context
|
||||||
|
|
||||||
PchumLog = logging.getLogger("pchumLogger")
|
PchumLog = logging.getLogger("pchumLogger")
|
||||||
SERVICES = [
|
SERVICES = [
|
||||||
|
@ -57,25 +59,10 @@ SERVICES = [
|
||||||
"botserv",
|
"botserv",
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
|
||||||
import certifi
|
|
||||||
except ImportError:
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
# Certifi is required to validate certificates on MacOS with pyinstaller builds.
|
|
||||||
PchumLog.warning(
|
|
||||||
"Failed to import certifi, which is recommended on MacOS. "
|
|
||||||
"Pesterchum might not be able to validate certificates unless "
|
|
||||||
"Python's root certs are installed."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
PchumLog.info(
|
|
||||||
"Failed to import certifi, Pesterchum will not be able to validate "
|
|
||||||
"certificates if the system-provided root certificates are invalid."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PesterIRC(QtCore.QThread):
|
class PesterIRC(QtCore.QThread):
|
||||||
"""Class for making a thread that manages the connection to server."""
|
"""Class for making a thread that manages the connection to server."""
|
||||||
|
|
||||||
def __init__(self, config, window, verify_hostname=True):
|
def __init__(self, config, window, verify_hostname=True):
|
||||||
QtCore.QThread.__init__(self)
|
QtCore.QThread.__init__(self)
|
||||||
self.mainwindow = window
|
self.mainwindow = window
|
||||||
|
@ -137,38 +124,6 @@ class PesterIRC(QtCore.QThread):
|
||||||
"cap": self.cap, # IRCv3 Client Capability Negotiation
|
"cap": self.cap, # IRCv3 Client Capability Negotiation
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_ssl_context(self):
|
|
||||||
"""Returns an SSL context for connecting over SSL/TLS.
|
|
||||||
Loads the certifi root certificate bundle if the certifi module is less
|
|
||||||
than a year old or if the system certificate store is empty.
|
|
||||||
|
|
||||||
The cert store on Windows also seems to have issues, so it's better
|
|
||||||
to use the certifi provided bundle assuming it's a recent version.
|
|
||||||
|
|
||||||
On MacOS the system cert store is usually empty, as Python does not use
|
|
||||||
the system provided ones, instead relying on a bundle installed with the
|
|
||||||
python installer."""
|
|
||||||
default_context = ssl.create_default_context()
|
|
||||||
if "certifi" not in sys.modules:
|
|
||||||
return default_context
|
|
||||||
|
|
||||||
# Get age of certifi module
|
|
||||||
certifi_date = datetime.datetime.strptime(certifi.__version__, "%Y.%m.%d")
|
|
||||||
current_date = datetime.datetime.now()
|
|
||||||
certifi_age = current_date - certifi_date
|
|
||||||
|
|
||||||
empty_cert_store = (
|
|
||||||
list(default_context.cert_store_stats().values()).count(0) == 3
|
|
||||||
)
|
|
||||||
# 31557600 seconds is approximately 1 year
|
|
||||||
if empty_cert_store or certifi_age.total_seconds() <= 31557600:
|
|
||||||
PchumLog.info(
|
|
||||||
"Using SSL/TLS context with certifi-provided root certificates."
|
|
||||||
)
|
|
||||||
return ssl.create_default_context(cafile=certifi.where())
|
|
||||||
PchumLog.info("Using SSL/TLS context with system-provided root certificates.")
|
|
||||||
return default_context
|
|
||||||
|
|
||||||
def connect(self, verify_hostname=True):
|
def connect(self, verify_hostname=True):
|
||||||
"""Initiates the connection to the server set in self.host:self.port
|
"""Initiates the connection to the server set in self.host:self.port
|
||||||
self.ssl decides whether the connection uses ssl.
|
self.ssl decides whether the connection uses ssl.
|
||||||
|
@ -184,7 +139,7 @@ class PesterIRC(QtCore.QThread):
|
||||||
|
|
||||||
if self.ssl:
|
if self.ssl:
|
||||||
# Upgrade connection to use SSL/TLS if enabled
|
# Upgrade connection to use SSL/TLS if enabled
|
||||||
context = self.get_ssl_context()
|
context = get_ssl_context()
|
||||||
context.check_hostname = verify_hostname
|
context.check_hostname = verify_hostname
|
||||||
self.socket = context.wrap_socket(
|
self.socket = context.wrap_socket(
|
||||||
plaintext_socket, server_hostname=self.host
|
plaintext_socket, server_hostname=self.host
|
||||||
|
@ -225,7 +180,7 @@ class PesterIRC(QtCore.QThread):
|
||||||
|
|
||||||
for line in split_buffer:
|
for line in split_buffer:
|
||||||
line = line.decode(encoding="utf-8", errors="replace")
|
line = line.decode(encoding="utf-8", errors="replace")
|
||||||
tags, prefix, command, args = self.parse_irc_line(line)
|
tags, prefix, command, args = parse_irc_line(line)
|
||||||
if command:
|
if command:
|
||||||
# Only need tags with tagmsg
|
# Only need tags with tagmsg
|
||||||
if command.casefold() == "tagmsg":
|
if command.casefold() == "tagmsg":
|
||||||
|
@ -237,7 +192,7 @@ class PesterIRC(QtCore.QThread):
|
||||||
except socket.timeout as se:
|
except socket.timeout as se:
|
||||||
PchumLog.debug("passing timeout")
|
PchumLog.debug("passing timeout")
|
||||||
raise se
|
raise se
|
||||||
except (OSError, ssl.SSLEOFError) as se:
|
except (OSError, SSLEOFError) as se:
|
||||||
PchumLog.warning("Problem: %s", se)
|
PchumLog.warning("Problem: %s", se)
|
||||||
if self.socket:
|
if self.socket:
|
||||||
PchumLog.info("Error: closing socket.")
|
PchumLog.info("Error: closing socket.")
|
||||||
|
@ -253,36 +208,23 @@ class PesterIRC(QtCore.QThread):
|
||||||
self.socket.close()
|
self.socket.close()
|
||||||
yield False
|
yield False
|
||||||
|
|
||||||
def parse_irc_line(self, line: str):
|
def run_command(self, command, *args):
|
||||||
"""Retrieves tags, prefix, command, and arguments from an unparsed IRC line."""
|
"""Finds and runs a command if it has a matching function in the self.command dict."""
|
||||||
parts = line.split(" ")
|
PchumLog.debug("run_command %s(%s)", command, args)
|
||||||
tags = None
|
if command in self.commands:
|
||||||
prefix = None
|
command_function = self.commands[command]
|
||||||
if parts[0].startswith(":"):
|
|
||||||
prefix = parts[0][1:]
|
|
||||||
command = parts[1]
|
|
||||||
args = parts[2:]
|
|
||||||
elif parts[0].startswith("@"):
|
|
||||||
tags = parts[0] # IRCv3 message tag
|
|
||||||
prefix = parts[1][1:]
|
|
||||||
command = parts[2]
|
|
||||||
args = parts[3:]
|
|
||||||
else:
|
else:
|
||||||
command = parts[0]
|
PchumLog.warning("No matching function for command: %s(%s)", command, args)
|
||||||
args = parts[1:]
|
return
|
||||||
command = command.casefold()
|
|
||||||
|
|
||||||
# If ':' is present the subsequent args are one parameter.
|
try:
|
||||||
fused_args = []
|
command_function(*args)
|
||||||
for idx, arg in enumerate(args):
|
except TypeError:
|
||||||
if arg.startswith(":"):
|
PchumLog.exception(
|
||||||
final_param = " ".join(args[idx:])
|
"Failed to pass command, did the server pass an unsupported paramater?"
|
||||||
fused_args.append(final_param[1:])
|
)
|
||||||
break
|
except Exception:
|
||||||
else:
|
PchumLog.exception("Exception while parsing command.")
|
||||||
fused_args.append(arg)
|
|
||||||
|
|
||||||
return (tags, prefix, command, fused_args)
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Kill the socket 'with extreme prejudice'."""
|
"""Kill the socket 'with extreme prejudice'."""
|
||||||
|
@ -301,7 +243,7 @@ class PesterIRC(QtCore.QThread):
|
||||||
def IRCConnect(self):
|
def IRCConnect(self):
|
||||||
try:
|
try:
|
||||||
self.connect(self.verify_hostname)
|
self.connect(self.verify_hostname)
|
||||||
except ssl.SSLCertVerificationError as e:
|
except SSLCertVerificationError as e:
|
||||||
# Ask if users wants to connect anyway
|
# Ask if users wants to connect anyway
|
||||||
self.askToConnect.emit(e)
|
self.askToConnect.emit(e)
|
||||||
raise e
|
raise e
|
||||||
|
@ -334,14 +276,6 @@ class PesterIRC(QtCore.QThread):
|
||||||
PchumLog.debug("False Yield: %s, returning", res)
|
PchumLog.debug("False Yield: %s, returning", res)
|
||||||
return
|
return
|
||||||
|
|
||||||
def setConnected(self):
|
|
||||||
"""Called when connected and registered to server.
|
|
||||||
|
|
||||||
Meaning the server has accepted our nick and user and has replied with 001/welcome.
|
|
||||||
"""
|
|
||||||
self.registeredIRC = True
|
|
||||||
self.connected.emit()
|
|
||||||
|
|
||||||
def setConnectionBroken(self):
|
def setConnectionBroken(self):
|
||||||
"""Called when the connection is broken."""
|
"""Called when the connection is broken."""
|
||||||
PchumLog.critical("setConnectionBroken() got called, disconnecting.")
|
PchumLog.critical("setConnectionBroken() got called, disconnecting.")
|
||||||
|
@ -851,10 +785,9 @@ class PesterIRC(QtCore.QThread):
|
||||||
|
|
||||||
def welcome(self, _server, _nick, _msg):
|
def welcome(self, _server, _nick, _msg):
|
||||||
"""Numeric reply 001 RPL_WELCOME, send when we've connected to the server."""
|
"""Numeric reply 001 RPL_WELCOME, send when we've connected to the server."""
|
||||||
self.setConnected()
|
self.registeredIRC = True # Registered as in, the server has accepted our nick & user.
|
||||||
# mychumhandle = self.mainwindow.profile().handle
|
self.connected.emit() # Alert main thread that we've connected.
|
||||||
mymood = self.mainwindow.profile().mood.value()
|
profile = self.mainwindow.profile()
|
||||||
color = self.mainwindow.profile().color
|
|
||||||
if not self.mainwindow.config.lowBandwidth():
|
if not self.mainwindow.config.lowBandwidth():
|
||||||
# Negotiate capabilities
|
# Negotiate capabilities
|
||||||
self.send_irc.cap("REQ", "message-tags")
|
self.send_irc.cap("REQ", "message-tags")
|
||||||
|
@ -867,12 +800,12 @@ class PesterIRC(QtCore.QThread):
|
||||||
self.send_irc.join("#pesterchum")
|
self.send_irc.join("#pesterchum")
|
||||||
# Moods via metadata
|
# Moods via metadata
|
||||||
self.send_irc.metadata("*", "sub", "mood")
|
self.send_irc.metadata("*", "sub", "mood")
|
||||||
self.send_irc.metadata("*", "set", "mood", str(mymood))
|
self.send_irc.metadata("*", "set", "mood", str(profile.mood.value))
|
||||||
# Color via metadata
|
# Color via metadata
|
||||||
self.send_irc.metadata("*", "sub", "color")
|
self.send_irc.metadata("*", "sub", "color")
|
||||||
self.send_irc.metadata("*", "set", "color", str(color.name()))
|
self.send_irc.metadata("*", "set", "color", profile.color.name())
|
||||||
# Backwards compatible moods
|
# Backwards compatible moods
|
||||||
self.send_irc.privmsg("#pesterchum", f"MOOD >{mymood}")
|
self.send_irc.privmsg("#pesterchum", f"MOOD >{profile.mymood}")
|
||||||
|
|
||||||
def featurelist(self, _target, _handle, *params):
|
def featurelist(self, _target, _handle, *params):
|
||||||
"""Numerical reply 005 RPL_ISUPPORT to communicate supported server features.
|
"""Numerical reply 005 RPL_ISUPPORT to communicate supported server features.
|
||||||
|
@ -980,7 +913,9 @@ class PesterIRC(QtCore.QThread):
|
||||||
|
|
||||||
def _reset_nick(self, oldnick):
|
def _reset_nick(self, oldnick):
|
||||||
"""Set our nick to a random pesterClient."""
|
"""Set our nick to a random pesterClient."""
|
||||||
random_number = int(random.random() * 9999) # Random int in range 1000 <---> 9999
|
random_number = int(
|
||||||
|
random.random() * 9999
|
||||||
|
) # Random int in range 1000 <---> 9999
|
||||||
newnick = f"pesterClient{random_number}"
|
newnick = f"pesterClient{random_number}"
|
||||||
self.send_irc.nick(newnick)
|
self.send_irc.nick(newnick)
|
||||||
self.nickCollision.emit(oldnick, newnick)
|
self.nickCollision.emit(oldnick, newnick)
|
||||||
|
@ -1032,24 +967,6 @@ class PesterIRC(QtCore.QThread):
|
||||||
""" "METADATA DRAFT numeric reply 770 RPL_METADATASUBOK, we subbed to a key."""
|
""" "METADATA DRAFT numeric reply 770 RPL_METADATASUBOK, we subbed to a key."""
|
||||||
PchumLog.info("metadatasubok: %s", params)
|
PchumLog.info("metadatasubok: %s", params)
|
||||||
|
|
||||||
def run_command(self, command, *args):
|
|
||||||
"""Finds and runs a command."""
|
|
||||||
PchumLog.debug("run_command %s(%s)", command, args)
|
|
||||||
if command in self.commands:
|
|
||||||
command_function = self.commands[command]
|
|
||||||
else:
|
|
||||||
PchumLog.warning("No matching function for command: %s(%s)", command, args)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
command_function(*args)
|
|
||||||
except TypeError:
|
|
||||||
PchumLog.exception(
|
|
||||||
"Failed to pass command, did the server pass an unsupported paramater?"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
PchumLog.exception("Exception while parsing command.")
|
|
||||||
|
|
||||||
moodUpdated = QtCore.pyqtSignal("QString", Mood)
|
moodUpdated = QtCore.pyqtSignal("QString", Mood)
|
||||||
colorUpdated = QtCore.pyqtSignal("QString", QtGui.QColor)
|
colorUpdated = QtCore.pyqtSignal("QString", QtGui.QColor)
|
||||||
messageReceived = QtCore.pyqtSignal("QString", "QString")
|
messageReceived = QtCore.pyqtSignal("QString", "QString")
|
||||||
|
@ -1070,140 +987,3 @@ class PesterIRC(QtCore.QThread):
|
||||||
cannotSendToChan = QtCore.pyqtSignal("QString", "QString")
|
cannotSendToChan = QtCore.pyqtSignal("QString", "QString")
|
||||||
quirkDisable = QtCore.pyqtSignal("QString", "QString", "QString")
|
quirkDisable = QtCore.pyqtSignal("QString", "QString", "QString")
|
||||||
signal_forbiddenchannel = QtCore.pyqtSignal("QString", "QString")
|
signal_forbiddenchannel = QtCore.pyqtSignal("QString", "QString")
|
||||||
|
|
||||||
class SendIRC:
|
|
||||||
"""Provides functions for outgoing IRC commands."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.socket = None # INET socket connected with server.
|
|
||||||
|
|
||||||
def _send(self, *args: str, text=None):
|
|
||||||
"""Send a command to the IRC server.
|
|
||||||
|
|
||||||
Takes either a string or a list of strings.
|
|
||||||
The 'text' argument is for the final parameter, which can have spaces.
|
|
||||||
|
|
||||||
Since this checks if the socket is alive, it's best to send via this method."""
|
|
||||||
# Return if disconnected
|
|
||||||
if not self.socket or self.socket.fileno() == -1:
|
|
||||||
PchumLog.error(
|
|
||||||
"Send attempted while disconnected, args: %s, text: %s.", args, text
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
command = ""
|
|
||||||
# Convert command arguments to a single string if passed.
|
|
||||||
if args:
|
|
||||||
command += " ".join(args)
|
|
||||||
# If text is passed, add ':' to imply everything after it is one parameter.
|
|
||||||
if text:
|
|
||||||
command += f" :{text}"
|
|
||||||
# Add characters for end of line in IRC.
|
|
||||||
command += "\r\n"
|
|
||||||
# UTF-8 is the prefered encoding in 2023.
|
|
||||||
outgoing_bytes = command.encode(encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
try:
|
|
||||||
PchumLog.debug("Sending: %s", command)
|
|
||||||
self.socket.sendall(outgoing_bytes)
|
|
||||||
except OSError:
|
|
||||||
PchumLog.exception("Error while sending: '%s'", command.strip())
|
|
||||||
self.socket.close()
|
|
||||||
|
|
||||||
def ping(self, token):
|
|
||||||
"""Send PING command to server to check for connectivity."""
|
|
||||||
self._send("PING", text=token)
|
|
||||||
|
|
||||||
def pong(self, token):
|
|
||||||
"""Send PONG command to reply to server PING."""
|
|
||||||
self._send("PONG", token)
|
|
||||||
|
|
||||||
def nick(self, nick):
|
|
||||||
"""Send USER command to communicate nick to server."""
|
|
||||||
self._send("NICK", nick)
|
|
||||||
|
|
||||||
def user(self, username, realname):
|
|
||||||
"""Send USER command to communicate username and realname to server."""
|
|
||||||
self._send("USER", username, "0", "*", text=realname)
|
|
||||||
|
|
||||||
def privmsg(self, target, text):
|
|
||||||
"""Send PRIVMSG command to send a message."""
|
|
||||||
for line in text.split("\n"):
|
|
||||||
self._send("PRIVMSG", target, text=line)
|
|
||||||
|
|
||||||
def names(self, channel):
|
|
||||||
"""Send NAMES command to view channel members."""
|
|
||||||
self._send("NAMES", channel)
|
|
||||||
|
|
||||||
def kick(self, channel, user, reason=""):
|
|
||||||
"""Send KICK command to force user from channel."""
|
|
||||||
if reason:
|
|
||||||
self._send(f"KICK {channel} {user}", text=reason)
|
|
||||||
else:
|
|
||||||
self._send(f"KICK {channel} {user}")
|
|
||||||
|
|
||||||
def mode(self, target, modestring="", mode_arguments=""):
|
|
||||||
"""Set or remove modes from target."""
|
|
||||||
outgoing_mode = " ".join([target, modestring, mode_arguments]).strip()
|
|
||||||
self._send("MODE", outgoing_mode)
|
|
||||||
|
|
||||||
def ctcp(self, target, command, msg=""):
|
|
||||||
"""Send Client-to-Client Protocol message."""
|
|
||||||
outgoing_ctcp = " ".join(
|
|
||||||
[command, msg]
|
|
||||||
).strip() # Extra spaces break protocol, so strip.
|
|
||||||
self.privmsg(target, f"\x01{outgoing_ctcp}\x01")
|
|
||||||
|
|
||||||
def metadata(self, target, subcommand, *params):
|
|
||||||
"""Send Metadata command to get or set metadata.
|
|
||||||
|
|
||||||
See IRC metadata draft specification:
|
|
||||||
https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237
|
|
||||||
"""
|
|
||||||
self._send("METADATA", target, subcommand, *params)
|
|
||||||
|
|
||||||
def cap(self, subcommand, *params):
|
|
||||||
"""Send IRCv3 CAP command for capability negotiation.
|
|
||||||
|
|
||||||
See: https://ircv3.net/specs/extensions/capability-negotiation.html"""
|
|
||||||
self._send("CAP", subcommand, *params)
|
|
||||||
|
|
||||||
def join(self, channel, key=""):
|
|
||||||
"""Send JOIN command to join a channel/memo.
|
|
||||||
|
|
||||||
Keys or joining multiple channels is possible in the specification, but unused.
|
|
||||||
"""
|
|
||||||
channel_and_key = " ".join([channel, key]).strip()
|
|
||||||
self._send("JOIN", channel_and_key)
|
|
||||||
|
|
||||||
def part(self, channel):
|
|
||||||
"""Send PART command to leave a channel/memo.
|
|
||||||
|
|
||||||
Providing a reason or leaving multiple channels is possible in the specification.
|
|
||||||
"""
|
|
||||||
self._send("PART", channel)
|
|
||||||
|
|
||||||
def notice(self, target, text):
|
|
||||||
"""Send a NOTICE to a user or channel."""
|
|
||||||
self._send("NOTICE", target, text=text)
|
|
||||||
|
|
||||||
def invite(self, nick, channel):
|
|
||||||
"""Send INVITE command to invite a user to a channel."""
|
|
||||||
self._send("INVITE", nick, channel)
|
|
||||||
|
|
||||||
def away(self, text=None):
|
|
||||||
"""AWAY command to mark client as away or no longer away.
|
|
||||||
|
|
||||||
No 'text' parameter means the client is no longer away."""
|
|
||||||
if text:
|
|
||||||
self._send("AWAY", text=text)
|
|
||||||
else:
|
|
||||||
self._send("AWAY")
|
|
||||||
|
|
||||||
def list(self):
|
|
||||||
"""Send LIST command to get list of channels."""
|
|
||||||
self._send("LIST")
|
|
||||||
|
|
||||||
def quit(self, reason=""):
|
|
||||||
"""Send QUIT to terminate connection."""
|
|
||||||
self._send("QUIT", text=reason)
|
|
||||||
|
|
176
scripts/irc_protocol.py
Normal file
176
scripts/irc_protocol.py
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
"""IRC-related functions and classes to be imported by irc.py"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
PchumLog = logging.getLogger("pchumLogger")
|
||||||
|
|
||||||
|
|
||||||
|
class SendIRC:
|
||||||
|
"""Provides functions for outgoing IRC commands.
|
||||||
|
|
||||||
|
Functions are protocol compliant but don't implement all valid uses of certain commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.socket = None # INET socket connected with server.
|
||||||
|
|
||||||
|
def _send(self, *args: str, text=None):
|
||||||
|
"""Send a command to the IRC server.
|
||||||
|
|
||||||
|
Takes either a string or a list of strings.
|
||||||
|
The 'text' argument is for the final parameter, which can have spaces.
|
||||||
|
|
||||||
|
Since this checks if the socket is alive, it's best to send via this method."""
|
||||||
|
# Return if disconnected
|
||||||
|
if not self.socket or self.socket.fileno() == -1:
|
||||||
|
PchumLog.error(
|
||||||
|
"Send attempted while disconnected, args: %s, text: %s.", args, text
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
command = ""
|
||||||
|
# Convert command arguments to a single string if passed.
|
||||||
|
if args:
|
||||||
|
command += " ".join(args)
|
||||||
|
# If text is passed, add ':' to imply everything after it is one parameter.
|
||||||
|
if text:
|
||||||
|
command += f" :{text}"
|
||||||
|
# Add characters for end of line in IRC.
|
||||||
|
command += "\r\n"
|
||||||
|
# UTF-8 is the prefered encoding in 2023.
|
||||||
|
outgoing_bytes = command.encode(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
try:
|
||||||
|
PchumLog.debug("Sending: %s", command)
|
||||||
|
self.socket.sendall(outgoing_bytes)
|
||||||
|
except OSError:
|
||||||
|
PchumLog.exception("Error while sending: '%s'", command.strip())
|
||||||
|
self.socket.close()
|
||||||
|
|
||||||
|
def ping(self, token):
|
||||||
|
"""Send PING command to server to check for connectivity."""
|
||||||
|
self._send("PING", text=token)
|
||||||
|
|
||||||
|
def pong(self, token):
|
||||||
|
"""Send PONG command to reply to server PING."""
|
||||||
|
self._send("PONG", token)
|
||||||
|
|
||||||
|
def nick(self, nick):
|
||||||
|
"""Send USER command to communicate nick to server."""
|
||||||
|
self._send("NICK", nick)
|
||||||
|
|
||||||
|
def user(self, username, realname):
|
||||||
|
"""Send USER command to communicate username and realname to server."""
|
||||||
|
self._send("USER", username, "0", "*", text=realname)
|
||||||
|
|
||||||
|
def privmsg(self, target, text):
|
||||||
|
"""Send PRIVMSG command to send a message."""
|
||||||
|
for line in text.split("\n"):
|
||||||
|
self._send("PRIVMSG", target, text=line)
|
||||||
|
|
||||||
|
def names(self, channel):
|
||||||
|
"""Send NAMES command to view channel members."""
|
||||||
|
self._send("NAMES", channel)
|
||||||
|
|
||||||
|
def kick(self, channel, user, reason=""):
|
||||||
|
"""Send KICK command to force user from channel."""
|
||||||
|
if reason:
|
||||||
|
self._send(f"KICK {channel} {user}", text=reason)
|
||||||
|
else:
|
||||||
|
self._send(f"KICK {channel} {user}")
|
||||||
|
|
||||||
|
def mode(self, target, modestring="", mode_arguments=""):
|
||||||
|
"""Set or remove modes from target."""
|
||||||
|
outgoing_mode = " ".join([target, modestring, mode_arguments]).strip()
|
||||||
|
self._send("MODE", outgoing_mode)
|
||||||
|
|
||||||
|
def ctcp(self, target, command, msg=""):
|
||||||
|
"""Send Client-to-Client Protocol message."""
|
||||||
|
outgoing_ctcp = " ".join(
|
||||||
|
[command, msg]
|
||||||
|
).strip() # Extra spaces break protocol, so strip.
|
||||||
|
self.privmsg(target, f"\x01{outgoing_ctcp}\x01")
|
||||||
|
|
||||||
|
def metadata(self, target, subcommand, *params):
|
||||||
|
"""Send Metadata command to get or set metadata.
|
||||||
|
|
||||||
|
See IRC metadata draft specification:
|
||||||
|
https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237
|
||||||
|
"""
|
||||||
|
self._send("METADATA", target, subcommand, *params)
|
||||||
|
|
||||||
|
def cap(self, subcommand, *params):
|
||||||
|
"""Send IRCv3 CAP command for capability negotiation.
|
||||||
|
|
||||||
|
See: https://ircv3.net/specs/extensions/capability-negotiation.html"""
|
||||||
|
self._send("CAP", subcommand, *params)
|
||||||
|
|
||||||
|
def join(self, channel, key=""):
|
||||||
|
"""Send JOIN command to join a channel/memo.
|
||||||
|
|
||||||
|
Keys or joining multiple channels is possible in the specification, but unused.
|
||||||
|
"""
|
||||||
|
channel_and_key = " ".join([channel, key]).strip()
|
||||||
|
self._send("JOIN", channel_and_key)
|
||||||
|
|
||||||
|
def part(self, channel):
|
||||||
|
"""Send PART command to leave a channel/memo.
|
||||||
|
|
||||||
|
Providing a reason or leaving multiple channels is possible in the specification.
|
||||||
|
"""
|
||||||
|
self._send("PART", channel)
|
||||||
|
|
||||||
|
def notice(self, target, text):
|
||||||
|
"""Send a NOTICE to a user or channel."""
|
||||||
|
self._send("NOTICE", target, text=text)
|
||||||
|
|
||||||
|
def invite(self, nick, channel):
|
||||||
|
"""Send INVITE command to invite a user to a channel."""
|
||||||
|
self._send("INVITE", nick, channel)
|
||||||
|
|
||||||
|
def away(self, text=None):
|
||||||
|
"""AWAY command to mark client as away or no longer away.
|
||||||
|
|
||||||
|
No 'text' parameter means the client is no longer away."""
|
||||||
|
if text:
|
||||||
|
self._send("AWAY", text=text)
|
||||||
|
else:
|
||||||
|
self._send("AWAY")
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
"""Send LIST command to get list of channels."""
|
||||||
|
self._send("LIST")
|
||||||
|
|
||||||
|
def quit(self, reason=""):
|
||||||
|
"""Send QUIT to terminate connection."""
|
||||||
|
self._send("QUIT", text=reason)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_irc_line(line: str):
|
||||||
|
"""Retrieves tags, prefix, command, and arguments from an unparsed IRC line."""
|
||||||
|
parts = line.split(" ")
|
||||||
|
tags = None
|
||||||
|
prefix = None
|
||||||
|
if parts[0].startswith(":"):
|
||||||
|
prefix = parts[0][1:]
|
||||||
|
command = parts[1]
|
||||||
|
args = parts[2:]
|
||||||
|
elif parts[0].startswith("@"):
|
||||||
|
tags = parts[0] # IRCv3 message tag
|
||||||
|
prefix = parts[1][1:]
|
||||||
|
command = parts[2]
|
||||||
|
args = parts[3:]
|
||||||
|
else:
|
||||||
|
command = parts[0]
|
||||||
|
args = parts[1:]
|
||||||
|
command = command.casefold()
|
||||||
|
|
||||||
|
# If ':' is present the subsequent args are one parameter.
|
||||||
|
fused_args = []
|
||||||
|
for idx, arg in enumerate(args):
|
||||||
|
if arg.startswith(":"):
|
||||||
|
final_param = " ".join(args[idx:])
|
||||||
|
fused_args.append(final_param[1:])
|
||||||
|
break
|
||||||
|
fused_args.append(arg)
|
||||||
|
|
||||||
|
return (tags, prefix, command, fused_args)
|
52
scripts/ssl_context.py
Normal file
52
scripts/ssl_context.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
"""Provides a function for creating an appropriate SSL context."""
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
PchumLog = logging.getLogger("pchumLogger")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
except ImportError:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
# Certifi is required to validate certificates on MacOS with pyinstaller builds.
|
||||||
|
PchumLog.warning(
|
||||||
|
"Failed to import certifi, which is recommended on MacOS. "
|
||||||
|
"Pesterchum might not be able to validate certificates unless "
|
||||||
|
"Python's root certs are installed."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
PchumLog.info(
|
||||||
|
"Failed to import certifi, Pesterchum will not be able to validate "
|
||||||
|
"certificates if the system-provided root certificates are invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ssl_context():
|
||||||
|
"""Returns an SSL context for connecting over SSL/TLS.
|
||||||
|
Loads the certifi root certificate bundle if the certifi module is less
|
||||||
|
than a year old or if the system certificate store is empty.
|
||||||
|
|
||||||
|
The cert store on Windows also seems to have issues, so it's better
|
||||||
|
to use the certifi provided bundle assuming it's a recent version.
|
||||||
|
|
||||||
|
On MacOS the system cert store is usually empty, as Python does not use
|
||||||
|
the system provided ones, instead relying on a bundle installed with the
|
||||||
|
python installer."""
|
||||||
|
default_context = ssl.create_default_context()
|
||||||
|
if "certifi" not in sys.modules:
|
||||||
|
return default_context
|
||||||
|
|
||||||
|
# Get age of certifi module
|
||||||
|
certifi_date = datetime.datetime.strptime(certifi.__version__, "%Y.%m.%d")
|
||||||
|
current_date = datetime.datetime.now()
|
||||||
|
certifi_age = current_date - certifi_date
|
||||||
|
|
||||||
|
empty_cert_store = list(default_context.cert_store_stats().values()).count(0) == 3
|
||||||
|
# 31557600 seconds is approximately 1 year
|
||||||
|
if empty_cert_store or certifi_age.total_seconds() <= 31557600:
|
||||||
|
PchumLog.info("Using SSL/TLS context with certifi-provided root certificates.")
|
||||||
|
return ssl.create_default_context(cafile=certifi.where())
|
||||||
|
PchumLog.info("Using SSL/TLS context with system-provided root certificates.")
|
||||||
|
return default_context
|
Loading…
Reference in a new issue