diff options
author | luk3yx <luk3yx@users.noreply.github.com> | 2022-03-19 17:12:19 +1300 |
---|---|---|
committer | luk3yx <luk3yx@users.noreply.github.com> | 2022-03-19 17:12:19 +1300 |
commit | fcd3e87793f44c20c92bdadb4c12c4915b7b411e (patch) | |
tree | db08580af3351d9ec18ba92f94dd120056101fb5 | |
download | lurklite-commands-fcd3e87793f44c20c92bdadb4c12c4915b7b411e.tar.gz lurklite-commands-fcd3e87793f44c20c92bdadb4c12c4915b7b411e.zip |
Initial public commit
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | currency.liteonly.py | 205 | ||||
-rw-r--r-- | debug.py | 18 | ||||
-rw-r--r-- | int.py | 41 | ||||
-rw-r--r-- | join-part.liteonly.py | 15 | ||||
-rw-r--r-- | misc.py | 174 | ||||
-rw-r--r-- | multiline.py | 85 | ||||
-rwxr-xr-x | nickrate.py | 293 | ||||
-rw-r--r-- | qotd.py | 376 | ||||
-rw-r--r-- | spellcheck.py | 51 | ||||
-rw-r--r-- | translate.liteonly.py | 129 | ||||
-rw-r--r-- | units.liteonly.py | 199 | ||||
-rw-r--r-- | wild.py | 178 |
13 files changed, 1769 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..335a4e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +qotd_*.txt +old_quotes.txt +qotd.json +aspire.liteonly.py +roa.py diff --git a/currency.liteonly.py b/currency.liteonly.py new file mode 100644 index 0000000..7288676 --- /dev/null +++ b/currency.liteonly.py @@ -0,0 +1,205 @@ +""" +Based on currency.py - Sopel Currency Conversion Plugin +Copyright 2013, Elsie Powell, embolalia.com +Copyright 2019, Mikkel Jeppesen +Licensed under the Eiffel Forum License 2. + +Original currency.py: https://sopel.chat +""" + +import logging +import re +import time +import urllib.parse + +import requests + + +# Which data provider to use (some of which require no API key) +FIAT_PROVIDER = 'exchangerate.host' + +FIXER_IO_KEY = None +FIXER_USE_SSL = False + + +FIAT_PROVIDERS = { + 'exchangerate.host': 'https://api.exchangerate.host/latest?base=EUR', + 'fixer.io': '//data.fixer.io/api/latest?base=EUR&access_key={}', +} +CRYPTO_URL = 'https://api.coingecko.com/api/v3/exchange_rates' +EXCHANGE_REGEX = re.compile(r''' + ^(\d+(?:\.\d+)?) # Decimal number + \s*([a-zA-Z]{3}) # 3-letter currency code + \s+(?:in|as|of|to)\s+ # preposition + (([a-zA-Z]{3}$)|([a-zA-Z]{3})\s)+$ # one or more 3-letter currency code +''', re.VERBOSE) +LOGGER = logging.getLogger(__name__) +UNSUPPORTED_CURRENCY = "Sorry, {} isn't currently supported." +USAGE = "Usage: .cur 100 usd in btc cad eur" +UNRECOGNIZED_INPUT = f"Sorry, I didn't understand the input. {USAGE}" + +rates = {} +rates_updated = 0.0 + + +class FixerError(Exception): + """A Fixer.io API Error Exception""" + def __init__(self, status): + super().__init__("FixerError: {}".format(status)) + + +class UnsupportedCurrencyError(Exception): + """A currency is currently not supported by the API""" + def __init__(self, currency): + super().__init__(currency) + + +def build_reply(amount, base, target, out_string): + rate_raw = get_rate(base, target) + rate = float(rate_raw) + result = float(rate * amount) + + digits = 0 + # up to 10 (8+2) digits precision when result is less than 1 + # as smaller results need more precision + while digits < 8 and 1 / 10**digits > result: + digits += 1 + + digits += 2 + + out_string += ' {value:,.{precision}f} {currency},'.format( + value=result, precision=digits, currency=target + ) + + return out_string + + +def exchange(match): + """Show the exchange rate between two currencies""" + if not match: + return UNRECOGNIZED_INPUT + + # Try and update rates. Rate-limiting is done in update_rates() + try: + update_rates() + except requests.exceptions.RequestException as err: + traceback.print_exc() + return "Something went wrong while I was getting the exchange rate." + except (KeyError, ValueError) as err: + traceback.print_exc() + return "Error: Could not update exchange rates. Try again later." + except FixerError as err: + traceback.print_exc() + return 'Sorry, something went wrong with Fixer' + + query = match.string + amount_in, base, _, *targets = query.split() + + try: + amount = float(amount_in) + except ValueError: + return UNRECOGNIZED_INPUT + except OverflowError: + return "Sorry, input amount was out of range." + + if not amount: + return "Zero is zero, no matter what country you're in." + + out_string = '{} {} is'.format(amount_in, base.upper()) + + unsupported_currencies = [] + for target in targets: + try: + out_string = build_reply(amount, base.upper(), target.upper(), + out_string) + except ValueError: + return "Error: Raw rate wasn't a float" + except KeyError as err: + return "Error: Invalid rates" + except UnsupportedCurrencyError as cur: + unsupported_currencies.append(cur) + + if unsupported_currencies: + out_string = out_string + ' (unsupported:' + for target in unsupported_currencies: + out_string = out_string + ' {},'.format(target) + out_string = out_string[0:-1] + ')' + else: + out_string = out_string[0:-1] + + return out_string + + +def get_rate(base, target): + base = base.upper() + target = target.upper() + + if base not in rates: + raise UnsupportedCurrencyError(base) + + if target not in rates: + raise UnsupportedCurrencyError(target) + + return (1 / rates[base]) * rates[target] + + +def update_rates(): + global rates, rates_updated + + # If we have data that is less than 24h old, return + if time.time() - rates_updated < 24 * 60 * 60: + return + + # Update crypto rates + response = requests.get(CRYPTO_URL) + response.raise_for_status() + rates_crypto = response.json() + + # Update fiat rates + if FIXER_IO_KEY is not None: + assert FIAT_PROVIDER == 'fixer.io' + + proto = 'https:' if FIXER_USE_SSL else 'http:' + response = requests.get( + proto + + FIAT_PROVIDERS['fixer.io'].format( + urllib.parse.quote(FIXER_IO_KEY) + ) + ) + + if not response.json()['success']: + raise FixerError('Fixer.io request failed with error: {}'.format( + response.json()['error'] + )) + else: + LOGGER.debug('Updating fiat rates from %s', FIAT_PROVIDER) + response = requests.get(FIAT_PROVIDERS[FIAT_PROVIDER]) + + response.raise_for_status() + rates_fiat = response.json() + + rates = rates_fiat['rates'] + rates['EUR'] = 1.0 # Put this here to make logic easier + + eur_btc_rate = 1 / rates_crypto['rates']['eur']['value'] + + for rate in rates_crypto['rates']: + if rate.upper() not in rates: + rates[rate.upper()] = (rates_crypto['rates'][rate]['value'] * + eur_btc_rate) + + # if an error aborted the operation prematurely, we want the next call to + # retry updating rates therefore we'll update the stored timestamp at the + # last possible moment + rates_updated = time.time() + + +@register_command('cur', 'currency', 'exchange') +def exchange_cmd(irc, hostmask, is_admin, args): + """Show the exchange rate between two currencies.""" + if param := args[1].strip(): + msg = exchange(EXCHANGE_REGEX.match(param)) + else: + msg = f"No search term. {USAGE}" + + irc.msg(args[0], f'{hostmask[0]}: {msg}') diff --git a/debug.py b/debug.py new file mode 100644 index 0000000..e888014 --- /dev/null +++ b/debug.py @@ -0,0 +1,18 @@ +import time, subprocess + +def get_free_ram(): + output = subprocess.check_output(('free', '-h')) + return output.split(b'\n', 2)[1].rsplit(b' ', 1)[-1].decode('utf-8') + +@register_command('collectgarbage', requires_admin=True) +def collectgarbage(irc, hostmask, is_admin, args): + import gc + ram = get_free_ram() + t1 = time.time() + gc.collect() + irc.msg(args[0], f'Done in {time.time() - t1} seconds.\n' + f'Previous ram usage: {ram}, current RAM usage: {get_free_ram()}') + +@register_command('get_free_ram', requires_admin=True) +def get_free_ram_cmd(irc, hostmask, is_admin, args): + irc.msg(args[0], f'Free RAM: {get_free_ram()}') @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# +# .int: Check if something is a number +# +# Copyright © 2019-2021 by luk3yx +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +@register_command('int') +def int_cmd(irc, hostmask, is_admin, args): + """ Check if something is a number. """ + + number = args[1].replace(' ', '') + try: + number = int(number) + desc = 'an integer' + except ValueError: + try: + number = float(number) + desc = 'a floating-point number' + except ValueError: + try: + number = complex(number.replace('i', 'j')) + desc = 'a complex number' + except ValueError: + number = args[1] + desc = 'not a number' + + irc.msg(args[0], hostmask[0] + ': {} is {}.'.format(repr(number), desc)) diff --git a/join-part.liteonly.py b/join-part.liteonly.py new file mode 100644 index 0000000..434a1f8 --- /dev/null +++ b/join-part.liteonly.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + +@register_command('join', requires_admin=True) +def irc_join(irc, hostmask, is_admin, args): + """ Joins a channel. """ + assert is_admin + irc.send('JOIN', args[1]) + irc.notice(hostmask[0], 'Done!') + +@register_command('part', requires_admin=True) +def irc_part(irc, hostmask, is_admin, args): + """ Leaves a channel. """ + assert is_admin + irc.send('PART', args[1], f'Requested by {is_admin!r}.') + irc.notice(hostmask[0], 'Done!') @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# +# Random commands that used to be in sopeltest.py but are to small to get a +# file of their own. +# +# Copyright © 2019-2021 by luk3yx +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +import random, string, subprocess, time + +# A quick "reply" function +def reply(irc, hostmask, channel, *msg): + mention = hostmask[0] + if not hostmask[0].endswith('>'): + mention += ':' + if isinstance(channel, list): + channel = channel[0] + irc.msg(channel, mention, *map(str, msg)) + +en_chars = string.ascii_uppercase + string.ascii_lowercase + \ + '1234567890!@#$%^&*(),\'' +alt_chars = 'ÅıÇδϩӈÔ˚Ò˜Ø∏Œ®Í†¨√∑≈ÁΩå∫ç∂´ƒ©˙ˆ∆˚¬µ˜øπœ®ß†¨√∑≈\Ω¡™£¢∞§¶•ªº' \ + '⁄€‹›fifl‡°·‚≤æ' + +@register_command('altcode', requires_admin = True) +def altcode_cmd(irc, hostmask, is_admin, args): + """ + Usage: .altcode [encode|decode] <text> + """ + + assert is_admin + assert len(en_chars) == len(alt_chars), 'Internal error!' + + res = args[1] + + if res.startswith('encode '): + res = res[7:] + it = zip(en_chars, alt_chars) + elif res.startswith('decode ') or any(i in res for i in alt_chars): + it = zip(alt_chars, en_chars) + else: + it = zip(en_chars, alt_chars) + + for en, alt in it: + res = res.replace(en, alt) + + reply(irc, hostmask, args, res) + +# Random capitalisation +def randcaps(text): + res = ''.join(random.choice((str.upper, str.lower))(i) for i in text) + return res.replace('S', 'Z').replace('O', '0') + +# .silly +@register_command('silly', 'sillycaps') +def silly_cmd(irc, hostmask, is_admin, args): + """ Makes the capitalisation in a string... strange. """ + if args[1].strip(): + reply(irc, hostmask, args, randcaps(args[1])) + else: + irc.msg(args[0], randcaps('{}nvalid syntax! Syntax:{}<string>').format( + 'I', ' .silly ')) + +# .c0mmandz +@register_command('c0mmandz') +def bonkerzhelp(irc, hostmask, is_admin, args): + irc.msg(args[0], randcaps('Hello! You must be bonkers too!')) + time.sleep(0.75) + irc.msg(args[0], 'So snap out of it and do .commands.') + +# .upper and .lower +@register_command('upper', 'uppercase') +def upper_cmd(irc, hostmask, is_admin, args): + """ Makes a string uppercase. """ + reply(irc, hostmask, args, args[1].upper().replace('.', '!')) + +@register_command('lower', 'lowercase') +def lower_cmd(irc, hostmask, is_admin, args): + """ Makes a string lowercase. """ + reply(irc, hostmask, args, args[1].lower().replace('!', '.')) + +@register_command('rev', 'reverse') +def rev_cmd(irc, hostmask, is_admin, args): + """ Reverses a string. """ + if '\u202e' in args[1]: + irc.msg(args[0], "I don't think so {}™.".format(hostmask[0])) + else: + reply(irc, hostmask, args, args[1][::-1]) + +@register_command('ping') +def ping(irc, hostmask, is_admin, args): + if not random.randint(0, 9): + irc.me(args[0], 'slaps {} with a frying pan'.format(hostmask[0])) + else: + reply(irc, hostmask, args, 'pong') + +# .roulette +roulette_objs = ('trout', '... feather?', 'water molecule', 'troutgun', + 'anvil', 'sheet of paper') +@register_command('roulette', 'r') +def roulette_cmd(irc, hostmask, is_admin, args): + """ A Russian roulette-style game. """ + if random.randint(1, 6) == 1: + obj = random.choice(roulette_objs) + if not obj.startswith('.'): + obj = ' ' + obj + irc.me(args[0], 'slaps {} around a bit with a large{}'.format( + hostmask[0], obj)) + else: + reply(irc, hostmask, args, '\x1dClick!\x1d') + +# .choice +def choice_cmd(irc, hostmask, is_admin, args): + choices = map(str.strip, args[-1].split(',')) + + # Deduplication + choices = {choice.casefold(): choice for choice in choices} + choices = tuple(choices.values()) + + if len(choices) < 2: + if not choices or not choices[0]: + reply(irc, hostmask, args, 'Invalid syntax! Usage: .choice ' + '<comma-separated list of options>') + else: + reply(irc, hostmask, args, + 'I need more than one option to choose from!') + return + + choice = random.choice(choices) + reply(irc, hostmask, args, 'Your options: ' + ', '.join(choices) + + '. My (random) choice: ' + choice) + +# Only register .choice on lurklite. +if register_command.__module__.startswith('lurklite.'): + register_command('choice', 'choose')(choice_cmd) + +def get_fortune(): + n = subprocess.check_output('/usr/games/fortune').decode('utf-8') + # Avoid bad and possibly offensive fortunes + # This is only targeted at the fortunes provided by Ubuntu. + i = 0 + while ('ugly' in n or 'tall, dark' in n or 'pass away' in n or + 'blond' in n or 'cigarette' in n or 'appeal' in n): + n = subprocess.check_output('/usr/games/fortune').decode('utf-8') + i += 1 + assert i < 100 + return n.strip().replace('\t', ' ') + +@register_command('f', 'fortune') +def fortune_cmd(irc, hostmask, is_admin, args): + reply(irc, hostmask, args, get_fortune()) + +@register_command('coin', 'coinflip') +def coinflip_cmd(irc, hostmask, is_admin, args): + if random.randint(0, 6000) == 0: + msg = 'The coin landed on its side!' + elif random.randint(0, 1): + msg = 'Heads' + else: + msg = 'Tails' + reply(irc, hostmask, args, msg) diff --git a/multiline.py b/multiline.py new file mode 100644 index 0000000..3cbb280 --- /dev/null +++ b/multiline.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# lurklite commands with multi-line output. +# +# Copyright © 2019-2021 by luk3yx +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +import time + +# Multi-line commands +running_commands = set() +def multiline_command(*cmds, monospace=False): + def res(func): + @register_command(*cmds) + def multiline_wrapper(irc, hostmask, is_admin, args): + res = str(func(irc, hostmask, is_admin, args)) + + if getattr(irc, 'msglen', 512) > 512: + if monospace: + res = '```\n' + res + '\n```' + irc.msg(args[0], res) + elif hostmask[0] in running_commands: + irc.msg(args[0], 'This command has been rate-limited, please ' + 'try again later.') + else: + running_commands.add(hostmask[0]) + try: + irc.msg(args[0], '...and let the PM spamming commence!') + lines = res.split('\n') + + # Pad monospaced text correctly. + if monospace: + line_length = max(map(len, lines)) + for i, line in enumerate(lines): + lines[i] = '\x11' + line.ljust(line_length) + '\x11' + + for line in lines: + time.sleep(len(running_commands)) + irc.msg(hostmask[0], line) + time.sleep(5) + finally: + running_commands.discard(hostmask[0]) + return multiline_wrapper + return res + +# .calendar +@multiline_command('calendar', 'cal', monospace=True) +def calendar_cmd(irc, hostmask, is_admin, args): + """ Displays a calendar. """ + import calendar, datetime + t = datetime.datetime.now() + return calendar.TextCalendar().formatmonth(t.year, t.month).strip('\n') + +# .sudokuify +@multiline_command('sudokuify', monospace=True) +def sudokuify(irc, hostmask, is_admin, args): + s = args[1].replace('0', '').split(',') + if len(s) != 81: + return 'Invalid sudoku!' + res = '╔═══╤═══╤═══╦═══╤═══╤═══╦═══╤═══╤═══╗\n' + for i, num in enumerate(s): + res += '│' if i % 3 else '║' + res += ' ' + (num[:1] or ' ') + ' ' + if i % 9 == 8: + res += '║\n' + if i == 80: + break + if i % 27 == 26: + res += '╠═══╪═══╪═══╬═══╪═══╪═══╬═══╪═══╪═══╣\n' + else: + res += '╟───┼───┼───╫───┼───┼───╫───┼───┼───╢\n' + return res + '╚═══╧═══╧═══╩═══╧═══╧═══╩═══╧═══╧═══╝' diff --git a/nickrate.py b/nickrate.py new file mode 100755 index 0000000..ca6c8fc --- /dev/null +++ b/nickrate.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# +# Nickname rating system +# +# The MIT License (MIT) +# +# Copyright © 2019 by luk3yx. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +import re + +# stolen from http://pi.math.cornell.edu/~mec/2003-2004/cryptography/subs/frequencies.html +letter_frequencies = { + "E": 12.02, + "T": 9.10, + "A": 8.12, + "O": 7.68, + "I": 7.31, + "N": 6.95, + "S": 6.28, + "R": 6.02, + "H": 5.92, + "D": 4.32, + "L": 3.98, + "U": 2.88, + "C": 2.71, + "M": 2.61, + "F": 2.30, + "Y": 2.11, + "W": 2.09, + "G": 2.03, + "P": 1.82, + "B": 1.49, + "V": 1.11, + "K": 0.69, + "X": 0.17, + "Q": 0.11, + "J": 0.10, + "Z": 0.07 +} +default_letter_frequency = 4 +maximum_letter_frequency = 12 + +def letter_frequency(l): + return letter_frequencies.get(l.upper(), default_letter_frequency) + +def inverse_letter_freqency(l): + return maximum_letter_frequency - letter_frequency(l) + +def rate_string(nick): + # Don't give a basic rating if the nickname is long + if len(nick) > 20: + return 0 + + # rate name based on letter frequency, divide by the length + return sum(map(inverse_letter_freqency, nick.upper())) + +def rate_repetition(repetition_length, last_rate): + if last_rate == 0 or repetition_length == 0: + return 0 + # squaring the rating to avoid penalizing common double letters like Billy, dividing by 64 to bring it back to earth + m = (last_rate**2)/64 + return repetition_length*m + +def repetition_rating(nick): + nick = nick.lower() + # look for repetition, remove points & remove repetitions for frequency analysis + repetition_rating = 0 + + # we're catching repetitions between 1 and 4 chars in length + # you can decrease this if you think it's looping too much + max_rep_len = 4 + + nick_len = len(nick) + + # lets not let everyone do enormous loops + if nick_len > 20: + return 0 + + for c in range(1,max_rep_len+1): + last_rate = 0 + repetition_length = 0 + # accumulator, lets us skip ahead in the loop + a = 0 + for N in range(nick_len): + n = N+a + nc = n+c + ncc = nc+c + if ncc > nick_len: + repetition_rating += rate_repetition(repetition_length, last_rate) + break + this_set = nick[n:nc] + next_set = nick[nc:ncc] + if this_set == next_set: + # rate the string here so we have it if we find the next one is not a repetition + if repetition_length == 0: # only the first time, each repetition has the same rating... + last_rate = rate_string(this_set) + a += c - 1 + repetition_length += 1 + else: + repetition_rating += rate_repetition(repetition_length, last_rate) + repetition_length = 0 + return -round(repetition_rating) + + +def plural(n): return '' if n == 1 else 's' + +class BasePointModifier: + __slots__ = ('text', 'points') + + def __repr__(self): + return '<{} {}, {} point{}>'.format(type(self).__name__, + repr(self.text), self.points, plural(self.points)) + + def get_raw_description(self): + return repr(self.text) + + def get_description(self, raw_points): + points = str(raw_points) + if not points.startswith('-'): + points = '+' + points + return '{} point{}: {}'.format(points, plural(raw_points), + self.get_raw_description()) + + def match(self, nick): + raise NotImplementedError('match() not implemented') + + def get_points(self, nick): + return self.points if self.match(nick) else 0 + + def __init__(self, text, points): + self.text = str(text).lower() + self.points = points + +class PointModifier(BasePointModifier): + __slots__ = ('text', 'location') + + def get_raw_description(self): + msg = repr(self.text) + if self.location == 'start': + msg = 'starts with ' + msg + elif self.location == 'end': + msg = 'ends with ' + msg + else: + msg = 'contains ' + msg + return 'Nickname ' + msg + '.' + + def match(self, nick): + nick = nick.lower() + if self.location == 'start': + return nick.startswith(self.text) + elif self.location == 'end': + return nick.endswith(self.text) + + return self.text in nick + + def __init__(self, text, points, location=None): + super().__init__(text, points) + self.location = location + +class RegexPointModifier(BasePointModifier): + __slots__ = ('regex',) + def match(self, nick): + return bool(self.regex.search(nick)) + + def __init__(self, regex, points): + super().__init__(regex, points) + self.regex = re.compile(regex, flags=re.IGNORECASE) + +class CustomPointModifier(BasePointModifier): + __slots__ = ('_func', '_lower') + + def match(self, nick): + if self._lower: + nick = nick.lower() + return bool(self._func(nick)) + + def get_raw_description(self): + return self.text + + def __init__(self, func, points, description='<function>', lower=True): + super().__init__('', points) + self.text = description + self._func = func + self._lower = lower + +class DynamicPointModifier(CustomPointModifier): + def get_points(self, nick): + return self._func(nick) + + def match(self, nick): + # return self.get_points(nick) != 0 + raise NotImplementedError + + def __init__(self, func, text): + super().__init__(func, 0, text) + + +things = [ + DynamicPointModifier(lambda n: round(rate_string(n)/len(n)), "Basic rating."), + DynamicPointModifier(repetition_rating, "Repeating letters."), + PointModifier('xX', -8, 'start'), + PointModifier('Xx', -8, 'end'), + PointModifier('pro', -8), + PointModifier('hack', -8), + PointModifier('hacker', -4), + PointModifier('lol', -8), + PointModifier('YT', -10), + PointModifier('cute', -10), + PointModifier('fortnite', -500), + PointModifier('owo', -500), + PointModifier('uwu', -500), + # This is weirdly non-specific, who are you targeting here? + RegexPointModifier('^[a-z]+[0-9]{3}(xx)?$', -5), + CustomPointModifier(lambda n : len(n) < 4, -15, + 'Insanely short nickname.', False), + CustomPointModifier(lambda n : len(n) > 14, -8, 'Long nickname.', False), + CustomPointModifier(lambda n : len(n) > 20, -15, + 'Obnoxiously long nickname.', False), + CustomPointModifier(lambda n : not n.isalnum(), -15, + 'Non-alphanumeric characters used.', False), + CustomPointModifier(lambda n : n == 'plebs', -9000, + 'Sharing the nickname of a troll.'), + CustomPointModifier(lambda n : n.isupper(), -8, + 'Entirely uppercase nickname.', False), + CustomPointModifier(lambda n : n in ('lurk', 'lurk3', 'lurk`', 'lurklite', + 'father billy'), 9001, "Over 9000.") +] + +things.sort(key=lambda t : t.points) +things = tuple(things) + +def rate_nick(nick): + score = 0 + lnick = nick.lower() + reasons = [] + + for thing in things: + points = thing.get_points(nick) + if points != 0: + score += points + reasons.append(thing.get_description(points)) + + return score, reasons + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('nickname', help='The nickname to rate.') + args = parser.parse_args() + nick = args.nickname + del parser, args + + score, reasons = rate_nick(nick) + print('Score for nickname {}: {}'.format(repr(nick), score)) + for reason in reasons: + print(' •', reason) + +if __name__ == '__main__': + main() +elif 'register_command' in globals(): + @register_command('nickrate') + def nickrate_cmd(irc, hostmask, is_admin, args): + r = hostmask[0] + ('' if hostmask[0].endswith('>') else ':') + nick = args[-1].strip() + if not nick or nick.startswith('<@'): + return irc.msg(args[0], + r + ' Invalid syntax! Syntax: .nickrate <nickname>') + + score, reasons = rate_nick(nick) + msg = '{} Rating for nickname {}: \x02{} point{}.\x02'.format(r, + repr(nick), score, plural(score)) + if irc.msglen > 1024 and reasons: + msg = msg + '\n • ' + '\n • '.join(reasons) + irc.msg(args[0], msg) @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# +# lurklite quote of the day +# +# Copyright © 2019-2021 by luk3yx +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +from difflib import SequenceMatcher +import base64, json, math, os, random, tempfile, time, urllib.request + +# This isn't the best place to store data but lurklite doesn't have any +# database API. +data_path = os.path.dirname(__file__) +qotd_file = os.path.join(data_path, 'qotd.json') +archive_file = os.path.join(data_path, 'old_quotes.txt') +lockfile = os.path.join(tempfile.gettempdir(), '.qotd-' + + base64.urlsafe_b64encode(qotd_file.encode('utf-8')).decode('ascii' + ).rstrip('=') + '.lock') +qotd_static = os.path.join(data_path, 'qotd_current.txt') +try: + with open(os.path.join(data_path, 'qotd_webhook_url.txt')) as f: + webhook_url = f.read().strip() + del f +except FileNotFoundError: + webhook_url = None + +qotd_format = ('Quote of the day: \x1d{}\x1d\n\nDisclaimer: Quotes are mostly ' + 'user-generated content and may not be endorsed by lurklite ' + 'staff.') + +# A quick lockfile implementation +# There may be multiple bot instances (over separate processes) trying to +# access this file. +def lock_quotes(): + while True: + try: + with open(lockfile, 'x') as f: + f.write(str(time.time())) + except FileExistsError: + time.sleep(0.05) + else: + break + +def unlock_quotes(): + os.remove(lockfile) + +# Read quotes +def read_quotes(): + lock_quotes() + try: + with open(qotd_file, 'r') as f: + return json.load(f) + except: + return {} + +def save_quotes(quotes): + raw = json.dumps(quotes) + with open(qotd_file + '~', 'w') as f: + f.write(raw) + os.rename(qotd_file + '~', qotd_file) + + if not os.path.exists(qotd_static): + with open(qotd_static, 'w') as f: + f.write(quotes.get('current', '<No quotes>')) + +def archive_quote(quote, blame, expiry_time): + # Convert the expiry time into a creation time (provided the expiry time + # is a sane value). + if expiry_time and expiry_time > 1_000_000_000: + t = expiry_time - 86400 + else: + t = None + with open(archive_file, 'a') as f: + f.write(json.dumps({'quote': quote, 'blame': blame, 'time': t}) + '\n') + +def is_discord(irc): + return hasattr(irc, '_client') + +def send_webhook(**req): + if not webhook_url: + return + req = urllib.request.Request( + webhook_url, + data=json.dumps(req).encode('utf-8'), + headers={ + 'Content-Type': 'application/json', + 'User-Agent': 'lurklite-qotd-webhook', + }, + method='POST', + ) + print(req) + with urllib.request.urlopen(req): + pass + +def is_qotd_admin(irc, hostmask, is_admin, args, quotes): + if is_admin: + return True + # if quotes is None: + # quotes = read_quotes() + # unlock_quotes() + n = (hostmask[0].strip('<@!>') if is_discord(irc) else hostmask[-1]) + if n in quotes.get('admins', ()): + return True + else: + irc.msg(args[0], hostmask[0] + ': Permission denied!') + return False + +def prune_quote_blames(quotes): + if 'blame' not in quotes: + return + q = quotes.get('quotes', ()) + quotes['blame'] = {k: v for k, v in quotes['blame'].items() if k in q} + +def _aux_quote(quotes, source, quote): + quotes['current'] = quote + if 'blame' not in quotes: + quotes['blame'] = {} + quotes['blame'][quote] = f'Auxiliary quote system ({source})' + +def _fetch_json(url): + with urllib.request.urlopen(url, timeout=3) as res: + return json.loads(res.read()) + +def get_quotable_quote(): + data = _fetch_json('https://api.quotable.io/random') + return f'"{data["content"]}" -- {data["author"]}' + +# def get_pandorabot_quote(): +# pandorabox_quotes = _fetch_json('https://pandorabox.io/api/quotes') +# it = filter(lambda s : s.startswith('<'), pandorabox_quotes) +# return random.choice(tuple(it)) + +# Get the next quote +def get_quote(channel=None): + quotes = read_quotes() + try: + if channel and channel.lower() in quotes.get('blocklist', ()): + return + + if quotes.get('next_update', 0) <= time.time(): + # Archive the current quote + if 'current' in quotes: + archive_quote(quotes['current'], + quotes['blame'].get(quotes['current']), + quotes.get('next_update')) + prune_quote_blames(quotes) + + # quotes['next_update'] = time.time() + 86400 + + # Always expire quotes at midnight UTC + time_s = math.floor(time.time()) + quotes['next_update'] = (time_s // 86400 + 1) * 86400 + + if quotes.get('quotes', None): + quotes['current'] = random.choice(quotes['quotes']) + quotes['quotes'].remove(quotes['current']) + # elif (random.randint(1, 5) == 1 and + # hasattr(random, 'get_rule_of_acquisition')): + # _aux_quote(quotes, 'Rule of acquisition', + # random.get_rule_of_acquisition()) + # elif random.randint(1, 5) == 1: + # _aux_quote(quotes, 'Pandorabot', get_pandorabot_quote()) + else: + _aux_quote(quotes, 'Quotable', get_quotable_quote()) + + save_quotes(quotes) + + with open(qotd_static, 'w') as f: + f.write(qotd_format.format(quotes['current'])) + + return quotes.get('current', '<No quotes>') + finally: + unlock_quotes() + +# The quote of the day command +@register_command('qotd', 'quotd', 'quote') +def qotd_cmd(irc, hostmask, is_admin, args): + """ Get the current quote of the day. """ + quote = get_quote(args[0]) + irc.msg(args[0], qotd_format.format(quote) if quote is not None else + 'Unfortunately, .qotd has been disabled in this channel.') + +# @register_command('qotd-skip', requires_admin=True) +# def qotd_skip_cmd(irc, hostmask, is_admin, args): +# quotes = read_quotes() +# try: +# del quotes['next_update'] +# save_quotes(quotes) +# except KeyError: +# irc.msg(args[0], 'There is no quote to skip!') +# finally: +# unlock_quotes() +# irc.msg(args[0], 'Quote skipped!') + +@register_command('qotd-blame', 'qotd_blame') +def qotd_blame_cmd(irc, hostmask, is_admin, args): + quotes = read_quotes() + unlock_quotes() + adder = 'blame' in quotes and quotes['blame'].get(quotes.get('current')) + if adder: + irc.msg(args[0], f'The current quote was added by {adder!r}.') + else: + irc.msg(args[0], "I don't know who added the current quote.") + +def compare_quotes(quotes, quote): + for existing_quote in quotes['blame']: + if SequenceMatcher(None, quote, existing_quote).ratio() >= 0.85: + return existing_quote + +# Add a quote +@register_command('add-quote', 'add_quote') +def add_quote_cmd(irc, hostmask, is_admin, args): + """ Add a quote of the day (requires admin). """ + quotes = read_quotes() + try: + if not is_qotd_admin(irc, hostmask, is_admin, args, quotes): + return + + quote = args[1].strip().replace('\t', ' ') + if quote[:1] not in ('*', '<', '"'): + return irc.msg(args[0], hostmask[0] + ': Invalid quote!') + + if 'quotes' not in quotes: + quotes['quotes'] = [] + + if 'blame' not in quotes: + quotes['blame'] = {} + + if quote in quotes['quotes']: + return irc.msg(args[0], hostmask[0] + ': That quote has already ' + 'been added!') + if quote in quotes['blame']: + return irc.msg(args[0], hostmask[0] + ': That quote was recently ' + 'removed, you may not re-add it.') + + if similar_quote := compare_quotes(quotes, quote): + state = ('an existing' if similar_quote in quotes['quotes'] + else 'a recently removed') + return irc.msg(args[0], f'{hostmask[0]}: That quote is too similar' + f' to {state} quote:\n> {similar_quote}') + + quotes['quotes'].append(quote) + quotes['blame'][quote] = hostmask[1 if is_discord(irc) else 2] + save_quotes(quotes) + finally: + unlock_quotes() + + irc.msg(args[0], hostmask[0] + ': Done!') + + send_webhook(embeds=[{ + 'title': f'{hostmask[1]} added a quote', + 'color': 0x008000, + 'description': quote, + 'footer': {'text': hostmask[2]}, + }]) + +# Remove a quote +@register_command('remove-quote', 'remove_quote') +def remove_quote_cmd(irc, hostmask, is_admin, args): + """ Remove a quote of the day (requires admin). """ + quotes = read_quotes() + try: + if not is_qotd_admin(irc, hostmask, is_admin, args, quotes): + return + quote = args[1].strip().replace('\t', ' ') + if quote not in quotes.get('quotes', ()): + msg = 'That quote does not exist!' + #if hostmask[1] == 'Ranger#3292': + # msg = 'Done!' + irc.msg(args[0], hostmask[0] + ': ' + msg) + return + + quotes['quotes'].remove(quote) + + # Remove from the blame list if this is the same person that added it + blame = quotes.get('blame') + if blame and blame.get(quote) == hostmask[1 if is_discord(irc) else 2]: + del blame[quote] + + save_quotes(quotes) + finally: + unlock_quotes() + + irc.msg(args[0], hostmask[0] + ': Done!') + + send_webhook(embeds=[{ + 'title': f'{hostmask[1]} removed a quote', + 'color': 0xFFA500, + 'description': quote, + 'footer': {'text': hostmask[2]}, + }]) + +def _plural(n): + return '' if n == 1 else 's' + +@register_command('qotd-status', 'qotd_status') +def qotd_status_cmd(irc, hostmask, is_admin, args): + quotes = read_quotes() + unlock_quotes() + if not is_qotd_admin(irc, hostmask, is_admin, args, quotes): + return + + saved_quotes = len(quotes.get('quotes', ())) + expiry = max(int(quotes.get('next_update', 0) - time.time()), 0) + + expiry_s = expiry % 60 + expiry_m = (expiry // 60) % 60 + expiry_h = expiry // 3600 + + irc.msg(args[0], f'{hostmask[0]}: ' + f'The current quote expires in \x02{expiry_h} hour' + f'{_plural(expiry_h)}, {expiry_m} minute{_plural(expiry_m)}, and ' + f'{expiry_s} second{_plural(expiry_s)}\x02. ' + f'Amount of saved quotes: \x02{saved_quotes}\x02.') + +@register_command('qotd-admins', 'qotd_admins', requires_admin=True) +def qotd_admins_cmd(irc, hostmask, is_admin, args): + quotes = read_quotes() + unlock_quotes() + admins_it = quotes.get('admins', ()) + if is_discord(irc): + admins_it = map('<@{}>'.format, admins_it) + admins = ', '.join(admins_it) + irc.msg(args[0], f'{hostmask[0]}: Current .qotd admins: {admins}') + +def _get_admin_id(irc, args): + admin = args[1].strip() + if is_discord(irc): + admin = admin.strip('<@!>') + return admin + +@register_command('qotd-grant', 'qotd_grant', requires_admin=True) +def qotd_grant_cmd(irc, hostmask, is_admin, args): + quotes = read_quotes() + try: + admin = _get_admin_id(irc, args) + admins = quotes.get('admins') + if admins is None: + admins = quotes['admins'] = [] + elif admin in admins: + irc.msg(args[0], f'{hostmask[0]}: That user is already has ' + '.add-quote privs!') + return + admins.append(admin) + admins.sort() + save_quotes(quotes) + finally: + unlock_quotes() + irc.msg(args[0], f'{hostmask[0]}: Done!') + +@register_command('qotd-revoke', 'qotd_revoke', requires_admin=True) +def qotd_revoke_cmd(irc, hostmask, is_admin, args): + quotes = read_quotes() + try: + admin = _get_admin_id(irc, args) + try: + quotes.get('admins', []).remove(admin) + except ValueError: + pass + save_quotes(quotes) + finally: + unlock_quotes() + irc.msg(args[0], f'{hostmask[0]}: Done!') diff --git a/spellcheck.py b/spellcheck.py new file mode 100644 index 0000000..b3a2b04 --- /dev/null +++ b/spellcheck.py @@ -0,0 +1,51 @@ +""" +Ported from spellcheck.py - Sopel spell check Module +Copyright © 2012, Elad Alfassa, <elad@fedoraproject.org> +Copyright © 2012, Lior Ramati + +Licensed under the Eiffel Forum License 2. + +http://sopel.chat +""" + +try: + import enchant +except ImportError: + enchant = None + + +@register_command('spellcheck', 'spell') +def spellcheck(irc, hostmask, is_admin, args): + """ + Says whether the given word is spelled correctly, and gives suggestions if + it's not. + """ + if enchant is None: + irc.msg(args[0], 'Missing pyenchant module.') + return + + word = args[1].rstrip() + if not word: + irc.msg(args[0], 'Usage: .spellcheck <word>') + return + if ' ' in word: + irc.msg(args[0], 'One word at a time, please') + return + dictionary_us = enchant.Dict('en_US') + dictionary_uk = enchant.Dict('en_GB') + + if dictionary_uk.check(word): + if dictionary_us.check(word): + irc.msg(args[0], f'\u200b{word} is spelled correctly') + else: + irc.msg(args[0], f'\u200b{word} is spelled correctly (British)') + elif dictionary_us.check(word): + irc.msg(args[0], f'\u200b{word} is spelled correctly (American)') + else: + suggestions = {*dictionary_us.suggest(word)[:10], + *dictionary_uk.suggest(word)[:10]} + suggestions_str = ', '.join(f"'{suggestion}'" + for suggestion in sorted(suggestions)) + irc.msg(args[0], f'\u200b{word} is not spelled correctly. ' + f'Maybe you want one of these spellings: ' + f'{suggestions_str}') diff --git a/translate.liteonly.py b/translate.liteonly.py new file mode 100644 index 0000000..2f24418 --- /dev/null +++ b/translate.liteonly.py @@ -0,0 +1,129 @@ +r""" + +Based on translate.py - Sopel Translation Plugin +Copyright 2008, Sean B. Palmer, inamidst.com +Copyright 2013-2014, Elad Alfassa <elad@fedoraproject.org> +Licensed under the Eiffel Forum License 2. +https://sopel.chat + + Eiffel Forum License, version 2 + + 1. Permission is hereby granted to use, copy, modify and/or + distribute this package, provided that: + * copyright notices are retained unchanged, + * any distribution of this package, whether modified or not, + includes this license text. + 2. Permission is hereby also granted to distribute binary programs + which depend on this package. If the binary program depends on a + modified version of this package, you are encouraged to publicly + release the modified version of this package. + +*********************** + +THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT WARRANTY. ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY PARTY FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS PACKAGE. + +*********************** +""" + +import json, urllib.error, urllib.request +from urllib.parse import unquote + +def translate(text, in_lang='auto', out_lang='en'): + raw = False + if str(out_lang).endswith('-raw'): + out_lang = out_lang[:-4] + raw = True + + headers = { + 'User-Agent': 'Mozilla/5.0' + + '(X11; U; Linux i686)' + + 'Gecko/20071127 Firefox/2.0.0.11' + } + + query = { + "client": "gtx", + "sl": in_lang, + "tl": out_lang, + "dt": "t", + "q": text, + } + url = ("https://translate.googleapis.com/translate_a/single?" + + urllib.parse.urlencode(query)) + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as r: + result = r.read().decode('utf-8') + except urllib.error.HTTPError: + return None, None + + if result == '[,,""]': + return None, in_lang + + while ',,' in result: + result = result.replace(',,', ',null,') + result = result.replace('[,', '[null,') + + try: + data = json.loads(result) + except ValueError: + return None, None + + if raw: + return str(data), 'en-raw' + + try: + language = data[2] # -2][0][0] + except IndexError: + language = '?' + + return ''.join(x[0] for x in data[0]), language + + +@register_command('tr', 'translate', 'üb', 'übersetzen') +def tr2(irc, hostmask, is_admin, args): + """Translates a phrase, with an optional language hint.""" + channel, command = args + + reply = lambda msg : irc.msg(channel, f'{hostmask[0]}: {msg}') + + if not command: + return reply('You did not give me anything to translate') + + def langcode(p): + return p.startswith(':') and (2 < len(p) < 10) and p[1:].isalpha() + + args = ['auto', 'en'] + + for i in range(2): + if ' ' not in command: + break + prefix, cmd = command.split(' ', 1) + if langcode(prefix): + args[i] = prefix[1:] + command = cmd + phrase = command + + if len(phrase) > 350 and not is_admin: + return reply('Phrase must be under 350 characters.') + + if phrase.strip() == '': + return reply('You need to specify a string for me to translate!') + + src, dest = args + if src != dest: + msg, src = translate(phrase, src, dest) + if not src: + return reply("Translation failed, probably because of a rate-limit.") + if msg: + msg = msg.replace(''', "'") + msg = '"%s" (%s to %s, translate.google.com)' % (msg, src, dest) + else: + msg = 'The %s to %s translation failed, are you sure you specified valid language abbreviations?' % (src, dest) + reply(msg) + else: + reply('Language guessing failed, so try suggesting one!') diff --git a/units.liteonly.py b/units.liteonly.py new file mode 100644 index 0000000..bda7670 --- /dev/null +++ b/units.liteonly.py @@ -0,0 +1,199 @@ +""" +Ported from units.py - Sopel Unit Conversion Plugin +Copyright © 2013, Elad Alfassa, <elad@fedoraproject.org> +Copyright © 2013, Dimitri Molenaars, <tyrope@tyrope.nl> +Licensed under the Eiffel Forum License 2. + +https://sopel.chat + + + Eiffel Forum License, version 2 + + 1. Permission is hereby granted to use, copy, modify and/or + distribute this package, provided that: + * copyright notices are retained unchanged, + * any distribution of this package, whether modified or not, + includes this license text. + 2. Permission is hereby also granted to distribute binary programs + which depend on this package. If the binary program depends on a + modified version of this package, you are encouraged to publicly + release the modified version of this package. + +*********************** + +THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT WARRANTY. ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY PARTY FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS PACKAGE. + +*********************** + +""" + +import re + +find_temp = re.compile(r'(-?[0-9]*\.?[0-9]*)[ °]*(K|C|F)', re.IGNORECASE) +find_length = re.compile(r'([0-9]*\.?[0-9]*)[ ]*(mile[s]?|mi|inch|in|foot|feet|ft|yard[s]?|yd|(?:milli|centi|kilo|)meter[s]?|[mkc]?m|ly|light-year[s]?|au|astronomical unit[s]?|parsec[s]?|pc)', re.IGNORECASE) +find_mass = re.compile(r'([0-9]*\.?[0-9]*)[ ]*(lb|lbm|pound[s]?|ounce|oz|(?:kilo|)gram(?:me|)[s]?|[k]?g)', re.IGNORECASE) + + +def f_to_c(temp): + return (float(temp) - 32) * 5 / 9 + + +def c_to_k(temp): + return temp + 273.15 + + +def c_to_f(temp): + return (9.0 / 5.0 * temp + 32) + + +def k_to_c(temp): + return temp - 273.15 + +# A quick "reply" function +def _reply(irc, hostmask, args, msg): + mention = hostmask[0] + if not hostmask[0].endswith('>'): + mention += ':' + irc.msg(args[0], mention, msg) + +@register_command('temp') +def temperature(irc, hostmask, is_admin, args): + """Convert temperatures""" + reply = lambda msg : _reply(irc, hostmask, args, msg) + try: + source = find_temp.match(args[-1]).groups() + except (AttributeError, TypeError): + reply("That's not a valid temperature.") + return + unit = source[1].upper() + numeric = float(source[0]) + celsius = 0 + if unit == 'C': + celsius = numeric + elif unit == 'F': + celsius = f_to_c(numeric) + elif unit == 'K': + celsius = k_to_c(numeric) + + kelvin = c_to_k(celsius) + fahrenheit = c_to_f(celsius) + + if kelvin >= 0: + reply("{:.2f}°C = {:.2f}°F = {:.2f}K".format(celsius, fahrenheit, kelvin)) + else: + reply("Physically impossible temperature.") + + +@register_command('length', 'distance') +def distance(irc, hostmask, is_admin, args): + """Convert distances""" + reply = lambda msg : _reply(irc, hostmask, args, msg) + try: + source = find_length.match(args[-1]).groups() + except (AttributeError, TypeError): + reply("That's not a valid length unit.") + return + unit = source[1].lower() + numeric = float(source[0]) + meter = 0 + if unit in ("meters", "meter", "m"): + meter = numeric + elif unit in ("millimeters", "millimeter", "mm"): + meter = numeric / 1000 + elif unit in ("kilometers", "kilometer", "km"): + meter = numeric * 1000 + elif unit in ("miles", "mile", "mi"): + meter = numeric / 0.00062137 + elif unit in ("inch", "in"): + meter = numeric / 39.370 + elif unit in ("centimeters", "centimeter", "cm"): + meter = numeric / 100 + elif unit in ("feet", "foot", "ft"): + meter = numeric / 3.2808 + elif unit in ("yards", "yard", "yd"): + meter = numeric / (3.2808 / 3) + elif unit in ("light-year", "light-years", "ly"): + meter = numeric * 9460730472580800 + elif unit in ("astronomical unit", "astronomical units", "au"): + meter = numeric * 149597870700 + elif unit in ("parsec", "parsecs", "pc"): + meter = numeric * 30856776376340068 + + if meter >= 1000: + metric_part = '{:.2f}km'.format(meter / 1000) + elif meter < 0.01: + metric_part = '{:.2f}mm'.format(meter * 1000) + elif meter < 1: + metric_part = '{:.2f}cm'.format(meter * 100) + else: + metric_part = '{:.2f}m'.format(meter) + + inch = meter * 39.37 + foot = int(inch) // 12 + inch = inch - (foot * 12) + yard = foot // 3 + mile = meter * 0.000621371192 + + if yard > 500: + imperial_part = '{:.2f} miles'.format(mile) + else: + parts = [] + if yard >= 100: + parts.append('{} yards'.format(yard)) + foot -= (yard * 3) + + if foot == 1: + parts.append('1 foot') + elif foot != 0: + parts.append('{:.0f} feet'.format(foot)) + + parts.append('{:.2f} inches'.format(inch)) + + imperial_part = ', '.join(parts) + + reply('{} = {}'.format(metric_part, imperial_part)) + + +@register_command('weight', 'mass') +def mass(irc, hostmask, is_admin, args): + """Convert mass""" + reply = lambda msg : _reply(irc, hostmask, args, msg) + try: + source = find_mass.match(args[-1]).groups() + except (AttributeError, TypeError): + reply("That's not a valid mass unit.") + return + unit = source[1].lower() + numeric = float(source[0]) + metric = 0 + if unit in ("gram", "grams", "gramme", "grammes", "g"): + metric = numeric + elif unit in ("kilogram", "kilograms", "kilogramme", "kilogrammes", "kg"): + metric = numeric * 1000 + elif unit in ("lb", "lbm", "pound", "pounds"): + metric = numeric * 453.59237 + elif unit in ("oz", "ounce"): + metric = numeric * 28.35 + + if metric >= 1000: + metric_part = '{:.2f}kg'.format(metric / 1000) + else: + metric_part = '{:.2f}g'.format(metric) + + ounce = metric * .035274 + pound = int(ounce) // 16 + ounce = ounce - (pound * 16) + + if pound >= 1: + imperial_part = f'{pound} pound{"" if pound == 1 else "s"}' + if ounce > 0.01: + imperial_part += ' {:.2f} ounces'.format(ounce) + else: + imperial_part = '{:.2f} oz'.format(ounce) + + reply('{} = {}'.format(metric_part, imperial_part)) @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# lurklite .wild command +# +# Copyright © 2021 by luk3yx +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +import random, time + +# The base wild thing +class WildThing: + __slots__ = ('name', 'option1', 'option2', 'option3') + custom = {} + + @property + def boldname(self): + return '\2{}\u200b{}\2'.format(self.name[:1], self.name[1:]) + + def __str__(self): + return self.name + + # Options + options1 = 'run', 'flee', 'skedaddle' + options2 = 'fight', 'eat them', 'TNT' + options3 = 'yay', 'meep', 'do nothing' + + # The 3 choices + def choice1(self, i): + if i == 1: + return 'You ran away from the wild {}!' + return False, 'You \2trip over\2 a wild \2' + random.choice(('chair', + 'blade of grass', 'knife', 'rake', 'fire hydrant', 'van door')) \ + + '\2, and the wild {} wins!' + + def choice2(self, i): + if i < 4: + return + elif self.option2 == 'TNT': + return 'You \2blow up\2 the wild {}!' + return 'You hit and destroy the wild {}!' + + def choice3(self, i): + if i == 5 and self.option3 != 'do nothing': + return "Somehow, magically, the wild {}'s HP gets set to " \ + '\2negative infinity\2!' + + # Select an option + def __getitem__(self, option): + if len(option) < 1: + option = random.choice('123') + elif option not in ('1', '2', '3'): + return 'Invalid number!' + + msg = getattr(self, 'choice' + option)(random.randint(1, 5)) + if isinstance(msg, tuple): + win, msg = msg + win = win and isinstance(msg, str) + elif isinstance(msg, str): + win = True + else: + win = False + msg = 'Your attack failed, and the wild {} wins!' + + if win: + msg += '\nYay! You win!' + + return win, msg.format(self.boldname) + + # Get the options + @property + def options(self): + return 'Your options: 1. {}, 2. {}, 3. {}'.format(self.option1, + self.option2, self.option3) + + # Create the object and select the options + def __init__(self, name): + self.name = str(name) + self.option1 = random.choice(self.options1) + self.option2 = random.choice(self.options2) + self.option3 = random.choice(self.options3) + + # Create a new wild thing + def __new__(cls, name): + newcls = cls.custom.get(name.lower()) + if newcls and newcls is not cls: + return newcls(name) + + return super().__new__(cls) + +# "Custom" wild things +def CustomWildThing(*names): + def n(cls): + assert issubclass(cls, WildThing) + if len(names) > 0: + cls.__init__ = lambda self, name : super(cls, + self).__init__(names[0]) + + for name in names: + WildThing.custom[name.lower()] = cls + return cls + return n + +@CustomWildThing('lurk', 'lurk3', 'lurklite') +class WildLurk(WildThing): + __slots__ = () + options1 = options2 = options3 = ('do nothing',) + + def choice3(self, i): + i *= random.randint(1, 5) + if i == 1: + self.name = 'mutated lurk' + + if i > 20: + return '\2HEY!\2 How did you beat me?' + + return False, 'You... uhh... do nothing, and the wild {} wins!' + + choice1 = choice2 = choice3 + +@CustomWildThing('superfluous comma', ',', 'comma') +class WildComma(WildThing): + options1 = 'erase it', 'press backspace' + options2 = 'use sed to fix it', 'attempt to use sed' + options3 = 'accept your grammatical fate', + + def choice2(self, i): + if i > 3: + return 'Your sed expression works, destroying the wild {}!' + + return False, 'You \2forgot to close\2 your sed expression, allowing' \ + ' the wild {} to \2sneak past\2!' + + def choice3(self, i): + return False, 'After a long day of consideration, you give up and' \ + ' allow the wild {} to win!' + +data = {} +@register_command('wild') +def wild_cmd(irc, hostmask, is_admin, args): + """ A wild \2<victim>\2 appeared! """ + param = args[-1] + + # Handle existing games + if not param or param in ('1', '2', '3'): + if hostmask[0] not in data: + irc.msg(args[0], hostmask[0] + ': You are not currently in a game!' + ' To create one, do \2.wild <object>\2.') + return + thing = data.pop(hostmask[0]) + win, msg = thing[param] + irc.msg(args[0], msg) + if win or isinstance(win, bool): + return + param = thing.name + time.sleep(0.75) + + # Create new games + param = param.strip() + + if not param.isprintable() or '|' in param: + return irc.msg(args[0], "I don't think so {}™.".format(hostmask[0])) + + data[hostmask[0]] = thing = WildThing(param) + irc.msg(args[0], 'A wild {} appeared! {}'.format(thing.boldname, + thing.options)) |