server: Replace hand-rolled IRC with irc (jaraco) library
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Switch the IRC server implementation from the custom socket-based parser to the irc Python library (SingleServerIRCBot), gaining automatic exponential-backoff reconnection, built-in PING/PONG handling, and nick-collision recovery for free. - Add IRCLib server (server/IRCLib.py) extending ThreadedServer: _IRCBotAdapter wraps SingleServerIRCBot with a threading.Event stop flag so shutdown is clean and on_disconnect skips reconnect when stopping. subparse() is implemented directly for alias/grep/rnd/cat. - Add IRCLib printer (message/printer/IRCLib.py) calling connection.privmsg() directly instead of building raw PRIVMSG strings. - Update factory to use IRCLib for irc:// and ircs://; SSL is now passed as a connect_factory kwarg rather than post-hoc socket wrapping. - Add irc to requirements.txt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de2c37a54a
commit
8a01aecd71
13 changed files with 453 additions and 923 deletions
|
|
@ -1,25 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2016 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from nemubot.message import Text
|
|
||||||
from nemubot.message.printer.socket import Socket as SocketPrinter
|
|
||||||
|
|
||||||
|
|
||||||
class IRC(SocketPrinter):
|
|
||||||
|
|
||||||
def visit_Text(self, msg):
|
|
||||||
self.pp += "PRIVMSG %s :" % ",".join(msg.to)
|
|
||||||
super().visit_Text(msg)
|
|
||||||
67
nemubot/message/printer/IRCLib.py
Normal file
67
nemubot/message/printer/IRCLib.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Nemubot is a smart and modulable IM bot.
|
||||||
|
# Copyright (C) 2012-2026 Mercier Pierre-Olivier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from nemubot.message.visitor import AbstractVisitor
|
||||||
|
|
||||||
|
|
||||||
|
class IRCLib(AbstractVisitor):
|
||||||
|
|
||||||
|
"""Visitor that sends bot responses via an irc.client.ServerConnection.
|
||||||
|
|
||||||
|
Unlike the socket-based IRC printer (which builds a raw PRIVMSG string),
|
||||||
|
this calls connection.privmsg() directly so the library handles encoding,
|
||||||
|
line-length capping, and any internal locking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, connection):
|
||||||
|
self._conn = connection
|
||||||
|
|
||||||
|
def _send(self, target, text):
|
||||||
|
try:
|
||||||
|
self._conn.privmsg(target, text)
|
||||||
|
except Exception:
|
||||||
|
pass # drop silently during reconnection
|
||||||
|
|
||||||
|
# Visitor methods
|
||||||
|
|
||||||
|
def visit_Text(self, msg):
|
||||||
|
if isinstance(msg.message, str):
|
||||||
|
for target in msg.to:
|
||||||
|
self._send(target, msg.message)
|
||||||
|
else:
|
||||||
|
msg.message.accept(self)
|
||||||
|
|
||||||
|
def visit_DirectAsk(self, msg):
|
||||||
|
text = msg.message if isinstance(msg.message, str) else str(msg.message)
|
||||||
|
# Mirrors socket.py logic:
|
||||||
|
# rooms that are NOT the designated nick get a "nick: " prefix
|
||||||
|
others = [to for to in msg.to if to != msg.designated]
|
||||||
|
if len(others) == 0 or len(others) != len(msg.to):
|
||||||
|
for target in msg.to:
|
||||||
|
self._send(target, text)
|
||||||
|
if others:
|
||||||
|
for target in others:
|
||||||
|
self._send(target, "%s: %s" % (msg.designated, text))
|
||||||
|
|
||||||
|
def visit_Command(self, msg):
|
||||||
|
parts = ["!" + msg.cmd] + list(msg.args)
|
||||||
|
for target in msg.to:
|
||||||
|
self._send(target, " ".join(parts))
|
||||||
|
|
||||||
|
def visit_OwnerCommand(self, msg):
|
||||||
|
parts = ["`" + msg.cmd] + list(msg.args)
|
||||||
|
for target in msg.to:
|
||||||
|
self._send(target, " ".join(parts))
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2015 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import imp
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import nemubot.message as message
|
|
||||||
import nemubot.server as server
|
|
||||||
|
|
||||||
#Store all used ports
|
|
||||||
PORTS = list()
|
|
||||||
|
|
||||||
class DCC(server.AbstractServer):
|
|
||||||
def __init__(self, srv, dest, socket=None):
|
|
||||||
super().__init__(name="Nemubot DCC server")
|
|
||||||
|
|
||||||
self.error = False # An error has occur, closing the connection?
|
|
||||||
self.messages = list() # Message queued before connexion
|
|
||||||
|
|
||||||
# Informations about the sender
|
|
||||||
self.sender = dest
|
|
||||||
if self.sender is not None:
|
|
||||||
self.nick = (self.sender.split('!'))[0]
|
|
||||||
if self.nick != self.sender:
|
|
||||||
self.realname = (self.sender.split('!'))[1]
|
|
||||||
else:
|
|
||||||
self.realname = self.nick
|
|
||||||
|
|
||||||
# Keep the server
|
|
||||||
self.srv = srv
|
|
||||||
self.treatement = self.treat_msg
|
|
||||||
|
|
||||||
# Found a port for the connection
|
|
||||||
self.port = self.foundPort()
|
|
||||||
|
|
||||||
if self.port is None:
|
|
||||||
self._logger.critical("No more available slot for DCC connection")
|
|
||||||
self.setError("Il n'y a plus de place disponible sur le serveur"
|
|
||||||
" pour initialiser une session DCC.")
|
|
||||||
|
|
||||||
def foundPort(self):
|
|
||||||
"""Found a free port for the connection"""
|
|
||||||
for p in range(65432, 65535):
|
|
||||||
if p not in PORTS:
|
|
||||||
PORTS.append(p)
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self):
|
|
||||||
"""Gives the server identifiant"""
|
|
||||||
return self.srv.id + "/" + self.sender
|
|
||||||
|
|
||||||
def setError(self, msg):
|
|
||||||
self.error = True
|
|
||||||
self.srv.send_msg_usr(self.sender, msg)
|
|
||||||
|
|
||||||
def accept_user(self, host, port):
|
|
||||||
"""Accept a DCC connection"""
|
|
||||||
self.s = socket.socket()
|
|
||||||
try:
|
|
||||||
self.s.connect((host, port))
|
|
||||||
self._logger.info("Accepted user from %s:%d for %s", host, port, self.sender)
|
|
||||||
self.connected = True
|
|
||||||
self.stop = False
|
|
||||||
except:
|
|
||||||
self.connected = False
|
|
||||||
self.error = True
|
|
||||||
return False
|
|
||||||
self.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def request_user(self, type="CHAT", filename="CHAT", size=""):
|
|
||||||
"""Create a DCC connection"""
|
|
||||||
#Open the port
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
try:
|
|
||||||
s.bind(('', self.port))
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
self.port = self.foundPort()
|
|
||||||
s.bind(('', self.port))
|
|
||||||
except:
|
|
||||||
self.setError("Une erreur s'est produite durant la tentative"
|
|
||||||
" d'ouverture d'une session DCC.")
|
|
||||||
return False
|
|
||||||
self._logger.info("Listening on %d for %s", self.port, self.sender)
|
|
||||||
|
|
||||||
#Send CTCP request for DCC
|
|
||||||
self.srv.send_ctcp(self.sender,
|
|
||||||
"DCC %s %s %d %d %s" % (type, filename, self.srv.ip,
|
|
||||||
self.port, size),
|
|
||||||
"PRIVMSG")
|
|
||||||
|
|
||||||
s.listen(1)
|
|
||||||
#Waiting for the client
|
|
||||||
(self.s, addr) = s.accept()
|
|
||||||
self._logger.info("Connected by %d", addr)
|
|
||||||
self.connected = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def send_dcc_raw(self, line):
|
|
||||||
self.s.sendall(line + b'\n')
|
|
||||||
|
|
||||||
def send_dcc(self, msg, to = None):
|
|
||||||
"""If we talk to this user, send a message through this connection
|
|
||||||
else, send the message to the server class"""
|
|
||||||
if to is None or to == self.sender or to == self.nick:
|
|
||||||
if self.error:
|
|
||||||
self.srv.send_msg_final(self.nick, msg)
|
|
||||||
elif not self.connected or self.s is None:
|
|
||||||
try:
|
|
||||||
self.start()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
self.messages.append(msg)
|
|
||||||
else:
|
|
||||||
for line in msg.split("\n"):
|
|
||||||
self.send_dcc_raw(line.encode())
|
|
||||||
else:
|
|
||||||
self.srv.send_dcc(msg, to)
|
|
||||||
|
|
||||||
def send_file(self, filename):
|
|
||||||
"""Send a file over DCC"""
|
|
||||||
if os.path.isfile(filename):
|
|
||||||
self.messages = filename
|
|
||||||
try:
|
|
||||||
self.start()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self._logger.error("File not found `%s'", filename)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
self.stopping.clear()
|
|
||||||
|
|
||||||
# Send file connection
|
|
||||||
if not isinstance(self.messages, list):
|
|
||||||
self.request_user("SEND",
|
|
||||||
os.path.basename(self.messages),
|
|
||||||
os.path.getsize(self.messages))
|
|
||||||
if self.connected:
|
|
||||||
with open(self.messages, 'rb') as f:
|
|
||||||
d = f.read(268435456) #Packets size: 256Mo
|
|
||||||
while d:
|
|
||||||
self.s.sendall(d)
|
|
||||||
self.s.recv(4) #The client send a confirmation after each packet
|
|
||||||
d = f.read(268435456) #Packets size: 256Mo
|
|
||||||
|
|
||||||
# Messages connection
|
|
||||||
else:
|
|
||||||
if not self.connected:
|
|
||||||
if not self.request_user():
|
|
||||||
#TODO: do something here
|
|
||||||
return False
|
|
||||||
|
|
||||||
#Start by sending all queued messages
|
|
||||||
for mess in self.messages:
|
|
||||||
self.send_dcc(mess)
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
readbuffer = b''
|
|
||||||
self.nicksize = len(self.srv.nick)
|
|
||||||
self.Bnick = self.srv.nick.encode()
|
|
||||||
while not self.stop:
|
|
||||||
raw = self.s.recv(1024) #recieve server messages
|
|
||||||
if not raw:
|
|
||||||
break
|
|
||||||
readbuffer = readbuffer + raw
|
|
||||||
temp = readbuffer.split(b'\n')
|
|
||||||
readbuffer = temp.pop()
|
|
||||||
|
|
||||||
for line in temp:
|
|
||||||
self.treatement(line)
|
|
||||||
|
|
||||||
if self.connected:
|
|
||||||
self.s.close()
|
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
#Remove from DCC connections server list
|
|
||||||
if self.realname in self.srv.dcc_clients:
|
|
||||||
del self.srv.dcc_clients[self.realname]
|
|
||||||
|
|
||||||
self._logger.info("Closing connection with %s", self.nick)
|
|
||||||
self.stopping.set()
|
|
||||||
if self.closing_event is not None:
|
|
||||||
self.closing_event()
|
|
||||||
#Rearm Thread
|
|
||||||
threading.Thread.__init__(self)
|
|
||||||
|
|
||||||
def treat_msg(self, line):
|
|
||||||
"""Treat a receive message, *can be overwritten*"""
|
|
||||||
if line == b'NEMUBOT###':
|
|
||||||
bot = self.srv.add_networkbot(self.srv, self.sender, self)
|
|
||||||
self.treatement = bot.treat_msg
|
|
||||||
self.send_dcc("NEMUBOT###")
|
|
||||||
elif (line[:self.nicksize] == self.Bnick and
|
|
||||||
line[self.nicksize+1:].strip()[:10] == b'my name is'):
|
|
||||||
name = line[self.nicksize+1:].strip()[11:].decode('utf-8',
|
|
||||||
'replace')
|
|
||||||
if re.match("^[a-zA-Z0-9_-]+$", name):
|
|
||||||
if name not in self.srv.dcc_clients:
|
|
||||||
del self.srv.dcc_clients[self.sender]
|
|
||||||
self.nick = name
|
|
||||||
self.sender = self.nick + "!" + self.realname
|
|
||||||
self.srv.dcc_clients[self.realname] = self
|
|
||||||
self.send_dcc("Hi " + self.nick)
|
|
||||||
else:
|
|
||||||
self.send_dcc("This nickname is already in use"
|
|
||||||
", please choose another one.")
|
|
||||||
else:
|
|
||||||
self.send_dcc("The name you entered contain"
|
|
||||||
" invalid char.")
|
|
||||||
else:
|
|
||||||
self.srv.treat_msg(
|
|
||||||
(":%s PRIVMSG %s :" % (
|
|
||||||
self.sender,self.srv.nick)).encode() + line,
|
|
||||||
True)
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2015 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
|
|
||||||
from nemubot.channel import Channel
|
|
||||||
from nemubot.message.printer.IRC import IRC as IRCPrinter
|
|
||||||
from nemubot.server.message.IRC import IRC as IRCMessage
|
|
||||||
from nemubot.server.socket import SocketServer
|
|
||||||
|
|
||||||
|
|
||||||
class IRC(SocketServer):
|
|
||||||
|
|
||||||
"""Concrete implementation of a connexion to an IRC server"""
|
|
||||||
|
|
||||||
def __init__(self, host="localhost", port=6667, owner=None,
|
|
||||||
nick="nemubot", username=None, password=None,
|
|
||||||
realname="Nemubot", encoding="utf-8", caps=None,
|
|
||||||
channels=list(), on_connect=None, **kwargs):
|
|
||||||
"""Prepare a connection with an IRC server
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
host -- host to join
|
|
||||||
port -- port on the host to reach
|
|
||||||
ssl -- is this server using a TLS socket
|
|
||||||
owner -- bot's owner
|
|
||||||
nick -- bot's nick
|
|
||||||
username -- the username as sent to server
|
|
||||||
password -- if a password is required to connect to the server
|
|
||||||
realname -- the bot's realname
|
|
||||||
encoding -- the encoding used on the whole server
|
|
||||||
caps -- client capabilities to register on the server
|
|
||||||
channels -- list of channels to join on connection
|
|
||||||
on_connect -- generator to call when connection is done
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.username = username if username is not None else nick
|
|
||||||
self.password = password
|
|
||||||
self.nick = nick
|
|
||||||
self.owner = owner
|
|
||||||
self.realname = realname
|
|
||||||
|
|
||||||
super().__init__(name=self.username + "@" + host + ":" + str(port),
|
|
||||||
host=host, port=port, **kwargs)
|
|
||||||
self.printer = IRCPrinter
|
|
||||||
|
|
||||||
self.encoding = encoding
|
|
||||||
|
|
||||||
# Keep a list of joined channels
|
|
||||||
self.channels = dict()
|
|
||||||
|
|
||||||
# Server/client capabilities
|
|
||||||
self.capabilities = caps
|
|
||||||
|
|
||||||
# Register CTCP capabilities
|
|
||||||
self.ctcp_capabilities = dict()
|
|
||||||
|
|
||||||
def _ctcp_clientinfo(msg, cmds):
|
|
||||||
"""Response to CLIENTINFO CTCP message"""
|
|
||||||
return " ".join(self.ctcp_capabilities.keys())
|
|
||||||
|
|
||||||
def _ctcp_dcc(msg, cmds):
|
|
||||||
"""Response to DCC CTCP message"""
|
|
||||||
try:
|
|
||||||
import ipaddress
|
|
||||||
ip = ipaddress.ip_address(int(cmds[3]))
|
|
||||||
port = int(cmds[4])
|
|
||||||
conn = DCC(srv, msg.sender)
|
|
||||||
except:
|
|
||||||
return "ERRMSG invalid parameters provided as DCC CTCP request"
|
|
||||||
|
|
||||||
self._logger.info("Receive DCC connection request from %s to %s:%d", conn.sender, ip, port)
|
|
||||||
|
|
||||||
if conn.accept_user(ip, port):
|
|
||||||
srv.dcc_clients[conn.sender] = conn
|
|
||||||
conn.send_dcc("Hello %s!" % conn.nick)
|
|
||||||
else:
|
|
||||||
self._logger.error("DCC: unable to connect to %s:%d", ip, port)
|
|
||||||
return "ERRMSG unable to connect to %s:%d" % (ip, port)
|
|
||||||
|
|
||||||
import nemubot
|
|
||||||
|
|
||||||
self.ctcp_capabilities["ACTION"] = lambda msg, cmds: None
|
|
||||||
self.ctcp_capabilities["CLIENTINFO"] = _ctcp_clientinfo
|
|
||||||
#self.ctcp_capabilities["DCC"] = _ctcp_dcc
|
|
||||||
self.ctcp_capabilities["FINGER"] = lambda msg, cmds: "VERSION nemubot v%s" % nemubot.__version__
|
|
||||||
self.ctcp_capabilities["NEMUBOT"] = lambda msg, cmds: "NEMUBOT %s" % nemubot.__version__
|
|
||||||
self.ctcp_capabilities["PING"] = lambda msg, cmds: "PING %s" % " ".join(cmds[1:])
|
|
||||||
self.ctcp_capabilities["SOURCE"] = lambda msg, cmds: "SOURCE https://github.com/nemunaire/nemubot"
|
|
||||||
self.ctcp_capabilities["TIME"] = lambda msg, cmds: "TIME %s" % (datetime.now())
|
|
||||||
self.ctcp_capabilities["USERINFO"] = lambda msg, cmds: "USERINFO %s" % self.realname
|
|
||||||
self.ctcp_capabilities["VERSION"] = lambda msg, cmds: "VERSION nemubot v%s" % nemubot.__version__
|
|
||||||
|
|
||||||
# TODO: Temporary fix, waiting for hook based CTCP management
|
|
||||||
self.ctcp_capabilities["TYPING"] = lambda msg, cmds: None
|
|
||||||
|
|
||||||
self._logger.debug("CTCP capabilities setup: %s", ", ".join(self.ctcp_capabilities))
|
|
||||||
|
|
||||||
|
|
||||||
# Register hooks on some IRC CMD
|
|
||||||
self.hookscmd = dict()
|
|
||||||
|
|
||||||
# Respond to PING
|
|
||||||
def _on_ping(msg):
|
|
||||||
self.write(b"PONG :" + msg.params[0])
|
|
||||||
self.hookscmd["PING"] = _on_ping
|
|
||||||
|
|
||||||
# Respond to 001
|
|
||||||
def _on_connect(msg):
|
|
||||||
# First, send user defined command
|
|
||||||
if on_connect is not None:
|
|
||||||
if callable(on_connect):
|
|
||||||
toc = on_connect()
|
|
||||||
else:
|
|
||||||
toc = on_connect
|
|
||||||
if toc is not None:
|
|
||||||
for oc in toc:
|
|
||||||
self.write(oc)
|
|
||||||
# Then, JOIN some channels
|
|
||||||
for chn in channels:
|
|
||||||
if chn.password:
|
|
||||||
self.write("JOIN %s %s" % (chn.name, chn.password))
|
|
||||||
else:
|
|
||||||
self.write("JOIN %s" % chn.name)
|
|
||||||
self.hookscmd["001"] = _on_connect
|
|
||||||
|
|
||||||
# Respond to ERROR
|
|
||||||
def _on_error(msg):
|
|
||||||
self.close()
|
|
||||||
self.hookscmd["ERROR"] = _on_error
|
|
||||||
|
|
||||||
# Respond to CAP
|
|
||||||
def _on_cap(msg):
|
|
||||||
if len(msg.params) != 3 or msg.params[1] != b"LS":
|
|
||||||
return
|
|
||||||
server_caps = msg.params[2].decode().split(" ")
|
|
||||||
for cap in self.capabilities:
|
|
||||||
if cap not in server_caps:
|
|
||||||
self.capabilities.remove(cap)
|
|
||||||
if len(self.capabilities) > 0:
|
|
||||||
self.write("CAP REQ :" + " ".join(self.capabilities))
|
|
||||||
self.write("CAP END")
|
|
||||||
self.hookscmd["CAP"] = _on_cap
|
|
||||||
|
|
||||||
# Respond to JOIN
|
|
||||||
def _on_join(msg):
|
|
||||||
if len(msg.params) == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
for chname in msg.decode(msg.params[0]).split(","):
|
|
||||||
# Register the channel
|
|
||||||
chan = Channel(chname)
|
|
||||||
self.channels[chname] = chan
|
|
||||||
self.hookscmd["JOIN"] = _on_join
|
|
||||||
# Respond to PART
|
|
||||||
def _on_part(msg):
|
|
||||||
if len(msg.params) != 1 and len(msg.params) != 2:
|
|
||||||
return
|
|
||||||
|
|
||||||
for chname in msg.params[0].split(b","):
|
|
||||||
if chname in self.channels:
|
|
||||||
if msg.frm == self.nick:
|
|
||||||
del self.channels[chname]
|
|
||||||
elif msg.frm in self.channels[chname].people:
|
|
||||||
del self.channels[chname].people[msg.frm]
|
|
||||||
self.hookscmd["PART"] = _on_part
|
|
||||||
# Respond to 331/RPL_NOTOPIC,332/RPL_TOPIC,TOPIC
|
|
||||||
def _on_topic(msg):
|
|
||||||
if len(msg.params) != 1 and len(msg.params) != 2:
|
|
||||||
return
|
|
||||||
if msg.params[0] in self.channels:
|
|
||||||
if len(msg.params) == 1 or len(msg.params[1]) == 0:
|
|
||||||
self.channels[msg.params[0]].topic = None
|
|
||||||
else:
|
|
||||||
self.channels[msg.params[0]].topic = msg.decode(msg.params[1])
|
|
||||||
self.hookscmd["331"] = _on_topic
|
|
||||||
self.hookscmd["332"] = _on_topic
|
|
||||||
self.hookscmd["TOPIC"] = _on_topic
|
|
||||||
# Respond to 353/RPL_NAMREPLY
|
|
||||||
def _on_353(msg):
|
|
||||||
if len(msg.params) == 3:
|
|
||||||
msg.params.pop(0) # 353: like RFC 1459
|
|
||||||
if len(msg.params) != 2:
|
|
||||||
return
|
|
||||||
if msg.params[0] in self.channels:
|
|
||||||
for nk in msg.decode(msg.params[1]).split(" "):
|
|
||||||
res = re.match("^(?P<level>[^a-zA-Z[\]\\`_^{|}])(?P<nickname>[a-zA-Z[\]\\`_^{|}][a-zA-Z0-9[\]\\`_^{|}-]*)$")
|
|
||||||
self.channels[msg.params[0]].people[res.group("nickname")] = res.group("level")
|
|
||||||
self.hookscmd["353"] = _on_353
|
|
||||||
|
|
||||||
# Respond to INVITE
|
|
||||||
def _on_invite(msg):
|
|
||||||
if len(msg.params) != 2:
|
|
||||||
return
|
|
||||||
self.write("JOIN " + msg.decode(msg.params[1]))
|
|
||||||
self.hookscmd["INVITE"] = _on_invite
|
|
||||||
|
|
||||||
# Respond to ERR_NICKCOLLISION
|
|
||||||
def _on_nickcollision(msg):
|
|
||||||
self.nick += "_"
|
|
||||||
self.write("NICK " + self.nick)
|
|
||||||
self.hookscmd["433"] = _on_nickcollision
|
|
||||||
self.hookscmd["436"] = _on_nickcollision
|
|
||||||
|
|
||||||
# Handle CTCP requests
|
|
||||||
def _on_ctcp(msg):
|
|
||||||
if len(msg.params) != 2 or not msg.is_ctcp:
|
|
||||||
return
|
|
||||||
cmds = msg.decode(msg.params[1][1:len(msg.params[1])-1]).split(' ')
|
|
||||||
if cmds[0] in self.ctcp_capabilities:
|
|
||||||
res = self.ctcp_capabilities[cmds[0]](msg, cmds)
|
|
||||||
else:
|
|
||||||
res = "ERRMSG Unknown or unimplemented CTCP request"
|
|
||||||
if res is not None:
|
|
||||||
self.write("NOTICE %s :\x01%s\x01" % (msg.frm, res))
|
|
||||||
self.hookscmd["PRIVMSG"] = _on_ctcp
|
|
||||||
|
|
||||||
|
|
||||||
# Open/close
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
super().connect()
|
|
||||||
|
|
||||||
if self.password is not None:
|
|
||||||
self.write("PASS :" + self.password)
|
|
||||||
if self.capabilities is not None:
|
|
||||||
self.write("CAP LS")
|
|
||||||
self.write("NICK :" + self.nick)
|
|
||||||
self.write("USER %s %s bla :%s" % (self.username, socket.getfqdn(), self.realname))
|
|
||||||
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if not self._fd._closed:
|
|
||||||
self.write("QUIT")
|
|
||||||
return super().close()
|
|
||||||
|
|
||||||
|
|
||||||
# Writes: as inherited
|
|
||||||
|
|
||||||
# Read
|
|
||||||
|
|
||||||
def async_read(self):
|
|
||||||
for line in super().async_read():
|
|
||||||
# PING should be handled here, so start parsing here :/
|
|
||||||
msg = IRCMessage(line, self.encoding)
|
|
||||||
|
|
||||||
if msg.cmd in self.hookscmd:
|
|
||||||
self.hookscmd[msg.cmd](msg)
|
|
||||||
|
|
||||||
yield msg
|
|
||||||
|
|
||||||
|
|
||||||
def parse(self, msg):
|
|
||||||
mes = msg.to_bot_message(self)
|
|
||||||
if mes is not None:
|
|
||||||
yield mes
|
|
||||||
|
|
||||||
|
|
||||||
def subparse(self, orig, cnt):
|
|
||||||
msg = IRCMessage(("@time=%s :%s!user@host.com PRIVMSG %s :%s" % (orig.date.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), orig.frm, ",".join(orig.to), cnt)).encode(self.encoding), self.encoding)
|
|
||||||
return msg.to_bot_message(self)
|
|
||||||
375
nemubot/server/IRCLib.py
Normal file
375
nemubot/server/IRCLib.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
# Nemubot is a smart and modulable IM bot.
|
||||||
|
# Copyright (C) 2012-2026 Mercier Pierre-Olivier
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import shlex
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import irc.bot
|
||||||
|
import irc.client
|
||||||
|
|
||||||
|
import nemubot.message as message
|
||||||
|
from nemubot.server.threaded import ThreadedServer
|
||||||
|
|
||||||
|
|
||||||
|
class _IRCBotAdapter(irc.bot.SingleServerIRCBot):
|
||||||
|
|
||||||
|
"""Internal adapter that bridges the irc library event model to nemubot.
|
||||||
|
|
||||||
|
Subclasses SingleServerIRCBot to get automatic reconnection, PING/PONG,
|
||||||
|
and nick-collision handling for free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, server_name, push_fn, channels, on_connect_cmds,
|
||||||
|
nick, server_list, owner=None, realname="Nemubot",
|
||||||
|
encoding="utf-8", **connect_params):
|
||||||
|
super().__init__(server_list, nick, realname, **connect_params)
|
||||||
|
self._nemubot_name = server_name
|
||||||
|
self._push = push_fn
|
||||||
|
self._channels_to_join = channels
|
||||||
|
self._on_connect_cmds = on_connect_cmds or []
|
||||||
|
self.owner = owner
|
||||||
|
self.encoding = encoding
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
# Event loop control
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Run the reactor loop until stop() is called."""
|
||||||
|
self._connect()
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
self.reactor.process_once(timeout=0.2)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Signal the loop to exit and disconnect cleanly."""
|
||||||
|
self._stop_event.set()
|
||||||
|
try:
|
||||||
|
self.connection.disconnect("Goodbye")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_disconnect(self, connection, event):
|
||||||
|
"""Reconnect automatically unless we are shutting down."""
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
super().on_disconnect(connection, event)
|
||||||
|
|
||||||
|
|
||||||
|
# Connection lifecycle
|
||||||
|
|
||||||
|
def on_welcome(self, connection, event):
|
||||||
|
"""001 — run on_connect commands then join channels."""
|
||||||
|
for cmd in self._on_connect_cmds:
|
||||||
|
if callable(cmd):
|
||||||
|
for c in (cmd() or []):
|
||||||
|
connection.send_raw(c)
|
||||||
|
else:
|
||||||
|
connection.send_raw(cmd)
|
||||||
|
|
||||||
|
for ch in self._channels_to_join:
|
||||||
|
if isinstance(ch, tuple):
|
||||||
|
connection.join(ch[0], ch[1] if len(ch) > 1 else "")
|
||||||
|
elif hasattr(ch, 'name'):
|
||||||
|
connection.join(ch.name, getattr(ch, 'password', "") or "")
|
||||||
|
else:
|
||||||
|
connection.join(str(ch))
|
||||||
|
|
||||||
|
def on_invite(self, connection, event):
|
||||||
|
"""Auto-join on INVITE."""
|
||||||
|
if event.arguments:
|
||||||
|
connection.join(event.arguments[0])
|
||||||
|
|
||||||
|
|
||||||
|
# CTCP
|
||||||
|
|
||||||
|
def on_ctcp(self, connection, event):
|
||||||
|
"""Handle CTCP requests (irc library >= 19 dispatches all to on_ctcp)."""
|
||||||
|
nick = irc.client.NickMask(event.source).nick
|
||||||
|
ctcp_type = event.arguments[0].upper() if event.arguments else ""
|
||||||
|
ctcp_arg = event.arguments[1] if len(event.arguments) > 1 else ""
|
||||||
|
self._reply_ctcp(connection, nick, ctcp_type, ctcp_arg)
|
||||||
|
|
||||||
|
# Fallbacks for older irc library versions that dispatch per-type
|
||||||
|
def on_ctcpversion(self, connection, event):
|
||||||
|
import nemubot
|
||||||
|
nick = irc.client.NickMask(event.source).nick
|
||||||
|
connection.ctcp_reply(nick, "VERSION nemubot v%s" % nemubot.__version__)
|
||||||
|
|
||||||
|
def on_ctcpping(self, connection, event):
|
||||||
|
nick = irc.client.NickMask(event.source).nick
|
||||||
|
arg = event.arguments[0] if event.arguments else ""
|
||||||
|
connection.ctcp_reply(nick, "PING %s" % arg)
|
||||||
|
|
||||||
|
def _reply_ctcp(self, connection, nick, ctcp_type, ctcp_arg):
|
||||||
|
import nemubot
|
||||||
|
responses = {
|
||||||
|
"ACTION": None, # handled as on_action
|
||||||
|
"CLIENTINFO": "CLIENTINFO ACTION CLIENTINFO FINGER PING SOURCE TIME USERINFO VERSION",
|
||||||
|
"FINGER": "FINGER nemubot v%s" % nemubot.__version__,
|
||||||
|
"PING": "PING %s" % ctcp_arg,
|
||||||
|
"SOURCE": "SOURCE https://github.com/nemunaire/nemubot",
|
||||||
|
"TIME": "TIME %s" % datetime.now(),
|
||||||
|
"USERINFO": "USERINFO Nemubot",
|
||||||
|
"VERSION": "VERSION nemubot v%s" % nemubot.__version__,
|
||||||
|
}
|
||||||
|
if ctcp_type in responses and responses[ctcp_type] is not None:
|
||||||
|
connection.ctcp_reply(nick, responses[ctcp_type])
|
||||||
|
|
||||||
|
|
||||||
|
# Incoming messages
|
||||||
|
|
||||||
|
def _decode(self, text):
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
try:
|
||||||
|
return text.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return text.decode(self.encoding, "replace")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _make_message(self, connection, source, target, text):
|
||||||
|
"""Convert raw IRC event data into a nemubot bot message."""
|
||||||
|
nick = irc.client.NickMask(source).nick
|
||||||
|
text = self._decode(text)
|
||||||
|
bot_nick = connection.get_nickname()
|
||||||
|
is_channel = irc.client.is_channel(target)
|
||||||
|
to = [target] if is_channel else [nick]
|
||||||
|
to_response = [target] if is_channel else [nick]
|
||||||
|
|
||||||
|
common = dict(
|
||||||
|
server=self._nemubot_name,
|
||||||
|
to=to,
|
||||||
|
to_response=to_response,
|
||||||
|
frm=nick,
|
||||||
|
frm_owner=(nick == self.owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
# "botname: text" or "botname, text"
|
||||||
|
if (text.startswith(bot_nick + ":") or
|
||||||
|
text.startswith(bot_nick + ",")):
|
||||||
|
inner = text[len(bot_nick) + 1:].strip()
|
||||||
|
return message.DirectAsk(designated=bot_nick, message=inner,
|
||||||
|
**common)
|
||||||
|
|
||||||
|
# "!command [args]"
|
||||||
|
if len(text) > 1 and text[0] == '!':
|
||||||
|
inner = text[1:].strip()
|
||||||
|
try:
|
||||||
|
args = shlex.split(inner)
|
||||||
|
except ValueError:
|
||||||
|
args = inner.split()
|
||||||
|
if args:
|
||||||
|
# Extract @key=value named arguments (same logic as IRC.py)
|
||||||
|
kwargs = {}
|
||||||
|
while len(args) > 1:
|
||||||
|
arg = args[1]
|
||||||
|
if len(arg) > 2 and arg[0:2] == '\\@':
|
||||||
|
args[1] = arg[1:]
|
||||||
|
elif len(arg) > 1 and arg[0] == '@':
|
||||||
|
arsp = arg[1:].split("=", 1)
|
||||||
|
kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None
|
||||||
|
args.pop(1)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return message.Command(cmd=args[0], args=args[1:],
|
||||||
|
kwargs=kwargs, **common)
|
||||||
|
|
||||||
|
return message.Text(message=text, **common)
|
||||||
|
|
||||||
|
def on_pubmsg(self, connection, event):
|
||||||
|
msg = self._make_message(
|
||||||
|
connection, event.source, event.target,
|
||||||
|
event.arguments[0] if event.arguments else "",
|
||||||
|
)
|
||||||
|
if msg:
|
||||||
|
self._push(msg)
|
||||||
|
|
||||||
|
def on_privmsg(self, connection, event):
|
||||||
|
nick = irc.client.NickMask(event.source).nick
|
||||||
|
msg = self._make_message(
|
||||||
|
connection, event.source, nick,
|
||||||
|
event.arguments[0] if event.arguments else "",
|
||||||
|
)
|
||||||
|
if msg:
|
||||||
|
self._push(msg)
|
||||||
|
|
||||||
|
def on_action(self, connection, event):
|
||||||
|
"""CTCP ACTION (/me) — delivered as a plain Text message."""
|
||||||
|
nick = irc.client.NickMask(event.source).nick
|
||||||
|
text = "/me %s" % (event.arguments[0] if event.arguments else "")
|
||||||
|
is_channel = irc.client.is_channel(event.target)
|
||||||
|
to = [event.target] if is_channel else [nick]
|
||||||
|
self._push(message.Text(
|
||||||
|
message=text,
|
||||||
|
server=self._nemubot_name,
|
||||||
|
to=to, to_response=to,
|
||||||
|
frm=nick, frm_owner=(nick == self.owner),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class IRCLib(ThreadedServer):
|
||||||
|
|
||||||
|
"""IRC server using the irc Python library (jaraco).
|
||||||
|
|
||||||
|
Compared to the hand-rolled IRC.py implementation, this gets:
|
||||||
|
- Automatic exponential-backoff reconnection
|
||||||
|
- PING/PONG handled transparently
|
||||||
|
- Nick-collision suffix logic built-in
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, host="localhost", port=6667, nick="nemubot",
|
||||||
|
username=None, password=None, realname="Nemubot",
|
||||||
|
encoding="utf-8", owner=None, channels=None,
|
||||||
|
on_connect=None, ssl=False, **kwargs):
|
||||||
|
"""Prepare a connection to an IRC server.
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
host -- IRC server hostname
|
||||||
|
port -- IRC server port (default 6667)
|
||||||
|
nick -- bot's nickname
|
||||||
|
username -- username for USER command (defaults to nick)
|
||||||
|
password -- server password (sent as PASS)
|
||||||
|
realname -- bot's real name
|
||||||
|
encoding -- fallback encoding for non-UTF-8 servers
|
||||||
|
owner -- nick of the bot's owner (sets frm_owner on messages)
|
||||||
|
channels -- list of channel names / (name, key) tuples to join
|
||||||
|
on_connect -- list of raw IRC commands (or a callable returning one)
|
||||||
|
to send after receiving 001
|
||||||
|
ssl -- wrap the connection in TLS
|
||||||
|
"""
|
||||||
|
name = (username or nick) + "@" + host + ":" + str(port)
|
||||||
|
super().__init__(name=name)
|
||||||
|
|
||||||
|
self._host = host
|
||||||
|
self._port = int(port)
|
||||||
|
self._nick = nick
|
||||||
|
self._username = username or nick
|
||||||
|
self._password = password
|
||||||
|
self._realname = realname
|
||||||
|
self._encoding = encoding
|
||||||
|
self.owner = owner
|
||||||
|
self._channels = channels or []
|
||||||
|
self._on_connect_cmds = on_connect
|
||||||
|
self._ssl = ssl
|
||||||
|
|
||||||
|
self._bot = None
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
|
||||||
|
# ThreadedServer hooks
|
||||||
|
|
||||||
|
def _start(self):
|
||||||
|
server_list = [irc.bot.ServerSpec(self._host, self._port,
|
||||||
|
self._password)]
|
||||||
|
|
||||||
|
connect_params = {"username": self._username}
|
||||||
|
|
||||||
|
if self._ssl:
|
||||||
|
import ssl as ssl_mod
|
||||||
|
import irc.connection
|
||||||
|
ctx = ssl_mod.create_default_context()
|
||||||
|
host = self._host # capture for closure
|
||||||
|
connect_params["connect_factory"] = irc.connection.Factory(
|
||||||
|
wrapper=lambda sock: ctx.wrap_socket(sock,
|
||||||
|
server_hostname=host)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._bot = _IRCBotAdapter(
|
||||||
|
server_name=self.name,
|
||||||
|
push_fn=self._push_message,
|
||||||
|
channels=self._channels,
|
||||||
|
on_connect_cmds=self._on_connect_cmds,
|
||||||
|
nick=self._nick,
|
||||||
|
server_list=server_list,
|
||||||
|
owner=self.owner,
|
||||||
|
realname=self._realname,
|
||||||
|
encoding=self._encoding,
|
||||||
|
**connect_params,
|
||||||
|
)
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._bot.start,
|
||||||
|
daemon=True,
|
||||||
|
name="nemubot.IRC/" + self.name,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _stop(self):
|
||||||
|
if self._bot:
|
||||||
|
self._bot.stop()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
# Outgoing messages
|
||||||
|
|
||||||
|
def send_response(self, response):
|
||||||
|
if response is None:
|
||||||
|
return
|
||||||
|
if isinstance(response, list):
|
||||||
|
for r in response:
|
||||||
|
self.send_response(r)
|
||||||
|
return
|
||||||
|
if not self._bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
from nemubot.message.printer.IRCLib import IRCLib as IRCLibPrinter
|
||||||
|
printer = IRCLibPrinter(self._bot.connection)
|
||||||
|
response.accept(printer)
|
||||||
|
|
||||||
|
|
||||||
|
# subparse: re-parse a plain string in the context of an existing message
|
||||||
|
# (used by alias, rnd, grep, cat, smmry, sms modules)
|
||||||
|
|
||||||
|
def subparse(self, orig, cnt):
|
||||||
|
bot_nick = (self._bot.connection.get_nickname()
|
||||||
|
if self._bot else self._nick)
|
||||||
|
common = dict(
|
||||||
|
server=self.name,
|
||||||
|
to=orig.to,
|
||||||
|
to_response=orig.to_response,
|
||||||
|
frm=orig.frm,
|
||||||
|
frm_owner=orig.frm_owner,
|
||||||
|
date=orig.date,
|
||||||
|
)
|
||||||
|
text = cnt
|
||||||
|
|
||||||
|
if (text.startswith(bot_nick + ":") or
|
||||||
|
text.startswith(bot_nick + ",")):
|
||||||
|
inner = text[len(bot_nick) + 1:].strip()
|
||||||
|
return message.DirectAsk(designated=bot_nick, message=inner,
|
||||||
|
**common)
|
||||||
|
|
||||||
|
if len(text) > 1 and text[0] == '!':
|
||||||
|
inner = text[1:].strip()
|
||||||
|
try:
|
||||||
|
args = shlex.split(inner)
|
||||||
|
except ValueError:
|
||||||
|
args = inner.split()
|
||||||
|
if args:
|
||||||
|
kwargs = {}
|
||||||
|
while len(args) > 1:
|
||||||
|
arg = args[1]
|
||||||
|
if len(arg) > 2 and arg[0:2] == '\\@':
|
||||||
|
args[1] = arg[1:]
|
||||||
|
elif len(arg) > 1 and arg[0] == '@':
|
||||||
|
arsp = arg[1:].split("=", 1)
|
||||||
|
kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None
|
||||||
|
args.pop(1)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return message.Command(cmd=args[0], args=args[1:],
|
||||||
|
kwargs=kwargs, **common)
|
||||||
|
|
||||||
|
return message.Text(message=text, **common)
|
||||||
|
|
@ -24,13 +24,13 @@ def factory(uri, ssl=False, **init_args):
|
||||||
if o.scheme == "irc" or o.scheme == "ircs":
|
if o.scheme == "irc" or o.scheme == "ircs":
|
||||||
# https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt
|
# https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt
|
||||||
# https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html
|
# https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html
|
||||||
args = init_args
|
args = dict(init_args)
|
||||||
|
|
||||||
if o.scheme == "ircs": ssl = True
|
if o.scheme == "ircs": ssl = True
|
||||||
if o.hostname is not None: args["host"] = o.hostname
|
if o.hostname is not None: args["host"] = o.hostname
|
||||||
if o.port is not None: args["port"] = o.port
|
if o.port is not None: args["port"] = o.port
|
||||||
if o.username is not None: args["username"] = o.username
|
if o.username is not None: args["username"] = o.username
|
||||||
if o.password is not None: args["password"] = o.password
|
if o.password is not None: args["password"] = unquote(o.password)
|
||||||
|
|
||||||
modifiers = o.path.split(",")
|
modifiers = o.path.split(",")
|
||||||
target = unquote(modifiers.pop(0)[1:])
|
target = unquote(modifiers.pop(0)[1:])
|
||||||
|
|
@ -41,36 +41,26 @@ def factory(uri, ssl=False, **init_args):
|
||||||
if "msg" in params:
|
if "msg" in params:
|
||||||
if "on_connect" not in args:
|
if "on_connect" not in args:
|
||||||
args["on_connect"] = []
|
args["on_connect"] = []
|
||||||
args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"]))
|
args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"][0]))
|
||||||
|
|
||||||
if "key" in params:
|
if "key" in params:
|
||||||
if "channels" not in args:
|
if "channels" not in args:
|
||||||
args["channels"] = []
|
args["channels"] = []
|
||||||
args["channels"].append((target, params["key"]))
|
args["channels"].append((target, params["key"][0]))
|
||||||
|
|
||||||
if "pass" in params:
|
if "pass" in params:
|
||||||
args["password"] = params["pass"]
|
args["password"] = params["pass"][0]
|
||||||
|
|
||||||
if "charset" in params:
|
if "charset" in params:
|
||||||
args["encoding"] = params["charset"]
|
args["encoding"] = params["charset"][0]
|
||||||
|
|
||||||
#
|
|
||||||
if "channels" not in args and "isnick" not in modifiers:
|
if "channels" not in args and "isnick" not in modifiers:
|
||||||
args["channels"] = [target]
|
args["channels"] = [target]
|
||||||
|
|
||||||
from nemubot.server.IRC import IRC as IRCServer
|
args["ssl"] = ssl
|
||||||
srv = IRCServer(**args)
|
|
||||||
|
|
||||||
if ssl:
|
from nemubot.server.IRCLib import IRCLib as IRCServer
|
||||||
try:
|
srv = IRCServer(**args)
|
||||||
from ssl import create_default_context
|
|
||||||
context = create_default_context()
|
|
||||||
except ImportError:
|
|
||||||
# Python 3.3 compat
|
|
||||||
from ssl import SSLContext, PROTOCOL_TLSv1
|
|
||||||
context = SSLContext(PROTOCOL_TLSv1)
|
|
||||||
from ssl import wrap_socket
|
|
||||||
srv._fd = context.wrap_socket(srv._fd, server_hostname=o.hostname)
|
|
||||||
|
|
||||||
elif o.scheme == "matrix":
|
elif o.scheme == "matrix":
|
||||||
# matrix://localpart:password@homeserver.tld/!room:homeserver.tld
|
# matrix://localpart:password@homeserver.tld/!room:homeserver.tld
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2015 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from nemubot.server import factory
|
|
||||||
|
|
||||||
class TestFactory(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_IRC1(self):
|
|
||||||
from nemubot.server.IRC import IRC as IRCServer
|
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
|
|
||||||
# <host>: If omitted, the client must connect to a prespecified default IRC server.
|
|
||||||
server = factory("irc:///")
|
|
||||||
self.assertIsInstance(server, IRCServer)
|
|
||||||
self.assertIsInstance(server._fd, socket.socket)
|
|
||||||
self.assertIn(server._sockaddr[0], ["127.0.0.1", "::1"])
|
|
||||||
|
|
||||||
server = factory("irc://2.2.2.2")
|
|
||||||
self.assertIsInstance(server, IRCServer)
|
|
||||||
self.assertEqual(server._sockaddr[0], "2.2.2.2")
|
|
||||||
|
|
||||||
server = factory("ircs://1.2.1.2")
|
|
||||||
self.assertIsInstance(server, IRCServer)
|
|
||||||
self.assertIsInstance(server._fd, ssl.SSLSocket)
|
|
||||||
|
|
||||||
server = factory("irc://1.2.3.4:6667")
|
|
||||||
self.assertIsInstance(server, IRCServer)
|
|
||||||
self.assertEqual(server._sockaddr[0], "1.2.3.4")
|
|
||||||
self.assertEqual(server._sockaddr[1], 6667)
|
|
||||||
|
|
||||||
server = factory("ircs://4.3.2.1:194/")
|
|
||||||
self.assertIsInstance(server, IRCServer)
|
|
||||||
self.assertEqual(server._sockaddr[0], "4.3.2.1")
|
|
||||||
self.assertEqual(server._sockaddr[1], 194)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2016 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
|
|
||||||
import nemubot.message as message
|
|
||||||
from nemubot.server.message.abstract import Abstract
|
|
||||||
|
|
||||||
mgx = re.compile(b'''^(?:@(?P<tags>[^ ]+)\ )?
|
|
||||||
(?::(?P<prefix>
|
|
||||||
(?P<nick>[^!@ ]+)
|
|
||||||
(?: !(?P<user>[^@ ]+))?
|
|
||||||
(?:@(?P<host>[^ ]*))?
|
|
||||||
)\ )?
|
|
||||||
(?P<command>(?:[a-zA-Z]+|[0-9]{3}))
|
|
||||||
(?P<params>(?:\ [^:][^ ]*)*)(?:\ :(?P<trailing>.*))?
|
|
||||||
$''', re.X)
|
|
||||||
|
|
||||||
class IRC(Abstract):
|
|
||||||
|
|
||||||
"""Class responsible for parsing IRC messages"""
|
|
||||||
|
|
||||||
def __init__(self, raw, encoding="utf-8"):
|
|
||||||
self.encoding = encoding
|
|
||||||
self.tags = { 'time': datetime.now(timezone.utc) }
|
|
||||||
self.params = list()
|
|
||||||
|
|
||||||
p = mgx.match(raw.rstrip())
|
|
||||||
|
|
||||||
if p is None:
|
|
||||||
raise Exception("Not a valid IRC message: %s" % raw)
|
|
||||||
|
|
||||||
# Parse tags if exists: @aaa=bbb;ccc;example.com/ddd=eee
|
|
||||||
if p.group("tags"):
|
|
||||||
for tgs in self.decode(p.group("tags")).split(';'):
|
|
||||||
tag = tgs.split('=')
|
|
||||||
if len(tag) > 1:
|
|
||||||
self.add_tag(tag[0], tag[1])
|
|
||||||
else:
|
|
||||||
self.add_tag(tag[0])
|
|
||||||
|
|
||||||
# Parse prefix if exists: :nick!user@host.com
|
|
||||||
self.prefix = self.decode(p.group("prefix"))
|
|
||||||
self.nick = self.decode(p.group("nick"))
|
|
||||||
self.user = self.decode(p.group("user"))
|
|
||||||
self.host = self.decode(p.group("host"))
|
|
||||||
|
|
||||||
# Parse command
|
|
||||||
self.cmd = self.decode(p.group("command"))
|
|
||||||
|
|
||||||
# Parse params
|
|
||||||
if p.group("params") is not None and p.group("params") != b'':
|
|
||||||
for param in p.group("params").strip().split(b' '):
|
|
||||||
self.params.append(param)
|
|
||||||
|
|
||||||
if p.group("trailing") is not None:
|
|
||||||
self.params.append(p.group("trailing"))
|
|
||||||
|
|
||||||
|
|
||||||
def add_tag(self, key, value=None):
|
|
||||||
"""Add an IRCv3.2 Message Tags
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
key -- tag identifier (unique for the message)
|
|
||||||
value -- optional value for the tag
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Treat special tags
|
|
||||||
if key == "time" and value is not None:
|
|
||||||
import calendar, time
|
|
||||||
value = datetime.fromtimestamp(calendar.timegm(time.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")), timezone.utc)
|
|
||||||
|
|
||||||
# Store tag
|
|
||||||
self.tags[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_ctcp(self):
|
|
||||||
"""Analyze a message, to determine if this is a CTCP one"""
|
|
||||||
return self.cmd == "PRIVMSG" and len(self.params) == 2 and len(self.params[1]) > 1 and (self.params[1][0] == 0x01 or self.params[1][1] == 0x01)
|
|
||||||
|
|
||||||
|
|
||||||
def decode(self, s):
|
|
||||||
"""Decode the content string usign a specific encoding
|
|
||||||
|
|
||||||
Argument:
|
|
||||||
s -- string to decode
|
|
||||||
"""
|
|
||||||
|
|
||||||
if isinstance(s, bytes):
|
|
||||||
try:
|
|
||||||
s = s.decode()
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
s = s.decode(self.encoding, 'replace')
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def to_server_string(self, client=True):
|
|
||||||
"""Pretty print the message to close to original input string
|
|
||||||
|
|
||||||
Keyword argument:
|
|
||||||
client -- export as a client-side string if true
|
|
||||||
"""
|
|
||||||
|
|
||||||
res = ";".join(["@%s=%s" % (k, v if not isinstance(v, datetime) else v.strftime("%Y-%m-%dT%H:%M:%S.%fZ")) for k, v in self.tags.items()])
|
|
||||||
|
|
||||||
if not client:
|
|
||||||
res += " :%s!%s@%s" % (self.nick, self.user, self.host)
|
|
||||||
|
|
||||||
res += " " + self.cmd
|
|
||||||
|
|
||||||
if len(self.params) > 0:
|
|
||||||
|
|
||||||
if len(self.params) > 1:
|
|
||||||
res += " " + self.decode(b" ".join(self.params[:-1]))
|
|
||||||
res += " :" + self.decode(self.params[-1])
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
def to_bot_message(self, srv):
|
|
||||||
"""Convert to one of concrete implementation of AbstractMessage
|
|
||||||
|
|
||||||
Argument:
|
|
||||||
srv -- the server from the message was received
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.cmd == "PRIVMSG" or self.cmd == "NOTICE":
|
|
||||||
|
|
||||||
receivers = self.decode(self.params[0]).split(',')
|
|
||||||
|
|
||||||
common_args = {
|
|
||||||
"server": srv.name,
|
|
||||||
"date": self.tags["time"],
|
|
||||||
"to": receivers,
|
|
||||||
"to_response": [r if r != srv.nick else self.nick for r in receivers],
|
|
||||||
"frm": self.nick,
|
|
||||||
"frm_owner": self.nick == srv.owner
|
|
||||||
}
|
|
||||||
|
|
||||||
# If CTCP, remove 0x01
|
|
||||||
if self.is_ctcp:
|
|
||||||
text = self.decode(self.params[1][1:len(self.params[1])-1])
|
|
||||||
else:
|
|
||||||
text = self.decode(self.params[1])
|
|
||||||
|
|
||||||
if text.find(srv.nick) == 0 and len(text) > len(srv.nick) + 2 and text[len(srv.nick)] == ":":
|
|
||||||
designated = srv.nick
|
|
||||||
text = text[len(srv.nick) + 1:].strip()
|
|
||||||
else:
|
|
||||||
designated = None
|
|
||||||
|
|
||||||
# Is this a command?
|
|
||||||
if len(text) > 1 and text[0] == '!':
|
|
||||||
text = text[1:].strip()
|
|
||||||
|
|
||||||
# Split content by words
|
|
||||||
try:
|
|
||||||
args = shlex.split(text)
|
|
||||||
except ValueError:
|
|
||||||
args = text.split(' ')
|
|
||||||
|
|
||||||
# Extract explicit named arguments: @key=value or just @key, only at begening
|
|
||||||
kwargs = {}
|
|
||||||
while len(args) > 1:
|
|
||||||
arg = args[1]
|
|
||||||
if len(arg) > 2:
|
|
||||||
if arg[0:2] == '\\@':
|
|
||||||
args[1] = arg[1:]
|
|
||||||
elif arg[0] == '@':
|
|
||||||
arsp = arg[1:].split("=", 1)
|
|
||||||
if len(arsp) == 2:
|
|
||||||
kwargs[arsp[0]] = arsp[1]
|
|
||||||
else:
|
|
||||||
kwargs[arg[1:]] = None
|
|
||||||
args.pop(1)
|
|
||||||
continue
|
|
||||||
# Futher argument are considered as normal argument (this helps for subcommand treatment)
|
|
||||||
break
|
|
||||||
|
|
||||||
return message.Command(cmd=args[0],
|
|
||||||
args=args[1:],
|
|
||||||
kwargs=kwargs,
|
|
||||||
**common_args)
|
|
||||||
|
|
||||||
# Is this an ask for this bot?
|
|
||||||
elif designated is not None:
|
|
||||||
return message.DirectAsk(designated=designated, message=text, **common_args)
|
|
||||||
|
|
||||||
# Normal message
|
|
||||||
else:
|
|
||||||
return message.Text(message=text, **common_args)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2015 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Nemubot is a smart and modulable IM bot.
|
|
||||||
# Copyright (C) 2012-2016 Mercier Pierre-Olivier
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
class Abstract:
|
|
||||||
|
|
||||||
def to_bot_message(self, srv):
|
|
||||||
"""Convert to one of concrete implementation of AbstractMessage
|
|
||||||
|
|
||||||
Argument:
|
|
||||||
srv -- the server from the message was received
|
|
||||||
"""
|
|
||||||
|
|
||||||
raise NotImplemented
|
|
||||||
|
|
||||||
|
|
||||||
def to_server_string(self, **kwargs):
|
|
||||||
"""Pretty print the message to close to original input string
|
|
||||||
"""
|
|
||||||
|
|
||||||
raise NotImplemented
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import unittest
|
|
||||||
|
|
||||||
import nemubot.server.IRC as IRC
|
|
||||||
|
|
||||||
|
|
||||||
class TestIRCMessage(unittest.TestCase):
|
|
||||||
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.msg = IRC.IRCMessage(b":toto!titi@RZ-3je16g.re PRIVMSG #the-channel :Can you parse this message?")
|
|
||||||
|
|
||||||
|
|
||||||
def test_parsing(self):
|
|
||||||
self.assertEqual(self.msg.prefix, "toto!titi@RZ-3je16g.re")
|
|
||||||
self.assertEqual(self.msg.nick, "toto")
|
|
||||||
self.assertEqual(self.msg.user, "titi")
|
|
||||||
self.assertEqual(self.msg.host, "RZ-3je16g.re")
|
|
||||||
|
|
||||||
self.assertEqual(len(self.msg.params), 2)
|
|
||||||
|
|
||||||
self.assertEqual(self.msg.params[0], b"#the-channel")
|
|
||||||
self.assertEqual(self.msg.params[1], b"Can you parse this message?")
|
|
||||||
|
|
||||||
|
|
||||||
def test_prettyprint(self):
|
|
||||||
bst1 = self.msg.to_server_string(False)
|
|
||||||
msg2 = IRC.IRCMessage(bst1.encode())
|
|
||||||
|
|
||||||
bst2 = msg2.to_server_string(False)
|
|
||||||
msg3 = IRC.IRCMessage(bst2.encode())
|
|
||||||
|
|
||||||
bst3 = msg3.to_server_string(False)
|
|
||||||
|
|
||||||
self.assertEqual(bst2, bst3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tags(self):
|
|
||||||
self.assertEqual(len(self.msg.tags), 1)
|
|
||||||
self.assertIn("time", self.msg.tags)
|
|
||||||
|
|
||||||
self.msg.add_tag("time")
|
|
||||||
self.assertEqual(len(self.msg.tags), 1)
|
|
||||||
|
|
||||||
self.msg.add_tag("toto")
|
|
||||||
self.assertEqual(len(self.msg.tags), 2)
|
|
||||||
self.assertIn("toto", self.msg.tags)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
|
irc
|
||||||
matrix-nio
|
matrix-nio
|
||||||
|
|
|
||||||
1
setup.py
1
setup.py
|
|
@ -71,7 +71,6 @@ setup(
|
||||||
'nemubot.message.printer',
|
'nemubot.message.printer',
|
||||||
'nemubot.module',
|
'nemubot.module',
|
||||||
'nemubot.server',
|
'nemubot.server',
|
||||||
'nemubot.server.message',
|
|
||||||
'nemubot.tools',
|
'nemubot.tools',
|
||||||
'nemubot.tools.xmlparser',
|
'nemubot.tools.xmlparser',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue