diff --git a/docs/source/platypush/plugins/chat.irc.rst b/docs/source/platypush/plugins/chat.irc.rst deleted file mode 100644 index fa61969983..0000000000 --- a/docs/source/platypush/plugins/chat.irc.rst +++ /dev/null @@ -1,5 +0,0 @@ -``chat.irc`` -============ - -.. automodule:: platypush.plugins.chat.irc - :members: diff --git a/docs/source/platypush/plugins/irc.rst b/docs/source/platypush/plugins/irc.rst new file mode 100644 index 0000000000..629eeb4b83 --- /dev/null +++ b/docs/source/platypush/plugins/irc.rst @@ -0,0 +1,5 @@ +``irc`` +======= + +.. automodule:: platypush.plugins.irc + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index edb7d3e141..6b0bdff40c 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -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 diff --git a/platypush/plugins/chat/irc/__init__.py b/platypush/plugins/irc/__init__.py similarity index 91% rename from platypush/plugins/chat/irc/__init__.py rename to platypush/plugins/irc/__init__.py index ef86239849..8015fe2e44 100644 --- a/platypush/plugins/chat/irc/__init__.py +++ b/platypush/plugins/irc/__init__.py @@ -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,21 +184,23 @@ 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( - { - 'is_invite_only': channel.is_invite_only(), - 'is_moderated': channel.is_moderated(), - 'is_protected': channel.is_protected(), - 'is_secret': channel.is_secret(), - 'name': channel_name, - 'modes': channel.modes, - 'opers': list(channel.opers()), - 'owners': channel.owners(), - 'users': list(channel.users()), - 'voiced': list(channel.voiced()), - } + ch = bot.channels.get(channel) + assert ch, f'Not connected to channel {channel}' + return dict( + IRCChannelSchema().dump( + { + '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': ch.modes, + 'opers': list(ch.opers()), + 'owners': ch.owners(), + 'users': list(ch.users()), + 'voiced': list(ch.voiced()), + } + ) ) @action diff --git a/platypush/plugins/chat/irc/_bot.py b/platypush/plugins/irc/_bot.py similarity index 70% rename from platypush/plugins/chat/irc/_bot.py rename to platypush/plugins/irc/_bot.py index ac0dc97c82..946081a62a 100644 --- a/platypush/plugins/chat/irc/_bot.py +++ b/platypush/plugins/irc/_bot.py @@ -8,37 +8,66 @@ 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, - 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_ip_whitelist: Optional[Sequence[str]] = None, - dcc_ip_blacklist: Optional[Sequence[str]] = None, - dcc_nick_whitelist: Optional[Sequence[str]] = None, - dcc_nick_blacklist: Optional[Sequence[str]] = None, - dcc_max_connections: Optional[int] = None, + 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_ip_whitelist: Optional[Sequence[str]] = None, + dcc_ip_blacklist: Optional[Sequence[str]] = None, + dcc_nick_whitelist: Optional[Sequence[str]] = None, + dcc_nick_blacklist: Optional[Sequence[str]] = None, + dcc_max_connections: Optional[int] = None, ): connection_factory = ConnectionFactory() if ssl: @@ -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(): diff --git a/platypush/plugins/chat/irc/manifest.yaml b/platypush/plugins/irc/manifest.yaml similarity index 97% rename from platypush/plugins/chat/irc/manifest.yaml rename to platypush/plugins/irc/manifest.yaml index d64aeb4adc..00623d07d9 100644 --- a/platypush/plugins/chat/irc/manifest.yaml +++ b/platypush/plugins/irc/manifest.yaml @@ -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: