diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df715ef3..e432c3ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Changelog + - **1.0.3** - More minor changes to plugins, fixed rate-limiting properly, banished SCP to CloudBotIRC/Plugins, added wildcard support to permissions (note: don't use this yet, it's still not entirely finalized!) - **1.0.2** - Minor internal changes and fixes, banished minecraft_bukget and worldofwarcraft to CloudBotIRC/Plugins - **1.0.1** - Fix history.py tracking - **1.0.0** - Initial stable release \ No newline at end of file diff --git a/cloudbot/__init__.py b/cloudbot/__init__.py index 8e1a6f85e..be8a34c6d 100644 --- a/cloudbot/__init__.py +++ b/cloudbot/__init__.py @@ -10,7 +10,7 @@ import logging import os -__version__ = "1.0.2" +__version__ = "1.0.3" __all__ = ["util", "bot", "connection", "config", "permissions", "plugin", "event", "hook", "log_dir"] diff --git a/cloudbot/bot.py b/cloudbot/bot.py index 6b86e9605..5f4699f3a 100644 --- a/cloudbot/bot.py +++ b/cloudbot/bot.py @@ -1,6 +1,7 @@ import asyncio import time import logging +import collections import re import os import gc @@ -61,6 +62,9 @@ def __init__(self, loop=asyncio.get_event_loop()): # for plugins self.logger = logger + # for plugins to abuse + self.memory = collections.defaultdict() + # declare and create data folder self.data_dir = os.path.abspath('data') if not os.path.exists(self.data_dir): diff --git a/cloudbot/client.py b/cloudbot/client.py index 23d015cbf..d6665c09f 100644 --- a/cloudbot/client.py +++ b/cloudbot/client.py @@ -1,5 +1,6 @@ import asyncio import logging +import collections from cloudbot.permissions import PermissionManager @@ -48,6 +49,9 @@ def __init__(self, bot, name, nick, *, channels=None, config=None): # create permissions manager self.permissions = PermissionManager(self) + # for plugins to abuse + self.memory = collections.defaultdict() + def describe_server(self): raise NotImplementedError diff --git a/cloudbot/clients/irc.py b/cloudbot/clients/irc.py index d86750c33..1f413f3ca 100644 --- a/cloudbot/clients/irc.py +++ b/cloudbot/clients/irc.py @@ -277,7 +277,7 @@ def send(self, line): # make sure we are connected before sending if not self._connected: yield from self._connected_future - line = line.splitlines()[0][:500] + "\r\n" + line = line[:510] + "\r\n" data = line.encode("utf-8", "replace") self._transport.write(data) diff --git a/cloudbot/permissions.py b/cloudbot/permissions.py index 50c20ccea..786a60199 100644 --- a/cloudbot/permissions.py +++ b/cloudbot/permissions.py @@ -75,17 +75,15 @@ def has_perm_mask(self, user_mask, perm, notice=True): if fnmatch(user_mask.lower(), backdoor.lower()): return True - if not perm.lower() in self.perm_users: - # no one has access - return False - - allowed_users = self.perm_users[perm.lower()] - - for allowed_mask in allowed_users: - if fnmatch(user_mask.lower(), allowed_mask): - if notice: - logger.info("[{}|permissions] Allowed user {} access to {}".format(self.name, user_mask, perm)) - return True + perm = perm.lower() + + for user_perm, allowed_users in self.perm_users.items(): + if fnmatch(perm, user_perm): + for allowed_mask in allowed_users: + if fnmatch(allowed_mask, user_mask.lower()): + if notice: + logger.info("[{}|permissions] Allowed user {} access to {}".format(self.name, user_mask, perm)) + return True return False diff --git a/cloudbot/util/cleverbot.py b/cloudbot/util/cleverbot.py index 2e0814640..de1cefc99 100644 --- a/cloudbot/util/cleverbot.py +++ b/cloudbot/util/cleverbot.py @@ -122,6 +122,6 @@ def quote(s, safe='/'): safe_map[c] = (c in safe) and c or ('%%%02X' % i) try: res = list(map(safe_map.__getitem__, s)) - except: + except Exception: return '' return ''.join(res) diff --git a/cloudbot/util/formatting.py b/cloudbot/util/formatting.py index 7943efccb..e2233a28b 100644 --- a/cloudbot/util/formatting.py +++ b/cloudbot/util/formatting.py @@ -202,13 +202,13 @@ def dict_format(args, formats): m = f.format(**args) # Insert match and number of matched values (max matched values if already in dict) matches[m] = max([matches.get(m, 0), len(re.findall(r'(\{.*?\})', f))]) - except: + except Exception: continue # Return most complete match, ranked by values matched and then my match length or None try: return max(matches.items(), key=lambda x: (x[1], len(x[0])))[0] - except: + except Exception: return None diff --git a/plugins/core_sieve.py b/plugins/core_sieve.py index e1af9b8a3..41d45f745 100644 --- a/plugins/core_sieve.py +++ b/plugins/core_sieve.py @@ -6,45 +6,37 @@ from cloudbot import hook from cloudbot.util.tokenbucket import TokenBucket -inited = [] - -# when STRICT is enabled, every time a user gets ratelimted it wipes -# their tokens so they have to wait at least X seconds to regen - +ready = False buckets = {} - logger = logging.getLogger("cloudbot") def task_clear(loop): - for uid, _bucket in buckets: + global buckets + for uid, _bucket in buckets.copy().items(): if (time() - _bucket.timestamp) > 600: del buckets[uid] - loop.call_later(600, task_clear, loop) + loop.call_later(10, task_clear, loop) @asyncio.coroutine @hook.irc_raw('004') def init_tasks(loop, conn): - global inited - if conn.name in inited: + global ready + if ready: # tasks already started return logger.info("[{}|sieve] Bot is starting ratelimiter cleanup task.".format(conn.name)) - loop.call_later(600, task_clear, loop) - inited.append(conn.name) + loop.call_later(10, task_clear, loop) + ready = True @asyncio.coroutine @hook.sieve def sieve_suite(bot, event, _hook): - """ - this function stands between your users and the commands they want to use. it decides if they can or not - :type bot: cloudbot.bot.CloudBot - :type event: cloudbot.event.Event - :type _hook: cloudbot.plugin.Hook - """ + global buckets + conn = event.conn # check ignore bots if event.irc_command == 'PRIVMSG' and event.nick.endswith('bot') and _hook.ignore_bots: @@ -83,8 +75,7 @@ def sieve_suite(bot, event, _hook): # check command spam tokens if _hook.type == "command": - # right now ratelimiting is per-channel, but this can be changed - uid = (event.chan, event.nick.lower()) + uid = "!".join([conn.name, event.chan, event.nick]).lower() tokens = conn.config.get('ratelimit', {}).get('tokens', 17.5) restore_rate = conn.config.get('ratelimit', {}).get('restore_rate', 2.5) @@ -110,5 +101,3 @@ def sieve_suite(bot, event, _hook): return None return event - - diff --git a/plugins/core_tracker.py b/plugins/core_tracker.py index 70b09e369..a23093ea4 100644 --- a/plugins/core_tracker.py +++ b/plugins/core_tracker.py @@ -3,7 +3,6 @@ import asyncio import logging import re -import functools from collections import deque from cloudbot import hook diff --git a/plugins/dice.py b/plugins/dice.py deleted file mode 100644 index fd0ad8815..000000000 --- a/plugins/dice.py +++ /dev/null @@ -1,99 +0,0 @@ -# Written by Scaevolus, updated by Lukeroge - - -import re -import asyncio -import random - -from cloudbot import hook - -whitespace_re = re.compile(r'\s+') -valid_diceroll = re.compile(r'^([+-]?(?:\d+|\d*d(?:\d+|F))(?:[+-](?:\d+|\d*d(?:\d+|F)))*)( .+)?$', re.I) -sign_re = re.compile(r'[+-]?(?:\d*d)?(?:\d+|F)', re.I) -split_re = re.compile(r'([\d+-]*)d?(F|\d*)', re.I) - - -def n_rolls(count, n): - """roll an n-sided die count times - :type count: int - :type n: int | str - """ - if n == "F": - return [random.randint(-1, 1) for x in range(min(count, 100))] - if n < 2: # it's a coin - if count < 100: - return [random.randint(0, 1) for x in range(count)] - else: # fake it - return [int(random.normalvariate(.5 * count, (.75 * count) ** .5))] - else: - if count < 100: - return [random.randint(1, n) for x in range(count)] - else: # fake it - return [int(random.normalvariate(.5 * (1 + n) * count, - (((n + 1) * (2 * n + 1) / 6. - - (.5 * (1 + n)) ** 2) * count) ** .5))] - - -# @hook.regex(valid_diceroll, re.I) -@asyncio.coroutine -@hook.command("roll", "dice") -def dice(text, notice): - """ - simulates dice rolls. Example: 'dice 2d20-d5+4 roll 2': D20s, subtract 1D5, add 4 - :type text: str - """ - - if hasattr(text, "groups"): - text, desc = text.groups() - else: # type(text) == str - match = valid_diceroll.match(whitespace_re.sub("", text)) - if match: - text, desc = match.groups() - else: - notice("Invalid dice roll '{}'".format(text)) - return - - if "d" not in text: - return - - spec = whitespace_re.sub('', text) - if not valid_diceroll.match(spec): - notice("Invalid dice roll '{}'".format(text)) - return - groups = sign_re.findall(spec) - - total = 0 - rolls = [] - - for roll in groups: - count, side = split_re.match(roll).groups() - count = int(count) if count not in " +-" else 1 - if side.upper() == "F": # fudge dice are basically 1d3-2 - for fudge in n_rolls(count, "F"): - if fudge == 1: - rolls.append("\x033+\x0F") - elif fudge == -1: - rolls.append("\x034-\x0F") - else: - rolls.append("0") - total += fudge - elif side == "": - total += count - else: - side = int(side) - try: - if count > 0: - d = n_rolls(count, side) - rolls += list(map(str, d)) - total += sum(d) - else: - d = n_rolls(-count, side) - rolls += [str(-x) for x in d] - total -= sum(d) - except OverflowError: - # I have never seen this happen. If you make this happen, you win a cookie - return "Thanks for overflowing a float, jerk >:[" - - if desc: - return "{}: {} ({})".format(desc.strip(), total, ", ".join(rolls)) - else: - return "{} ({})".format(total, ", ".join(rolls)) diff --git a/plugins/geoip.py b/plugins/geoip.py index dc1b8ae31..a9d9baa29 100644 --- a/plugins/geoip.py +++ b/plugins/geoip.py @@ -42,7 +42,7 @@ def update_db(): else: try: return geoip2.database.Reader(PATH) - except: + except geoip2.errors.GeoIP2Error: # issue loading, geo fetch_db() return geoip2.database.Reader(PATH) diff --git a/plugins/ignore.py b/plugins/ignore.py index 81a3a65b5..7266ad125 100644 --- a/plugins/ignore.py +++ b/plugins/ignore.py @@ -77,7 +77,7 @@ def ignore_sieve(bot, event, _hook): return event # don't block an event that could be unignoring - if _hook.type == "command" and event.triggered_command == "unignore": + if _hook.type == "command" and event.triggered_command in ("unignore", "global_unignore"): return event if event.mask is None: diff --git a/plugins/imgur.py b/plugins/imgur.py index 69afc9b85..59e24faed 100644 --- a/plugins/imgur.py +++ b/plugins/imgur.py @@ -2,6 +2,7 @@ import random from imgurpython import ImgurClient +from contextlib import suppress from cloudbot import hook from cloudbot.util import web @@ -85,11 +86,9 @@ def imgur(text): title = item.title # if it's an imgur meme, add the meme name - try: + # if not, AttributeError will trigger and code will carry on + with suppress(AttributeError): title = "\x02{}\x02 - {}".format(item.meme_metadata["meme_name"].lower(), title) - except: - # this is a super un-important thing, so if it fails we don't care, carry on - pass # if the item has a tag, show that if item.section: diff --git a/plugins/password.py b/plugins/password.py index 4bc4aaff2..0b3185043 100644 --- a/plugins/password.py +++ b/plugins/password.py @@ -32,6 +32,7 @@ def password(text, notice): if length > 50: notice("Maximum length is 50 characters.") + return # add alpha characters if "alpha" in text or "letter" in text: @@ -65,18 +66,18 @@ def password(text, notice): @hook.command("wpass", "wordpass", "wordpassword", autohelp=False) def word_password(text, notice): """[length] - generates an easy to remember password with [length] (default 4) commonly used words""" - if text: - try: - length = int(text) - except ValueError: - notice("Invalid input '{}'".format(text)) - return - else: - length = 4 + try: + length = int(text) + except ValueError: + length = 3 + + if length > 10: + notice("Maximum length is 50 characters.") + return + words = [] # generate password for x in range(length): words.append(gen.choice(common_words)) notice("Your password is '{}'. Feel free to remove the spaces when using it.".format(" ".join(words))) - diff --git a/plugins/quote.py b/plugins/quote.py index db470eb92..17c133966 100644 --- a/plugins/quote.py +++ b/plugins/quote.py @@ -4,9 +4,12 @@ from cloudbot import hook from cloudbot.util import botvars + +from sqlalchemy import select from sqlalchemy import Table, Column, String, PrimaryKeyConstraint from sqlalchemy.types import REAL -from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + qtable = Table( 'quote', @@ -40,7 +43,7 @@ def add_quote(db, chan, target, sender, message): ) db.execute(query) db.commit() - except: + except IntegrityError: return "Message already stored, doing nothing." return "Quote added." diff --git a/plugins/randoms.py b/plugins/randoms.py index cc5ed744e..dc45c364d 100644 --- a/plugins/randoms.py +++ b/plugins/randoms.py @@ -1,9 +1,104 @@ +""" +randoms.py +Dice, coins, and other randomized things! +""" + import asyncio import random import re from cloudbot import hook +whitespace_re = re.compile(r'\s+') +valid_diceroll = re.compile(r'^([+-]?(?:\d+|\d*d(?:\d+|F))(?:[+-](?:\d+|\d*d(?:\d+|F)))*)( .+)?$', re.I) +sign_re = re.compile(r'[+-]?(?:\d*d)?(?:\d+|F)', re.I) +split_re = re.compile(r'([\d+-]*)d?(F|\d*)', re.I) + + +def n_rolls(count, n): + """roll an n-sided die count times + :type count: int + :type n: int | str + """ + if n == "F": + return [random.randint(-1, 1) for x in range(min(count, 100))] + if n < 2: # it's a coin + if count < 100: + return [random.randint(0, 1) for x in range(count)] + else: # fake it + return [int(random.normalvariate(.5 * count, (.75 * count) ** .5))] + else: + if count < 100: + return [random.randint(1, n) for x in range(count)] + else: # fake it + return [int(random.normalvariate(.5 * (1 + n) * count, + (((n + 1) * (2 * n + 1) / 6. - + (.5 * (1 + n)) ** 2) * count) ** .5))] + + +@asyncio.coroutine +@hook.command("roll", "dice") +def dice(text, notice): + """ - simulates dice rolls. Example: 'dice 2d20-d5+4 roll 2': D20s, subtract 1D5, add 4 + :type text: str + """ + + if hasattr(text, "groups"): + text, desc = text.groups() + else: # type(text) == str + match = valid_diceroll.match(whitespace_re.sub("", text)) + if match: + text, desc = match.groups() + else: + notice("Invalid dice roll '{}'".format(text)) + return + + if "d" not in text: + return + + spec = whitespace_re.sub('', text) + if not valid_diceroll.match(spec): + notice("Invalid dice roll '{}'".format(text)) + return + groups = sign_re.findall(spec) + + total = 0 + rolls = [] + + for roll in groups: + count, side = split_re.match(roll).groups() + count = int(count) if count not in " +-" else 1 + if side.upper() == "F": # fudge dice are basically 1d3-2 + for fudge in n_rolls(count, "F"): + if fudge == 1: + rolls.append("\x033+\x0F") + elif fudge == -1: + rolls.append("\x034-\x0F") + else: + rolls.append("0") + total += fudge + elif side == "": + total += count + else: + side = int(side) + try: + if count > 0: + d = n_rolls(count, side) + rolls += list(map(str, d)) + total += sum(d) + else: + d = n_rolls(-count, side) + rolls += [str(-x) for x in d] + total -= sum(d) + except OverflowError: + # I have never seen this happen. If you make this happen, you win a cookie + return "Thanks for overflowing a float, jerk >:[" + + if desc: + return "{}: {} ({})".format(desc.strip(), total, ", ".join(rolls)) + else: + return "{} ({})".format(total, ", ".join(rolls)) + @asyncio.coroutine @hook.command diff --git a/plugins/scp.py b/plugins/scp.py deleted file mode 100644 index bfe63caa4..000000000 --- a/plugins/scp.py +++ /dev/null @@ -1,136 +0,0 @@ -import re -import asyncio - -import requests -from bs4 import BeautifulSoup - -from cloudbot import hook -from cloudbot.util import web, formatting - - -class SCPError(Exception): - pass - -SCP_SEARCH = "http://www.scp-wiki.net/search:site/q/{}" -NAME_LISTS = ["http://www.scp-wiki.net/joke-scps", "http://www.scp-wiki.net/archived-scps", - "http://www.scp-wiki.net/decommissioned-scps", "http://www.scp-wiki.net/scp-ex", - "http://www.scp-wiki.net/scp-series", "http://www.scp-wiki.net/scp-series-2", - "http://www.scp-wiki.net/scp-series-3"] - -scp_cache = {} -scp_re = re.compile(r"(www.scp-wiki.net/scp-([a-zA-Z0-9-]+))") - - -@asyncio.coroutine -@hook.command -def load_names(loop): - """ creates a SCP-ID > NAME/URL mapping """ - for url in NAME_LISTS: - request = yield from loop.run_in_executor(None, requests.get, url) - soup = BeautifulSoup(request.text) - - page = soup.find('div', {'id': 'page-content'}).find('div', {'class': 'content-panel standalone series'}) - names = page.find_all("a", text=re.compile(r"SCP-")) - - for item in names: - scp_id = item.text - name = item.parent.contents[1][3:].strip() - url = item['href'] - data = (name, url) - scp_cache[scp_id] = data - - -@asyncio.coroutine -@hook.on_start() -def initial_refresh(loop): - # do an initial refresh of the caches - yield from load_names(loop) - - -def search(query): - """Takes an SCP name and returns a link""" - # we see if the query is an SCPID in our pre-generated cache - if query.upper() in scp_cache: - return "http://www.scp-wiki.net" + scp_cache[query.upper()][1] - - request = requests.get(SCP_SEARCH.format(query)) - soup = BeautifulSoup(request.content) - - results = soup.find('div', {'class': 'search-results'}) - if "no results" in results.get_text(): - return None - - item = results.find('div', {'class': 'item'}) - return item.find('div', {'class': 'url'}).get_text().strip() - - -def get_info(url, show_url=True): - """ Takes a SCPWiki URL and returns a formatted string """ - try: - request = requests.get(url) - request.raise_for_status() - except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError) as e: - raise SCPError("Error: Unable to fetch URL. ({})".format(e)) - html = request.text - contents = formatting.strip_html(html) - - try: - item_id = re.findall("Item #: (.+?)\n", contents, re.S)[0] - object_class = re.findall("Object Class: (.+?)\n", contents, re.S)[0] - description = re.findall("Description: (.+?)\n", contents, re.S)[0] - except IndexError: - raise SCPError("Error: Invalid or unreadable SCP. Does this SCP exist?") - - description = formatting.truncate(description, 130) - short_url = web.try_shorten(url) - - # get the title from our pre-generated cache - if item_id in scp_cache: - title = scp_cache[item_id][0] - else: - title = "Unknown" - - if show_url: - return "\x02Item Name:\x02 {}, \x02Item #:\x02 {}, \x02Class\x02: {}," \ - " \x02Description:\x02 {} - {}".format(title, item_id, object_class, description, short_url) - else: - return "\x02Item Name:\x02 {}, \x02Item #:\x02 {}, \x02Class\x02: {}," \ - " \x02Description:\x02 {}".format(title, item_id, object_class, description) - - -@hook.regex(scp_re) -def scp_url(match): - url = "http://" + match.group(1) - try: - return get_info(url, show_url=False) - except SCPError: - return - - -@hook.command -def scp(text): - """scp / -- Returns SCP Foundation wiki search result for /.""" - if not text.isdigit(): - term = text - else: - if len(text) == 4: - term = "SCP-" + text - elif len(text) == 3: - term = "SCP-" + text - elif len(text) == 2: - term = "SCP-0" + text - elif len(text) == 1: - term = "SCP-00" + text - else: - term = text - - # search for the SCP - url = search(term) - - if not url: - return "No results found." - - try: - return get_info(url) - except SCPError as e: - return e diff --git a/plugins/twitch.py b/plugins/twitch.py index f0d357544..d39329654 100644 --- a/plugins/twitch.py +++ b/plugins/twitch.py @@ -51,7 +51,7 @@ def twitch_lookup(location): else: try: data = http.get_json("https://api.twitch.tv/kraken/channels/" + channel) - except: + except Exception: return "Unable to get channel data. Maybe channel is on justin.tv instead of twitch.tv?" title = data['status'] playing = data['game'] diff --git a/plugins/whois.py b/plugins/whois.py index b051207f3..a81c36266 100644 --- a/plugins/whois.py +++ b/plugins/whois.py @@ -1,33 +1,34 @@ +""" +whois.py +Provides a command to allow users to look up information on domain names. +""" + import pythonwhois +from contextlib import suppress from cloudbot import hook @hook.command def whois(text): + """ -- Does a whois query on .""" domain = text.strip().lower() - data = pythonwhois.get_whois(domain, normalized=True) - + try: + data = pythonwhois.get_whois(domain, normalized=True) + except pythonwhois.shared.WhoisException: + return "Invalid input." info = [] - try: - i = "\x02Registrar\x02: {}".format(data["registrar"][0]) - info.append(i) - except: - pass + # We suppress errors here because different domains provide different data fields + with suppress(KeyError): + info.append("\x02Registrar\x02: {}".format(data["registrar"][0])) - try: - i = "\x02Registered\x02: {}".format(data["creation_date"][0].strftime("%d-%m-%Y")) - info.append(i) - except: - pass + with suppress(KeyError): + info.append("\x02Registered\x02: {}".format(data["creation_date"][0].strftime("%d-%m-%Y"))) - try: - i = "\x02Expires\x02: {}".format(data["expiration_date"][0].strftime("%d-%m-%Y")) - info.append(i) - except: - pass + with suppress(KeyError): + info.append("\x02Expires\x02: {}".format(data["expiration_date"][0].strftime("%d-%m-%Y"))) info_text = ", ".join(info) return "{} - {}".format(domain, info_text) diff --git a/plugins/youtube.py b/plugins/youtube.py index 56f44ef9e..b86ef49de 100644 --- a/plugins/youtube.py +++ b/plugins/youtube.py @@ -19,8 +19,8 @@ err_no_api = "The YouTube API is off in the Google Developers Console." -def get_video_description(video_id, key): - json = requests.get(api_url.format(video_id, key)).json() +def get_video_description(video_id): + json = requests.get(api_url.format(video_id, dev_key)).json() if json.get('error'): if json['error']['code'] == 403: @@ -75,7 +75,7 @@ def load_key(bot): @hook.regex(youtube_re) def youtube_url(match): - return get_video_description(match.group(1), dev_key) + return get_video_description(match.group(1)) @hook.command("youtube", "you", "yt", "y") @@ -97,7 +97,7 @@ def youtube(text): video_id = json['items'][0]['id']['videoId'] - return get_video_description(video_id, dev_key) + " - " + video_url % video_id + return get_video_description(video_id) + " - " + video_url % video_id @hook.command("youtime", "ytime")