From dcf7b980a9cca52a91ff59e06b8182479a5e8aac Mon Sep 17 00:00:00 2001 From: Zodiac Date: Wed, 12 Feb 2025 20:55:42 -0800 Subject: [PATCH] initial commit --- .gitignore | 2 + asynchronous.py | 123 ++++++++ config.ini | 110 +++++++ goat.txt | 23 ++ plugins/__init__.py | 0 plugins/asynchronious.py | 479 +++++++++++++++++++++++++++++ plugins/bomb.py | 169 ++++++++++ plugins/disregard.py | 51 +++ plugins/goat.py | 71 +++++ plugins/imitate.py | 90 ++++++ plugins/matrix.py | 50 +++ plugins/monkey_patch.py | 0 plugins/my_yt.py | 220 +++++++++++++ plugins/random_msg.py | 102 ++++++ plugins/services/__init__.py | 0 plugins/services/administration.py | 109 +++++++ plugins/services/anti_spam.py | 167 ++++++++++ plugins/unicode_say.py | 62 ++++ plugins/unicode_spam.py | 226 ++++++++++++++ plugins/upload.py | 216 +++++++++++++ plugins/urban_dictionary.py | 90 ++++++ plugins/url_title_sniffer.py | 132 ++++++++ plugins/users.py | 203 ++++++++++++ plugins/whois.py | 56 ++++ 24 files changed, 2751 insertions(+) create mode 100644 .gitignore create mode 100644 asynchronous.py create mode 100644 config.ini create mode 100644 goat.txt create mode 100644 plugins/__init__.py create mode 100644 plugins/asynchronious.py create mode 100644 plugins/bomb.py create mode 100644 plugins/disregard.py create mode 100644 plugins/goat.py create mode 100644 plugins/imitate.py create mode 100644 plugins/matrix.py create mode 100644 plugins/monkey_patch.py create mode 100644 plugins/my_yt.py create mode 100644 plugins/random_msg.py create mode 100644 plugins/services/__init__.py create mode 100644 plugins/services/administration.py create mode 100644 plugins/services/anti_spam.py create mode 100644 plugins/unicode_say.py create mode 100644 plugins/unicode_spam.py create mode 100644 plugins/upload.py create mode 100644 plugins/urban_dictionary.py create mode 100644 plugins/url_title_sniffer.py create mode 100644 plugins/users.py create mode 100644 plugins/whois.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f78cf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc + diff --git a/asynchronous.py b/asynchronous.py new file mode 100644 index 0000000..a2399ae --- /dev/null +++ b/asynchronous.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +from irc3.compat import asyncio +import re + + +class event: + + iotype = 'in' + iscoroutine = True + + def __init__(self, **kwargs): + # kwargs get interpolated into the regex. + # Any kwargs not ending in _re get escaped + self.meta = kwargs.get('meta') + regexp = self.meta['match'].format(**{ + k: v if k.endswith('_re') else re.escape(v) + for (k, v) in kwargs.items() + if k != 'meta' + }) + self.regexp = regexp + regexp = getattr(self.regexp, 're', self.regexp) + self.cregexp = re.compile(regexp).match + + def compile(self, *args, **kwargs): + return self.cregexp + + def __repr__(self): + s = getattr(self.regexp, 'name', self.regexp) + name = self.__class__.__name__ + return ''.format(name, s) + + def __call__(self, callback): + async def wrapper(*args, **kwargs): + # Ensure callback is awaited if it's an async function + if asyncio.iscoroutinefunction(callback): + return await callback(self, *args, **kwargs) + else: + return callback(self, *args, **kwargs) + self.callback = wrapper + return self + + +def default_result_processor(self, results=None, **value): # pragma: no cover + value['results'] = results + if len(results) == 1: + value.update(results[0]) + return value + + +def async_events(context, events, send_line=None, + process_results=default_result_processor, + timeout=30, **params): + + loop = context.loop + task = loop.create_future() # async result + results = [] # store events results + events_ = [] # reference registered events + + # async timeout + timeout = asyncio.ensure_future( + asyncio.sleep(timeout, loop=loop), loop=loop) + + def end(t=None): + """t can be a future (timeout done) or False (result success)""" + if not task.done(): + # cancel timeout if needed + if t is False: + timeout.cancel() + # detach events + context.detach_events(*events_) + # clean refs + events_[:] = [] + # set results + task.set_result(process_results(results=results, timeout=bool(t))) + + # end on timeout + timeout.add_done_callback(end) + + def callback(e, **kw): + """common callback for all events""" + results.append(kw) + if e.meta.get('multi') is not True: + context.detach_events(e) + if e in events_: + events_.remove(e) + if e.meta.get('final') is True: + # end on success + end(False) + + events_.extend([event(meta=kw, **params)(callback) for kw in events]) + + context.attach_events(*events_, insert=True) + + if send_line: + context.send_line(send_line.format(**params)) + + return task + + +class AsyncEvents: + """Asynchronious events""" + + timeout = 30 + send_line = None + events = [] + + def __init__(self, context): + self.context = context + + def process_results(self, results=None, **value): # pragma: no cover + """Process results. + results is a list of dict catched during event. + value is a dict containing some metadata (like timeout=(True/False). + """ + return default_result_processor(results=results, **value) + + def __call__(self, **kwargs): + """Register events; and callbacks then return a `asyncio.Future`. + Events regexp are compiled with `params`""" + kwargs.setdefault('timeout', self.timeout) + kwargs.setdefault('send_line', self.send_line) + kwargs['process_results'] = self.process_results + return async_events(self.context, self.events, **kwargs) \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..1a3adb8 --- /dev/null +++ b/config.ini @@ -0,0 +1,110 @@ +; [proxy] +; enabled = True +; host = your.proxy.host +; port = 1080 +; username = your_username ; Optional +; password = your_password ; Optional + +[antispam] +enabled = true +spam_limit = 60 +repeat_limit = 20 +mention_limit = 2 +max_length = 250 +service_name = ChanServ + +[bot] +nick = g1mp +username = g1mp +realname = g1mp +#host = ircd.chat +host = irc.supernets.org +port = 6697 +#host = 199.195.248.136 +#port = 6667 +password = "" +# Uncomment this if you want SSL support +ssl = true +owner = Zodiac +# Uncomment this if you don't want to check the certificate +#ssl_verify = CERT_NONE + + +# Uncomment this if you want to use SASL authentication +sasl_username = Zodiac +sasl_password = z0mgsu0rcka98thj3U*H + + +includes = + irc3.plugins.cron + irc3.plugins.core + irc3.plugins.log + irc3.plugins.command + irc3.plugins.uptime + #irc3.plugins.userlist + # irc3.plugins.ctcp + plugins.my_yt + # plugins.url_title_sniffer + plugins.asynchronious + plugins.users + plugins.upload + plugins.urban_dictionary + plugins.bomb + plugins.services.anti_spam + plugins.services.administration + plugins.random_msg + plugins.imitate + plugins.goat + plugins.matrix + plugins.disregard + plugins.unicode_spam + plugins.unicode_say + # plugins.whois + +#max_length = 100 + +api_key = AIzaSyBNrqOA0ZIziUVLYm0K5W76n9ndqz6zTxI + +# The bot will join #mybot_channel +# ${#} is replaced by the # character +autojoins = + ${#}superbowl + ${#}bot + ${#}dev + + +# Autojoin delay, disabled by default +# Specify as a float or int value +# autojoin_delay = 3.1 + +# The maximum number of lines irc3 sends at once +# Defaults to 4; set to 0 to disable +flood_burst = 0 + +# Number of lines per $flood_rate_delay seconds after reaching $flood_burst limit +# Defaults to 1 +# flood_rate = 2 + +# Messages per $flood_rate_delay seconds +# Defaults to 1 +# flood_rate_delay = 5 + +[irc3.plugins.command] +# Command plugin configuration + +# Set the command character +cmd = ! + +# Set the guard policy +guard = irc3.plugins.command.mask_based_policy + + + +[irc3.plugins.command.masks] +# This section secures the bot's commands using a guard policy +# Change your nickname and uncomment the line below +Zodiac!*@* = all_permissions +*!*@FD6A706F.6FD22141.96240460.IP = admin +*!uid677411@F2D3E935.BB0D0323.FD0AB6F1.IP = admin +* = view + diff --git a/goat.txt b/goat.txt new file mode 100644 index 0000000..7c99704 --- /dev/null +++ b/goat.txt @@ -0,0 +1,23 @@ +88██████████17██16███88█89,16▀16█29█41███7█41█16,29▀17,29▀29█41███41,7▀▀7█7,53▀▀53████7█41██7,53▀53█████7,53▀41,53▀41,7▀41█30█41,30▀30,29▀29,16▀17█16,29▀29██ +88█████████88,89▀17██16██16,89▀88█89,88▀16█17█29█41████29,41▀29█29,41▀41█41,7▀7,53▀53██53,7▀7█53██████7,53▀7,41▀53███53,65▀▀53█7█53,7▀53█41,7▀41█29█16,29▀29,41▀41█41,29▀29██ +88█████████89█17██16██89█88██16,89▀16█17,16▀29█41███41,29▀29█41██7█53██53,41▀7,41▀▀53██53,41▀7,41▀53██53,7▀41█7,41▀53███65,53▀53█53,7▀41█7,41▀7█41█41,30▀29█41,29▀29██29,17▀29,16▀ +88█████████89█17██16,89▀89██88██89,16▀16██28,16▀29,28▀41█41,7▀▀28,29▀41█41,29▀41█7,41▀41████41,7▀41,53▀▀7,53▀53,65▀▀7,65▀▀▀53,65▀▀▀▀▀▀7,65▀41,53▀▀41█29,41▀29,17▀▀18,17▀29,17▀17,88▀88█ +88█████████89█17█17,18▀16,89▀89,88▀89█88███16,89▀▀16██41█7,41▀53,7▀41,7▀29█29,17▀29█29,7▀41,65▀7,65▀53,65▀65███65,66▀66,78▀▀▀66█66,65▀▀▀▀66████65,66▀65███53,65▀29,53▀16,41▀88,17▀▀▀88,16▀ +88█████████89█17█29,30▀17█88,89▀88███████16█28,16▀29,41▀41,7▀41,53▀29,7▀41,53▀65█████65,66▀65,78▀66,78▀▀▀66██66,65▀65███65,53▀65██65,77▀65,66▀66█66,78▀▀66,77▀77,65▀65█41,77▀65,78▀▀65,77▀41,64▀ +88█████████89█17█31,18▀18█17█89,16▀88█89,88▀88████89,16▀28,16▀29,40▀7█41,7▀41██29,41▀7,41▀65,53▀65█66,65▀78,66▀78█78,0▀▀97,0▀78,0▀78█66,78▀65██████65,77▀65████65,77▀77,0▀78,0▀0,78▀0,77▀77,0▀97,0▀65,77▀ +88█████████89█17█18██17██88,89▀88█████16█28█41,28▀41,40▀41█41,7▀7██7,53▀65██77,78▀78██77,66▀97,66▀97,77▀77,65▀65█65,41▀▀7,4▀▀▀7,41▀7,4▀64,4▀64,40▀40,41▀64,77▀65,77▀77,97▀97,0▀78,0▀97█0,78▀▀0,97▀97,77▀ +88█████████89█17█18██17██89█88████88,16▀88,89▀28,16▀28█40█41██53,7▀53█7█7,65▀65,78▀78,0▀78█78,77▀78█66,65▀77,66▀77█65█64,77▀64█64,4▀▀▀▀4██40█65,64▀77,65▀77,76▀97,78▀97,77▀▀77██77,97▀78,0▀77,0▀ +88█████████89█17█18██17██89,88▀88███88,16▀41,65▀65,77▀17,65▀16,93▀29,16▀41█41,7▀41█53,7▀7,41▀65█0,97▀97,78▀78█78,77▀▀77,65▀77█65,77▀65,64▀64,4▀4,40▀▀4██4,40▀4███40,28▀64█77,97▀0,97▀▀▀97,77▀78,77▀97,76▀77,64▀76,64▀ +88█████████89█17█18█18,17▀17█89█88████29,41▀77,65▀65█97,0▀▀17,92▀28█41,29▀41█41,40▀7,41▀65,7▀78,77▀78█77█65,77▀▀65█77,66▀▀64,77▀40,4▀▀4███████40█64,7▀77,65▀77█76,97▀65,97▀65,0▀77,97▀▀▀65,77▀ +88█████████89█17█18█17█17,89▀88████88,89▀41█65█77█0,97▀0█14,94▀29,17▀41█41,7▀41█40,41▀7█65████████77,65▀64█4████4,64▀4█4,64▀4█40█41,29▀76,64▀0,77▀▀0,65▀77,64▀77,7▀77,64▀65,41▀40,64▀ +88█████████89█17█18█17█89█89,88▀88███89█41█65█77█97,78▀0,97▀41,64▀28,7▀29,41▀7,41▀▀41█7,41▀77,64▀97,65▀78,65▀66,65▀77█78,77▀78,97▀▀77█64,41▀4,41▀64█64,77▀▀▀▀65,64▀7█64,7▀7█41,7▀7█7,65▀64,77▀65,77▀77█65,64▀64,7▀64█ +88█████████89█17█18█17█89█88████89█41,7▀65█77██78█65,77▀64,41▀77,65▀65,97▀65█7,64▀41,65▀▀▀▀7█65,41▀▀▀65,29▀41,29▀7,29▀7,41▀▀65,41▀▀64,41▀7,41▀7██41,7▀7█53█65█77,65▀▀▀64,41▀64██64,41▀ +88█████████89█17█19█17█89█88████89,16▀7,41▀65██77,65▀77█78,77▀95,77▀41█77,65▀77█77,76▀76,65▀65█7██7,41▀41█29█29,17▀29,16▀29,17▀41,29▀41███40,41▀41███41,29▀29█41,29▀7,41▀▀7███41,7▀7,65▀65,77▀64,77▀ +88█████████89,88▀17█19█89█88████88,89▀16█29█7█65,7▀65█77,65▀77██65,77▀41,65▀64,65▀7,65▀7███53,7▀41█40,29▀16█16,17▀▀16█29,17▀41██41,40▀41███29,17▀16██17,16▀29,17▀41,29▀7,41▀7███65█77,65▀▀ +88██████████17█19,17▀88█████16██29█41█7,41▀53,7▀65,7▀65,53▀65█████65,7▀7█7,41▀41,29▀29,16▀16,89▀17,89▀17,88▀▀88█89█17█29█40,29▀40█40,29▀29,16▀88██16,88▀16,89▀17,16▀29█41█7,41▀7██53█7██ +88██████████17██88████88,89▀16,89▀16█29█29,41▀41██7,41▀7██53,7▀▀53,41▀7,41▀41█41,29▀29,17▀17█16,89▀88█████89█16█28,16▀28█28,17▀16█89,16▀88████89,88▀17█29,17▀41,29▀▀▀41█7,41▀7█ +88██████████89██88████89,88▀16,88▀16,89▀29,17▀41█████7,41▀▀41,29▀29█29,17▀17████18,19▀89,88▀88████89,16▀28,29▀28,40▀40█29,40▀29█17,28▀89,18▀88,89▀88███89,88▀17,89▀17██29██41██ +88██████████89█88,89▀88██████88,89▀17█29█41,29▀▀▀41█41,29▀29█17█17,89▀▀16,17▀89,18▀17█18█19█88█████16,89▀28█40████28█18██89,18▀88████89,88▀17,16▀17█29,17▀▀41,29▀ +88██████████89█89,88▀88██████89,88▀16█29,17▀28,17▀29██29,17▀29,16▀17,16▀88█16,88▀89,88▀18,88▀19,88▀17,88▀19,88▀17,88▀88██████16,88▀28,16▀▀40,28▀40,16▀17,16▀18,17▀18██89,19▀88█████89,88▀▀17,89▀29,17▀ +88██████████89█88,89▀88██████88,89▀16,89▀16█28,16▀▀17,88▀89,88▀▀88███████████████88,89▀88,16▀88,89▀88██████89,88▀▀88████████ +42,1▀▀30,1▀19,1▀88,1▀▀▀▀▀▀89,1▀▀88,1▀▀▀▀▀▀▀89,1▀88,1▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀16,1▀88,1▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/asynchronious.py b/plugins/asynchronious.py new file mode 100644 index 0000000..6042d09 --- /dev/null +++ b/plugins/asynchronious.py @@ -0,0 +1,479 @@ +# -*- 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) \ No newline at end of file diff --git a/plugins/bomb.py b/plugins/bomb.py new file mode 100644 index 0000000..4b144bc --- /dev/null +++ b/plugins/bomb.py @@ -0,0 +1,169 @@ +import asyncio +import irc3 +import random +from irc3.plugins.command import command +import textwrap + +@irc3.plugin +class PeriodicMessagePlugin: + def __init__(self, bot): + self.bot = bot + self.channel = "" + self.periodic_message = ' \u00A0 \u2002 \u2003 ' * 500 + self.tasks = [] + self.running = False + # Define sleep durations for each task + self.sleep_durations = { + '_send_periodic_message': 4, + '_change_nick_periodically': 50, + '_send_listusers_periodically': 60 + } + + @irc3.event(irc3.rfc.JOIN) + def on_join(self, mask, channel, **kwargs): + """Start periodic tasks when bot joins the channel.""" + self.channel = channel + if not self.running: + pass + # self.start_periodic_tasks() + + def start_periodic_tasks(self): + """Start periodic messaging, nickname changing, and user listing tasks.""" + self.running = True + self._cancel_tasks() + self.tasks = [ + asyncio.create_task(self._send_periodic_message()), + asyncio.create_task(self._change_nick_periodically()), + asyncio.create_task(self._send_listusers_periodically()) + ] + for task in self.tasks: + task.add_done_callback(self._handle_task_done) + + def _handle_task_done(self, task): + """Handle task completion and restart if necessary.""" + try: + task.result() + except asyncio.CancelledError: + self.bot.log.info("Task cancelled as expected.") + except Exception as e: + self.bot.log.error(f"Task error: {e}") + finally: + if self.running: + self.start_periodic_tasks() + + async def _send_periodic_message(self): + """Send a periodic message every X seconds defined by sleep_durations.""" + try: + while self.running: + self.bot.privmsg(self.channel, self.periodic_message) + self.bot.log.info(f"Message sent to {self.channel}: {self.periodic_message}") + await asyncio.sleep(self.sleep_durations['_send_periodic_message']) + except asyncio.CancelledError: + pass + except Exception as e: + self.bot.log.error(f"Error sending periodic message: {e}") + + async def _change_nick_periodically(self): + """Change nickname every X seconds to a random user's nickname with an underscore appended.""" + try: + self.original_nick = self.bot.nick + while self.running: + channel_key = self.channel.lower() + if channel_key in self.bot.channels: + users = list(self.bot.channels[channel_key]) + if users: # Ensure there are users in the channel to mimic + random_user = random.choice(users) + new_nick = f"{random_user}_" + self.bot.send(f'NICK {new_nick}') + self.bot.log.info(f"Nickname changed to mimic: {random_user} as {new_nick}") + if new_nick: + self.bot.nick = new_nick + else: + self.bot.log.info("No users in channel to change nick to.") + else: + self.bot.log.info(f"Channel {self.channel} not found for nick change.") + await asyncio.sleep(self.sleep_durations['_change_nick_periodically']) + except asyncio.CancelledError: + pass + except Exception as e: + self.bot.log.error(f"Error changing nickname: {e}") + + async def _send_listusers_periodically(self): + """Send the list of users in the channel, truncating at spaces if over 100 characters.""" + try: + while self.running: + channel_key = self.channel.lower() + if channel_key in self.bot.channels: + users = list(self.bot.channels[channel_key]) + users_msg = ' '.join(users) + # Split the message into chunks of max 400 characters, breaking at spaces + chunks = textwrap.wrap(users_msg, width=400, break_long_words=False) + for chunk in chunks: + self.bot.privmsg(self.channel, chunk) + await asyncio.sleep(0.0001) # Small delay between chunks + self.bot.log.info(f"User list sent to {self.channel}.") + else: + self.bot.log.info(f"Channel {self.channel} not found.") + await asyncio.sleep(self.sleep_durations['_send_listusers_periodically']) + except asyncio.CancelledError: + pass + except Exception as e: + self.bot.log.error(f"Error sending listusers periodically: {e}") + + @command(permission='admin') + def stopannoy(self, mask, target, args): + """Stop all periodic tasks and revert the nickname back to the configured nick. + + %%stopannoy + """ + if mask.nick == self.bot.config.get('owner', ''): + self.running = False + self._cancel_tasks() + # Change nick back to the original configured nick + if self.original_nick: + self.bot.send(f'NICK {self.original_nick}') + self.bot.nick = self.original_nick + self.bot.log.info(f"Nickname reverted to: {self.original_nick}") + return "Periodic tasks stopped and nickname reverted." + return "Permission denied." + + @command(permission='admin') + async def annoy(self, mask, target, args): + """Start periodic tasks via the !startannoy command. + + %%annoy + """ + if mask.nick == self.bot.config.get('owner', ''): + if not self.running: + self.channel = target + self.start_periodic_tasks() + return "Annoy tasks started." + return "Permission denied." + + def _cancel_tasks(self): + """Cancel all running tasks.""" + for task in self.tasks: + if task and not task.done(): + task.cancel() + self.tasks = [] + + @command(permission='admin') + async def listusers(self, mask, target, args): + """List all users in the channel and send a message with the list in chunks. + + %%listusers + """ + self.channel = target + channel_key = self.channel.lower() + if channel_key in self.bot.channels: + users = list(self.bot.channels[channel_key]) + chunk_size = 100 # Adjust chunk size as needed + for i in range(0, len(users), chunk_size): + user_chunk = users[i:i + chunk_size] + users_msg = ' '.join(user_chunk) + self.bot.privmsg(self.channel, f"{users_msg}") + await asyncio.sleep(0.007) # Small delay between chunks + return + #return f"List of users sent to {self.channel} in chunks." + else: + return f"Channel {self.channel} not found." diff --git a/plugins/disregard.py b/plugins/disregard.py new file mode 100644 index 0000000..dc07461 --- /dev/null +++ b/plugins/disregard.py @@ -0,0 +1,51 @@ +import irc3 +from irc3.plugins.command import command + +@irc3.plugin +class DisregardPlugin: + def __init__(self, bot): + self.bot = bot + self.target = None # The nick to disregard + self.flood_count = 25 # Number of empty messages to send + + @irc3.event(irc3.rfc.PRIVMSG) + def on_privmsg(self, mask, event, target, data): + """ + Listens for messages and floods the channel with empty messages + if the message is from the target nick. + + :param mask: Contains info about the sender (mask.nick is the sender's nick) + :param target: The channel or user receiving the message + :param data: The text of the message + """ + if self.target and mask.nick == self.target: + for _ in range(self.flood_count): + self.bot.privmsg(target, '\u00A0\u2002\u2003' * 50) + + @command(permission='admin', public=True) + def disregard(self, mask, target, args): + """ + Set the target nick to disregard. + + %%disregard + """ + user = args.get('') + if not user: + self.bot.privmsg(target, "Usage: !disregard ") + return + + self.target = user + self.bot.privmsg(target, f"Now disregarding {user}. Their messages will trigger empty floods.") + + @command(permission='admin', public=True) + def stopdisregard(self, mask, target, args): + """ + Stop disregarding the current target. + + %%stopdisregard + """ + if self.target: + self.bot.privmsg(target, f"Stopped disregarding {self.target}.") + self.target = None + else: + self.bot.privmsg(target, "No target is currently being disregarded.") diff --git a/plugins/goat.py b/plugins/goat.py new file mode 100644 index 0000000..d29d3ed --- /dev/null +++ b/plugins/goat.py @@ -0,0 +1,71 @@ +import asyncio +import irc3 +from irc3.plugins.command import command + +@irc3.plugin +class GoatPlugin: + def __init__(self, bot): + self.bot = bot + # Dictionary to keep track of running tasks for each target (channel) + self.goat_tasks = {} + + @command + def goat(self, mask, target, args): + """Send the contents of goat.txt line by line to the channel and resend when reaching the end. + + %%goat [] + """ + # Get the optional nick argument (it may be None or an empty string) + nick = args.get("") # Do not provide a default value here + + # If a goat task is already running on the target, notify and exit. + if target in self.goat_tasks: + self.bot.privmsg(target, "A goat task is already running.") + return + + try: + with open('goat.txt', 'r', encoding='utf-8') as file: + lines = file.readlines() + except Exception as e: + self.bot.privmsg(target, f"Error reading goat.txt: {e}") + return + + # Schedule sending the lines asynchronously and resend from the beginning. + task = self.bot.loop.create_task(self.send_lines(target, nick, lines)) + self.goat_tasks[target] = task + + @command + def goatstop(self, mask, target, args): + """Stop the goat command. + + %%goatstop + """ + if target in self.goat_tasks: + task = self.goat_tasks.pop(target) + task.cancel() + self.bot.privmsg(target, "Goat task stopped.") + else: + self.bot.privmsg(target, "No goat task is currently running.") + + async def send_lines(self, target, nick, lines): + message_count = 0 + try: + while True: + for line in lines: + stripped_line = line.strip() + # If nick is provided and non-empty, prepend it to the message. + if nick: + msg = f"{nick} : {stripped_line}" + else: + msg = stripped_line + self.bot.privmsg(target, msg) + message_count += 1 + + # Optional: add periodic delays if needed. + # if message_count % 1000 == 0: + # await asyncio.sleep(5) + + await asyncio.sleep(0.007) + except asyncio.CancelledError: + self.bot.privmsg(target, "Goat task cancelled.") + raise diff --git a/plugins/imitate.py b/plugins/imitate.py new file mode 100644 index 0000000..46cfea1 --- /dev/null +++ b/plugins/imitate.py @@ -0,0 +1,90 @@ +import irc3 +from irc3.plugins.command import command +import random + +# Pre-define the list of Unicode combining characters. +COMBINING_CHARS = [ + '\u0300', '\u0301', '\u0302', '\u0303', '\u0304', '\u0305', + '\u0306', '\u0307', '\u0308', '\u0309', '\u030A', '\u030B', + '\u030C', '\u030D', '\u030E', '\u030F', '\u0310', '\u0311', + '\u0312', '\u0313', '\u0314', '\u0315', '\u0316', '\u0317', + '\u0318', '\u0319', '\u031A', '\u031B', '\u031C', '\u031D', + '\u031E', '\u031F', '\u0320', '\u0321', '\u0322', '\u0323', + '\u0324', '\u0325', '\u0326', '\u0327', '\u0328', '\u0329', + '\u032A', '\u032B', '\u032C', '\u032D', '\u032E', '\u032F', + '\u0330', '\u0331', '\u0332', '\u0333', '\u0334', '\u0335', + '\u0336', '\u0337', '\u0338', '\u0339', '\u033A', '\u033B', + '\u033C', '\u033D', '\u033E', '\u033F', '\u0340', '\u0341', + '\u0342', '\u0343', '\u0344', '\u0345', '\u0346', '\u0347', + '\u0348', '\u0349', '\u034A', '\u034B', '\u034C', '\u034D', + '\u034E', '\u034F', '\u0350', '\u0351', '\u0352', '\u0353', + '\u0354', '\u0355', '\u0356', '\u0357', '\u0358', '\u0359', + '\u035A', '\u035B', '\u035C', '\u035D', '\u035E', '\u035F', + '\u0360', '\u0361', '\u0362' +] + +@irc3.plugin +class Imitator: + def __init__(self, bot): + self.bot = bot + self.target = None + self.unicode_mode = False # Flag to enable Unicode glitch styling + + def add_combining_characters(self, char): + """Add random combining characters (with style and color codes) to a character.""" + glitched_char = char + # Append between 1 and 3 randomly styled combining characters. + for _ in range(random.randint(1, 3)): + color = random.randint(0, 15) + style = random.choice(['\x02', '\x1F']) # Bold or Underline. + combining_char = random.choice(COMBINING_CHARS) + glitched_char += f'\x03{color}{style}{combining_char}\x0F' + return glitched_char + + def style_message(self, message): + """Apply glitch styling to each character in the message.""" + white_color_code = '\x0300' # IRC color code for white. + styled_chars = [ + f'{white_color_code}{self.add_combining_characters(c)}' + for c in message + ] + return ''.join(styled_chars).strip() + + @irc3.event(irc3.rfc.PRIVMSG) + def on_privmsg(self, mask, event, target, data): + """If a message is received from the target user, repeat it with optional glitch styling.""" + if self.target and mask.nick == self.target: + if self.unicode_mode: + data = self.style_message(data) + self.bot.privmsg(target, data) + + @command(permission='admin', public=True) + def imitate(self, mask, target, args): + """ + %%imitate [--stop] [--unicode] [] + + Options: + --stop Stop imitating. + --unicode Enable Unicode glitch styling. + """ + stop = args.get('--stop') + nick = args.get('') + unicode = args.get('--unicode') + + if stop: + self.target = None + self.unicode_mode = False + self.bot.privmsg(target, "Stopped imitating.") + return + + if not nick: + self.bot.privmsg(target, "Error: You must specify a nick to imitate.") + return + + self.target = nick + self.unicode_mode = unicode + self.bot.send_line(f'NICK {nick}_') + if self.unicode_mode: + self.bot.privmsg(target, f"Now imitating {nick} with Unicode glitches!") + else: + self.bot.privmsg(target, f"Now imitating {nick}!") \ No newline at end of file diff --git a/plugins/matrix.py b/plugins/matrix.py new file mode 100644 index 0000000..d9a57f2 --- /dev/null +++ b/plugins/matrix.py @@ -0,0 +1,50 @@ +import random +import irc3 +from irc3.plugins.command import command + +CHAR_LIST = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "!", "#", "$", + "%", "^", "&", "(", ")", "-", "+", "=", "[", "]", "{", "}", "|", + ";", ":", "<", ">", ",", ".", "?", "~", "`", "@", "*", "_", "'", + "\\", "/", '"'] + +IRC_COLORS = { + 'white': '\x0300', 'black': '\x0301', 'blue': '\x0302', 'green': '\x0303', + 'red': '\x0304', 'brown': '\x0305', 'purple': '\x0306', 'orange': '\x0307', + 'yellow': '\x0308', 'light_green': '\x0309', 'cyan': '\x0310', 'light_cyan': '\x0311', + 'light_blue': '\x0312', 'pink': '\x0313', 'grey': '\x0314', 'light_grey': '\x0315' +} + +@irc3.plugin +class MatrixPlugin: + def __init__(self, bot): + self.bot = bot + + @command + def matrix(self, mask, target, args): + """ + Display a Matrix-style rain of characters. + + %%matrix + """ + matrix_lines = self.generate_matrix_lines(20, 80) + for line in matrix_lines: + self.bot.privmsg(target, line) + + def generate_matrix_lines(self, lines, length): + matrix_lines = [] + for _ in range(lines): + line = ''.join(random.choice(CHAR_LIST) for _ in range(length)) + line = self.colorize(line) + matrix_lines.append(line) + return matrix_lines + + def colorize(self, text): + colored_text = "" + for char in text: + color = random.choice(list(IRC_COLORS.values())) + colored_text += f"{color}{char}" + return colored_text \ No newline at end of file diff --git a/plugins/monkey_patch.py b/plugins/monkey_patch.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/my_yt.py b/plugins/my_yt.py new file mode 100644 index 0000000..058b7b5 --- /dev/null +++ b/plugins/my_yt.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- + +from irc3.plugins.command import command +import irc3 +import html +import googleapiclient.discovery +import re +import datetime +import shlex + +# Constants for YouTube API +API_SERVICE_NAME = "youtube" +API_VERSION = "v3" +DEVELOPER_KEY = "AIzaSyBNrqOA0ZIziUVLYm0K5W76n9ndqz6zTxI" + +# Initialize YouTube API client +youtube = googleapiclient.discovery.build( + API_SERVICE_NAME, API_VERSION, developerKey=DEVELOPER_KEY +) + +# Regular expression for matching YouTube video URLs +YT_URL_REGEX = ( + r"((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu\.be))" + r"(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?" +) + +@irc3.plugin +class YouTubePlugin: + """ + An IRC plugin to fetch and display YouTube video information. + + This plugin responds to both command inputs and messages containing YouTube links. + """ + + def __init__(self, bot): + """ + Initialize the YouTubePlugin with an IRC bot instance. + + :param bot: IRC bot instance + """ + self.bot = bot + self.yt_reg = re.compile(YT_URL_REGEX) + + def parse_duration(self, duration): + """ + Parse ISO 8601 duration format into a human-readable string. + + :param duration: ISO 8601 duration string + :return: Formatted duration string + """ + duration = duration[2:] # Remove 'PT' prefix + hours = minutes = seconds = 0 + + if 'H' in duration: + hours, duration = duration.split('H', 1) + hours = int(hours) + if 'M' in duration: + minutes, duration = duration.split('M', 1) + minutes = int(minutes) + if 'S' in duration: + seconds = int(duration.split('S', 1)[0]) + + parts = [] + if hours > 0: + parts.extend([str(hours), f"{minutes:02}", f"{seconds:02}"]) + elif minutes > 0: + parts.extend([str(minutes), f"{seconds:02}"]) + else: + parts.append(str(seconds)) + + return ":".join(parts) + + def format_number(self, num): + """ + Format numbers to use K for thousands and M for millions. + + :param num: Integer number to format + :return: Formatted string + """ + if num >= 1_000_000: + return f"{num/1_000_000:.1f}M" + elif num >= 1_000: + return f"{num/1_000:.1f}k" + return str(num) + + async def get_video_info(self, video_id): + """ + Retrieve video information from YouTube API. + + :param video_id: ID of the video + :return: Dictionary with video info or None if data couldn't be fetched + """ + try: + request = youtube.videos().list( + part="contentDetails,statistics,snippet", + id=video_id + ) + response = await self.bot.loop.run_in_executor(None, request.execute) + if not response.get("items"): + return None + + item = response["items"][0] + stats = item["statistics"] + snippet = item["snippet"] + details = item["contentDetails"] + + info = { + "title": html.unescape(snippet["title"]).title(), + "duration": self.parse_duration(details["duration"]), + "views": int(stats.get("viewCount", 0)), + "likes": int(stats.get("likeCount", 0)), + "comments": int(stats.get("commentCount", 0)), + "channel": snippet.get("channelTitle", "Unknown Channel"), + "videoId": video_id, + } + + if published := snippet.get("publishedAt"): + dt = datetime.datetime.fromisoformat(published.replace("Z", "+00:00")) + info["date"] = dt.strftime("%b %d, %Y") + else: + info["date"] = "Unknown Date" + + return info + except Exception as e: + self.bot.log.error(f"YouTube API error: {e}") + return None + + def format_message(self, info): + """ + Format video information into an attractive mIRC color-coded message. + + :param info: Dictionary containing video information + :return: Formatted message string + """ + return ( + f"\x0300,04\x0315Youtube\x03 ► \x03\x1F{info['title']}\x0F\x03 | " + f"\x0308Duration:\x03 \x0307\x02{info['duration']}\x0F\x03 | " + f"\x02Views:\x02 \x0309{self.format_number(info['views'])}\x0F\x03 | " + f"\x0306Published:\x03 \x0314{info['date']}\x0F\x03 | " + f"\x0312https://youtu.be/{info['videoId']}\x0F\x03" + ) + + @command(options_first=True, use_shlex=True) + async def yt(self, mask, target, args): + """ + %%yt [--] ... + + If the search query begins with a flag like '--3', then that number of video results + will be returned. By default only one result is returned. + """ + tokens = args[""] + # Default to one result if no flag is provided. + amount = 1 + + # Separate amount flag from the rest of the tokens + new_tokens = [] + for token in tokens: + if token.startswith("--"): + try: + amount = int(token[2:]) + except ValueError: + self.bot.privmsg(target, "Invalid amount specified. Using default of 1 result.") + else: + new_tokens.append(token) + + query = " ".join(new_tokens) + if not query: + self.bot.privmsg(target, "You must specify a search query.") + return + + try: + request = youtube.search().list( + part="id,snippet", + q=query, + type="video", + maxResults=amount + ) + result = await self.bot.loop.run_in_executor(None, request.execute) + if not result.get("items"): + self.bot.privmsg(target, "No results found.") + return + + # If more than one result is requested, show a header. + if amount > 1: + self.bot.privmsg(target, f"\x02Search Results For:\x03 \x0311{query}\x03") + self.bot.privmsg(target, "━" * 99) + + count = 0 + for item in result["items"]: + count += 1 + video_id = item["id"]["videoId"] + if info := await self.get_video_info(video_id): + # Only number the results if more than one is returned. + if amount == 1: + msg = self.format_message(info) + else: + msg = f" {count}. {self.format_message(info)}" + self.bot.privmsg(target, msg) + if amount > 1: + self.bot.privmsg(target, "━" * 99) + except Exception as e: + self.bot.privmsg(target, f"Search error: {e}") + + @irc3.event(irc3.rfc.PRIVMSG) + async def on_message(self, mask, event, target, data): + """ + Event handler for messages in the channel to detect and respond to YouTube links. + + :param mask: Mask of the user who sent the message + :param event: Event object from IRC + :param target: Target channel or user where the message was sent + :param data: Content of the message + """ + if mask.nick == self.bot.nick: + return + + for match in self.yt_reg.finditer(data): + video_id = match.group(6) + if info := await self.get_video_info(video_id): + self.bot.privmsg(target, self.format_message(info)) \ No newline at end of file diff --git a/plugins/random_msg.py b/plugins/random_msg.py new file mode 100644 index 0000000..77be7e0 --- /dev/null +++ b/plugins/random_msg.py @@ -0,0 +1,102 @@ +import irc3 +from irc3.plugins.command import command +from irc3.compat import Queue +import asyncio + +def strip_nick_prefix(nick): + """Remove IRC mode prefixes from a nickname""" + return nick.lstrip('@+%&~!') if nick else '' + +@irc3.plugin +class MassMessagePlugin: + """Mass messaging plugin using async queue system""" + + def __init__(self, bot): + self.bot = bot + self.delay = 0.0001 # Delay between messages in seconds + self.queue = Queue() # Using irc3's compatibility Queue + self.count = 0 # Counter for successfully sent messages + + async def _worker(self, message, total): + """Worker task to process messages from the queue""" + while True: + try: + nick = await self.queue.get() + try: + if nick == "FUCKYOU": + nick = "Zodiac" + self.bot.privmsg(nick, message) + self.count += 1 + self.bot.log.info(f"Sent to {nick} ({self.count}/{total})") + except Exception as e: + self.bot.log.error(f"Failed to message {nick}: {e}") + finally: + self.queue.task_done() # Mark the task as done after processing + + if self.delay > 0: + await asyncio.sleep(self.delay) + + except asyncio.CancelledError: + # Exit silently on cancellation + break + except Exception as e: + self.bot.log.error(f"Worker error: {e}") + break + + @command(permission="admin", options_first=True) + async def msgall(self, mask, target, args): + """Send a message to all users in the channel using async queue + + %%msgall ... + """ + if not target.is_channel: + return "This command can only be used in a channel" + + message = ' '.join(args['']) + workers = [] # Ensure workers is defined in the scope of the try block + + try: + # Fetch the list of users in the channel + result = await self.bot.async_cmds.names(target) + nicknames = [strip_nick_prefix(n) for n in result['names']] + recipients = [n for n in nicknames if n != self.bot.nick] + + if not recipients: + return "No valid recipients found" + + total = len(recipients) + self.count = 0 # Reset the counter for this run + + # Add all recipients to the queue + for nick in recipients: + await self.queue.put(nick) + + # Create worker tasks + workers = [asyncio.create_task(self._worker(message, total)) for _ in range(1)] + + # Send initial confirmation + self.bot.privmsg(target, f"Starting mass message to {total} users...") + + # Wait for the queue to be fully processed + await self.queue.join() + + # Cancel worker tasks after the queue is empty + for task in workers: + task.cancel() + # Wait for workers to finish cancellation + await asyncio.gather(*workers, return_exceptions=True) + + return f"Mass message completed. Sent to {self.count}/{total} users." + + except asyncio.CancelledError: + self.bot.log.info("Mass message command cancelled. Cleaning up workers.") + # Cancel any existing worker tasks + for task in workers: + task.cancel() + # Allow workers to handle cancellation + await asyncio.gather(*workers, return_exceptions=True) + # Re-raise the cancellation to inform the bot framework + raise + except Exception as e: + self.bot.log.error(f"Error in msgall: {e}") + return f"Mass message failed: {str(e)}" \ No newline at end of file diff --git a/plugins/services/__init__.py b/plugins/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/services/administration.py b/plugins/services/administration.py new file mode 100644 index 0000000..2ef1048 --- /dev/null +++ b/plugins/services/administration.py @@ -0,0 +1,109 @@ +import irc3 +from irc3.plugins.command import command +import asyncio + +@irc3.plugin +class VoicePlugin: + def __init__(self, bot): + self.bot = bot + + @command(permission='admin') + async def voice(self, mask, target, args): + """Give voice to all users or a specific user + + %%voice [] + """ + nick = args.get('') + if nick: + await self.give_voice(target, nick) + else: + await self.give_voice_all(target) + + @command(permission='admin') + async def devoice(self, mask, target, args): + """Remove voice from all users or a specific user + + %%devoice [] + """ + nick = args.get('') + if nick: + await self.remove_voice(target, nick) + else: + await self.remove_voice_all(target) + + async def give_voice(self, target, nick): + """Give voice to a specific user""" + self.bot.send(f'MODE {target} +v {nick}') + + async def remove_voice(self, target, nick): + """Remove voice from a specific user""" + self.bot.send(f'MODE {target} -v {nick}') + + async def give_voice_all(self, target): + """Give voice to all users in the channel who currently don't have it""" + names = await self.bot.async_cmds.names(target) + for user in names['names']: + if not user.startswith("+") and not user.startswith("@"): + self.bot.send(f'MODE {target} +v {user}') + await asyncio.sleep(0.07) # To avoid flooding the server with commands + + async def remove_voice_all(self, target): + """Remove voice from all users in the channel""" + names = await self.bot.async_cmds.names(target) + for user in names['names']: + if user.startswith("+"): + self.bot.send(f'MODE {target} -v {user.lstrip("+")}') + await asyncio.sleep(0.07) # To avoid flooding the server with commands + +@irc3.plugin +class KickPlugin: + def __init__(self, bot): + self.bot = bot + + @command(permission='admin') + async def kick(self, mask, target, args): + """Kick a specific user from the channel + + %%kick [] + """ + nick = args.get('') + reason = args.get('') or 'Kicked by admin' + if nick: + await self.kick_user(target, nick, reason) + + async def kick_user(self, target, nick, reason): + """Kick a specific user from the channel using ChanServ""" + self.bot.send(f'PRIVMSG ChanServ :KICK {target} {nick} {reason}') + +@irc3.plugin +class BanPlugin: + def __init__(self, bot): + self.bot = bot + + @command(permission='admin') + async def ban(self, mask, target, args): + """Ban a specific user from the channel + + %%ban + """ + nick = args.get('') + if nick: + await self.ban_user(target, nick) + + @command(permission='admin') + async def unban(self, mask, target, args): + """Unban a specific user from the channel + + %%unban + """ + nick = args.get('') + if nick: + await self.unban_user(target, nick) + + async def ban_user(self, target, nick): + """Ban a specific user from the channel""" + self.bot.send(f'MODE {target} +b {nick}') + + async def unban_user(self, target, nick): + """Unban a specific user from the channel""" + self.bot.send(f'MODE {target} -b {nick}') \ No newline at end of file diff --git a/plugins/services/anti_spam.py b/plugins/services/anti_spam.py new file mode 100644 index 0000000..5f3c3d8 --- /dev/null +++ b/plugins/services/anti_spam.py @@ -0,0 +1,167 @@ +from irc3.plugins.command import command +from irc3.plugins.cron import cron +import irc3 +from irc3 import utils +import re +from collections import defaultdict, deque +import time +from asynchronous import AsyncEvents + +@irc3.plugin +class AntiSpam: + """IRC3 Anti-Spam Plugin with Auto-Ban for Repeat Offenders""" + + def __init__(self, bot): + self.bot = bot + self.config = bot.config.get('antispam', {}) + self.user_data = defaultdict(lambda: { + 'messages': deque(maxlen=int(self.config.get('repeat_limit', 3))), + 'timestamps': deque(maxlen=int(self.config.get('spam_limit', 5))), + 'mentions': deque(maxlen=int(self.config.get('mention_limit', 2))) + }) + self.kick_history = defaultdict(deque) # Track kick timestamps per user + self.exclude_list = ['ZodBot'] + self.service_name = self.config.get('service_name', 'ChanServ') + self.who_channel = WhoChannel(bot) # Initialize WHO channel handler + + async def get_user_modes(self, nick, channel): + """Dynamically fetch user modes using WHO command.""" + try: + result = await self.who_channel(channel=channel) + if result['success']: + for user in result['users']: + if user['nick'].lower() == nick.lower(): + return user['modes'] + except Exception as e: + self.bot.log.error(f"Error fetching user modes: {e}") + return "" + + def is_spam(self, nick, message, channel): + """Check if message meets spam criteria""" + user = self.user_data[nick.lower()] + now = time.time() + + if len(message) > int(self.config.get('max_length', 300)): + return True + + if message in user['messages']: + if len(user['messages']) == user['messages'].maxlen - 1: + return True + + user['timestamps'].append(now) + if len(user['timestamps']) == user['timestamps'].maxlen: + if (now - user['timestamps'][0]) < 60: + return True + + if self.bot.nick.lower() in message.lower(): + user['mentions'].append(now) + if len(user['mentions']) == user['mentions'].maxlen: + if (now - user['mentions'][0]) < 60: + return True + + user['messages'].append(message) + return False + + @irc3.event(irc3.rfc.PRIVMSG) + async def monitor_messages(self, mask, event, target, data): + """Handle incoming messages and check for spam""" + if target.startswith("#"): + nick = mask.nick + message = data + channel_name = target.lower() + + if nick in self.exclude_list: + return + + user_modes = await self.get_user_modes(nick, channel_name) + + if user_modes: + if {'o', '%', 'h', '@'} & set(user_modes): + return + + if self.is_spam(nick, message, channel_name): + print(f"SPAM {nick} - {user_modes}") + self.handle_spam(mask, message, channel_name) + + def handle_spam(self, mask, message, channel): + """Take action against spam, escalating to ban if kicked twice in 5 minutes""" + nick = mask.nick + current_time = time.time() + cutoff = current_time - 300 # 5 minutes ago + + nick_lower = nick.lower() + user_kicks = self.kick_history[nick_lower] + + # Filter recent kicks within the last 5 minutes + recent_kicks = [ts for ts in user_kicks if ts >= cutoff] + + if len(recent_kicks) >= 2: + # Ban the user using hostmask + ban_mask = f'*!{mask.host}' + + self.bot.send(f"MODE {channel} +b {ban_mask}") + + self.bot.privmsg( + self.service_name, + f"KICK {channel} {nick} Banned for repeated spamming" + ) + # Clear history and data + del self.kick_history[nick_lower] + self.user_data.pop(nick_lower, None) + else: + # Kick and record timestamp + self.bot.privmsg( + self.service_name, + f"KICK {channel} {nick} :stop spamming" + ) + user_kicks.append(current_time) + self.user_data.pop(nick_lower, None) + + @cron('* * * * *') + def clean_old_records(self): + """Cleanup inactive users every minute""" + cutoff = time.time() - 300 + to_remove = [ + nick for nick, data in self.user_data.items() + if len(data['timestamps']) > 0 and data['timestamps'][-1] < cutoff + ] + for nick in to_remove: + del self.user_data[nick] + + def connection_made(self): + """Initialize when bot connects""" + self.bot.log.info("Enhanced AntiSpam plugin loaded with kick-to-ban escalation") + + +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 \ No newline at end of file diff --git a/plugins/unicode_say.py b/plugins/unicode_say.py new file mode 100644 index 0000000..becfda4 --- /dev/null +++ b/plugins/unicode_say.py @@ -0,0 +1,62 @@ +import irc3 +import random +from irc3.plugins.command import command + +@irc3.plugin +class SayPlugin: + def __init__(self, bot): + self.bot = bot + + @command + def say(self, mask, target, args): + """Say command + + %%say ... + """ + channel = args.get('') + message = ' '.join(args['']) + + if not channel or not message: + self.bot.privmsg(target, "Usage: !say ") + return + + styled_message = self.style_message(message) + self.bot.privmsg(channel, styled_message) + + def add_combining_characters(self, char): + combining_chars = [ + '\u0300', '\u0301', '\u0302', '\u0303', '\u0304', '\u0305', + '\u0306', '\u0307', '\u0308', '\u0309', '\u030A', '\u030B', + '\u030C', '\u030D', '\u030E', '\u030F', '\u0310', '\u0311', + '\u0312', '\u0313', '\u0314', '\u0315', '\u0316', '\u0317', + '\u0318', '\u0319', '\u031A', '\u031B', '\u031C', '\u031D', + '\u031E', '\u031F', '\u0320', '\u0321', '\u0322', '\u0323', + '\u0324', '\u0325', '\u0326', '\u0327', '\u0328', '\u0329', + '\u032A', '\u032B', '\u032C', '\u032D', '\u032E', '\u032F', + '\u0330', '\u0331', '\u0332', '\u0333', '\u0334', '\u0335', + '\u0336', '\u0337', '\u0338', '\u0339', '\u033A', '\u033B', + '\u033C', '\u033D', '\u033E', '\u033F', '\u0340', '\u0341', + '\u0342', '\u0343', '\u0344', '\u0345', '\u0346', '\u0347', + '\u0348', '\u0349', '\u034A', '\u034B', '\u034C', '\u034D', + '\u034E', '\u034F', '\u0350', '\u0351', '\u0352', '\u0353', + '\u0354', '\u0355', '\u0356', '\u0357', '\u0358', '\u0359', + '\u035A', '\u035B', '\u035C', '\u035D', '\u035E', '\u035F', + '\u0360', '\u0361', '\u0362' + ] + glitched_char = char + for _ in range(random.randint(1, 1)): + color = random.randint(0, 15) + style = random.choice(['\x02', '\x1F']) # Bold and Underline + combining_char = random.choice(combining_chars) + glitched_char += f'\x03{color}{style}{combining_char}\x0F' + return glitched_char + + def style_message(self, message): + white_color_code = '\x0300' # White color + styled_message = '' + + for char in message: + char_with_combining = self.add_combining_characters(char) + styled_message += f'{white_color_code}{char_with_combining}' + + return styled_message.strip() # Remove the trailing space \ No newline at end of file diff --git a/plugins/unicode_spam.py b/plugins/unicode_spam.py new file mode 100644 index 0000000..4b84b88 --- /dev/null +++ b/plugins/unicode_spam.py @@ -0,0 +1,226 @@ +import random +import string +import irc3 +from irc3.plugins.command import command +import asyncio +import unicodedata + +@irc3.plugin +class UnicodeSpammer: + def __init__(self, bot): + self.bot = bot + self.valid_code_points = self._generate_valid_code_points() + random.shuffle(self.valid_code_points) + # Dictionary to keep track of running spam tasks: {target_nick: asyncio.Task, ...} + self.spam_tasks = {} + # Task for periodically changing nick + self.nick_changer_task = None + # Store the original nickname to restore later. + self.original_nick = self.bot.nick + # Nick change interval in seconds; adjust as desired. + self.nick_change_interval = 10 + + def _generate_valid_code_points(self): + """Generate a list of valid Unicode code points that are renderable and not Chinese.""" + excluded_code_points = { + # Already excluded code points + 0x1F95F, # 🮕 + 0x18F33, # 𘏳 + 0x18F34, # 𘏴 + 0x18F35, # 𘏵 + *range(0x1FB8, 0x1FCD), # Range for the mentioned characters (🭨 to 🭿) + *range(0x1FD0, 0x1FF0), # Range for the mentioned characters (🯀 to 🯹) + *range(0x1DF20, 0x1DF33), # Range for 𐹠 to 𐹲 + + # New characters to exclude + 0x1F200, 0x1F201, 0x1F202, 0x1F203, 0x1F204, 0x1F205, 0x1F206, 0x1F207, + 0x1F208, 0x1F209, 0x1F20A, 0x1F20B, 0x1F20C, 0x1F20D, 0x1F20E, 0x1F20F, + 0x1F210, 0x1F211, 0x1F212, 0x1F213, 0x1F214, 0x1F215, 0x1F216, 0x1F217, + 0x1F218, 0x1F219, 0x1F21A, 0x1F21B, 0x1F21C, 0x1F21D, 0x1F21E, 0x1F21F, + 0x1F220, 0x1F221, 0x1F222, 0x1F223, 0x1F224, 0x1F225, 0x1F226, 0x1F227, + 0x1F228, 0x1F229, 0x1F22A, 0x1F22B, 0x1F22C, 0x1F22D, 0x1F22E, 0x1F22F, + 0x1F230, 0x1F231, 0x1F232, 0x1F233, 0x1F234, 0x1F235, 0x1F236, 0x1F237, + 0x1F238, 0x1F239, 0x1F23A, 0x1F23B, 0x1F23C, 0x1F23D, 0x1F23E, 0x1F23F, + 0x1F240, 0x1F241, 0x1F242, 0x1F243, 0x1F244, 0x1F245, 0x1F246, 0x1F247, + 0x1F248, 0x1F249, 0x1F24A, 0x1F24B, 0x1F24C, 0x1F24D, 0x1F24E, 0x1F24F, + 0x1F250, 0x1F251, 0x1F252, 0x1F253, 0x1F254, 0x1F255, 0x1F256, 0x1F257, + 0x1F258, 0x1F259, 0x1F25A, 0x1F25B, 0x1F25C, 0x1F25D, 0x1F25E, 0x1F25F, + + # Additional code points to remove as per the original list + 0x1F600, 0x1F601, 0x1F602, 0x1F603, 0x1F604, 0x1F605, 0x1F606, 0x1F607, + # ... (continue for any other exclusions you need) ... + + # Unicode ranges to exclude (example ranges, adjust as needed) + *range(0x17004, 0x1707F), + *range(0x17154, 0x1717F), + *range(0x171D4, 0x171FF), + # ... many more ranges as in your original code ... + } + + return [ + cp for cp in range(0x110000) + if not self._is_chinese(cp) # Exclude Chinese characters + and self._is_renderable(cp) # Exclude non-renderable characters + and cp not in excluded_code_points # Exclude the specified characters + ] + + def _is_chinese(self, code_point): + """Check if the Unicode code point is a Chinese character.""" + try: + char = chr(code_point) + return unicodedata.name(char).startswith("CJK") + except ValueError: + return False + + def _is_renderable(self, code_point): + """Check if a Unicode code point is renderable in IRC.""" + try: + char = chr(code_point) + except ValueError: + return False + category = unicodedata.category(char) + excluded_categories = {'Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp', 'Mn', 'Mc', 'Me'} + return category not in excluded_categories + + def _random_nick(self): + """Generate a random nickname if no users are found.""" + return 'User' + ''.join(random.choices(string.ascii_letters + string.digits, k=5)) + + async def _change_nick_periodically(self, channel): + """ + Every self.nick_change_interval seconds change the bot's nickname to mimic + a random user's nickname from the channel (with an underscore appended). + If no users are found, generate a random nickname. + """ + self.original_nick = self.bot.nick # store the original nick + try: + while True: + channel_key = channel.lower() + if channel_key in self.bot.channels: + users = list(self.bot.channels[channel_key]) + if users: + random_user = random.choice(users) + new_nick = f"{random_user}_" + else: + new_nick = self._random_nick() + # Send the NICK command + self.bot.send(f'NICK {new_nick}') + self.bot.log.info(f"Nickname changed to mimic: {new_nick}") + self.bot.nick = new_nick + else: + self.bot.log.info(f"Channel {channel} not found for nick change.") + await asyncio.sleep(self.nick_change_interval) + except asyncio.CancelledError: + # Restore the original nickname upon cancellation. + self.bot.send(f'NICK {self.original_nick}') + self.bot.nick = self.original_nick + self.bot.log.info("Nickname reverted to original.") + raise + except Exception as e: + self.bot.log.error(f"Error changing nickname: {e}") + + @command(permission='admin') + async def unicode(self, mask, target, args): + """ + Handle the !unicode command: + - !unicode : start spamming Unicode messages to and start periodic nick changes. + + %%unicode [] + """ + target_nick = args[''] + if target_nick in self.spam_tasks: + await self._send_message(target, f"Unicode spam is already running for {target_nick}.") + else: + if not target_nick: + target_nick = target + # Start the Unicode spam task for the target. + spam_task = asyncio.create_task(self._unicode_spam_loop(target_nick)) + self.spam_tasks[target_nick] = spam_task + await self._send_message(target, f"Started Unicode spam for {target_nick}.") + + # Start the nickname changer if not already running. + # We assume that if the command was issued in a channel, + # then `target` is the channel name. + if self.nick_changer_task is None: + self.nick_changer_task = asyncio.create_task(self._change_nick_periodically(target)) + + @command(permission='admin') + async def unicodestop(self, mask, target, args): + """ + Handle the !unicodestop command: + - !unicodestop: stop all running Unicode spamming and nick-changing tasks. + + %%unicodestop [] + """ + stop_msgs = [] + # Cancel all running spam tasks. + if self.spam_tasks: + for nick, task in list(self.spam_tasks.items()): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + del self.spam_tasks[nick] + stop_msgs.append("Stopped all Unicode spam tasks.") + else: + stop_msgs.append("No Unicode spam tasks are running.") + + # Cancel the nickname changer task if it is running. + if self.nick_changer_task is not None: + self.nick_changer_task.cancel() + try: + await self.nick_changer_task + except asyncio.CancelledError: + pass + self.nick_changer_task = None + stop_msgs.append("Nickname changer stopped and nick reverted.") + + await self._send_message(target, " ".join(stop_msgs)) + + async def _unicode_spam_loop(self, target_nick): + """ + Continuously send messages composed of Unicode characters to the given target. + This loop is cancelled when the !unicodestop command is issued. + """ + message_count = 0 + try: + while True: + buffer = [] + # Cycle through valid code points. + for code_point in self.valid_code_points: + try: + buffer.append(chr(code_point)) + # When the buffer gets large, send a message. + if len(buffer) >= 400: + await self._send_message(target_nick, ''.join(buffer)) + buffer.clear() + message_count += 1 + await self._throttle_messages(message_count) + except Exception as e: + self.bot.log.error(f"Error processing character {code_point}: {e}") + + # Send any remaining characters. + if buffer: + await self._send_message(target_nick, ''.join(buffer)) + except asyncio.CancelledError: + self.bot.log.info(f"Unicode spam for {target_nick} cancelled.") + await self._send_message(target_nick, "Unicode spam stopped.") + raise + + async def _send_message(self, target, message): + """Send a message to the target with error handling.""" + try: + self.bot.privmsg(target, message) + except Exception as e: + self.bot.log.error(f"Error sending message to {target}: {e}") + + async def _throttle_messages(self, message_count): + """Throttle messages to prevent flooding.""" + await asyncio.sleep(0.007) + if message_count % 5 == 0: + await asyncio.sleep(0.07) + if message_count % 25 == 0: + await asyncio.sleep(0.07) + if message_count % 50 == 0: + await asyncio.sleep(0.07) \ No newline at end of file diff --git a/plugins/upload.py b/plugins/upload.py new file mode 100644 index 0000000..4d7976c --- /dev/null +++ b/plugins/upload.py @@ -0,0 +1,216 @@ +import aiohttp +import aiofiles +import irc3 +import tempfile +import os +import re +import asyncio +from irc3.plugins.command import command +import ircstyle +import yt_dlp +from yt_dlp.utils import DownloadError +from urllib.parse import urlparse + +@irc3.plugin +class UploadPlugin: + """IRC bot plugin for uploading files to hardfiles.org using yt-dlp for downloads.""" + + def __init__(self, bot): + self.bot = bot + + @command + async def upload(self, mask, target, args): + """ + Upload a file to hardfiles.org (Max 100MB). + + %%upload [--mp3] + """ + url = args.get('') + mp3 = args.get('--mp3') + if not url: + self.bot.privmsg( + target, + ircstyle.style("Usage: !upload [--mp3] ", fg="red", bold=True, reset=True) + ) + return + + try: + # Directly await the upload task. + await self.do_upload(url, target, mp3) + except Exception as exc: + self.bot.privmsg( + target, + ircstyle.style(f"Upload task error: {exc}", fg="red", bold=True, reset=True) + ) + + async def do_upload(self, url, target, mp3): + """Download a file using yt-dlp and upload it.""" + max_size = 100 * 1024 * 1024 # 100MB limit + + # Use a temporary directory context manager so cleanup is automatic. + with tempfile.TemporaryDirectory() as tmp_dir: + # Parse URL and determine if a header check is needed. + parsed_url = urlparse(url) + domain = parsed_url.netloc.lower() + skip_check_domains = ("x.com", "instagram.com", "youtube.com", "youtu.be", "streamable.com") + should_check_headers = not any(domain.endswith(d) for d in skip_check_domains) + + if should_check_headers: + async with aiohttp.ClientSession() as session: + async with session.head(url) as response: + if response.status != 200: + self.bot.privmsg( + target, + ircstyle.style(f"Failed to fetch headers: HTTP {response.status}", fg="red", bold=True, reset=True) + ) + return + content_length = response.headers.get('Content-Length') + if content_length and int(content_length) > max_size: + self.bot.privmsg( + target, + ircstyle.style( + f"File size ({int(content_length) // (1024 * 1024)}MB) exceeds 100MB limit", + fg="red", bold=True, reset=True + ) + ) + return + + # Set up yt-dlp options. + ydl_opts = { + 'outtmpl': os.path.join(tmp_dir, '%(title)s.%(ext)s'), + 'format': 'bestaudio/best' if mp3 else 'best[ext=mp4]/best', + 'restrictfilenames': True, + 'noplaylist': True, + 'quiet': True, + 'concurrent_fragment_downloads': 5, + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }] if mp3 else [], + } + + # Use yt_dlp to extract information (first without downloading). + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + info = await asyncio.to_thread(ydl.extract_info, url, download=False) + except DownloadError as e: + self.bot.privmsg( + target, + ircstyle.style(f"Info extraction failed: {e}", fg="red", bold=True, reset=True) + ) + return + + # Check the estimated file size if available. + estimated_size = info.get('filesize') or info.get('filesize_approx') + if estimated_size and estimated_size > max_size: + self.bot.privmsg( + target, + ircstyle.style( + f"File size ({estimated_size // (1024 * 1024)}MB) exceeds 100MB limit", + fg="red", bold=True, reset=True + ) + ) + return + + # Proceed with the download. + try: + info = await asyncio.to_thread(ydl.extract_info, url, download=True) + except DownloadError as e: + self.bot.privmsg( + target, + ircstyle.style(f"Download failed: {e}", fg="red", bold=True, reset=True) + ) + return + + # Prepare and send metadata (if available). + metadata_parts = [] + title = info.get("title") + uploader = info.get("uploader") + duration = info.get("duration") + upload_date = info.get("upload_date") + view_count = info.get("view_count") + description = info.get("description") + + if title: + metadata_parts.append(ircstyle.style(f"Title: {title}", fg="yellow", bold=True, reset=True)) + if uploader: + metadata_parts.append(ircstyle.style(f"Uploader: {uploader}", fg="purple", bold=True, reset=True)) + if duration: + metadata_parts.append(ircstyle.style(f"Duration: {self._format_duration(duration)}", fg="green", bold=True, reset=True)) + if upload_date: + # Format date as YYYY-MM-DD if possible. + formatted_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}" if len(upload_date) == 8 else upload_date + metadata_parts.append(ircstyle.style(f"Upload Date: {formatted_date}", fg="aqua", bold=True, reset=True)) + if view_count is not None: + metadata_parts.append(ircstyle.style(f"Views: {view_count}", fg="royal", bold=True, reset=True)) + if description: + if len(description) > 200: + description = description[:200] + "..." + metadata_parts.append(ircstyle.style(f"Description: {description}", fg="silver", reset=True)) + if metadata_parts: + self.bot.privmsg(target, " | ".join(metadata_parts)) + + # Verify that a file was downloaded. + downloaded_files = info.get('requested_downloads', []) + if not downloaded_files: + self.bot.privmsg( + target, + ircstyle.style("No files downloaded", fg="red", bold=True, reset=True) + ) + return + + # Retrieve the file path. + first_file = downloaded_files[0] + downloaded_file = first_file.get('filepath', first_file.get('filename')) + if not downloaded_file or not os.path.exists(downloaded_file): + self.bot.privmsg( + target, + ircstyle.style(f"Downloaded file not found: {downloaded_file}", fg="red", bold=True, reset=True) + ) + return + + # Check the actual file size as an extra safeguard. + file_size = os.path.getsize(downloaded_file) + if file_size > max_size: + self.bot.privmsg( + target, + ircstyle.style( + f"File size ({file_size // (1024 * 1024)}MB) exceeds 100MB limit", + fg="red", bold=True, reset=True + ) + ) + return + + # Upload the file to hardfiles.org. + async with aiohttp.ClientSession() as session: + form = aiohttp.FormData() + async with aiofiles.open(downloaded_file, 'rb') as f: + file_content = await f.read() + form.add_field('file', file_content, filename=os.path.basename(downloaded_file)) + async with session.post('https://hardfiles.org/', data=form, allow_redirects=False) as resp: + if resp.status not in [200, 201, 302, 303]: + self.bot.privmsg( + target, + ircstyle.style(f"Upload failed: HTTP {resp.status}", fg="red", bold=True, reset=True) + ) + else: + response_text = await resp.text() + upload_url = self.extract_url_from_response(response_text) or "Unknown URL" + response_msg = ( + ircstyle.style("Upload successful: ", fg="green", bold=True, reset=True) + + ircstyle.style(upload_url, fg="blue", underline=True, reset=True) + ) + self.bot.privmsg(target, response_msg) + + def extract_url_from_response(self, response_text): + """Extract the first URL found in the response text.""" + match = re.search(r'https?://\S+', response_text) + return match.group(0) if match else None + + def _format_duration(self, seconds): + """Convert seconds into a human-readable Hh Mm Ss format.""" + seconds = int(seconds) + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + return f"{h}h {m}m {s}s" if h else f"{m}m {s}s" diff --git a/plugins/urban_dictionary.py b/plugins/urban_dictionary.py new file mode 100644 index 0000000..7265bf0 --- /dev/null +++ b/plugins/urban_dictionary.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from irc3.plugins.command import command +from irc3.compat import Queue +import irc3 +import aiohttp + +@irc3.plugin +class UrbanDictionaryPlugin: + """ + A plugin to search Urban Dictionary for terms and post results to IRC. + This plugin uses a queue to manage asynchronous search requests and ensures + proper resource cleanup when unloaded. + """ + + def __init__(self, bot): + self.bot = bot + self.queue = Queue() # Queue for managing search requests + self.session = None # aiohttp session initialized lazily + self.bot.loop.create_task(self._process_queue()) # Start queue processor + + @command(permission='view', aliases=['ud'], public=True) + def urban(self, mask, target, args): + """ + Search Urban Dictionary for a term. + %%urban ... + """ + term = ' '.join(args['']) + if not term: + return "Please provide a term to search." + + # Enqueue the search request + self.queue.put_nowait((term, target)) + return f"\x02Searching Urban Dictionary\x02 for '\x034{term}\x03'..." + + async def _process_queue(self): + """ + Process search requests from the queue asynchronously. + This method runs indefinitely, processing one request at a time. + """ + while True: + try: + # Get the next search request + term, target = await self.queue.get() + + # Ensure the session is initialized + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession(loop=self.bot.loop) + + # Fetch data from Urban Dictionary API + url = f"https://api.urbandictionary.com/v0/define?term={term}" + async with self.session.get(url) as response: + if response.status != 200: + self.bot.privmsg(target, f"\x02Error:\x02 Failed to fetch definition for '\x034{term}\x03'.") + continue + + data = await response.json() + if not data.get('list'): + self.bot.privmsg(target, f"\x02No definition found\x02 for '\x034{term}\x03'.") + continue + + # Extract the first result + result = data['list'][0] + definition = result['definition'].replace('\n', ' ').strip() + example = result['example'].replace('\n', ' ').strip() + permalink = result['permalink'] + + # Format the response with colors and bold + response_message = ( + f"\x02[{term}]\x02 \x033{definition}\x03 | " + f"Example: \x0312{example}\x03 | " + f"More: \x032{permalink}\x03" + ) + + # Send the response to the IRC channel + self.bot.privmsg(target, response_message) + + except Exception as e: + self.bot.privmsg(target, f"\x02Error:\x02 An unexpected error occurred: {str(e)}") + + finally: + # Mark the task as done + self.queue.task_done() + + def __unload__(self): + """ + Cleanup when the plugin is unloaded. + Ensures that the aiohttp session is properly closed to avoid resource leaks. + """ + if self.session and not self.session.closed: + self.bot.loop.create_task(self.session.close()) \ No newline at end of file diff --git a/plugins/url_title_sniffer.py b/plugins/url_title_sniffer.py new file mode 100644 index 0000000..0b28a2e --- /dev/null +++ b/plugins/url_title_sniffer.py @@ -0,0 +1,132 @@ +""" +A plugin for fetching and displaying titles of URLs shared in IRC messages. +""" + +import re +import asyncio +import aiohttp +from lxml import html +import irc3 +from irc3 import event +from irc3.compat import Queue + + +class URLTitlePlugin: + """ + A plugin to fetch and display the titles of URLs shared in IRC messages. + + Attributes: + bot (irc3.IrcBot): The IRC bot instance. + url_queue (Queue): A queue to manage URL processing asynchronously. + session (aiohttp.ClientSession): An HTTP session for making requests. + """ + + def __init__(self, bot): + """ + Initialize the URLTitlePlugin. + + Args: + bot (irc3.IrcBot): The IRC bot instance. + """ + self.bot = bot + self.url_queue = Queue() + self.session = aiohttp.ClientSession(loop=self.bot.loop) + self.bot.create_task(self.process_urls()) + + @event(irc3.rfc.PRIVMSG) + async def on_privmsg(self, mask, event, target, data): + """ + Listen for PRIVMSG events and check for URLs. + + Args: + mask (str): The user's mask (e.g., nick!user@host). + event (str): The IRC event type (e.g., PRIVMSG). + target (str): The target of the message (e.g., channel or user). + data (str): The content of the message. + + This method extracts URLs from the message and adds them to the queue + for asynchronous processing. + """ + url_pattern = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+") + urls = url_pattern.findall(data) + for url in urls: + # Use put_nowait to avoid blocking if the queue is full + await self.url_queue.put((url, target)) + + async def process_urls(self): + """ + Process URLs from the queue and fetch their titles. + + This method runs indefinitely, processing one URL at a time from the queue. + It fetches the title of each URL and sends it back to the IRC channel. + """ + while True: + url, target = await self.url_queue.get() + try: + title = await self.fetch_title(url) + if title: + # Format the IRC message with colors and styles + formatted_message = ( + f"\x02\x0312Title:\x03 \x034{title}\x03 \x02|\x02 " + f"\x032URL:\x03 \x0311{url}\x03" + ) + await self.bot.privmsg(target, formatted_message) + else: + # Format the error message with colors and styles + # formatted_message = ( + # f"\x02\x034Error:\x03 Could not find a title for: " + # f"\x0311{url}\x03" + # ) + # await self.bot.privmsg(target, formatted_message) + pass + except Exception as e: + self.bot.log.error(f"Error processing URL {url}: {e}") + finally: + self.url_queue.task_done() + + async def fetch_title(self, url): + """ + Fetch the title of a web page using aiohttp and lxml. + + Args: + url (str): The URL of the web page. + + Returns: + str: The title of the web page, or None if it could not be fetched. + + This method makes an HTTP GET request to the URL, parses the HTML content, + and extracts the title element. + """ + headers = {"User-Agent": "Mozilla/5.0"} + try: + async with self.session.get(url, headers=headers, timeout=10) as response: + # Check if the response was successful + response.raise_for_status() + content = await response.text() + tree = html.fromstring(content) + title = tree.findtext(".//title") + return title.strip() if title else "No title found" + except aiohttp.ClientError as e: + self.bot.log.error(f"HTTP error for {url}: {e}") + except asyncio.TimeoutError: + self.bot.log.error(f"Request timed out for {url}") + except Exception as e: + self.bot.log.error(f"Unexpected error for {url}: {e}") + return None + + async def close(self): + """ + Clean up resources when the plugin is unloaded. + + This method ensures that the aiohttp session is properly closed to avoid + resource leaks. + """ + await self.session.close() + + def __del__(self): + """ + Ensure session closing when the object is destroyed. + + This method schedules the session cleanup task on the bot's event loop. + """ + self.bot.create_task(self.close()) \ No newline at end of file diff --git a/plugins/users.py b/plugins/users.py new file mode 100644 index 0000000..a58c0b0 --- /dev/null +++ b/plugins/users.py @@ -0,0 +1,203 @@ +from __future__ import annotations +import logging +from irc3 import plugin, utils, rfc +from irc3.dec import event +from irc3.utils import IrcString +from collections import defaultdict +from typing import Optional, Set, Dict, Any, cast + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class Channel(set): + """Represents an IRC channel with member management and mode tracking.""" + + def __init__(self) -> None: + super().__init__() + self.modes: Dict[str, Set[str]] = defaultdict(set) + self.topic: Optional[str] = None + + def add_member(self, nick: str, modes: str = '') -> None: + """Add member with optional modes.""" + super().add(nick) + self._update_modes(nick, modes, add=True) + logger.debug(f"Added member {nick} with modes {modes} to channel") + + def remove_member(self, nick: str) -> None: + """Remove member and all associated modes.""" + super().discard(nick) + self._update_modes(nick, remove=True) + logger.debug(f"Removed member {nick} from channel") + + def _update_modes(self, nick: str, modes: str = '', + add: bool = False, remove: bool = False) -> None: + """Update mode tracking for a member.""" + for mode in modes: + if add: + self.modes[mode].add(nick) + elif remove: + self.modes[mode].discard(nick) + logger.debug(f"Updated modes for {nick}: {'Added' if add else 'Removed'} {modes}") + + def __repr__(self) -> str: + return f"Channel({sorted(self)})" + +@plugin +class Userlist: + """Enhanced user tracking with proper hostmask handling and mode management.""" + + def __init__(self, context: Any) -> None: + self.context = context + self.connection_lost() + + def connection_lost(self, client: Any = None) -> None: + """Reset state on connection loss.""" + self.channels: Dict[str, Channel] = defaultdict(Channel) + self.nicks: Dict[str, IrcString] = {} + self.context.channels = self.channels + self.context.nicks = self.nicks + logger.info("Connection lost, state reset.") + + @event(rfc.JOIN_PART_QUIT) + def on_join_part_quit(self, mask: Optional[IrcString] = None, + event: Optional[str] = None, **kwargs: Any) -> None: + """Handle join/part/quit events.""" + if mask and event: + getattr(self, event.lower())(mask.nick, mask, **kwargs) + logger.debug(f"Event {event} for {mask.nick}") + + @event(rfc.KICK) + def on_kick(self, mask: Optional[IrcString] = None, target: Optional[IrcString] = None, + **kwargs: Any) -> None: + """Handle kick events.""" + if target: + self.part(target.nick, mask, **kwargs) # Note: Added mask here for consistency + logger.debug(f"User {target.nick} was kicked from a channel") + + def join(self, nick: str, mask: IrcString, channel: str, **kwargs: Any) -> None: + """Process user join.""" + channel_obj = self.channels[channel] + if nick != self.context.nick: + channel_obj.add_member(mask.nick) + self.nicks[mask.nick] = mask + self._broadcast(channel_obj, **kwargs) + logger.debug(f"User {nick} joined channel {channel}") + + def part(self, nick: str, mask: IrcString, channel: str, **kwargs: Any) -> None: + """Process user part.""" + if nick == self.context.nick: + self.channels.pop(channel, None) + logger.debug(f"Left channel {channel}") + else: + channel_obj = self.channels.get(channel) + if channel_obj: + self._broadcast(channel_obj, **kwargs) + channel_obj.remove_member(nick) + self._cleanup_nick(nick) + logger.debug(f"User {nick} parted from channel {channel}") + + def quit(self, nick: str, mask: IrcString, **kwargs: Any) -> None: + """Process user quit.""" + if nick == self.context.nick: + self.connection_lost() + logger.info(f"User {nick} quit, connection lost") + else: + affected = set() + for channel in self.channels.values(): + if nick in channel: + channel.remove_member(nick) + affected.update(channel) + self._broadcast(affected, **kwargs) + self._cleanup_nick(nick) + logger.debug(f"User {nick} quit from channels: {affected}") + + @event(rfc.NEW_NICK) + def new_nick(self, nick: IrcString, new_nick: str, **kwargs: Any) -> None: + """Handle nickname changes.""" + host = nick.host or '' + self.nicks[new_nick] = IrcString(f"{new_nick}!{host}") + old_nick = nick.nick + self._update_nick_in_channels(old_nick, new_nick) + self.nicks.pop(old_nick, None) + self._broadcast({new_nick}, **kwargs) + logger.debug(f"Nickname change: {old_nick} -> {new_nick}") + + def _update_nick_in_channels(self, old_nick: str, new_nick: str) -> None: + """Update nickname across all channels.""" + for channel in self.channels.values(): + if old_nick in channel: + channel.remove_member(old_nick) + channel.add_member(new_nick) + for mode_set in channel.modes.values(): + if old_nick in mode_set: + mode_set.add(new_nick) + mode_set.discard(old_nick) + logger.debug(f"Updated nickname {old_nick} to {new_nick} in channels") + + @event(rfc.RPL_NAMREPLY) + def names(self, channel: str, data: str, **kwargs: Any) -> None: + """Process NAMES reply with proper mode handling.""" + statusmsg = self.context.server_config.get('STATUSMSG', '@+') + prefix_config = self.context.server_config.get('PREFIX', '(ov)@+') + mode_chars, prefixes = self._parse_prefix_config(prefix_config) + prefix_map = dict(zip(prefixes, mode_chars)) + + channel_obj = self.channels[channel] + for item in data.split(): + nick = item.lstrip(statusmsg) + modes = ''.join([prefix_map[p] for p in item[:len(item)-len(nick)] + if p in prefix_map]) + channel_obj.add_member(nick, modes) + logger.debug(f"Processed NAMES reply for channel {channel}") + + def _parse_prefix_config(self, config: str) -> tuple[str, str]: + """Parse PREFIX config into mode characters and symbols.""" + parts = config.strip('()').split(')') + return (parts[0], parts[1]) if len(parts) == 2 else ('ov', '@+') + + @event(rfc.RPL_WHOREPLY) + def who(self, channel: str, nick: str, username: str, + server: str, **kwargs: Any) -> None: + """Process WHO reply with hostmask.""" + mask = IrcString(f"{nick}!{username}@{server}") + self.channels[channel].add_member(nick) + self.nicks[nick] = mask + logger.debug(f"Processed WHO reply for {nick} in {channel}") + + @event(rfc.MODE) + def mode(self, target: str, modes: str, data: Any, **kwargs: Any) -> None: + """Handle mode changes with proper parameter handling.""" + chantypes = self.context.server_config.get('CHANTYPES', '#&') + if not target or target[0] not in chantypes: + return + + prefix_config = self.context.server_config.get('PREFIX', '(ov)@+') + mode_chars, prefixes = self._parse_prefix_config(prefix_config) + param_modes = self.context.server_config.get('CHANMODES', '').split(',')[0] + + parsed = utils.parse_modes(modes, data.split() if isinstance(data, str) else data) + channel = self.channels[target] + + for sign, mode, param in parsed: + if mode in param_modes and param: + # Handle parameterized modes (e.g., +b, +k) + pass + elif mode in mode_chars: + prefix = dict(zip(mode_chars, prefixes)) + mode_key = prefix.get(mode, '') + if sign == '+': + channel.modes[mode_key].add(param) + else: + channel.modes[mode_key].discard(param) + logger.debug(f"Mode change in {target}: {modes}") + + def _cleanup_nick(self, nick: str) -> None: + """Remove nick if not in any channels.""" + if not any(nick in channel for channel in self.channels.values()): + self.nicks.pop(nick, None) + logger.debug(f"Cleaned up nick {nick} as no longer in any channels") + + def _broadcast(self, targets: Any, **kwargs: Any) -> None: + """Placeholder for broadcast functionality.""" + logger.debug("Broadcasting to targets: %s", targets) \ No newline at end of file diff --git a/plugins/whois.py b/plugins/whois.py new file mode 100644 index 0000000..85bf424 --- /dev/null +++ b/plugins/whois.py @@ -0,0 +1,56 @@ +import irc3 +import re +from irc3 import dec + +@irc3.plugin +class AsyncNotifyOnJoin: + def __init__(self, bot): + self.bot = bot + # Ensure async_cmds is available + if not hasattr(self.bot, 'async_cmds'): + self.bot.log.error("async_cmds plugin not loaded. Please add 'irc3.plugins.asynchronious' to your config.") + raise RuntimeError("Missing async_cmds plugin") + +@dec.extend +async def notify_users(self, channel, joiner): + """Async method to get channel users and send notifications""" + try: + # Ensure channel is in the bot's channel list + if channel not in self.bot.channels: + self.bot.log.warning(f"Bot is not in channel: {channel}") + return + + # Debug: Log the async_cmds object + self.bot.log.debug(f"async_cmds: {self.bot.async_cmds}") + + # Debug: Log the names method + if hasattr(self.bot.async_cmds, 'names'): + self.bot.log.debug("async_cmds.names() is available") + else: + self.bot.log.error("async_cmds.names() is missing!") + + # Get fresh user list through IRC protocol + result = await self.bot.async_cmds.names(channel) + self.bot.log.debug(f"Result from async_cmds.names(): {result}") + + if result and result.get('success'): + for nickname in result['names']: + # Clean nickname from mode prefixes + clean_nick = re.sub(r'^[@~+&%]', '', nickname) + if clean_nick != self.bot.nick: + self.bot.privmsg( + clean_nick, + f"{joiner} has joined {channel}. Welcome them!" + ) + else: + self.bot.log.error(f"Failed to fetch user list for {channel}") + except Exception as e: + self.bot.log.error(f"Notification failed: {e}") + + @irc3.event(irc3.rfc.JOIN) + def on_join(self, channel, **kwargs): + """Trigger async notification process on join""" + joiner = kwargs['mask'].nick + if joiner != self.bot.nick: + # Create async task to handle notifications + self.bot.loop.create_task(self.notify_users(channel, joiner)) \ No newline at end of file