Merge pull request #116 from Dpeta/network-integration-testing

Rewrite networking and IRC handling
This commit is contained in:
Dpeta 2023-02-12 22:56:55 +01:00 committed by GitHub
commit ca992feb21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1366 additions and 2490 deletions

View file

@ -235,6 +235,7 @@ class PesterTabWindow(QtWidgets.QFrame):
i, QtGui.QColor(self.mainwindow.theme["%s/tabs/newmsgcolor" % (self.type)])
)
convo = self.convos[handle]
# Create a function for the icon to use
# TODO: Let us disable this.
def func():
@ -286,7 +287,7 @@ class PesterTabWindow(QtWidgets.QFrame):
del self.convos[handle]
del self.tabIndices[handle]
self.tabs.removeTab(i)
for (h, j) in self.tabIndices.items():
for h, j in self.tabIndices.items():
if j > i:
self.tabIndices[h] = j - 1
self.layout.removeWidget(convo)
@ -954,6 +955,7 @@ class PesterConvo(QtWidgets.QFrame):
else:
self.newmessage = True
self.setWindowTitle(title + "*")
# karxi: The order of execution here is a bit unclear...I'm not
# entirely sure how much of this directly affects what we see.
def func():

View file

@ -146,7 +146,7 @@ class pesterQuirks:
# suffix = [q for q in self.quirklist if q.type == "suffix"]
newlist = []
for (i, o) in enumerate(lexed):
for i, o in enumerate(lexed):
if type(o) not in [str, str]:
if i == 0:
string = " "

View file

@ -119,7 +119,7 @@ class MultiTextDialog(QtWidgets.QDialog):
r = self.exec()
if r == QtWidgets.QDialog.DialogCode.Accepted:
retval = {}
for (name, widget) in self.inputs.items():
for name, widget in self.inputs.items():
retval[name] = str(widget.text())
return retval
else:

1940
irc.py

File diff suppressed because it is too large Load diff

View file

