forked from platypush/platypush
Merge branch '203-irc-integration' into 'master'
Resolve "IRC integration" Closes #203 See merge request platypush/platypush!9
This commit is contained in:
commit
82fe4d93f3
14 changed files with 1230 additions and 3 deletions
|
@ -7,10 +7,12 @@ Given the high speed of development in the first phase, changes are being report
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added Mastodon integration.
|
- Added `mastodon` plugin.
|
||||||
|
- Added `chat.irc` plugin.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `switchbot.status` method in case of virtual devices.
|
||||||
- Fixed `switchbot.status` method in case of virtual devices.
|
- Fixed `switchbot.status` method in case of virtual devices.
|
||||||
|
|
||||||
## [0.22.4] - 2021-10-19
|
## [0.22.4] - 2021-10-19
|
||||||
|
|
|
@ -281,6 +281,12 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
|
||||||
'aiohttp',
|
'aiohttp',
|
||||||
'watchdog',
|
'watchdog',
|
||||||
'pyngrok',
|
'pyngrok',
|
||||||
|
'irc',
|
||||||
|
'irc.bot',
|
||||||
|
'irc.strings',
|
||||||
|
'irc.client',
|
||||||
|
'irc.connection',
|
||||||
|
'irc.events',
|
||||||
]
|
]
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../..'))
|
sys.path.insert(0, os.path.abspath('../..'))
|
||||||
|
|
|
@ -32,6 +32,7 @@ Events
|
||||||
platypush/events/http.hook.rst
|
platypush/events/http.hook.rst
|
||||||
platypush/events/http.rss.rst
|
platypush/events/http.rss.rst
|
||||||
platypush/events/inotify.rst
|
platypush/events/inotify.rst
|
||||||
|
platypush/events/irc.rst
|
||||||
platypush/events/joystick.rst
|
platypush/events/joystick.rst
|
||||||
platypush/events/kafka.rst
|
platypush/events/kafka.rst
|
||||||
platypush/events/light.rst
|
platypush/events/light.rst
|
||||||
|
|
5
docs/source/platypush/events/irc.rst
Normal file
5
docs/source/platypush/events/irc.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``platypush.message.event.irc``
|
||||||
|
===============================
|
||||||
|
|
||||||
|
.. automodule:: platypush.message.event.irc
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/chat.irc.rst
Normal file
5
docs/source/platypush/plugins/chat.irc.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``chat.irc``
|
||||||
|
============
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.chat.irc
|
||||||
|
:members:
|
|
@ -23,6 +23,7 @@ Plugins
|
||||||
platypush/plugins/camera.gstreamer.rst
|
platypush/plugins/camera.gstreamer.rst
|
||||||
platypush/plugins/camera.ir.mlx90640.rst
|
platypush/plugins/camera.ir.mlx90640.rst
|
||||||
platypush/plugins/camera.pi.rst
|
platypush/plugins/camera.pi.rst
|
||||||
|
platypush/plugins/chat.irc.rst
|
||||||
platypush/plugins/chat.telegram.rst
|
platypush/plugins/chat.telegram.rst
|
||||||
platypush/plugins/clipboard.rst
|
platypush/plugins/clipboard.rst
|
||||||
platypush/plugins/config.rst
|
platypush/plugins/config.rst
|
||||||
|
|
|
@ -39,7 +39,9 @@ class Event(Message):
|
||||||
self.disable_web_clients_notification = disable_web_clients_notification
|
self.disable_web_clients_notification = disable_web_clients_notification
|
||||||
|
|
||||||
for arg, value in self.args.items():
|
for arg, value in self.args.items():
|
||||||
if arg != 'args':
|
if arg not in [
|
||||||
|
'id', 'args', 'origin', 'target', 'type', 'timestamp', 'disable_logging'
|
||||||
|
] and not arg.startswith('_'):
|
||||||
self.__setattr__(arg, value)
|
self.__setattr__(arg, value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
184
platypush/message/event/irc.py
Normal file
184
platypush/message/event/irc.py
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
from abc import ABC
|
||||||
|
from base64 import b64encode
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.message.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class IRCEvent(Event, ABC):
|
||||||
|
"""
|
||||||
|
IRC base event.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, server: Optional[str] = None, port: Optional[int] = None,
|
||||||
|
alias: Optional[str] = None, channel: Optional[str] = None, **kwargs):
|
||||||
|
super().__init__(*args, server=server, port=port, alias=alias, channel=channel, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCChannelJoinEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered upon account channel join.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, nick: str, **kwargs):
|
||||||
|
super().__init__(*args, nick=nick, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCChannelKickEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered upon account channel kick.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, target_nick: str, source_nick: Optional[str] = None, **kwargs):
|
||||||
|
super().__init__(*args, source_nick=source_nick, target_nick=target_nick, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCModeEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when the IRC mode of a channel user changes.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, *args, mode: str, channel: Optional[str] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
target_: Optional[str] = None, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(*args, mode=mode, channel=channel, source=source, target_=target_, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCPartEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when an IRC nick parts.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, nick: str, **kwargs):
|
||||||
|
super().__init__(*args, nick=nick, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCQuitEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when an IRC nick quits.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, nick: str, **kwargs):
|
||||||
|
super().__init__(*args, nick=nick, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCNickChangeEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a IRC nick changes.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, before: str, after: str, **kwargs):
|
||||||
|
super().__init__(*args, before=before, after=after, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCConnectEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered upon server connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDisconnectEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered upon server disconnection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IRCPrivateMessageEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a private message is received.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, text: str, nick: str, mentions_me: bool = False, **kwargs):
|
||||||
|
super().__init__(*args, text=text, nick=nick, mentions_me=mentions_me, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCPublicMessageEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a public message is received.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, text: str, nick: str, mentions_me: bool = False, **kwargs):
|
||||||
|
super().__init__(*args, text=text, nick=nick, mentions_me=mentions_me, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCRequestEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC connection request is received.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, address: str, port: int, nick: str, **kwargs):
|
||||||
|
super().__init__(*args, address=address, port=port, nick=nick, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCMessageEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC message is received.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, address: str, body: bytes, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
*args, address=address, body=b64encode(body).decode(), **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCCTCPMessageEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a CTCP message is received.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, address: str, message: str, **kwargs):
|
||||||
|
super().__init__(*args, address=address, message=message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCFileRequestEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC file send request is received.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, *args, nick: str, address: str, file: str,
|
||||||
|
port: int, size: Optional[int] = None, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
*args, nick=nick, address=address, file=file, port=port,
|
||||||
|
size=size, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCFileRecvCompletedEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC file transfer RECV is completed.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, *args, address: str, port: int, file: str,
|
||||||
|
size: Optional[int] = None, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
*args, address=address, file=file,
|
||||||
|
port=port, size=size, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCFileRecvCancelledEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC file transfer RECV is cancelled.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, *args, address: str, port: int, file: str,
|
||||||
|
error: str, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
*args, address=address, file=file, port=port,
|
||||||
|
error=error, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCFileSendCompletedEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC file transfer SEND is completed.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, address: str, port: int, file: str, **kwargs):
|
||||||
|
super().__init__(*args, address=address, file=file, port=port, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCDCCFileSendCancelledEvent(IRCEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a DCC file transfer SEND is cancelled.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, *args, address: str, port: int, file: str,
|
||||||
|
error: str, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
*args, address=address, file=file, port=port,
|
||||||
|
error=error, **kwargs
|
||||||
|
)
|
396
platypush/plugins/chat/irc/__init__.py
Normal file
396
platypush/plugins/chat/irc/__init__.py
Normal file
|
@ -0,0 +1,396 @@
|
||||||
|
import os
|
||||||
|
from typing import Sequence, Dict, Tuple, Union, Optional
|
||||||
|
|
||||||
|
from platypush.plugins import RunnablePlugin, action
|
||||||
|
from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema
|
||||||
|
|
||||||
|
from ._bot import IRCBot
|
||||||
|
from .. import ChatPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
||||||
|
"""
|
||||||
|
IRC integration.
|
||||||
|
|
||||||
|
This plugin allows you to easily create IRC bots with custom logic that reacts to IRC events
|
||||||
|
and interact with IRC sessions.
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
|
||||||
|
* :class:`platypush.message.event.irc.IRCChannelJoinEvent` when a user joins a channel.
|
||||||
|
* :class:`platypush.message.event.irc.IRCChannelKickEvent` when a user is kicked from a channel.
|
||||||
|
* :class:`platypush.message.event.irc.IRCModeEvent` when a user/channel mode change event occurs.
|
||||||
|
* :class:`platypush.message.event.irc.IRCPartEvent` when a user parts a channel.
|
||||||
|
* :class:`platypush.message.event.irc.IRCQuitEvent` when a user quits.
|
||||||
|
* :class:`platypush.message.event.irc.IRCNickChangeEvent` when a user nick changes.
|
||||||
|
* :class:`platypush.message.event.irc.IRCConnectEvent` when the bot connects to a server.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDisconnectEvent` when the bot disconnects from a server.
|
||||||
|
* :class:`platypush.message.event.irc.IRCPrivateMessageEvent` when a private message is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCPublicMessageEvent` when a public message is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCRequestEvent` when a DCC connection request is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCMessageEvent` when a DCC message is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCCTCPMessageEvent` when a CTCP message is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCFileRequestEvent` when a DCC file request is received.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCFileRecvCompletedEvent` when a DCC file download is completed.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCFileRecvCancelledEvent` when a DCC file download is cancelled.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCFileSendCompletedEvent` when a DCC file upload is completed.
|
||||||
|
* :class:`platypush.message.event.irc.IRCDCCFileSendCancelledEvent` when a DCC file upload is cancelled.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
|
||||||
|
* **irc** (``pip install irc``)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, servers: Sequence[dict], **kwargs):
|
||||||
|
"""
|
||||||
|
:param servers: List of servers/channels that the bot will automatically connect/join.
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Could not load IRC server configuration: {e}')
|
||||||
|
self.logger.exception(e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _bots_by_server(self) -> Dict[str, IRCBot]:
|
||||||
|
return {
|
||||||
|
bot.server: bot
|
||||||
|
for srv, bot in self._bots.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _bots_by_alias(self) -> Dict[str, IRCBot]:
|
||||||
|
return {
|
||||||
|
bot.alias: bot
|
||||||
|
for srv, bot in self._bots.items()
|
||||||
|
if bot.alias
|
||||||
|
}
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
self._connect()
|
||||||
|
self._should_stop.wait()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
for srv, bot in self._bots.items():
|
||||||
|
self.logger.info(f'Connecting to IRC server {srv}')
|
||||||
|
bot.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
for srv, bot in self._bots.items():
|
||||||
|
self.logger.info(f'Disconnecting from IRC server {srv}')
|
||||||
|
try:
|
||||||
|
bot.stop(bot.stop_message or 'Application stopped')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Error while stopping connection to {srv}: {e}')
|
||||||
|
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
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)]
|
||||||
|
else:
|
||||||
|
bot = self._bots_by_alias.get(server, self._bots_by_server.get(server))
|
||||||
|
|
||||||
|
assert bot, f'Bot connection to {server} not found'
|
||||||
|
return bot
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_file(
|
||||||
|
self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a file to an IRC user over DCC connection.
|
||||||
|
Note that passive connections are currently not supported.
|
||||||
|
|
||||||
|
:param file: Path of the file that should be transferred.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param nick: Target IRC nick.
|
||||||
|
:param bind_address: DCC listen bind address (default: any).
|
||||||
|
"""
|
||||||
|
file = os.path.expanduser(file)
|
||||||
|
assert os.path.isfile(file), f'{file} is not a regular file'
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.dcc_file_transfer(file=file, nick=nick, bind_address=bind_address)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_message(
|
||||||
|
self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a message to a channel or a nick.
|
||||||
|
|
||||||
|
:param text: Message content.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param target: Message target (nick or channel). If it's a list then the message will be sent
|
||||||
|
to multiple targets.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
method = (
|
||||||
|
bot.connection.privmsg if isinstance(target, str)
|
||||||
|
else bot.connection.privmsg_many
|
||||||
|
)
|
||||||
|
method(target, text)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_notice(
|
||||||
|
self, text: str, server: Union[str, Tuple[str, int]], target: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a notice to a channel or a nick.
|
||||||
|
|
||||||
|
:param text: Message content.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param target: Message target (nick or channel).
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.notice(target, text)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def servers(self) -> Sequence[dict]:
|
||||||
|
"""
|
||||||
|
Get information about the connected servers.
|
||||||
|
|
||||||
|
:return: .. schema:: irc.IRCServerStatusSchema(many=True)
|
||||||
|
"""
|
||||||
|
bots = self._bots_by_server.values()
|
||||||
|
return IRCServerStatusSchema().dump(
|
||||||
|
{
|
||||||
|
'server': bot.server,
|
||||||
|
'port': bot.port,
|
||||||
|
'alias': bot.alias,
|
||||||
|
'real_name': bot.connection.get_server_name(),
|
||||||
|
'nickname': bot.connection.get_nickname(),
|
||||||
|
'is_connected': bot.connection.is_connected(),
|
||||||
|
'connected_channels': bot.channels.keys(),
|
||||||
|
}
|
||||||
|
for bot in bots
|
||||||
|
)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def channel(self, server: Union[str, Tuple[str, int]], channel: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get information about a connected channel.
|
||||||
|
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param channel:
|
||||||
|
:return: .. schema:: irc.IRCChannelSchema
|
||||||
|
"""
|
||||||
|
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()),
|
||||||
|
})
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_ctcp_message(
|
||||||
|
self, ctcp_type: str, body: str, server: Union[str, Tuple[str, int]], target: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a CTCP message to a target.
|
||||||
|
|
||||||
|
:param ctcp_type: CTCP message type.
|
||||||
|
:param body: Message content.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param target: Message target.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.ctcp(ctcp_type, target, body)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_ctcp_reply(
|
||||||
|
self, body: str, server: Union[str, Tuple[str, int]], target: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a CTCP REPLY command.
|
||||||
|
|
||||||
|
:param body: Message content.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param target: Message target.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.ctcp_reply(target, body)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Disconnect from a server.
|
||||||
|
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param message: Disconnect message (default: configured ``stop_message``.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.disconnect(message or bot.stop_message)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def invite(
|
||||||
|
self, nick: str, channel: str, server: Union[str, Tuple[str, int]]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Invite a nick to a channel.
|
||||||
|
|
||||||
|
:param nick: Target IRC nick.
|
||||||
|
:param channel: Target IRC channel.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.invite(nick, channel)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def join(self, channel: str, server: Union[str, Tuple[str, int]]):
|
||||||
|
"""
|
||||||
|
Join a channel.
|
||||||
|
|
||||||
|
:param channel: Target IRC channel.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.join(channel)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def kick(
|
||||||
|
self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Kick a nick from a channel.
|
||||||
|
|
||||||
|
:param nick: Target IRC nick.
|
||||||
|
:param channel: Target IRC channel.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param reason: Kick reason.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.kick(channel, nick, reason)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def mode(
|
||||||
|
self, target: str, command: str, server: Union[str, Tuple[str, int]]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a MODE command on the selected target.
|
||||||
|
|
||||||
|
:param target: IRC target.
|
||||||
|
:param command: Mode command.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.mode(target, command)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def set_nick(self, nick: str, server: Union[str, Tuple[str, int]]):
|
||||||
|
"""
|
||||||
|
Set the IRC nick.
|
||||||
|
|
||||||
|
:param nick: New IRC nick.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.nick(nick)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def oper(self, nick: str, password: str, server: Union[str, Tuple[str, int]]):
|
||||||
|
"""
|
||||||
|
Send an OPER command.
|
||||||
|
|
||||||
|
:param nick: IRC nick.
|
||||||
|
:param password: Nick password.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.oper(nick, password)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def part(
|
||||||
|
self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]],
|
||||||
|
message: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Parts/exits a channel.
|
||||||
|
|
||||||
|
:param channel: IRC channel (or list of channels).
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param message: Optional part message (default: same as the bot's ``stop_message``).
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
channels = [channel] if isinstance(channel, str) else channel
|
||||||
|
bot.connection.part(channels=channels, message=message or bot.stop_message)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def quit(
|
||||||
|
self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a QUIT command.
|
||||||
|
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param message: Optional quit message (default: same as the bot's ``stop_message``).
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.quit(message=message or bot.stop_message)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_raw(self, message: str, server: Union[str, Tuple[str, int]]):
|
||||||
|
"""
|
||||||
|
Send a raw IRC message to a connected server.
|
||||||
|
|
||||||
|
:param message: IRC message.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
bot.connection.send_raw(message)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Get/set the topic of an IRC channel.
|
||||||
|
|
||||||
|
:param channel: IRC channel.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
:param topic: If specified, then set the new topic as channel topic.
|
||||||
|
Otherwise, just return the current channel topic.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
with bot.event_queue('currenttopic') as evt_queue:
|
||||||
|
bot.connection.topic(channel, topic)
|
||||||
|
evt = evt_queue.get(block=True, timeout=bot.response_timeout)
|
||||||
|
return evt.arguments[1]
|
||||||
|
|
||||||
|
@action
|
||||||
|
def whois(self, target: str, server: Union[str, Tuple[str, int]]):
|
||||||
|
"""
|
||||||
|
Send a WHOIS command for a target.
|
||||||
|
|
||||||
|
:param target: IRC target.
|
||||||
|
:param server: IRC server, identified either by ``alias`` or ``(server, port)`` tuple.
|
||||||
|
"""
|
||||||
|
bot = self._get_bot(server)
|
||||||
|
with bot.event_queue('whoisuser') as evt_queue:
|
||||||
|
bot.connection.whois([target])
|
||||||
|
evt = evt_queue.get(block=True, timeout=bot.response_timeout)
|
||||||
|
return evt.arguments
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
462
platypush/plugins/chat/irc/_bot.py
Normal file
462
platypush/plugins/chat/irc/_bot.py
Normal file
|
@ -0,0 +1,462 @@
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import ssl as _ssl
|
||||||
|
import struct
|
||||||
|
from typing import 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.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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
):
|
||||||
|
connection_factory = ConnectionFactory()
|
||||||
|
if ssl:
|
||||||
|
connection_factory.wrapper = _ssl.wrap_socket
|
||||||
|
if ipv6:
|
||||||
|
connection_factory.family = socket.AF_INET6
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
server_list=[(server, port)], nickname=nickname,
|
||||||
|
realname=realname, connect_factory=connection_factory
|
||||||
|
)
|
||||||
|
|
||||||
|
self.server = server
|
||||||
|
self.port = port
|
||||||
|
self.alias = alias
|
||||||
|
self._password = password
|
||||||
|
self.channels.update({channel: None for channel in channels})
|
||||||
|
self._stop_message = stop_message
|
||||||
|
self.dcc_ip_whitelist = set(dcc_ip_whitelist or [])
|
||||||
|
self.dcc_ip_blacklist = set(dcc_ip_blacklist or [])
|
||||||
|
self.dcc_nick_whitelist = set(dcc_nick_whitelist or [])
|
||||||
|
self.dcc_nick_blacklist = set(dcc_nick_blacklist or [])
|
||||||
|
self.dcc_downloads_dir = dcc_downloads_dir
|
||||||
|
self.response_timeout = response_timeout
|
||||||
|
self.dcc_file_transfer_timeout = dcc_file_transfer_timeout
|
||||||
|
self.dcc_accept_timeout = dcc_accept_timeout
|
||||||
|
self.dcc_max_connections = dcc_max_connections
|
||||||
|
self._dcc_send_procs: Dict[Tuple[str, int], multiprocessing.Process] = {}
|
||||||
|
self._dcc_recv_procs: Dict[Tuple[str, int], multiprocessing.Process] = {}
|
||||||
|
self._dcc_proc_completion_queue = multiprocessing.Queue()
|
||||||
|
self._dcc_processor: Optional[multiprocessing.Process] = None
|
||||||
|
self.logger = logging.getLogger(f'irc@{server}')
|
||||||
|
# Maps <matching_event_type> -> <response_queue>
|
||||||
|
self._pending_requests: Dict[str, multiprocessing.Queue] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stop_message(self) -> Optional[str]:
|
||||||
|
return self._stop_message
|
||||||
|
|
||||||
|
def _post_event(self, connection: Connection, output_event_type: Type[IRCEvent], **kwargs):
|
||||||
|
if isinstance(connection, ServerConnection):
|
||||||
|
kwargs['server'] = connection.server
|
||||||
|
kwargs['port'] = connection.port
|
||||||
|
|
||||||
|
kwargs['connected'] = connection.connected
|
||||||
|
kwargs['alias'] = self.alias
|
||||||
|
event = output_event_type(**kwargs)
|
||||||
|
get_bus().post(event)
|
||||||
|
|
||||||
|
def on_join(self, connection: ServerConnection, event: _IRCEvent):
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
super()._on_nick(connection, event)
|
||||||
|
|
||||||
|
def on_mode(self, connection: ServerConnection, event: _IRCEvent):
|
||||||
|
self._post_event(
|
||||||
|
connection, IRCModeEvent, mode=event.arguments[0],
|
||||||
|
source=event.source.nick,
|
||||||
|
target_=(event.arguments[1] if len(event.arguments) > 1 else None),
|
||||||
|
channel=event.target,
|
||||||
|
)
|
||||||
|
|
||||||
|
super()._on_mode(connection, event)
|
||||||
|
|
||||||
|
def on_part(self, connection: Connection, event: _IRCEvent):
|
||||||
|
self._post_event(connection, IRCPartEvent, nick=event.source.nick)
|
||||||
|
super()._on_part(connection, event)
|
||||||
|
|
||||||
|
def on_quit(self, connection: Connection, event: _IRCEvent):
|
||||||
|
self._post_event(connection, IRCQuitEvent, nick=event.source.nick)
|
||||||
|
super()._on_quit(connection, event)
|
||||||
|
|
||||||
|
def on_welcome(self, connection: Connection, *_):
|
||||||
|
self._post_event(connection, IRCConnectEvent)
|
||||||
|
for channel in self.channels:
|
||||||
|
self.logger.info(f'Joining channel {channel}')
|
||||||
|
self.connection.join(channel)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def on_nicknameinuse(connection: ServerConnection, *_):
|
||||||
|
connection.nick(connection.nickname + '_')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mentions_me(connection: ServerConnection, text: str) -> bool:
|
||||||
|
return bool(re.search(fr'(^|\s|@){connection.nickname}(:|\s|!|\?|$)', text))
|
||||||
|
|
||||||
|
def on_pubmsg(self, connection: ServerConnection, event: _IRCEvent):
|
||||||
|
self._post_event(
|
||||||
|
connection, IRCPublicMessageEvent,
|
||||||
|
text=event.arguments[0], nick=event.source.nick,
|
||||||
|
channel=event.target,
|
||||||
|
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,
|
||||||
|
channel=event.target,
|
||||||
|
mentions_me=self._mentions_me(connection, event.arguments[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_dccchat(self, connection: DCCConnection, event: _IRCEvent):
|
||||||
|
if len(event.arguments) != 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
args = event.arguments[1].split()
|
||||||
|
if len(args) == 4:
|
||||||
|
try:
|
||||||
|
address = ip_numstr_to_quad(args[2])
|
||||||
|
port = int(args[3])
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
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}')
|
||||||
|
connection.disconnect('Unauthorized peer')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._post_event(
|
||||||
|
connection, IRCDCCRequestEvent, address=address, port=port, nick=nick
|
||||||
|
)
|
||||||
|
|
||||||
|
self.dcc('chat').connect(address, port)
|
||||||
|
|
||||||
|
def _read(self, sock: socket.socket, bufsize: int = 4096) -> bytes:
|
||||||
|
if self.dcc_file_transfer_timeout:
|
||||||
|
sock.setblocking(False)
|
||||||
|
has_data = select.select([sock], [], [], self.dcc_file_transfer_timeout)
|
||||||
|
if has_data[0]:
|
||||||
|
return sock.recv(bufsize)
|
||||||
|
raise TimeoutError(f'Time out ({self.dcc_file_transfer_timeout}s)')
|
||||||
|
|
||||||
|
return sock.recv(bufsize)
|
||||||
|
|
||||||
|
def _dcc_connect_processor(self):
|
||||||
|
while True:
|
||||||
|
proc_type, addr, port = self._dcc_proc_completion_queue.get()
|
||||||
|
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):
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock, \
|
||||||
|
open(filename, 'wb') as f:
|
||||||
|
try:
|
||||||
|
sock.connect((address, port))
|
||||||
|
processed_bytes = 0
|
||||||
|
|
||||||
|
while processed_bytes < size:
|
||||||
|
buf = self._read(sock)
|
||||||
|
processed_bytes += len(buf)
|
||||||
|
f.write(buf)
|
||||||
|
sock.send(f'{socket.htonl(processed_bytes)}'.encode())
|
||||||
|
|
||||||
|
self._post_event(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.disconnect()
|
||||||
|
self._dcc_proc_completion_queue.put(('recv', address, port))
|
||||||
|
|
||||||
|
def _process_dcc_send(self, connection: DCCConnection, filename: str):
|
||||||
|
try:
|
||||||
|
dcc_sock, addr = connection.socket.accept()
|
||||||
|
self._set_accept_timeout(dcc_sock)
|
||||||
|
self.logger.info(f'Accepted DCC connection from {addr}')
|
||||||
|
|
||||||
|
# Create a new server socket for the file transfer
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv_sock:
|
||||||
|
srv_sock.bind((connection.localaddress, 0))
|
||||||
|
address, transfer_port = srv_sock.getsockname()
|
||||||
|
dcc_sock.send(
|
||||||
|
(
|
||||||
|
f'CTCP_MESSAGE \x01'
|
||||||
|
f'DCC SEND {os.path.basename(filename)} '
|
||||||
|
f'{ip_quad_to_numstr(address)} {transfer_port} '
|
||||||
|
f'{os.path.getsize(filename)}\x01\r\n'
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for a connection on the file transfer port
|
||||||
|
self._set_accept_timeout(srv_sock)
|
||||||
|
srv_sock.listen(1)
|
||||||
|
client_sock, addr = srv_sock.accept()
|
||||||
|
self.logger.info(f'DCC file transfer for {filename} accepted on {addr}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
buf = f.read(4096)
|
||||||
|
while buf:
|
||||||
|
client_sock.send(buf)
|
||||||
|
recv_bytes = struct.unpack('>i', self._read(client_sock))[0]
|
||||||
|
buf = buf[recv_bytes:]
|
||||||
|
|
||||||
|
if not buf:
|
||||||
|
buf = f.read(4096)
|
||||||
|
|
||||||
|
self._post_event(
|
||||||
|
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.exception(e)
|
||||||
|
self._post_event(
|
||||||
|
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))
|
||||||
|
|
||||||
|
def _set_accept_timeout(self, sock: socket.socket):
|
||||||
|
if self.dcc_accept_timeout:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.settimeout(self.dcc_accept_timeout)
|
||||||
|
|
||||||
|
def _stop_dcc_connection(self, address: str, port: int):
|
||||||
|
self.logger.info(f'Stopping DCC connection to {address}:{port}')
|
||||||
|
proc = self._dcc_recv_procs.pop((address, port), None)
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
filename = ' '.join(ctcp_msg[2:-3])
|
||||||
|
address, port, size = ctcp_msg[-3:]
|
||||||
|
|
||||||
|
if not (filename and address and port):
|
||||||
|
return
|
||||||
|
|
||||||
|
address = ip_numstr_to_quad(address)
|
||||||
|
port = int(port)
|
||||||
|
size = int(size)
|
||||||
|
|
||||||
|
if port == 0:
|
||||||
|
self.logger.warning('Passive CTCP file transfer is currently not supported')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._post_event(
|
||||||
|
connection, IRCDCCFileRequestEvent,
|
||||||
|
address=address,
|
||||||
|
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={
|
||||||
|
'connection': connection,
|
||||||
|
'address': address,
|
||||||
|
'port': port,
|
||||||
|
'filename': filename,
|
||||||
|
'size': size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self._dcc_recv_procs[(address, port)].start()
|
||||||
|
|
||||||
|
def _handle_ctcp_message(self, connection: DCCConnection, event: _IRCEvent):
|
||||||
|
body = event.arguments[0]
|
||||||
|
sep_pos = [i for i, ch in enumerate(body) if ch == ord(b'\x01')]
|
||||||
|
msg = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = [
|
||||||
|
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):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not msg:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if this is a DCC file send request
|
||||||
|
if msg[0].startswith('DCC SEND '):
|
||||||
|
self._accept_dcc_file_request(connection, msg[0], event.source)
|
||||||
|
else:
|
||||||
|
self._post_event(
|
||||||
|
connection, IRCCTCPMessageEvent, address=event.source, message=msg
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_dccmsg(self, connection: DCCConnection, event: _IRCEvent):
|
||||||
|
ctcp_header = b'CTCP_MESSAGE'
|
||||||
|
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])
|
||||||
|
|
||||||
|
def on_disconnect(self, connection: Connection, event: _IRCEvent):
|
||||||
|
self._post_event(connection, IRCDisconnectEvent)
|
||||||
|
# Cache channels for reconnect logic
|
||||||
|
channels = {ch: None for ch in self.channels.keys()}
|
||||||
|
super()._on_disconnect(connection, event)
|
||||||
|
self.channels.update(channels)
|
||||||
|
|
||||||
|
def on_whoisuser(self, _, event: _IRCEvent):
|
||||||
|
self._process_event_on_queue(event)
|
||||||
|
|
||||||
|
def on_currenttopic(self, _, event: _IRCEvent):
|
||||||
|
self._process_event_on_queue(event)
|
||||||
|
|
||||||
|
def _process_event_on_queue(self, event: _IRCEvent):
|
||||||
|
q = self._pending_requests.get(event.type)
|
||||||
|
if q:
|
||||||
|
q.put(event)
|
||||||
|
|
||||||
|
def _on_generic_event(self, _, event: _IRCEvent):
|
||||||
|
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:
|
||||||
|
return False
|
||||||
|
if self.dcc_ip_blacklist and address in self.dcc_ip_blacklist:
|
||||||
|
return False
|
||||||
|
if self.dcc_nick_whitelist and nick not in self.dcc_nick_whitelist:
|
||||||
|
return False
|
||||||
|
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:
|
||||||
|
self.logger.warning(
|
||||||
|
f'Refused new DCC connection: maximum number of concurrent '
|
||||||
|
f'connections ({self.dcc_max_connections}) reached'
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
q = self._pending_requests[event_type] = multiprocessing.Queue()
|
||||||
|
try:
|
||||||
|
yield q
|
||||||
|
finally:
|
||||||
|
q.close()
|
||||||
|
self._pending_requests.pop(event_type, None)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._dcc_processor = multiprocessing.Process(target=self._dcc_connect_processor)
|
||||||
|
self._dcc_processor.start()
|
||||||
|
|
||||||
|
for event_code in irc_event_codes.keys():
|
||||||
|
handler = getattr(self, f'on_{event_code}', None)
|
||||||
|
if not handler:
|
||||||
|
self.reactor.add_global_handler(
|
||||||
|
event_code, lambda conn, evt: self._on_generic_event(conn, evt)
|
||||||
|
)
|
||||||
|
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
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()):
|
||||||
|
self._stop_dcc_connection(addr, port)
|
||||||
|
|
||||||
|
if self._dcc_processor and self._dcc_processor.is_alive():
|
||||||
|
self._dcc_processor.terminate()
|
||||||
|
self._dcc_processor = None
|
||||||
|
|
||||||
|
super().disconnect(msg=msg)
|
25
platypush/plugins/chat/irc/manifest.yaml
Normal file
25
platypush/plugins/chat/irc/manifest.yaml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
manifest:
|
||||||
|
events:
|
||||||
|
platypush.message.event.irc.IRCChannelJoinEvent: when a user joins a channel
|
||||||
|
platypush.message.event.irc.IRCChannelKickEvent: when a user is kicked from a channel
|
||||||
|
platypush.message.event.irc.IRCModeEvent: when a user/channel mode change event occurs
|
||||||
|
platypush.message.event.irc.IRCPartEvent: when a user parts a channel
|
||||||
|
platypush.message.event.irc.IRCQuitEvent: when a user quits
|
||||||
|
platypush.message.event.irc.IRCNickChangeEvent: when a user nick changes
|
||||||
|
platypush.message.event.irc.IRCConnectEvent: when the bot connects to a server
|
||||||
|
platypush.message.event.irc.IRCDisconnectEvent: when the bot disconnects from a server
|
||||||
|
platypush.message.event.irc.IRCPrivateMessageEvent: when a private message is received
|
||||||
|
platypush.message.event.irc.IRCPublicMessageEvent: when a public message is received
|
||||||
|
platypush.message.event.irc.IRCDCCRequestEvent: when a DCC connection request is received
|
||||||
|
platypush.message.event.irc.IRCDCCMessageEvent: when a DCC message is received
|
||||||
|
platypush.message.event.irc.IRCCTCPMessageEvent: when a CTCP message is received
|
||||||
|
platypush.message.event.irc.IRCDCCFileRequestEvent: when a DCC file request is received
|
||||||
|
platypush.message.event.irc.IRCDCCFileRecvCompletedEvent: when a DCC file download is completed
|
||||||
|
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
|
||||||
|
type: plugin
|
||||||
|
install:
|
||||||
|
pip:
|
||||||
|
- irc
|
|
@ -1,4 +1,5 @@
|
||||||
manifest:
|
manifest:
|
||||||
events: {}
|
events:
|
||||||
|
platypush.message.event.gotify.GotifyMessageEvent: when a new message is received.
|
||||||
package: platypush.plugins.gotify
|
package: platypush.plugins.gotify
|
||||||
type: plugin
|
type: plugin
|
||||||
|
|
135
platypush/schemas/irc.py
Normal file
135
platypush/schemas/irc.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from marshmallow import fields
|
||||||
|
from marshmallow.schema import Schema
|
||||||
|
|
||||||
|
from platypush.schemas import StrippedString
|
||||||
|
|
||||||
|
|
||||||
|
class IRCServerSchema(Schema):
|
||||||
|
server = fields.String(
|
||||||
|
required=True,
|
||||||
|
metadata=dict(
|
||||||
|
description='Server address or hostname',
|
||||||
|
example='irc.example.org',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
port = fields.Int(
|
||||||
|
missing=6667,
|
||||||
|
metadata=dict(
|
||||||
|
description='IRC server port',
|
||||||
|
example=6667,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
password = fields.String(allow_none=True, default=None, metadata=dict(example='password'))
|
||||||
|
nickname = fields.String(required=True, metadata=dict(example='testbot'))
|
||||||
|
realname = fields.String(allow_none=True, metadata=dict(example='My Real Name'))
|
||||||
|
alias = StrippedString(
|
||||||
|
metadata=dict(description='Friendly name for this bot/server connection')
|
||||||
|
)
|
||||||
|
|
||||||
|
channels = fields.List(
|
||||||
|
fields.String(),
|
||||||
|
missing=list,
|
||||||
|
metadata=dict(
|
||||||
|
description='List of channels the bot will connect to',
|
||||||
|
example=['#channel1', '#channel2', '#channel3']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ssl = fields.Boolean(missing=False)
|
||||||
|
ipv6 = fields.Boolean(missing=False)
|
||||||
|
stop_message = StrippedString(missing='Application stopped', metadata=dict(description='Quit/die message'))
|
||||||
|
|
||||||
|
dcc_ip_whitelist = fields.List(
|
||||||
|
fields.String,
|
||||||
|
missing=list,
|
||||||
|
metadata=dict(
|
||||||
|
description='If specified then only DCC connections from the IP addresses on this list will be accepted',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_ip_blacklist = fields.List(
|
||||||
|
fields.String,
|
||||||
|
missing=list,
|
||||||
|
metadata=dict(
|
||||||
|
description='If specified then DCC connections from the IP addresses on this list will be rejected',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_nick_whitelist = fields.List(
|
||||||
|
fields.String,
|
||||||
|
missing=list,
|
||||||
|
metadata=dict(
|
||||||
|
description='If specified then only DCC connections from the nicknames on this list will be accepted',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_nick_blacklist = fields.List(
|
||||||
|
fields.String,
|
||||||
|
missing=list,
|
||||||
|
metadata=dict(
|
||||||
|
description='If specified then DCC connections from the nicknames on this list will be rejected',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_downloads_dir = fields.String(
|
||||||
|
missing=os.path.join(os.path.expanduser('~'), 'Downloads'),
|
||||||
|
metadata=dict(description='DCC file transfers will be downloaded to this folder (default: ~/Downloads)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
response_timeout = fields.Number(
|
||||||
|
missing=30.,
|
||||||
|
metadata=dict(
|
||||||
|
description='How long we should wait for a response to an IRC request '
|
||||||
|
'(default: 30 seconds)',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_file_transfer_timeout = fields.Number(
|
||||||
|
missing=30.,
|
||||||
|
metadata=dict(
|
||||||
|
description='How long we should wait on a pending DCC file transfer with '
|
||||||
|
'no data being transmitted (default: 30 seconds)',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_accept_timeout = fields.Number(
|
||||||
|
missing=300.,
|
||||||
|
metadata=dict(
|
||||||
|
description='How long we should wait on a pending DCC request '
|
||||||
|
'until the user accepts (default: 300 seconds)',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dcc_max_connections = fields.Int(
|
||||||
|
missing=10,
|
||||||
|
metadata=dict(
|
||||||
|
description='Maximum number of concurrent DCC connections allowed on this bot (default: 10)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCServerStatusSchema(Schema):
|
||||||
|
server = StrippedString(required=True)
|
||||||
|
port = fields.Int(required=True)
|
||||||
|
alias = StrippedString()
|
||||||
|
real_name = fields.String()
|
||||||
|
nickname = fields.String()
|
||||||
|
is_connected = fields.Boolean()
|
||||||
|
connected_channels = fields.List(fields.String)
|
||||||
|
|
||||||
|
|
||||||
|
class IRCChannelSchema(Schema):
|
||||||
|
name = fields.String(required=True)
|
||||||
|
modes = fields.List(fields.String)
|
||||||
|
opers = fields.List(fields.String)
|
||||||
|
owners = fields.List(fields.String)
|
||||||
|
users = fields.List(fields.String)
|
||||||
|
voiced = fields.List(fields.String)
|
||||||
|
is_invite_only = fields.Boolean()
|
||||||
|
is_moderated = fields.Boolean()
|
||||||
|
is_protected = fields.Boolean()
|
||||||
|
is_secret = fields.Boolean()
|
2
setup.py
2
setup.py
|
@ -252,5 +252,7 @@ setup(
|
||||||
'pca9685': ['adafruit-python-shell', 'adafruit-circuitpython-pca9685'],
|
'pca9685': ['adafruit-python-shell', 'adafruit-circuitpython-pca9685'],
|
||||||
# Support for ngrok integration
|
# Support for ngrok integration
|
||||||
'ngrok': ['pyngrok'],
|
'ngrok': ['pyngrok'],
|
||||||
|
# Support for IRC integration
|
||||||
|
'irc': ['irc'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue