# -*- coding: utf-8 -*- from collections import OrderedDict import re from irc3.asynchronous import AsyncEvents # Corrected import path from irc3 import utils from irc3 import dec __doc__ = """ ====================================================== :mod:`irc3.plugins.asynchronious` Asynchronious events ====================================================== This module provide a way to catch data from various predefined events. Usage ===== You'll have to define a subclass of :class:`~irc3.asynchronous.AsyncEvents`: .. literalinclude:: ../../irc3/plugins/asynchronious.py :pyobject: Whois Notice that regexps and send_line contains some `{nick}`. This will be substitued later with the keyword arguments passed to the instance. Then you're able to use it in a plugin: .. code-block:: py class MyPlugin: def __init__(self, bot): self.bot = bot self.whois = Whois(bot) def do_whois(self): # remember {nick} in the regexp? Here it is whois = await self.whois(nick='gawel') if int(whois['idle']) / 60 > 10: self.bot.privmsg('gawel', 'Wake up dude') .. warning:: Your code should always check if the result has been set before timeout by using `result['timeout']` which is True when the bot failed to get a result before 30s (you can override the default value per call) .. warning:: Do not over use this feature. If you're making a lot of calls at the same time you should experience some weird behavior since irc do not allow to identify responses for a command. That's why the exemple use {nick} in the regexp to filter events efficiently. But two concurent call for the same nick can still fail. API === .. autoclass:: irc3.asynchronous.AsyncEvents :members: process_results, __call__ .. autoclass:: Async :members: """ class Whois(AsyncEvents): """Asynchronously handle WHOIS responses from the IRC server.""" # Command timeout in seconds timeout = 20 # Line sent to trigger WHOIS send_line = 'WHOIS {nick} {nick}' # Regex patterns to match server responses events = ( {'match': r"(?i)^:\S+ 301 \S+ {nick} :(?P.*)"}, { 'match': ( r"(?i)^:\S+ 311 \S+ {nick} (?P\S+) " r"(?P\S+) . :(?P.*)" ) }, { 'match': ( r"(?i)^:\S+ 312 \S+ {nick} (?P\S+) " r":(?P.*)" ) }, {'match': r"(?i)^:\S+ 317 \S+ {nick} (?P[0-9]+).*"}, { 'match': r"(?i)^:\S+ 319 \S+ {nick} :(?P.*)", 'multi': True }, { 'match': ( r"(?i)^:\S+ 330 \S+ {nick} (?P\S+) " r":(?P.*)" ) }, {'match': r"(?i)^:\S+ 671 \S+ {nick} :(?P.*)"}, { 'match': ( r"(?i)^:\S+ (?P(318|401)) \S+ (?P{nick}) :.*" ), 'final': True }, ) def process_results(self, results=None, **value): """Process WHOIS results into a structured dictionary. Args: results (list): List of event results. **value: Accumulated results. Returns: dict: Processed WHOIS data with channels, success flag, etc. """ channels = [] for res in results: channels.extend(res.pop('channels', '').split()) value.update(res) value['channels'] = channels value['success'] = value.get('retcode') == '318' return value class WhoChannel(AsyncEvents): """Handle WHO responses for a channel.""" send_line = 'WHO {channel}' events = ( { 'match': ( r"(?i)^:\S+ 352 \S+ {channel} (?P\S+) " r"(?P\S+) (?P\S+) (?P\S+) " r"(?P\S+) :(?P\S+) (?P.*)" ), 'multi': True }, { 'match': r"(?i)^:\S+ (?P(315|401)) \S+ {channel} :.*", 'final': True }, ) def process_results(self, results=None, **value): """Process WHO channel results into a user list.""" users = [] for res in results: if 'retcode' in res: value.update(res) else: res['mask'] = utils.IrcString('{nick}!{user}@{host}'.format(**res)) users.append(res) value['users'] = users value['success'] = value.get('retcode') == '315' return value class WhoChannelFlags(AsyncEvents): """Handle WHO responses with specific flags for a channel.""" flags = OrderedDict([ ("u", r"(?P\S+)"), ("i", r"(?P\S+)"), ("h", r"(?P\S+)"), ("s", r"(?P\S+)"), ("n", r"(?P\S+)"), ("a", r"(?P\S+)"), ("r", r":(?P.*)"), ]) send_line = "WHO {channel} c%{flags}" events = ( { 'match': r"(?i)^:\S+ (?P(315|401)) \S+ {channel} :.*", 'final': True }, ) def process_results(self, results=None, **value): """Process WHO results with flags into a user list.""" users = [] for res in results: if 'retcode' in res: value.update(res) else: if res.get('account') == '0': res['account'] = None users.append(res) value['users'] = users value['success'] = value.get('retcode') == '315' return value class WhoNick(AsyncEvents): """Handle WHO responses for a specific nickname.""" send_line = 'WHO {nick}' events = ( { 'match': ( r"(?i)^:\S+ 352 \S+ (?P\S+) (?P\S+) " r"(?P\S+) (?P\S+) (?P{nick}) " r"(?P\S+) :(?P\S+)\s*(?P.*)" ) }, { 'match': r"(?i)^:\S+ (?P(315|401)) \S+ {nick} :.*", 'final': True }, ) def process_results(self, results=None, **value): """Process WHO nickname results into user data.""" for res in results: if 'retcode' not in res: res['mask'] = utils.IrcString('{nick}!{user}@{host}'.format(**res)) value.update(res) value['success'] = value.get('retcode') == '315' return value class IsOn(AsyncEvents): """Handle ISON responses to check nickname presence.""" events = ( { 'match': ( r"(?i)^:\S+ 303 \S+ :(?P({nicknames_re}.*|$))" ), 'final': True }, ) def process_results(self, results=None, **value): """Extract nicknames from ISON results.""" nicknames = [] for res in results: nicknames.extend(res.pop('nicknames', '').split()) value['names'] = nicknames return value class Topic(AsyncEvents): """Handle TOPIC commands to get or set a channel topic.""" send_line = 'TOPIC {channel}{topic}' events = ( { 'match': ( r"(?i)^:\S+ (?P(331|332|TOPIC))" r"(:?\s+\S+\s+|\s+){channel} :(?P.*)" ), 'final': True }, ) def process_results(self, results=None, **value): """Determine the topic from server response.""" for res in results: status = res.get('retcode', '') if status.upper() in ('332', 'TOPIC'): value['topic'] = res.get('topic') else: value['topic'] = None return value class Names(AsyncEvents): """Handle NAMES responses to list users in a channel.""" send_line = 'NAMES {channel}' events = ( { 'match': r"(?i)^:\S+ 353 .*{channel} :(?P.*)", 'multi': True }, { 'match': r"(?i)^:\S+ (?P(366|401)) \S+ {channel} :.*", 'final': True }, ) def process_results(self, results=None, **value): """Aggregate nicknames from NAMES responses.""" nicknames = [] for res in results: nicknames.extend(res.pop('nicknames', '').split()) value['names'] = nicknames value['success'] = value.get('retcode') == '366' return value class ChannelBans(AsyncEvents): """Handle MODE +b responses to list channel bans.""" send_line = 'MODE {channel} +b' events = ( { 'match': ( r"(?i)^:\S+ 367 \S+ {channel} (?P\S+) " r"(?P\S+) (?P\d+)" ), 'multi': True }, { 'match': r"(?i)^:\S+ 368 \S+ {channel} :.*", 'final': True }, ) def process_results(self, results=None, **value): """Compile ban entries from server responses.""" bans = [] for res in results: if not res: continue # Skip empty results res['timestamp'] = int(res['timestamp']) bans.append(res) value['bans'] = bans return value class CTCP(AsyncEvents): """Handle CTCP commands and responses.""" send_line = 'PRIVMSG {nick} :\x01{ctcp}\x01' events = ( { 'match': ( r"(?i):(?P\S+) NOTICE \S+ :\x01(?P\S+) " r"(?P.*)\x01" ), 'final': True }, { 'match': r"(?i)^:\S+ (?P486) \S+ :(?P.*)", 'final': True } ) def process_results(self, results=None, **value): """Extract CTCP reply data from responses.""" for res in results: if 'mask' in res: res['mask'] = utils.IrcString(res['mask']) value['success'] = res.pop('retcode', None) != '486' value.update(res) return value @dec.plugin class Async: """Provide asynchronous commands for IRC interactions. Extends the bot with methods using AsyncEvents for handling server responses. """ def __init__(self, context): self.context = context self.context.async_cmds = self self.async_whois = Whois(context) self.async_who_channel = WhoChannel(context) self.async_who_nick = WhoNick(context) self.async_topic = Topic(context) self.async_ison = IsOn(context) self.async_names = Names(context) self.async_channel_bans = ChannelBans(context) self.async_ctcp = CTCP(context) async def send_message(self, target, message): """Send a message asynchronously""" self.context.privmsg(target, message) def async_who_channel_flags(self, channel, flags, timeout): """Create a dynamic WHO command with flags for channel user details.""" flags = ''.join([f.lower() for f in WhoChannelFlags.flags if f in flags]) regex = [WhoChannelFlags.flags[f] for f in flags] channel = channel.lower() cls = type( WhoChannelFlags.__name__, (WhoChannelFlags,), { "events": WhoChannelFlags.events + ( { "match": ( r"(?i)^:\S+ 354 \S+ {0}".format(' '.join(regex)) ), "multi": True }, ) } ) return cls(self.context)(channel=channel, flags=flags, timeout=timeout) @dec.extend def whois(self, nick, timeout=20): """Send a WHOIS and return a Future with received data. Example: result = await bot.async_cmds.whois('gawel') """ return self.async_whois(nick=nick.lower(), timeout=timeout) @dec.extend def who(self, target, flags=None, timeout=20): """Send a WHO and return a Future with received data. Examples: result = await bot.async_cmds.who('gawel') result = await bot.async_cmds.who('#irc3', 'an') """ target = target.lower() if target.startswith('#'): if flags: return self.async_who_channel_flags( channel=target, flags=flags, timeout=timeout ) return self.async_who_channel(channel=target, timeout=timeout) else: return self.async_who_nick(nick=target, timeout=timeout) def topic(self, channel, topic=None, timeout=20): """Get or set the topic for a channel.""" if not topic: topic = '' else: topic = ' ' + topic.strip() return self.async_topic(channel=channel, topic=topic, timeout=timeout) @dec.extend def ison(self, *nicknames, **kwargs): """Send ISON to check online status of nicknames. Example: result = await bot.async_cmds.ison('gawel', 'irc3') """ nicknames = [n.lower() for n in nicknames] self.context.send_line(f'ISON :{" ".join(nicknames)}') nicknames_re = '(%s)' % '|'.join(re.escape(n) for n in nicknames) return self.async_ison(nicknames_re=nicknames_re, **kwargs) @dec.extend def names(self, channel, timeout=20): """Send NAMES to list users in a channel. Example: result = await bot.async_cmds.names('#irc3') """ return self.async_names(channel=channel.lower(), timeout=timeout) @dec.extend def channel_bans(self, channel, timeout=20): """List channel bans via MODE +b. Example: result = await bot.async_cmds.channel_bans('#irc3') """ return self.async_channel_bans(channel=channel.lower(), timeout=timeout) @dec.extend def ctcp_async(self, nick, ctcp, timeout=20): """Send a CTCP request and return a Future with the reply. Example: result = await bot.async_cmds.ctcp('irc3', 'VERSION') """ return self.async_ctcp(nick=nick, ctcp=ctcp.upper(), timeout=timeout)