From fcd3e87793f44c20c92bdadb4c12c4915b7b411e Mon Sep 17 00:00:00 2001 From: luk3yx Date: Sat, 19 Mar 2022 17:12:19 +1300 Subject: Initial public commit --- .gitignore | 5 + currency.liteonly.py | 205 +++++++++++++++++++++++++++ debug.py | 18 +++ int.py | 41 ++++++ join-part.liteonly.py | 15 ++ misc.py | 174 +++++++++++++++++++++++ multiline.py | 85 ++++++++++++ nickrate.py | 293 +++++++++++++++++++++++++++++++++++++++ qotd.py | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++ spellcheck.py | 51 +++++++ translate.liteonly.py | 129 +++++++++++++++++ units.liteonly.py | 199 ++++++++++++++++++++++++++ wild.py | 178 ++++++++++++++++++++++++ 13 files changed, 1769 insertions(+) create mode 100644 .gitignore create mode 100644 currency.liteonly.py create mode 100644 debug.py create mode 100644 int.py create mode 100644 join-part.liteonly.py create mode 100644 misc.py create mode 100644 multiline.py create mode 100755 nickrate.py create mode 100644 qotd.py create mode 100644 spellcheck.py create mode 100644 translate.liteonly.py create mode 100644 units.liteonly.py create mode 100644 wild.py 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()}') diff --git a/int.py b/int.py new file mode 100644 index 0000000..b323aa2 --- /dev/null +++ b/int.py @@ -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 . +# + +@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!') diff --git a/misc.py b/misc.py new file mode 100644 index 0000000..5d94381 --- /dev/null +++ b/misc.py @@ -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 . +# + +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] + """ + + 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:{}').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 ' + '') + 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 . +# + +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='', 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 ') + + 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) diff --git a/qotd.py b/qotd.py new file mode 100644 index 0000000..0874aef --- /dev/null +++ b/qotd.py @@ -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 . +# + +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', '')) + +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', '') + 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, +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 ') + 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 +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, +Copyright © 2013, Dimitri Molenaars, +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)) diff --git a/wild.py b/wild.py new file mode 100644 index 0000000..bb46a1c --- /dev/null +++ b/wild.py @@ -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 . +# + +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\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 \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)) -- cgit v1.2.3