diff options
Diffstat (limited to 'salvador')
| -rw-r--r-- | salvador/__init__.py | 0 | ||||
| -rw-r--r-- | salvador/commands.py | 64 | ||||
| -rw-r--r-- | salvador/salvador.cfg | 19 | ||||
| -rw-r--r-- | salvador/salvador.py | 172 |
4 files changed, 255 insertions, 0 deletions
diff --git a/salvador/__init__.py b/salvador/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/salvador/__init__.py diff --git a/salvador/commands.py b/salvador/commands.py new file mode 100644 index 0000000..833a953 --- /dev/null +++ b/salvador/commands.py @@ -0,0 +1,64 @@ +""" +Sopel plugin: salvador admin commands + +Owner-only commands for managing the bot at runtime. + + !join #channel - join a channel + !part [#channel] - leave a channel (defaults to current) + !chans - list channels the bot is in + !help - show usage +""" + +from sopel import plugin + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel") + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel: + channel = trigger.sender + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + if chans: + bot.say("in: " + " ".join(chans)) + else: + bot.say("not in any channels") + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!draw [<lines>] <url> - render image as mIRC colour art (lines: 4-12, default 6)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel (owner)", + "!part [#channel] - leave a channel (owner)", + "!chans - list channels the bot is in (owner)", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/salvador/salvador.cfg b/salvador/salvador.cfg new file mode 100644 index 0000000..6295525 --- /dev/null +++ b/salvador/salvador.cfg @@ -0,0 +1,19 @@ +[core] +nick = salvador +user = salvador +name = salvador +host = 127.0.0.1 +port = 6667 +use_ssl = false +verify_ssl = false +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/salvador +enable = + salvador + commands +logdir = /var/log/salvador +logging_level = INFO +timeout = 120 diff --git a/salvador/salvador.py b/salvador/salvador.py new file mode 100644 index 0000000..f346e2a --- /dev/null +++ b/salvador/salvador.py @@ -0,0 +1,172 @@ +""" +Sopel plugin: salvador + +Converts an image URL to mIRC colour art using half-block characters. + + !draw <url> - render at default height (6 lines) + !draw <lines> <url> - render at custom height (4-12 lines) +""" + +import time +import urllib.request +from io import BytesIO + +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = True +from sopel import plugin + + +MAX_WIDTH = 60 +DEFAULT_HEIGHT = 6 +MIN_HEIGHT = 4 +MAX_HEIGHT = 12 +FLOOD_DELAY = 0.4 +MAX_BYTES = 5 * 1024 * 1024 # 5 MB + +# IRC monospace character cells are approximately twice as tall as they are wide. +# _fit multiplies the image aspect ratio by this factor so the rendered output +# uses enough character columns to compensate for the tall cells. +CHAR_RATIO = 2.0 + +USAGE = f'usage: salvador [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {DEFAULT_HEIGHT})' + +# Full mIRC 99-colour palette (indices 0-98) +MIRC = [ + (0xff,0xff,0xff),(0x00,0x00,0x00),(0x00,0x00,0x7f),(0x00,0x93,0x00), + (0xff,0x00,0x00),(0x7f,0x00,0x00),(0x9c,0x00,0x9c),(0xfc,0x7f,0x00), + (0xff,0xff,0x00),(0x00,0xfc,0x00),(0x00,0x93,0x93),(0x00,0xff,0xff), + (0x00,0x00,0xfc),(0xff,0x00,0xff),(0x55,0x55,0x55),(0xaa,0xaa,0xaa), + (0x47,0x00,0x00),(0x47,0x21,0x00),(0x47,0x47,0x00),(0x32,0x47,0x00), + (0x00,0x47,0x00),(0x00,0x47,0x2c),(0x00,0x47,0x47),(0x00,0x27,0x47), + (0x00,0x00,0x47),(0x2e,0x00,0x47),(0x47,0x00,0x47),(0x47,0x00,0x2a), + (0x74,0x00,0x00),(0x74,0x3a,0x00),(0x74,0x74,0x00),(0x51,0x74,0x00), + (0x00,0x74,0x00),(0x00,0x74,0x49),(0x00,0x74,0x74),(0x00,0x40,0x74), + (0x00,0x00,0x74),(0x4b,0x00,0x74),(0x74,0x00,0x74),(0x74,0x00,0x45), + (0xb5,0x00,0x00),(0xb5,0x63,0x00),(0xb5,0xb5,0x00),(0x7d,0xb5,0x00), + (0x00,0xb5,0x00),(0x00,0xb5,0x71),(0x00,0xb5,0xb5),(0x00,0x63,0xb5), + (0x00,0x00,0xb5),(0x75,0x00,0xb5),(0xb5,0x00,0xb5),(0xb5,0x00,0x6b), + (0xff,0x00,0x00),(0xff,0x8c,0x00),(0xff,0xff,0x00),(0xb2,0xff,0x00), + (0x00,0xff,0x00),(0x00,0xff,0xa0),(0x00,0xff,0xff),(0x00,0x8c,0xff), + (0x00,0x00,0xff),(0xa5,0x00,0xff),(0xff,0x00,0xff),(0xff,0x00,0x98), + (0xff,0x59,0x59),(0xff,0xb4,0x59),(0xff,0xff,0x71),(0xcf,0xff,0x60), + (0x6f,0xff,0x6f),(0x65,0xff,0xc9),(0x6d,0xff,0xff),(0x59,0xb4,0xff), + (0x59,0x59,0xff),(0xc4,0x59,0xff),(0xff,0x66,0xff),(0xff,0x59,0xbc), + (0xff,0x9c,0x9c),(0xff,0xd3,0x9c),(0xff,0xff,0x9c),(0xe2,0xff,0x9c), + (0x9c,0xff,0x9c),(0x9c,0xff,0xdb),(0x9c,0xff,0xff),(0x9c,0xd3,0xff), + (0x9c,0x9c,0xff),(0xdc,0x9c,0xff),(0xff,0x9c,0xff),(0xff,0x94,0xd3), + (0x00,0x00,0x00),(0x13,0x13,0x13),(0x28,0x28,0x28),(0x36,0x36,0x36), + (0x4d,0x4d,0x4d),(0x65,0x65,0x65),(0x81,0x81,0x81),(0x9f,0x9f,0x9f), + (0xbc,0xbc,0xbc),(0xe2,0xe2,0xe2),(0xff,0xff,0xff), +] + + +def _nearest(r, g, b): + # Perceptually-weighted RGB distance + rmean = (r + 128) / 2 + best, best_dist = 0, float('inf') + for i, (cr, cg, cb) in enumerate(MIRC): + dr, dg, db = r - cr, g - cg, b - cb + dist = (2 + rmean / 256) * dr*dr + 4*dg*dg + (2 + (255 - rmean) / 256) * db*db + if dist < best_dist: + best_dist = dist + best = i + return best + + +def _fetch(url): + req = urllib.request.Request(url, headers={'User-Agent': 'salvador/1.0'}) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get('Content-Type', '') + if 'image' not in content_type: + raise ValueError(f'not an image ({content_type})') + return resp.read(MAX_BYTES) + + +def _fit(img_w, img_h, max_w, max_h): + aspect = (img_w / img_h) * CHAR_RATIO + char_aspect = max_w / max_h + if aspect >= char_aspect: + w = max_w + h = max(1, round(max_w / aspect)) + else: + h = max_h + w = max(1, round(max_h * aspect)) + return w, h + + +def _dither(img, w, h): + buf = [[list(map(float, img.getpixel((x, y)))) for x in range(w)] + for y in range(h)] + idx = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b = (max(0, min(255, int(v))) for v in buf[y][x]) + c = _nearest(r, g, b) + idx[y][x] = c + cr, cg, cb = MIRC[c] + er, eg, eb = r - cr, g - cg, b - cb + for dx, dy, f in ((1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + buf[ny][nx][0] += er * f / 16 + buf[ny][nx][1] += eg * f / 16 + buf[ny][nx][2] += eb * f / 16 + return idx + + +def _img_to_irc(url, height=DEFAULT_HEIGHT): + data = _fetch(url) + img = Image.open(BytesIO(data)).convert('RGB') + char_w, char_h = _fit(img.width, img.height, MAX_WIDTH, height) + img = img.resize((char_w, char_h * 2), Image.LANCZOS) + idx = _dither(img, char_w, char_h * 2) + + lines = [] + for row in range(char_h): + line = '' + last_fg = last_bg = None + for col in range(char_w): + bg = idx[row * 2][col] + fg = idx[row * 2 + 1][col] + if fg != last_fg or bg != last_bg: + line += f'\x03{fg:02d},{bg:02d}' + last_fg, last_bg = fg, bg + line += '▄' + line += '\x0f' + lines.append(line) + return lines + + +@plugin.command("draw") +@plugin.require_chanmsg +def cmd_draw(bot, trigger): + args = (trigger.group(2) or "").split(None, 1) + if not args: + bot.say(USAGE, trigger.nick) + return + + height = DEFAULT_HEIGHT + if len(args) == 2 and args[0].isdigit(): + n = int(args[0]) + if not (MIN_HEIGHT <= n <= MAX_HEIGHT): + bot.say(USAGE, trigger.nick) + return + height = n + url = args[1] + else: + url = args[0] + + url = url.rstrip('.,)>') + if not url.startswith(('http://', 'https://')): + bot.say(USAGE, trigger.nick) + return + + try: + lines = _img_to_irc(url, height=height) + except Exception as e: + bot.say(str(e)) + return + for line in lines: + bot.say(line) + time.sleep(FLOOD_DELAY) |
