aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorluk3yx <luk3yx@users.noreply.github.com>2022-03-19 17:12:19 +1300
committerluk3yx <luk3yx@users.noreply.github.com>2022-03-19 17:12:19 +1300
commitfcd3e87793f44c20c92bdadb4c12c4915b7b411e (patch)
treedb08580af3351d9ec18ba92f94dd120056101fb5
downloadlurklite-commands-fcd3e87793f44c20c92bdadb4c12c4915b7b411e.tar.gz
lurklite-commands-fcd3e87793f44c20c92bdadb4c12c4915b7b411e.zip
Initial public commit
-rw-r--r--.gitignore5
-rw-r--r--currency.liteonly.py205
-rw-r--r--debug.py18
-rw-r--r--int.py41
-rw-r--r--join-part.liteonly.py15
-rw-r--r--misc.py174
-rw-r--r--multiline.py85
-rwxr-xr-xnickrate.py293
-rw-r--r--qotd.py376
-rw-r--r--spellcheck.py51
-rw-r--r--translate.liteonly.py129
-rw-r--r--units.liteonly.py199
-rw-r--r--wild.py178
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()}')
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 <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!')
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 <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)
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 <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('&#39;', "'")
+ 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))
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 <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))