[irc] Plugin rename/refactor.

The `chat.irc` plugin is now `irc`.
This commit is contained in:
Fabio Manganiello 2024-03-01 21:20:33 +01:00
parent 1ba85231d8
commit 7637890a54
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 207 additions and 104 deletions

View file

@ -1,5 +0,0 @@
``chat.irc``
============
.. automodule:: platypush.plugins.chat.irc
:members:

View file

@ -0,0 +1,5 @@
``irc``
=======
.. automodule:: platypush.plugins.irc
:members:

View file

@ -22,7 +22,6 @@ Plugins
platypush/plugins/camera.ir.mlx90640.rst
platypush/plugins/camera.pi.rst
platypush/plugins/camera.pi.legacy.rst
platypush/plugins/chat.irc.rst
platypush/plugins/chat.telegram.rst
platypush/plugins/clipboard.rst
platypush/plugins/config.rst
@ -55,6 +54,7 @@ Plugins
platypush/plugins/http.webpage.rst
platypush/plugins/ifttt.rst
platypush/plugins/inspect.rst
platypush/plugins/irc.rst
platypush/plugins/joystick.rst
platypush/plugins/kafka.rst
platypush/plugins/lastfm.rst

View file

@ -2,6 +2,7 @@ import os
from typing import Sequence, Dict, Tuple, Union, Optional
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.chat import ChatPlugin
from platypush.schemas.irc import (
IRCServerSchema,
IRCServerStatusSchema,
@ -9,10 +10,9 @@ from platypush.schemas.irc import (
)
from ._bot import IRCBot
from .. import ChatPlugin
class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
class IrcPlugin(RunnablePlugin, ChatPlugin):
"""
IRC integration.
@ -48,7 +48,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
try:
self._bots: Dict[Tuple[str, int], IRCBot] = {
(server_conf['server'], server_conf['port']): IRCBot(**server_conf)
for server_conf in IRCServerSchema().load(servers, many=True)
for server_conf in IRCServerSchema().load(servers, many=True) # type: ignore
}
except Exception as e:
self.logger.warning(f'Could not load IRC server configuration: {e}')
@ -57,15 +57,15 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@property
def _bots_by_server(self) -> Dict[str, IRCBot]:
return {bot.server: bot for srv, bot in self._bots.items()}
return {bot.server: bot for bot in self._bots.values()}
@property
def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]:
return {(bot.server, bot.port): bot for srv, bot in self._bots.items()}
return {(bot.server, bot.port): bot for bot in self._bots.values()}
@property
def _bots_by_alias(self) -> Dict[str, IRCBot]:
return {bot.alias: bot for srv, bot in self._bots.items() if bot.alias}
return {bot.alias: bot for bot in self._bots.values() if bot.alias}
def main(self):
self._connect()
@ -88,7 +88,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
def _get_bot(self, server: Union[str, Tuple[str, int]]) -> IRCBot:
if isinstance(server, (tuple, list, set)):
bot = self._bots_by_server_and_port[tuple(server)]
bot = self._bots_by_server_and_port[tuple(server)] # type: ignore
else:
bot = self._bots_by_alias.get(server, self._bots_by_server.get(server))
@ -184,22 +184,24 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
"""
bot = self._get_bot(server)
channel_name = channel
channel = bot.channels.get(channel)
assert channel, f'Not connected to channel {channel}'
return IRCChannelSchema().dump(
ch = bot.channels.get(channel)
assert ch, f'Not connected to channel {channel}'
return dict(
IRCChannelSchema().dump(
{
'is_invite_only': channel.is_invite_only(),
'is_moderated': channel.is_moderated(),
'is_protected': channel.is_protected(),
'is_secret': channel.is_secret(),
'is_invite_only': ch.is_invite_only(),
'is_moderated': ch.is_moderated(),
'is_protected': ch.is_protected(),
'is_secret': ch.is_secret(),
'name': channel_name,
'modes': channel.modes,
'opers': list(channel.opers()),
'owners': channel.owners(),
'users': list(channel.users()),
'voiced': list(channel.voiced()),
'modes': ch.modes,
'opers': list(ch.opers()),
'owners': ch.owners(),
'users': list(ch.users()),
'voiced': list(ch.voiced()),
}
)
)
@action
def send_ctcp_message(

View file

@ -8,32 +8,61 @@ import select
import socket
import ssl as _ssl
import struct
from typing import Iterable, Optional, Type, Sequence, Dict, Tuple
from typing import Generator, Iterable, Optional, Type, Sequence, Dict, Tuple
import irc.bot
from irc.client import Connection, Event as _IRCEvent, ServerConnection, DCCConnection, ip_numstr_to_quad, \
ip_quad_to_numstr
from irc.client import (
Connection,
Event as _IRCEvent,
ServerConnection,
DCCConnection,
ip_numstr_to_quad,
ip_quad_to_numstr,
)
from irc.connection import Factory as ConnectionFactory
from irc.events import codes as irc_event_codes
from platypush.context import get_bus
from platypush.message.event.irc import IRCEvent, IRCChannelJoinEvent, IRCChannelKickEvent, IRCDisconnectEvent, \
IRCModeEvent, IRCNickChangeEvent, IRCPartEvent, IRCQuitEvent, IRCConnectEvent, IRCPrivateMessageEvent, \
IRCPublicMessageEvent, IRCDCCRequestEvent, IRCDCCMessageEvent, IRCDCCFileRequestEvent, IRCCTCPMessageEvent, \
IRCDCCFileRecvCompletedEvent, IRCDCCFileRecvCancelledEvent, IRCDCCFileSendCompletedEvent, \
IRCDCCFileSendCancelledEvent
from platypush.message.event.irc import (
IRCEvent,
IRCChannelJoinEvent,
IRCChannelKickEvent,
IRCDisconnectEvent,
IRCModeEvent,
IRCNickChangeEvent,
IRCPartEvent,
IRCQuitEvent,
IRCConnectEvent,
IRCPrivateMessageEvent,
IRCPublicMessageEvent,
IRCDCCRequestEvent,
IRCDCCMessageEvent,
IRCDCCFileRequestEvent,
IRCCTCPMessageEvent,
IRCDCCFileRecvCompletedEvent,
IRCDCCFileRecvCancelledEvent,
IRCDCCFileSendCompletedEvent,
IRCDCCFileSendCancelledEvent,
)
class IRCBot(irc.bot.SingleServerIRCBot):
def __init__(
self, server: str, port: int, nickname: str, alias: str,
self,
server: str,
port: int,
nickname: str,
alias: str,
channels: Iterable[str],
response_timeout: Optional[float],
dcc_file_transfer_timeout: Optional[float],
dcc_accept_timeout: Optional[float],
dcc_downloads_dir: str, realname: Optional[str] = None,
password: Optional[str] = None, ssl: bool = False,
ipv6: bool = False, stop_message: Optional[str] = None,
dcc_downloads_dir: str,
realname: Optional[str] = None,
password: Optional[str] = None,
ssl: bool = False,
ipv6: bool = False,
stop_message: Optional[str] = None,
dcc_ip_whitelist: Optional[Sequence[str]] = None,
dcc_ip_blacklist: Optional[Sequence[str]] = None,
dcc_nick_whitelist: Optional[Sequence[str]] = None,
@ -47,8 +76,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
connection_factory.family = socket.AF_INET6
super().__init__(
server_list=[(server, port)], nickname=nickname,
realname=realname, connect_factory=connection_factory
server_list=[(server, port)],
nickname=nickname,
realname=realname,
connect_factory=connection_factory,
)
self.server = server
@ -78,7 +109,9 @@ class IRCBot(irc.bot.SingleServerIRCBot):
def stop_message(self) -> Optional[str]:
return self._stop_message
def _post_event(self, connection: Connection, output_event_type: Type[IRCEvent], **kwargs):
def _post_event(
self, connection: Connection, output_event_type: Type[IRCEvent], **kwargs
):
if isinstance(connection, ServerConnection):
kwargs['server'] = connection.server
kwargs['port'] = connection.port
@ -89,26 +122,39 @@ class IRCBot(irc.bot.SingleServerIRCBot):
get_bus().post(event)
def on_join(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(connection, IRCChannelJoinEvent, channel=event.target, nick=event.source.nick)
self._post_event(
connection,
IRCChannelJoinEvent,
channel=event.target,
nick=event.source.nick,
)
super()._on_join(connection, event)
def on_kick(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(
connection, IRCChannelKickEvent, channel=event.target,
source_nick=event.arguments[1], target_nick=event.arguments[0]
connection,
IRCChannelKickEvent,
channel=event.target,
source_nick=event.arguments[1],
target_nick=event.arguments[0],
)
super()._on_kick(connection, event)
def on_nick(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(
connection, IRCNickChangeEvent, before=event.source.nick, after=event.target.nick
connection,
IRCNickChangeEvent,
before=event.source.nick,
after=event.target.nick,
)
super()._on_nick(connection, event)
def on_mode(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(
connection, IRCModeEvent, mode=event.arguments[0],
connection,
IRCModeEvent,
mode=event.arguments[0],
source=event.source.nick,
target_=(event.arguments[1] if len(event.arguments) > 1 else None),
channel=event.target,
@ -140,18 +186,22 @@ class IRCBot(irc.bot.SingleServerIRCBot):
def on_pubmsg(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(
connection, IRCPublicMessageEvent,
text=event.arguments[0], nick=event.source.nick,
connection,
IRCPublicMessageEvent,
text=event.arguments[0],
nick=event.source.nick,
channel=event.target,
mentions_me=self._mentions_me(connection, event.arguments[0])
mentions_me=self._mentions_me(connection, event.arguments[0]),
)
def on_privmsg(self, connection: ServerConnection, event: _IRCEvent):
self._post_event(
connection, IRCPrivateMessageEvent,
text=event.arguments[0], nick=event.source.nick,
connection,
IRCPrivateMessageEvent,
text=event.arguments[0],
nick=event.source.nick,
channel=event.target,
mentions_me=self._mentions_me(connection, event.arguments[0])
mentions_me=self._mentions_me(connection, event.arguments[0]),
)
def on_dccchat(self, connection: DCCConnection, event: _IRCEvent):
@ -168,7 +218,9 @@ class IRCBot(irc.bot.SingleServerIRCBot):
nick = event.source.nick
if not self._is_dcc_connection_request_allowed(address=address, nick=nick):
self.logger.info(f'Refused DCC connection from address={address} nick={nick}')
self.logger.info(
f'Refused DCC connection from address={address} nick={nick}'
)
connection.disconnect('Unauthorized peer')
return
@ -194,15 +246,23 @@ class IRCBot(irc.bot.SingleServerIRCBot):
conn_map = getattr(self, f'_dcc_{proc_type}_procs', {})
conn_map.pop((addr, port), None)
def _process_dcc_recv(self, connection: DCCConnection, filename: str, address: str, port: int, size: int):
def _process_dcc_recv(
self,
connection: DCCConnection,
filename: str,
address: str,
port: int,
size: int,
):
pathlib.Path(self.dcc_downloads_dir).mkdir(parents=True, exist_ok=True)
filename = os.path.abspath(os.path.join(self.dcc_downloads_dir, filename))
assert filename.startswith(self.dcc_downloads_dir), (
'Attempt to save a file outside the downloads directory'
)
assert filename.startswith(
self.dcc_downloads_dir
), 'Attempt to save a file outside the downloads directory'
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock, \
open(filename, 'wb') as f:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock, open(
filename, 'wb'
) as f:
try:
sock.connect((address, port))
processed_bytes = 0
@ -214,15 +274,23 @@ class IRCBot(irc.bot.SingleServerIRCBot):
sock.send(f'{socket.htonl(processed_bytes)}'.encode())
self._post_event(
connection, IRCDCCFileRecvCompletedEvent, address=address,
port=port, file=filename, size=size
connection,
IRCDCCFileRecvCompletedEvent,
address=address,
port=port,
file=filename,
size=size,
)
except Exception as e:
self.logger.error(f'DCC transfer error from {address}:{port}: {e}')
self.logger.exception(e)
self._post_event(
connection, IRCDCCFileRecvCancelledEvent, address=address,
port=port, file=filename, error=str(e)
connection,
IRCDCCFileRecvCancelledEvent,
address=address,
port=port,
file=filename,
error=str(e),
)
finally:
connection.disconnect()
@ -265,22 +333,33 @@ class IRCBot(irc.bot.SingleServerIRCBot):
buf = f.read(4096)
self._post_event(
connection, IRCDCCFileSendCompletedEvent,
file=filename, address=address, port=transfer_port
connection,
IRCDCCFileSendCompletedEvent,
file=filename,
address=address,
port=transfer_port,
)
finally:
client_sock.close()
dcc_sock.close()
except Exception as e:
self.logger.error(f'DCC transfer error to {connection.peeraddress}:{connection.peerport}: {e}')
self.logger.error(
f'DCC transfer error to {connection.peeraddress}:{connection.peerport}: {e}'
)
self.logger.exception(e)
self._post_event(
connection, IRCDCCFileSendCancelledEvent, address=connection.peeraddress,
port=connection.peerport, file=filename, error=str(e)
connection,
IRCDCCFileSendCancelledEvent,
address=connection.peeraddress,
port=connection.peerport,
file=filename,
error=str(e),
)
finally:
connection.disconnect()
self._dcc_proc_completion_queue.put(('send', connection.localaddress, connection.localport))
self._dcc_proc_completion_queue.put(
('send', connection.localaddress, connection.localport)
)
def _set_accept_timeout(self, sock: socket.socket):
if self.dcc_accept_timeout:
@ -293,12 +372,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
if proc and proc.is_alive():
proc.terminate()
def _accept_dcc_file_request(self, connection: DCCConnection, ctcp_msg: str, nick: str):
ctcp_msg = [
token.strip()
for token in ctcp_msg.split(' ')
if token
]
def _accept_dcc_file_request(
self, connection: DCCConnection, ctcp_msg: str, nick: str
):
ctcp_msg = [token.strip() for token in ctcp_msg.split(' ') if token]
filename = ' '.join(ctcp_msg[2:-3])
address, port, size = ctcp_msg[-3:]
@ -315,22 +392,26 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return
self._post_event(
connection, IRCDCCFileRequestEvent,
connection,
IRCDCCFileRequestEvent,
address=address,
port=port, file=filename,
size=size, nick=nick
port=port,
file=filename,
size=size,
nick=nick,
)
# Accept the file request
# (if we're here then the peer is whitelisted/not blacklisted)
self._dcc_recv_procs[(address, port)] = multiprocessing.Process(
target=self._process_dcc_recv, kwargs={
target=self._process_dcc_recv,
kwargs={
'connection': connection,
'address': address,
'port': port,
'filename': filename,
'size': size,
}
},
)
self._dcc_recv_procs[(address, port)].start()
@ -342,7 +423,7 @@ class IRCBot(irc.bot.SingleServerIRCBot):
try:
msg = [
body[(0 if i == 0 else sep_pos[i - 1] + 1):pos].decode().strip()
body[(0 if i == 0 else sep_pos[i - 1] + 1) : pos].decode().strip()
for i, pos in enumerate(sep_pos)
][1:]
except (ValueError, TypeError):
@ -361,10 +442,15 @@ class IRCBot(irc.bot.SingleServerIRCBot):
def on_dccmsg(self, connection: DCCConnection, event: _IRCEvent):
ctcp_header = b'CTCP_MESSAGE'
if event.arguments[0][:len(ctcp_header)] == ctcp_header:
if event.arguments[0][: len(ctcp_header)] == ctcp_header:
return self._handle_ctcp_message(connection, event)
self._post_event(connection, IRCDCCMessageEvent, address=event.source, body=event.arguments[0])
self._post_event(
connection,
IRCDCCMessageEvent,
address=event.source,
body=event.arguments[0],
)
def on_disconnect(self, connection: Connection, event: _IRCEvent):
self._post_event(connection, IRCDisconnectEvent)
@ -385,7 +471,9 @@ class IRCBot(irc.bot.SingleServerIRCBot):
q.put(event)
def _on_generic_event(self, _, event: _IRCEvent):
self.logger.debug(f'Received raw unhandled IRC event on {self.server}: {event.__dict__}')
self.logger.debug(
f'Received raw unhandled IRC event on {self.server}: {event.__dict__}'
)
def _is_dcc_connection_request_allowed(self, address: str, nick: str) -> bool:
if self.dcc_ip_whitelist and address not in self.dcc_ip_whitelist:
@ -397,7 +485,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
if self.dcc_nick_blacklist and nick in self.dcc_nick_blacklist:
return False
if self.dcc_max_connections and len(self._dcc_recv_procs) >= self.dcc_max_connections:
if (
self.dcc_max_connections
and len(self._dcc_recv_procs) >= self.dcc_max_connections
):
self.logger.warning(
f'Refused new DCC connection: maximum number of concurrent '
f'connections ({self.dcc_max_connections}) reached'
@ -407,29 +498,35 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return True
def dcc_file_transfer(self, file: str, nick: str, bind_address: Optional[str] = None):
def dcc_file_transfer(
self, file: str, nick: str, bind_address: Optional[str] = None
):
conn: DCCConnection = self.dcc('chat')
bind_address = (bind_address, 0) if bind_address else None
conn.listen(bind_address)
conn.passive = False
self.connection.privmsg(
nick,
f'\x01DCC CHAT chat {ip_quad_to_numstr(conn.localaddress)} {conn.localport}\x01'
f'\x01DCC CHAT chat {ip_quad_to_numstr(conn.localaddress)} {conn.localport}\x01',
)
send_proc = self._dcc_send_procs[(conn.localaddress, conn.localport)] = multiprocessing.Process(
send_proc = self._dcc_send_procs[
(conn.localaddress, conn.localport)
] = multiprocessing.Process(
target=self._process_dcc_send,
kwargs={
'connection': conn,
'filename': file,
}
},
)
send_proc.start()
send_proc.join()
@contextlib.contextmanager
def event_queue(self, event_type: str) -> multiprocessing.Queue:
def event_queue(
self, event_type: str
) -> Generator[multiprocessing.Queue, None, None]:
q = self._pending_requests[event_type] = multiprocessing.Queue()
try:
yield q
@ -438,7 +535,9 @@ class IRCBot(irc.bot.SingleServerIRCBot):
self._pending_requests.pop(event_type, None)
def start(self):
self._dcc_processor = multiprocessing.Process(target=self._dcc_connect_processor)
self._dcc_processor = multiprocessing.Process(
target=self._dcc_connect_processor
)
self._dcc_processor.start()
for event_code in irc_event_codes.keys():
@ -452,7 +551,9 @@ class IRCBot(irc.bot.SingleServerIRCBot):
def stop(self, msg: Optional[str] = None):
msg = msg or self.stop_message
for addr, port in list(self._dcc_recv_procs.keys()) + list(self._dcc_send_procs.keys()):
for addr, port in list(self._dcc_recv_procs.keys()) + list(
self._dcc_send_procs.keys()
):
self._stop_dcc_connection(addr, port)
if self._dcc_processor and self._dcc_processor.is_alive():

View file

@ -18,7 +18,7 @@ manifest:
platypush.message.event.irc.IRCDCCFileRecvCancelledEvent: when a DCC file download is cancelled
platypush.message.event.irc.IRCDCCFileSendCompletedEvent: when a DCC file upload is completed
platypush.message.event.irc.IRCDCCFileSendCancelledEvent: when a DCC file upload is cancelled
package: platypush.plugins.chat.irc
package: platypush.plugins.irc
type: plugin
install:
apt: