pyircbot/pyircbot/modules/ASCII.py

253 lines
8.8 KiB
Python
Raw Normal View History

2017-04-30 22:54:09 -07:00
#!/usr/bin/env python3
"""
.. module::ASCII
:synopsis: Spam chat with awesome ascii texts
"""
2017-11-24 16:22:38 -08:00
from pyircbot.modulebase import ModuleBase, command
2017-04-30 22:54:09 -07:00
from threading import Thread
from glob import iglob
from collections import defaultdict
from time import sleep
import re
import os
2017-07-02 14:52:20 -07:00
import json
from textwrap import wrap
2017-11-24 16:22:38 -08:00
from pyircbot.modules.ModInfo import info
2017-04-30 22:54:09 -07:00
RE_ASCII_FNAME = re.compile(r'^[a-zA-Z0-9\-_]+$')
2017-07-02 14:52:20 -07:00
IRC_COLOR = "\x03"
def is_numeric(char):
i = ord(char)
return i >= 48 and i <= 57
2017-04-30 22:54:09 -07:00
class ASCII(ModuleBase):
def __init__(self, bot, moduleName):
ModuleBase.__init__(self, bot, moduleName)
self.running_asciis = defaultdict(lambda: None)
self.killed_channels = defaultdict(lambda: False)
2017-11-24 16:22:38 -08:00
@info("listascii list available asciis", cmds=["listascii"])
2017-07-02 14:52:20 -07:00
@command("listascii")
def cmd_listascii(self, msg, cmd):
2017-04-30 22:54:09 -07:00
"""
2017-07-02 14:52:20 -07:00
List available asciis
2017-04-30 22:54:09 -07:00
"""
2017-07-02 14:52:20 -07:00
fnames = [os.path.basename(f).split(".", 2)[0] for f in iglob(os.path.join(self.getFilePath(), "*.txt"))]
fnames.sort()
message = "Avalable asciis: {}".format(", ".join(fnames[0:self.config.get("list_max")]))
self.bot.act_PRIVMSG(msg.args[0], message)
2017-04-30 22:54:09 -07:00
2017-07-02 14:52:20 -07:00
if len(fnames) > self.config.get("list_max"):
self.bot.act_PRIVMSG(msg.args[0], "...and {} more".format(len(fnames) - self.config.get("list_max")))
return
2017-04-30 22:54:09 -07:00
2017-11-24 16:22:38 -08:00
@info("ascii <name> print an ascii", cmds=["ascii"])
2017-07-02 14:52:20 -07:00
@command("ascii", require_args=True)
def cmd_ascii(self, msg, cmd):
2017-07-02 14:52:20 -07:00
if self.channel_busy(msg.args[0]):
return
2017-04-30 22:54:09 -07:00
2017-07-02 14:52:20 -07:00
# Prevent parallel spamming in different channels
if not self.config.get("allow_parallel") and any(self.running_asciis.values()):
2017-04-30 22:54:09 -07:00
return
2017-07-02 14:52:20 -07:00
ascii_name = cmd.args.pop(0)
if not RE_ASCII_FNAME.match(ascii_name):
2017-04-30 22:54:09 -07:00
return
2017-07-02 14:52:20 -07:00
hilight = cmd.args[0] + ": " if self.config.get("allow_hilight", False) and len(cmd.args) >= 1 else False
try:
self.send_to_channel(msg.args[0], self.load_ascii(ascii_name), prefix=hilight)
except FileNotFoundError:
return
2017-11-24 16:22:38 -08:00
@info("stopascii stop the currently scrolling ascii", cmds=["stopascii"])
2017-07-02 14:52:20 -07:00
@command("stopascii")
def cmd_stopascii(self, msg, cmd):
2017-07-02 14:52:20 -07:00
"""
Command to stop the running ascii in a given channel
"""
if self.running_asciis[msg.args[0]]:
self.killed_channels[msg.args[0]] = True
def channel_busy(self, channel_name):
"""
Prevent parallel spamming in same channel
"""
if self.running_asciis[channel_name]:
return True
return False
def send_to_channel(self, channel, lines, **kwargs):
self.running_asciis[channel] = Thread(target=self.print_lines, daemon=True,
args=[channel, lines], kwargs=kwargs)
self.running_asciis[channel].start()
2017-04-30 22:54:09 -07:00
2017-07-02 14:52:20 -07:00
def load_ascii(self, ascii_name):
"""
Loads contents of ascii from disk by name
:return: list of string lines
"""
with open(self.getFilePath(ascii_name + ".txt"), "rb") as f:
return [i.rstrip() for i in f.read().decode("UTF-8", errors="ignore").split("\n")]
def print_lines(self, channel, lines, prefix=None):
2017-04-30 22:54:09 -07:00
"""
Print the contents of ascii_path to channel
:param ascii_path: file path to the ascii art file to read and print
:param channel: channel name to print to
"""
delay = self.config.get("line_delay")
2017-07-02 14:52:20 -07:00
for line in lines:
2017-04-30 22:54:09 -07:00
if self.killed_channels[channel]:
break
if not line:
line = " "
2017-07-02 14:52:20 -07:00
if prefix:
line = "{}{}".format(prefix, line)
2017-04-30 22:54:09 -07:00
self.bot.act_PRIVMSG(channel, line)
if delay:
sleep(delay)
del self.running_asciis[channel]
del self.killed_channels[channel]
2017-07-02 14:52:20 -07:00
2017-11-24 16:22:38 -08:00
@info("asciiedit <args> customize an ascii with input", cmds=["asciiedit"])
2017-07-02 14:52:20 -07:00
@command("asciiedit", require_args=True)
def cmd_asciiedit(self, msg, cmd):
2017-07-02 14:52:20 -07:00
ascii_name = cmd.args.pop(0)
try:
with open(self.getFilePath(ascii_name + ".json")) as f:
ascii_infos = json.load(f)
ascii_lines = self.load_ascii(ascii_name)
except FileNotFoundError:
return
template_data = " ".join(cmd.args).split("|")
for i in range(0, min(len(ascii_infos["areas"]), len(template_data))):
ascii_infos["areas"][i]["string"] = template_data[i]
lines = self.edit_ascii(ascii_lines, ascii_infos)
self.send_to_channel(msg.args[0], lines)
def edit_ascii(self, lines, info_dict):
asci = AsciiEdit(lines)
# Per region in the metadata
for area in info_dict["areas"]:
message = area.get("format", "{}").format(area["string"])
x = area["x"]
y = area["y"]
# Format the text to the area's width
boxed_lines = wrap(message, area["width"])
# Write each line
for line in boxed_lines:
line_x = x
props = area.get("props", [])
# move the X over for centered text
if "centered" in props:
padding = (area["width"] - len(line)) / 2
line_x += padding
if "upper" in props:
line = line.upper()
if "lower" in props:
line = line.lower()
if "blacktext" in props:
asci.set_charcolor(1)
# Ensure anything in the template will be covered
# line = line + " " * (area["width"] - len(line))
# Write the formatted line
asci.goto(line_x, y)
asci.write(line)
# Check if height limit is passed
y += 1
if y >= area["y"] + area["height"]:
break
return asci.getlines()
class AsciiEdit(object):
"""
Semi-smart ascii editing library
"""
def __init__(self, lines):
self.lines = [list(line) for line in lines]
self.x = 0
self.virtual_x = 0
self.y = 0
self.charcolor = None
def goto(self, x, y):
"""
This method moves the internal pointer to the passed X and Y coordinate. The origin is the top left corner.
The literal coordinates (self.x and self.y) point to where in the data the pointer is. Y is simple, self.y is
always the data and rendered Y coordinate. X has invisible-when-rendered formatting data, so the self.virtual_x
attribute tracks where the cursor is in the rendered output; self.x tracks the location in the actual data.
:param x: x coordinate
:type x: int
:param y: y coordinate
:type y: int
"""
# Immediately set the x and y pointers
self.virtual_x = x
self.y = y
# Calculate the x position in-data by increasing self.x to the right until real_x == target x
self.x = 0
real_x = 0
while real_x <= x:
# Scan throw any repeated color codes
while self.lines[self.y][self.x] == IRC_COLOR:
# Scan forward until color code is crossed
self.x += 1 # the escape char
if is_numeric(self._getchar()):
self.x += 1 # color code 1st digit
if is_numeric(self._getchar()):
self.x += 1 # color code 2nd digit
# Check for background color code after comma
if self._getchar() == "," and is_numeric(self._getchar(self.x + 1)):
self.x += 2 # comma and background code digit 1
if is_numeric(self._getchar()):
self.x += 1 # color code digit 2
real_x += 1
if real_x <= x:
self.x += 1
def set_charcolor(self, color):
self.charcolor = color
def write(self, text):
"""
Write a single line of text to the ascii
"""
for char in text:
self._putchar(self.x, self.y, char)
self.goto(self.virtual_x + 1, self.y)
def getlines(self):
"""
Return the rendered ascii, formatted as a list of string lines
"""
return [''.join(line) for line in self.lines]
def _getchar(self, x=None, y=None):
if x is None:
x = self.x
if y is None:
y = self.y
return self.lines[y][x]
def _putchar(self, x, y, char):
if self.charcolor is not None and char != " ":
self.lines[y].pop(x)
for char in list((IRC_COLOR + str(self.charcolor) + char)[::-1]):
self.lines[y].insert(x, char)
else:
self.lines[y][x] = char