@ -194,7 +194,7 @@ SYSTEM = [
"umount2",
"vhangup",
]
CALL_BLACKLIST = SETUID + SYSTEM
CALL_BLACKLIST = SYSTEM # + SETUID
"""
# Optional

View file

@ -78,7 +78,7 @@ class PesterLogUserSelect(QtWidgets.QDialog):
self.chumsBox.setStyleSheet(self.theme["main/chums/style"])
self.chumsBox.optionsMenu = QtWidgets.QMenu(self)
for (_, t) in enumerate(chumMemoList):
for _, t in enumerate(chumMemoList):
item = QtWidgets.QListWidgetItem(t)
item.setForeground(
QtGui.QBrush(QtGui.QColor(self.theme["main/chums/userlistcolor"]))
@ -244,7 +244,7 @@ class PesterLogViewer(QtWidgets.QDialog):
child_1 = None
last = ["", ""]
# blackbrush = QtGui.QBrush(QtCore.Qt.GlobalColor.black)
for (i, l) in enumerate(self.logList):
for i, l in enumerate(self.logList):
my = self.fileToMonthYear(l)
if my[0] != last[0]:
child_1 = QtWidgets.QTreeWidgetItem(["{} {}".format(my[0], my[1])])

View file

@ -973,6 +973,7 @@ class PesterMemo(PesterConvo):
self.userlist.addItem(u)
def updateChanModes(self, modes, op):
PchumLog.debug("updateChanModes(%s, %s)", modes, op)
if not hasattr(self, "modes"):
self.modes = ""
chanmodes = list(str(self.modes))
@ -1413,8 +1414,8 @@ class PesterMemo(PesterConvo):
@QtCore.pyqtSlot(QString, QString)
def modesUpdated(self, channel, modes):
c = str(channel)
if c.lower() == self.channel.lower():
PchumLog.debug(f"modesUpdated(%s, %s)", channel, modes)
if channel.lower() == self.channel.lower():
self.updateChanModes(modes, None)
@QtCore.pyqtSlot(QString)
@ -1871,9 +1872,7 @@ class PesterMemo(PesterConvo):
self, "Ban User", "Enter the reason you are banning this user (optional):"
)
if ok:
self.mainwindow.kickUser.emit(
"{}:{}".format(currentHandle, reason), self.channel
)
self.mainwindow.kickUser.emit(currentHandle, reason, self.channel)
@QtCore.pyqtSlot()
def opSelectedUser(self):

View file

@ -576,7 +576,7 @@ class PesterQuirkTypes(QtWidgets.QDialog):
if quirk:
types = ["prefix", "suffix", "replace", "regexp", "random", "spelling"]
for (i, r) in enumerate(self.radios):
for i, r in enumerate(self.radios):
if i == types.index(quirk.quirk.type):
r.setChecked(True)
self.changePage(types.index(quirk.quirk.type) + 1)
@ -648,7 +648,7 @@ class PesterQuirkTypes(QtWidgets.QDialog):
return
cur = self.pages.currentIndex()
if cur == 0:
for (i, r) in enumerate(self.radios):
for i, r in enumerate(self.radios):
if r.isChecked():
self.changePage(i + 1)
else:
@ -933,7 +933,7 @@ class PesterChooseTheme(QtWidgets.QDialog):
avail_themes = config.availableThemes()
self.themeBox = QtWidgets.QComboBox(self)
for (i, t) in enumerate(avail_themes):
for i, t in enumerate(avail_themes):
self.themeBox.addItem(t)
if t == theme.name:
self.themeBox.setCurrentIndex(i)
@ -1416,7 +1416,7 @@ class PesterOptions(QtWidgets.QDialog):
avail_themes = self.config.availableThemes()
self.themeBox = QtWidgets.QComboBox(self)
notheme = theme.name not in avail_themes
for (i, t) in enumerate(avail_themes):
for i, t in enumerate(avail_themes):
self.themeBox.addItem(t)
if (not notheme and t == theme.name) or (notheme and t == "pesterchum"):
self.themeBox.setCurrentIndex(i)
@ -1454,7 +1454,7 @@ class PesterOptions(QtWidgets.QDialog):
types = self.parent().tm.availableTypes()
cur = self.parent().tm.currentType()
self.notifyOptions.addItems(types)
for (i, t) in enumerate(types):
for i, t in enumerate(types):
if t == cur:
self.notifyOptions.setCurrentIndex(i)
break

View file

@ -7,8 +7,8 @@ kbloc = [
[x for x in "zxcvbnm,.>/?"],
]
kbdict = {}
for (i, l) in enumerate(kbloc):
for (j, k) in enumerate(l):
for i, l in enumerate(kbloc):
for j, k in enumerate(l):
kbdict[k] = (i, j)
sounddict = {

View file

@ -61,11 +61,15 @@ class Mood:
}
def __init__(self, mood):
if type(mood) is int:
if isinstance(mood, int):
self.mood = mood
else:
self.mood = self.moods.index(mood)
def value_str(self):
"""Return mood index as str."""
return str(self.mood)
def value(self):
return self.mood

View file

@ -1,489 +0,0 @@
# Copyright (c) 2008 Duncan Fordyce
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import sys
import time
import ssl
import socket
import select
import logging
import datetime
import traceback
from oyoyo.parse import parse_raw_irc_command
from oyoyo import helpers
from oyoyo.cmdhandler import CommandError
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."
)
class IRCClientError(Exception):
pass
class IRCClient:
"""IRC Client class. This handles one connection to a server.
This can be used either with or without IRCApp ( see connect() docs )
"""
def __init__(self, cmd_handler, **kwargs):
"""the first argument should be an object with attributes/methods named
as the irc commands. You may subclass from one of the classes in
oyoyo.cmdhandler for convenience but it is not required. The
methods should have arguments (prefix, args). prefix is
normally the sender of the command. args is a list of arguments.
Its recommened you subclass oyoyo.cmdhandler.DefaultCommandHandler,
this class provides defaults for callbacks that are required for
normal IRC operation.
all other arguments should be keyword arguments. The most commonly
used will be nick, host and port. You can also specify an "on connect"
callback. ( check the source for others )
Warning: By default this class will not block on socket operations, this
means if you use a plain while loop your app will consume 100% cpu.
To enable blocking pass blocking=True.
>>> class My_Handler(DefaultCommandHandler):
... def privmsg(self, prefix, command, args):
... print "%s said %s" % (prefix, args[1])
...
>>> def connect_callback(c):
... helpers.join(c, '#myroom')
...
>>> cli = IRCClient(My_Handler,
... host="irc.freenode.net",
... port=6667,
... nick="myname",
... connect_cb=connect_callback)
...
>>> cli_con = cli.connect()
>>> while 1:
... cli_con.next()
...
"""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.nick = None
self.realname = None
self.username = None
self.host = None
self.port = None
# self.connect_cb = None
self.timeout = None
self.blocking = None
self.ssl = None
self.__dict__.update(kwargs)
self.command_handler = cmd_handler(self)
self._end = False
def send(self, *args, **kwargs):
"""send a message to the connected server. all arguments are joined
with a space for convenience, for example the following are identical
>>> cli.send("JOIN %s" % some_room)
>>> cli.send("JOIN", some_room)
In python 2, all args must be of type str or unicode, *BUT* if they are
unicode they will be converted to str with the encoding specified by
the 'encoding' keyword argument (default 'utf8').
In python 3, all args must be of type str or bytes, *BUT* if they are
str they will be converted to bytes with the encoding specified by the
'encoding' keyword argument (default 'utf8').
"""
if self._end == True:
return
# Convert all args to bytes if not already
encoding = kwargs.get("encoding") or "utf8"
bargs = []
for arg in args:
if isinstance(arg, str):
bargs.append(bytes(arg, encoding))
elif isinstance(arg, bytes):
bargs.append(arg)
elif type(arg).__name__ == "unicode":
bargs.append(arg.encode(encoding))
else:
PchumLog.warning(
"Refusing to send one of the args from provided: %s"
% repr([(type(arg), arg) for arg in args])
)
raise IRCClientError(
"Refusing to send one of the args from provided: %s"
% repr([(type(arg), arg) for arg in args])
)
msg = bytes(" ", "UTF-8").join(bargs)
PchumLog.info('---> send "%s"' % msg)
try:
tries = 1
while tries < 10:
# Check if alive
if self._end == True:
break
if self.socket.fileno() == -1:
self._end = True
break
try:
ready_to_read, ready_to_write, in_error = select.select(
[], [self.socket], []
)
for x in ready_to_write:
x.sendall(msg + bytes("\r\n", "UTF-8"))
break
except ssl.SSLWantReadError as e:
PchumLog.warning("ssl.SSLWantReadError on send, " + str(e))
select.select([self.socket], [], [])
if tries >= 9:
raise e
except ssl.SSLWantWriteError as e:
PchumLog.warning("ssl.SSLWantWriteError on send, " + str(e))
select.select([], [self.socket], [])
if tries >= 9:
raise e
except ssl.SSLEOFError as e:
# ssl.SSLEOFError guarantees a broken connection.
PchumLog.warning("ssl.SSLEOFError in on send, " + str(e))
raise ssl.SSLEOFError
except (socket.timeout, TimeoutError) as e:
# socket.timeout is deprecated in 3.10
PchumLog.warning("TimeoutError in on send, " + str(e))
raise socket.timeout
except (OSError, IndexError, ValueError, Exception) as e:
PchumLog.warning("Unkown error on send, " + str(e))
if tries >= 9:
raise e
tries += 1
PchumLog.warning("Retrying send. (attempt %s)" % str(tries))
time.sleep(0.1)
PchumLog.debug(
"ready_to_write (len %s): " % str(len(ready_to_write))
+ str(ready_to_write)
)
except Exception as se:
PchumLog.warning("Send Exception %s" % str(se))
try:
if not self.blocking and se.errno == 11:
pass
else:
# raise se
self._end = True # This ok?
except AttributeError:
# raise se
self._end = True # This ok?
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):
"""initiates the connection to the server set in self.host:self.port
self.ssl decides whether the connection uses ssl.
Certificate validation when using SSL/TLS may be disabled by
passing the 'verify_hostname' parameter. The user is asked if they
want to disable it if this functions raises a certificate validation error,
in which case the function may be called again with 'verify_hostname'."""
PchumLog.info("connecting to {}:{}".format(self.host, self.port))
# Open connection
plaintext_socket = socket.create_connection((self.host, self.port))
if self.ssl:
# Upgrade connection to use SSL/TLS if enabled
context = self.get_ssl_context()
context.check_hostname = verify_hostname
self.socket = context.wrap_socket(
plaintext_socket, server_hostname=self.host
)
else:
# SSL/TLS is disabled, connection is plaintext
self.socket = plaintext_socket
# setblocking is a shorthand for timeout,
# we shouldn't use both.
if self.timeout:
self.socket.settimeout(self.timeout)
elif not self.blocking:
self.socket.setblocking(False)
elif self.blocking:
self.socket.setblocking(True)
helpers.nick(self, self.nick)
helpers.user(self, self.username, self.realname)
# if self.connect_cb:
# self.connect_cb(self)
def conn(self):
"""returns a generator object."""
try:
buffer = b""
while not self._end:
# Block for connection-killing exceptions
try:
tries = 1
while tries < 10:
# Check if alive
if self._end == True:
break
if self.socket.fileno() == -1:
self._end = True
break
try:
ready_to_read, ready_to_write, in_error = select.select(
[self.socket], [], []
)
for x in ready_to_read:
buffer += x.recv(1024)
break
except ssl.SSLWantReadError as e:
PchumLog.warning("ssl.SSLWantReadError on send, " + str(e))
select.select([self.socket], [], [])
if tries >= 9:
raise e
except ssl.SSLWantWriteError as e:
PchumLog.warning("ssl.SSLWantWriteError on send, " + str(e))
select.select([], [self.socket], [])
if tries >= 9:
raise e
except ssl.SSLEOFError as e:
# ssl.SSLEOFError guarantees a broken connection.
PchumLog.warning("ssl.SSLEOFError in on send, " + str(e))
raise e
except (socket.timeout, TimeoutError) as e:
# socket.timeout is deprecated in 3.10
PchumLog.warning("TimeoutError in on send, " + str(e))
raise socket.timeout
except (OSError, IndexError, ValueError, Exception) as e:
PchumLog.debug("Miscellaneous exception in conn, " + str(e))
if tries >= 9:
raise e
tries += 1
PchumLog.debug(
"Possibly retrying recv. (attempt %s)" % str(tries)
)
time.sleep(0.1)
except socket.timeout as e:
PchumLog.warning("timeout in client.py, " + str(e))
if self._end:
break
raise e
except ssl.SSLEOFError as e:
raise e
except OSError as e:
PchumLog.warning("conn exception {} in {}".format(e, self))
if self._end:
break
if not self.blocking and e.errno == 11:
pass
else:
raise e
else:
if self._end:
break
if len(buffer) == 0 and self.blocking:
PchumLog.debug("len(buffer) = 0")
raise OSError("Connection closed")
data = buffer.split(bytes("\n", "UTF-8"))
buffer = data.pop()
PchumLog.debug("data = " + str(data))
for el in data:
tags, prefix, command, args = parse_raw_irc_command(el)
# print(tags, prefix, command, args)
try:
# Only need tags with tagmsg
if command.upper() == "TAGMSG":
self.command_handler.run(command, prefix, tags, *args)
else:
self.command_handler.run(command, prefix, *args)
except CommandError as e:
PchumLog.warning("CommandError %s" % str(e))
yield True
except socket.timeout as se:
PchumLog.debug("passing timeout")
raise se
except (OSError, ssl.SSLEOFError) as se:
PchumLog.debug("problem: %s" % (str(se)))
if self.socket:
PchumLog.info("error: closing socket")
self.socket.close()
raise se
except Exception as e:
PchumLog.debug("other exception: %s" % str(e))
raise e
else:
PchumLog.debug("ending while, end is %s" % self._end)
if self.socket:
PchumLog.info("finished: closing socket")
self.socket.close()
yield False
def close(self):
# with extreme prejudice
if self.socket:
PchumLog.info("shutdown socket")
# print("shutdown socket")
self._end = True
try:
self.socket.shutdown(socket.SHUT_RDWR)
except OSError as e:
PchumLog.debug(
"Error while shutting down socket, already broken? %s" % str(e)
)
try:
self.socket.close()
except OSError as e:
PchumLog.debug(
"Error while closing socket, already broken? %s" % str(e)
)
class IRCApp:
"""This class manages several IRCClient instances without the use of threads.
(Non-threaded) Timer functionality is also included.
"""
class _ClientDesc:
def __init__(self, **kwargs):
self.con = None
self.autoreconnect = False
self.__dict__.update(kwargs)
def __init__(self):
self._clients = {}
self._timers = []
self.running = False
self.sleep_time = 0.5
def addClient(self, client, autoreconnect=False):
"""add a client object to the application. setting autoreconnect
to true will mean the application will attempt to reconnect the client
after every disconnect. you can also set autoreconnect to a number
to specify how many reconnects should happen.
warning: if you add a client that has blocking set to true,
timers will no longer function properly"""
PchumLog.info("added client {} (ar={})".format(client, autoreconnect))
self._clients[client] = self._ClientDesc(autoreconnect=autoreconnect)
def addTimer(self, seconds, cb):
"""add a timed callback. accuracy is not specified, you can only
garuntee the callback will be called after seconds has passed.
( the only advantage to these timers is they dont use threads )
"""
assert callable(cb)
PchumLog.info("added timer to call {} in {}s".format(cb, seconds))
self._timers.append((time.time() + seconds, cb))
def run(self):
"""run the application. this will block until stop() is called"""
# TODO: convert this to use generators too?
self.running = True
while self.running:
found_one_alive = False
for client, clientdesc in self._clients.items():
if clientdesc.con is None:
clientdesc.con = client.connect()
try:
next(clientdesc.con)
except Exception as e:
PchumLog.error("client error %s" % str(e))
PchumLog.error(traceback.format_exc())
if clientdesc.autoreconnect:
clientdesc.con = None
if isinstance(clientdesc.autoreconnect, (int, float)):
clientdesc.autoreconnect -= 1
found_one_alive = True
else:
clientdesc.con = False
else:
found_one_alive = True
if not found_one_alive:
PchumLog.info("nothing left alive... quiting")
self.stop()
now = time.time()
timers = self._timers[:]
self._timers = []
for target_time, cb in timers:
if now > target_time:
PchumLog.info("calling timer cb %s" % cb)
cb()
else:
self._timers.append((target_time, cb))
time.sleep(self.sleep_time)
def stop(self):
"""stop the application"""
self.running = False

View file

@ -1,212 +0,0 @@
# Copyright (c) 2008 Duncan Fordyce
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import logging
import inspect
from oyoyo import helpers
from oyoyo.parse import parse_nick
PchumLog = logging.getLogger("pchumLogger")
def protected(func):
"""decorator to protect functions from being called"""
func.protected = True
return func
class CommandError(Exception):
def __init__(self, cmd):
self.cmd = cmd
class NoSuchCommandError(CommandError):
def __str__(self):
return 'No such command "%s"' % ".".join(self.cmd)
class ProtectedCommandError(CommandError):
def __str__(self):
return 'Command "%s" is protected' % ".".join(self.cmd)
class CommandHandler:
"""The most basic CommandHandler"""
def __init__(self, client):
self.client = client
@protected
def get(self, in_command_parts):
PchumLog.debug("in_command_parts: %s" % in_command_parts)
""" finds a command
commands may be dotted. each command part is checked that it does
not start with and underscore and does not have an attribute
"protected". if either of these is true, ProtectedCommandError
is raised.
its possible to pass both "command.sub.func" and
["command", "sub", "func"].
"""
if isinstance(in_command_parts, (str, bytes)):
in_command_parts = in_command_parts.split(".")
command_parts = in_command_parts[:]
p = self
while command_parts:
cmd = command_parts.pop(0)
if cmd.startswith("_"):
raise ProtectedCommandError(in_command_parts)
try:
f = getattr(p, cmd)
except AttributeError:
raise NoSuchCommandError(in_command_parts)
if hasattr(f, "protected"):
raise ProtectedCommandError(in_command_parts)
if isinstance(f, CommandHandler) and command_parts:
return f.get(command_parts)
p = f
return f
@protected
def run(self, command, *args):
"""finds and runs a command"""
arguments_str = ""
for x in args:
arguments_str += str(x) + " "
PchumLog.debug("processCommand {}({})".format(command, arguments_str.strip()))
try:
f = self.get(command)
except NoSuchCommandError as e:
PchumLog.info(e)
self.__unhandled__(command, *args)
return
PchumLog.debug("f %s" % f)
try:
f(*args)
except TypeError as e:
PchumLog.info(
"Failed to pass command, did the server pass an unsupported paramater? "
+ str(e)
)
except Exception as e:
# logging.info("Failed to pass command, %s" % str(e))
PchumLog.exception("Failed to pass command")
@protected
def __unhandled__(self, cmd, *args):
"""The default handler for commands. Override this method to
apply custom behavior (example, printing) unhandled commands.
"""
PchumLog.debug("unhandled command {}({})".format(cmd, args))
class DefaultCommandHandler(CommandHandler):
"""CommandHandler that provides methods for the normal operation of IRC.
If you want your bot to properly respond to pings, etc, you should subclass this.
"""
def ping(self, prefix, server):
self.client.send("PONG", server)
class DefaultBotCommandHandler(CommandHandler):
"""default command handler for bots. methods/attributes are made
available as commands"""
@protected
def getVisibleCommands(self, obj=None):
test = (
lambda x: isinstance(x, CommandHandler)
or inspect.ismethod(x)
or inspect.isfunction(x)
)
members = inspect.getmembers(obj or self, test)
return [
m
for m, _ in members
if (not m.startswith("_") and not hasattr(getattr(obj, m), "protected"))
]
def help(self, sender, dest, arg=None):
"""list all available commands or get help on a specific command"""
PchumLog.info("help sender={} dest={} arg={}".format(sender, dest, arg))
if not arg:
commands = self.getVisibleCommands()
commands.sort()
helpers.msg(
self.client, dest, "available commands: %s" % " ".join(commands)
)
else:
try:
f = self.get(arg)
except CommandError as e:
helpers.msg(self.client, dest, str(e))
return
doc = f.__doc__.strip() if f.__doc__ else "No help available"
if not inspect.ismethod(f):
subcommands = self.getVisibleCommands(f)
if subcommands:
doc += " [sub commands: %s]" % " ".join(subcommands)
helpers.msg(self.client, dest, "{}: {}".format(arg, doc))
class BotCommandHandler(DefaultCommandHandler):
"""complete command handler for bots"""
def __init__(self, client, command_handler):
DefaultCommandHandler.__init__(self, client)
self.command_handler = command_handler
def privmsg(self, prefix, dest, msg):
self.tryBotCommand(prefix, dest, msg)
@protected
def tryBotCommand(self, prefix, dest, msg):
"""tests a command to see if its a command for the bot, returns True
and calls self.processBotCommand(cmd, sender) if its is.
"""
PchumLog.debug("tryBotCommand('{}' '{}' '{}')".format(prefix, dest, msg))
if dest == self.client.nick:
dest = parse_nick(prefix)[0]
elif msg.startswith(self.client.nick):
msg = msg[len(self.client.nick) + 1 :]
else:
return False
msg = msg.strip()
parts = msg.split(" ", 1)
command = parts[0]
arg = parts[1:]
try:
self.command_handler.run(command, prefix, dest, *arg)
except CommandError as e:
helpers.msg(self.client, dest, str(e))
return True

View file

@ -1,159 +0,0 @@
# Copyright (c) 2008 Duncan Fordyce
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
""" contains helper functions for common irc commands """
import logging
import random
PchumLog = logging.getLogger("pchumLogger")
def msg(cli, user, msg):
for line in msg.split("\n"):
cli.send("PRIVMSG", user, ":%s" % line)
def names(cli, *channels):
tmp = __builtins__["list"](channels)
msglist = []
while len(tmp) > 0:
msglist.append(tmp.pop())
if len(",".join(msglist)) > 490:
tmp.append(msglist.pop())
cli.send("NAMES %s" % (",".join(msglist)))
msglist = []
if len(msglist) > 0:
cli.send("NAMES %s" % (",".join(msglist)))
def channel_list(cli):
cli.send("LIST")
def kick(cli, handle, channel, reason=""):
cli.send("KICK {} {} {}".format(channel, handle, reason))
def mode(cli, channel, mode, options=None):
PchumLog.debug("mode = " + str(mode))
PchumLog.debug("options = " + str(options))
cmd = "MODE {} {}".format(channel, mode)
if options:
cmd += " %s" % (options)
cli.send(cmd)
def ctcp(cli, handle, cmd, msg=""):
# Space breaks protocol if msg is absent
if msg == "":
cli.send("PRIVMSG", handle, "\x01%s\x01" % (cmd))
else:
cli.send("PRIVMSG", handle, "\x01{} {}\x01".format(cmd, msg))
def ctcp_reply(cli, handle, cmd, msg=""):
notice(cli, str(handle), "\x01{} {}\x01".format(cmd.upper(), msg))
def metadata(cli, target, subcommand, *params):
# IRC metadata draft specification
# https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237
cli.send("METADATA", target, subcommand, *params)
def cap(cli, subcommand, *params):
# Capability Negotiation
# https://ircv3.net/specs/extensions/capability-negotiation.html
cli.send("CAP", subcommand, *params)
def msgrandom(cli, choices, dest, user=None):
o = "%s: " % user if user else ""
o += random.choice(choices)
msg(cli, dest, o)
def _makeMsgRandomFunc(choices):
def func(cli, dest, user=None):
msgrandom(cli, choices, dest, user)
return func
msgYes = _makeMsgRandomFunc(["yes", "alright", "ok"])
msgOK = _makeMsgRandomFunc(["ok", "done"])
msgNo = _makeMsgRandomFunc(["no", "no-way"])
def ns(cli, *args):
msg(cli, "NickServ", " ".join(args))
def cs(cli, *args):
msg(cli, "ChanServ", " ".join(args))
def identify(cli, passwd, authuser="NickServ"):
msg(cli, authuser, "IDENTIFY %s" % passwd)
def quit(cli, msg):
cli.send("QUIT %s" % (msg))
def nick(cli, nick):
cli.send("NICK", nick)
def user(cli, username, realname):
cli.send("USER", username, "0", "*", ":" + realname)
def join(cli, channel):
"""Protocol potentially allows multiple channels or keys."""
cli.send("JOIN", channel)
def part(cli, channel):
cli.send("PART", channel)
def notice(cli, target, text):
cli.send("NOTICE", target, text)
def invite(cli, nick, channel):
cli.send("INVITE", nick, channel)
def _addNumerics():
import sys
from oyoyo import ircevents
def numericcmd(cmd_num, cmd_name):
def f(cli, *args):
cli.send(cmd_num, *args)
return f
m = sys.modules[__name__]
for num, name in ircevents.numeric_events.items():
setattr(m, name, numericcmd(num, name))
_addNumerics()

View file

@ -1,232 +0,0 @@
# Copyright (c) 2008 Duncan Fordyce
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# taken from python irclib.. who took it from...
# Numeric table mostly stolen from the Perl IRC module (Net::IRC).
numeric_events = {
"001": "welcome",
"002": "yourhost",
"003": "created",
"004": "myinfo",
"005": "featurelist", # XXX
"010": "toomanypeeps",
"200": "tracelink",
"201": "traceconnecting",
"202": "tracehandshake",
"203": "traceunknown",
"204": "traceoperator",
"205": "traceuser",
"206": "traceserver",
"207": "traceservice",
"208": "tracenewtype",
"209": "traceclass",
"210": "tracereconnect",
"211": "statslinkinfo",
"212": "statscommands",
"213": "statscline",
"214": "statsnline",
"215": "statsiline",
"216": "statskline",
"217": "statsqline",
"218": "statsyline",
"219": "endofstats",
"221": "umodeis",
"231": "serviceinfo",
"232": "endofservices",
"233": "service",
"234": "servlist",
"235": "servlistend",
"241": "statslline",
"242": "statsuptime",
"243": "statsoline",
"244": "statshline",
"250": "luserconns",
"251": "luserclient",
"252": "luserop",
"253": "luserunknown",
"254": "luserchannels",
"255": "luserme",
"256": "adminme",
"257": "adminloc1",
"258": "adminloc2",
"259": "adminemail",
"261": "tracelog",
"262": "endoftrace",
"263": "tryagain",
"265": "n_local",
"266": "n_global",
"300": "none",
"301": "away",
"302": "userhost",
"303": "ison",
"305": "unaway",
"306": "nowaway",
"311": "whoisuser",
"312": "whoisserver",
"313": "whoisoperator",
"314": "whowasuser",
"315": "endofwho",
"316": "whoischanop",
"317": "whoisidle",
"318": "endofwhois",
"319": "whoischannels",
"321": "liststart",
"322": "list",
"323": "listend",
"324": "channelmodeis",
"329": "channelcreate",
"331": "notopic",
"332": "currenttopic",
"333": "topicinfo",
"341": "inviting",
"342": "summoning",
"346": "invitelist",
"347": "endofinvitelist",
"348": "exceptlist",
"349": "endofexceptlist",
"351": "version",
"352": "whoreply",
"353": "namreply",
"361": "killdone",
"362": "closing",
"363": "closeend",
"364": "links",
"365": "endoflinks",
"366": "endofnames",
"367": "banlist",
"368": "endofbanlist",
"369": "endofwhowas",
"371": "info",
"372": "motd",
"373": "infostart",
"374": "endofinfo",
"375": "motdstart",
"376": "endofmotd",
"377": "motd2", # 1997-10-16 -- tkil
"381": "youreoper",
"382": "rehashing",
"384": "myportis",
"391": "time",
"392": "usersstart",
"393": "users",
"394": "endofusers",
"395": "nousers",
"396": "hosthidden",
"401": "nosuchnick",
"402": "nosuchserver",
"403": "nosuchchannel",
"404": "cannotsendtochan",
"405": "toomanychannels",
"406": "wasnosuchnick",
"407": "toomanytargets",
"409": "noorigin",
"411": "norecipient",
"412": "notexttosend",
"413": "notoplevel",
"414": "wildtoplevel",
"421": "unknowncommand",
"422": "nomotd",
"423": "noadmininfo",
"424": "fileerror",
"431": "nonicknamegiven",
"432": "erroneusnickname", # Thiss iz how its speld in thee RFC.
"433": "nicknameinuse",
"436": "nickcollision",
"437": "unavailresource", # "Nick temporally unavailable"
"441": "usernotinchannel",
"442": "notonchannel",
"443": "useronchannel",
"444": "nologin",
"445": "summondisabled",
"446": "usersdisabled",
"451": "notregistered",
"461": "needmoreparams",
"462": "alreadyregistered",
"463": "nopermforhost",
"464": "passwdmismatch",
"465": "yourebannedcreep", # I love this one...
"466": "youwillbebanned",
"467": "keyset",
"471": "channelisfull",
"472": "unknownmode",
"473": "inviteonlychan",
"474": "bannedfromchan",
"475": "badchannelkey",
"476": "badchanmask",
"477": "nochanmodes", # "Channel doesn't support modes"
"478": "banlistfull",
"481": "noprivileges",
"482": "chanoprivsneeded",
"483": "cantkillserver",
"484": "restricted", # Connection is restricted
"485": "uniqopprivsneeded",
"491": "nooperhost",
"492": "noservicehost",
"501": "umodeunknownflag",
"502": "usersdontmatch",
}
# Unrealircd extras
unrealircd_events = {
"448": "forbiddenchannel",
}
numeric_events.update(unrealircd_events)
# IRC metadata draft specification
# https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237
metadata_numeric_events = {
"761": "keyvalue",
"762": "metadataend",
"766": "nomatchingkey",
"768": "keynotset",
"769": "keynopermission",
"770": "metadatasubok",
}
numeric_events.update(metadata_numeric_events)
generated_events = [
# Generated events
"dcc_connect",
"dcc_disconnect",
"dccmsg",
"disconnect",
"ctcp",
"ctcpreply",
]
protocol_events = [
# IRC protocol events
"error",
"join",
"kick",
"mode",
"part",
"ping",
"privmsg",
"privnotice",
"pubmsg",
"pubnotice",
"quit",
"invite",
"pong",
"nick", # We can get svsnicked
"metadata", # Metadata specification
"tagmsg", # IRCv3 message tags extension
"cap", # IRCv3 Client Capability Negotiation
]
all_events = generated_events + protocol_events + list(numeric_events.values())

View file

@ -1,121 +0,0 @@
# Copyright (c) 2008 Duncan Fordyce
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import logging
from oyoyo.ircevents import numeric_events
PchumLog = logging.getLogger("pchumLogger")
def parse_raw_irc_command(element):
"""
This function parses a raw irc command and returns a tuple
of (prefix, command, args).
The following is a psuedo BNF of the input text:
<message> ::= [':' <prefix> <SPACE> ] <command> <params> <crlf>
<prefix> ::= <servername> | <nick> [ '!' <user> ] [ '@' <host> ]
<command> ::= <letter> { <letter> } | <number> <number> <number>
<SPACE> ::= ' ' { ' ' }
<params> ::= <SPACE> [ ':' <trailing> | <middle> <params> ]
<middle> ::= <Any *non-empty* sequence of octets not including SPACE
or NUL or CR or LF, the first of which may not be ':'>
<trailing> ::= <Any, possibly *empty*, sequence of octets not including
NUL or CR or LF>
<crlf> ::= CR LF
"""
"""
When message-tags are enabled, the message pseudo-BNF,
as defined in RFC 1459, section 2.3.1 is extended as follows:
<message> ::= ['@' <tags> <SPACE>] [':' <prefix> <SPACE> ] <command> [params] <crlf>
<tags> ::= <tag> [';' <tag>]*
<tag> ::= <key> ['=' <escaped_value>]
<key> ::= [ <client_prefix> ] [ <vendor> '/' ] <key_name>
<client_prefix> ::= '+'
<key_name> ::= <non-empty sequence of ascii letters, digits, hyphens ('-')>
<escaped_value> ::= <sequence of zero or more utf8 characters except NUL, CR, LF, semicolon (`;`) and SPACE>
<vendor> ::= <host>
"""
try:
element = element.decode("utf-8")
except UnicodeDecodeError as e:
PchumLog.debug("utf-8 error %s" % str(e))
element = element.decode("latin-1", "replace")
parts = element.strip().split(" ")
if parts[0].startswith(":"):
tags = None
prefix = parts[0][1:]
command = parts[1]
args = parts[2:]
elif parts[0].startswith("@"):
# Message tag
tags = parts[0]
prefix = parts[1][1:]
command = parts[2]
args = parts[3:]
else:
tags = None
prefix = None
command = parts[0]
args = parts[1:]
if command.isdigit():
try:
command = numeric_events[command]
except KeyError:
PchumLog.info("unknown numeric event %s" % command)
command = command.lower()
if args[0].startswith(":"):
args = [" ".join(args)[1:]]
else:
for idx, arg in enumerate(args):
if arg.startswith(":"):
args = args[:idx] + [" ".join(args[idx:])[1:]]
break
return (tags, prefix, command, args)
def parse_nick(name):
"""parse a nickname and return a tuple of (nick, mode, user, host)
<nick> [ '!' [<mode> = ] <user> ] [ '@' <host> ]
"""
try:
nick, rest = name.split("!")
except ValueError:
return (name, None, None, None)
try:
mode, rest = rest.split("=")
except ValueError:
mode, rest = None, rest
try:
user, host = rest.split("@")
except ValueError:
return (name, mode, rest, None)
return (name, mode, user, host)

View file

@ -1,125 +0,0 @@
# NickServ basic functions
_nickservfuncs = (
"register",
"group",
"glist",
"identify",
"access",
"drop",
"recover",
"release",
"sendpass",
"ghost",
"alist",
"info",
"list",
"logout",
"status",
"update",
)
# NickServ SET functions
_nickservsetfuncs = (
"display",
"password",
"language",
"url",
"email",
"icq",
"greet",
"kill",
"secure",
"private",
"hide",
"msg",
"autoop",
)
# ChanServ basic functions
_chanservfuncs = (
"register",
"identify",
"sop",
"aop",
"hop",
"vop",
"access",
"levels",
"akick",
"drop",
"sendpass",
"ban",
"unban",
"clear",
"owner",
"deowner",
"protect",
"deprotect",
"op",
"deop",
"halfop",
"dehalfop",
"voice",
"devoice",
"getkey",
"invite",
"kick",
"list",
"logout",
"topic",
"info",
"appendtopic",
"enforce",
)
_chanservsetfuncs = (
"founder",
"successor",
"password",
"desc",
"url",
"email",
"entrymsg",
"bantype",
"mlock",
"keeptopic",
"opnotice",
"peace",
"private",
"restricted",
"secure",
"secureops",
"securefounder",
"signkick",
"topiclock",
"xop",
)
def _addServ(serv, funcs, prefix=""):
def simplecmd(cmd_name):
if prefix:
cmd_name = prefix.upper() + " " + cmd_name
def f(cli, *args):
print(cmd_name, " ".join(args))
# cli.send(cmd_name, serv.name, *args)
return f
for t in funcs:
setattr(serv, t, simplecmd(t.upper()))
class NickServ:
def __init__(self, nick="NickServ"):
self.name = nick
_addServ(self, _nickservfuncs)
_addServ(self, _nickservsetfuncs, "set")
class ChanServ:
def __init__(self, nick="ChanServ"):
self.name = nick
_addServ(self, _chanservfuncs)
_addServ(self, _chanservsetfuncs, "set")

View file

@ -64,9 +64,9 @@ def reloadQuirkFunctions():
def lexer(string, objlist):
"""objlist is a list: [(objecttype, re),...] list is in order of preference"""
stringlist = [string]
for (oType, regexp) in objlist:
for oType, regexp in objlist:
newstringlist = []
for (stri, s) in enumerate(stringlist):
for stri, s in enumerate(stringlist):
if type(s) not in [str, str]:
newstringlist.append(s)
continue
@ -339,7 +339,7 @@ def convertTags(lexed, format="html"):
lexed = lexMessage(lexed)
escaped = ""
# firststr = True
for (i, o) in enumerate(lexed):
for i, o in enumerate(lexed):
if type(o) in [str, str]:
if format == "html":
escaped += (
@ -429,9 +429,7 @@ def kxsplitMsg(lexed, ctx, fmt="pchum", maxlen=None, debug=False):
curlen = 0
# Maximum number of characters *to* use.
if not maxlen:
maxlen = _max_msg_len(
None, None, ctx.mainwindow.profile().handle, ctx.mainwindow.irc.cli.realname
)
maxlen = _max_msg_len(None, None, ctx.mainwindow.profile().handle, "pcc31")
elif maxlen < 0:
# Subtract the (negative) length, giving us less leeway in this
# function.
@ -440,7 +438,7 @@ def kxsplitMsg(lexed, ctx, fmt="pchum", maxlen=None, debug=False):
None,
None,
ctx.mainwindow.profile().handle,
ctx.mainwindow.irc.cli.realname,
"pcc31",
)
+ maxlen
)
@ -805,11 +803,9 @@ def kxhandleInput(ctx, text=None, flavor=None):
# We'll use those later.
# Split the messages so we don't go over the buffer and lose text.
maxlen = _max_msg_len(
None, None, ctx.mainwindow.profile().handle, ctx.mainwindow.irc.cli.realname
)
maxlen = _max_msg_len(None, None, ctx.mainwindow.profile().handle, "pcc31")
# ctx.mainwindow.profile().handle ==> Get handle
# ctx.mainwindow.irc.cli.realname ==> Get ident (Same as realname in this case.)
# "pcc31" ==> Get ident (Same as realname in this case.)
# Since we have to do some post-processing, we need to adjust the maximum
# length we can use.
if flavor == "convo":

View file

@ -2971,7 +2971,7 @@ class PesterWindow(MovingWindow):
# Tell everyone we're in a chat with that we just went idle.
sysColor = QtGui.QColor(self.theme["convo/systemMsgColor"])
verb = self.theme["convo/text/idle"]
for (h, convo) in self.convos.items():
for h, convo in self.convos.items():
# karxi: There's an irritating issue here involving a lack of
# consideration for case-sensitivity.
# This fix is a little sloppy, and I need to look into what it
@ -3051,7 +3051,6 @@ class PesterWindow(MovingWindow):
@QtCore.pyqtSlot()
def joinSelectedMemo(self):
time = str(self.memochooser.timeinput.text())
secret = self.memochooser.secretChannel.isChecked()
invite = self.memochooser.inviteChannel.isChecked()
@ -3246,7 +3245,7 @@ class PesterWindow(MovingWindow):
# combine
self.createTabWindow()
newconvos = {}
for (h, c) in self.convos.items():
for h, c in self.convos.items():
c.setParent(self.tabconvo)
self.tabconvo.addChat(c)
self.tabconvo.show()
@ -3276,7 +3275,7 @@ class PesterWindow(MovingWindow):
# combine
newmemos = {}
self.createMemoTabWindow()
for (h, m) in self.memos.items():
for h, m in self.memos.items():
m.setParent(self.tabmemo)
self.tabmemo.addChat(m)
self.tabmemo.show()
@ -3806,9 +3805,10 @@ class PesterWindow(MovingWindow):
"port": int(
server_and_port[1]
), # to make sure port is a valid integer, and raise an exception if it cannot be converted.
"pass": self.auth_pass_qline.text(),
"TLS": self.TLS_checkbox.isChecked(),
}
PchumLog.info("server: " + str(server))
PchumLog.info("server: %s", server)
except:
msgbox = QtWidgets.QMessageBox()
msgbox.setStyleSheet(
@ -3951,12 +3951,24 @@ class PesterWindow(MovingWindow):
layout.addWidget(cancel)
layout.addWidget(ok)
main_layout = QtWidgets.QVBoxLayout()
nep_prompt = QtWidgets.QLabel(
":33 < Please put in the server's address in the format HOSTNAME:PORT\n:33 < Fur example, irc.pesterchum.xyz:6697"
)
nep_prompt.setStyleSheet("QLabel { color: #416600; font-weight: bold;}")
auth_pass_prompt = QtWidgets.QLabel(":33 < type the password!! (optional)")
auth_pass_prompt.setStyleSheet(
"QLabel { color: #416600; font-weight: bold;}"
)
self.auth_pass_qline = QtWidgets.QLineEdit(self)
self.auth_pass_qline.setMinimumWidth(200)
main_layout.addWidget(nep_prompt)
main_layout.addWidget(self.customServerPrompt_qline)
main_layout.addWidget(auth_pass_prompt)
main_layout.addWidget(self.auth_pass_qline)
main_layout.addLayout(TLS_layout)
main_layout.addLayout(layout)
@ -4049,6 +4061,11 @@ class PesterWindow(MovingWindow):
try:
selected_entry = self.serverBox.currentIndex()
PchumLog.debug(
"'%s' == '%s'",
server_obj[selected_entry]["server"],
self.serverBox.currentText(),
)
assert (
server_obj[selected_entry]["server"] == self.serverBox.currentText()
)
@ -4061,9 +4078,13 @@ class PesterWindow(MovingWindow):
try:
with open(_datadir + "server.json", "w") as server_file:
password = ""
if "pass" in server_obj[selected_entry]:
password = server_obj[selected_entry]["pass"]
json_server_file = {
"server": server_obj[selected_entry]["server"],
"port": server_obj[selected_entry]["port"],
"pass": password,
"TLS": server_obj[selected_entry]["TLS"],
}
server_file.write(json.dumps(json_server_file, indent=4))
@ -4090,12 +4111,13 @@ class PesterWindow(MovingWindow):
for i in range(len(server_obj)):
server_list_items.append(server_obj[i]["server"])
except:
PchumLog.exception("")
if not self.chooseServerAskedToReset:
self.chooseServerAskedToReset = True
self.resetServerlist()
return 1
PchumLog.info("server_list_items: " + str(server_list_items))
PchumLog.info("server_list_items: %s", server_list_items)
# Widget 1
self.chooseServerWidged = QtWidgets.QDialog()
@ -4188,7 +4210,7 @@ class PesterWindow(MovingWindow):
trayIconSignal = QtCore.pyqtSignal(int)
blockedChum = QtCore.pyqtSignal("QString")
unblockedChum = QtCore.pyqtSignal("QString")
kickUser = QtCore.pyqtSignal("QString", "QString")
kickUser = QtCore.pyqtSignal("QString", "QString", "QString")
joinChannel = QtCore.pyqtSignal("QString")
leftChannel = QtCore.pyqtSignal("QString")
setChannelMode = QtCore.pyqtSignal("QString", "QString", "QString")
@ -4293,7 +4315,7 @@ class MainProgram(QtCore.QObject):
for k in Mood.moodcats:
moodCategories[k] = moodMenu.addMenu(k.upper())
self.moodactions = {}
for (i, m) in enumerate(Mood.moods):
for i, m in enumerate(Mood.moods):
maction = QAction(m.upper(), self)
mobj = PesterMoodAction(i, self.widget.moods.updateMood)
maction.triggered.connect(mobj.updateMood)
@ -4321,7 +4343,14 @@ class MainProgram(QtCore.QObject):
self.attempts = 0
# but it's at least better than the way it was before.
self.irc = PesterIRC(self.widget.config, self.widget)
# FIXME: we should not pass widget here
self.irc = PesterIRC(
self.widget,
self.widget.config.server(),
self.widget.config.port(),
self.widget.config.ssl(),
password=self.widget.config.password(),
)
self.connectWidgets(self.irc, self.widget)
self.widget.passIRC(
@ -4364,107 +4393,33 @@ class MainProgram(QtCore.QObject):
def trayMessageClick(self):
self.widget.config.set("traymsg", False)
widget2irc = [
("sendMessage(QString, QString)", "sendMessage(QString, QString)"),
("sendNotice(QString, QString)", "sendNotice(QString, QString)"),
("sendCTCP(QString, QString)", "sendCTCP(QString, QString)"),
("newConvoStarted(QString, bool)", "startConvo(QString, bool)"),
("convoClosed(QString)", "endConvo(QString)"),
("profileChanged()", "updateProfile()"),
("moodRequest(PyQt_PyObject)", "getMood(PyQt_PyObject)"),
("moodsRequest(PyQt_PyObject)", "getMoods(PyQt_PyObject)"),
("moodUpdated()", "updateMood()"),
("mycolorUpdated()", "updateColor()"),
("blockedChum(QString)", "blockedChum(QString)"),
("unblockedChum(QString)", "unblockedChum(QString)"),
("requestNames(QString)", "requestNames(QString)"),
("requestChannelList()", "requestChannelList()"),
("joinChannel(QString)", "joinChannel(QString)"),
("leftChannel(QString)", "leftChannel(QString)"),
("kickUser(QString, QString)", "kickUser(QString, QString)"),
(
"setChannelMode(QString, QString, QString)",
"setChannelMode(QString, QString, QString)",
),
("channelNames(QString)", "channelNames(QString)"),
("inviteChum(QString, QString)", "inviteChum(QString, QString)"),
("pingServer()", "pingServer()"),
("setAway(bool)", "setAway(bool)"),
("killSomeQuirks(QString, QString)", "killSomeQuirks(QString, QString)"),
("disconnectIRC()", "disconnectIRC()"),
]
# IRC --> Main window
irc2widget = [
("connected()", "connected()"),
(
"moodUpdated(QString, PyQt_PyObject)",
"updateMoodSlot(QString, PyQt_PyObject)",
),
(
"colorUpdated(QString, QtGui.QColor)",
"updateColorSlot(QString, QtGui.QColor)",
),
("messageReceived(QString, QString)", "deliverMessage(QString, QString)"),
(
"memoReceived(QString, QString, QString)",
"deliverMemo(QString, QString, QString)",
),
("noticeReceived(QString, QString)", "deliverNotice(QString, QString)"),
("inviteReceived(QString, QString)", "deliverInvite(QString, QString)"),
("nickCollision(QString, QString)", "nickCollision(QString, QString)"),
("getSvsnickedOn(QString, QString)", "getSvsnickedOn(QString, QString)"),
("myHandleChanged(QString)", "myHandleChanged(QString)"),
(
"namesReceived(QString, PyQt_PyObject)",
"updateNames(QString, PyQt_PyObject)",
),
(
"userPresentUpdate(QString, QString, QString)",
"userPresentUpdate(QString, QString, QString)",
),
("channelListReceived(PyQt_PyObject)", "updateChannelList(PyQt_PyObject)"),
(
"timeCommand(QString, QString, QString)",
"timeCommand(QString, QString, QString)",
),
("chanInviteOnly(QString)", "chanInviteOnly(QString)"),
("modesUpdated(QString, QString)", "modesUpdated(QString, QString)"),
("cannotSendToChan(QString, QString)", "cannotSendToChan(QString, QString)"),
("tooManyPeeps()", "tooManyPeeps()"),
(
"quirkDisable(QString, QString, QString)",
"quirkDisable(QString, QString, QString)",
),
("forbiddenchannel(QString)", "forbiddenchannel(QString)"),
]
def ircQtConnections(self, irc, widget):
# IRC --> Main window
return (
(widget.sendMessage, irc.sendMessage),
(widget.sendNotice, irc.sendNotice),
(widget.sendCTCP, irc.sendCTCP),
(widget.newConvoStarted, irc.startConvo),
(widget.convoClosed, irc.endConvo),
(widget.profileChanged, irc.updateProfile),
(widget.moodRequest, irc.getMood),
(widget.moodsRequest, irc.getMoods),
(widget.moodUpdated, irc.updateMood),
(widget.mycolorUpdated, irc.updateColor),
(widget.blockedChum, irc.blockedChum),
(widget.unblockedChum, irc.unblockedChum),
(widget.requestNames, irc.requestNames),
(widget.requestChannelList, irc.requestChannelList),
(widget.joinChannel, irc.joinChannel),
(widget.leftChannel, irc.leftChannel),
(widget.kickUser, irc.kickUser),
(widget.setChannelMode, irc.setChannelMode),
(widget.channelNames, irc.channelNames),
(widget.inviteChum, irc.inviteChum),
(widget.pingServer, irc.pingServer),
(widget.setAway, irc.setAway),
(widget.killSomeQuirks, irc.killSomeQuirks),
(widget.disconnectIRC, irc.disconnectIRC),
(widget.sendMessage, irc.send_message),
(widget.sendNotice, irc.send_notice),
(widget.sendCTCP, irc.send_ctcp),
(widget.newConvoStarted, irc.start_convo),
(widget.convoClosed, irc.end_convo),
(widget.profileChanged, irc.update_profile),
(widget.moodRequest, irc.get_mood),
(widget.moodsRequest, irc.get_moods),
(widget.moodUpdated, irc.update_mood),
(widget.mycolorUpdated, irc.update_color),
(widget.blockedChum, irc.blocked_chum),
(widget.unblockedChum, irc.unblocked_chum),
(widget.requestNames, irc.request_names),
(widget.requestChannelList, irc.request_channel_list),
(widget.joinChannel, irc.join_channel),
(widget.leftChannel, irc.left_channel),
(widget.kickUser, irc.kick_user),
(widget.setChannelMode, irc.set_channel_mode),
(widget.channelNames, irc.channel_names),
(widget.inviteChum, irc.invite_chum),
(widget.pingServer, irc.ping_server),
(widget.setAway, irc.set_away),
(widget.killSomeQuirks, irc.kill_some_quirks),
(widget.disconnectIRC, irc.disconnect_irc),
# Main window --> IRC
(irc.connected, widget.connected),
(irc.askToConnect, widget.connectAnyway),
@ -4484,9 +4439,7 @@ class MainProgram(QtCore.QObject):
(irc.chanInviteOnly, widget.chanInviteOnly),
(irc.modesUpdated, widget.modesUpdated),
(irc.cannotSendToChan, widget.cannotSendToChan),
(irc.forbiddenchannel, widget.forbiddenchannel),
(irc.tooManyPeeps, widget.tooManyPeeps),
(irc.quirkDisable, widget.quirkDisable),
(irc.signal_forbiddenchannel, widget.forbiddenchannel),
)
def connectWidgets(self, irc, widget):
@ -4533,7 +4486,7 @@ class MainProgram(QtCore.QObject):
self.widget.loadingscreen.tryAgain.connect(self.tryAgain)
if (
hasattr(self, "irc")
and self.irc.registeredIRC
and self.irc.registered_irc
and not self.irc.unresponsive
):
return
@ -4564,13 +4517,18 @@ class MainProgram(QtCore.QObject):
def restartIRC(self, verify_hostname=True):
if hasattr(self, "irc") and self.irc:
self.disconnectWidgets(self.irc, self.widget)
stop = self.irc.stopIRC
stop = self.irc.stop_irc
del self.irc
else:
stop = None
if stop is None:
self.irc = PesterIRC(
self.widget.config, self.widget, verify_hostname=verify_hostname
self.widget,
self.widget.config.server(),
self.widget.config.port(),
self.widget.config.ssl(),
password=self.widget.config.password(),
verify_hostname=verify_hostname,
)
self.connectWidgets(self.irc, self.widget)
self.irc.start()

View file

@ -27,6 +27,7 @@ class Color:
# The threshold at which to consider two colors noticeably different, even
# if only barely
jnd = 2.3
# TODO: Either subclass (this is probably best) or add a .native_type; in
# the case of the former, just make sure each type is geared towards using
# a certain kind of color space as a starting point, e.g. RGB, XYZ, HSV,

View file

@ -64,7 +64,7 @@ class PesterLog:
if handle not in self.convos:
log_time = datetime.now().strftime("%Y-%m-%d.%H.%M")
self.convos[handle] = {}
for (format, t) in modes.items():
for format, t in modes.items():
if not os.path.exists(
"{}/{}/{}/{}".format(self.logpath, self.handle, handle, format)
):
@ -81,7 +81,7 @@ class PesterLog:
)
self.convos[handle][format] = fp
for (format, t) in modes.items():
for format, t in modes.items():
f = self.convos[handle][format]
f.write(t + "\r\n")
# flush + fsync force a write,
@ -446,10 +446,10 @@ with a backup from: <a href='%s'>%s</a></h3></html>"
try:
with open(_datadir + "server.json") as server_file:
read_file = server_file.read()
server_file.close()
server_obj = json.loads(read_file)
return server_obj["server"]
except:
PchumLog.exception("Failed to load server, falling back to default.")
try:
with open(_datadir + "server.json", "w") as server_file:
json_server_file = {
@ -458,7 +458,6 @@ with a backup from: <a href='%s'>%s</a></h3></html>"
"TLS": True,
}
server_file.write(json.dumps(json_server_file, indent=4))
server_file.close()
server = "irc.pesterchum.xyz"
except:
return self.config.get("server", "irc.pesterchum.xyz")
@ -469,11 +468,11 @@ with a backup from: <a href='%s'>%s</a></h3></html>"
try:
with open(_datadir + "server.json") as server_file:
read_file = server_file.read()
server_file.close()
server_obj = json.loads(read_file)
server_obj = json.loads(read_file)
port = server_obj["port"]
return port
except:
PchumLog.exception("Failed to load port, falling back to default.")
return self.config.get("port", "6697")
def ssl(self):
@ -482,10 +481,23 @@ with a backup from: <a href='%s'>%s</a></h3></html>"
try:
with open(_datadir + "server.json") as server_file:
read_file = server_file.read()
server_file.close()
server_obj = json.loads(read_file)
server_obj = json.loads(read_file)
return server_obj["TLS"]
except:
PchumLog.exception("Failed to load TLS setting, falling back to default.")
return self.config.get("TLS", True)
def password(self):
try:
with open(_datadir + "server.json") as server_file:
read_file = server_file.read()
server_obj = json.loads(read_file)
password = ""
if "pass" in server_obj:
password = server_obj["pass"]
return password
except:
PchumLog.exception("Failed to load TLS setting, falling back to default.")
return self.config.get("TLS", True)
def soundOn(self):
@ -737,7 +749,7 @@ class userProfile:
def setMentions(self, mentions):
i = None
try:
for (i, m) in enumerate(mentions):
for i, m in enumerate(mentions):
re.compile(m)
except re.error as e:
PchumLog.error("#{} Not a valid regular expression: {}".format(i, e))
@ -835,7 +847,7 @@ class PesterProfileDB(dict):
json.dump(chumdict, fp)
u = []
for (handle, c) in chumdict.items():
for handle, c in chumdict.items():
options = dict()
if "group" in c:
options["group"] = c["group"]
@ -960,7 +972,7 @@ class pesterTheme(dict):
return v
def pathHook(self, d):
for (k, v) in d.items():
for k, v in d.items():
if isinstance(v, str):
s = Template(v)
d[k] = s.safe_substitute(path=self.path)

View file

@ -0,0 +1,55 @@
"""Provides functions for validating input from the server and other clients."""
# import re
# _color_rgb = re.compile(r"^\d{1,3},\d{1,3},\d{1,3}$")
# _color_hex = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
def is_valid_mood(value: str):
"""Returns True if an unparsed value (str) is a valid mood index."""
if value in [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
"22",
]:
return True
return False
def is_valid_rgb_color(value: str):
"""Returns True if an unparsed value (str) is a valid rgb color as "r,g,b".
Yeah you could do this via re but this is faster."""
if not isinstance(value, str):
return False
if 4 > len(value) > 11:
return False
components = value.split(",")
if len(components) != 3:
return False
for component in components:
if not component.isnumeric():
return False
if int(component) > 255:
return False
return True

189
scripts/irc_protocol.py Normal file
View file

@ -0,0 +1,189 @@
"""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 pass_(self, password):
"""Send a 'connection password' to the server.
Function is 'pass_' because 'pass' is reserved."""
self._send("PASS", text=password)
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 ctcp_reply(self, target, command, msg=""):
"""Send Client-to-Client Protocol reply message, responding to a CTCP message."""
outgoing_ctcp = " ".join(
[command, msg]
).strip() # Extra spaces break protocol, so strip.
self.notice(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
View 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

View file

@ -103,7 +103,7 @@ class ToastMachine:
def realShow(self):
self.machine.displaying = True
t = None
for (k, v) in self.machine.types.items():
for k, v in self.machine.types.items():
if self.machine.type == k:
try:
args = inspect.getargspec(v.__init__).args