diff --git a/README.md b/README.md index 9f88121..d4205a8 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ A template utility bot based on [Alastair](Make-Alastair-Great-Again) and [Pinki Currently supports [Pixelcanvas.io](http://pixelcanvas.io/), [Pixelz.io](http://pixelz.io/), [Pixelzone.io](http://pixelzone.io/), and [Pxls.space](http://pxls.space/). -**Invite:** `https://discordapp.com/oauth2/authorize?&client_id=405480380930588682&scope=bot&permissions=101376` +**Invite:** `https://discordapp.com/oauth2/authorize?&client_id=405480380930588682&scope=bot&permissions=109569` #### Requires: - [Python 3.6](https://www.python.org/downloads/release/python-365/) - [Discord.py rewrite](https://github.com/Rapptz/discord.py/tree/rewrite) - [Pillow](https://pillow.readthedocs.io/en/latest/installation.html) 5.1.0 - [aiohttp](https://aiohttp.readthedocs.io/en/stable/) 3.2.0 +- [numpy](https://www.scipy.org/scipylib/download.html) 1.14.4 - [websockets](https://pypi.org/project/websockets/) 4.0.1 - [lz4](https://github.com/python-lz4/python-lz4) 1.1.0 - [cloudflare-scrape async](https://github.com/lucasgadams/cloudflare-scrape@cf_async) @@ -27,24 +28,24 @@ Currently supports [Pixelcanvas.io](http://pixelcanvas.io/), [Pixelz.io](http:// - Automatic live canvas preview - Automatic live template checking - Template storage for easy access to templates you care about most +- Faction creation, to share your templates with other guilds - Color quantization of templates to canvas palette - Gridifyer to create gridded, human-readable templates - Dithering sample charts for assisting color selection when you are making a template - Configurable roles - [Animotes](https://github.com/ev1l0rd/animotes) support, just because -- Cross-guild emoji through Animotes (must opt-in) - Full language localization For a more in-depth walkthrough of Glimmer's core functions, see [the wiki page](https://github.com/DiamondIceNS/StarlightGlimmer/wiki). #### Languages: - English (US) -- Portuguese (BR) +- Portuguese (BR) - Special thanks to Ataribr / ✠ /#6703 If you happen to know a language that is not listed and would be willing to translate, please translate the strings in `lang/en_US.py` and submit a pull request. (Currently looking for: French, Turkish) #### Help: -If you need assistance with the bot, have a problem, or would like to recommend a feature to me directly, you can contact me [on my support server](https://discord.gg/UtyJx2x). You can also DM me if you see me around -- I am `Fawfulcopter#3234` on Discord. +If you need assistance with the bot, have a problem, or would like to recommend a feature to me directly, you can contact me [on my support server](https://discord.gg/UtyJx2x). You can also DM me if you see me around -- I am `Fawfulcopter#3432` on Discord. [avatar]: avatar.jpg \ No newline at end of file diff --git a/commands/animotes.py b/commands/animotes.py index e90e5df..08af335 100644 --- a/commands/animotes.py +++ b/commands/animotes.py @@ -1,8 +1,9 @@ import re + import discord from discord.ext import commands -from utils import checks, sqlite as sql +from utils import sqlite as sql from objects.channel_logger import ChannelLogger from objects.logger import Log @@ -31,91 +32,36 @@ def __init__(self, bot): self.ch_log = ChannelLogger(bot) self.log = Log(__name__) - async def on_message(self, message): - if not message.author.bot and sql.animotes_users_is_registered(message.author.id): - channel = message.channel - content = emote_corrector(self, message) - if content: - await message.delete() - await channel.send(content=content) - @commands.command() async def register(self, ctx): sql.animotes_users_add(ctx.author.id) - await ctx.send(ctx.get_str("animotes.member_opt_in")) + await ctx.send(ctx.s("animotes.opt_in")) @commands.command() async def unregister(self, ctx): sql.animotes_users_delete(ctx.author.id) - await ctx.send(ctx.get_str("animotes.member_opt_out")) + await ctx.send(ctx.s("animotes.opt_out")) - @checks.admin_only() - @commands.guild_only() - @commands.command() - async def registerguild(self, ctx): - sql.guild_update(ctx.guild.id, emojishare=1) - await self.ch_log.log("Guild **{0.name}** (ID: `{0.id}`) has opted in to emoji sharing.".format(ctx.guild)) - await ctx.send(ctx.get_str("animotes.guild_opt_in")) + @staticmethod + async def on_message(message): + if not message.author.bot and sql.animotes_users_is_registered(message.author.id): + channel = message.channel + content = emote_corrector(message) + if content: + await message.delete() + await channel.send(content=content) - @checks.admin_only() - @commands.guild_only() - @commands.command() - async def unregisterguild(self, ctx): - sql.guild_update(ctx.guild.id, emojishare=0) - await self.ch_log.log("Guild **{0.name}** (ID: `{0.id}`) has opted out of emoji sharing.".format(ctx.guild)) - await ctx.send(ctx.get_str("animotes.guild_opt_out")) - @commands.guild_only() - @commands.command() - async def listemotes(self, ctx): - # TODO: WHY IS THIS NEVER CONSISTENT??? - guilds = [] - blacklist = [] - whitelist = [] - opted_in = sql.guild_is_emojishare(ctx.guild.id) - whitelist.append(ctx.guild.id) # Emoji from this server are allowed automatically - for e in self.bot.emojis: - if e.animated: - # No emoji from blacklisted servers - if e.guild_id in blacklist: - continue - # Do not list cross-server emoji if this server has not opted in - if e.guild_id != ctx.guild.id and not opted_in: - continue - # Blacklist servers that have not themselves opted in - if not (e.guild_id in whitelist or sql.guild_is_emojishare(e.guild_id)): - blacklist.append(e.guild_id) - continue - # If passed all checks, ensure this server is whitelisted so we can skip future opt-in checks - if e.guild_id not in whitelist: - whitelist.append(e.guild_id) - if not any(x['id'] for x in guilds if x['id'] == e.guild_id): - guild = next(x for x in self.bot.guilds if x.id == e.guild_id) - guilds.append({'id': e.guild_id, 'name': guild.name, 'animojis': []}) - pos = next(i for i, x in enumerate(guilds) if x['id'] == e.guild_id) - guilds[pos]['animojis'].append(str(e)) - - for g in guilds: - msg = "**{0}**:".format(g['name']) - msg += "\n" - for e in g['animojis']: - msg += e - await ctx.send(msg) - - -def emote_corrector(self, message): +# noinspection PyTypeChecker +def emote_corrector(message): r = re.compile(r'(? 0: + perc = ">0.00%" + elif perc == 100 and err > 0: + perc = "<100.00%" + else: + perc = "{:.2f}%".format(perc / 100) + out = ctx.s("canvas.diff") if bad == 0 else ctx.s("canvas.diff_bad_color") + out = out.format(done, tot, err, perc, bad=bad) + + with io.BytesIO() as bio: + diff_img.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "diff.png") + await ctx.send(content=out, file=f) + + if list_pixels and len(err_list) > 0: + out = ["```xl"] + for p in err_list: + x, y, current, target = p + current = ctx.s("color.{}.{}".format(t.canvas, current)) + target = ctx.s("color.{}.{}".format(t.canvas, target)) + out.append("({}, {}) is {}, should be {}".format(x + t.x, y + t.y, current, target)) + if err > 15: + out.append("...") + out.append("```") + await ctx.send('\n'.join(out)) + return await ctx.invoke_default("diff") @commands.cooldown(1, 5, BucketType.guild) @diff.command(name="pixelcanvas", aliases=["pc"]) - async def diff_pixelcanvas(self, ctx, *, raw_arg: str): - args = await Canvas.parse_diff(ctx, raw_arg) - if args: - await render.diff(*args, render.fetch_pixelcanvas, colors.pixelcanvas) + async def diff_pixelcanvas(self, ctx, *args): + await _diff(ctx, args, "pixelcanvas", render.fetch_pixelcanvas, colors.pixelcanvas) @commands.cooldown(1, 5, BucketType.guild) @diff.command(name="pixelzio", aliases=["pzi"]) - async def diff_pixelzio(self, ctx, *, raw_arg: str): - args = await Canvas.parse_diff(ctx, raw_arg) - if args: - await render.diff(*args, render.fetch_pixelzio, colors.pixelzio) + async def diff_pixelzio(self, ctx, *args): + await _diff(ctx, args, "pixelzio", render.fetch_pixelzio, colors.pixelzio) @commands.cooldown(1, 5, BucketType.guild) @diff.command(name="pixelzone", aliases=["pz"]) - async def diff_pixelzone(self, ctx, *, raw_arg: str): - args = await Canvas.parse_diff(ctx, raw_arg) - if args: - await render.diff(*args, render.fetch_pixelzone, colors.pixelzone) + async def diff_pixelzone(self, ctx, *args): + await _diff(ctx, args, "pixelzone", render.fetch_pixelzone, colors.pixelzone) @commands.cooldown(1, 5, BucketType.guild) @diff.command(name="pxlsspace", aliases=["ps"]) - async def diff_pxlsspace(self, ctx, *, raw_arg: str): - args = await Canvas.parse_diff(ctx, raw_arg) - if args: - await render.diff(*args, render.fetch_pxlsspace, colors.pxlsspace) + async def diff_pxlsspace(self, ctx, *args): + await _diff(ctx, args, "pxlsspace", render.fetch_pxlsspace, colors.pxlsspace) # ======================= # PREVIEW @@ -74,33 +132,23 @@ async def preview(self, ctx): @commands.cooldown(1, 5, BucketType.guild) @preview.command(name="pixelcanvas", aliases=["pc"]) - async def preview_pixelcanvas(self, ctx, *, coordinates: str): - args = await Canvas.parse_preview(ctx, coordinates) - if args: - await render.preview(*args, render.fetch_pixelcanvas) + async def preview_pixelcanvas(self, ctx, *args): + await _preview(ctx, args, render.fetch_pixelcanvas) @commands.cooldown(1, 5, BucketType.guild) @preview.command(name="pixelzio", aliases=["pzi"]) - async def preview_pixelzio(self, ctx, *, coordinates: str): - args = await Canvas.parse_preview(ctx, coordinates) - if args: - await render.preview(*args, render.fetch_pixelzio) + async def preview_pixelzio(self, ctx, *args): + await _preview(ctx, args, render.fetch_pixelzio) @commands.cooldown(1, 5, BucketType.guild) @preview.command(name="pixelzone", aliases=["pz"]) - async def preview_pixelzone(self, ctx, *, coordinates: str): - args = await Canvas.parse_preview(ctx, coordinates) - if args: - await render.preview(*args, render.fetch_pixelzone) + async def preview_pixelzone(self, ctx, *args): + await _preview(ctx, args, render.fetch_pixelzone) @commands.cooldown(1, 5, BucketType.guild) @preview.command(name="pxlsspace", aliases=["ps"]) - async def preview_pxlsspace(self, ctx, *, coordinates: str): - args = await Canvas.parse_preview(ctx, coordinates) - if args: - x = max(0, min(1279, args[1])) - y = max(0, min(719, args[2])) - await render.preview(ctx, x, y, args[3], render.fetch_pxlsspace) + async def preview_pxlsspace(self, ctx, *args): + await _preview(ctx, args, render.fetch_pxlsspace) # ======================= # QUANTIZE @@ -113,31 +161,23 @@ async def quantize(self, ctx): @commands.cooldown(1, 5, BucketType.guild) @quantize.command(name="pixelcanvas", aliases=["pc"]) - async def quantize_pixelcanvas(self, ctx, a=None): - data = await self.parse_quantize(ctx, a, "pixelcanvas") - if data: - await render.quantize(ctx, data, colors.pixelcanvas) + async def quantize_pixelcanvas(self, ctx, *args): + await _quantize(ctx, args, "pixelcanvas", colors.pixelcanvas) @commands.cooldown(1, 5, BucketType.guild) @quantize.command(name="pixelzio", aliases=["pzi"]) - async def quantize_pixelzio(self, ctx, a=None): - data = await self.parse_quantize(ctx, a, "pixelzio") - if data: - await render.quantize(ctx, data, colors.pixelzio) + async def quantize_pixelzio(self, ctx, *args): + await _quantize(ctx, args, "pixelzio", colors.pixelzio) @commands.cooldown(1, 5, BucketType.guild) @quantize.command(name="pixelzone", aliases=["pz"]) - async def quantize_pixelzone(self, ctx, a=None): - data = await self.parse_quantize(ctx, a, "pixelzone") - if data: - await render.quantize(ctx, data, colors.pixelzone) + async def quantize_pixelzone(self, ctx, *args): + await _quantize(ctx, args, "pixelzone", colors.pixelzone) @commands.cooldown(1, 5, BucketType.guild) @quantize.command(name="pxlsspace", aliases=["ps"]) - async def quantize_pxlsspace(self, ctx, a=None): - data = await self.parse_quantize(ctx, a, "pxlsspace") - if data: - await render.quantize(ctx, data, colors.pxlsspace) + async def quantize_pxlsspace(self, ctx, *args): + await _quantize(ctx, args, "pxlsspace", colors.pxlsspace) # ======================= # GRIDIFY @@ -145,27 +185,60 @@ async def quantize_pxlsspace(self, ctx, a=None): @commands.cooldown(1, 5, BucketType.guild) @commands.command(name="gridify", aliases=["g"]) - async def gridify(self, ctx, a=None, b=None): - t = next((x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.name == a), None) + async def gridify(self, ctx, *args): + faction = None + color = 8421504 + iter_args = iter(args) + a = next(iter_args, None) + while a in ["-f", "-c"]: + if a == "-f": + faction = sql.guild_get_by_faction_name_or_alias(next(iter_args, None)) + if not faction: + await ctx.send(ctx.s("error.faction_not_found")) + return + if a == "-c": + try: + color = abs(int(next(iter_args, None), 16) % 16777215) + except ValueError: + await ctx.send(ctx.s("error.invalid_color")) + return + a = next(iter_args, None) + name = a + zoom = next(iter_args, None) + + if faction: + t = sql.template_get_by_name(faction.id, name) + else: + t = sql.template_get_by_name(ctx.guild.id, name) + + template = None if t: - data = await utils.get_template(t.url) + data = await http.get_template(t.url) max_zoom = int(math.sqrt(4000000 // (t.width * t.height))) try: - zoom = max(1, min(int(b[1:]) if b and b.startswith("#") else 1, max_zoom)) + zoom = max(1, min(int(zoom[1:]) if zoom and zoom.startswith("#") else 1, max_zoom)) except ValueError: zoom = 1 - await render.gridify(ctx, data, zoom) - return - att = await utils.verify_attachment(ctx) - if att: - data = io.BytesIO() - await att.save(data) - max_zoom = int(math.sqrt(4000000 // (att.width * att.height))) - try: - zoom = max(1, min(int(a[1:]) if a and a.startswith("#") else 1, max_zoom)) - except ValueError: - zoom = 1 - await render.gridify(ctx, data, zoom) + template = await render.gridify(data, color, zoom) + else: + att = await utils.verify_attachment(ctx) + if att: + data = io.BytesIO() + await att.save(data) + zoom = args[0] if len(args) >= 1 else "#1" + max_zoom = int(math.sqrt(4000000 // (att.width * att.height))) + try: + zoom = max(1, min(int(zoom[1:]) if zoom and zoom.startswith("#") else 1, max_zoom)) + except ValueError: + zoom = 1 + template = await render.gridify(data, color, zoom) + + if template: + with io.BytesIO() as bio: + template.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "gridded.png") + await ctx.send(file=f) # ====================== # DITHERCHART @@ -209,52 +282,146 @@ async def repeat(self, ctx): if await utils.autoscan(new_ctx): return - await ctx.send(ctx.get_str("canvas.repeat_not_found")) + await ctx.send(ctx.s("canvas.repeat_not_found")) - # ====================== - @staticmethod - async def parse_diff(ctx, raw_arg): - m = re.search('(-?\d+)(?:,| |, )(-?\d+)(?: #(\d+))?', raw_arg) - if not m: - await ctx.send(ctx.get_str("canvas.invalid_input")) - return +async def _diff(ctx, args, canvas, fetch, palette): + async with ctx.typing(): att = await utils.verify_attachment(ctx) - if att: - x = int(m.group(1)) - y = int(m.group(2)) - data = io.BytesIO() - await att.save(data) - zoom = max(1, min(int(m.group(3)) if m.group(3) else 1, 400 // att.width, 400 // att.height)) - return ctx, x, y, data, zoom - - @staticmethod - async def parse_preview(ctx, coords): - m = re.search('(-?\d+)(?:,|&y=) ?(-?\d+)(?:(?:,|&scale=)(\d+))?/?\s?#?(-?\d+)?', coords) - if m is not None: - x = int(m.group(1)) - y = int(m.group(2)) - if m.group(4): - zoom = int(m.group(4)) - elif m.group(3): - zoom = int(m.group(3)) - else: - zoom = 1 - zoom = max(min(zoom, 16), -8) - return ctx, x, y, zoom + list_pixels = False + iter_args = iter(args) + a = next(iter_args, None) + if a == "-e": + list_pixels = True + a = next(iter_args, None) + if a and ',' in a: + x, y = a.split(',') + else: + x = a + y = next(iter_args, None) + + try: + x = int(x) + y = int(y) + except (ValueError, TypeError): + await ctx.send(ctx.s("canvas.invalid_input")) + return - @staticmethod - async def parse_quantize(ctx, a, canvas): - t = next((x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.name == a), None) - if t: - if t.canvas == canvas: - raise checks.IdempotentActionError - return await utils.get_template(t.url) + zoom = next(iter_args, 1) + try: + if type(zoom) is not int: + if zoom.startswith("#"): + zoom = zoom[1:] + zoom = int(zoom) + except ValueError: + zoom = 1 + + data = io.BytesIO() + await att.save(data) + max_zoom = int(math.sqrt(4000000 // (att.width * att.height))) + zoom = max(1, min(zoom, max_zoom)) + diff_img, tot, err, bad, err_list = await render.diff(x, y, data, zoom, fetch, palette) + + done = tot - err + perc = int(10000 * (tot - err) / tot) + if perc == 0 and done > 0: + perc = ">0.00%" + elif perc == 100 and err > 0: + perc = "<100.00%" + else: + perc = "{:.2f}%".format(perc / 100) + out = ctx.s("canvas.diff") if bad == 0 else ctx.s("canvas.diff_bad_color") + out = out.format(done, tot, err, perc, bad=bad) + + with io.BytesIO() as bio: + diff_img.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "diff.png") + await ctx.send(content=out, file=f) + + if list_pixels and len(err_list) > 0: + out = ["```xl"] + for p in err_list: + err_x, err_y, current, target = p + current = ctx.s("color.{}.{}".format(canvas, current)) + target = ctx.s("color.{}.{}".format(canvas, target)) + out.append(ctx.s("canvas.diff_error_list").format(err_x + x, err_y + y, current, target)) + if err > 15: + out.append("...") + out.append("```") + await ctx.send('\n'.join(out)) + + +async def _preview(ctx, args, fetch): + async with ctx.typing(): + iter_args = iter(args) + a = next(iter_args, None) + if a and ',' in a: + x, y = a.split(',') + else: + x = a + y = next(iter_args, None) + + try: + x = int(x) + y = int(y) + except (ValueError, TypeError): + await ctx.send(ctx.s("canvas.invalid_input")) + return + + zoom = next(iter_args, 1) + try: + if type(zoom) is not int: + if zoom.startswith("#"): + zoom = zoom[1:] + zoom = int(zoom) + except ValueError: + zoom = 1 + + preview_img = await render.preview(x, y, zoom, fetch) + + with io.BytesIO() as bio: + preview_img.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "preview.png") + await ctx.send(file=f) + + +async def _quantize(ctx, args, canvas, palette): + if len(args) < 1: + return + if args[0] == "-f": + if len(args) < 3: + return + f = sql.guild_get_by_faction_name_or_alias(args[1]) + if not f: + await ctx.send(ctx.s("error.faction_not_found")) + return + name = args[2] + t = sql.template_get_by_name(f.id, name) + else: + name = args[0] + t = sql.template_get_by_name(ctx.guild.id, name) + + data = None + if t: + if t.canvas == canvas: + raise errors.IdempotentActionError + data = await http.get_template(t.url) + else: att = await utils.verify_attachment(ctx) if att: data = io.BytesIO() await att.save(data) - return data + + if data: + template, bad_pixels = await render.quantize(data, palette) + + with io.BytesIO() as bio: + template.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "template.png") + return await ctx.send(ctx.s("canvas.quantize").format(bad_pixels), file=f) def setup(bot): diff --git a/commands/configuration.py b/commands/configuration.py index 95ae6a0..61fe92b 100644 --- a/commands/configuration.py +++ b/commands/configuration.py @@ -1,4 +1,5 @@ import re + from discord import TextChannel from discord.ext import commands from discord.utils import get as dget @@ -16,7 +17,7 @@ def __init__(self, bot): @commands.guild_only() @commands.group(name="alertchannel", invoke_without_command=True) async def alertchannel(self, ctx): - channel = dget(ctx.guild.channels, id=sql.guild_get_by_id(ctx.guild.id)['alert_channel']) + channel = dget(ctx.guild.channels, id=sql.guild_get_by_id(ctx.guild.id).alert_channel) if channel: await ctx.send("Alert channel is currently set to {0}.".format(channel.mention)) else: @@ -28,7 +29,7 @@ async def alertchannel(self, ctx): async def alertchannel_set(self, ctx, channel: TextChannel): sql.guild_update(ctx.guild.id, alert_channel=channel.id) self.log.info("Alert channel for {0.name} set to {1.name} (GID:{0.id} CID:{1.name})".format(ctx.guild, channel)) - await ctx.send(ctx.get_str("configuration.alert_channel_set").format(channel.mention)) + await ctx.send(ctx.s("configuration.alert_channel_set").format(channel.mention)) @checks.admin_only() @commands.guild_only() @@ -36,7 +37,7 @@ async def alertchannel_set(self, ctx, channel: TextChannel): async def alertchannel_clear(self, ctx): sql.guild_update(ctx.guild.id, alert_channel=0) self.log.info("Alert channel for {0.name} cleared (GID:{0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.alert_channel_cleared")) + await ctx.send(ctx.s("configuration.alert_channel_cleared")) @checks.admin_only() @commands.guild_only() @@ -46,7 +47,7 @@ async def prefix(self, ctx, prefix): raise commands.BadArgument sql.guild_update(ctx.guild.id, prefix=prefix) self.log.info("Prefix for {0.name} set to {1} (GID: {0.id})".format(ctx.guild, prefix)) - await ctx.send(ctx.get_str("configuration.prefix_set").format(prefix)) + await ctx.send(ctx.s("configuration.prefix_set").format(prefix)) @checks.admin_only() @commands.guild_only() @@ -55,17 +56,19 @@ async def autoscan(self, ctx): if sql.guild_is_autoscan(ctx.guild.id): sql.guild_update(ctx.guild.id, autoscan=1) self.log.info("Autoscan enabled for {0.name} (GID: {0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.autoscan_enabled")) + await ctx.send(ctx.s("configuration.autoscan_enabled")) else: sql.guild_update(ctx.guild.id, autoscan=0) self.log.info("Autoscan disabled for {0.name} (GID: {0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.autoscan_disabled")) + await ctx.send(ctx.s("configuration.autoscan_disabled")) @checks.admin_only() @commands.guild_only() @commands.group(name="canvas", invoke_without_command=True) async def canvas(self, ctx): - await ctx.send(ctx.get_str("configuration.canvas_check").format(ctx.canvas_pretty, ctx.prefix)) + out = [ctx.s("configuration.canvas_check_1").format(ctx.canvas_pretty), + ctx.s("configuration.canvas_check_2").format(ctx.prefix)] + await ctx.send('\n'.join(out)) @checks.admin_only() @commands.guild_only() @@ -73,7 +76,7 @@ async def canvas(self, ctx): async def canvas_pixelcanvas(self, ctx): sql.guild_update(ctx.guild.id, canvas="pixelcanvas") self.log.info("Default canvas for {0.name} set to pixelcanvas (GID:{0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.canvas_set").format("Pixelcanvas.io")) + await ctx.send(ctx.s("configuration.canvas_set").format("Pixelcanvas.io")) @checks.admin_only() @commands.guild_only() @@ -81,7 +84,7 @@ async def canvas_pixelcanvas(self, ctx): async def canvas_pixelzio(self, ctx): sql.guild_update(ctx.guild.id, canvas="pixelzio") self.log.info("Default canvas for {0.name} set to pixelzio (GID:{0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.canvas_set").format("Pixelz.io")) + await ctx.send(ctx.s("configuration.canvas_set").format("Pixelz.io")) @checks.admin_only() @commands.guild_only() @@ -89,7 +92,7 @@ async def canvas_pixelzio(self, ctx): async def canvas_pixelzone(self, ctx): sql.guild_update(ctx.guild.id, canvas="pixelzone") self.log.info("Default canvas for {0.name} set to pixelzone (GID:{0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.canvas_set").format("Pixelzone.io")) + await ctx.send(ctx.s("configuration.canvas_set").format("Pixelzone.io")) @checks.admin_only() @commands.guild_only() @@ -97,31 +100,39 @@ async def canvas_pixelzone(self, ctx): async def canvas_pxlsspace(self, ctx): sql.guild_update(ctx.guild.id, canvas="pxlsspace") self.log.info("Default canvas for {0.name} set to pxlsspace (GID:{0.id})".format(ctx.guild)) - await ctx.send(ctx.get_str("configuration.canvas_set").format("Pxls.space")) + await ctx.send(ctx.s("configuration.canvas_set").format("Pxls.space")) @checks.admin_only() @commands.guild_only() @commands.command() async def language(self, ctx, option=None): if not option: - lang_list = "" - for i, (code, name) in enumerate(ctx.langs.items(), 1): - lang_list = lang_list + "{0} - {1}".format(code, name) - if i < len(ctx.langs): - lang_list = lang_list + "\n" - await ctx.send(ctx.get_str("configuration.language_check").format(lang_list, ctx.lang)) + out = [ + ctx.s("configuration.language_check_1").format(ctx.langs[ctx.lang]), + ctx.s("configuration.language_check_2"), + "```" + ] + for code, name in ctx.langs.items(): + out.append("{0} - {1}".format(code, name)) + out.append("```") + await ctx.send('\n'.join(out)) return if option.lower() not in ctx.langs: return sql.guild_update(ctx.guild.id, language=option.lower()) self.log.info("Language for {0.name} set to {1} (GID:{0.id})".format(ctx.guild, option.lower())) - await ctx.send(ctx.get_str("configuration.language_set").format(ctx.langs[option.lower()])) + await ctx.send(ctx.s("configuration.language_set").format(ctx.langs[option.lower()])) @checks.admin_only() @commands.guild_only() @commands.group(name="role", invoke_without_command=True) async def role(self, ctx): - await ctx.send(ctx.get_str("configuration.role_list").format(ctx.prefix)) + roles = ["botadmin", "templateadder", "templateadmin"] + out = [ctx.s("configuration.role_list_header"), "```xl"] + max_len = max(map(lambda x: len(x[0]), roles)) + for r in roles: + out.append("{0:<{max_len} - {1}".format(r, ctx.s("configuration.role_list_" + r), max_len=max_len)) + await ctx.send('\n'.join(out)) @checks.admin_only() @commands.guild_only() @@ -129,9 +140,9 @@ async def role(self, ctx): async def role_botadmin(self, ctx): r = utils.get_botadmin_role(ctx) if r: - await ctx.send(ctx.get_str("configuration.role_bot_admin_check").format(r.name)) + await ctx.send(ctx.s("configuration.role_bot_admin_check").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_bot_admin_not_set")) + await ctx.send(ctx.s("configuration.role_bot_admin_not_set")) @checks.admin_only() @commands.guild_only() @@ -141,16 +152,16 @@ async def role_botadmin_set(self, ctx, role=None): r = dget(ctx.guild.role_hierarchy, id=int(m.group(1))) if m else dget(ctx.guild.role_hierarchy, name=role) if r: sql.guild_update(ctx.guild.id, bot_admin=r.id) - await ctx.send(ctx.get_str("configuration.role_bot_admin_set").format(r.name)) + await ctx.send(ctx.s("configuration.role_bot_admin_set").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_not_found")) + await ctx.send(ctx.s("configuration.role_not_found")) @checks.admin_only() @commands.guild_only() @role_botadmin.command(name="clear") async def role_botadmin_clear(self, ctx): sql.guild_update(ctx.guild.id, bot_admin=None) - await ctx.send(ctx.get_str("configuration.role_bot_admin_cleared")) + await ctx.send(ctx.s("configuration.role_bot_admin_cleared")) @checks.admin_only() @commands.guild_only() @@ -158,9 +169,9 @@ async def role_botadmin_clear(self, ctx): async def role_templateadder(self, ctx): r = utils.get_templateadder_role(ctx) if r: - await ctx.send(ctx.get_str("configuration.role_template_adder_check").format(r.name)) + await ctx.send(ctx.s("configuration.role_template_adder_check").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_template_adder_not_set")) + await ctx.send(ctx.s("configuration.role_template_adder_not_set")) @checks.admin_only() @commands.guild_only() @@ -170,16 +181,16 @@ async def role_templateadder_set(self, ctx, role=None): r = dget(ctx.guild.role_hierarchy, id=int(m.group(1))) if m else dget(ctx.guild.role_hierarchy, name=role) if r: sql.guild_update(ctx.guild.id, template_adder=r.id) - await ctx.send(ctx.get_str("configuration.role_template_adder_set").format(r.name)) + await ctx.send(ctx.s("configuration.role_template_adder_set").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_not_found")) + await ctx.send(ctx.s("configuration.role_not_found")) @checks.admin_only() @commands.guild_only() @role_templateadder.command(name="clear") async def role_templateadder_clear(self, ctx): sql.guild_update(ctx.guild.id, template_adder=None) - await ctx.send(ctx.get_str("configuration.role_template_adder_cleared")) + await ctx.send(ctx.s("configuration.role_template_adder_cleared")) @checks.admin_only() @commands.guild_only() @@ -187,9 +198,9 @@ async def role_templateadder_clear(self, ctx): async def role_templateadmin(self, ctx): r = utils.get_templateadmin_role(ctx) if r: - await ctx.send(ctx.get_str("configuration.role_template_admin_check").format(r.name)) + await ctx.send(ctx.s("configuration.role_template_admin_check").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_template_admin_not_set")) + await ctx.send(ctx.s("configuration.role_template_admin_not_set")) @checks.admin_only() @commands.guild_only() @@ -199,16 +210,16 @@ async def role_templateadmin_set(self, ctx, role=None): r = dget(ctx.guild.role_hierarchy, id=int(m.group(1))) if m else dget(ctx.guild.role_hierarchy, name=role) if r: sql.guild_update(ctx.guild.id, template_admin=r.id) - await ctx.send(ctx.get_str("configuration.role_template_admin_set").format(r.name)) + await ctx.send(ctx.s("configuration.role_template_admin_set").format(r.name)) else: - await ctx.send(ctx.get_str("configuration.role_not_found")) + await ctx.send(ctx.s("configuration.role_not_found")) @checks.admin_only() @commands.guild_only() @role_templateadmin.command(name="clear") async def role_templateadmin_clear(self, ctx): sql.guild_update(ctx.guild.id, template_admin=None) - await ctx.send(ctx.get_str("configuration.role_template_admin_cleared")) + await ctx.send(ctx.s("configuration.role_template_admin_cleared")) def setup(bot): diff --git a/commands/faction.py b/commands/faction.py new file mode 100644 index 0000000..2cb1b22 --- /dev/null +++ b/commands/faction.py @@ -0,0 +1,325 @@ +import io +import re + +import discord +from discord.ext import commands +from PIL import Image + +from objects import errors +from objects.logger import Log +from objects.config import Config +from utils import canvases, checks, sqlite as sql + +log = Log(__name__) +cfg = Config() + + +class Faction: + def __init__(self, bot): + self.bot = bot + + @checks.admin_only() + @commands.command(name="assemble") + async def assemble(self, ctx, name, alias=None): + if sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.already_faction")) + return + name = re.sub("[^\S ]+", "", name) + if not (6 <= len(name) <= 32): + raise errors.BadArgumentErrorWithMessage(ctx.s("faction.err.name_length")) + if sql.guild_get_by_faction_name(name): + await ctx.send(ctx.s("faction.name_already_exists")) + return + alias = re.sub("[^A-Za-z]+", "", alias).lower() + if alias and not (1 <= len(alias) <= 5): + raise errors.BadArgumentErrorWithMessage(ctx.s("faction.err.alias_length")) + if sql.guild_get_by_faction_alias(alias): + await ctx.send(ctx.s("faction.alias_already_exists")) + return + + sql.guild_faction_set(ctx.guild.id, name=name, alias=alias) + await ctx.send(ctx.s("faction.assembled").format(name)) + + @checks.admin_only() + @commands.command(name="disband") + async def disband(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + sql.guild_faction_disband(ctx.guild.id) + await ctx.send(ctx.s("faction.disbanded")) + + @checks.admin_only() + @commands.group(name="faction") + async def faction(self, ctx): + pass + + @faction.group(name="alias") + async def faction_alias(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "alias": + alias = sql.guild_get_by_id(ctx.guild.id).faction_alias + if alias: + await ctx.send(alias) + else: + await ctx.send(ctx.s("faction.no_alias")) + + @faction_alias.command(name="clear") + async def faction_alias_clear(self, ctx): + sql.guild_faction_clear(ctx.guild.id, alias=True) + await ctx.send(ctx.s("faction.clear_alias")) + + @faction_alias.command(name="set") + async def faction_alias_set(self, ctx, new_alias): + new_alias = re.sub("[^A-Za-z]+", "", new_alias).lower() + if not (1 <= len(new_alias) <= 5): + raise errors.BadArgumentErrorWithMessage(ctx.s("faction.err.alias_length")) + if sql.guild_get_by_faction_alias(new_alias): + await ctx.send(ctx.s("faction.alias_already_exists")) + return + sql.guild_faction_set(ctx.guild.id, alias=new_alias) + await ctx.send(ctx.s("faction.set_alias").format(new_alias)) + + @faction.group(name="color", aliases=["colour"]) + async def faction_color(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "color": + color = sql.guild_get_by_id(ctx.guild.id).faction_color + img = Image.new('RGB', (32, 32), color) + with io.BytesIO() as bio: + img.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "color.png") + await ctx.send('0x' + format(color, 'X'), file=f) + + @faction_color.command(name="clear") + async def faction_color_clear(self, ctx): + sql.guild_faction_clear(ctx.guild.id, color=True) + await ctx.send(ctx.s("faction.clear_color")) + + @faction_color.command(name="set") + async def faction_color_set(self, ctx, color: str): + try: + color = int(color, 0) + except ValueError: + await ctx.send(ctx.s("error.invalid_color")) + return + color = abs(color % 16777215) + sql.guild_faction_set(ctx.guild.id, color=color) + await ctx.send(ctx.s("faction.set_color")) + + @faction.group(name="desc") + async def faction_desc(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "desc": + desc = sql.guild_get_by_id(ctx.guild.id).faction_desc + if desc: + await ctx.send(desc) + else: + await ctx.send(ctx.s("faction.no_description")) + + @faction_desc.command(name="clear") + async def faction_desc_clear(self, ctx): + sql.guild_faction_clear(ctx.guild.id, desc=True) + await ctx.send(ctx.s("faction.clear_description")) + + @faction_desc.command(name="set") + async def faction_desc_set(self, ctx, *, description): + description = re.sub("[^\S ]+", "", description) + if not (len(description) <= 240): + raise errors.BadArgumentErrorWithMessage(ctx.s("faction.err.description_length")) + sql.guild_faction_set(ctx.guild.id, desc=description) + await ctx.send(ctx.s("faction.set_description")) + + @faction.group(name="emblem") + async def faction_emblem(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "emblem": + emblem = sql.guild_get_by_id(ctx.guild.id).faction_emblem + if emblem: + await ctx.send(emblem) + else: + await ctx.send(ctx.s("faction.no_emblem")) + + @faction_emblem.command(name="clear") + async def faction_emblem_clear(self, ctx): + sql.guild_faction_clear(ctx.guild.id, emblem=True) + await ctx.send(ctx.s("faction.clear_emblem")) + + @faction_emblem.command(name="set") + async def faction_emblem_set(self, ctx, emblem_url=None): + if emblem_url: + if not re.search('^(?:https?://)cdn\.discordapp\.com/', emblem_url): + raise errors.UrlError + elif len(ctx.message.attachments) > 0: + emblem_url = ctx.message.attachments[0].url + + if not emblem_url: + return + + sql.guild_faction_set(ctx.guild.id, emblem=emblem_url) + await ctx.send(ctx.s("faction.set_emblem")) + + @faction.group(name="invite") + async def faction_invite(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "invite": + invite = sql.guild_get_by_id(ctx.guild.id).faction_invite + if invite: + await ctx.send(invite) + else: + await ctx.send(ctx.s("faction.no_invite")) + + @faction_invite.command(name="clear") + async def faction_invite_clear(self, ctx): + sql.guild_faction_clear(ctx.guild.id, invite=True) + await ctx.send(ctx.s("faction.clear_invite")) + + @faction_invite.command(name="set") + async def faction_invite_set(self, ctx, url=None): + if url: + try: + invite = await self.bot.get_invite(url) + except discord.NotFound: + await ctx.send(ctx.s("faction.err.invalid_invite")) + return + if invite.guild.id != ctx.guild.id: + await ctx.send(ctx.s("faction.err.invite_not_this_guild")) + return + if not re.match(r'(?:https?://)discord\.gg/\w+', url): + url = "https://discord.gg/" + url + else: + if not ctx.channel.permissions_for(ctx.guild.me).create_instant_invite: + raise errors.NoSelfPermissionError + invite = await ctx.channel.create_invite(reason="Invite for faction info page") + url = invite.url + sql.guild_faction_set(ctx.guild.id, invite=url) + await ctx.send(ctx.s("faction.set_invite")) + + @faction.group(name="name") + async def faction_name(self, ctx): + if not sql.guild_is_faction(ctx.guild.id): + await ctx.send(ctx.s("faction.must_be_a_faction")) + return + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "name": + await ctx.send(sql.guild_get_by_id(ctx.guild.id).faction_name) + + @faction_name.command(name="set") + async def faction_name_set(self, ctx, new_name): + new_name = re.sub("[^\S ]+", "", new_name) + if not (6 <= len(new_name) <= 32): + raise errors.BadArgumentErrorWithMessage(ctx.s("faction.err.name_length")) + if sql.guild_get_by_faction_name(new_name): + await ctx.send(ctx.s("faction.name_already_exists")) + return + sql.guild_faction_set(ctx.guild.id, name=new_name) + await ctx.send(ctx.s("faction.set_name").format(new_name)) + + @commands.command(name="factionlist", aliases=['fl']) + async def factionlist(self, ctx, page: int = 1): + fs = [x for x in sql.guild_get_all_factions() if x.id not in sql.faction_hides_get_all(ctx.guild.id)] + if len(fs) > 0: + pages = 1 + len(fs) // 10 + page = min(max(page, 1), pages) + g = sql.guild_get_prefix_by_id(ctx.guild.id) + + msg = [ + "**{}** - {} {}/{}".format(ctx.s("faction.list_header"), ctx.s("bot.page"), page, pages), + "```xl", + "{0:<34} {1:<5}".format(ctx.s("bot.name"), ctx.s("bot.alias")) + ] + for f in fs[(page - 1) * 10:page * 10]: + alias = '"{}"'.format(f.faction_alias) if f.faction_alias else "" + msg.append("{0:<34} {1:<5}".format('"{}"'.format(f.faction_name), alias)) + msg.append("") + msg.append("// " + ctx.s("faction.faction_list_footer_1").format(g)) + msg.append("// " + ctx.s("faction.faction_list_footer_2").format(g)) + msg.append("```") + await ctx.send('\n'.join(msg)) + else: + await ctx.send(ctx.s("faction.no_factions")) + + @checks.admin_only() + @commands.command(name="hide") + async def hide(self, ctx, other): + other_fac = sql.guild_get_by_faction_name_or_alias(other) + if not other_fac: + await ctx.send(ctx.s("error.faction_not_found")) + return + sql.faction_hides_add(ctx.guild.id, other_fac.id) + await ctx.send(ctx.s("faction.set_hide").format(other_fac.faction_name)) + + @commands.command(name="factioninfo", aliases=['fi']) + async def factioninfo(self, ctx, other=None): + g = sql.guild_get_by_faction_name_or_alias(other) if other else sql.guild_get_by_id(ctx.guild.id) + if not g: + await ctx.send(ctx.s("error.faction_not_found")) + return + if not g.faction_name: + await ctx.send(ctx.s("faction.not_a_faction_yet")) + return + + templates = sql.template_get_all_public_by_guild_id(g.id) + canvas_list = set() + for t in templates: + canvas_list.add(t.canvas) + + canvases_pretty = [] + for c in canvas_list: + canvases_pretty.append(canvases.pretty_print[c]) + canvases_pretty.sort() + + e = discord.Embed(color=g.faction_color) \ + .add_field(name=ctx.s("bot.canvases"), value='\n'.join(canvases_pretty)) + if g.faction_invite: + icon_url = self.bot.get_guild(g.id).icon_url + e.set_author(name=g.faction_name, url=g.faction_invite, icon_url=icon_url) + else: + e.set_author(name=g.faction_name) + e.description = g.faction_desc if g.faction_desc else "" + if g.faction_alias: + e.description += "\n**{}:** {}".format(ctx.s("bot.alias"), g.faction_alias) + if g.faction_emblem: + e.set_thumbnail(url=g.faction_emblem) + + await ctx.send(embed=e) + + @checks.admin_only() + @commands.command(name="unhide") + async def unhide(self, ctx, other=None): + if other is None: + fs = [x for x in sql.guild_get_all_factions() if x.id not in sql.faction_hides_get_all(ctx.guild.id)] + if len(fs) == 0: + await ctx.send(ctx.s("faction.no_factions_hidden")) + return + out = [ + ctx.s("faction.currently_hidden"), + "```xl", + "{0:<34} {1:<5}".format(ctx.s("bot.name"), ctx.s("bot.alias")) + ] + for f in fs: + alias = '"{}"'.format(f.faction_alias) if f.faction_alias else "" + out.append("{0:<34} {1:<5}".format('"{}"'.format(f.faction_name), alias)) + out.append('```') + await ctx.send('\n'.join(out)) + return + other_fac = sql.guild_get_by_faction_name_or_alias(other) + if not other_fac: + await ctx.send(ctx.s("error.faction_not_found")) + return + sql.faction_hides_remove(ctx.guild.id, other_fac.id) + await ctx.send(ctx.s("faction.clear_hide").format(other_fac.faction_name)) + + +def setup(bot): + bot.add_cog(Faction(bot)) diff --git a/commands/general.py b/commands/general.py index 4f8d2ed..dea57e3 100644 --- a/commands/general.py +++ b/commands/general.py @@ -1,10 +1,13 @@ +from time import time + +import discord from discord.ext import commands from discord.ext.commands import BucketType -from time import time from objects.channel_logger import ChannelLogger from objects.config import Config from objects.logger import Log +from utils import http from utils.version import VERSION @@ -17,12 +20,65 @@ def __init__(self, bot): @commands.command() async def changelog(self, ctx): - await ctx.send("https://github.com/DiamondIceNS/StarlightGlimmer/releases") + data = await http.get_changelog(VERSION) + if not data: + await ctx.send(ctx.s("general.err.cannot_get_changelog")) + return + e = discord.Embed(title=data['name'], url=data['url'], color=13594340, description=data['body']) \ + .set_author(name=data['author']['login']) \ + .set_thumbnail(url=data['author']['avatar_url']) \ + .set_footer(text="Released " + data['published_at']) + await ctx.send(embed=e) @commands.command() async def github(self, ctx): await ctx.send("https://github.com/DiamondIceNS/StarlightGlimmer") + @commands.command(aliases=['h', 'commands']) + async def help(self, ctx, *cmds: str): + """Shows this message.""" + bot = ctx.bot + + def repl(obj): + return ctx.bot._mentions_transforms.get(obj.group(0), '') + + # help by itself just lists our own commands. + if len(cmds) == 0: + out = await self.bot.formatter.format_help_for(ctx, self.bot) + + elif len(cmds) == 1: + # try to see if it is a cog name + # name = _mention_pattern.sub(repl, commands[0]) # TODO: Filter @everyone here? + name = cmds[0] + command = bot.all_commands.get(name) + if command is None: + await ctx.send(bot.command_not_found.format(name)) + return + + out = await self.bot.formatter.format_help_for(ctx, command) + + else: + # name = _mention_pattern.sub(repl, commands[0]) + name = cmds[0] + command = bot.all_commands.get(name) + if command is None: + await ctx.send(bot.command_not_found.format(name)) + return + for key in cmds[1:]: + try: + # key = _mention_pattern.sub(repl, key) + command = command.all_commands.get(key) + if command is None: + await ctx.send(bot.command_not_found.format(key)) + return + except AttributeError: + await ctx.send(bot.command_has_no_subcommands.format(command, key)) + return + out = await bot.formatter.format_help_for(ctx, command) + + if out: + await ctx.send('\n'.join(out)) + @commands.command() async def invite(self, ctx): await ctx.send(self.cfg.invite) @@ -30,10 +86,10 @@ async def invite(self, ctx): @commands.command() async def ping(self, ctx): ping_start = time() - ping_msg = await ctx.send(ctx.get_str("bot.ping")) + ping_msg = await ctx.send(ctx.s("general.ping")) ping_time = time() - ping_start self.log.debug("(Ping:{0}ms)".format(int(ping_time * 1000))) - await ping_msg.edit(content=ctx.get_str("bot.pong").format(int(ping_time * 1000))) + await ping_msg.edit(content=ctx.s("general.pong").format(int(ping_time * 1000))) @commands.cooldown(1, 5, BucketType.guild) @commands.command() @@ -42,11 +98,11 @@ async def suggest(self, ctx, *, suggestion: str): await self.ch_log.log("New suggestion from **{0.name}#{0.discriminator}** (ID: `{0.id}`) in guild " "**{1.name}** (ID: `{1.id}`):".format(ctx.author, ctx.guild)) await self.ch_log.log("> `{}`".format(suggestion)) - await ctx.send(ctx.get_str("bot.suggest")) + await ctx.send(ctx.s(ctx.guild.id, "general.suggest")) @commands.command() async def version(self, ctx): - await ctx.send(ctx.get_str("bot.version").format(VERSION)) + await ctx.send(ctx.s("general.version").format(VERSION)) def setup(bot): diff --git a/commands/template.py b/commands/template.py index 29462cf..44a7205 100644 --- a/commands/template.py +++ b/commands/template.py @@ -1,18 +1,26 @@ -import aiohttp import asyncio import datetime -import discord import hashlib +import io +import itertools +import math import re import time -from PIL import Image +from typing import List + +import aiohttp +import discord +import numpy as np from discord.ext import commands from discord.ext.commands import BucketType +from PIL import Image, ImageChops -from objects.template import Template as Template_ -from utils import canvases, checks, colors, render, sqlite as sql, utils -from objects.logger import Log +from objects import errors +from objects.chunks import BigChunk, ChunkPzi, ChunkPz, PxlsBoard from objects.config import Config +from objects.dbtemplate import DbTemplate +from objects.logger import Log +from utils import canvases, checks, colors, http, render, sqlite as sql, utils log = Log(__name__) cfg = Config() @@ -25,27 +33,95 @@ def __init__(self, bot): @commands.guild_only() @commands.cooldown(1, 5, BucketType.guild) @commands.group(name='template', invoke_without_command=True, aliases=['t']) - async def template(self, ctx, page: int=1): - ts = sql.template_get_all_by_guild_id(ctx.guild.id) + async def template(self, ctx, *args): + gid = ctx.guild.id + iter_args = iter(args) + page = next(iter_args, 1) + if page == "-f": + faction = sql.guild_get_by_faction_name_or_alias(next(iter_args, None)) + if not faction: + raise errors.FactionNotFound + gid = faction.id + page = next(iter_args, 1) + try: + page = int(page) + except ValueError: + page = 1 + + ts = sql.template_get_all_by_guild_id(gid) + if len(ts) > 0: + pages = 1 + len(ts) // 10 + page = min(max(page, 1), pages) + w1 = max(max(map(lambda tx: len(tx.name), ts)) + 2, len(ctx.s("bot.name"))) + msg = [ + "**{}** - {} {}/{}".format(ctx.s("template.list_header"), ctx.s("bot.page"), page, pages), + "```xl", + "{0:<{w1}} {1:<14} {2}".format(ctx.s("bot.name"), + ctx.s("bot.canvas"), + ctx.s("bot.coordinates"), w1=w1) + ] + for t in ts[(page - 1) * 10:page * 10]: + coords = "({}, {})".format(t.x, t.y) + name = '"{}"'.format(t.name) + canvas_name = canvases.pretty_print[t.canvas] + msg.append("{0:<{w1}} {1:<14} {2}".format(name, canvas_name, coords, w1=w1)) + msg.append("") + msg.append("// " + ctx.s("template.list_footer_1").format(ctx.gprefix)) + msg.append("// " + ctx.s("template.list_footer_2").format(ctx.gprefix)) + msg.append("```") + await ctx.send('\n'.join(msg)) + else: + await ctx.send(ctx.s("template.err.no_templates")) + + @commands.guild_only() + @commands.cooldown(1, 5, BucketType.guild) + @template.command(name='all') + async def template_all(self, ctx, page: int = 1): + gs = [x for x in sql.guild_get_all_factions() if x.id not in sql.faction_hides_get_all(ctx.guild.id)] + ts = [x for x in sql.template_get_all() if x.gid in [y.id for y in gs]] + + def by_faction_name(template): + for g in gs: + if template.gid == g.id: + return g.faction_name + + ts = sorted(ts, key=by_faction_name) + ts_with_f = [] + for faction, ts2 in itertools.groupby(ts, key=by_faction_name): + for t in ts2: + ts_with_f.append((t, faction)) + if len(ts) > 0: pages = 1 + len(ts) // 10 page = min(max(page, 1), pages) - w1 = max(max(map(lambda tx: len(tx.name), ts)) + 2, len(ctx.get_str("template.info_name"))) + w1 = max(max(map(lambda tx: len(tx.name), ts)) + 2, len(ctx.s("bot.name"))) msg = [ - ctx.get_str("template.list_open").format(page, pages), - "{0:<{w1}} {1:<14} {2}\n".format(ctx.get_str("template.info_name"), - ctx.get_str("template.info_canvas"), - ctx.get_str("template.info_coords"), w1=w1) + "**{}** - {} {}/{}".format(ctx.s("template.list_header"), ctx.s("bot.page"), page, pages), + "```xl", + "{0:<{w1}} {1:<34} {2:<14} {3}".format(ctx.s("bot.name"), + ctx.s("bot.faction"), + ctx.s("bot.canvas"), + ctx.s("bot.coordinates"), w1=w1) ] - for t in ts[(page-1)*10:page*10]: + for t, f in ts_with_f[(page - 1) * 10:page * 10]: coords = "({}, {})".format(t.x, t.y) + faction = '"{}"'.format(f) name = '"{}"'.format(t.name) canvas_name = canvases.pretty_print[t.canvas] +<<<<<<< HEAD msg.append("{0:<{w1}} {1:<14} {2}\n".format(name, canvas_name, coords, w1=w1)) msg.append(ctx.get_str("template.list_close").format(ctx.prefix)) await ctx.send(''.join(msg)) +======= + msg.append("{0:<{w1}} {1:<34} {2:<14} {3}".format(name, faction, canvas_name, coords, w1=w1)) + msg.append("") + msg.append("// " + ctx.s("template.list_all_footer_1").format(ctx.gprefix)) + msg.append("// " + ctx.s("template.list_all_footer_2").format(ctx.gprefix)) + msg.append("```") + await ctx.send('\n'.join(msg)) +>>>>>>> 1.6 else: - await ctx.send(ctx.get_str("template.list_no_templates")) + await ctx.send(ctx.s("template.err.no_public_templates")) @commands.guild_only() @commands.cooldown(1, 5, BucketType.guild) @@ -82,19 +158,123 @@ async def template_add_pixelzone(self, ctx, name: str, x: int, y: int, url=None) async def template_add_pxlsspace(self, ctx, name: str, x: int, y: int, url=None): await self.add_template(ctx, "pxlsspace", name, x, y, url) + @commands.guild_only() + @commands.cooldown(1, 60, BucketType.guild) + @template.group(name='check') + async def template_check(self, ctx): + if not ctx.invoked_subcommand or ctx.invoked_subcommand.name == "check": + ts = sql.template_get_all_by_guild_id(ctx.guild.id) + + if len(ts) < 1: + ctx.command.parent.reset_cooldown(ctx) + raise errors.NoTemplatesError(False) + + msg = None + ts = sorted(ts, key=lambda tx: tx.name) + ts = sorted(ts, key=lambda tx: tx.canvas) + for canvas, canvas_ts in itertools.groupby(ts, lambda tx: tx.canvas): + ct = list(canvas_ts) + msg = await _check_canvas(ctx, ct, canvas, msg=msg) + + await msg.delete() + await _build_template_report(ctx, ts) + + @commands.guild_only() + @template_check.command(name='pixelcanvas', aliases=['pc']) + async def template_check_pixelcanvas(self, ctx): + ts = [x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.canvas == 'pixelcanvas'] + if len(ts) <= 0: + ctx.command.parent.reset_cooldown(ctx) + raise errors.NoTemplatesError(True) + ts = sorted(ts, key=lambda tx: tx.name) + msg = await _check_canvas(ctx, ts, "pixelcanvas") + await msg.delete() + await _build_template_report(ctx, ts) + + @commands.guild_only() + @template_check.command(name='pixelzio', aliases=['pzi']) + async def template_check_pixelzio(self, ctx): + ts = [x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.canvas == 'pixelzio'] + if len(ts) <= 0: + ctx.command.parent.reset_cooldown(ctx) + raise errors.NoTemplatesError(True) + ts = sorted(ts, key=lambda tx: tx.name) + msg = await _check_canvas(ctx, ts, "pixelzio") + await msg.delete() + await _build_template_report(ctx, ts) + + @commands.guild_only() + @template_check.command(name='pixelzone', aliases=['pz']) + async def template_check_pixelzone(self, ctx): + ts = [x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.canvas == 'pixelzone'] + if len(ts) <= 0: + ctx.command.parent.reset_cooldown(ctx) + raise errors.NoTemplatesError(True) + ts = sorted(ts, key=lambda tx: tx.name) + msg = await _check_canvas(ctx, ts, "pixelzone") + await msg.delete() + await _build_template_report(ctx, ts) + + @commands.guild_only() + @template_check.command(name='pxlsspace', aliases=['ps']) + async def template_check_pxlsspace(self, ctx): + ts = [x for x in sql.template_get_all_by_guild_id(ctx.guild.id) if x.canvas == 'pxlsspace'] + if len(ts) <= 0: + ctx.command.parent.reset_cooldown(ctx) + raise errors.NoTemplatesError(True) + ts = sorted(ts, key=lambda tx: tx.name) + msg = await _check_canvas(ctx, ts, "pxlsspace") + await msg.delete() + await _build_template_report(ctx, ts) + @commands.guild_only() @commands.cooldown(1, 5, BucketType.guild) @template.command(name='info') - async def template_info(self, ctx, name): - t = sql.template_get_by_name(ctx.guild.id, name) + async def template_info(self, ctx, *args): + gid = ctx.guild.id + iter_args = iter(args) + name = next(iter_args, 1) + image_only = False + if name == "-r": + image_only = True + name = next(iter_args, 1) + if name == "-f": + faction = sql.guild_get_by_faction_name_or_alias(next(iter_args, None)) + if not faction: + raise errors.FactionNotFound + gid = faction.id + name = next(iter_args, 1) + t = sql.template_get_by_name(gid, name) if not t: - await ctx.send(ctx.get_str("template.name_not_found").format(name)) + raise errors.TemplateNotFound + + if image_only: + zoom = next(iter_args, 1) + try: + if type(zoom) is not int: + if zoom.startswith("#"): + zoom = zoom[1:] + zoom = int(zoom) + except ValueError: + zoom = 1 + max_zoom = int(math.sqrt(4000000 // (t.width * t.height))) + zoom = max(1, min(zoom, max_zoom)) + + img = render.zoom(await http.get_template(t.url), zoom) + + with io.BytesIO() as bio: + img.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, t.name + ".png") + await ctx.send(file=f) return canvas_url = canvases.url_templates[t.canvas].format(*t.center()) canvas_name = canvases.pretty_print[t.canvas] coords = "({}, {})".format(t.x, t.y) - size = "{} x {}".format(t.width, t.height) + dimensions = "{} x {}".format(t.width, t.height) + size = t.size + visibility = ctx.s("bot.private") if bool(t.private) else ctx.s("bot.private") owner = self.bot.get_user(t.owner_id) added_by = owner.name + "#" + owner.discriminator date_added = datetime.date.fromtimestamp(t.date_created).strftime("%d %b, %Y") @@ -102,12 +282,14 @@ async def template_info(self, ctx, name): e = discord.Embed(title=t.name, url=canvas_url, color=13594340) \ .set_image(url=t.url) \ - .add_field(name=ctx.get_str("template.info_canvas"), value=canvas_name, inline=True) \ - .add_field(name=ctx.get_str("template.info_coords"), value=coords, inline=True) \ - .add_field(name=ctx.get_str("template.info_size"), value=size, inline=True) \ - .add_field(name=ctx.get_str("template.info_added_by"), value=added_by, inline=True) \ - .add_field(name=ctx.get_str("template.info_date_added"), value=date_added, inline=True) \ - .add_field(name=ctx.get_str("template.info_date_modified"), value=date_modified, inline=True) + .add_field(name=ctx.s("bot.canvas"), value=canvas_name, inline=True) \ + .add_field(name=ctx.s("bot.coordinates"), value=coords, inline=True) \ + .add_field(name=ctx.s("bot.dimensions"), value=dimensions, inline=True) \ + .add_field(name=ctx.s("bot.size"), value=size, inline=True) \ + .add_field(name=ctx.s("bot.visibility"), value=visibility, inline=True) \ + .add_field(name=ctx.s("bot.added_by"), value=added_by, inline=True) \ + .add_field(name=ctx.s("bot.date_added"), value=date_added, inline=True) \ + .add_field(name=ctx.s("bot.date_modified"), value=date_modified, inline=True) await ctx.send(embed=e) @commands.guild_only() @@ -117,21 +299,20 @@ async def template_info(self, ctx, name): async def template_remove(self, ctx, name): t = sql.template_get_by_name(ctx.guild.id, name) if not t: - await ctx.send(ctx.get_str("template.no_template_named").format(name)) - return + raise errors.TemplateNotFound if t.owner_id != ctx.author.id and not utils.is_template_admin(ctx) and not utils.is_admin(ctx): - await ctx.send(ctx.get_str("template.not_owner")) + await ctx.send(ctx.s("template.err.not_owner")) return sql.template_delete(t.gid, t.name) - await ctx.send(ctx.get_str("template.remove").format(name)) + await ctx.send(ctx.s("template.remove").format(name)) @staticmethod async def add_template(ctx, canvas, name, x, y, url): if len(name) > cfg.max_template_name_length: - await ctx.send(ctx.get_str("template.name_too_long").format(cfg.max_template_name_length)) + await ctx.send(ctx.s("template.err.name_too_long").format(cfg.max_template_name_length)) return if sql.template_count_by_guild_id(ctx.guild.id) >= cfg.max_templates_per_guild: - await ctx.send(ctx.get_str("template.max_templates")) + await ctx.send(ctx.s("template.max_templates")) return url = await Template.select_url(ctx, url) if url is None: @@ -145,34 +326,43 @@ async def add_template(ctx, canvas, name, x, y, url): if not chk or await Template.check_for_duplicates_by_md5(ctx, t) is False: return sql.template_update(t) - await ctx.send(ctx.get_str("template.updated").format(name)) + await ctx.send(ctx.s("template.updated").format(name)) return elif await Template.check_for_duplicates_by_md5(ctx, t) is False: return sql.template_add(t) - await ctx.send(ctx.get_str("template.added").format(name)) + await ctx.send(ctx.s("template.added").format(name)) @staticmethod async def build_template(ctx, name, x, y, url, canvas): try: - with await utils.get_template(url) as data: + with await http.get_template(url) as data: + size = await render.calculate_size(data) md5 = hashlib.md5(data.getvalue()).hexdigest() with Image.open(data).convert("RGBA") as tmp: w, h = tmp.size quantized = await Template.check_colors(tmp, colors.by_name[canvas]) if not quantized: - if not await utils.yes_no(ctx, ctx.get_str("template.not_quantized")): + if not await utils.yes_no(ctx, ctx.s("template.not_quantized")): return - new_msg = await render.quantize(ctx, data, colors.by_name[canvas]) + + template, bad_pixels = await canvas.quantize(data, colors.by_name[canvas]) + with io.BytesIO() as bio: + template.save(bio, format="PNG") + bio.seek(0) + f = discord.File(bio, "template.png") + new_msg = await ctx.send(ctx.s("canvas.quantize").format(bad_pixels), file=f) + url = new_msg.attachments[0].url - with await utils.get_template(url) as data2: + with await http.get_template(url) as data2: md5 = hashlib.md5(data2.getvalue()).hexdigest() created = int(time.time()) - return Template_(ctx.guild.id, name, url, canvas, x, y, w, h, created, created, md5, ctx.author.id) + return DbTemplate(ctx.guild.id, name, url, canvas, x, y, w, h, size, created, created, md5, + ctx.author.id) except aiohttp.client_exceptions.InvalidURL: - raise checks.UrlError + raise errors.UrlError except IOError: - raise checks.PilImageError + raise errors.PilImageError @staticmethod async def check_colors(img, palette): @@ -192,24 +382,26 @@ async def check_colors(img, palette): async def check_for_duplicates_by_md5(ctx, template): dups = sql.template_get_by_hash(ctx.guild.id, template.md5) if len(dups) > 0: - msg = [ctx.get_str("template.duplicate_list_open")] + msg = [ctx.s("template.duplicate_list_open"), + "```xl"] w = max(map(lambda tx: len(tx.name), dups)) + 2 for d in dups: name = '"{}"'.format(d.name) canvas_name = canvases.pretty_print[d.canvas] msg.append("{0:<{w}} {1:>15} ({2}, {3})\n".format(name, canvas_name, d.x, d.y, w=w)) - msg.append(ctx.get_str("template.duplicate_list_close")) - return await utils.yes_no(ctx, ''.join(msg)) + msg.append("```") + msg.append(ctx.s("template.duplicate_list_close")) + return await utils.yes_no(ctx, '\n'.join(msg)) @staticmethod async def check_for_duplicate_by_name(ctx, template): dup = sql.template_get_by_name(ctx.guild.id, template.name) if dup: if template.owner_id != ctx.author.id and not utils.is_admin(ctx): - await ctx.send(ctx.get_str("template.name_exists_no_permission")) + await ctx.send(ctx.s("template.err.name_exists")) return False print(dup.x) - q = ctx.get_str("template.name_exists_ask_replace")\ + q = ctx.s("template.name_exists_ask_replace") \ .format(dup.name, canvases.pretty_print[dup.canvas], dup.x, dup.y) return await utils.yes_no(ctx, q) @@ -218,10 +410,76 @@ async def select_url(ctx, input_url): if input_url: if re.search('^(?:https?://)cdn\.discordapp\.com/', input_url): return input_url - raise checks.UrlError + raise errors.UrlError if len(ctx.message.attachments) > 0: return ctx.message.attachments[0].url +async def _build_template_report(ctx, ts: List[DbTemplate]): + name = ctx.s("bot.name") + tot = ctx.s("bot.total") + err = ctx.s("bot.errors") + perc = ctx.s("bot.percent") + + w1 = max(max(map(lambda tx: len(tx.name), ts)) + 2, len(name)) + w2 = max(max(map(lambda tx: len(str(tx.height * tx.width)), ts)), len(tot)) + w3 = max(max(map(lambda tx: len(str(tx.errors)), ts)), len(err)) + w4 = max(len(perc), 6) + + out = [ + "**{}**".format(ctx.s("template.template_report_header")), + "```xl", + "{0:<{w1}} {1:>{w2}} {2:>{w3}} {3:>{w4}}".format(name, tot, err, perc, w1=w1, w2=w2, w3=w3, w4=w4) + ] + for t in ts: + tot = t.size + name = '"{}"'.format(t.name) + perc = "{:>6.2f}%".format(100 * (tot - t.errors) / tot) + out.append('{0:<{w1}} {1:>{w2}} {2:>{w3}} {3:>{w4}}' + .format(name, tot, t.errors, perc, w1=w1, w2=w2, w3=w3, w4=w4)) + out.append("```") + await ctx.send(content='\n'.join(out)) + + +async def _check_canvas(ctx, templates, canvas, msg=None): + chunk_classes = { + 'pixelcanvas': BigChunk, + 'pixelzio': ChunkPzi, + 'pixelzone': ChunkPz, + 'pxlsspace': PxlsBoard + } + + chunks = set() + for t in templates: + empty_bcs, shape = chunk_classes[canvas].get_intersecting(t.x, t.y, t.width, t.height) + chunks.update(empty_bcs) + + if msg is not None: + await msg.edit(content=ctx.s("template.fetching_data").format(canvases.pretty_print[canvas])) + else: + msg = await ctx.send(ctx.s("template.fetching_data").format(canvases.pretty_print[canvas])) + await http.fetch_chunks(chunks) + + await msg.edit(content=ctx.s("template.calculating")) + example_chunk = next(iter(chunks)) + for t in templates: + empty_bcs, shape = example_chunk.get_intersecting(t.x, t.y, t.width, t.height) + tmp = Image.new("RGBA", (example_chunk.width * shape[0], example_chunk.height * shape[1])) + for i, ch in enumerate(empty_bcs): + ch = next((x for x in chunks if x == ch)) + tmp.paste(ch.image, ((i % shape[0]) * ch.width, (i // shape[0]) * ch.height)) + + x, y = t.x - empty_bcs[0].p_x, t.y - empty_bcs[0].p_y + tmp = tmp.crop((x, y, x + t.width, y + t.height)) + template = Image.open(await http.get_template(t.url)).convert('RGBA') + alpha = Image.new('RGBA', template.size, (255, 255, 255, 0)) + template = Image.composite(template, alpha, template) + tmp = Image.composite(tmp, alpha, template) + tmp = ImageChops.difference(tmp.convert('RGB'), template.convert('RGB')) + t.errors = np.array(tmp).any(axis=-1).sum() + + return msg + + def setup(bot): bot.add_cog(Template(bot)) diff --git a/glimmer.py b/glimmer.py index 539407a..ad0927c 100644 --- a/glimmer.py +++ b/glimmer.py @@ -1,29 +1,34 @@ -import discord import traceback + +import discord from discord import TextChannel from discord.ext import commands -from objects.glimcontext import GlimContext -from utils import canvases, checks, sqlite as sql, utils +from objects import errors from objects.channel_logger import ChannelLogger from objects.config import Config +from objects.glimcontext import GlimContext from objects.help_formatter import GlimmerHelpFormatter from objects.logger import Log +from utils import canvases, http, render, sqlite as sql, utils from utils.version import VERSION -def get_prefix(bot, msg): - return sql.guild_get_prefix_by_id(msg.guild.id) +def get_prefix(bot_, msg: discord.Message): + return [sql.guild_get_prefix_by_id(msg.guild.id), bot_.user.mention + " "] \ + if msg.guild else [cfg.prefix, bot_.user.mention + " "] cfg = Config() log = Log(''.join(cfg.name.split())) bot = commands.Bot(command_prefix=get_prefix, formatter=GlimmerHelpFormatter()) +bot.remove_command('help') ch_log = ChannelLogger(bot) extensions = [ "commands.animotes", "commands.canvas", "commands.configuration", + "commands.faction", "commands.general", "commands.template", ] @@ -37,10 +42,16 @@ async def on_ready(): sql.version_init(VERSION) is_new_version = False else: - is_new_version = sql.version_get() != VERSION and sql.version_get() is not None + old_version = sql.version_get() + is_new_version = old_version != VERSION and old_version is not None if is_new_version: log.info("Database is a previous version. Updating...") sql.version_update(VERSION) + if old_version < 1.6 <= VERSION: + # Fix legacy templates not having a size + for t in sql.template_get_all(): + t.size = await render.calculate_size(await http.get_template(t.url)) + sql.template_update(t) log.info("Loading extensions...") for extension in extensions: @@ -52,16 +63,25 @@ async def on_ready(): log.info("Performing guilds check...") for g in bot.guilds: log.info("'{0.name}' (ID: {0.id})".format(g)) - row = sql.guild_get_by_id(g.id) - if row: - prefix = row['prefix'] if row['prefix'] else cfg.prefix - if g.name != row['name']: - await ch_log.log("Guild **{1}** is now known as **{0.name}** `(ID:{0.id})`".format(g, row['name'])) + db_g = sql.guild_get_by_id(g.id) + if db_g: + prefix = db_g.prefix if db_g.prefix else cfg.prefix + if g.name != db_g.name: + await ch_log.log("Guild **{1}** is now known as **{0.name}** `(ID:{0.id})`".format(g, db_g.name)) sql.guild_update(g.id, name=g.name) if is_new_version: - ch = next((x for x in g.channels if x.id == row['alert_channel']), None) + ch = next((x for x in g.channels if x.id == db_g.alert_channel), None) if ch: - await ch.send(GlimContext.get_str_from_guild(g, "bot.alert_update").format(VERSION, prefix)) + data = await http.get_changelog(VERSION) + if data: + e = discord.Embed(title=data['name'], url=data['url'], color=13594340, + description=data['body']) \ + .set_author(name=data['author']['login']) \ + .set_thumbnail(url=data['author']['avatar_url']) \ + .set_footer(text="Released " + data['published_at']) + await ch.send(GlimContext.get_from_guild(g, "bot.update").format(VERSION, prefix), embed=e) + else: + await ch.send(GlimContext.get_from_guild(g, "bot.update_no_changelog").format(VERSION, prefix)) log.info("- Sent update message") else: log.info("- Could not send update message: alert channel not found.") @@ -75,10 +95,10 @@ async def on_ready(): db_guilds = sql.guild_get_all() if len(bot.guilds) != len(db_guilds): for g in db_guilds: - if not any(x for x in bot.guilds if x.id == g['id']): - log.info("Kicked from guild '{0}' (ID: {1}) between sessions".format(g['name'], g['id'])) - await ch_log.log("Kicked from guild **{0}** (ID: `{1}`)".format(g['name'], g['id'])) - sql.guild_delete(g['id']) + if not any(x for x in bot.guilds if x.id == g.id): + log.info("Kicked from guild '{0}' (ID: {1}) between sessions".format(g.name, g.id)) + await ch_log.log("Kicked from guild **{0}** (ID: `{1}`)".format(g.name, g.id)) + sql.guild_delete(g.id) log.info('I am ready!') await ch_log.log("I am ready!") @@ -122,62 +142,72 @@ async def on_command_preprocess(ctx): async def on_command_error(ctx, error): # Command errors if isinstance(error, commands.BadArgument): - return - if isinstance(error, commands.CommandInvokeError): - if isinstance(error.original, discord.HTTPException) and error.original.code == 50013: - return - if isinstance(error, commands.CommandOnCooldown): - await ctx.send(ctx.get_str("bot.error.command_on_cooldown").format(error.retry_after)) - return - if isinstance(error, commands.CommandNotFound): - return - if isinstance(error, commands.MissingRequiredArgument): - return - if isinstance(error, commands.NoPrivateMessage): - await ctx.send(ctx.get_str("bot.error.no_private_message")) - return + pass + elif isinstance(error, commands.CommandInvokeError) \ + and isinstance(error.original, discord.HTTPException) \ + and error.original.code == 50013: + pass + elif isinstance(error, commands.CommandOnCooldown): + await ctx.send(ctx.s("error.cooldown").format(error.retry_after)) + elif isinstance(error, commands.CommandNotFound): + pass + elif isinstance(error, commands.MissingRequiredArgument): + pass + elif isinstance(error, commands.NoPrivateMessage): + await ctx.send(ctx.s("error.no_dm")) # Check errors - if isinstance(error, checks.IdempotentActionError): + elif isinstance(error, errors.BadArgumentErrorWithMessage): + await ctx.send(error.message) + elif isinstance(error, errors.FactionNotFound): + await ctx.send(ctx.s("error.faction_not_found")) + elif isinstance(error, errors.IdempotentActionError): try: f = discord.File("assets/y_tho.png", "y_tho.png") - await ctx.send(ctx.get_str("bot.why"), file=f) + await ctx.send(ctx.s("error.why"), file=f) except IOError: - await ctx.send(ctx.get_str("bot.why")) - return - if isinstance(error, checks.NoJpegsError): + await ctx.send(ctx.s("error.why")) + elif isinstance(error, errors.NoAttachmentError): + await ctx.send(ctx.s("error.no_attachment")) + elif isinstance(error, errors.NoJpegsError): try: f = discord.File("assets/disdain_for_jpegs.gif", "disdain_for_jpegs.gif") - await ctx.send(ctx.get_str("bot.error.jpeg"), file=f) + await ctx.send(ctx.s("error.jpeg"), file=f) except IOError: - await ctx.send(ctx.get_str("bot.error.jpeg")) - return - if isinstance(error, checks.NoPermissionError): - await ctx.send(ctx.get_str("bot.error.no_permission")) - return - if isinstance(error, checks.NotPngError): - await ctx.send(ctx.get_str("bot.error.no_png")) - return - if isinstance(error, checks.PilImageError): - await ctx.send(ctx.get_str("bot.error.pil_open_exception")) - return - if isinstance(error, checks.TemplateHttpError): - await ctx.send(ctx.get_str("bot.error.template_http_error")) - return - if isinstance(error, checks.UrlError): - await ctx.send(ctx.get_str("bot.error.url_error")) - return - if isinstance(error, checks.HttpPayloadError): - await ctx.send(ctx.get_str("bot.error.http_payload_error").format(canvases.pretty_print[error.canvas])) - return + await ctx.send(ctx.s("error.jpeg")) + elif isinstance(error, errors.NoSelfPermissionError): + await ctx.send(ctx.s("error.no_self_permission")) + elif isinstance(error, errors.NoTemplatesError): + if error.is_canvas_specific: + await ctx.send(ctx.s("error.no_templates_for_canvas")) + else: + await ctx.send(ctx.s("error.no_templates")) + elif isinstance(error, errors.NoUserPermissionError): + await ctx.send(ctx.s("error.no_user_permission")) + elif isinstance(error, errors.NotPngError): + await ctx.send(ctx.s("error.not_png")) + elif isinstance(error, errors.PilImageError): + await ctx.send(ctx.s("error.bad_image")) + elif isinstance(error, errors.TemplateHttpError): + await ctx.send(ctx.s("error.cannot_fetch_template")) + elif isinstance(error, errors.TemplateNotFound): + await ctx.send(ctx.s("error.template_not_found")) + elif isinstance(error, errors.UrlError): + await ctx.send(ctx.s("error.non_discord_url")) + elif isinstance(error, errors.HttpCanvasError): + await ctx.send(ctx.s("error.http_canvas").format(canvases.pretty_print[error.canvas])) + elif isinstance(error, errors.HttpGeneralError): + await ctx.send(ctx.s("error.http")) # Uncaught error - name = ctx.command.qualified_name if ctx.command else "None" - await ch_log.log("An error occurred executing `{0}` in server **{1.name}** (ID: `{1.id}`):".format(name, ctx.guild)) - await ch_log.log("```{}```".format(error)) - log.error("An error occurred executing '{}': {}\n{}" - .format(name, error, ''.join(traceback.format_exception(None, error, error.__traceback__)))) - await ctx.send(ctx.get_str("bot.error.unhandled_command_error")) + else: + name = ctx.command.qualified_name if ctx.command else "None" + await ch_log.log( + "An error occurred executing `{0}` in server **{1.name}** (ID: `{1.id}`):".format(name, ctx.guild)) + await ch_log.log("```{}```".format(error)) + log.error("An error occurred executing '{}': {}\n{}" + .format(name, error, ''.join(traceback.format_exception(None, error, error.__traceback__)))) + await ctx.send(ctx.s("error.unknown")) @bot.event @@ -207,7 +237,7 @@ async def on_message(message): async def print_welcome_message(guild): - channels = [x for x in guild.channels if x.permissions_for(guild.me).send_messages and type(x) is TextChannel] + channels = (x for x in guild.channels if x.permissions_for(guild.me).send_messages and type(x) is TextChannel) c = next((x for x in channels if x.name == "general"), next(channels, None)) if c: await c.send("Hi! I'm {0}. For a full list of commands, pull up my help page with `{1}help`. " diff --git a/lang/en_US.py b/lang/en_US.py index f3bd91f..a70c772 100644 --- a/lang/en_US.py +++ b/lang/en_US.py @@ -1,113 +1,176 @@ STRINGS = { - # General messages - "bot.alert_update": "I have updated to version **{0}**! Check out the command help page for new commands with `{1}help`, or visit https://github.com/DiamondIceNS/StarlightGlimmer/releases for the full changelog.", - "bot.description": - """Hi! I'm {0}! I'm here to help coordinate pixel art on pixel-placing websites. - I've got features like canvas preview and template checking that are sure to be useful. - Let's get pixel painting!""", - "bot.discord_urls_only": "I can only accept Discord attachment URLs.", - "bot.help_ending_note": "Type '{0}{1} ' for more info on a command.", - "bot.ping": "Pinging...", - "bot.pong": "Pong! | **{0}ms**", - "bot.suggest": "Your suggestion has been sent. Thank you for your input!", - "bot.version": "My version number is **{0}**", - "bot.why": "But... why?", - "bot.yes_no": "\n `0` - No\n `1` - Yes", - "bot.yes_no_invalid": "That is not a valid option. Please try again.", - "bot.yes_no_timed_out": "Command timed out.", + # Global messages + "bot.added_by": "Added By", + "bot.alias": "Alias", + "bot.aliases": "Aliases", + "bot.canvas": "Canvas", + "bot.canvases": "Canvases", + "bot.coordinates": "Coordinates", + "bot.date_added": "Date Added", + "bot.date_modified": "Date Modified", + "bot.dimensions": "Dimensions", + "bot.errors": "Errors", + "bot.examples": "Examples", + "bot.faction": "Faction", + "bot.name": "Name", + "bot.no": "No", + "bot.or_all_caps": "OR", + "bot.page": "Page", + "bot.percent": "Percent", + "bot.private": "Private", + "bot.public": "Public", + "bot.size": "Size", + "bot.subcommands": "Subcommands", + "bot.total": "Total", + "bot.update": "I have updated to version **{0}**! Here's the changelog:", + "bot.update_no_changelog": "I have updated to version **{0}**! Visit https://github.com/DiamondIceNS/StarlightGlimmer/releases for the full changelog.", + "bot.usage": "Usage", + "bot.visibility": "Visibility", + "bot.yes": "Yes", - # Animotes messages - "animotes.guild_opt_in": "Emoji sharing has been **enabled** for this guild.", - "animotes.guild_opt_out": "Emoji sharing has been **disabled** for this guild.", - "animotes.member_opt_in": "You have successfully **opted-in** to emoji conversion.", - "animotes.member_opt_out": "You have successfully **opted-out** of emoji conversion.", - - # Canvas messages - "canvas.invalid_input": "Invalid input: does not match any template name or supported coordinates format.", - "canvas.repeat_not_found": "Could not find a valid command to repeat.", - - # Render messages - "render.diff": "{0}/{1} | {2} errors | {3:.2f}% complete", - "render.diff_bad_color": "{0}/{1} | {2} errors | {3} bad color | {4:.2f}% complete", - "render.large_template": "(Processing large template, this might take a few seconds...)", - "render.quantize": "Fixed {0} pixels.", + # Error messages + "error.bad_image": "An error occurred while attempting to open an image. Ensure that the supplied image is not corrupted.", + "error.bad_png": "This image seems to be corrupted. Try re-saving it with an image editor or using `{0}quantize`.", + "error.cannot_fetch_template": "Could not access template URL. (Was the original attachment deleted?)", + "error.cooldown": "That command is on cooldown. Try again in {0:.01f}s.", + "error.faction_not_found": "That faction could not be found.", + "error.http": "There was an error retrieving data from a remote location. Try again later.", + "error.http_canvas": "{0} seems to be having connection issues. Try again later.", + "error.invalid_color": "That is not a valid color.", + "error.invalid_option": "That is not a valid option. Please try again.", + "error.jpeg": "Seriously? A JPEG? Gross! Please create a PNG template instead.", + "error.no_attachment": "That command requires an attachment.", + "error.no_dm": "That command only works in guilds.", + "error.no_self_permission": "I do not have the permission to do that in this channel.", + "error.no_templates": "This guild currently has no templates.", + "error.no_templates_for_canvas": "This guild currently has no templates for that canvas.", + "error.no_user_permission": "You do not have permission to use that command.", + "error.not_png": "That command requires a PNG image.", + "error.non_discord_url": "I can only accept Discord attachment URLs.", + "error.template_not_found": "That template could not be found.", + "error.timed_out": "Command timed out.", + "error.unknown": "An unknown error occurred. The dev has been notified.", + "error.why": "But... why?", - # Template messages - "template.added": "Template '{0}' added!", - "template.duplicate_list_open": "The following templates already match this image:\n```xl\n", - "template.duplicate_list_close": "```\nCreate a new template anyway?", - "template.info_added_by": "Added By", - "template.info_date_added": "Date Added", - "template.info_date_modified": "Date Modified", - "template.info_canvas": "Canvas", - "template.info_coords": "Coords", - "template.info_name": "Name", - "template.info_size": "Size", - "template.list_close": "\n// Use '{0}template ' to see that page\n// Use '{0}template info ' to see more info on a template```", - "template.list_no_templates": "This guild currently has no templates.", - "template.list_open": "**Template List** - Page {0}/{1}\n```xl\n", - "template.max_templates": "This guild already has the maximum number of templates. Please remove a template before adding another.", - "template.name_exists_ask_replace": "A template with the name '{0}' already exists for {1} at ({2}, {3}). Replace it?", - "template.name_exists_no_permission": "A template with that name already exists. Please choose a different name.", - "template.name_not_found": "Could not find template with name `{0}`.", - "template.name_too_long": "That name is too long. Please use a name under {0} characters.", - "template.no_template_named": "There is no template named '{0}'.", - "template.not_owner": "You do not have permission to modify that template.", - "template.not_quantized": "This image contains colors that are not part of this canvas's palette. Would you like to quantize it?", - "template.remove": "Successfully removed '{0}'.", - "template.updated": "Template '{0}' updated!", + # Animotes command messages + "animotes.opt_in": "You have successfully **opted-in** to emoji conversion.", + "animotes.opt_out": "You have successfully **opted-out** of emoji conversion.", + # Canvas command messages + "canvas.diff": "{0}/{1} | {2} errors | {3} complete", + "canvas.diff_bad_color": "{0}/{1} | {2} errors | {bad} bad color | {3} complete", + "canvas.diff_error_list": "({}, {}) is {}, should be {}", + "canvas.invalid_input": "Invalid input: does not match any template name or supported coordinates format.", + "canvas.large_template": "(Processing large template, this might take a few seconds...)", + "canvas.quantize": "Fixed {0} pixels.", + "canvas.repeat_not_found": "Could not find a valid command to repeat.", - # Configuration messages + # Configuration command messages "configuration.alert_channel_cleared": "Alert channel has been cleared.", "configuration.alert_channel_set": "Alert channel has been set to {0}.", "configuration.autoscan_disabled": "Autoscan has been disabled.", "configuration.autoscan_enabled": "Autoscan has been enabled.", - "configuration.canvas_check": "This guild's default canvas is **{0}**.\n" - "To change the default canvas, run this command again with a supported canvas. (Use `{1}help canvas` to see a list.)", + "configuration.canvas_check_1": "This guild's default canvas is **{0}**.", + "configuration.canvas_check_2": "To change the default canvas, run this command again with a supported canvas. (Use `{1}help canvas` to see a list.)", "configuration.canvas_set": "Default canvas has been set to **{0}**.", - "configuration.language_check": "This guild's current language is **{1}**.\n" - "To set a new language, run this command again with one of the following options:\n" - "```{0}```", + "configuration.language_check_1": "This guild's current language is **{0}**.", + "configuration.language_check_2": "To set a new language, run this command again with one of the following options:", "configuration.language_set": "Language has been set to **English (US)**.", "configuration.prefix_set": "Prefix for this guild has been set to **{0}**.", - "configuration.role_list": "**Roles List**\n```xl\n" - "'botadmin' - Can do anything an Administrator can do\n" - "'templateadder' - Can add templates, and remove templates they added themself\n" - "'templateadmin' - Can add and remove any template\n" - "\n// Use '{0}role ' to view the current linked role.\n```", - "configuration.role_not_found": "That role could not be found.", - "configuration.role_bot_admin_check": "Bot admin privileges are currently assigned to `@{0}`.", - "configuration.role_bot_admin_cleared": "Bot admin privileges successfully cleared.", - "configuration.role_bot_admin_not_set": "Bot admin privileges have not been assigned to a role.", - "configuration.role_bot_admin_set": "Bot admin privileges assigned to role `@{0}`.", - "configuration.role_template_adder_check": "Template adder privileges are currently assigned to `@{0}`.", - "configuration.role_template_adder_cleared": "Template adder privileges successfully cleared.", - "configuration.role_template_adder_not_set": "Template adder privileges have not been assigned to a role.", - "configuration.role_template_adder_set": "Template adder privileges assigned to role `@{0}`.", - "configuration.role_template_admin_check": "Template admin privileges are currently assigned to `@{0}`.", - "configuration.role_template_admin_cleared": "Template admin privileges successfully cleared.", - "configuration.role_template_admin_not_set": "Template admin privileges have not been assigned to a role.", - "configuration.role_template_admin_set": "Template admin privileges assigned to role `@{0}`.", + "configuration.role_list_header": "Roles List", + "configuration.role_list_botadmin": "Can do anything an Administrator can do", + "configuration.role_list_footer": "Use '{0}role ' to view the current linked role.", + "configuration.role_list_templateadder": "Can add templates, and remove templates they added themself", + "configuration.role_list_templateadmin": "Can add and remove any template", + "configuration.role_not_found": "That role could not be found.", + "configuration.role_bot_admin_check": "Bot admin privileges are currently assigned to `@{0}`.", + "configuration.role_bot_admin_cleared": "Bot admin privileges successfully cleared.", + "configuration.role_bot_admin_not_set": "Bot admin privileges have not been assigned to a role.", + "configuration.role_bot_admin_set": "Bot admin privileges assigned to role `@{0}`.", + "configuration.role_template_adder_check": "Template adder privileges are currently assigned to `@{0}`.", + "configuration.role_template_adder_cleared": "Template adder privileges successfully cleared.", + "configuration.role_template_adder_not_set": "Template adder privileges have not been assigned to a role.", + "configuration.role_template_adder_set": "Template adder privileges assigned to role `@{0}`.", + "configuration.role_template_admin_check": "Template admin privileges are currently assigned to `@{0}`.", + "configuration.role_template_admin_cleared": "Template admin privileges successfully cleared.", + "configuration.role_template_admin_not_set": "Template admin privileges have not been assigned to a role.", + "configuration.role_template_admin_set": "Template admin privileges assigned to role `@{0}`.", - # Error messages - "bot.error.bad_png": "This image seems to be corrupted. Try re-saving it with an image editor or using `{0}quantize`.", - "bot.error.command_on_cooldown": "That command is on cooldown. Try again in {0:.01f}s.", - "bot.error.http_payload_error": "{0} seems to be having connection issues. Try again later.", - "bot.error.jpeg": "Seriously? A JPEG? Gross! Please create a PNG template instead.", - "bot.error.missing_attachment": "That command requires an attachment.", - "bot.error.no_permission": "You do not have permission to use that command.", - "bot.error.no_png": "That command requires a PNG image.", - "bot.error.no_private_message": "That command only works in guilds.", - "bot.error.pil_image_open_exception": "An error occurred while attempting to open an image. Ensure that the supplied image is not corrupted.", - "bot.error.template.http_error": "Could not access template URL. (Was the original attachment deleted?)", - "bot.error.unhandled_command_error": "An unknown error occurred. The dev has been notified.", - "bot.error.url_error": "That URL is invalid. I can only accept Discord attachment URLs.", + # Faction command messages + "faction.alias_already_exists": "A faction with that alias already exists.", + "faction.already_faction": "This guild is already a faction.", + "faction.assembled": "Faction `{}` assembled.", + "faction.clear_alias": "Faction alias cleared.", + "faction.clear_color": "Faction color cleared.", + "faction.clear_description": "Faction description cleared.", + "faction.clear_emblem": "Faction emblem cleared.", + "faction.clear_hide": "Unhid faction `{}`.", + "faction.clear_invite": "Faction invite cleared. NOTE: Invite link is still active and must be removed manually.", + "faction.currently_hidden": "The following factions are currently hidden:", + "faction.disbanded": "Faction successfully disbanded.", + "faction.err.alias_length": "Faction aliases must be between 1 and 5 characters.", + "faction.err.description_length": "Faction descriptions must be at most 240 characters.", + "faction.err.invalid_invite": "That is not a valid invite.", + "faction.err.invite_not_this_guild": "You must use an invite to this guild.", + "faction.err.name_length": "Faction names must be between 6 and 32 characters.", + "faction.faction_list_footer_1": "Use '{0}faction ' to see that page", + "faction.faction_list_footer_2": "Use '{0}faction info ' to see more info on a faction", + "faction.list_header": "Faction List", + "faction.must_be_a_faction": "This guild needs to become a faction to use that command.", + "faction.name_already_exists": "A faction with that name already exists.", + "faction.no_alias": "This faction does not have an alias.", + "faction.no_description": "This faction does not have a description.", + "faction.no_emblem": "This faction does not have an emblem.", + "faction.no_invite": "This faction has not set a public invite.", + "faction.no_factions": "There doesn't seem to be any guilds yet...", + "faction.no_factions_hidden": "This guild has not hidden any factions.", + "faction.not_a_faction_yet": "This guild has not created a faction yet.", + "faction.set_alias": "Faction alias set to `{}`.", + "faction.set_color": "Faction color set.", + "faction.set_description": "Faction description set.", + "faction.set_emblem": "Faction emblem set.", + "faction.set_invite": "Faction invite set.", + "faction.set_hide": "Hid faction `{}`.", + "faction.set_name": "Faction renamed to `{}`.", + + # General command messages + "general.err.cannot_get_changelog": "There was an error fetching the changelog. Visit https://github.com/DiamondIceNS/StarlightGlimmer/releases to see all releases.", + "general.help_command_list_header": "Command List", + "general.help_more_info": "Use `{}help ` to view more info about a specific command.", + "general.help_subcommand": "# Use '{}help {} (subcommand)' to view more info about a subcommand", + "general.ping": "Pinging...", + "general.pong": "Pong! | **{0}ms**", + "general.suggest": "Your suggestion has been sent. Thank you for your input!", + "general.version": "My version number is **{0}**", + + # Template command messages + "template.added": "Template '{0}' added!", + "template.calculating": "Calculating...", + "template.duplicate_list_open": "The following templates already match this image:", + "template.duplicate_list_close": "Create a new template anyway?", + "template.err.max_templates": "This guild already has the maximum number of templates. Please remove a template before adding another.", + "template.err.name_exists": "A template with that name already exists. Please choose a different name.", + "template.err.name_not_found": "Could not find template with name `{0}`.", + "template.err.name_too_long": "That name is too long. Please use a name under {0} characters.", + "template.err.no_public_templates": "There are currently no public templates.", + "template.err.not_owner": "You do not have permission to modify that template.", + "template.fetching_data": "Fetching data from {}...", + "template.list_all_footer_1": "Use '{0}template all ' to see that page", + "template.list_all_footer_2": "Use '{0}template info -f ' to see more info on a template", + "template.list_header": "Template List", + "template.list_footer_1": "Use '{0}template ' to see that page", + "template.list_footer_2": "Use '{0}template info ' to see more info on a template", + "template.name_exists_ask_replace": "A template with the name '{0}' already exists for {1} at ({2}, {3}). Replace it?", + "template.not_quantized": "This image contains colors that are not part of this canvas's palette. Would you like to quantize it?", + "template.remove": "Successfully removed '{0}'.", + "template.template_report_header": "Template Report", + "template.updated": "Template '{0}' updated!", # Command brief help "brief.alertchannel": "Set or clear the channel used for update alerts.", "brief.alertchannel.clear": "Clears the alert channel.", "brief.alertchannel.set": "Sets the alert channel.", + "brief.assemble": "Assemble this guild into a faction.", "brief.autoscan": "Toggles automatic preview and diff.", "brief.canvas": "Sets the default canvas website for this guild.", "brief.canvas.pixelcanvas": "Sets the default canvas to Pixelcanvas.io.", @@ -120,17 +183,39 @@ "brief.diff.pixelzio": "Creates a diff using Pixelz.io.", "brief.diff.pixelzone": "Creates a diff using Pixelzone.io.", "brief.diff.pxlsspace": "Creates a diff using Pxls.space", + "brief.disband": "Disband this guild's faction.", "brief.ditherchart": "Gets a chart of canvas colors dithered together.", "brief.ditherchart.pixelcanvas": "Gets a dither chart of Pixelcanvas.io colors.", "brief.ditherchart.pixelzio": "Gets a dither chart of Pixelz.io colors.", "brief.ditherchart.pixelzone": "Gets a dither chart of Pixelzone.io colors.", "brief.ditherchart.pxlsspace": "Gets a dither chart of Pxls.space colors.", + "brief.faction": "Manages this guild's faction.", + "brief.faction.name": "View or modify the name of this guild's faction.", + "brief.faction.name.set": "Set the name of this guild's faction.", + "brief.faction.alias": "View or modify the alias of this guild's faction.", + "brief.faction.alias.clear": "Clear the alias of this guild's faction.", + "brief.faction.alias.set": "Set the alias of this guild's faction.", + "brief.faction.invite": "View or modify the invite link.", + "brief.faction.invite.clear": "Clear the invite link.", + "brief.faction.invite.set": "Set the invite link.", + "brief.faction.desc": "View or modify the description.", + "brief.faction.desc.clear": "Clear the description.", + "brief.faction.desc.set": "Set the description.", + "brief.faction.emblem": "View or modify the emblem image.", + "brief.faction.emblem.clear": "Clear the emblem image.", + "brief.faction.emblem.set": "Set the emblem image.", + "brief.faction.color": "View or modify the color.", + "brief.faction.color.clear": "Clear the color.", + "brief.faction.color.set": "Set the color.", + "brief.factioninfo": "Get info about a faction.", + "brief.factionlist": "List all factions.", "brief.github": "Gets a link to my GitHub repository.", "brief.gridify": "Adds a grid to a template.", "brief.help": "Displays this message.", + "brief.hide": "Hide a faction from public lists.", + "brief.info": "Get info about a faction.", "brief.invite": "Gets my invite link.", "brief.language": "Sets my language.", - "brief.listemotes": "Lists all the animated emoji that I know about.", "brief.ping": "Pong!", "brief.prefix": "Sets my command prefix for this guild.", "brief.preview": "Previews the canvas at a given coordinate.", @@ -144,7 +229,6 @@ "brief.quantize.pixelzone": "Quantizes colors using the palette of Pixelzone.io.", "brief.quantize.pxlsspace": "Quantizes colors using the palette of Pxls.space.", "brief.register": "Opt-in to animated emoji replacement.", - "brief.registerguild": "Opt-in to emoji sharing for this guild.", "brief.repeat": "Repeats the last used canvas command.", "brief.role": "Assign bot privileges to a role.", "brief.role.botadmin": "Configure Bot Admin privileges.", @@ -163,274 +247,322 @@ "brief.template.add.pixelzio": "Adds a template for Pixelz.io.", "brief.template.add.pixelzone": "Adds a template for Pixelzone.io.", "brief.template.add.pxlsspace": "Adds a template for Pxls.space.", + "brief.template.all": "List all templates for all factions.", + "brief.template.check": "Check the completion status of all templates.", + "brief.template.check.pixelcanvas": "Check the completion status of all Pixelcanvas.io templates.", + "brief.template.check.pixelzio": "Check the completion status of all Pixelz.io templates.", + "brief.template.check.pixelzone": "Check the completion status of all Pixelzone.io templates.", + "brief.template.check.pxlsspace": "Check the completion status of all Pxls.space templates.", "brief.template.info": "Displays info about a template.", "brief.template.remove": "Removes a template.", + "brief.unhide": "Unhide a faction from public lists.", "brief.unregister": "Opt-out of animated emoji replacement.", - "brief.unregisterguild": "Opt-out of emoji sharing for this guild.", "brief.version": "Gets my version number.", # Command long help - "help.alertchannel": """If an alert channel is set, I will post a message in that channel any time my version number changes to alert you to updates.""", - "help.alertchannel.clear": """This effectively disables update alerts until a new channel is set.""", - "help.alertchannel.set": - """Use the #channel mention syntax with this command to ensure the correct channel is set. - - This command can only be used by members with the Administrator permission.""", + "help.alertchannel.set": "Use the #channel mention syntax with this command to ensure the correct channel is set.", + "help.assemble": "Faction names and aliases must be unique. Names must be between 6 and 32 characters, case sensitive. Aliases must be between 1 and 5 characters, case insensitive.", "help.autoscan": """If enabled, I will watch all messages for coordinates and automatically create previews and diffs according to these rules: - - Any message with coordinates in the form "@0, 0" will trigger a preview for the default canvas. + - Any message with coordinates in the form "@0, 0" will trigger a preview for the default canvas (see `{p}help canvas`) - Any message with a link to a supported canvas will trigger a preview for that canvas. - Any message with coordinates in the form "0, 0" with a PNG attached will trigger a diff for the default canvas. - - Previews take precedence over diffs - - See 'setdefaultcanvas' for more information about the default canvas. - - Only users with the Administrator role can use this command.""", - "help.canvas": - """The default canvas is the canvas that will be used for automatic previews or diffs triggered by autoscan. (See 'autoscan') - - Defaults to Pixelcanvas.io. - - This command can only be used by members with the Administrator permission.""", - "help.canvas.pixelcanvas": """This command can only be used by members with the Administrator permission.""", - "help.canvas.pixelzio": """This command can only be used by members with the Administrator permission.""", - "help.canvas.pixelzone": """This command can only be used by members with the Administrator permission.""", - "help.canvas.pxlsspace": """This command can only be used by members with the Administrator permission.""", - "help.changelog": None, + - Previews take precedence over diffs""", + "help.canvas": "Defaults to Pixelcanvas.io.", "help.diff": - """Takes either a template or an image attachment, compares it to the current state of the canvas, and calculates how complete it is. It will also generate an image showing you where the unfinished pixels are. - - If the image is smaller than 200x200, you can create a larger image with a zoom factor. (i.e. "0, 0 #4) You cannot zoom an image to be larger than 400x400. - - Attachments must be PNG format. - - NOTE: "Bad color" pixels are pixels that are not part of the canvas's palette. (See `quantize`) - - If autoscan is enabled, this happens automatically using the default canvas. (See 'autoscan' and 'setdefaultcanvas')""", - "help.diff.pixelcanvas": None, - "help.diff.pixelzio": None, - "help.diff.pixelzone": None, - "help.diff.pxlsspace": None, - "help.ditherchart": None, - "help.ditherchart.pixelcanvas": None, - "help.ditherchart.pixelzio": None, - "help.ditherchart.pixelzone": None, - "help.ditherchart.pxlsspace": None, - "help.github": None, - "help.gridify": "Takes either a template or an image attachment and creates a gridded version for an easier reference. Use the 'size' parameter to set how large the individual pixels should be. (Default 1) You cannot zoom an image to contain more than 4 million pixels.", - "help.help": None, - "help.invite": None, - "help.language": """Use this command with no arguments to see the current and available languages.""", - "help.listemotes": """See 'registerserver' for more information about emoji sharing.""", - "help.ping": None, - "help.prefix": - """Max length is 5 characters. You really shouldn't need more than 2. - - This command can only be used by members with the Administrator permission.""", - "help.preview": - """Given a coordinate pair or a URL, renders a live view of a canvas at those coordinates. - - You can create a zoomed-in preview by adding a zoom factor. (i.e. "0, 0 #4") Maximum zoom is 16. You can also create a zoomed-out preview by using a negative zoom. (i.e. "0,0 #-4) Minimum zoom is -8. - - If you do not specify a canvas to use, the default canvas will be used. - - If autoscan is enabled, this happens automatically using the default canvas. (See 'autoscan' and 'setdefaultcanvas')""", - "help.preview.pixelcanvas": None, - "help.preview.pixelzio": None, - "help.preview.pixelzone": None, - "help.preview.pxlsspace": None, + """Images must be in PNG format. + Error pixels will be marked in red. Pixels that do not match the canvas palette ('bad color') will be marked in blue (see `{p}help quantize`). + You cannot zoom an image to contain more than 4 million pixels. + Use the `-e` flag to print out the specific coordinates of the first 15 error pixels.""", + "help.faction.create": + """Factions must have unique names (6 to 32 chars, case sensitive) and, if at all, unique aliases (1 to 5 chars, case insensitive). + A guild can only have one faction at any given time.""", + "help.faction.hide": "You can still view info about hidden factions if you explicitly use their name or alias in commands with the `-f` paramater.", + "help.faction.name.set": "Faction names must be unique. Min 6 chars, max 32 chars. Case sensitive.", + "help.faction.alias.set": "Faction aliases must be unique. Min 1 char, max 32 chars. Case insensitive.", + "help.faction.desc.set": "Max 240 characters.", + "help.faction.emblem.set": "URLs must be Discord URLs.", + "help.faction.color.set": "Color must be a valid hexidecimal number. Default 0xCF6EE4.", + "help.gridify": "You cannot zoom an image to contain more than 4 million pixels.", + "help.prefix": "Max length is 5 characters. You really shouldn't need more than 2.", + "help.preview": "Maximum zoom is 16. Minimum zoom is -8.", "help.quantize": - """Takes either a template or an image attachment and converts its colors to the palette of a given canvas. - - This should primarily be used if the 'pcdiff' command is telling you your image has 'bad color' in it. Using this command to create templates from raw images is not suggested.""", - "help.quantize.pixelcanvas": None, - "help.quantize.pixelzio": None, - "help.quantize.pixelzone": None, - "help.quantize.pxlsspace": None, + """This should primarily be used if `{p}diff` is telling you your image has 'bad color' in it. + Using this command to create templates from raw images is not suggested.""", "help.register": - """If you opt-in with this command, I will watch for any time you try to use an animated emoji and replace your message with another that has the emoji in it. You only need to opt-in once for this to apply to all guilds. Use this command again to opt-out. - - If your guild has opted-in to emoji sharing, you can use emoji from any other guild that has also opted-in. (See 'registerguild') - - I can't use animated emoji from guilds I am not in, so I cannot use animated emoji from other guilds posted by Discord Nitro users or from Twitch-integrated guilds. - + """You only need to register once for this to apply to all guilds. This feature requires that I have the Manage Messages permission.""", - "help.registerguild": - """If opted-in, members of this guild will be able to use animated emoji from any other guild that has also opted-in. In return, animated emoji from this guild can also be used by any of those guilds. This is not required to use animated emoji from this guild. - - NOTE: Opting-in to emoji sharing will let other guilds see this guild's name and ID. If your guild is not a public guild, enabling this feature is not recommended. - - This command can only be used by members with the Manage Emojis permission.""", "help.repeat": "This command only applies to 'preview', 'diff', and their autoscan invocations. Only 50 messages back will be searched.", - "help.role": - """Admins can use this command to create roles in their guilds that grant users special privileges when using my commands. - - Use this command with no arguments to see which privilege settings are available. - - See the help page for any of the following subcommands for more info on what each privilege grants. - """, - "help.role.botadmin": "If a user has a role with this privilege bound to it, that user can use any command with no restrictions. They will have the same permissions as guild Administrators.", - "help.role.botadmin.clear": None, - "help.role.botadmin.set": None, - "help.role.templateadder": - """If a user has a role with this privilege bound to it, that user can add templates using the 'templates' command. They can also remove templates, but only if that user was the one who originally added it. - - NOTE: If this privilege is set to any role, all other members will lose the ability to add templates. If you want to allow any user to add templates, do not set this.""", - "help.role.templateadder.clear": None, - "help.role.templateadder.set": None, - "help.role.templateadmin": "If a user has a role with this privilege bound to it, that user can add and remove any template using the 'templates' command, regardless of ownership. This is useful if you want to grant members full control over templates, but not all bot functions.", - "help.role.templateadmin.clear": None, - "help.role.templateadmin.set": None, - "help.suggest": None, - "help.template": "Use this command with no arguments to view a list of all added templates.", + "help.role.botadmin": "If a user has a role with this privilege bound to it, that user can use any of my commands with no restrictions. They will have the same permissions as guild Administrators.", + "help.role.templateadder": "If this privilege is bound to a role, all regular members will lose the ability to modify templates unless they have that role.", + "help.role.templateadmin": "If a user has a role with this privilege bound to it, that user can add and remove any template using the 'templates' command, regardless of ownership.", "help.template.add": - """This command can accept either a direct file attachment or a Discord attachment URL. Template must be in PNG format and must already be quantized to the palette of the canvas it belongs to. If the image is not quantized, the command will offer to quantize it for you. A guild can have up to 25 templates at any time. - - Only one template can be added with any given name (max 32 chars). If you add a second template with the same name, it will overwrite the first template. You can only overwrite your own templates, unless you are a Template Admin, Bot Admin, or have the Administrator permission (see 'role'). - - By default, everyone can use this command. If the Template Adder privilege is bound to any role, only users who are Template Adders and above can use this command (see 'role'). - - A template is stored as the URL of an attachment. If the message that uploaded that attachment is deleted, the template that references it will break. It is recommended that you save backup copies of templates to your computer just in case.""", - "help.template.add.pixelcanvas": None, - "help.template.add.pixelzio": None, - "help.template.add.pixelzone": None, - "help.template.add.pxlsspace": None, - "help.template.info": None, - "help.template.remove": "This command can only be used if the template being removed was added by you, unless you are a Template Admin, Bot Admin, or have the Administrator permission (see 'role').", - "help.unregister": "See 'register'.", - "help.unregisterguild": - """See 'registerguild'. - - This command can only be used by members with the Manage Emojis permission.""", - "help.version": None, - - # Command names - "command.alertchannel": "alertchannel", - "command.alertchannel.clear": "clear", - "command.alertchannel.set": "set", - "command.autoscan": "autoscan", - "command.canvas": "canvas", - "command.canvas.pixelcanvas": "pixelcanvas", - "command.canvas.pixelzio": "pixelzio", - "command.canvas.pixelzone": "pixelzone", - "command.canvas.pxlsspace": "pxlsspace", - "command.changelog": "changelog", - "command.diff": "diff", - "command.diff.pixelcanvas": "pixelcanvas", - "command.diff.pixelzio": "pixelzio", - "command.diff.pixelzone": "pixelzone", - "command.diff.pxlsspace": "pxlsspace", - "command.ditherchart": "ditherchart", - "command.ditherchart.pixelcanvas": "pixelcanvas", - "command.ditherchart.pixelzio": "pixelzio", - "command.ditherchart.pixelzone": "pixelzone", - "command.ditherchart.pxlsspace": "pxlsspace", - "command.github": "github", - "command.gridify": "gridify", - "command.help": "help", - "command.invite": "invite", - "command.language": "language", - "command.listemotes": "listemotes", - "command.ping": "ping", - "command.prefix": "prefix", - "command.preview": "preview", - "command.preview.pixelcanvas": "pixelcanvas", - "command.preview.pixelzio": "pixelzio", - "command.preview.pixelzone": "pixelzone", - "command.preview.pxlsspace": "pxlsspace", - "command.quantize": "quantize", - "command.quantize.pixelcanvas": "pixelcanvas", - "command.quantize.pixelzio": "pixelzio", - "command.quantize.pixelzone": "pixelzone", - "command.quantize.pxlsspace": "pxlsspace", - "command.register": "register", - "command.registerguild": "registerguild", - "command.repeat": "repeat", - "command.role": "role", - "command.role.botadmin": "botadmin", - "command.role.botadmin.clear": "clear", - "command.role.botadmin.set": "set", - "command.role.templateadder": "templateadder", - "command.role.templateadder.clear": "clear", - "command.role.templateadder.set": "set", - "command.role.templateadmin": "templateadmin", - "command.role.templateadmin.clear": "clear", - "command.role.templateadmin.set": "set", - "command.suggest": "suggest", - "command.template": "template", - "command.template.add": "add", - "command.template.add.pixelcanvas": "pixelcanvas", - "command.template.add.pixelzio": "pixelzio", - "command.template.add.pixelzone": "pixelzone", - "command.template.add.pxlsspace": "pxlsspace", - "command.template.info": "info", - "command.template.remove": "remove", - "command.unregister": "unregister", - "command.unregisterguild": "unregisterguild", - "command.version": "version", + """Image must be in PNG format. If the image is not quantized to the target canvas's palette, I will offer to quantize it for you. + A guild can have up to 25 templates at any time. + Templates must have unique names (max 32 chars, case sensitive). If you attempt to add a new template with the same name as an existing one, it will be replaced if you have permission to remove the old one (see `{p}help remove`). + I only store URLs to templates. If the message that originally uploaded a template is deleted, its URL will break and the template will be lost. Save backups to your computer just in case.""", + "help.template.info": "Use the `-r` flag to return just the raw image without extra info. You can also add a zoom factor when using this option.", + "help.template.remove": "This command can only be used if the template being removed was added by you, unless you are a Template Admin, Bot Admin, or have the Administrator permission (see 'role').", + "help.unregister": "You only need to unregister once for this to apply to all guilds.", # Command signatures - "signature.alertchannel": "alertchannel (subcommand)", - "signature.alertchannel.clear": "alertchannel clear", - "signature.alertchannel.set": "alertchannel set ", - "signature.autoscan": "autoscan", - "signature.canvas": "canvas (canvas)", - "signature.canvas.pixelcanvas": "canvas [pixelcanvas|pc]", - "signature.canvas.pixelzio": "canvas [pixelzio|pzi]", - "signature.canvas.pixelzone": "canvas [pixelzone|pz]", - "signature.canvas.pxlsspace": "canvas [pxlsspace|ps]", - "signature.changelog": "changelog", - "signature.diff": "[diff|d] (template) (zoom) --OR-- [diff|d] (canvas) (zoom)", - "signature.diff.pixelcanvas": "[diff|d] [pixelcanvas|pc] (zoom)", - "signature.diff.pixelzio": "[diff|d] [pixelzio|pzi] (zoom)", - "signature.diff.pixelzone": "[diff|d] [pixelzone|ps] (zoom)", - "signature.diff.pxlsspace": "[diff|d] [pxlsspace|ps] (zoom)", - "signature.ditherchart": "ditherchart ", - "signature.ditherchart.pixelcanvas": "ditherchart [pixelcanvas|pc]", - "signature.ditherchart.pixelzio": "ditherchart [pixelzio|pzi]", - "signature.ditherchart.pixelzone": "ditherchart [pixelzone|pz]", - "signature.ditherchart.pxlsspace": "ditherchart [pxlsspace|ps]", - "signature.github": "github", - "signature.gridify": "gridify (template) (size)", - "signature.help": "help", - "signature.invite": "invite", - "signature.language": "language (code)", - "signature.listemotes": "listemotes", - "signature.ping": "ping", - "signature.prefix": "prefix ", - "signature.preview": "[preview|p] (canvas) (zoom)", - "signature.preview.pixelcanvas": "[preview|p] [pixelcanvas|pc] (zoom)", - "signature.preview.pixelzio": "[preview|p] pixelzio (zoom)", - "signature.preview.pixelzone": "[preview|p] pixelzone (zoom)", - "signature.preview.pxlsspace": "[preview|p] pxlsspace (zoom)", - "signature.quantize": "[quantize|q] (canvas)", - "signature.quantize.pixelcanvas": "[quantize|q] [pixelcanvas|pc] (template)", - "signature.quantize.pixelzio": "[quantize|q] [pixelzio|pzi] (template)", - "signature.quantize.pixelzone": "[quantize|q] [pixelzone|pz] (template)", - "signature.quantize.pxlsspace": "[quantize|q] [pxlsspace|ps] (template)", - "signature.register": "register", - "signature.registerguild": "registerguild", - "signature.repeat": "[repeat|r]", - "signature.role": "role (role)", - "signature.role.botadmin": "role botadmin (set|clear)", - "signature.role.botadmin.clear": "role botadmin clear", - "signature.role.botadmin.set": "role botadmin set ", - "signature.role.templateadder": "role templateadder (set|clear)", - "signature.role.templateadder.clear": "role templateadder clear", - "signature.role.templateadder.set": "role templateadder set ", - "signature.role.templateadmin": "role templateadmin (set|clear)", - "signature.role.templateadmin.clear": "role templateadmin clear", - "signature.role.templateadmin.set": "role templateadmin set ", - "signature.suggest": "suggest ", - "signature.template": "[template|t] (subcommand)", - "signature.template.add": "[template|t] add (canvas) (url)", - "signature.template.add.pixelcanvas": "[template|t] add [pixelcanvas|pc] (url)", - "signature.template.add.pixelzio": "[template|t] add [pixelzio|pzi] (url)", - "signature.template.add.pixelzone": "[template|t] add [pixelzone|pz] (url)", - "signature.template.add.pxlsspace": "[template|t] add [pxlsspace|ps] (url)", - "signature.template.info": "[template|t] info", - "signature.template.remove": "[template|t] remove", - "signature.unregister": "unregister", - "signature.unregisterguild": "unregisterguild", - "signature.version": "version", + "signature.alertchannel": "(subcommand)", + "signature.alertchannel.set": "", + "signature.assemble": " (alias)", + "signature.canvas": "(subcommand)", + "signature.diff": ["(subcommand) (-e) (zoom)", "(-e) (-f faction)