├── .gitignore ├── CONTRIBUTING.md ├── INSTALLATION.md ├── LICENSE.header ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── emote_collector ├── __init__.py ├── __main__.py ├── backend_creator.py ├── data │ ├── avatar.png │ ├── bingo │ │ ├── DejaVuSans.ttf │ │ └── bingo_board_base.png │ ├── config.example.py │ ├── decay_test.sql │ ├── discord-emoji-shortcodes.json │ ├── e0-final-emojis.json │ ├── memes.example.py │ └── short-license.txt ├── extensions │ ├── api.py │ ├── bingo │ │ ├── commands.py │ │ ├── db.py │ │ └── errors.py │ ├── db.py │ ├── emote.py │ ├── file_upload_hook.py │ ├── gimme.py │ ├── locale.py │ ├── logging.py │ ├── meme.py │ ├── meta.py │ └── stats.py ├── locale │ ├── POTFILES.in │ ├── de_DE │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── de_DE_rude │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── en_US_rude │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── es_ES │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── fr_FR │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── fr_FR_rude │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── hu_HU │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── messages.pot │ ├── pl_PL │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── pl_PL_rude │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── tr_TR │ │ └── LC_MESSAGES │ │ │ ├── emote_collector.mo │ │ │ └── emote_collector.po │ ├── update-locale.sh │ └── 😃 │ │ └── LC_MESSAGES │ │ ├── emote_collector.mo │ │ └── emote_collector.po ├── sql │ ├── api.sql │ ├── bingo.sql │ ├── emotes.sql │ ├── locale.sql │ └── schema.sql └── utils │ ├── __init__.py │ ├── bingo │ ├── __init__.py │ ├── __main__.py │ ├── board.py │ └── tests.py │ ├── checks.py │ ├── context.py │ ├── converter.py │ ├── custom_send.py │ ├── custom_typing.py │ ├── emote.py │ ├── errors.py │ ├── i18n.py │ ├── image.py │ ├── lexer.py │ ├── misc.py │ ├── paginator.py │ └── proxy.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .venv/ 3 | 4 | .mypy_cache/ 5 | __pycache__/ 6 | *.egg-info/ 7 | *.py[cod] 8 | *.swp 9 | 10 | emote_collector/data/config.py 11 | emote_collector/data/memes.py 12 | 13 | *.png 14 | *.gif 15 | *.webp 16 | !emote_collector/data/bingo/bingo_board_base.png 17 | 18 | dist/ 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Emote Collector 2 | 3 | 1. Use tabs for indentation and limit your line length to 120 columns (inclusive, assuming 4-column-sized tabs). 4 | 2. If you *can* test your code, please do so [using your own instance of the bot](INSTALLATION.md), 5 | but since installation is so hard, just let me know in the pull request if you haven't tested your changes 6 | and I'll do it for you. 7 | 3. Make sure to add docstrings to your functions, and comment them as necessary. 8 | 4. New extensions go in [emote_collector/extensions/](emote_collector/extensions/). 9 | New utilities should go in the appropriate file in [emote_collector/utils/](emote_collector/utils/) 10 | or [emote_collector/utils/misc.py](emote_collector/utils/misc.py). 11 | 12 | ## New database cogs 13 | 14 | If a new cog requires access to the database, it should be split into three files: 15 | a database abstraction cog, a discord.py commands cog, 16 | and a [jinja2](https://palletsprojects.com/p/jinja/) template file containing SQL query definitions. 17 | 18 | Add its schema definitions (if any) to [emote_collector/sql/schema.sql](emote_collector/sql/schema.sql). 19 | 20 | Add its database abstraction cog to emote_collector/extensions/extname/db.py 21 | and queries to a new file in the [sql/](emote_collector/sql/) directory, named after the 22 | extension that uses it (e.g. extensions/bingo/db.py → sql/bingo.sql). 23 | 24 | Finally, the commands cog should use the database cog in the following way: 25 | 26 | ```py 27 | from ...utils.proxy import ObjectProxy 28 | 29 | class Xyz: 30 | def __init__(self, bot): 31 | self.bot = bot 32 | self.db = ObjectProxy(lambda: bot.cogs['XyzDatabase']) 33 | self.queries = bot.queries('xyz.sql') # see below 34 | ``` 35 | 36 | The ObjectProxy ensures that the Xyz cog has an up to date reference to XyzDatabase even if XyzDatabase is reloaded. 37 | 38 | Your SQL query file should use the following format: 39 | 40 | ``` 41 | -- :macro query1_name() 42 | -- params: parameter 1 description, parameter 2 description, ... 43 | SELECT whatever 44 | FROM tab 45 | WHERE example_code = true 46 | -- :endmacro 47 | 48 | -- :macro query2_name() 49 | ... 50 | -- :endmacro 51 | ``` 52 | 53 | If you need conditional inclusion, use `varargs` in the macro, like so: 54 | 55 | ``` 56 | -- :macro get_bingo_board() 57 | -- params: user_id 58 | -- optional varargs: with_categories, ... 59 | SELECT 60 | value 61 | -- :if 'with_categories' in varargs 62 | , pos, category 63 | -- :endif 64 | FROM 65 | bingo_boards 66 | -- :if 'with_categories' in varargs 67 | INNER JOIN bingo_board_categories USING (user_id) 68 | INNER JOIN bingo_categories USING (category_id) 69 | -- :endif 70 | WHERE user_id = $1 71 | -- :endmacro 72 | ``` 73 | 74 | You may also use any jinja2 features, including the normal `{% tag %}` and `{{ }}` syntax. 75 | 76 | The database abstraction cog should use `emote_collector.utils.connection` and `emote_collector.utils.optional_connection` 77 | like so: 78 | 79 | ```py 80 | @optional_connection 81 | async def get_xyz(self, user_id): 82 | async with connection().transaction(): 83 | x1 = await connection().fetchval(self.queries.get_x1(), user_id) 84 | x2 = await connection().fetchrow(self.queries.get_x2('with_abc'), user_id) 85 | return await connection().fetchrow(self.queries.get_xyz(), user_id) 86 | ``` 87 | 88 | This ensures that a pool connection is always acquired before get_xyz is called, 89 | unless one has already been acquired in that Task. 90 | 91 | If the commands cog needs to call @optional_connection methods more than once, either decorate the command itself: 92 | 93 | ```py 94 | @commands.command(...) 95 | @optional_connection 96 | async def get_xyz(self, context): 97 | ... 98 | ``` 99 | 100 | Or acquire the connection manually (in case there are other HTTP calls or other expensive IO in the function that should not hold on to a connection): 101 | 102 | ```py 103 | @commands.command(...) 104 | async def get_xyz(self, context): 105 | await do_expensive_io() 106 | async with self.bot.pool.acquire() as conn: 107 | connection.set(conn) 108 | await self.db.get_xyz(context.author.id) 109 | abc = await self.db.get_abc(context.author.id) 110 | await context.send(abc) # note that this is done *after* the pool is released 111 | ``` 112 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Installing Emote Collector 2 | 3 | I can think of two reasons you'd want to install this bot: 4 | 5 | 1. You have to test some changes you made. 6 | 2. You want to run a copy of the bot for yourself. 7 | 8 | If you do 2), please rename the bot and change the icon. Also, please do not run this bot for public use. I implore you to enable the "private bot" option in the developer panel. 9 | 10 | ## Prerequisites 11 | 12 | - libmagickwand-dev, for emote resizing via the Wand library 13 | - PostgreSQL 10+, for persistent data storage 14 | - Postgres database administration ability (backup, migration, role management etc) 15 | - At least 2 GB of RAM (image resizing is memory hungry) 16 | - Python 3.6+ 17 | - Two bot tokens (one for the bot itself, and one for the backend_creator script to create guilds) 18 | - One additional user account per 100 backend servers needed 19 | - All requirements specified in the setup.py file. 20 | 21 | ## How Emote Collector works 22 | 23 | To get an overview, it is important to first understand how the bot works. There are several components to this bot. In rough order of importance: 24 | 25 | - The backend servers, which are created by the backend_creator script. These are used to store user-generated emotes. 26 | - The backend_creator script itself, which creates backend servers owned by separate user accounts, one per 100 backend servers, since user accounts may only be in 100 servers maximum. 27 | - A server is deemed a backend server if it is owned by any of the configured backend user accounts. 28 | - Emote Collector needs administrator access in all of these servers to create emotes, delete messages, and re-create channels (see "gimme system" below). This permission is granted by the backend_creator script. 29 | - Administrator is not strictly needed, however, since the servers are only used by Emote Collector anyway and their entire purpose is to store emotes, there is no risk in granting administrator. 30 | - The cache synchronization system, which is used to allow the [Emote Collector website and API](https://github.com/EmoteCollector/EmoteCollector-website) 31 | to access the list of emote moderators (who are authorized to modify other users' emotes) which is determined by a configured role in an optional configured support server, and the list of backend servers. 32 | to choose from. 33 | - The core functionality of the bot thus works without access to a websocket, which is important for scalability of the web server since it imports and uses emote collector's code. 34 | - The emote decay system, which removes old and unused emotes to make room for new ones, if configured. 35 | - Emote logging, for transparency, data recovery, and moderation purposes. 36 | - The "gimme" system which allows users to be invited to backend servers. Emote Collector deletes all messages sent in the backend servers after 5 seconds, in order to prevent me being liable for moderating hundreds of servers. In case any messages were missed, on startup, Emote Collector erases and recreates all channels in the backend servers and creates a new one in order to invite people to them. 37 | 38 | There are more components, but these are the ones you should know about. 39 | 40 | ### Adding an emote 41 | 42 | When a user adds a new emote: 43 | 44 | 1. The image is fetched from the internet. 45 | 2. The image is validated as a GIF, PNG, or JPEG file. 46 | 3. If the image is too big to be uploaded to discord (>256 KiB) it is repeatedly resized in 1/2 increments until it does fit using Wand. This is done in a subprocess to reduce memory leaks, as when a process is killed all of its memory is reclaimed by the system. 47 | 4. If the image is not too big after that, a backend server is picked which has enough static or animated slots for the emote, depending on the type of the image 48 | - The backend server picked is the one that least recently had an emote added to it. 49 | This is to minimize being rate limited, as the emote rate limits are very strict. 50 | 5. The emote is uploaded to the chosen backend server. 51 | 6. The emote data that Discord gives us is then inserted into the database, along with our own data (the user who created the emote). 52 | 7. The emote creation is logged to all configured discord channels, if applicable. 53 | 54 | ## Installation 55 | 56 | 1) 57 | ``` 58 | git clone https://github.com/EmoteCollector/EmoteCollector.git ec 59 | cd ec 60 | python3 -m venv .venv 61 | source .venv/bin/activate 62 | pip install -U setuptools pip wheel 63 | pip install -e . 64 | ``` 65 | 2) Run these sql commands in `sudo -u postgres psql`: 66 | ```sql 67 | -- for ease of use, you can name the role you use after your system user account to use peer authentication 68 | -- then you won't have to specify a username or password in the config file. 69 | CREATE DATABASE ec WITH OWNER my_postgres_role; -- or whatever you want to call the database 70 | \c ec 71 | CREATE EXTENSION pg_trgm; -- used for searching for emotes by name 72 | ``` 73 | 3) `psql ec -f emote_collector/sql/schema.sql` 74 | 4) Copy `emote_collector/data/config.example.py` to `emote_collector/data/config.py`, 75 | and edit accordingly. Make sure you touch the database and tokens, and backend_user_accounts sections. 76 | 5) Create a brand new Discord user account. This is to hold the servers that will store the emotes. 77 | Unfortunately, every time I make a new alt, discord requires me to verify it by phone. 78 | If this happens to you, you must use an actual physical phone number, rather than a VoIP number, and it can't be used to verify another discord account within 24 hours. 79 | 6) Create two bot applications under any Discord user account. One will be the backend server creation bot and one will be the public-facing Emote Collector bot. Preferably create these under your main account, rather than your backend account, so that your main has admin access to the bot (or use Teams). 80 | 7) Run `emote_collector/backend_creator.py [existing backend server count]`. Make sure you are signed in to the emote backend user account in your web browser. The optional last parameter is in case you need to stop the script and resume it later. 81 | - It'll create one server at a time, each time opening an invite link so that you can join the server. Then it will transfer ownership to you (your emote backend account) and open Emote Collector's invite link in your web browser. 82 | - After Emote Collector is invited to the server, the backend creator leaves the server so that it can make another one (bots are only allowed to create servers if they are in at most 10 servers total). 83 | 8) Run `python -m emote_collector`. 84 | 9) *Optional*: run an instance of the [list website](https://github.com/EmoteCollector/website) 85 | and set up its information in config.py. 86 | 87 | Confused about how the backend creator works? Watch this [video demo of it in action](https://streamable.com/mjtfu). 88 | 89 | If you need any help, DM @lambda#0987 or file a GitHub issue. 90 | -------------------------------------------------------------------------------- /LICENSE.header: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune *.egg-info 2 | 3 | include emote_collector/data/avatar.png 4 | include emote_collector/data/discord-emoji-shortcodes.json 5 | include emote_collector/data/e0-final-emojis.json 6 | include emote_collector/data/config.example.py 7 | include emote_collector/data/memes.example.py 8 | include emote_collector/data/decay_test.sql 9 | include emote_collector/data/short-license.txt 10 | include emote_collector/data/bingo/* 11 | include emote_collector/locale/* 12 | include emote_collector/sql/* 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emote Collector 2 | 3 | Emote Collector lets you use emotes you don't have, even without Nitro. 4 | 5 | Once you've found an emoji you'd like to use, just send it as if you were sending a regular Discord emoji (e.g. :speedtest:). 6 | It will be detected and sent by the bot. Using semicolons (;thonkang;) is also supported. 7 | 8 | Note that this bot is still in beta status, and is subject to change at any time until it becomes stable. 9 | 10 | - To add the bot to your server, visit https://discordapp.com/oauth2/authorize?client_id=405953712113057794&scope=bot&permissions=355392. 11 | - To run your own instance of the bot, read [the installation guide](INSTALLATION.md). 12 | - If you'd like to help out with the code, read [CONTRIBUTING.md](CONTRIBUTING.md). 13 | 14 | ## Commands 15 | 16 |

17 | To add an emote: 18 |

    19 |
  • ec/add :thonkang: (if you already have that emote)
  • 20 |
  • ec/add rollsafe https://image.noelshack.com/fichiers/2017/06/1486495269-rollsafe.png
  • 21 |
  • ec/add speedtest https://cdn.discordapp.com/emojis/379127000398430219.png
  • 22 |
23 | If you invoke ec/add with an image upload, the image will be used as the emote image, and the filename will be used as the emote name. To choose a different name, simply run it like
24 | ec/add :some_emote: instead.

25 | 26 |

27 | Running ec/info :some_emote: will show you some information about the emote, including when it was created and how many times it's been used. 28 |

29 | 30 |

31 | Running ec/big :some_emote: will enlarge the emote. 32 |

33 | 34 |

35 | There's a few ways to react to a message with an emote you don't have: 36 |

    37 |
  • ec/react speedtest will react with :speedtest: to the last message. 38 |
  • ec/react :speedtest: hello there will react with :speedtest: to the most recent message containing "hello there". 39 |
  • ec/react speedtest @Someone will react with :speedtest: to the last message by Someone. 40 |
  • ec/react ;speedtest; -2 will react with :speedtest: to the second-to-last message. 41 |
  • ec/react speedtest 462092903540457473 will react with :speedtest: to message ID 462092903540457473. 42 |
43 | After running this command, the bot will wait for you to also react. Once you react, or after 30s, the bot will remove its reaction. Confused? It works like this:
44 | demonstration of how the ec/react command works 45 |

46 | 47 |

48 | ec/list [user] gives you a list of all emotes. If you provide a user (IDs, names, and @mentions all work), 49 | then the bot will limit the list to only emotes created by that user. 50 |

51 | 52 |

53 | ec/popular will list all emotes, sorted by how often they've been used within the last 4 weeks. 54 |

55 | -------------------------------------------------------------------------------- /emote_collector/__init__.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import contextlib 19 | import inspect 20 | import itertools 21 | import json 22 | import logging 23 | import traceback 24 | from pathlib import Path 25 | 26 | import asyncpg 27 | import discord 28 | import jinja2 29 | from bot_bin.bot import Bot 30 | from braceexpand import braceexpand 31 | from discord.ext import commands 32 | try: 33 | import uvloop 34 | except ImportError: 35 | pass # Windows 36 | else: 37 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 38 | 39 | # set BASE_DIR before importing utils because utils.i18n depends on it 40 | BASE_DIR = Path(__file__).parent 41 | 42 | from . import utils 43 | from . import extensions 44 | 45 | logging.basicConfig(level=logging.INFO) 46 | logger = logging.getLogger('bot') 47 | 48 | class EmoteCollector(Bot): 49 | def __init__(self, **kwargs): 50 | super().__init__(setup_db=True, **kwargs) 51 | self.jinja_env = jinja2.Environment( 52 | loader=jinja2.FileSystemLoader(str(BASE_DIR / 'sql')), 53 | line_statement_prefix='-- :') 54 | 55 | def process_config(self): 56 | super().process_config() 57 | self.config['backend_user_accounts'] = set(self.config['backend_user_accounts']) 58 | with contextlib.suppress(KeyError): 59 | self.config['copyright_license_file'] = BASE_DIR / self.config['copyright_license_file'] 60 | utils.SUCCESS_EMOJIS = self.config.get('success_or_failure_emojis', ('❌', '✅')) 61 | 62 | ### Events 63 | 64 | async def on_message(self, message): 65 | if self.should_reply(message): 66 | await self.set_locale(message) 67 | await self.process_commands(message) 68 | 69 | async def set_locale(self, message): 70 | locale = await self.cogs['Locales'].locale(message) 71 | utils.i18n.current_locale.set(locale) 72 | 73 | # https://github.com/Rapptz/RoboDanny/blob/ca75fae7de132e55270e53d89bc19dd2958c2ae0/bot.py#L77-L85 74 | async def on_command_error(self, context, error): 75 | if isinstance(error, commands.NoPrivateMessage): 76 | await context.author.send(_('This command cannot be used in private messages.')) 77 | elif isinstance(error, commands.DisabledCommand): 78 | await context.send(_('Sorry. This command is disabled and cannot be used.')) 79 | elif isinstance(error, commands.NotOwner): 80 | logger.error('%s tried to run %s but is not the owner', context.author, context.command.name) 81 | with contextlib.suppress(discord.HTTPException): 82 | await context.try_add_reaction(utils.SUCCESS_EMOJIS[False]) 83 | elif isinstance(error, (commands.UserInputError, commands.CheckFailure)): 84 | await context.send(error) 85 | elif ( 86 | isinstance(error, commands.CommandInvokeError) 87 | # abort if it's overridden 88 | and 89 | getattr( 90 | type(context.cog), 91 | 'cog_command_error', 92 | # treat ones with no cog (e.g. eval'd ones) as being in a cog that did not override 93 | commands.Cog.cog_command_error) 94 | is commands.Cog.cog_command_error 95 | ): 96 | if not isinstance(error.original, discord.HTTPException): 97 | logger.error('"%s" caused an exception', context.message.content) 98 | logger.error(''.join(traceback.format_tb(error.original.__traceback__))) 99 | # pylint: disable=logging-format-interpolation 100 | logger.error('{0.__class__.__name__}: {0}'.format(error.original)) 101 | 102 | await context.send(_('An internal error occurred while trying to run that command.')) 103 | elif isinstance(error.original, discord.Forbidden): 104 | await context.send(_("I'm missing permissions to perform that action.")) 105 | 106 | ### Utility functions 107 | 108 | async def get_context(self, message, cls=None): 109 | return await super().get_context(message, cls=cls or utils.context.CustomContext) 110 | 111 | # https://github.com/Rapptz/discord.py/blob/814b03f5a8a6faa33d80495691f1e1cbdce40ce2/discord/ext/commands/core.py#L1338-L1346 112 | def has_permissions(self, message, **perms): 113 | guild = message.guild 114 | me = guild.me if guild is not None else self.user 115 | permissions = message.channel.permissions_for(me) 116 | 117 | for perm, value in perms.items(): 118 | if getattr(permissions, perm, None) != value: 119 | return False 120 | 121 | return True 122 | 123 | def queries(self, template_name): 124 | return self.jinja_env.get_template(str(template_name)).module 125 | 126 | ### Init / Shutdown 127 | 128 | startup_extensions = list(braceexpand("""{ 129 | emote_collector.extensions.{ 130 | locale, 131 | file_upload_hook, 132 | logging, 133 | db, 134 | emote, 135 | api, 136 | gimme, 137 | meta, 138 | stats, 139 | meme, 140 | bingo.{ 141 | db, 142 | commands}}, 143 | jishaku, 144 | bot_bin.{ 145 | misc, 146 | debug, 147 | sql}} 148 | """.replace('\t', '').replace('\n', ''))) 149 | 150 | def load_extensions(self): 151 | utils.i18n.set_default_locale() 152 | super().load_extensions() 153 | -------------------------------------------------------------------------------- /emote_collector/__main__.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from . import EmoteCollector, BASE_DIR 4 | from . import utils 5 | 6 | config = utils.load_json_compat(BASE_DIR / 'data' / 'config.py') 7 | 8 | bot = EmoteCollector(config=config) 9 | bot.run() 10 | -------------------------------------------------------------------------------- /emote_collector/backend_creator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Emote Collector collects emotes from other servers for use by people without Nitro 4 | # Copyright © 2018–2019 lambda#0987 5 | # 6 | # Emote Collector is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # Emote Collector is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Emote Collector. If not, see . 18 | 19 | import os 20 | import sys 21 | import time 22 | import typing 23 | import asyncio 24 | import inspect 25 | import logging 26 | import functools 27 | import contextlib 28 | import webbrowser 29 | 30 | import discord 31 | 32 | logging.getLogger('discord').setLevel(logging.ERROR) 33 | 34 | GUILD_NAME_PREFIX = 'EmoteBackend ' 35 | GUILDS_TO_CREATE = 100 36 | 37 | bot = discord.Client() 38 | 39 | @bot.event 40 | async def on_ready(): 41 | global guild_count 42 | 43 | await delete_guilds() 44 | 45 | first_guild = await create_guild() 46 | await update_permissions() 47 | 48 | print('You will have to fill out a lot of CAPTCHAs, so it is recommended to install the Buster captcha solver.') 49 | print('For Firefox: https://addons.mozilla.org/en-US/firefox/addon/buster-captcha-solver/') 50 | print('For Chrome: https://chrome.google.com/webstore/detail/buster-captcha-solver-for/mpbjkejclgfgadiemmefgebjfooflfhl') 51 | print("I also needed to install the user input simulation, which you can install by enabling it in the extension's settings.") 52 | print("If you can't or don't want to install the add-on, you should still do the audio CAPTCHAs, as they're way easier.") 53 | 54 | await add_user_to_guild(first_guild) 55 | 56 | async def delete_guilds(): 57 | for guild in bot.guilds: 58 | with contextlib.suppress(discord.HTTPException): 59 | await guild.delete() 60 | 61 | def format_guild_name(n): 62 | pad_length = len(str(GUILDS_TO_CREATE)) - 1 63 | # space out the number so that the icon for each guild in the sidebar shows the full number 64 | # e.g. 3 -> '0 3' if the limit is 100 65 | return GUILD_NAME_PREFIX + ' '.join(str(n).zfill(pad_length)) 66 | 67 | async def create_guild(): 68 | global guild_count 69 | try: 70 | guild = await bot.create_guild(format_guild_name(guild_count)) 71 | except discord.HTTPException: 72 | return 73 | guild_count += 1 74 | return guild 75 | 76 | async def clear_guild(guild): 77 | # By default, discord creates 4 channels to make it easy for users: 78 | # A "text channels" category, a "voice channels" category, 79 | # a voice channel and a text channel. We want none of those. 80 | # There is also an invite created for the text channel, but that's deleted when the channel dies. 81 | for channel in guild.channels: 82 | await channel.delete() 83 | 84 | administrator = discord.Permissions() 85 | administrator.administrator = True 86 | 87 | async def update_permissions(): 88 | for guild in bot.guilds: 89 | default_role = guild.default_role 90 | default_role.permissions.mention_everyone = False 91 | with contextlib.suppress(discord.HTTPException): 92 | await default_role.edit(permissions=default_role.permissions) 93 | 94 | async def add_user_to_guild(guild): 95 | ch = await guild.create_text_channel('foo') 96 | invite = (await ch.create_invite()).url 97 | webbrowser.open(invite) 98 | 99 | def add_bot_to_guild(guild): 100 | url = discord.utils.oauth_url(bot_user_id, permissions=administrator, guild=guild) 101 | webbrowser.open(url) 102 | 103 | @bot.event 104 | async def on_member_join(member): 105 | guild = member.guild 106 | 107 | if member == bot.user: 108 | return 109 | 110 | if member.id != bot_user_id: 111 | await clear_guild(guild) 112 | await guild.edit(owner=member) 113 | add_bot_to_guild(guild) 114 | else: 115 | await guild.leave() 116 | if guild_count > original_guild_count and guild_count % GUILDS_TO_CREATE == 0: 117 | await bot.close() 118 | return 119 | 120 | guild = await create_guild() 121 | await add_user_to_guild(guild) 122 | 123 | def usage(): 124 | print( 125 | 'Usage:', sys.argv[0], 126 | ' [existing backend guild count]', 127 | file=sys.stderr) 128 | 129 | def main(): 130 | global bot_user_id, guild_count, original_guild_count 131 | 132 | if len(sys.argv) == 1: 133 | usage() 134 | sys.exit(1) 135 | if len(sys.argv) == 2: 136 | usage() 137 | sys.exit(1) 138 | token = sys.argv[1] 139 | bot_user_id = int(sys.argv[2]) 140 | if len(sys.argv) > 3: 141 | guild_count = original_guild_count = int(sys.argv[3]) 142 | 143 | bot.run(token) 144 | 145 | if __name__ == '__main__': 146 | main() 147 | -------------------------------------------------------------------------------- /emote_collector/data/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/data/avatar.png -------------------------------------------------------------------------------- /emote_collector/data/bingo/DejaVuSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/data/bingo/DejaVuSans.ttf -------------------------------------------------------------------------------- /emote_collector/data/bingo/bingo_board_base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/data/bingo/bingo_board_base.png -------------------------------------------------------------------------------- /emote_collector/data/config.example.py: -------------------------------------------------------------------------------- 1 | { 2 | 'description': 'Emote Collector curates emotes from any server and lets you use them without Nitro.', 3 | 'prefixes': ['ec/'], 4 | 5 | 'decay': { 6 | 'enabled': False, # whether to enable the deletion of old emotes 7 | 'cutoff': { # emotes older than 4 weeks old that were not used 3 times will be removed automatically 8 | 'usage': 2, 9 | 'time': datetime.timedelta(weeks=4), 10 | }, 11 | }, 12 | 13 | # your instance of the website code located at https://github.com/EmoteCollector/website 14 | # if this is left blank, the ec/list command will not advertise the online version of the list. 15 | 'website': 'https://ec.emote.bot', 16 | 17 | # change this user agent if you change the code 18 | 'user_agent': 'EmoteCollectorBot (https://github.com/EmoteBot/EmoteCollector)', 19 | 20 | 'repo': 'https://github.com/EmoteBot/EmoteCollector', 21 | 22 | # related to your instance of https://github.com/EmoteCollector/website 23 | # if this dict is left empty, the API related commands will be disabled. 24 | 'api': { 25 | 'docs_url': 'https://ec.emote.bot/api/v0/docs', 26 | }, 27 | 28 | # the contents of this file will be sent to the user when they run the "copyright" command 29 | # as provided by bot_bin 30 | 'copyright_license_file': 'data/short-license.txt', 31 | 32 | 'support_server': { # a guild where users can get help using the bot 33 | 'id': None, # the ID of the guild itself 34 | # where should users be invited to when they need help? the bot must have Create Instant Invites permission 35 | # for this channel 36 | 'invite_channel_id': None, # if set to None, the ec/support command will be disabled 37 | }, 38 | 39 | # a user ID of someone to send logs to 40 | # note: currently nothing is sent except a notification of the bot's guild count being a power of 2 41 | 'send_logs_to': None, 42 | 43 | 'ignore_bots': { 44 | 'default': True, 45 | 'overrides': { 46 | 'guilds': frozenset({ 47 | # put guild IDs in here for which you want to override the default behavior 48 | }), 49 | 'channels': frozenset({ 50 | # put channel IDs in here for which you want to override the default behavior 51 | }), 52 | } 53 | }, 54 | 55 | # this is a dict mapping log channel IDs to settings for that channel 56 | # each settings dict looks like this: 57 | # { 58 | # 'include_nsfw_emotes': True, # optional, if not specified, it defaults to False 59 | # 'actions': { # a set of actions to log to this channel 60 | # # possible action strings: 61 | # 'add', 62 | # 'remove', 63 | # 'force_remove', # when an emote is removed by a moderator 64 | # 'preserve', # when an emote is preserved (excluded from the emote decay) 65 | # 'unpreserve', # when an emote is un-preserved (included in the emote decay) 66 | # 'nsfw', # when an emote is marked as NSFW 67 | # 'sfw', # when an emote is marked as SFW 68 | # 'decay', # when an emote is removed by the automatic emote decay 69 | # } 70 | # } 71 | # multiple channels can be configured. all of them will be used for logging. 72 | 'logs': { 73 | }, 74 | 75 | # User IDs of accounts that own the backend guilds. This is required for the bot to function. 76 | 'backend_user_accounts': [ 77 | ], 78 | 79 | # postgresql connection info 80 | # any fields left None will use defaults or environment variables 81 | 'database': { 82 | 'user': None, 83 | 'password': None, 84 | 'database': None, 85 | 'host': None, 86 | 'port': None, 87 | }, 88 | 89 | 'tokens': { 90 | 'discord': 'sek.rit.token', # get this from https://discordapp.com/developers/applications/me 91 | 'stats': { # keep these set to None unless your bot is listed on any of these sites 92 | 'bots.discord.pw': None, 93 | 'discordbots.org': None, 94 | 'botsfordiscord.com': None 95 | } 96 | }, 97 | 98 | 'success_or_failure_emojis': {False: '❌', True: '✅'}, 99 | } 100 | -------------------------------------------------------------------------------- /emote_collector/data/decay_test.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda--0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | CREATE TABLE emotes( 18 | name VARCHAR(32) NOT NULL, 19 | id BIGINT PRIMARY KEY, 20 | created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 21 | preserve BOOLEAN DEFAULT FALSE); 22 | 23 | CREATE TABLE emote_usage_history( 24 | id BIGINT, 25 | time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP); 26 | 27 | INSERT INTO emotes 28 | (name, id, created, preserve) 29 | VALUES 30 | ('donotdecay1', 1, CURRENT_TIMESTAMP, false), -- new without usage 31 | ('donotdecay2', 2, CURRENT_TIMESTAMP, false), -- new with usage 32 | ('donotdecay3', 3, '1970-01-01', false), -- old with usage 33 | ('donotdecay4', 4, '1970-01-01', true), -- old without usage but preserved 34 | ('donotdecay5', 5, '1970-01-01', true), -- old with little (1) usage but preserved 35 | ('decay1', 6, '1970-01-01', false), -- old without usage 36 | ('decay2', 7, '1970-01-01', false); -- old with some (3) usage but a long time ago 37 | 38 | 39 | INSERT INTO emote_usage_history 40 | (id) 41 | VALUES 42 | (2), 43 | (2), 44 | (2), 45 | 46 | (3), 47 | (3), 48 | (3), 49 | 50 | (5); 51 | 52 | INSERT INTO emote_usage_history 53 | (id, time) 54 | VALUES 55 | (7, '1970-01-01'), 56 | (7, '1970-01-01'), 57 | (7, '1970-01-01'); 58 | 59 | SELECT e.name, e.id, COUNT(euh.id) AS usage 60 | FROM emotes AS e 61 | LEFT JOIN emote_usage_history AS euh 62 | ON euh.id = e.id 63 | AND time > CURRENT_TIMESTAMP - INTERVAL '4 weeks' 64 | WHERE created < CURRENT_TIMESTAMP - INTERVAL '4 weeks' 65 | AND NOT preserve 66 | GROUP BY e.id 67 | HAVING COUNT(euh.id) < 3; 68 | -------------------------------------------------------------------------------- /emote_collector/data/memes.example.py: -------------------------------------------------------------------------------- 1 | # this file just contains various text/emote memes 2 | # if data/memes.py doesn't exist, the ec/meme command will be disabled. 3 | { 4 | # running ec/meme linux would respond with the interjection 5 | 'linux': "I'd just like to interject for a moment…", 6 | 'multi line': 7 | 'line1\n' 8 | 'line2\n' 9 | 'line3', 10 | } 11 | -------------------------------------------------------------------------------- /emote_collector/data/short-license.txt: -------------------------------------------------------------------------------- 1 | AGPLv3: https://github.com/EmoteBot/EmoteCollector/blob/master/LICENSE.md 2 | -------------------------------------------------------------------------------- /emote_collector/extensions/api.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import base64 18 | import contextlib 19 | import secrets 20 | 21 | import discord 22 | from discord.ext import commands 23 | 24 | from .. import utils 25 | 26 | class API(commands.Cog): 27 | TOKEN_DELIMITER = b';' 28 | 29 | def __init__(self, bot): 30 | self.bot = bot 31 | self.queries = self.bot.queries('api.sql') 32 | 33 | @staticmethod 34 | def any_parent_command_is(command, parent_command): 35 | while command is not None: 36 | if command is parent_command: 37 | return True 38 | command = command.parent 39 | return False 40 | 41 | async def cog_check(self, context): 42 | # we're doing this as a local check because 43 | # A) if invoke_without_command=True, checks don't propagate to subcommands 44 | # B) even if invoke_without_command=False, checks still don't propagate to sub-sub-commands 45 | # AFAIK 46 | if self.any_parent_command_is(context.command, self.token_command): 47 | # bots may not have API tokens 48 | return not context.author.bot 49 | return True 50 | 51 | @commands.group(invoke_without_command=True) 52 | async def api(self, context): 53 | """Commands related to the Emote Collector API. 54 | 55 | This command on its own will tell you a bit about the API. 56 | """ 57 | if context.invoked_subcommand is None: 58 | await context.send(_( 59 | 'I have a RESTful API available. The docs for it are located at ' 60 | '{docs_url}.').format(docs_url=self.bot.config['api']['docs_url'])) 61 | 62 | @api.group(name='token', aliases=('toke1', 'toke', 'tok'), invoke_without_command=True) 63 | async def token_command(self, context): 64 | """Sends you your token to use the API.""" 65 | token = await self.token(context.author.id) 66 | await self.send_token(context, token) 67 | 68 | @token_command.command(name='regenerate', aliases=('regen',)) 69 | async def regenerate_command(self, context): 70 | """Regenerates your user token. Use this if your token is compromised.""" 71 | token = await self.regenerate_token(context.author.id) 72 | await self.send_token(context, token, new=True) 73 | 74 | async def send_token(self, context, token, *, new=False): 75 | if new: 76 | first_line = _('Your new API token is:\n') 77 | else: 78 | first_line = _('Your API token is:\n') 79 | 80 | message = ( 81 | first_line 82 | + f'`{token.decode()}`\n' 83 | + _('Do **not** share it with anyone!')) 84 | 85 | try: 86 | await context.author.send(message) 87 | except discord.Forbidden: 88 | await context.send(_('Error: I could not send you your token via DMs.')) 89 | else: 90 | with contextlib.suppress(discord.HTTPException): 91 | await context.message.add_reaction('📬') 92 | 93 | async def token(self, user_id): 94 | """get the user's API token. If they don't already have a token, make a new one""" 95 | return await self.existing_token(user_id) or await self.new_token(user_id) 96 | 97 | async def delete_user_account(self, user_id): 98 | await self.bot.pool.execute(self.queries.delete_token(), user_id) 99 | 100 | async def existing_token(self, user_id): 101 | secret = await self.bot.pool.fetchval(self.queries.existing_token(), user_id) 102 | if secret: 103 | return self.encode_token(user_id, secret) 104 | 105 | async def new_token(self, user_id): 106 | secret = secrets.token_bytes() 107 | await self.bot.pool.execute(self.queries.new_token(), user_id, secret) 108 | return self.encode_token(user_id, secret) 109 | 110 | async def regenerate_token(self, user_id): 111 | await self.bot.pool.execute(self.queries.delete_token(), user_id) 112 | return await self.new_token(user_id) 113 | 114 | async def validate_token(self, token, user_id=None): 115 | try: 116 | token_user_id, secret = self.decode_token(token) 117 | except: # XXX 118 | secrets.compare_digest(token, token) 119 | return False 120 | 121 | if user_id is None: 122 | # allow auth with just a secret 123 | user_id = token_user_id 124 | 125 | db_secret = await self.bot.pool.fetchval(self.queries.existing_token(), user_id) 126 | if db_secret is None: 127 | secrets.compare_digest(token, token) 128 | return False 129 | 130 | db_token = self.encode_token(user_id, db_secret) 131 | return secrets.compare_digest(token, db_token) and user_id 132 | 133 | def generate_token(self, user_id): 134 | secret = base64.b64encode(secrets.token_bytes()) 135 | return self.encode_token(user_id, secret) 136 | 137 | def encode_token(self, user_id, secret: bytes): 138 | return base64.b64encode(utils.int_to_bytes(user_id)) + self.TOKEN_DELIMITER + base64.b64encode(secret) 139 | 140 | def decode_token(self, token): 141 | user_id, secret = map(base64.b64decode, token.split(self.TOKEN_DELIMITER)) 142 | user_id = utils.bytes_to_int(user_id) 143 | 144 | return user_id, secret 145 | 146 | def setup(bot): 147 | if bot.config.get('api'): 148 | bot.add_cog(API(bot)) 149 | -------------------------------------------------------------------------------- /emote_collector/extensions/bingo/commands.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import io 18 | 19 | import discord 20 | from bot_bin.sql import connection, optional_connection 21 | from discord.ext import commands 22 | 23 | from .errors import BoardTooLewdError 24 | from ... import utils 25 | from ...utils import bingo 26 | from ...utils.converter import DatabaseOrLoggedEmote, MultiConverter 27 | from ...utils.proxy import ObjectProxy 28 | 29 | class Bingo(commands.Cog): 30 | def __init__(self, bot): 31 | self.bot = bot 32 | self.db = ObjectProxy(lambda: bot.cogs['BingoDatabase']) 33 | 34 | @commands.group(invoke_without_command=True) 35 | async def bingo(self, context): 36 | """Shows you your current bingo board. All other functionality is in subcommands.""" 37 | board = await self.db.get_board(context.author.id) 38 | await self.send_board(context, _('You win!') if board.has_won() else None, board) 39 | 40 | @bingo.command() 41 | async def new(self, context): 42 | """Creates a new bingo board or replaces your current one.""" 43 | await self.send_board(context, _('Your new bingo board:'), await self.db.new_board(context.author.id)) 44 | 45 | @bingo.command(usage=' [, ...]') 46 | @optional_connection 47 | async def mark(self, context, *, args: MultiConverter[str.upper, DatabaseOrLoggedEmote]): 48 | """Adds one or more marks to your board.""" 49 | if not args: 50 | raise commands.BadArgument(_('You must specify at least one position and emote name.')) 51 | 52 | async with connection().transaction(isolation='repeatable_read'): 53 | await self.db.mark(context.author.id, args) 54 | board = await self.db.get_board(context.author.id) 55 | 56 | message = _('You win! Your new bingo board:') if board.has_won() else _('Your new bingo board:') 57 | await self.send_board(context, message, board) 58 | 59 | @bingo.command() 60 | async def unmark(self, context, *positions: str.upper): 61 | async with self.bot.pool.acquire() as conn: 62 | connection.set(conn) 63 | await self.db.unmark(context.author.id, positions) 64 | await self.send_board(context, _('Your new bingo board:'), await self.db.get_board(context.author.id)) 65 | 66 | async def send_board(self, context, message, board): 67 | if board.is_nsfw() and not getattr(context.channel, 'nsfw', True): 68 | raise BoardTooLewdError 69 | async with context.typing(): 70 | f = discord.File( 71 | io.BytesIO(await bingo.render_in_subprocess(self.bot, board)), 72 | f'{context.author.id}_board.png') 73 | await context.send(message, file=f) 74 | 75 | def setup(bot): 76 | bot.add_cog(Bingo(bot)) 77 | -------------------------------------------------------------------------------- /emote_collector/extensions/bingo/db.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import functools 18 | import io 19 | import itertools 20 | import operator 21 | 22 | from bot_bin.sql import connection, optional_connection 23 | from discord.ext import commands 24 | 25 | from .errors import NoBoardError 26 | from ... import utils 27 | from ...utils import bingo, compose 28 | 29 | DEFAULT_BOARD_VALUE = bingo.EmoteCollectorBingoBoard().value 30 | 31 | class BingoDatabase(commands.Cog): 32 | def __init__(self, bot): 33 | self.bot = bot 34 | self.queries = self.bot.queries('bingo.sql') 35 | 36 | @optional_connection 37 | async def get_board(self, user_id): 38 | val = await connection().fetchval(self.queries.get_board_value(), user_id) 39 | if val is None: 40 | raise NoBoardError 41 | categories = [cat for cat, in await connection().fetch(self.queries.get_board_categories(), user_id)] 42 | board = bingo.EmoteCollectorBingoBoard(value=val, categories=categories) 43 | for mark in await connection().fetch(self.queries.get_board_marks(), user_id): 44 | board.marks.items[mark['pos']] = mark['nsfw'], mark['name'], mark['id'], mark['animated'] 45 | return board 46 | 47 | @optional_connection 48 | async def new_board(self, user_id): 49 | async with connection().transaction(isolation='repeatable_read'): 50 | await connection().execute(self.queries.delete_board(), user_id) 51 | await connection().execute(self.queries.set_board_value(), user_id, DEFAULT_BOARD_VALUE) 52 | rows = await connection().fetch(self.queries.get_categories(), bingo.BingoBoard.SQUARES) 53 | to_insert = [ 54 | (user_id, pos + 1 if pos >= bingo.BingoBoard.FREE_SPACE_I else pos, category_id) # skip free space 55 | for pos, (category_id, category_text) 56 | in enumerate(rows)] 57 | await connection().copy_records_to_table( 58 | 'bingo_board_categories', 59 | records=to_insert, 60 | columns=('user_id', 'pos', 'category_id')) 61 | return bingo.EmoteCollectorBingoBoard(categories=[category_text for __, category_text in rows]) 62 | 63 | @optional_connection 64 | async def mark(self, user_id, marks): 65 | async with connection().transaction(isolation='repeatable_read'): 66 | marks = list(marks) 67 | params = ( 68 | (user_id, bingo.index(point), emote.nsfw, emote.name, emote.id, emote.animated) 69 | for point, emote 70 | in marks) 71 | await connection().executemany(self.queries.set_board_mark(), params) 72 | indices = map(compose(bingo.index, operator.itemgetter(0)), marks) 73 | mask = functools.reduce(operator.or_, (1 << i for i in indices)) 74 | await connection().execute(self.queries.add_board_marks_by_mask(), user_id, mask) 75 | 76 | @optional_connection 77 | async def unmark(self, user_id, points): 78 | indices = list(map(bingo.index, points)) 79 | mask = functools.reduce(operator.or_, (1 << i for i in indices)) 80 | async with connection().transaction(isolation='serializable'): 81 | params = list(zip(itertools.repeat(user_id), indices)) 82 | await connection().executemany(self.queries.delete_board_mark(), params) 83 | await connection().execute(self.queries.delete_board_marks_by_mask(), user_id, mask) 84 | 85 | @optional_connection 86 | async def check_win(self, user_id): 87 | val = await connection().fetchval(self.queries.get_board_value(), user_id) 88 | board = bingo.EmoteCollectorBingoBoard(value=val) 89 | return board.has_won() 90 | 91 | @optional_connection 92 | async def delete_user_account(self, user_id): 93 | await connection().execute(self.queries.delete_board(), user_id) 94 | 95 | def setup(bot): 96 | bot.add_cog(BingoDatabase(bot)) 97 | -------------------------------------------------------------------------------- /emote_collector/extensions/bingo/errors.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | from ...utils.errors import ConnoisseurError 18 | 19 | class BingoError(ConnoisseurError): 20 | pass 21 | 22 | class NoBoardError(BingoError): 23 | def __init__(self): 24 | super().__init__(_('You do not have a bingo board yet.')) 25 | 26 | class BoardTooLewdError(BingoError): 27 | def __init__(self): 28 | super().__init__(_('An NSFW channel is required to display this board.')) 29 | -------------------------------------------------------------------------------- /emote_collector/extensions/file_upload_hook.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import io 18 | 19 | import discord 20 | 21 | from .. import utils 22 | from ..utils import custom_send 23 | 24 | # it's not exactly 8MiB because 25 | # the limit is not on the file size but on the whole request 26 | FILE_SIZE_LIMIT = 8 * 1024 ** 2 - 512 27 | 28 | async def upload_to_privatebin_if_too_long(original_send, content=None, **kwargs): 29 | if content is None: 30 | return True, 31 | 32 | content = str(content) 33 | if len(content) <= 2000: 34 | return True, 35 | 36 | out = io.StringIO(content) 37 | if utils.size(out) > FILE_SIZE_LIMIT: 38 | # translator's note: this is sent to the user when the bot tries to send a message larger than ~8MiB 39 | return False, await original_send(_('Way too long.')) 40 | 41 | file = discord.File(fp=io.StringIO(content), filename='message.txt') 42 | # translator's note: this is sent to the user when the bot tries to send a message >2000 characters 43 | # but less than 8MiB 44 | return False, await original_send(_('Way too long. Message attached.'), **kwargs, file=file) 45 | 46 | def setup(bot): 47 | custom_send.register(upload_to_privatebin_if_too_long) 48 | 49 | def teardown(bot): 50 | custom_send.unregister(upload_to_private_bin_if_too_long) 51 | -------------------------------------------------------------------------------- /emote_collector/extensions/gimme.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import contextlib 19 | import logging 20 | 21 | import discord 22 | from discord.ext import commands 23 | 24 | from .. import utils 25 | from ..utils import ObjectProxy 26 | from ..utils.converter import DatabaseEmoteConverter 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | class Gimme(commands.Cog): 31 | def __init__(self, bot): 32 | self.bot = bot 33 | self.guild_ids = ObjectProxy(lambda: bot.cogs['Database'].guild_ids) 34 | self.guilds = ObjectProxy(lambda: bot.cogs['Database'].guilds) 35 | self.task = self.bot.loop.create_task(self.delete_backend_guild_messages()) 36 | 37 | def cog_unload(self): 38 | self.task.cancel() 39 | 40 | @commands.command() 41 | async def gimme(self, context, emote: DatabaseEmoteConverter(check_nsfw=False)): 42 | """Lets you join the server that has the emote you specify. 43 | 44 | If you have nitro, this will let you use it anywhere! 45 | """ 46 | 47 | guild = self.bot.get_guild(emote.guild) 48 | invite = await guild.text_channels[0].create_invite( 49 | max_age=600, 50 | max_uses=2, 51 | reason='Created for {user}'.format( 52 | user=utils.format_user(self.bot, context.author, mention=False))) 53 | 54 | try: 55 | await context.author.send(_( 56 | 'Invite to the server that has {emote}: {invite.url}').format(**locals())) 57 | except discord.Forbidden: 58 | await context.send(_('Unable to send invite in DMs. Please allow DMs from server members.')) 59 | else: 60 | with contextlib.suppress(discord.HTTPException): 61 | await context.message.add_reaction('📬') 62 | 63 | @commands.Cog.listener() 64 | async def on_message(self, message): 65 | if getattr(message.guild, 'id', None) in self.guild_ids: 66 | await asyncio.sleep(5) 67 | with contextlib.suppress(discord.HTTPException): 68 | await message.delete() 69 | 70 | @commands.Cog.listener(name='on_ready') 71 | async def delete_backend_guild_messages(self): 72 | # ensure there's no messages left over from last run 73 | for guild in self.guilds: 74 | await self.clear_guild(guild) 75 | logger.info('all backend guild text channels have been cleared') 76 | 77 | @commands.Cog.listener(name='on_backend_guild_join') 78 | async def clear_guild(self, guild): 79 | permissions = guild.default_role.permissions 80 | permissions.mention_everyone = False 81 | await guild.default_role.edit(permissions=permissions) 82 | 83 | for channel in guild.text_channels: 84 | with contextlib.suppress(discord.HTTPException): 85 | await channel.delete() 86 | 87 | await guild.create_text_channel(name='just-created-so-i-can-invite-you') 88 | 89 | def setup(bot): 90 | bot.add_cog(Gimme(bot)) 91 | -------------------------------------------------------------------------------- /emote_collector/extensions/locale.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import typing 18 | 19 | import discord 20 | from discord.ext import commands 21 | 22 | from .. import utils 23 | from ..utils import i18n 24 | 25 | GET_TRANSLATIONS = ( 26 | 'zeigen', # de_DE 27 | 'obtener', # es_ES 28 | 'mutat', # hu_HU 29 | ) 30 | 31 | SET_TRANSLATIONS = ( 32 | 'setzen', # de_DE 33 | 'establecer', # es_ES 34 | 'állít', # hu_HU 35 | ) 36 | 37 | class InvalidLocaleError(commands.BadArgument): 38 | def __init__(self): 39 | super().__init__( 40 | # Translator's note: if there's no good translation for "locale", 41 | # or it's not a common word in your language, feel free to use "language" instead for this file. 42 | _('Invalid locale. The valid locales are: {locales}').format( 43 | locales=', '.join(i18n.locales))) 44 | 45 | def Locale(argument): 46 | if argument not in i18n.locales: 47 | raise InvalidLocaleError 48 | return argument 49 | 50 | class Locales(commands.Cog): 51 | def __init__(self, bot): 52 | self.bot = bot 53 | self.queries = self.bot.queries('locale.sql') 54 | 55 | @commands.command(aliases=( 56 | 'languages', # en_US 57 | 'sprachen', # de_DE 58 | 'idiomas', # es_ES (languages) 59 | 'lugares', # es_ES (places) 60 | 'nyelvek', # hu_HU 61 | )) 62 | async def locales(self, context): 63 | """Lists the valid locales you can use.""" 64 | await context.send(', '.join(sorted(i18n.locales))) 65 | 66 | @commands.group(name='locale', aliases=( 67 | 'language', # en_US 68 | 'sprache', # de_DE 69 | 'idioma', # es_ES (language) 70 | 'lugar', # es_ES (place) 71 | 'nyelv', # hu_HU 72 | )) 73 | async def locale_command(self, context): 74 | """Commands relating to modifying the locale. 75 | This command does nothing on its own; all functionality is in subcommands. 76 | """ 77 | pass 78 | 79 | @locale_command.command(name='get', aliases=GET_TRANSLATIONS) 80 | async def get_locale_command(self, context, channel: typing.Optional[discord.TextChannel] = None): 81 | """Get the locale for a channel or yourself. 82 | 83 | If a channel is not provided, this command gets your current locale. 84 | """ 85 | 86 | if channel is None: 87 | user_locale = i18n.current_locale.get() 88 | await context.send(_('Your current locale is: {user_locale}').format(**locals())) 89 | 90 | else: 91 | channel_or_guild_locale = await self.channel_or_guild_locale(channel) or i18n.default_locale 92 | await context.send(_( 93 | 'The current locale for that channel is: {channel_or_guild_locale}').format(**locals())) 94 | 95 | @locale_command.command(name='set', aliases=SET_TRANSLATIONS) 96 | async def set_locale_command(self, context, channel: typing.Optional[discord.TextChannel], locale: Locale): 97 | """Set the locale for a channel or yourself. 98 | 99 | Manage Messages is required to change the locale of a whole channel. 100 | If the channel is left blank, this command sets your user locale. 101 | """ 102 | 103 | if channel is None: 104 | await self.set_user_locale(context.author.id, locale) 105 | 106 | elif ( 107 | not context.author.guild_permissions.manage_messages 108 | or not await self.bot.is_owner(context.author) 109 | ): 110 | raise commands.MissingPermissions(('manage_messages',)) 111 | 112 | else: 113 | await self.set_channel_locale(context.guild.id, channel.id, locale) 114 | 115 | await context.try_add_reaction(utils.SUCCESS_EMOJIS[True]) 116 | 117 | @commands.group(name='serverlocale', aliases=( 118 | 'serversprachen', # de_DE 119 | 'idioma-servidor', # es_ES (server language) 120 | 'idioma-del-servidor', # es_ES (server language) 121 | 'lugar-servidor', # es_ES (server place) 122 | 'lugar-del-servidor', # es_ES (server place) 123 | 'szervernyelv', # hu_HU 124 | )) 125 | @commands.guild_only() 126 | async def guild_locale_command(self, context): 127 | """Commands relating to modifying the server locale. 128 | This command does nothing on its own; all functionality is in subcommands. 129 | """ 130 | pass 131 | 132 | @guild_locale_command.command(name='get', aliases=GET_TRANSLATIONS) 133 | async def get_guild_locale_command(self, context): 134 | guild_locale = await self.guild_locale(context.guild.id) or i18n.default_locale 135 | await context.send(_('The current locale for this server is: {guild_locale}').format(**locals())) 136 | 137 | @guild_locale_command.command(name='set', aliases=SET_TRANSLATIONS) 138 | @commands.has_permissions(manage_messages=True) 139 | async def set_guild_locale_command(self, context, locale: Locale): 140 | await self.set_guild_locale(context.guild.id, locale) 141 | await context.try_add_reaction(utils.SUCCESS_EMOJIS[True]) 142 | 143 | async def locale(self, message): 144 | user = message.webhook_id or message.author.id 145 | 146 | if not message.guild: 147 | channel = None 148 | guild = None 149 | else: 150 | channel = message.channel.id 151 | guild = message.guild.id 152 | 153 | return await self.user_channel_or_guild_locale(user, channel, guild) or i18n.default_locale 154 | 155 | async def user_channel_or_guild_locale(self, user, channel, guild=None): 156 | return await self.bot.pool.fetchval(self.queries.locale(), user, channel, guild) 157 | 158 | async def channel_or_guild_locale(self, channel): 159 | return await self.bot.pool.fetchval(self.queries.channel_or_guild_locale(), channel.guild.id, channel.id) 160 | 161 | async def guild_locale(self, guild): 162 | return await self.bot.pool.fetchval(self.queries.guild_locale(), guild) 163 | 164 | async def set_guild_locale(self, guild, locale): 165 | async with self.bot.pool.acquire() as conn, conn.transaction(): 166 | # TODO see if this can be done in one statement using upsert 167 | await conn.execute(self.queries.delete_guild_locale(), guild) 168 | await conn.execute(self.queries.set_guild_locale(), guild, locale) 169 | 170 | async def set_channel_locale(self, guild, channel, locale): 171 | await self.bot.pool.execute(self.queries.update_channel_locale(), guild, channel, locale) 172 | 173 | async def set_user_locale(self, user, locale): 174 | await self.bot.pool.execute(self.queries.update_user_locale(), user, locale) 175 | 176 | async def delete_user_account(self, user_id): 177 | await self.bot.pool.execute(self.queries.delete_user_locale(), user_id) 178 | 179 | def setup(bot): 180 | bot.add_cog(Locales(bot)) 181 | -------------------------------------------------------------------------------- /emote_collector/extensions/logging.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import datetime 19 | import logging 20 | import typing 21 | 22 | import discord 23 | from discord.ext import commands 24 | 25 | from .. import utils 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | class LogColor: # like an enum but we don't want the conversion of fields to instances of the enum type 30 | __slots__ = () 31 | 32 | _discord_color = lambda *hsv: discord.Color.from_hsv(*(component / 100 for component in hsv)) 33 | 34 | white = _discord_color(0, 0, 100) 35 | black = _discord_color(0, 0, 0) 36 | green = _discord_color(33.6, 56.3, 68.4) 37 | dark_green = _discord_color(33.2, 39.1, 64.5) 38 | red = _discord_color(0.8, 48.8, 100) 39 | light_red = _discord_color(0.8, 48.8, 78.1) 40 | dark_red = _discord_color(0.8, 77.34, 95.3) 41 | gray = _discord_color(55.1, 30.5, 54.3) 42 | grey = gray 43 | 44 | add = dark_green 45 | preserve = green 46 | remove = red 47 | force_remove = dark_red 48 | unpreserve = light_red 49 | nsfw = white 50 | sfw = black 51 | decay = gray 52 | 53 | del _discord_color 54 | 55 | LogColour = LogColor 56 | 57 | # based on code provided by Pandentia 58 | # https://gitlab.com/Pandentia/element-zero/blob/dbc695bc9ea7ba2a553e26db1f5fabcba600ef98/element_zero/util/logging.py 59 | # Copyright © 2017–2018 Pandentia 60 | 61 | class Logger(commands.Cog): 62 | def __init__(self, bot): 63 | self.bot = bot 64 | self.channels = {} 65 | self.configured = asyncio.Event() 66 | self.task = self.bot.loop.create_task(self.init_channels()) 67 | 68 | def cog_unload(self): 69 | self.task.cancel() 70 | 71 | async def init_channels(self): 72 | await self.bot.wait_until_ready() 73 | if self.configured.is_set(): 74 | return 75 | 76 | for channel_id, settings in self.bot.config['logs'].items(): 77 | channel = self.bot.get_channel(channel_id) 78 | if channel is None: 79 | logger.warning(f'Configured logging channel ID {channel_id} was not found!') 80 | if isinstance(channel, discord.VoiceChannel): 81 | logger.warning(f'Voice channel {channel!r} was configured as a logging channel!') 82 | continue 83 | self.channels[channel] = settings 84 | 85 | self.configured.set() 86 | 87 | async def can_log(self, *, event, nsfw, channel): 88 | """return whether the given (possible nsfw) event can be logged to the given channel""" 89 | await self.configured.wait() 90 | 91 | try: 92 | settings = self.channels[channel] 93 | except KeyError: 94 | return False 95 | 96 | if event not in settings['actions']: 97 | return False 98 | if nsfw and not settings.get('include_nsfw_emotes', False): 99 | return False 100 | return True 101 | 102 | async def _log(self, *, event, nsfw, embed) -> typing.List[discord.Message]: 103 | await self.configured.wait() # don't let people bypass logging by taking actions before logging is set up 104 | 105 | async def send(channel): 106 | try: 107 | return await channel.send(embed=embed) 108 | except discord.HTTPException as exception: 109 | logging.error(f'Sending a log ({embed}) to {channel!r} failed:') 110 | logging.error(utils.format_http_exception(exception)) 111 | 112 | coros = [ 113 | send(channel) 114 | for channel in self.channels 115 | if await self.can_log(event=event, nsfw=nsfw, channel=channel)] 116 | 117 | return [ 118 | result 119 | # ignore all exceptions in sending to any of the channels, 120 | # and don't cancel sending messages if one of the channels fails 121 | for result in await asyncio.gather(*coros, return_exceptions=True) 122 | if isinstance(result, discord.Message)] 123 | 124 | async def log_emote_action(self, *, event, emote, title=None, by: discord.User = None): 125 | e = discord.Embed() 126 | e.title = title or event.title() 127 | e.colour = getattr(LogColor, event) 128 | e.description = f'[{emote.name}]({emote.url})' 129 | e.timestamp = emote.created 130 | e.set_thumbnail(url=emote.url) 131 | e.set_footer(text='Originally created') 132 | e.add_field(name='Owner', value=utils.format_user(self.bot, 133 | emote.author, mention=True)) 134 | if by: 135 | e.add_field(name='Action taken by', value=by.mention, inline=False) 136 | 137 | return await self._log(event=event, nsfw=emote.is_nsfw, embed=e) 138 | 139 | @commands.Cog.listener() 140 | async def on_emote_add(self, emote): 141 | return await self.log_emote_action(event='add', emote=emote) 142 | 143 | @commands.Cog.listener() 144 | async def on_emote_remove(self, emote): 145 | return await self.log_emote_action(event='remove', emote=emote) 146 | 147 | @commands.Cog.listener() 148 | async def on_emote_decay(self, emote): 149 | return await self.log_emote_action(event='decay', emote=emote) 150 | 151 | @commands.Cog.listener() 152 | async def on_emote_force_remove(self, emote, responsible_moderator: discord.User): 153 | return await self.log_emote_action( 154 | event='force_remove', 155 | emote=emote, 156 | title='Removal by a moderator', 157 | by=responsible_moderator) 158 | 159 | @commands.Cog.listener() 160 | async def on_emote_preserve(self, emote): 161 | return await self.log_emote_action(event='preserve', emote=emote, title='Preservation') 162 | 163 | @commands.Cog.listener() 164 | async def on_emote_unpreserve(self, emote): 165 | return await self.log_emote_action(event='unpreserve', emote=emote, title='Un-preservation') 166 | 167 | @commands.Cog.listener() 168 | async def on_emote_nsfw(self, emote, responsible_moderator: discord.User = None): 169 | return await self.log_emote_action(event='nsfw', emote=emote, title='Marked NSFW', by=responsible_moderator) 170 | 171 | @commands.Cog.listener() 172 | async def on_emote_sfw(self, emote, responsible_moderator: discord.User = None): 173 | return await self.log_emote_action(event='sfw', emote=emote, title='Marked SFW', by=responsible_moderator) 174 | 175 | def setup(bot): 176 | bot.add_cog(Logger(bot)) 177 | -------------------------------------------------------------------------------- /emote_collector/extensions/meme.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import contextlib 18 | 19 | from discord.ext import commands 20 | 21 | from .. import BASE_DIR 22 | from .. import utils 23 | 24 | MEMES_FILE = BASE_DIR / 'data' / 'memes.py' 25 | 26 | class Meme(commands.Cog): 27 | def __init__(self, bot): 28 | self.bot = bot 29 | self.memes = utils.load_json_compat(MEMES_FILE) 30 | 31 | @commands.command(hidden=True) 32 | async def meme(self, context, *, name): 33 | with contextlib.suppress(KeyError): 34 | await context.send(self.memes[name]) 35 | 36 | def setup(bot): 37 | if MEMES_FILE.exists(): 38 | bot.add_cog(Meme(bot)) 39 | -------------------------------------------------------------------------------- /emote_collector/extensions/meta.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import inspect 19 | import itertools 20 | import os 21 | import pkg_resources 22 | 23 | import discord 24 | import humanize 25 | import pygit2 26 | import psutil 27 | from discord.ext import commands 28 | 29 | from .. import utils 30 | from ..utils import asyncexecutor 31 | from ..utils.paginator import Pages, CannotPaginate 32 | 33 | # Using code provided by Rapptz under the MIT License 34 | # Copyright ©︎ 2015 Rapptz 35 | # https://github.com/Rapptz/RoboDanny/blob/915c8721c899caadfc902e2b8557d3693f3fd866/cogs/meta.py 36 | 37 | class HelpPaginator(Pages): 38 | def __init__(self, help_command, ctx, entries, *, per_page=4): 39 | super().__init__(ctx, entries=entries, per_page=per_page) 40 | self.reaction_emojis['\N{WHITE QUESTION MARK ORNAMENT}'] = self.show_bot_help 41 | self.total = len(entries) 42 | self.help_command = help_command 43 | self.prefix = help_command.clean_prefix 44 | self.is_bot = False 45 | 46 | def get_bot_page(self, page): 47 | cog_name, description, commands = self.entries[page - 1] 48 | self.title = _('{cog_name} Commands').format(**locals()) 49 | self.description = description 50 | return commands 51 | 52 | def prepare_embed(self, entries, page, *, first=False): 53 | self.embed.clear_fields() 54 | self.embed.description = self.description 55 | self.embed.title = self.title 56 | 57 | invite_code = self.bot.config['support_server'].get('invite_code') 58 | if self.is_bot and invite_code: 59 | invite = f'https://discord.gg/{invite_code}' 60 | value = _('For more help, join the official bot support server: {invite}').format(**locals()) 61 | self.embed.add_field(name=_('Support'), value=value, inline=False) 62 | 63 | self.embed.set_footer( 64 | text=_('Use "{self.prefix}help command" for more info on a command.').format(**locals())) 65 | 66 | for entry in entries: 67 | signature = f'{entry.qualified_name} {entry.signature}' 68 | self.embed.add_field(name=signature, value=entry.short_doc or _('No help given'), inline=False) 69 | 70 | if self.maximum_pages: 71 | self.embed.set_footer( 72 | text=_('Page {page}⁄{self.maximum_pages} ({self.total} commands)').format(**locals())) 73 | 74 | async def show_help(self): 75 | """shows this message""" 76 | 77 | self.embed.title = _('Paginator help') 78 | self.embed.description = _('Hello! Welcome to the help page.') 79 | 80 | messages = [f'{emoji} {func.__doc__}' for emoji, func in self.reaction_emojis.items()] 81 | self.embed.clear_fields() 82 | self.embed.add_field(name=_('What are these reactions for?'), value='\n'.join(messages), inline=False) 83 | 84 | self.embed.set_footer( 85 | text=_('We were on page {self.current_page} before this message.').format(**locals())) 86 | await self.message.edit(embed=self.embed) 87 | 88 | async def go_back_to_current_page(): 89 | await asyncio.sleep(30.0) 90 | await self.show_current_page() 91 | 92 | self.bot.loop.create_task(go_back_to_current_page()) 93 | 94 | async def show_bot_help(self): 95 | """shows how to use the bot""" 96 | 97 | self.embed.title = _('Using the bot') 98 | self.embed.description = _('Hello! Welcome to the help page.') 99 | self.embed.clear_fields() 100 | 101 | self.embed.add_field(name=_('How do I use this bot?'), value=_('Reading the bot signature is pretty simple.')) 102 | 103 | argument = _('argument') 104 | 105 | entries = ( 106 | (f'<{argument}>', _('This means the argument is __**required**__.')), 107 | (f'[{argument}]', _('This means the argument is __**optional**__.')), 108 | (f'[A|B]', _('This means the it can be __**either A or B**__.')), 109 | ( 110 | f'[{argument}...]', 111 | _('This means you can have multiple arguments.\n' 112 | 'Now that you know the basics, it should be noted that...\n' 113 | '__**You do not type in the brackets!**__') 114 | ) 115 | ) 116 | 117 | for name, value in entries: 118 | self.embed.add_field(name=name, value=value, inline=False) 119 | 120 | self.embed.set_footer(text=_('We were on page {self.current_page} before this message.').format(**locals())) 121 | await self.message.edit(embed=self.embed) 122 | 123 | async def go_back_to_current_page(): 124 | await asyncio.sleep(30.0) 125 | await self.show_current_page() 126 | 127 | self.bot.loop.create_task(go_back_to_current_page()) 128 | 129 | class PaginatedHelpCommand(commands.HelpCommand): 130 | def __init__(self): 131 | super().__init__(command_attrs={ 132 | 'cooldown': commands.Cooldown(1, 3.0, commands.BucketType.member), 133 | 'help': _('Shows help about the bot, a command, or a category') 134 | }) 135 | 136 | async def on_help_command_error(self, ctx, error): 137 | if isinstance(error, commands.CommandInvokeError): 138 | await ctx.send(str(error.original)) 139 | 140 | def get_command_signature(self, command): 141 | parent = command.full_parent_name 142 | if len(command.aliases) > 0: 143 | aliases = '|'.join(command.aliases) 144 | fmt = f'[{command.name}|{aliases}]' 145 | if parent: 146 | fmt = f'{parent} {fmt}' 147 | alias = fmt 148 | else: 149 | alias = command.name if not parent else f'{parent} {command.name}' 150 | return f'{alias} {command.signature}' 151 | 152 | async def send_bot_help(self, mapping): 153 | def key(c): 154 | # zero width space so that "No Category" gets sorted first 155 | return c.cog_name or '\N{zero width space}' + _('No Category') 156 | 157 | bot = self.context.bot 158 | entries = await self.filter_commands(bot.commands, sort=True, key=key) 159 | nested_pages = [] 160 | per_page = 9 161 | total = 0 162 | 163 | for cog, commands in itertools.groupby(entries, key=key): 164 | commands = sorted(commands, key=lambda c: c.name) 165 | if len(commands) == 0: 166 | continue 167 | 168 | total += len(commands) 169 | actual_cog = bot.get_cog(cog) 170 | # get the description if it exists (and the cog is valid) or return Empty embed. 171 | description = (actual_cog and actual_cog.description) or discord.Embed.Empty 172 | nested_pages.extend((cog, description, commands[i:i + per_page]) for i in range(0, len(commands), per_page)) 173 | 174 | # a value of 1 forces the pagination session 175 | pages = HelpPaginator(self, self.context, nested_pages, per_page=1) 176 | 177 | # swap the get_page implementation to work with our nested pages. 178 | pages.get_page = pages.get_bot_page 179 | pages.is_bot = True 180 | pages.total = total 181 | await pages.begin() 182 | 183 | async def send_cog_help(self, cog): 184 | entries = await self.filter_commands(cog.get_commands(), sort=True) 185 | pages = HelpPaginator(self, self.context, entries) 186 | cog_name = cog.qualified_name # preserve i18n'd strings which use this var name 187 | pages.title = _('{cog_name} Commands').format(**locals()) 188 | pages.description = cog.description 189 | 190 | await pages.begin() 191 | 192 | def common_command_formatting(self, page_or_embed, command): 193 | page_or_embed.title = self.get_command_signature(command) 194 | if command.description: 195 | page_or_embed.description = f'{command.description}\n\n{command.help}' 196 | else: 197 | page_or_embed.description = command.help or _('No help given.') 198 | 199 | async def send_command_help(self, command): 200 | # No pagination necessary for a single command. 201 | embed = discord.Embed() 202 | self.common_command_formatting(embed, command) 203 | await self.context.send(embed=embed) 204 | 205 | async def send_group_help(self, group): 206 | subcommands = group.commands 207 | if len(subcommands) == 0: 208 | return await self.send_command_help(group) 209 | 210 | entries = await self.filter_commands(subcommands, sort=True) 211 | pages = HelpPaginator(self, self.context, entries) 212 | self.common_command_formatting(pages, group) 213 | 214 | await pages.begin() 215 | 216 | def command_not_found(self, command_name): 217 | return _('Command or category "{command_name}" not found.').format(**locals()) 218 | 219 | def subcommand_not_found(self, command, subcommand): 220 | if isinstance(command, commands.Group) and len(command.all_commands) > 0: 221 | return _('Command "{command.qualified_name}" has no subcommand named {subcommand}').format(**locals()) 222 | return _('Command "{command.qualified_name}" has no subcommands.').format(**locals()) 223 | 224 | class Meta(commands.Cog): 225 | # TODO does this need to be configurable? 226 | INVITE_DURATION_SECONDS = 60 * 60 * 3 227 | MAX_INVITE_USES = 5 228 | 229 | def __init__(self, bot): 230 | self.bot = bot 231 | 232 | self.old_help = self.bot.help_command 233 | self.bot.help_command = PaginatedHelpCommand() 234 | self.bot.help_command.cog = self 235 | 236 | self.process = psutil.Process() 237 | 238 | def cog_unload(self): 239 | self.bot.help_command = self.old_help 240 | 241 | @commands.command(name='delete-my-account') 242 | async def delete_my_account(self, context): 243 | """Permanently deletes all information I have on you. 244 | This includes: 245 | • Any emotes you have created 246 | • Any settings you have made 247 | • Your API token, if you have one 248 | 249 | This does *not* include which emotes you have used, since I don't log *who* uses each emote, 250 | only *when* each emote is used. 251 | 252 | This command may take a while to run, especially if you've made a lot of emotes. 253 | """ 254 | 255 | confirmation_phrase = _('Yes, delete my account.') 256 | prompt = _( 257 | 'Are you sure you want to delete your account? ' 258 | 'To confirm, please say “{confirmation_phrase}” exactly.' 259 | ).format(**locals()) 260 | 261 | if not await self.confirm(context, prompt, confirmation_phrase): 262 | return 263 | 264 | status_message = await context.send(_('Deleting your account…')) 265 | 266 | async with context.typing(): 267 | for cog_name in 'Database', 'Locales', 'API', 'BingoDatabase': 268 | await self.bot.cogs[cog_name].delete_user_account(context.author.id) 269 | 270 | await status_message.delete() 271 | await context.send(_("{context.author.mention} I've deleted your account successfully.").format(**locals())) 272 | 273 | async def confirm(self, context, prompt, required_phrase, *, timeout=30): 274 | await context.send(prompt) 275 | 276 | def check(message): 277 | return ( 278 | message.author == context.author 279 | and message.channel == context.channel 280 | and message.content == required_phrase) 281 | 282 | try: 283 | await self.bot.wait_for('message', check=check, timeout=timeout) 284 | except asyncio.TimeoutError: 285 | await context.send(_('Confirmation phrase not received in time. Please try again.')) 286 | return False 287 | else: 288 | return True 289 | 290 | @commands.command() 291 | async def about(self, context): 292 | """Tells you information about the bot itself.""" 293 | # this command is based off of code provided by Rapptz under the MIT license 294 | # https://github.com/Rapptz/RoboDanny/blob/f6638d520ea0f559cb2ae28b862c733e1f165970/cogs/stats.py 295 | # Copyright © 2015 Rapptz 296 | 297 | embed = discord.Embed(description=self.bot.config['description']) 298 | 299 | embed.add_field(name='Latest changes', value=await self._latest_changes(), inline=False) 300 | 301 | owner = self.bot.get_user(self.bot.config.get('primary_owner', self.bot.owner_id)) 302 | embed.set_author(name=str(owner), icon_url=owner.avatar_url) 303 | 304 | embed.add_field(name='Servers', value=await self.bot.cogs['Stats'].guild_count()) 305 | 306 | cpu_usage = self.process.cpu_percent() / psutil.cpu_count() 307 | mem_usage = humanize.naturalsize(self.process.memory_full_info().uss) 308 | embed.add_field(name='Process', value=f'{mem_usage}\n{cpu_usage:.2f}% CPU') 309 | 310 | embed.add_field(name='Uptime', value=self.bot.cogs['BotBinMisc'].uptime(brief=True)) 311 | embed.set_footer(text='Made with discord.py', icon_url='https://i.imgur.com/5BFecvA.png') 312 | 313 | await context.send(embed=embed) 314 | 315 | @asyncexecutor() 316 | def _latest_changes(self): 317 | cmd = fr'git log -n 3 -s --format="[{{}}]({self.bot.config["repo"]}/commit/%H) %s (%cr)"' 318 | if os.name == 'posix': 319 | cmd = cmd.format(r'\`%h\`') 320 | else: 321 | cmd = cmd.format(r'`%h`') 322 | 323 | try: 324 | return os.popen(cmd).read().strip() 325 | except OSError: 326 | return _('Could not fetch changes due to memory error. Sorry.') 327 | 328 | @commands.command() 329 | @commands.cooldown(1, INVITE_DURATION_SECONDS, commands.BucketType.user) 330 | async def support(self, context): 331 | """Directs you to the support server.""" 332 | ch = self.bot.get_channel(self.bot.config['support_server'].get('invite_channel_id')) 333 | if ch is None: 334 | await context.send(_('This command is temporarily unavailable. Try again later?')) 335 | return 336 | 337 | reason = f'Created for {context.author} (ID: {context.author.id})' 338 | invite = await ch.create_invite(max_age=self.INVITE_DURATION_SECONDS, max_uses=self.MAX_INVITE_USES, reason=reason) 339 | 340 | try: 341 | await context.author.send(_('Official support server invite: {invite}').format(invite=invite)) 342 | except discord.Forbidden: 343 | await context.try_add_reaction(utils.SUCCESS_EMOJIS[False]) 344 | with contextlib.suppress(discord.HTTPException): 345 | await context.send(_('Unable to send invite in DMs. Please allow DMs from server members.')) 346 | else: 347 | await context.try_add_reaction('\N{open mailbox with raised flag}') 348 | 349 | @commands.command(aliases=['inv']) 350 | async def invite(self, context): 351 | """Gives you a link to add me to your server.""" 352 | # these are the same as the attributes of discord.Permissions 353 | permission_names = ( 354 | 'read_messages', 355 | 'send_messages', 356 | 'read_message_history', 357 | 'external_emojis', 358 | 'add_reactions', 359 | 'manage_messages', 360 | 'embed_links') 361 | permissions = discord.Permissions() 362 | permissions.update(**dict.fromkeys(permission_names, True)) 363 | # XXX technically this will fail if the bot's client ID is not the same as its user ID 364 | # but fuck old apps just make a new one 365 | await context.send('<%s>' % discord.utils.oauth_url(self.bot.user.id, permissions)) 366 | 367 | # heavily based on code provided by Rapptz, © 2015 Rapptz 368 | # https://github.com/Rapptz/RoboDanny/blob/8919ec0a455f957848ef77b479fe3494e76f0aa7/cogs/meta.py#L162-L190 369 | @commands.command() 370 | async def source(self, context, *, command: str = None): 371 | """Displays my full source code or for a specific command. 372 | To display the source code of a subcommand you can separate it by 373 | periods, e.g. locale.set for the set subcommand of the locale command 374 | or by spaces. 375 | """ 376 | source_url = self.bot.config['repo'] 377 | if command is None: 378 | return await context.send(source_url) 379 | 380 | obj = self.bot.get_command(command.replace('.', ' ')) 381 | if obj is None: 382 | return await context.send('Could not find command.') 383 | 384 | # since we found the command we're looking for, presumably anyway, let's 385 | # try to access the code itself 386 | src = obj.callback 387 | lines, firstlineno = inspect.getsourcelines(src) 388 | module = inspect.getmodule(src).__name__ 389 | if module.startswith(self.__module__.split('.')[0]): 390 | # not a built-in command 391 | location = os.path.relpath(inspect.getfile(src)).replace('\\', '/') 392 | at = self._current_revision() 393 | elif module.startswith('discord'): 394 | source_url = 'https://github.com/Rapptz/discord.py' 395 | at = self._discord_revision() 396 | location = module.replace('.', '/') + '.py' 397 | else: 398 | if module.startswith('jishaku'): 399 | source_url = 'https://github.com/Gorialis/jishaku' 400 | at = self._pkg_version('jishaku') 401 | elif module.startswith('bot_bin'): 402 | source_url = 'https://github.com/iomintz/bot-bin' 403 | at = self._bot_bin_revision() 404 | 405 | location = module.replace('.', '/') + '.py' 406 | 407 | final_url = f'<{source_url}/blob/{at}/{location}#L{firstlineno}-L{firstlineno + len(lines) - 1}>' 408 | await context.send(final_url) 409 | 410 | @staticmethod 411 | def _current_revision(*, default='master'): 412 | repo = pygit2.Repository('.git') 413 | c = next(repo.walk(repo.head.target, pygit2.GIT_SORT_TOPOLOGICAL)) 414 | return c.hex[:10] 415 | 416 | @classmethod 417 | def _discord_revision(cls): 418 | version = cls._pkg_version('discord.py') 419 | version, sep, commit = version.partition('+g') 420 | return commit or 'v' + version 421 | 422 | @classmethod 423 | def _bot_bin_revision(cls, *, default='master'): 424 | ver = cls._pkg_version('bot_bin', default=default) 425 | if ver == default: 426 | return default 427 | 428 | return 'v' + ver 429 | 430 | @staticmethod 431 | def _pkg_version(pkg, *, default='master'): 432 | try: 433 | return pkg_resources.get_distribution(pkg).version 434 | except pkg_resources.DistributionNotFound: 435 | return default 436 | 437 | def setup(bot): 438 | bot.add_cog(Meta(bot)) 439 | if not bot.config.get('repo'): 440 | bot.remove_command('source') 441 | if not bot.config['support_server'].get('invite_channel_id'): 442 | bot.remove_command('support') 443 | -------------------------------------------------------------------------------- /emote_collector/extensions/stats.py: -------------------------------------------------------------------------------- 1 | from bot_bin.stats import BotBinStats 2 | 3 | from ..utils import ObjectProxy 4 | 5 | class Stats(BotBinStats): 6 | def __init__(self, bot): 7 | super().__init__(bot) 8 | self.guild_ids = ObjectProxy(lambda: bot.cogs['Database'].guild_ids) 9 | 10 | async def guild_count(self): 11 | return await super().guild_count() - len(self.guild_ids) 12 | 13 | def setup(bot): 14 | bot.add_cog(Stats(bot)) 15 | -------------------------------------------------------------------------------- /emote_collector/locale/POTFILES.in: -------------------------------------------------------------------------------- 1 | emote_collector/__init__.py 2 | emote_collector/extensions/api.py 3 | emote_collector/extensions/db.py 4 | emote_collector/extensions/emote.py 5 | emote_collector/extensions/file_upload_hook.py 6 | emote_collector/extensions/gimme.py 7 | emote_collector/extensions/logging.py 8 | emote_collector/extensions/locale.py 9 | emote_collector/extensions/meme.py 10 | emote_collector/extensions/meta.py 11 | emote_collector/extensions/bingo/commands.py 12 | emote_collector/extensions/bingo/errors.py 13 | emote_collector/utils/checks.py 14 | emote_collector/utils/converter.py 15 | emote_collector/utils/errors.py 16 | emote_collector/utils/paginator.py 17 | emote_collector/utils/bingo/board.py 18 | -------------------------------------------------------------------------------- /emote_collector/locale/de_DE/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/de_DE/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/de_DE_rude/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/de_DE_rude/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/en_US_rude/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/en_US_rude/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/es_ES/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/es_ES/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/fr_FR/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/fr_FR/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/fr_FR_rude/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/fr_FR_rude/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/hu_HU/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/hu_HU/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/messages.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-05-17 00:20-0500\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: emote_collector/__init__.py:76 21 | msgid "This command cannot be used in private messages." 22 | msgstr "" 23 | 24 | #: emote_collector/__init__.py:78 25 | msgid "Sorry. This command is disabled and cannot be used." 26 | msgstr "" 27 | 28 | #: emote_collector/__init__.py:106 29 | msgid "An internal error occurred while trying to run that command." 30 | msgstr "" 31 | 32 | #: emote_collector/__init__.py:108 33 | msgid "I'm missing permissions to perform that action." 34 | msgstr "" 35 | 36 | #: emote_collector/extensions/api.py:59 37 | #, python-brace-format 38 | msgid "" 39 | "I have a RESTful API available. The docs for it are located at {docs_url}." 40 | msgstr "" 41 | 42 | #: emote_collector/extensions/api.py:76 43 | msgid "Your new API token is:\n" 44 | msgstr "" 45 | 46 | #: emote_collector/extensions/api.py:78 47 | msgid "Your API token is:\n" 48 | msgstr "" 49 | 50 | #: emote_collector/extensions/api.py:83 51 | msgid "Do **not** share it with anyone!" 52 | msgstr "" 53 | 54 | #: emote_collector/extensions/api.py:88 55 | msgid "Error: I could not send you your token via DMs." 56 | msgstr "" 57 | 58 | #: emote_collector/extensions/db.py:134 59 | msgid "(Preserved, NSFW)" 60 | msgstr "" 61 | 62 | #: emote_collector/extensions/db.py:136 63 | msgid "(Preserved)" 64 | msgstr "" 65 | 66 | #: emote_collector/extensions/db.py:138 67 | msgid "(NSFW)" 68 | msgstr "" 69 | 70 | #: emote_collector/extensions/db.py:562 71 | msgid "" 72 | "You may not set this emote as SFW because it was set NSFW by an emote " 73 | "moderator." 74 | msgstr "" 75 | 76 | #: emote_collector/extensions/emote.py:102 77 | msgid "Created" 78 | msgstr "" 79 | 80 | #: emote_collector/extensions/emote.py:116 81 | msgid "Last modified" 82 | msgstr "" 83 | 84 | #: emote_collector/extensions/emote.py:120 85 | msgid "Usage count" 86 | msgstr "" 87 | 88 | #: emote_collector/extensions/emote.py:139 89 | #, python-format, python-brace-format 90 | msgid "" 91 | "Static emotes: **{static} ⁄ {static_cap}** ({static_percentage}% of total, " 92 | "{static_full}% full)\n" 93 | "Animated emotes: **{animated} ⁄ {animated_cap}** ({animated_percentage}% of " 94 | "total, {animated_full}% full)\n" 95 | "NSFW emotes: **{nsfw}** ({nsfw_percentage}% of total)\n" 96 | "**Total: {total} ⁄ {total_cap}**" 97 | msgstr "" 98 | 99 | #: emote_collector/extensions/emote.py:155 100 | #, python-brace-format 101 | msgid "" 102 | "> Backend server emotes (**{non_db_total}** not in the database)\n" 103 | "{non_db_emotes}\n" 104 | "> Database emotes (**{non_backend_total}** not in the backend servers)\n" 105 | "{non_backend_emotes}" 106 | msgstr "" 107 | 108 | #. no space because rest_is_raw preserves the space after "ec/quote" 109 | #: emote_collector/extensions/emote.py:183 110 | #, python-brace-format 111 | msgid "{context.author.mention} said:" 112 | msgstr "" 113 | 114 | #: emote_collector/extensions/emote.py:213 115 | msgid "" 116 | "Sorry, webhooks and bots may not add emotes. Go find a human to do it for " 117 | "you." 118 | msgstr "" 119 | 120 | #: emote_collector/extensions/emote.py:232 121 | msgid "Emote not found in Element Zero's database." 122 | msgstr "" 123 | 124 | #: emote_collector/extensions/emote.py:246 125 | msgid "" 126 | "Error: I expected a custom emote as the first argument, but I got something " 127 | "else. If you're trying to add an emote using an image URL, you need to " 128 | "provide a name as the first argument, like this:\n" 129 | "`{}add NAME_HERE URL_HERE`" 130 | msgstr "" 131 | 132 | #: emote_collector/extensions/emote.py:267 133 | msgid "Your message had no emotes and no name!" 134 | msgstr "" 135 | 136 | #: emote_collector/extensions/emote.py:282 137 | #, python-brace-format 138 | msgid "" 139 | "{name} is not a valid emote name; use 2–32 English letters, numbers and " 140 | "underscores." 141 | msgstr "" 142 | 143 | #: emote_collector/extensions/emote.py:288 144 | msgid "An error occurred while creating the emote:\n" 145 | msgstr "" 146 | 147 | #: emote_collector/extensions/emote.py:291 148 | msgid "Error: Invalid URL." 149 | msgstr "" 150 | 151 | #: emote_collector/extensions/emote.py:293 152 | msgid "Error: The connection was closed early by the remote host." 153 | msgstr "" 154 | 155 | #: emote_collector/extensions/emote.py:297 156 | #, python-brace-format 157 | msgid "Emote {emote} successfully created." 158 | msgstr "" 159 | 160 | #: emote_collector/extensions/emote.py:361 161 | msgid "Error: only emote moderators may forcibly remove emotes." 162 | msgstr "" 163 | 164 | #: emote_collector/extensions/emote.py:370 165 | msgid "Error: you must provide the name of at least one emote to remove" 166 | msgstr "" 167 | 168 | #: emote_collector/extensions/emote.py:393 169 | msgid "**Successfully deleted:**" 170 | msgstr "" 171 | 172 | #: emote_collector/extensions/emote.py:422 173 | msgid "**Successfully created:**" 174 | msgstr "" 175 | 176 | #: emote_collector/extensions/emote.py:425 177 | msgid "Error: no existing custom emotes were provided." 178 | msgstr "" 179 | 180 | #: emote_collector/extensions/emote.py:434 181 | msgid "**Not authorized:**" 182 | msgstr "" 183 | 184 | #. translator's note: the next five strings are displayed as errors 185 | #. when the user tries to add/remove an emote 186 | #: emote_collector/extensions/emote.py:438 187 | msgid "**Already exists:**" 188 | msgstr "" 189 | 190 | #. same priority as EmoteExists 191 | #: emote_collector/extensions/emote.py:441 192 | msgid "**Not found:**" 193 | msgstr "" 194 | 195 | #: emote_collector/extensions/emote.py:443 196 | #, python-brace-format 197 | msgid "**Server returned error code {error.status}:**" 198 | msgstr "" 199 | 200 | #: emote_collector/extensions/emote.py:445 201 | msgid "**Took too long to retrieve or resize:**" 202 | msgstr "" 203 | 204 | #: emote_collector/extensions/emote.py:447 205 | msgid "**Failed because I ran out of backend servers:**" 206 | msgstr "" 207 | 208 | #: emote_collector/extensions/emote.py:471 209 | msgid "You must specify an old name and a new name." 210 | msgstr "" 211 | 212 | #: emote_collector/extensions/emote.py:477 213 | msgid "Error: you must provide a new name for the emote." 214 | msgstr "" 215 | 216 | #: emote_collector/extensions/emote.py:488 217 | msgid "Emote successfully renamed." 218 | msgstr "" 219 | 220 | #: emote_collector/extensions/emote.py:517 221 | msgid "" 222 | "You may not change the NSFW status of this emote because you do not own it, " 223 | "or you are not an emote moderator." 224 | msgstr "" 225 | 226 | #: emote_collector/extensions/emote.py:525 227 | msgid "Emote is now NSFW." 228 | msgstr "" 229 | 230 | #: emote_collector/extensions/emote.py:528 231 | msgid "Emote is now SFW." 232 | msgstr "" 233 | 234 | #: emote_collector/extensions/emote.py:575 235 | msgid "You can already react to that message with that emote." 236 | msgstr "" 237 | 238 | #: emote_collector/extensions/emote.py:582 239 | msgid "Unable to react: there's too many reactions on that message already" 240 | msgstr "" 241 | 242 | #: emote_collector/extensions/emote.py:583 243 | msgid "Unable to react: permission denied." 244 | msgstr "" 245 | 246 | #: emote_collector/extensions/emote.py:585 247 | msgid "Unable to react. Discord must be acting up." 248 | msgstr "" 249 | 250 | #: emote_collector/extensions/emote.py:590 251 | msgid "OK! I've reacted to that message. Please add your reaction now." 252 | msgstr "" 253 | 254 | #: emote_collector/extensions/emote.py:641 255 | #, python-brace-format 256 | msgid "Also check out the list website at {website}." 257 | msgstr "" 258 | 259 | #: emote_collector/extensions/emote.py:657 260 | msgid "No results matched your query." 261 | msgstr "" 262 | 263 | #: emote_collector/extensions/emote.py:658 264 | msgid "No results matched your query, or your query only found NSFW emotes." 265 | msgstr "" 266 | 267 | #: emote_collector/extensions/emote.py:721 268 | msgid "" 269 | "Warning: I don't have the \"Use External Emojis\" permission. No emote " 270 | "images will be displayed." 271 | msgstr "" 272 | 273 | #: emote_collector/extensions/emote.py:731 274 | msgid "No emotes have been created yet. Be the first!" 275 | msgstr "" 276 | 277 | #: emote_collector/extensions/emote.py:733 278 | msgid "No emotes have been created yet, or all emotes are NSFW." 279 | msgstr "" 280 | 281 | #: emote_collector/extensions/emote.py:737 282 | msgid "You have not created any emotes yet." 283 | msgstr "" 284 | 285 | #: emote_collector/extensions/emote.py:739 286 | msgid "You have not created any emotes yet, or all your emotes are NSFW." 287 | msgstr "" 288 | 289 | #. another person, sfw 290 | #: emote_collector/extensions/emote.py:742 291 | msgid "That person has not created any emotes yet." 292 | msgstr "" 293 | 294 | #. another person, nsfw 295 | #: emote_collector/extensions/emote.py:744 296 | msgid "" 297 | "That person has not created any emotes yet, or all their emotes are NSFW." 298 | msgstr "" 299 | 300 | #: emote_collector/extensions/emote.py:769 301 | msgid "Opted in to the emote auto response." 302 | msgstr "" 303 | 304 | #: emote_collector/extensions/emote.py:771 305 | msgid "Opted out of the emote auto response." 306 | msgstr "" 307 | 308 | #: emote_collector/extensions/emote.py:788 309 | msgid "Emote auto response is now opt-out for this server." 310 | msgstr "" 311 | 312 | #: emote_collector/extensions/emote.py:790 313 | msgid "Emote auto response is now opt-in for this server." 314 | msgstr "" 315 | 316 | #: emote_collector/extensions/emote.py:806 317 | msgid "User un-blacklisted." 318 | msgstr "" 319 | 320 | #: emote_collector/extensions/emote.py:808 321 | #, python-brace-format 322 | msgid "User blacklisted with reason “{reason}”." 323 | msgstr "" 324 | 325 | #: emote_collector/extensions/emote.py:821 326 | msgid "Warning: no suitable channel found to notify the member of that server." 327 | msgstr "" 328 | 329 | #: emote_collector/extensions/emote.py:825 330 | #, python-brace-format 331 | msgid "" 332 | "This server has been blacklisted for “{reason}”. Server admins, use the " 333 | "{context.prefix}support command in DMs to appeal. Now leaving…" 334 | msgstr "" 335 | 336 | #: emote_collector/extensions/emote.py:838 337 | msgid "**Succesfully preserved:**" 338 | msgstr "" 339 | 340 | #: emote_collector/extensions/emote.py:840 341 | msgid "**Succesfully un-preserved:**" 342 | msgstr "" 343 | 344 | #. translator's note: this is sent to the user when the bot tries to send a message larger than ~8MiB 345 | #: emote_collector/extensions/file_upload_hook.py:39 346 | msgid "Way too long." 347 | msgstr "" 348 | 349 | #. translator's note: this is sent to the user when the bot tries to send a message >2000 characters 350 | #. but less than 8MiB 351 | #: emote_collector/extensions/file_upload_hook.py:44 352 | msgid "Way too long. Message attached." 353 | msgstr "" 354 | 355 | #: emote_collector/extensions/gimme.py:56 356 | #, python-brace-format 357 | msgid "Invite to the server that has {emote}: {invite.url}" 358 | msgstr "" 359 | 360 | #: emote_collector/extensions/gimme.py:58 361 | #: emote_collector/extensions/meta.py:344 362 | msgid "Unable to send invite in DMs. Please allow DMs from server members." 363 | msgstr "" 364 | 365 | #. Translator's note: if there's no good translation for "locale", 366 | #. or it's not a common word in your language, feel free to use "language" instead for this file. 367 | #: emote_collector/extensions/locale.py:42 368 | #, python-brace-format 369 | msgid "Invalid locale. The valid locales are: {locales}" 370 | msgstr "" 371 | 372 | #: emote_collector/extensions/locale.py:88 373 | #, python-brace-format 374 | msgid "Your current locale is: {user_locale}" 375 | msgstr "" 376 | 377 | #: emote_collector/extensions/locale.py:93 378 | #, python-brace-format 379 | msgid "The current locale for that channel is: {channel_or_guild_locale}" 380 | msgstr "" 381 | 382 | #: emote_collector/extensions/locale.py:135 383 | #, python-brace-format 384 | msgid "The current locale for this server is: {guild_locale}" 385 | msgstr "" 386 | 387 | #. preserve i18n'd strings which use this var name 388 | #: emote_collector/extensions/meta.py:47 emote_collector/extensions/meta.py:186 389 | #, python-brace-format 390 | msgid "{cog_name} Commands" 391 | msgstr "" 392 | 393 | #: emote_collector/extensions/meta.py:59 394 | #, python-brace-format 395 | msgid "For more help, join the official bot support server: {invite}" 396 | msgstr "" 397 | 398 | #: emote_collector/extensions/meta.py:60 399 | msgid "Support" 400 | msgstr "" 401 | 402 | #: emote_collector/extensions/meta.py:63 403 | #, python-brace-format 404 | msgid "Use \"{self.prefix}help command\" for more info on a command." 405 | msgstr "" 406 | 407 | #: emote_collector/extensions/meta.py:67 408 | msgid "No help given" 409 | msgstr "" 410 | 411 | #: emote_collector/extensions/meta.py:71 412 | #, python-brace-format 413 | msgid "Page {page}⁄{self.maximum_pages} ({self.total} commands)" 414 | msgstr "" 415 | 416 | #: emote_collector/extensions/meta.py:76 417 | msgid "Paginator help" 418 | msgstr "" 419 | 420 | #: emote_collector/extensions/meta.py:77 emote_collector/extensions/meta.py:97 421 | msgid "Hello! Welcome to the help page." 422 | msgstr "" 423 | 424 | #: emote_collector/extensions/meta.py:81 425 | msgid "What are these reactions for?" 426 | msgstr "" 427 | 428 | #: emote_collector/extensions/meta.py:84 emote_collector/extensions/meta.py:119 429 | #: emote_collector/utils/paginator.py:247 430 | #, python-brace-format 431 | msgid "We were on page {self.current_page} before this message." 432 | msgstr "" 433 | 434 | #: emote_collector/extensions/meta.py:96 435 | msgid "Using the bot" 436 | msgstr "" 437 | 438 | #: emote_collector/extensions/meta.py:100 439 | msgid "How do I use this bot?" 440 | msgstr "" 441 | 442 | #: emote_collector/extensions/meta.py:100 443 | msgid "Reading the bot signature is pretty simple." 444 | msgstr "" 445 | 446 | #: emote_collector/extensions/meta.py:102 447 | msgid "argument" 448 | msgstr "" 449 | 450 | #: emote_collector/extensions/meta.py:105 451 | msgid "This means the argument is __**required**__." 452 | msgstr "" 453 | 454 | #: emote_collector/extensions/meta.py:106 455 | msgid "This means the argument is __**optional**__." 456 | msgstr "" 457 | 458 | #: emote_collector/extensions/meta.py:107 459 | msgid "This means the it can be __**either A or B**__." 460 | msgstr "" 461 | 462 | #: emote_collector/extensions/meta.py:110 463 | msgid "" 464 | "This means you can have multiple arguments.\n" 465 | "Now that you know the basics, it should be noted that...\n" 466 | "__**You do not type in the brackets!**__" 467 | msgstr "" 468 | 469 | #: emote_collector/extensions/meta.py:132 470 | msgid "Shows help about the bot, a command, or a category" 471 | msgstr "" 472 | 473 | #. zero width space so that "No Category" gets sorted first 474 | #: emote_collector/extensions/meta.py:154 475 | msgid "No Category" 476 | msgstr "" 477 | 478 | #: emote_collector/extensions/meta.py:196 479 | msgid "No help given." 480 | msgstr "" 481 | 482 | #: emote_collector/extensions/meta.py:216 483 | #, python-brace-format 484 | msgid "Command or category \"{command_name}\" not found." 485 | msgstr "" 486 | 487 | #: emote_collector/extensions/meta.py:220 488 | #, python-brace-format 489 | msgid "" 490 | "Command \"{command.qualified_name}\" has no subcommand named {subcommand}" 491 | msgstr "" 492 | 493 | #: emote_collector/extensions/meta.py:221 494 | #, python-brace-format 495 | msgid "Command \"{command.qualified_name}\" has no subcommands." 496 | msgstr "" 497 | 498 | #: emote_collector/extensions/meta.py:254 499 | msgid "Yes, delete my account." 500 | msgstr "" 501 | 502 | #: emote_collector/extensions/meta.py:256 503 | #, python-brace-format 504 | msgid "" 505 | "Are you sure you want to delete your account? To confirm, please say " 506 | "“{confirmation_phrase}” exactly." 507 | msgstr "" 508 | 509 | #: emote_collector/extensions/meta.py:263 510 | msgid "Deleting your account…" 511 | msgstr "" 512 | 513 | #: emote_collector/extensions/meta.py:270 514 | #, python-brace-format 515 | msgid "{context.author.mention} I've deleted your account successfully." 516 | msgstr "" 517 | 518 | #: emote_collector/extensions/meta.py:284 519 | msgid "Confirmation phrase not received in time. Please try again." 520 | msgstr "" 521 | 522 | #: emote_collector/extensions/meta.py:325 523 | msgid "Could not fetch changes due to memory error. Sorry." 524 | msgstr "" 525 | 526 | #: emote_collector/extensions/meta.py:333 527 | msgid "This command is temporarily unavailable. Try again later?" 528 | msgstr "" 529 | 530 | #: emote_collector/extensions/meta.py:340 531 | #, python-brace-format 532 | msgid "Official support server invite: {invite}" 533 | msgstr "" 534 | 535 | #: emote_collector/extensions/bingo/commands.py:38 536 | msgid "You win!" 537 | msgstr "" 538 | 539 | #: emote_collector/extensions/bingo/commands.py:43 540 | #: emote_collector/extensions/bingo/commands.py:56 541 | #: emote_collector/extensions/bingo/commands.py:64 542 | msgid "Your new bingo board:" 543 | msgstr "" 544 | 545 | #: emote_collector/extensions/bingo/commands.py:50 546 | msgid "You must specify at least one position and emote name." 547 | msgstr "" 548 | 549 | #: emote_collector/extensions/bingo/commands.py:56 550 | msgid "You win! Your new bingo board:" 551 | msgstr "" 552 | 553 | #: emote_collector/extensions/bingo/errors.py:24 554 | msgid "You do not have a bingo board yet." 555 | msgstr "" 556 | 557 | #: emote_collector/extensions/bingo/errors.py:28 558 | msgid "An NSFW channel is required to display this board." 559 | msgstr "" 560 | 561 | #: emote_collector/utils/checks.py:37 562 | msgid "You must be an emote moderator to run this command." 563 | msgstr "" 564 | 565 | #: emote_collector/utils/converter.py:46 566 | msgid "Not enough arguments." 567 | msgstr "" 568 | 569 | #: emote_collector/utils/converter.py:80 emote_collector/utils/converter.py:94 570 | msgid "Not a valid integer." 571 | msgstr "" 572 | 573 | #: emote_collector/utils/converter.py:88 574 | msgid "Not a message offset." 575 | msgstr "" 576 | 577 | #: emote_collector/utils/converter.py:97 578 | msgid "Not a valid message ID." 579 | msgstr "" 580 | 581 | #: emote_collector/utils/converter.py:108 582 | msgid "Message not found! Make sure your message ID is correct." 583 | msgstr "" 584 | 585 | #: emote_collector/utils/converter.py:111 586 | msgid "" 587 | "Permission denied! Make sure the bot has permission to read that message." 588 | msgstr "" 589 | 590 | #: emote_collector/utils/converter.py:140 591 | msgid "Message not found." 592 | msgstr "" 593 | 594 | #: emote_collector/utils/converter.py:158 595 | msgid "" 596 | "Failed to interpret that as a message offset, message ID, or user, or failed " 597 | "to find a message containing that search keyword." 598 | msgstr "" 599 | 600 | #: emote_collector/utils/converter.py:186 601 | msgid "" 602 | "Unable to react: you and I both need permission to read message history." 603 | msgstr "" 604 | 605 | #: emote_collector/utils/converter.py:188 606 | msgid "Unable to react: you and I both need permission to add reactions." 607 | msgstr "" 608 | 609 | #: emote_collector/utils/converter.py:190 610 | msgid "Unable to react: you and I both need permission to use external emotes." 611 | msgstr "" 612 | 613 | #: emote_collector/utils/converter.py:202 614 | msgid "That message is not from a log channel." 615 | msgstr "" 616 | 617 | #: emote_collector/utils/converter.py:207 618 | msgid "No embeds were found in that message." 619 | msgstr "" 620 | 621 | #: emote_collector/utils/converter.py:237 622 | #, python-brace-format 623 | msgid "" 624 | "Failed to interpret {argument} as a logged emote message or an emote in my " 625 | "database." 626 | msgstr "" 627 | 628 | #: emote_collector/utils/converter.py:252 629 | msgid "Server not found." 630 | msgstr "" 631 | 632 | #: emote_collector/utils/errors.py:29 633 | #, python-brace-format 634 | msgid "" 635 | "Sorry, you have been blacklisted for “{reason}”. To appeal, please join the " 636 | "support server by running __{prefix}support__." 637 | msgstr "" 638 | 639 | #: emote_collector/utils/errors.py:36 640 | #, python-brace-format 641 | msgid "URL error: server returned error code {status}" 642 | msgstr "" 643 | 644 | #: emote_collector/utils/errors.py:41 645 | msgid "The image supplied was not a valid GIF, PNG, JPG, or WEBP file." 646 | msgstr "" 647 | 648 | #: emote_collector/utils/errors.py:46 649 | msgid "Error: Retrieving the image took too long." 650 | msgstr "" 651 | 652 | #: emote_collector/utils/errors.py:51 653 | msgid "Error: Resizing the image took too long." 654 | msgstr "" 655 | 656 | #: emote_collector/utils/errors.py:63 657 | #, python-brace-format 658 | msgid "An emote called “{name}” already exists in my database." 659 | msgstr "" 660 | 661 | #: emote_collector/utils/errors.py:68 662 | #, python-brace-format 663 | msgid "An emote called “{name}” does not exist in my database." 664 | msgstr "" 665 | 666 | #: emote_collector/utils/errors.py:73 667 | #, python-brace-format 668 | msgid "You're not authorized to modify “{name}”." 669 | msgstr "" 670 | 671 | #: emote_collector/utils/errors.py:81 672 | #, python-brace-format 673 | msgid "That description is too long. The limit is {limit}." 674 | msgstr "" 675 | 676 | #: emote_collector/utils/errors.py:89 677 | msgid "No more room to store emotes." 678 | msgstr "" 679 | 680 | #: emote_collector/utils/errors.py:94 681 | msgid "Discord seems to be having issues right now, please try again later." 682 | msgstr "" 683 | 684 | #: emote_collector/utils/errors.py:100 685 | #, python-brace-format 686 | msgid "“{name}” is NSFW, but this channel is SFW." 687 | msgstr "" 688 | 689 | #: emote_collector/utils/paginator.py:106 690 | msgid "Bot does not have embed links permission." 691 | msgstr "" 692 | 693 | #: emote_collector/utils/paginator.py:109 694 | msgid "Bot cannot send messages." 695 | msgstr "" 696 | 697 | #: emote_collector/utils/paginator.py:114 698 | msgid "Bot does not have add reactions permission." 699 | msgstr "" 700 | 701 | #: emote_collector/utils/paginator.py:117 702 | msgid "Bot does not have Read Message History permission." 703 | msgstr "" 704 | 705 | #: emote_collector/utils/paginator.py:137 706 | #: emote_collector/utils/paginator.py:337 707 | #, python-brace-format 708 | msgid "Page {page}⁄{self.maximum_pages} ({num_entries} entries)" 709 | msgstr "" 710 | 711 | #: emote_collector/utils/paginator.py:141 712 | #: emote_collector/utils/paginator.py:341 713 | #, python-brace-format 714 | msgid "Page {page}⁄{self.maximum_pages}" 715 | msgstr "" 716 | 717 | #: emote_collector/utils/paginator.py:147 718 | msgid "Confused? React with \\N{INFORMATION SOURCE} for more info." 719 | msgstr "" 720 | 721 | #: emote_collector/utils/paginator.py:206 722 | msgid "What page do you want to go to?" 723 | msgstr "" 724 | 725 | #: emote_collector/utils/paginator.py:216 726 | msgid "You took too long." 727 | msgstr "" 728 | 729 | #: emote_collector/utils/paginator.py:225 730 | #, python-brace-format 731 | msgid "Invalid page given. ({page}/{self.maximum_pages})" 732 | msgstr "" 733 | 734 | #: emote_collector/utils/paginator.py:237 735 | msgid "Welcome to the interactive paginator!\n" 736 | msgstr "" 737 | 738 | #: emote_collector/utils/paginator.py:238 739 | msgid "" 740 | "This interactively allows you to see pages of text by navigating with " 741 | "reactions. They are as follows:\n" 742 | msgstr "" 743 | 744 | #: emote_collector/utils/bingo/board.py:70 745 | msgid "Invalid position." 746 | msgstr "" 747 | 748 | #: emote_collector/utils/bingo/board.py:140 749 | msgid "Position may not be the free space." 750 | msgstr "" 751 | -------------------------------------------------------------------------------- /emote_collector/locale/pl_PL/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/pl_PL/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/pl_PL_rude/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/pl_PL_rude/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/tr_TR/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/tr_TR/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/locale/update-locale.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | xgettext \ 4 | --files-from POTFILES.in \ 5 | --from-code utf-8 \ 6 | --add-comments \ 7 | --directory ../../ \ 8 | --output messages.pot 9 | 10 | for locale in */; do 11 | file="$locale/LC_MESSAGES/emote_collector" 12 | 13 | msgmerge \ 14 | --update \ 15 | --no-fuzzy-matching \ 16 | --backup off \ 17 | "$file.po" \ 18 | messages.pot 19 | 20 | msgfmt "$file.po" --output-file "$file.mo"; done 21 | -------------------------------------------------------------------------------- /emote_collector/locale/😃/LC_MESSAGES/emote_collector.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmoteBot/EmoteCollector/46faf484754d761397e8ed14b66292a824d7edf0/emote_collector/locale/😃/LC_MESSAGES/emote_collector.mo -------------------------------------------------------------------------------- /emote_collector/sql/api.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda#0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | -- :macro existing_token() 18 | -- params: user_id 19 | SELECT secret 20 | FROM api_tokens 21 | WHERE id = $1 22 | -- :endmacro 23 | 24 | -- :macro new_token() 25 | -- params: user_id, secret 26 | INSERT INTO api_tokens (id, secret) 27 | VALUES ($1, $2) 28 | -- :endmacro 29 | 30 | -- :macro delete_token() 31 | -- params: user_id 32 | DELETE FROM api_tokens 33 | WHERE id = $1 34 | -- :endmacro 35 | -------------------------------------------------------------------------------- /emote_collector/sql/bingo.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda#0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | -- :macro get_categories() 18 | -- params: num_categories 19 | SELECT category_id, category 20 | FROM bingo_categories 21 | -- it is not assumed that there will be very many categories, so sorting the entire table is fine 22 | ORDER BY RANDOM() 23 | LIMIT $1 24 | -- :endmacro 25 | 26 | -- :macro delete_board() 27 | -- params: user_id 28 | DELETE FROM bingo_boards 29 | WHERE user_id = $1 30 | -- :endmacro 31 | 32 | -- :macro get_board_value() 33 | -- params: user_id 34 | SELECT value 35 | FROM bingo_boards 36 | WHERE user_id = $1 37 | -- :endmacro 38 | 39 | -- :macro set_board_value() 40 | -- params: user_id, value 41 | INSERT INTO bingo_boards (user_id, value) 42 | VALUES ($1, $2) 43 | ON CONFLICT (user_id) DO UPDATE SET 44 | value = EXCLUDED.value 45 | -- :endmacro 46 | 47 | -- :macro get_board_categories() 48 | -- params: user_id 49 | SELECT category 50 | FROM 51 | bingo_boards 52 | INNER JOIN bingo_board_categories USING (user_id) 53 | INNER JOIN bingo_categories USING (category_id) 54 | WHERE user_id = $1 55 | ORDER BY pos 56 | -- :endmacro 57 | 58 | -- :macro set_board_category() 59 | -- params: user_id, pos, category 60 | INSERT INTO bingo_board_categories (user_id, pos, category_id) 61 | VALUES ($1, $2, (SELECT category_id FROM bingo_categories WHERE category = $3)) 62 | -- :endmacro 63 | 64 | -- :macro get_board_marks() 65 | -- params: user_id 66 | SELECT 67 | pos, 68 | COALESCE(deleted.nsfw, emotes.nsfw) AS nsfw, 69 | COALESCE(deleted.name, emotes.name) AS name, 70 | COALESCE(marks.deleted_emote_id, marks.emote_id) AS id, 71 | COALESCE(deleted.animated, emotes.animated) AS animated 72 | FROM 73 | bingo_board_marks AS marks 74 | LEFT JOIN bingo_deleted_emotes AS deleted USING (deleted_emote_id) 75 | LEFT JOIN emotes ON (marks.emote_id = emotes.id) 76 | WHERE user_id = $1 77 | ORDER BY pos 78 | -- :endmacro 79 | 80 | -- :macro set_board_mark() 81 | -- params: user_id, pos, nsfw, name, emote_id, animated 82 | -- required transaction isolation level: repeatable read 83 | CALL bingo_mark($1, $2, $3, $4, $5, $6); 84 | -- :endmacro 85 | 86 | -- :macro delete_board_mark() 87 | -- params: user_id, pos 88 | DELETE FROM bingo_board_marks 89 | WHERE (user_id, pos) = ($1, $2) 90 | -- :endmacro 91 | 92 | -- :macro delete_board_marks_by_mask() 93 | -- params: user_id, mask 94 | UPDATE bingo_boards 95 | SET value = value & ~$2::INTEGER 96 | WHERE user_id = $1 97 | -- :endmacro 98 | 99 | -- :macro add_board_marks_by_mask() 100 | -- params: user_id, mask 101 | UPDATE bingo_boards 102 | SET value = value | $2::INTEGER 103 | WHERE user_id = $1 104 | -- :endmacro 105 | -------------------------------------------------------------------------------- /emote_collector/sql/emotes.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda#0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | --- CACHE SYNCHRONIZATION 18 | 19 | -- :macro add_guild() 20 | -- params: guild_id 21 | INSERT INTO _guilds (id) 22 | VALUES ($1) 23 | ON CONFLICT DO NOTHING 24 | -- :endmacro 25 | 26 | -- :macro delete_guild() 27 | -- params: guild_id 28 | DELETE FROM _guilds 29 | WHERE id = $1 30 | -- :endmacro 31 | 32 | --- INFORMATIONAL 33 | 34 | -- :macro free_guild(animated) 35 | SELECT id 36 | FROM guilds 37 | WHERE {{ 'animated' if animated else 'static' }}_usage < 50 38 | ORDER BY last_creation 39 | LIMIT 1 40 | -- :endmacro 41 | 42 | -- :macro count() 43 | SELECT 44 | COUNT(*) FILTER (WHERE NOT animated) AS static, 45 | COUNT(*) FILTER (WHERE animated) AS animated, 46 | COUNT(*) FILTER (WHERE nsfw != 'SFW') AS nsfw, 47 | COUNT(*) AS total 48 | FROM emotes 49 | -- :endmacro 50 | 51 | -- :macro get_emote() 52 | -- params: name 53 | SELECT * 54 | FROM emotes 55 | WHERE LOWER(name) = LOWER($1) 56 | -- :endmacro 57 | 58 | -- :macro get_emote_usage() 59 | -- params: id, cutoff_time 60 | SELECT COUNT(*) 61 | FROM emote_usage_history 62 | WHERE id = $1 63 | AND time > $2 64 | -- :endmacro 65 | 66 | -- :macro get_reply_message() 67 | -- params: invoking_message_id 68 | SELECT type, reply_message 69 | FROM replies 70 | WHERE invoking_message = $1 71 | -- :endmacro 72 | 73 | --- ITERATORS 74 | 75 | -- :macro all_emotes_keyset(sort_order, filter_author=False, end=False) 76 | SELECT * 77 | FROM emotes 78 | WHERE nsfw = ANY ($1) 79 | -- :set argc = 2 80 | -- :if sort_order is defined and not end 81 | AND LOWER(name) {{ '>' if sort_order == 'ASC' else '<' }} LOWER(${{ argc }}) 82 | -- :set argc = argc + 1 83 | -- :endif 84 | -- :if filter_author 85 | AND author = ${{ argc }} 86 | -- :set argc = argc + 1 87 | -- :endif 88 | ORDER BY LOWER(name) {{ sort_order }} LIMIT ${{ argc }} 89 | -- :endmacro 90 | 91 | -- :set emote_usage_history_prelude 92 | SELECT e.*, COUNT(euh.id) AS usage 93 | FROM 94 | emotes AS e 95 | LEFT JOIN emote_usage_history AS euh 96 | ON euh.id = e.id 97 | AND euh.time > $1 98 | -- :endset 99 | 100 | -- :macro popular_emotes(filter_author=False) 101 | -- params: cutoff_time, limit, allowed_nsfw_types, author_id (optional) 102 | {{ emote_usage_history_prelude }} 103 | WHERE 104 | nsfw = ANY ($3) 105 | {% if filter_author %}AND author = $4{% endif %} 106 | GROUP BY e.id 107 | ORDER BY usage DESC, LOWER(e.name) 108 | LIMIT $2 109 | -- :endmacro 110 | 111 | -- :macro search() 112 | -- params: query, allowed_nsfw_types 113 | SELECT * 114 | FROM emotes 115 | WHERE name % $1 116 | AND nsfw = ANY ($2) 117 | ORDER BY similarity(name, $1) DESC, LOWER(name) 118 | LIMIT 100 119 | -- :endmacro 120 | 121 | -- :macro decayable_emotes() 122 | -- params: cutoff_time, usage_threshold 123 | {{ emote_usage_history_prelude }} 124 | WHERE 125 | created < $1 126 | AND NOT preserve 127 | GROUP BY e.id 128 | HAVING COUNT(euh.id) < $2 129 | -- :endmacro 130 | 131 | --- ACTIONS 132 | 133 | -- :macro create_emote() 134 | -- params: name, id, author, animated, guild 135 | INSERT INTO emotes (name, id, author, animated, guild) 136 | VALUES ($1, $2, $3, $4, $5) 137 | RETURNING * 138 | -- :endmacro 139 | 140 | -- :macro remove_emote() 141 | -- params: id 142 | DELETE FROM emotes 143 | WHERE id = $1 144 | -- :endmacro 145 | 146 | -- :macro rename_emote() 147 | -- params: id, new_name 148 | UPDATE emotes 149 | SET name = $2 150 | WHERE id = $1 151 | RETURNING * 152 | -- :endmacro 153 | 154 | -- :macro set_emote_creation() 155 | -- params: name, time 156 | UPDATE EMOTES 157 | SET created = $2 158 | WHERE LOWER(name) = LOWER($1) 159 | -- :endmacro 160 | 161 | -- :macro set_emote_description() 162 | -- params: id, description 163 | UPDATE emotes 164 | SET description = $2 165 | WHERE id = $1 166 | RETURNING * 167 | -- :endmacro 168 | 169 | -- :macro set_emote_preservation() 170 | -- params: name, should_preserve 171 | UPDATE emotes 172 | SET preserve = $2 173 | WHERE LOWER(name) = LOWER($1) 174 | RETURNING * 175 | -- :endmacro 176 | 177 | -- :macro set_emote_nsfw() 178 | -- params: id, new_status 179 | UPDATE emotes 180 | SET nsfw = $2 181 | WHERE id = $1 182 | RETURNING * 183 | -- :endmacro 184 | 185 | -- :macro log_emote_use() 186 | -- params: id 187 | INSERT INTO emote_usage_history (id) 188 | VALUES ($1) 189 | -- :endmacro 190 | 191 | -- :macro add_reply_message() 192 | -- params: invoking_message_id, reply_type, reply_message_id 193 | INSERT INTO replies (invoking_message, type, reply_message) 194 | VALUES ($1, $2, $3) 195 | -- :endmacro 196 | 197 | -- :macro delete_reply_by_invoking_message() 198 | -- params: reply_message_id 199 | DELETE FROM replies 200 | WHERE invoking_message = $1 201 | RETURNING reply_message 202 | -- :endmacro 203 | 204 | -- :macro delete_reply_by_reply_message() 205 | -- params: reply_message_id 206 | DELETE FROM replies 207 | WHERE reply_message = $1 208 | -- :endmacro 209 | 210 | --- USER / GUILD OPTIONS 211 | 212 | -- :macro delete_all_user_state() 213 | -- params: user_id 214 | DELETE FROM user_opt 215 | WHERE id = $1 216 | -- :endmacro 217 | 218 | -- :macro toggle_state(table) 219 | -- params: id, default 220 | INSERT INTO {{ table }} (id, state) 221 | VALUES ($1, $2) 222 | ON CONFLICT (id) DO UPDATE 223 | SET state = NOT {{ table }}.state 224 | RETURNING state 225 | -- :endmacro 226 | 227 | -- :macro get_individual_state(table) 228 | -- params: id 229 | SELECT state 230 | FROM {{ table }} 231 | WHERE id = $1 232 | -- :endmacro 233 | 234 | -- :macro get_state() 235 | -- params: guild_id, user_id 236 | SELECT COALESCE( 237 | CASE WHEN (SELECT blacklist_reason FROM user_opt WHERE id = $2) IS NOT NULL THEN FALSE END, 238 | (SELECT state FROM user_opt WHERE id = $2), 239 | (SELECT state FROM guild_opt WHERE id = $1), 240 | true -- not opted in in the guild or the user table, default behavior is ENABLED 241 | ) 242 | -- :endmacro 243 | 244 | --- BLACKLISTS 245 | 246 | -- :macro get_blacklist(table_name) 247 | -- params: id 248 | SELECT blacklist_reason 249 | FROM {{ table_name }} 250 | WHERE id = $1 251 | -- :endmacro 252 | 253 | -- :macro set_blacklist(table_name) 254 | -- params: id, reason 255 | INSERT INTO {{ table_name }} (id, blacklist_reason) 256 | VALUES ($1, $2) 257 | ON CONFLICT (id) DO UPDATE 258 | SET blacklist_reason = EXCLUDED.blacklist_reason 259 | -- :endmacro 260 | 261 | -- :macro blacklisted_guilds() 262 | SELECT id 263 | FROM guild_opt 264 | WHERE blacklist_reason IS NOT NULL 265 | -- :endmacro 266 | -------------------------------------------------------------------------------- /emote_collector/sql/locale.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda#0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | -- :macro locale() 18 | -- params: user_id, channel_id, guild_id (may be null) 19 | SELECT COALESCE( 20 | ( 21 | SELECT locale 22 | FROM locales 23 | WHERE "user" = $1), 24 | ( 25 | SELECT locale 26 | FROM locales 27 | WHERE channel = $2), 28 | ( 29 | SELECT locale 30 | FROM locales 31 | WHERE 32 | guild = $3 33 | AND channel IS NULL 34 | AND "user" IS NULL)) 35 | -- :endmacro 36 | 37 | -- :macro channel_or_guild_locale() 38 | -- params: channel_id, guild_id 39 | SELECT COALESCE( 40 | ( 41 | SELECT locale 42 | FROM locales 43 | WHERE channel = $2), 44 | ( 45 | SELECT locale 46 | FROM locales 47 | WHERE 48 | guild = $1 49 | AND channel IS NULL 50 | AND "user" IS NULL)) 51 | -- :endmacro 52 | 53 | -- :macro guild_locale() 54 | -- params: guild_id 55 | SELECT locale 56 | FROM locales 57 | WHERE 58 | guild = $1 59 | AND channel IS NULL 60 | AND "user" IS NULL 61 | -- :endmacro 62 | 63 | -- :macro delete_guild_locale() 64 | -- params: guild_id 65 | DELETE FROM locales 66 | WHERE 67 | guild = $1 68 | AND channel IS NULL 69 | AND "user" IS NULL 70 | -- :endmacro 71 | 72 | -- :macro set_guild_locale() 73 | -- params: guild_id, locale 74 | INSERT INTO locales (guild, locale) 75 | VALUES ($1, $2) 76 | -- :endmacro 77 | 78 | -- :macro update_channel_locale() 79 | -- params: guild_id, channel_id, locale 80 | INSERT INTO locales (guild, channel, locale) 81 | VALUES ($1, $2, $3) 82 | ON CONFLICT (guild, channel) DO UPDATE 83 | SET locale = EXCLUDED.locale 84 | -- :endmacro 85 | 86 | -- :macro update_user_locale() 87 | -- params: user_id, locale 88 | INSERT INTO locales ("user", locale) 89 | VALUES ($1, $2) 90 | ON CONFLICT ("user") DO UPDATE 91 | SET locale = EXCLUDED.locale 92 | -- :endmacro 93 | 94 | -- :macro delete_user_locale() 95 | -- params: user_id 96 | DELETE FROM locales 97 | WHERE "user" = $1 98 | -- :endmacro 99 | -------------------------------------------------------------------------------- /emote_collector/sql/schema.sql: -------------------------------------------------------------------------------- 1 | -- Emote Collector collects emotes from other servers for use by people without Nitro 2 | -- Copyright © 2019 lambda#0987 3 | -- 4 | -- Emote Collector is free software: you can redistribute it and/or modify 5 | -- it under the terms of the GNU Affero General Public License as 6 | -- published by the Free Software Foundation, either version 3 of the 7 | -- License, or (at your option) any later version. 8 | -- 9 | -- Emote Collector is distributed in the hope that it will be useful, 10 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | -- GNU Affero General Public License for more details. 13 | -- 14 | -- You should have received a copy of the GNU Affero General Public License 15 | -- along with Emote Collector. If not, see . 16 | 17 | SET TIME ZONE UTC; 18 | 19 | --- EMOTES 20 | 21 | CREATE TYPE nsfw AS ENUM ('SFW', 'SELF_NSFW', 'MOD_NSFW'); 22 | 23 | CREATE TABLE _guilds( 24 | id BIGINT PRIMARY KEY); 25 | 26 | CREATE TABLE emotes( 27 | name VARCHAR(32) NOT NULL, 28 | id BIGINT PRIMARY KEY, 29 | author BIGINT NOT NULL, 30 | animated BOOLEAN NOT NULL DEFAULT FALSE, 31 | description VARCHAR(500), 32 | created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 33 | modified TIMESTAMP WITH TIME ZONE, 34 | preserve BOOLEAN NOT NULL DEFAULT FALSE, 35 | guild BIGINT NOT NULL REFERENCES _guilds ON DELETE CASCADE, 36 | nsfw nsfw NOT NULL DEFAULT 'SFW'); 37 | 38 | CREATE UNIQUE INDEX emotes_lower_idx ON emotes (LOWER(name)); 39 | CREATE INDEX emotes_name_trgm_idx ON emotes USING GIN (name gin_trgm_ops); 40 | CREATE INDEX emotes_author_idx ON emotes (author); 41 | 42 | CREATE VIEW guilds AS 43 | SELECT 44 | g.id, 45 | COUNT(e.guild) AS usage, 46 | COUNT(e.guild) FILTER (WHERE NOT e.animated) AS static_usage, 47 | COUNT(e.guild) FILTER (WHERE e.animated) AS animated_usage, 48 | MAX(e.created) AS last_creation 49 | FROM 50 | _guilds AS g 51 | LEFT JOIN emotes AS e 52 | ON e.guild = g.id 53 | GROUP BY g.id; 54 | 55 | CREATE TYPE message_reply_type AS ENUM ('AUTO', 'QUOTE'); 56 | 57 | CREATE TABLE replies( 58 | invoking_message BIGINT PRIMARY KEY, 59 | type message_reply_type NOT NULL, 60 | reply_message BIGINT NOT NULL); 61 | 62 | -- https://stackoverflow.com/a/26284695/1378440 63 | CREATE FUNCTION update_modified_column() 64 | RETURNS TRIGGER AS $$ 65 | BEGIN 66 | IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN 67 | NEW.modified = CURRENT_TIMESTAMP; 68 | END IF; 69 | RETURN NEW; END; 70 | $$ LANGUAGE plpgsql; 71 | 72 | CREATE TRIGGER update_emote_modtime 73 | BEFORE UPDATE ON emotes 74 | FOR EACH ROW EXECUTE PROCEDURE update_modified_column(); 75 | 76 | CREATE TABLE emote_usage_history( 77 | id BIGINT NOT NULL REFERENCES emotes ON DELETE CASCADE ON UPDATE CASCADE, 78 | time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (CURRENT_TIMESTAMP)); 79 | 80 | CREATE INDEX emote_usage_history_id_idx ON emote_usage_history (id); 81 | CREATE INDEX emote_usage_history_time_idx ON emote_usage_history (time); 82 | 83 | --- OPTIONS / PLONKS 84 | 85 | CREATE TABLE user_opt( 86 | id BIGINT PRIMARY KEY, 87 | state BOOLEAN, 88 | blacklist_reason TEXT); 89 | 90 | CREATE TABLE guild_opt( 91 | id BIGINT PRIMARY KEY, 92 | state BOOLEAN, 93 | blacklist_reason TEXT); 94 | 95 | CREATE INDEX blacklisted_guild_idx ON guild_opt (id) WHERE blacklist_reason IS NOT NULL; 96 | 97 | CREATE TABLE api_tokens( 98 | id BIGINT PRIMARY KEY, 99 | secret BYTEA NOT NULL); 100 | 101 | CREATE INDEX api_token_id_secret_idx ON api_tokens (id, secret); 102 | 103 | CREATE TABLE locales( 104 | guild BIGINT, 105 | channel BIGINT, 106 | "user" BIGINT UNIQUE, 107 | locale VARCHAR(32) NOT NULL, 108 | 109 | CHECK ( 110 | guild IS NOT NULL AND channel IS NULL AND "user" IS NULL 111 | OR guild IS NOT NULL AND channel IS NOT NULL 112 | OR channel IS NOT NULL 113 | OR "user" IS NOT NULL)); 114 | 115 | CREATE UNIQUE INDEX locales_guild_channel_uniq_index ON locales (channel, guild); 116 | 117 | --- BINGO 118 | 119 | CREATE TABLE bingo_boards( 120 | user_id BIGINT PRIMARY KEY, 121 | value INTEGER NOT NULL); 122 | 123 | CREATE TABLE bingo_categories( 124 | category_id SMALLINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 125 | category TEXT NOT NULL UNIQUE); 126 | 127 | CREATE TABLE bingo_deleted_emotes( 128 | nsfw nsfw NOT NULL DEFAULT 'SFW', 129 | name VARCHAR(32) NOT NULL, 130 | deleted_emote_id BIGINT PRIMARY KEY, 131 | animated BOOLEAN NOT NULL DEFAULT FALSE); 132 | 133 | CREATE TABLE bingo_board_marks( 134 | user_id BIGINT NOT NULL REFERENCES bingo_boards ON DELETE CASCADE, 135 | pos SMALLINT NOT NULL, 136 | emote_id BIGINT REFERENCES emotes, 137 | deleted_emote_id BIGINT REFERENCES bingo_deleted_emotes DEFERRABLE INITIALLY DEFERRED, 138 | 139 | CHECK (num_nonnulls(emote_id, deleted_emote_id) = 1), 140 | PRIMARY KEY (user_id, pos)); 141 | 142 | CREATE INDEX "bingo_board_marks_emote_id_idx" ON bingo_board_marks (emote_id); 143 | 144 | CREATE FUNCTION bingo_archive_deleted_emote() 145 | RETURNS TRIGGER AS $$ BEGIN 146 | IF ( 147 | SELECT 1 148 | FROM bingo_board_marks 149 | WHERE emote_id = OLD.id 150 | ) IS NOT NULL THEN 151 | INSERT INTO bingo_deleted_emotes (nsfw, name, deleted_emote_id, animated) 152 | VALUES (OLD.nsfw, OLD.name, OLD.id, OLD.animated); 153 | 154 | UPDATE bingo_board_marks 155 | SET 156 | deleted_emote_id = emote_id, 157 | emote_id = NULL 158 | WHERE emote_id = OLD.id; 159 | END IF; 160 | RETURN OLD; 161 | END; $$ LANGUAGE plpgsql; 162 | 163 | CREATE TRIGGER bingo_archive_deleted_emotes 164 | BEFORE DELETE ON emotes 165 | FOR EACH ROW EXECUTE PROCEDURE bingo_archive_deleted_emote(); 166 | 167 | CREATE TABLE bingo_board_categories( 168 | user_id BIGINT NOT NULL REFERENCES bingo_boards ON DELETE CASCADE, 169 | pos SMALLINT NOT NULL, 170 | category_id SMALLINT NOT NULL REFERENCES bingo_categories, 171 | 172 | PRIMARY KEY (user_id, pos)); 173 | 174 | CREATE PROCEDURE bingo_mark( 175 | p_user_id BIGINT, 176 | p_pos SMALLINT, 177 | p_nsfw nsfw, 178 | p_name VARCHAR(32), 179 | p_emote_id BIGINT, 180 | p_animated BOOLEAN 181 | ) LANGUAGE plpgsql AS $$ BEGIN 182 | IF ( 183 | SELECT 1 184 | FROM emotes 185 | WHERE id = $5 186 | ) IS NOT NULL THEN 187 | INSERT INTO bingo_board_marks (user_id, pos, emote_id) 188 | VALUES (p_user_id, p_pos, p_emote_id) 189 | ON CONFLICT (user_id, pos) DO UPDATE SET 190 | emote_id = EXCLUDED.emote_id, 191 | deleted_emote_id = NULL; 192 | ELSE 193 | INSERT INTO bingo_deleted_emotes (nsfw, name, deleted_emote_id, animated) 194 | VALUES (p_nsfw, p_name, p_emote_id, p_animated) 195 | ON CONFLICT (deleted_emote_id) DO UPDATE SET 196 | nsfw = EXCLUDED.nsfw, 197 | name = EXCLUDED.name, 198 | animated = EXCLUDED.animated; 199 | INSERT INTO bingo_board_marks (user_id, pos, deleted_emote_id) 200 | VALUES (p_user_id, p_pos, p_emote_id) 201 | ON CONFLICT (user_id, pos) DO UPDATE SET 202 | deleted_emote_id = EXCLUDED.deleted_emote_id, 203 | emote_id = NULL; 204 | END IF; 205 | END; $$; 206 | -------------------------------------------------------------------------------- /emote_collector/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .misc import * # comes first since later imports depend on it 2 | from . import checks 3 | from . import context 4 | from . import converter 5 | from . import custom_send 6 | from . import custom_typing 7 | from . import emote 8 | from . import errors 9 | from . import i18n 10 | from . import lexer 11 | from . import paginator 12 | from .proxy import ObjectProxy 13 | -------------------------------------------------------------------------------- /emote_collector/utils/bingo/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BlueOak-1.0.0 2 | 3 | import asyncio 4 | import base64 5 | import collections 6 | import functools 7 | import io 8 | import itertools 9 | import json 10 | import random 11 | import operator 12 | import os 13 | import sys 14 | import textwrap 15 | from pathlib import Path 16 | 17 | import aiohttp 18 | 19 | from ... import BASE_DIR 20 | from ... import utils 21 | from .board import * 22 | 23 | COORDS = { 24 | c: [(x, y) for y in (327, 592, 857, 1121, 1387)] 25 | for c, x in zip('BINGO', (284, 548, 813, 1078, 1342))} 26 | COORDS['N'][2] = None 27 | 28 | # width and height (within the border) of one square 29 | SQUARE_SIZE = 256 30 | 31 | DATA_DIR = BASE_DIR / 'data' / 'bingo' 32 | 33 | def marshal(board): 34 | return board.value, board.categories.items, board.marks.items 35 | 36 | def draw_board(img, cats): 37 | from wand.drawing import Drawing 38 | 39 | with Drawing() as draw: 40 | draw.font = str(DATA_DIR / 'DejaVuSans.ttf') 41 | draw.font_size = 40 42 | for (col, row), cat in cats: 43 | try: 44 | x, y = COORDS[col][row - 1] 45 | except IndexError: 46 | print(col, row) 47 | raise 48 | draw.text(x, y, '\n'.join(textwrap.wrap(cat, 10))) 49 | draw(img) 50 | 51 | def render(board): 52 | from wand.image import Image 53 | from wand.drawing import Drawing 54 | 55 | with Image(filename=DATA_DIR / 'bingo_board_base.png') as img: 56 | draw_board(img, board.categories) 57 | with Drawing() as draw: 58 | draw_marks(draw, img, ((point, base64.b64decode(img.encode('ascii'))) for point, (*_, img) in board.marks)) 59 | draw(img) 60 | 61 | return img.make_blob(format='png') 62 | 63 | def draw_marks(draw, img, marks): 64 | from wand.image import Image 65 | 66 | for (col, row), eimg in marks: 67 | left, top = COORDS[col][row - 1] 68 | 69 | half = SQUARE_SIZE // 2 70 | with Image(blob=eimg) as eimg: 71 | eimg.transform(resize=f'{half}x{half}') 72 | draw.composite( 73 | operator='over', 74 | left=left+half-65, top=top+25, 75 | width=eimg.width, height=eimg.height, 76 | image=eimg) 77 | 78 | async def download_all(bot, urls): 79 | sess = bot.cogs['Emotes'].http 80 | async def read(url): 81 | async with sess.get(url, raise_for_status=True) as resp: 82 | return url, await resp.read() 83 | tasks = ( 84 | bot.loop.create_task(read(url)) 85 | for url in urls) 86 | return await utils.gather_or_cancel(*tasks) 87 | 88 | async def render_in_subprocess(bot, board): 89 | url_index = collections.defaultdict(list) 90 | for i, e in enumerate(board.marks.items): 91 | if e is None: 92 | continue 93 | nsfw, name, id, animated = e 94 | url_index[utils.emote.url(id, animated=animated)].append(i) 95 | 96 | images = await download_all(bot, url_index) 97 | marks = board.marks.items[:] 98 | for url, image in images: 99 | for i in url_index[url]: 100 | marks[i] += (base64.b64encode(image).decode('ascii'),) 101 | del images 102 | 103 | proc = await asyncio.create_subprocess_exec( 104 | # see __main__.py 105 | sys.executable, '-m', __name__, 106 | 107 | stdin=asyncio.subprocess.PIPE, 108 | stdout=asyncio.subprocess.PIPE, 109 | stderr=asyncio.subprocess.PIPE) 110 | 111 | image_data, err = await proc.communicate(json.dumps({ 112 | 'value': board.value, 113 | 'categories': board.categories.items, 114 | 'marks': marks}).encode()) 115 | 116 | if proc.returncode != 0: 117 | raise RuntimeError(err.decode('utf-8') + f'Return code: {proc.returncode}') 118 | 119 | return image_data 120 | -------------------------------------------------------------------------------- /emote_collector/utils/bingo/__main__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from . import EmoteCollectorBingoBoard, render 5 | 6 | board = EmoteCollectorBingoBoard(**json.load(sys.stdin)) 7 | sys.stdout.buffer.write(render(board)) 8 | -------------------------------------------------------------------------------- /emote_collector/utils/bingo/board.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BlueOak-1.0.0 2 | 3 | import itertools 4 | 5 | from discord.ext import commands 6 | 7 | __all__ = ('BingoBoard', 'index', 'EmoteCollectorBingoBoard') 8 | 9 | class BingoBoard: 10 | WIDTH = 5 11 | HEIGHT = 5 12 | 13 | SIZE = HEIGHT * WIDTH 14 | SQUARES = SIZE - 1 # free space 15 | 16 | COL_I = {c: i for i, c in enumerate('BINGO')} 17 | COL_NAMES = {i: c for c, i in COL_I.items()} 18 | 19 | FREE_SPACE_I = HEIGHT * COL_I['N'] + 2 20 | 21 | def __init__(self, *, value=None): 22 | self.value = 0 if value is None else value 23 | self['N', 3] = 1 # free space 24 | 25 | reset = __init__ 26 | 27 | def is_playable(self, col, row): 28 | """return whether the square has room""" 29 | return self[col, row] == 0 30 | 31 | def has_won(self): 32 | board = self.value 33 | 34 | horiz_mask = self.HORIZ_MASK 35 | for _ in range(self.HEIGHT): 36 | if board & horiz_mask == horiz_mask: 37 | return True 38 | horiz_mask <<= 1 39 | 40 | vert_mask = self.VERT_MASK 41 | for _ in range(self.WIDTH): 42 | if board & vert_mask == vert_mask: 43 | return True 44 | vert_mask <<= self.HEIGHT 45 | 46 | if board & self.DIAGONAL_TOP_LEFT == self.DIAGONAL_TOP_LEFT: 47 | return True 48 | if board & self.DIAGONAL_BOTTOM_LEFT == self.DIAGONAL_BOTTOM_LEFT: 49 | return True 50 | 51 | return False 52 | 53 | def __setitem__(self, pos, value): 54 | mask = self.mask(pos) 55 | if value: 56 | self.value |= mask 57 | else: 58 | self.value &= ~mask 59 | 60 | def __getitem__(self, pos): 61 | mask = self.mask(pos) 62 | return self.value & mask != 0 63 | 64 | @classmethod 65 | def parse_pos(cls, pos): 66 | col, row = pos 67 | try: 68 | col, row = cls.COL_I[col], int(row) - 1 69 | except (KeyError, IndexError): 70 | raise commands.BadArgument(_('Invalid position.')) 71 | return col, row 72 | 73 | @classmethod 74 | def index(cls, pos): 75 | col, row = cls.parse_pos(pos) 76 | return col * cls.HEIGHT + row 77 | 78 | @classmethod 79 | def mask(cls, pos): 80 | return 1 << cls.index(pos) 81 | 82 | @classmethod 83 | def skip_free_space(cls, items): 84 | """given a list SQUARES items long, return a list SIZE items long with a blank free space""" 85 | # set free space to None 86 | items.append(items[cls.FREE_SPACE_I]) 87 | items[cls.FREE_SPACE_I] = None 88 | return items 89 | 90 | def __str__(self): 91 | from io import StringIO 92 | buf = StringIO() 93 | 94 | buf.write(' ') 95 | for w in range(self.WIDTH): 96 | # column indexes 97 | buf.write(self.COL_NAMES[w]) 98 | buf.write(' ') 99 | 100 | buf.write('\n') 101 | 102 | for h in range(1, self.HEIGHT + 1): 103 | buf.write(str(h)) 104 | for w in 'BINGO': 105 | buf.write(' ') 106 | buf.write('X' if self[w, h] else '.') 107 | if h != self.HEIGHT: # skip writing the newline at the end 108 | buf.write('\n') 109 | 110 | return buf.getvalue() 111 | 112 | @classmethod 113 | def _init_masks(cls): 114 | import functools 115 | import operator 116 | 117 | positions = list(itertools.product('BINGO', range(1, 6))) 118 | masks = {pos: cls.mask(pos) for pos in positions} 119 | 120 | bit_or = functools.partial(functools.reduce, operator.or_) 121 | 122 | cls.HORIZ_MASK = bit_or(masks[col, 1] for col in 'BINGO') 123 | cls.VERT_MASK = bit_or(masks['B', i] for i in range(1, 6)) 124 | 125 | cls.DIAGONAL_TOP_LEFT = bit_or(masks['BINGO'[i - 1], i] for i in range(1, 6)) 126 | cls.DIAGONAL_BOTTOM_LEFT = bit_or(masks['BINGO'[5 - i], i] for i in range(1, 6)[::-1]) 127 | 128 | BingoBoard._init_masks() 129 | 130 | class BingoItemWrapper: 131 | def __init__(self, cls, *, items=None): 132 | self.cls = cls 133 | items = [None] * cls.SQUARES if items is None else items 134 | self.items = cls.skip_free_space(items) 135 | 136 | def index(self, pos): 137 | col, row = pos 138 | row = int(row) 139 | if col == 'N' and row == 3: 140 | raise commands.BadArgument(_('Position may not be the free space.')) 141 | col, row = self.cls.COL_I[col], row - 1 142 | i = self.cls.HEIGHT * col + row 143 | return i 144 | 145 | def __getitem__(self, pos): 146 | return self.items[self.index(pos)] 147 | 148 | def __setitem__(self, pos, value): 149 | self.items[self.index(pos)] = value 150 | 151 | def __delitem__(self, pos): 152 | self.items[self.index(pos)] = None 153 | 154 | def __iter__(self): 155 | for pos in itertools.product(self.cls.COL_I, range(1, self.cls.HEIGHT + 1)): 156 | if pos == ('N', 3): 157 | continue 158 | value = self[pos] 159 | if value is not None: 160 | yield pos, value 161 | 162 | index = BingoItemWrapper(BingoBoard).index 163 | 164 | class EmoteCollectorBingoBoard(BingoBoard): 165 | def __init__(self, *, value=None, categories=None, marks=None): 166 | super().__init__(value=value) 167 | self.categories = BingoItemWrapper(type(self), items=categories) 168 | self.marks = BingoItemWrapper(type(self), items=marks) 169 | 170 | def is_nsfw(self): 171 | return any(nsfw != 'SFW' for nsfw, name, id, image in filter(None, self.marks.items)) 172 | -------------------------------------------------------------------------------- /emote_collector/utils/bingo/tests.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BlueOak-1.0.0 2 | 3 | import contextlib 4 | import itertools 5 | import random 6 | 7 | from .board import BingoBoard 8 | 9 | def test_no_win(): 10 | b = BingoBoard() 11 | for c in 'BING': 12 | b[c, 1] = 1 13 | assert not b.has_won() 14 | 15 | b = BingoBoard() 16 | b['B', 1] = 1 17 | b['I', 2] = 1 18 | b['G', 4] = 1 19 | assert not b.has_won() 20 | 21 | for _ in range(50): 22 | b = BingoBoard() 23 | squares = list(itertools.product('BINGO', range(1, 6))) 24 | random.shuffle(squares) 25 | for _ in range(4): 26 | square = squares.pop() 27 | print(square) 28 | b[square] = 1 29 | assert not b.has_won() 30 | 31 | def test_horiz(): 32 | for row in range(1, 6): 33 | b = BingoBoard() 34 | for col in 'BINGO': 35 | b[col, row] = 1 36 | assert b.has_won() 37 | 38 | def test_vert(): 39 | for col in 'BINGO': 40 | b = BingoBoard() 41 | for row in range(1, 6): 42 | b[col, row] = 1 43 | assert b.has_won() 44 | 45 | def test_diag(): 46 | b = BingoBoard() 47 | for i in range(1, 6): 48 | b['BINGO'[i - 1], i] = 1 49 | assert b.has_won() 50 | 51 | b = BingoBoard() 52 | for i in range(1, 6): 53 | b['BINGO'[5 - i], i] = 1 54 | assert b.has_won() 55 | -------------------------------------------------------------------------------- /emote_collector/utils/checks.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | from discord.ext import commands 18 | 19 | from .errors import BlacklistedError 20 | 21 | # Used under the MIT license. Copyright (c) 2017 BeatButton 22 | # https://github.com/BeatButton/beattie/blob/44fd795aef7b1c19233510cda8046baab5ecabf3/utils/checks.py 23 | def owner_or_permissions(**perms): 24 | """Checks if the member is a bot owner or has any of the permissions necessary.""" 25 | async def predicate(ctx): 26 | if await ctx.bot.is_owner(ctx.author): 27 | return True 28 | permissions = ctx.channel.permissions_for(ctx.author) 29 | return any(getattr(permissions, perm, None) == value 30 | for perm, value in perms.items()) 31 | return commands.check(predicate) 32 | 33 | def is_moderator(): 34 | async def predicate(context): 35 | db = context.bot.cogs['Database'] 36 | if not await db.is_moderator(context.author.id): 37 | raise commands.CheckFailure(_('You must be an emote moderator to run this command.')) 38 | return True 39 | return commands.check(predicate) 40 | 41 | def not_blacklisted(): 42 | async def predicate(context): 43 | db = context.bot.cogs['Database'] 44 | blacklist_reason = await db.get_user_blacklist(context.author.id) 45 | if blacklist_reason is None: 46 | return True 47 | raise BlacklistedError(context.prefix, blacklist_reason) 48 | 49 | return commands.check(predicate) 50 | -------------------------------------------------------------------------------- /emote_collector/utils/context.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import discord 18 | from discord.ext import commands 19 | 20 | from . import strip_angle_brackets 21 | 22 | class CustomContext(commands.Context): 23 | """A custom context for discord.py which adds some utility functions.""" 24 | 25 | async def try_add_reaction(self, 26 | emoji: discord.Emoji, 27 | message: discord.Message = None, 28 | fallback_message='' 29 | ): 30 | """Try to add a reaction to the message. If it fails, send a message instead.""" 31 | if message is None: 32 | message = self.message 33 | 34 | try: 35 | await message.add_reaction(strip_angle_brackets(emoji)) 36 | except discord.Forbidden: 37 | await self.send(f'{emoji} {fallback_message}') 38 | -------------------------------------------------------------------------------- /emote_collector/utils/converter.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import inspect 18 | import re 19 | import typing 20 | 21 | import discord 22 | from discord.ext import commands 23 | from discord.ext.commands.view import StringView 24 | 25 | from .errors import EmoteNotFoundError, TooLewdError 26 | from .. import utils 27 | from ..extensions.db import DatabaseEmote 28 | 29 | 30 | class _MultiConverter(commands.Converter): 31 | def __init__(self, *, converters=None): 32 | self.converters = converters 33 | 34 | def __getitem__(self, params): 35 | return type(self)(converters=params) 36 | 37 | async def convert(self, ctx, argument): 38 | converted = [] 39 | view = StringView(argument) 40 | while not view.eof: 41 | args = [] 42 | for converter in self.converters: 43 | view.skip_ws() 44 | arg = view.get_quoted_word() 45 | if arg is None: 46 | raise commands.UserInputError(_('Not enough arguments.')) 47 | args.append(await self._do_conversion(ctx, converter, arg)) 48 | converted.append(tuple(args)) 49 | return converted 50 | 51 | async def _do_conversion(self, ctx, converter, arg): 52 | if inspect.isclass(converter) and issubclass(converter, commands.Converter): 53 | return await converter().convert(ctx, arg) 54 | if isinstance(converter, commands.Converter): 55 | return await converter.convert(ctx, arg) 56 | if callable(converter): 57 | return converter(arg) 58 | raise TypeError 59 | 60 | MultiConverter = _MultiConverter() 61 | 62 | class DatabaseEmoteConverter(commands.Converter): 63 | def __init__(self, *, check_nsfw=True): 64 | self.check_nsfw = check_nsfw 65 | 66 | async def convert(self, context, name: str): 67 | name = name.strip().strip(':;') 68 | cog = context.bot.cogs['Database'] 69 | emote = await cog.get_emote(name) 70 | if self.check_nsfw and emote.is_nsfw and not getattr(context.channel, 'nsfw', True): 71 | raise TooLewdError(emote.name) 72 | return emote 73 | 74 | UserOrMember = typing.Union[discord.Member, discord.User] 75 | 76 | async def convert_offset(context, channel, offset): 77 | try: 78 | offset = int(offset, base=0) - 1 # skip the invoking message 79 | except ValueError: 80 | raise commands.BadArgument(_('Not a valid integer.')) 81 | 82 | if offset == 0: 83 | # not sure why this should be allowed, but i see no reason to disallow it either. 84 | return message 85 | if offset < 0: 86 | return await utils.get_message_by_offset(channel, offset) 87 | 88 | raise commands.BadArgument(_('Not a message offset.')) 89 | 90 | def Snowflake(argument: str): 91 | try: 92 | id = int(argument) 93 | except ValueError: 94 | raise commands.BadArgument(_('Not a valid integer.')) 95 | 96 | if id < utils.SMALLEST_SNOWFLAKE: 97 | raise commands.BadArgument(_('Not a valid message ID.')) 98 | 99 | return id 100 | 101 | async def convert_id(context, channel, id: str): 102 | id = Snowflake(id) 103 | 104 | try: 105 | return await channel.fetch_message(id) 106 | except discord.NotFound: 107 | raise commands.BadArgument(_( 108 | 'Message not found! Make sure your message ID is correct.')) from None 109 | except discord.Forbidden: 110 | raise commands.BadArgument(_( 111 | 'Permission denied! Make sure the bot has permission to read that message.')) from None 112 | 113 | _member_converter = commands.converter.MemberConverter() 114 | 115 | async def convert_member(context, channel, argument): 116 | member = await _member_converter.convert(context, argument) 117 | 118 | def predicate(message): 119 | return ( 120 | message.id != context.message.id 121 | and message.author == member) 122 | 123 | return await _search_for_message(context, predicate) 124 | 125 | async def convert_keyword(context, channel, argument): 126 | argument = argument.lower() 127 | 128 | def normalize(message): 129 | # make sure that 1234 doesn't match <:emote:1234> 130 | return re.sub(utils.lexer.t_CUSTOM_EMOTE, lambda match: f':{match["name"]}:', message).lower() 131 | 132 | def predicate(message): 133 | return message.id != context.message.id and argument in normalize(message.content) 134 | 135 | return await _search_for_message(channel, predicate) 136 | 137 | async def _search_for_message(target, predicate): 138 | message = await target.history().find(predicate) 139 | if message is None: 140 | raise commands.BadArgument(_('Message not found.')) 141 | return message 142 | 143 | class Message(commands.Converter): 144 | _channel_converter = commands.converter.TextChannelConverter() 145 | 146 | @classmethod 147 | async def convert(cls, context, argument): 148 | channel, argument = await cls._parse_argument(context, argument) 149 | await cls._check_reaction_permissions(context, channel) 150 | 151 | for converter in convert_offset, convert_id, convert_member, convert_keyword: 152 | try: 153 | return await converter(context, channel, argument) 154 | except commands.CommandError as exception: 155 | pass 156 | 157 | raise commands.BadArgument(_( 158 | 'Failed to interpret that as a message offset, message ID, or user, ' 159 | 'or failed to find a message containing that search keyword.')) 160 | 161 | @classmethod 162 | async def _parse_argument(cls, context, argument) -> typing.Tuple[discord.abc.Messageable, str]: 163 | channel, slash, message = argument.partition('/') 164 | # allow spaces around the "/" 165 | channel = channel.rstrip() 166 | message = message.lstrip() 167 | if channel: 168 | try: 169 | channel = await cls._channel_converter.convert(context, channel) 170 | return channel, message 171 | except commands.BadArgument: 172 | pass 173 | 174 | return context.channel, argument 175 | 176 | @staticmethod 177 | async def _check_reaction_permissions(context, channel): 178 | # author might not be a Member, even in a guild, if it's a webhook. 179 | if not context.guild or not isinstance(context.author, discord.Member): 180 | return 181 | 182 | sender_permissions = channel.permissions_for(context.author) 183 | permissions = channel.permissions_for(context.guild.me) 184 | 185 | if not sender_permissions.read_message_history or not permissions.read_message_history: 186 | raise commands.CheckFailure(_('Unable to react: you and I both need permission to read message history.')) 187 | if not sender_permissions.add_reactions or not permissions.add_reactions: 188 | raise commands.CheckFailure(_('Unable to react: you and I both need permission to add reactions.')) 189 | if not sender_permissions.external_emojis or not permissions.external_emojis: 190 | raise commands.CheckFailure(_('Unable to react: you and I both need permission to use external emotes.')) 191 | 192 | LINKED_EMOTE = ( 193 | r'(?a)\[(?P\w{2,32})\]\(https://cdn\.discordapp' 194 | r'\.com/emojis/(?P\d{17,})\.(?P\w+)(?:\?v=1)?\)' 195 | ) 196 | 197 | class LoggedEmote(commands.Converter): 198 | async def convert(self, ctx, argument): 199 | message = await commands.converter.MessageConverter().convert(ctx, argument) 200 | 201 | if message.channel not in ctx.bot.cogs['Logger'].channels: 202 | raise commands.BadArgument(_('That message is not from a log channel.')) 203 | 204 | try: 205 | embed = message.embeds[0] 206 | except IndexError: 207 | raise commands.BadArgument(_('No embeds were found in that message.')) 208 | 209 | m = re.match(LINKED_EMOTE, embed.description) or re.match(utils.lexer.t_CUSTOM_EMOTE, embed.description) 210 | try: 211 | return await ctx.bot.cogs['Database'].get_emote(m['name']) 212 | except EmoteNotFoundError: 213 | d = m.groupdict() 214 | d['nsfw'] = 'MOD_NSFW' 215 | d['id'] = int(d['id']) 216 | d['animated'] = d.get('extension') == 'gif' or bool(d.get('animated')) 217 | return DatabaseEmote(d) 218 | 219 | # because MultiConverter does not support Union 220 | class DatabaseOrLoggedEmote(commands.Converter): 221 | def __init__(self, *, check_nsfw=True): 222 | self.db_conv = DatabaseEmoteConverter(check_nsfw=check_nsfw) 223 | 224 | async def convert(self, ctx, argument): 225 | err = None 226 | try: 227 | logged_emote = await LoggedEmote().convert(ctx, argument) 228 | except commands.CommandError as exc: 229 | pass 230 | else: 231 | return logged_emote 232 | 233 | try: 234 | db_emote = await self.db_conv.convert(ctx, argument) 235 | except commands.CommandError as exc: 236 | raise commands.BadArgument( 237 | _('Failed to interpret {argument} as a logged emote message or an emote in my database.') 238 | .format(argument=argument)) 239 | 240 | return db_emote 241 | 242 | class Guild(commands.Converter): 243 | async def convert(self, ctx, argument): 244 | try: 245 | guild_id = int(argument) 246 | except ValueError: 247 | guild = discord.utils.get(ctx.bot.guilds, name=argument) 248 | else: 249 | guild = ctx.bot.get_guild(guild_id) 250 | 251 | if guild is None: 252 | raise commands.BadArgument(_('Server not found.')) 253 | 254 | return guild 255 | -------------------------------------------------------------------------------- /emote_collector/utils/custom_send.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import functools 18 | 19 | import discord.abc 20 | 21 | # original at https://gist.github.com/ded2d8b33f29a449d4eaed0f77880adf 22 | 23 | _hooks = [] 24 | _patched = False 25 | _old_send = discord.abc.Messageable.send 26 | 27 | def global_message_send_hook(func): 28 | _hooks.append(func) 29 | _monkey_patch() 30 | # allow this to be used as a decorator 31 | return func 32 | 33 | register = global_message_send_hook 34 | 35 | unregister = _hooks.remove 36 | 37 | def restore(): 38 | global _patched 39 | 40 | _hooks.clear() 41 | discord.abc.Messageable.send = _old_send 42 | _patched = False 43 | 44 | def _monkey_patch(): 45 | global _patched 46 | 47 | if _patched: 48 | return 49 | 50 | @functools.wraps(_old_send) 51 | async def send(self, *args, **kwargs): 52 | # old_send is not a bound method. 53 | # "bind" it to self, so that the user doesnt have to pass in self manually 54 | bound_old_send = functools.partial(_old_send, self) 55 | 56 | for hook in _hooks: 57 | # allow the user to prevent default send behavior 58 | # by returning False 59 | # pass in old_send so that the user can still send 60 | # using the original behavior 61 | should_continue, *result = await hook(bound_old_send, *args, **kwargs) 62 | if not should_continue: 63 | return result[0] 64 | 65 | return await _old_send(self, *args, **kwargs) 66 | 67 | discord.abc.Messageable.send = send 68 | _patched = True 69 | -------------------------------------------------------------------------------- /emote_collector/utils/custom_typing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | 4 | class BetterTyping(discord.context_managers.Typing): 5 | async def do_typing(self): 6 | await asyncio.sleep(1) 7 | await super().do_typing() 8 | 9 | async def __aenter__(self): 10 | return self.__enter__() 11 | 12 | discord.abc.Messageable.typing = lambda self: BetterTyping(self) 13 | -------------------------------------------------------------------------------- /emote_collector/utils/emote.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | """ 18 | various utilities related to custom emotes and regular emojis 19 | """ 20 | 21 | import os.path 22 | import json 23 | 24 | import discord 25 | 26 | from .. import BASE_DIR 27 | 28 | with open(BASE_DIR / 'data' / 'discord-emoji-shortcodes.json') as f: 29 | emoji_shortcodes = frozenset(json.load(f)) 30 | del f 31 | 32 | def url(id, *, animated: bool = False): 33 | """Convert an emote ID to the image URL for that emote.""" 34 | return str(discord.PartialEmoji(animated=animated, name='', id=id).url) 35 | 36 | # remove when d.py v1.3.0 drops 37 | def is_usable(self): 38 | """:class:`bool`: Whether the bot can use this emoji.""" 39 | if not self.available: 40 | return False 41 | if not self._roles: 42 | return True 43 | emoji_roles, my_roles = self._roles, self.guild.me._roles 44 | return any(my_roles.has(role_id) for role_id in emoji_roles) 45 | 46 | if not hasattr(discord.Emoji, 'is_usable'): 47 | discord.Emoji.is_usable = is_usable 48 | -------------------------------------------------------------------------------- /emote_collector/utils/errors.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | 19 | from discord.ext import commands 20 | 21 | class ConnoisseurError(commands.CommandError): 22 | """Generic error with the bot. This can be used to catch all bot errors.""" 23 | pass 24 | 25 | class BlacklistedError(ConnoisseurError): 26 | """The user tried to use a command but they were blacklisted.""" 27 | def __init__(self, prefix, reason): 28 | super().__init__(_( 29 | 'Sorry, you have been blacklisted for “{reason}”. ' 30 | 'To appeal, please join the support server by running __{prefix}support__.').format(**locals())) 31 | 32 | class HTTPException(ConnoisseurError): 33 | """The server did not respond with an OK status code.""" 34 | def __init__(self, status): 35 | self.status = status 36 | super().__init__(_('URL error: server returned error code {status}').format(**locals())) 37 | 38 | class InvalidImageError(ConnoisseurError): 39 | """The image is not a valid GIF, PNG, JPG, or WEBP""" 40 | def __init__(self): 41 | super().__init__(_('The image supplied was not a valid GIF, PNG, JPG, or WEBP file.')) 42 | 43 | class URLTimeoutError(ConnoisseurError, asyncio.TimeoutError): 44 | """Retrieving the image took too long.""" 45 | def __init__(self): 46 | super().__init__(_('Error: Retrieving the image took too long.')) 47 | 48 | class ImageResizeTimeoutError(ConnoisseurError, asyncio.TimeoutError): 49 | """Resizing the image took too long.""" 50 | def __init__(self): 51 | super().__init__(_('Error: Resizing the image took too long.')) 52 | 53 | class EmoteError(ConnoisseurError): 54 | """Abstract error while trying to modify an emote""" 55 | def __init__(self, message, name=None): 56 | self.name = name 57 | super().__init__(message.format(name=self.name)) 58 | 59 | class EmoteExistsError(EmoteError): 60 | """An emote with that name already exists""" 61 | def __init__(self, emote): 62 | self.emote = emote 63 | super().__init__(_('An emote called “{name}” already exists in my database.'), self.emote.name) 64 | 65 | class EmoteNotFoundError(EmoteError): 66 | """An emote with that name was not found""" 67 | def __init__(self, name): 68 | super().__init__(_('An emote called “{name}” does not exist in my database.'), name) 69 | 70 | class PermissionDeniedError(EmoteError): 71 | """Raised when a user tries to modify an emote they don't own""" 72 | def __init__(self, name): 73 | super().__init__(_("You're not authorized to modify “{name}”."), name) 74 | 75 | class EmoteDescriptionTooLongError(EmoteError): 76 | """Raised when a user tries to set a description that's too long""" 77 | def __init__(self, name, actual_length, max_length): 78 | self.actual_length = actual_length 79 | self.limit = limit = self.max_length = max_length 80 | super().__init__(_( 81 | 'That description is too long. The limit is {limit}.').format(**locals())) 82 | 83 | class NoMoreSlotsError(ConnoisseurError): 84 | """Raised in the rare case that all slots of a particular type (static/animated) are full 85 | if this happens, make a new Emoji Backend account, create 100 more guilds, and add the bot 86 | to all of these guilds. 87 | """ 88 | def __init__(self): 89 | super().__init__(_('No more room to store emotes.')) 90 | 91 | class DiscordError(Exception): 92 | """Usually raised when the client cache is being baka""" 93 | def __init__(self): 94 | super().__init__(_('Discord seems to be having issues right now, please try again later.')) 95 | 96 | class TooLewdError(commands.BadArgument): 97 | """An NSFW emote was used in an SFW channel""" 98 | def __init__(self, name): 99 | self.name = name 100 | super().__init__(_('“{name}” is NSFW, but this channel is SFW.').format(**locals())) 101 | -------------------------------------------------------------------------------- /emote_collector/utils/i18n.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import builtins 18 | import gettext 19 | from glob import glob 20 | import os.path 21 | 22 | import aiocontextvars 23 | 24 | from .. import BASE_DIR 25 | 26 | default_locale = 'en_US' 27 | locale_dir = 'locale' 28 | locales = frozenset(p.name for p in (BASE_DIR / locale_dir).iterdir() if p.is_dir()) 29 | 30 | gettext_translations = { 31 | locale: gettext.translation( 32 | 'emote_collector', 33 | languages=(locale,), 34 | localedir=os.path.join(BASE_DIR, locale_dir)) 35 | for locale in locales} 36 | 37 | # source code is already in en_US. 38 | # we don't use default_locale as the key here 39 | # because the default locale for this installation may not be en_US 40 | gettext_translations['en_US'] = gettext.NullTranslations() 41 | locales = locales | {'en_US'} 42 | 43 | def use_current_gettext(*args, **kwargs): 44 | if not gettext_translations: 45 | return gettext.gettext(*args, **kwargs) 46 | 47 | locale = current_locale.get() 48 | return ( 49 | gettext_translations.get( 50 | locale, 51 | gettext_translations[default_locale]) 52 | .gettext(*args, **kwargs)) 53 | 54 | current_locale = aiocontextvars.ContextVar('i18n') 55 | builtins._ = use_current_gettext 56 | 57 | def set_default_locale(): current_locale.set(default_locale) 58 | set_default_locale() 59 | -------------------------------------------------------------------------------- /emote_collector/utils/image.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import base64 19 | import contextlib 20 | import io 21 | import logging 22 | import signal 23 | import sys 24 | import typing 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | try: 29 | import wand.image 30 | except (ImportError, OSError): 31 | logger.warning('Failed to import wand.image. Image manipulation functions will be unavailable.') 32 | else: 33 | import wand.exceptions 34 | 35 | from . import errors 36 | from . import size 37 | 38 | MAX_EMOTE_SIZE = 256 * 1024 39 | 40 | def resize_until_small(image_data: io.BytesIO) -> None: 41 | """If the image_data is bigger than the maximum allowed by discord, resize it until it's not.""" 42 | # It's important that we only attempt to resize the image when we have to, ie when it exceeds the Discord limit. 43 | # Apparently some small images become larger than the size limit when we attempt to resize them, 44 | # so resizing sometimes does more harm than good. 45 | max_resolution = 128 # pixels 46 | image_size = size(image_data) 47 | if image_size <= MAX_EMOTE_SIZE: 48 | return 49 | 50 | try: 51 | with wand.image.Image(blob=image_data) as original_image: 52 | while True: 53 | logger.debug('image size too big (%s bytes)', image_size) 54 | logger.debug('attempting resize to at most%s×%s pixels', max_resolution, max_resolution) 55 | 56 | with original_image.clone() as resized: 57 | # resize the image while preserving aspect ratio 58 | resized.transform(resize=f'{max_resolution}x{max_resolution}') 59 | image_size = len(resized.make_blob()) 60 | if image_size <= MAX_EMOTE_SIZE or max_resolution < 32: # don't resize past the max or 32×32 61 | image_data.truncate(0) 62 | image_data.seek(0) 63 | resized.save(file=image_data) 64 | image_data.seek(0) 65 | break 66 | 67 | max_resolution //= 2 68 | except wand.exceptions.CoderError: 69 | raise errors.InvalidImageError 70 | 71 | def is_animated(image_data: bytes): 72 | """Return whether the image data is animated, or raise InvalidImageError if it's not an image. 73 | Note: unlike mime_type_for_image(), this function requires the *entire* image. 74 | """ 75 | return mime_type_for_image(image_data) == 'image/gif' and is_animated_gif(image_data) 76 | 77 | def is_animated_gif(gif_image: bytes): 78 | with wand.image.Image(blob=gif_image) as img: 79 | return len(img.sequence) > 1 80 | 81 | """The fewest bytes needed to identify the type of an image.""" 82 | MINIMUM_BYTES_NEEDED = 12 83 | 84 | def mime_type_for_image(data): 85 | if data.startswith(b'\x89PNG\r\n\x1a\n'): 86 | return 'image/png' 87 | if data.startswith(b'\xFF\xD8\xFF') or data[6:10] in (b'JFIF', b'Exif'): 88 | return 'image/jpeg' 89 | if data.startswith((b'GIF87a', b'GIF89a')): 90 | return 'image/gif' 91 | if data.startswith(b'RIFF') and data[8:12] == b'WEBP': 92 | return 'image/webp' 93 | raise errors.InvalidImageError 94 | 95 | def image_to_base64_url(data): 96 | fmt = 'data:{mime};base64,{data}' 97 | mime = mime_type_for_image(data) 98 | b64 = base64.b64encode(data).decode('ascii') 99 | return fmt.format(mime=mime, data=b64) 100 | 101 | def main() -> typing.NoReturn: 102 | """resize an image from stdin and write the resized version to stdout.""" 103 | data = io.BytesIO(sys.stdin.buffer.read()) 104 | try: 105 | resize_until_small(data) 106 | except errors.InvalidImageError: 107 | # 2 is used because 1 is already used by python's default error handler 108 | sys.exit(2) 109 | 110 | stdout_write = sys.stdout.buffer.write # getattr optimization 111 | 112 | for buf in iter(lambda: data.read(16 * 1024), b''): 113 | stdout_write(buf) 114 | 115 | sys.exit(0) 116 | 117 | async def resize_in_subprocess(image_data: bytes): 118 | if len(image_data) <= MAX_EMOTE_SIZE: 119 | return image_data 120 | 121 | proc = await asyncio.create_subprocess_exec( 122 | sys.executable, '-m', __name__, 123 | 124 | stdin=asyncio.subprocess.PIPE, 125 | stdout=asyncio.subprocess.PIPE, 126 | stderr=asyncio.subprocess.PIPE) 127 | 128 | try: 129 | image_data, err = await asyncio.wait_for(proc.communicate(image_data), timeout=30) 130 | except asyncio.TimeoutError: 131 | proc.send_signal(signal.SIGINT) 132 | raise errors.ImageResizeTimeoutError 133 | else: 134 | if proc.returncode == 2: 135 | raise errors.InvalidImageError 136 | if proc.returncode != 0: 137 | raise RuntimeError(err.decode('utf-8') + f'Return code: {proc.returncode}') 138 | 139 | return image_data 140 | 141 | if __name__ == '__main__': 142 | main() 143 | -------------------------------------------------------------------------------- /emote_collector/utils/lexer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Emote Collector collects emotes from other servers for use by people without Nitro 4 | # Copyright © 2018–2019 lambda#0987 5 | # 6 | # Emote Collector is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # Emote Collector is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Emote Collector. If not, see . 18 | 19 | import ply.lex 20 | 21 | tokens = ( 22 | "CODE", 23 | "ESCAPED_EMOTE", 24 | "CUSTOM_EMOTE", 25 | "EMOTE", 26 | "TEXT", 27 | ) 28 | 29 | """Matches code blocks, which should be ignored.""" 30 | t_CODE = '(?su)(?P`{1,3}).+?(?P=code)' 31 | 32 | r"""Matches \:foo: and \;foo;, allowing one to prevent the emote auto response for one emote.""" 33 | # we don't need to match :foo\:, since "foo\" is not a valid emote name anyway 34 | t_ESCAPED_EMOTE = r'(?a)\\(?P:|;)\w{2,32}(?P=colon)' 35 | 36 | """Matches only custom server emotes.""" 37 | t_CUSTOM_EMOTE = r'(?a)<(?Pa?):(?P\w{2,32}):(?P\d{17,})>' 38 | 39 | """Matches :foo: and ;foo; but not :foo;. Used for emotes in text.""" 40 | t_EMOTE = r'(?a)(?P:|;)(?P\w{2,32})(?P=colon)' 41 | 42 | t_TEXT = r'(?s).' 43 | 44 | def t_error(t): 45 | raise SyntaxError(f'Unknown text "{t.value}"') 46 | 47 | # it is required that ply.lex.lex be run in the context of this module 48 | # so we can't just say "new = ply.lex.lex" cause that'll run lex() 49 | # in the context of the caller's module 50 | new = lambda: ply.lex.lex() 51 | 52 | def main(): 53 | import textwrap 54 | 55 | lexer = new() 56 | 57 | test = textwrap.dedent(r""" 58 | You're mom gay 59 | haha lol xd 60 | :hahaYes: :notlikeblob: ;cruz; 61 | \:thonk: `:speedtest:` 62 | <:foo:123456789123456789> 63 | ``` 64 | :congaparrot:;congaparrot;:congaparrot: 65 | ` foo bar 66 | <:foo:123456789123456789> 67 | `` baz `` 68 | ``` 69 | """) 70 | lexer.input(test) 71 | 72 | print(test) 73 | 74 | for toke1 in iter(lexer.token, None): 75 | print(f'{toke1.type}: {toke1.value!r}') 76 | 77 | ply.lex.runmain() 78 | 79 | if __name__ == '__main__': 80 | main() 81 | -------------------------------------------------------------------------------- /emote_collector/utils/misc.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import collections 19 | import contextlib 20 | import datetime 21 | import functools 22 | import io 23 | import math 24 | import re 25 | import time 26 | import typing 27 | import urllib.parse 28 | from email.message import EmailMessage 29 | from typing import Sequence, Union 30 | 31 | import asyncpg 32 | import discord 33 | from discord.ext import commands 34 | 35 | from . import errors 36 | 37 | """miscellaneous utility functions and constants""" 38 | 39 | """Stanislav#0001's user ID 40 | This is useful to test whether a number is a snowflake: 41 | if it's greater than this number, it probably is 42 | """ 43 | SMALLEST_SNOWFLAKE = 21154535154122752 44 | 45 | def bytes_to_int(x): 46 | return int.from_bytes(x, byteorder='big') 47 | 48 | def int_to_bytes(n): 49 | num_bytes = int(math.ceil(n.bit_length() / 8)) 50 | return n.to_bytes(num_bytes, byteorder='big') 51 | 52 | def codeblock(message, *, lang=''): 53 | cleaned = message.replace('```', '\N{zero width space}'.join('```')) 54 | return f'```{lang}\n{cleaned}```' 55 | 56 | async def get_message_by_offset(channel, index: int) -> discord.Message: 57 | """Gets channel[-index]. For instance get_message(channel, -2) == second to last message. 58 | Requires channel history permissions 59 | """ 60 | try: 61 | return (await channel.history(limit=abs(index)).flatten())[-1] 62 | except (discord.NoMoreItems, IndexError): 63 | raise commands.BadArgument(_('Message not found.')) 64 | 65 | def format_user(bot, id, *, mention=False): 66 | """Format a user ID for human readable display.""" 67 | user = bot.get_user(id) 68 | if user is None: 69 | return _('Unknown user with ID {id}').format(**locals()) 70 | # not mention: @null byte#8191 (140516693242937345) 71 | # mention: <@140516693242937345> (null byte#8191) 72 | # this allows people to still see the username and discrim 73 | # if they don't share a server with that user 74 | if mention: 75 | return f'{user.mention} (@{user})' 76 | else: 77 | return f'@{user} ({user.id})' 78 | 79 | def format_time(date: datetime.datetime): 80 | """Format a datetime to look like '2018-02-22 22:38:12 UTC'.""" 81 | return date.strftime('%Y-%m-%d %H:%m:%S %Z') 82 | 83 | def strip_angle_brackets(string): 84 | """Strip leading < and trailing > from a string. 85 | Useful if a user sends you a url like to avoid embeds, or to convert emotes to reactions.""" 86 | if string.startswith('<') and string.endswith('>'): 87 | return string[1:-1] 88 | return string 89 | 90 | def format_http_exception(exception: discord.HTTPException): 91 | """Formats a discord.HTTPException for relaying to the user. 92 | Sample return value: 93 | 94 | BAD REQUEST (status code: 400): 95 | Invalid Form Body 96 | In image: File cannot be larger than 256 kb. 97 | """ 98 | return ( 99 | f'{exception.response.reason} (status code: {exception.response.status}):' 100 | f'\n{exception.text}') 101 | 102 | def expand_cartesian_product(str) -> (str, str): 103 | """expand a string containing one non-nested cartesian product strings into two strings 104 | 105 | >>> expand_cartesian_product('foo{bar,baz}') 106 | ('foobar', 'foobaz') 107 | >>> expand_cartesian_product('{old,new}') 108 | ('old', 'new') 109 | >>> expand_cartesian_product('uninteresting') 110 | ('uninteresting', '') 111 | >>> expand_cartesian_product('{foo,bar,baz}') 112 | ('foo,bar', 'baz') # edge case that i don't need to fix 113 | 114 | """ 115 | 116 | match = re.search('{([^{}]*),([^{}]*)}', str) 117 | if match: 118 | return ( 119 | _expand_one_cartesian_product(str, match, 1), 120 | _expand_one_cartesian_product(str, match, 2) 121 | ) 122 | else: 123 | return (str, '') 124 | 125 | def _expand_one_cartesian_product(str, match, group): 126 | return str[:match.start()] + match[group] + str[match.end():] 127 | 128 | def load_json_compat(filename): 129 | """evaluate a python dictionary/list/thing, while maintaining some compatibility with JSON""" 130 | # >HOLD UP! Why the heck are you using eval in production?? 131 | # The config file is 100% trusted data. 132 | # NOTHING the user ever sends, ends up in there. 133 | # Also, consider another common approach: `import config`. 134 | # Which is arbitrary code execution anyway. 135 | # Also we add datetime to the globals so that we can use timedeltas in the config. 136 | globals = dict(true=True, false=False, null=None, datetime=datetime) 137 | with open(filename) as f: 138 | # we use compile so that tracebacks contain the filename 139 | compiled = compile(f.read(), filename, 'eval') 140 | 141 | return eval(compiled, globals) 142 | 143 | async def async_enumerate(async_iterator, start=0): 144 | i = int(start) 145 | async for x in async_iterator: 146 | yield i, x 147 | i += 1 148 | 149 | def size(data: typing.IO): 150 | """return the size, in bytes, of the data a BytesIO object represents""" 151 | with preserve_position(data): 152 | data.seek(0, io.SEEK_END) 153 | return data.tell() 154 | 155 | class preserve_position(contextlib.AbstractContextManager): 156 | def __init__(self, fp): 157 | self.fp = fp 158 | self.old_pos = fp.tell() 159 | 160 | def __exit__(self, *excinfo): 161 | self.fp.seek(self.old_pos) 162 | 163 | def clean_content(bot, message, content, *, fix_channel_mentions=False, use_nicknames=True, escape_markdown=False): 164 | transformations = {} 165 | 166 | if fix_channel_mentions and message.guild: 167 | def resolve_channel(id, *, _get=message.guild.get_channel): 168 | ch = _get(id) 169 | return ('<#%s>' % id), ('#' + ch.name if ch else '#deleted-channel') 170 | 171 | transformations.update(resolve_channel(channel) for channel in message.raw_channel_mentions) 172 | 173 | if use_nicknames and message.guild: 174 | def resolve_member(id, *, _get=message.guild.get_member): 175 | m = _get(id) 176 | return '@' + m.display_name if m else '@deleted-user' 177 | else: 178 | def resolve_member(id, *, _get=bot.get_user): 179 | m = _get(id) 180 | return '@' + m.name if m else '@deleted-user' 181 | 182 | 183 | transformations.update( 184 | ('<@%s>' % member_id, resolve_member(member_id)) 185 | for member_id in message.raw_mentions 186 | ) 187 | 188 | transformations.update( 189 | ('<@!%s>' % member_id, resolve_member(member_id)) 190 | for member_id in message.raw_mentions 191 | ) 192 | 193 | if message.guild: 194 | def resolve_role(id, *, _find=discord.utils.find, _roles=message.guild.roles): 195 | r = _find(lambda x: x.id == id, _roles) 196 | return '@' + r.name if r else '@deleted-role' 197 | 198 | transformations.update( 199 | ('<@&%s>' % role_id, resolve_role(role_id)) 200 | for role_id in message.raw_role_mentions 201 | ) 202 | 203 | def repl(match): 204 | return transformations.get(match[0], '') 205 | 206 | pattern = re.compile('|'.join(transformations.keys())) 207 | result = pattern.sub(repl, content) 208 | 209 | if escape_markdown: 210 | transformations = { 211 | re.escape(c): '\\' + c 212 | for c in ('*', '`', '_', '~', '\\') 213 | } 214 | 215 | def replace(match): 216 | return transformations.get(re.escape(match[0]), '') 217 | 218 | pattern = re.compile('|'.join(transformations.keys())) 219 | result = pattern.sub(replace, result) 220 | 221 | # Completely ensure no mentions escape: 222 | return re.sub(r'@(everyone|here|[!&]?[0-9]{17,21})', '@\u200b\\1', result) 223 | 224 | def asyncexecutor(*, timeout=None): 225 | """decorator that turns a synchronous function into an async one""" 226 | def decorator(func): 227 | @functools.wraps(func) 228 | def decorated(*args, **kwargs): 229 | f = functools.partial(func, *args, **kwargs) 230 | 231 | loop = asyncio.get_event_loop() 232 | coro = loop.run_in_executor(None, f) 233 | return asyncio.wait_for(coro, timeout=timeout, loop=loop) 234 | return decorated 235 | return decorator 236 | 237 | def channel_is_nsfw(channel): 238 | return ( 239 | not channel # if not specified, allow NSFW 240 | or getattr(channel, 'nsfw', True)) # otherwise, allow NSFW if DMs or the guild channel is NSFW 241 | 242 | def apply(f, *args, **kwargs): 243 | return f(*args, **kwargs) 244 | 245 | def flip(f): 246 | def flipped(y, x): return f(x, y) 247 | return flipped 248 | 249 | def compose(*funcs): 250 | def composed(x): return functools.reduce(flip(apply), reversed(funcs), x) 251 | return composed 252 | 253 | async def gather_or_cancel(*awaitables, loop=None): 254 | """run the awaitables in the sequence concurrently. If any of them raise an exception, 255 | propagate the first exception raised and cancel all other awaitables. 256 | """ 257 | futs = list(map(asyncio.ensure_future, awaitables)) 258 | gather_task = asyncio.gather(*awaitables, loop=loop) 259 | 260 | def cancel_children(_): 261 | for fut in futs: 262 | fut.cancel() 263 | 264 | gather_task.add_done_callback(cancel_children) 265 | return await gather_task 266 | 267 | def parse_header(h): 268 | """cgi.parse_header replacement for versions where cgi is deprecated/removed""" 269 | m = EmailMessage() 270 | m['content-type'] = h 271 | l = m.get_params() 272 | return l[0][0], dict(l[1:]) 273 | -------------------------------------------------------------------------------- /emote_collector/utils/paginator.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import asyncio 18 | import collections 19 | import contextlib 20 | 21 | import discord 22 | from discord.ext.commands import CommandError 23 | 24 | # Derived mainly from R.Danny but also from Liara: 25 | # Copyright © 2015 Rapptz 26 | 27 | # Copyright © 2016-2017 Pandentia and contributors 28 | # https://github.com/Thessia/Liara/blob/75fa11948b8b2ea27842d8815a32e51ef280a999/cogs/utils/paginator.py 29 | 30 | class CannotPaginate(CommandError): 31 | pass 32 | 33 | class Pages: 34 | """Implements a paginator that queries the user for the 35 | pagination interface. 36 | 37 | Pages are 1-index based, not 0-index based. 38 | 39 | If the user does not reply within 2 minutes then the pagination 40 | interface exits automatically. 41 | 42 | Parameters 43 | ------------ 44 | ctx: Context 45 | The context of the command. 46 | entries: List[str] 47 | A list of entries to paginate. 48 | per_page: int 49 | How many entries show up per page. 50 | show_entry_count: bool 51 | Whether to show an entry count in the footer. 52 | timeout: float 53 | How long to wait for reactions on the message. 54 | delete_message: bool 55 | Whether to delete the message when the user presses the stop button. 56 | delete_message_on_timeout: bool 57 | Whether to delete the message after the reaction timeout is reached. 58 | 59 | Attributes 60 | ----------- 61 | embed: discord.Embed 62 | The embed object that is being used to send pagination info. 63 | Feel free to modify this externally. Only the description 64 | and footer fields are internally modified. 65 | permissions: discord.Permissions 66 | Our permissions for the channel. 67 | text_message: Optional[str] 68 | What to display above the embed. 69 | """ 70 | def __init__(self, ctx, *, entries, per_page=7, show_entry_count=True, timeout=120.0, 71 | delete_message=True, delete_message_on_timeout=False, 72 | ): 73 | self.bot = ctx.bot 74 | self.entries = entries 75 | self.message = ctx.message 76 | self.channel = ctx.channel 77 | self.author = ctx.author 78 | self.per_page = per_page 79 | pages, left_over = divmod(len(self.entries), self.per_page) 80 | if left_over: 81 | pages += 1 82 | self.maximum_pages = pages 83 | self.embed = discord.Embed() 84 | self.paginating = len(entries) > per_page 85 | self.show_entry_count = show_entry_count 86 | self.timeout = timeout 87 | self.delete_message = delete_message 88 | self.delete_message_on_timeout = delete_message_on_timeout 89 | self.text_message = None 90 | self.reaction_emojis = collections.OrderedDict(( 91 | ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.first_page), 92 | ('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page), 93 | ('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page), 94 | ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.last_page), 95 | ('\N{INPUT SYMBOL FOR NUMBERS}', self.numbered_page), 96 | ('\N{BLACK SQUARE FOR STOP}', self.stop), 97 | ('\N{INFORMATION SOURCE}', self.show_help), 98 | )) 99 | 100 | if ctx.guild is not None: 101 | self.permissions = self.channel.permissions_for(ctx.guild.me) 102 | else: 103 | self.permissions = self.channel.permissions_for(ctx.bot.user) 104 | 105 | if not self.permissions.embed_links: 106 | raise CannotPaginate(_('Bot does not have embed links permission.')) 107 | 108 | if not self.permissions.send_messages: 109 | raise CannotPaginate(_('Bot cannot send messages.')) 110 | 111 | if self.paginating: 112 | # verify we can actually use the pagination session 113 | if not self.permissions.add_reactions: 114 | raise CannotPaginate(_('Bot does not have add reactions permission.')) 115 | 116 | if not self.permissions.read_message_history: 117 | raise CannotPaginate(_('Bot does not have Read Message History permission.')) 118 | 119 | def get_page(self, page): 120 | base = (page - 1) * self.per_page 121 | return self.entries[base:base + self.per_page] 122 | 123 | def get_content(self, entries, page, *, first=False): 124 | return self.text_message 125 | 126 | def get_embed(self, entries, page, *, first=False): 127 | self.prepare_embed(entries, page, first=first) 128 | return self.embed 129 | 130 | def prepare_embed(self, entries, page, *, first=False): 131 | p = [] 132 | for index, entry in enumerate(entries, 1 + ((page - 1) * self.per_page)): 133 | p.append(f'{index}. {entry}') 134 | 135 | if self.maximum_pages > 1: 136 | if self.show_entry_count: 137 | text = _('Page {page}⁄{self.maximum_pages} ({num_entries} entries)').format( 138 | num_entries=len(self.entries), 139 | **locals()) 140 | else: 141 | text = _('Page {page}⁄{self.maximum_pages}').format(**locals()) 142 | 143 | self.embed.set_footer(text=text) 144 | 145 | if self.paginating and first: 146 | p.append('') 147 | p.append(_('Confused? React with \N{INFORMATION SOURCE} for more info.')) 148 | 149 | self.embed.description = '\n'.join(p) 150 | 151 | async def show_page(self, page, *, first=False): 152 | self.current_page = page 153 | entries = self.get_page(page) 154 | content = self.get_content(entries, page, first=first) 155 | embed = self.get_embed(entries, page, first=first) 156 | 157 | if not self.paginating: 158 | return await self.channel.send(content=content, embed=embed) 159 | 160 | if not first: 161 | await self.message.edit(content=content, embed=embed) 162 | return 163 | 164 | self.message = await self.channel.send(content=content, embed=embed) 165 | await self.add_reactions() 166 | 167 | async def add_reactions(self): 168 | for reaction in self.reaction_emojis: 169 | if self.maximum_pages == 2 and reaction in {'⏮', '⏭'}: 170 | # no |<< or >>| buttons if we only have two pages 171 | # we can't forbid it if someone ends up using it but remove 172 | # it from the default set 173 | continue 174 | 175 | with contextlib.suppress(discord.HTTPException): 176 | await self.message.add_reaction(reaction) 177 | 178 | async def checked_show_page(self, page): 179 | if page != 0 and page <= self.maximum_pages: 180 | await self.show_page(page) 181 | 182 | async def first_page(self): 183 | """goes to the first page""" 184 | await self.show_page(1) 185 | 186 | async def last_page(self): 187 | """goes to the last page""" 188 | await self.show_page(self.maximum_pages) 189 | 190 | async def next_page(self): 191 | """goes to the next page""" 192 | await self.checked_show_page(self.current_page + 1) 193 | 194 | async def previous_page(self): 195 | """goes to the previous page""" 196 | await self.checked_show_page(self.current_page - 1) 197 | 198 | async def show_current_page(self): 199 | if self.paginating: 200 | await self.show_page(self.current_page) 201 | 202 | async def numbered_page(self): 203 | """lets you type a page number to go to""" 204 | 205 | to_delete = [] 206 | to_delete.append(await self.channel.send(_('What page do you want to go to?'))) 207 | 208 | def message_check(m): 209 | return m.author == self.author and \ 210 | self.channel == m.channel and \ 211 | m.content.isdigit() 212 | 213 | try: 214 | msg = await self.bot.wait_for('message', check=message_check, timeout=30.0) 215 | except asyncio.TimeoutError: 216 | to_delete.append(await self.channel.send(_('You took too long.'))) 217 | await asyncio.sleep(5) 218 | else: 219 | page = int(msg.content) 220 | to_delete.append(msg) 221 | if page != 0 and page <= self.maximum_pages: 222 | await self.show_page(page) 223 | else: 224 | to_delete.append(await self.channel.send(_( 225 | 'Invalid page given. ({page}/{self.maximum_pages})').format(**locals()))) 226 | await asyncio.sleep(5) 227 | 228 | for message in to_delete: 229 | # we could use self.channel.delete_messages, but doing so would stop as soon as one of them fails 230 | # doing it this way ensures all of them are deleted 231 | with contextlib.suppress(discord.HTTPException): 232 | await message.delete() 233 | 234 | async def show_help(self): 235 | """shows this message""" 236 | 237 | messages = [_('Welcome to the interactive paginator!\n')] 238 | messages.append(_('This interactively allows you to see pages of text by navigating with ' 239 | 'reactions. They are as follows:\n')) 240 | 241 | for emoji, func in self.reaction_emojis.items(): 242 | messages.append(f'{emoji} {func.__doc__}') 243 | 244 | self.embed.description = '\n'.join(messages) 245 | self.embed.clear_fields() 246 | self.embed.set_footer( 247 | text=_('We were on page {self.current_page} before this message.').format(**locals())) 248 | await self.message.edit(embed=self.embed) 249 | 250 | async def go_back_to_current_page(): 251 | await asyncio.sleep(60.0) 252 | await self.show_current_page() 253 | 254 | self.bot.loop.create_task(go_back_to_current_page()) 255 | 256 | async def stop(self, *, delete=None): 257 | """stops the interactive pagination session""" 258 | 259 | if delete is None: 260 | delete = self.delete_message 261 | 262 | if delete: 263 | with contextlib.suppress(discord.HTTPException): 264 | await self.message.delete() 265 | else: 266 | await self._clear_reactions() 267 | 268 | self.paginating = False 269 | 270 | async def _clear_reactions(self): 271 | try: 272 | await self.message.clear_reactions() 273 | except discord.Forbidden: 274 | for emoji in self.reaction_emojis: 275 | with contextlib.suppress(discord.HTTPException): 276 | await self.message.remove_reaction(emoji, self.message.author) 277 | except discord.HTTPException: 278 | pass 279 | 280 | def react_check(self, reaction, user): 281 | if user is None or user.id != self.author.id: 282 | return False 283 | 284 | if reaction.message.id != self.message.id: 285 | return False 286 | 287 | try: 288 | self.match = self.reaction_emojis[reaction.emoji] 289 | except KeyError: 290 | return False 291 | return True 292 | 293 | async def begin(self): 294 | """Actually paginate the entries and run the interactive loop if necessary.""" 295 | 296 | first_page = self.show_page(1, first=True) 297 | if not self.paginating: 298 | await first_page 299 | else: 300 | # allow us to react to reactions right away if we're paginating 301 | self.bot.loop.create_task(first_page) 302 | 303 | while self.paginating: 304 | try: 305 | reaction, user = await self.bot.wait_for( 306 | 'reaction_add', 307 | check=self.react_check, 308 | timeout=self.timeout) 309 | except asyncio.TimeoutError: 310 | await self.stop(delete=self.delete_message_on_timeout) 311 | break 312 | 313 | await asyncio.sleep(0.2) 314 | with contextlib.suppress(discord.HTTPException): 315 | await self.message.remove_reaction(reaction, user) 316 | 317 | await self.match() 318 | 319 | class FieldPages(Pages): 320 | """ 321 | Similar to Pages except entries should be a list of 322 | tuples having (key, value) to show as embed fields instead. 323 | """ 324 | 325 | async def show_page(self, page, *, first=False): 326 | self.current_page = page 327 | entries = self.get_page(page) 328 | 329 | self.embed.clear_fields() 330 | self.embed.description = discord.Embed.Empty 331 | 332 | for key, value in entries: 333 | self.embed.add_field(name=key, value=value, inline=False) 334 | 335 | if self.maximum_pages > 1: 336 | if self.show_entry_count: 337 | text = _('Page {page}⁄{self.maximum_pages} ({num_entries} entries)').format( 338 | num_entries=len(self.entries), 339 | **locals()) 340 | else: 341 | text = _('Page {page}⁄{self.maximum_pages}').format(**locals()) 342 | 343 | self.embed.set_footer(text=text) 344 | 345 | kwargs = {'embed': self.embed} 346 | if self.text_message: 347 | kwargs['content'] = self.text_message 348 | 349 | if not self.paginating: 350 | return await self.channel.send(**kwargs) 351 | 352 | if not first: 353 | await self.message.edit(**kwargs) 354 | return 355 | 356 | self.message = await self.channel.send(**kwargs) 357 | await self.add_reactions() 358 | -------------------------------------------------------------------------------- /emote_collector/utils/proxy.py: -------------------------------------------------------------------------------- 1 | # Emote Collector collects emotes from other servers for use by people without Nitro 2 | # Copyright © 2018–2019 lambda#0987 3 | # 4 | # Emote Collector is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as 6 | # published by the Free Software Foundation, either version 3 of the 7 | # License, or (at your option) any later version. 8 | # 9 | # Emote Collector is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with Emote Collector. If not, see . 16 | 17 | import importlib 18 | import importlib.util 19 | import os 20 | import sys 21 | 22 | class ObjectProxy: 23 | def __init__(self, thunk): 24 | vars(self)[f'_{type(self).__name__}__thunk'] = thunk 25 | 26 | for meth_name in (f'__{meth_name}__' for meth_name in ( 27 | 'call await enter exit aenter aexit len bool lt le eq ne gt ge dir delattr getitem setitem ' 28 | 'delitem setattr length_hint missing iter reversed contains add sub mul matmul truediv floordiv mod divmod pow ' 29 | 'lshift rshift and xor or radd rsub rmul rmatmul rtruediv rfloordiv rmod rdivmod rpow rlshift rrshift rand ' 30 | 'rxor ror iadd isub imul imatmul itruediv ifloordiv imod ipow ilshift irshift iand ixor ior neg abs pos abs ' 31 | 'invert complex int float index round trunc floor ceil aiter anext' 32 | ).split()): 33 | # avoid having to pass in meth_name as a default argument so we can avoid name conflicts 34 | def closure(meth_name=meth_name): 35 | def meth(self, *args, **kwargs): 36 | return getattr(self.__thunk(), meth_name)(*args, **kwargs) 37 | return meth 38 | meth = closure() 39 | meth.__name__ = meth_name 40 | vars()[meth_name] = meth 41 | 42 | del closure, meth, meth_name 43 | 44 | def __getattr__(self, k): 45 | return getattr(self.__thunk(), k) 46 | 47 | def __repr__(self): 48 | return f'' 49 | 50 | class ModuleReloadObjectProxy: 51 | def __init__(self, module_proxy): 52 | vars(self)[f'_{type(self).__name__}__module_proxy'] = module_proxy 53 | 54 | @classmethod 55 | def __is_mangled(cls, name): 56 | return name.startswith(f'_{cls.__name__}__') 57 | 58 | def __getattr__(self, k): 59 | if self.__is_mangled(k): 60 | return vars(self)[k] 61 | self.__module_proxy.reload() 62 | return getattr(self.__module_proxy._module, k) 63 | 64 | def __setattr__(self, k, v): 65 | if self.__is_mangled(k): 66 | vars(self)[k] = v 67 | else: 68 | setattr(self.__module_proxy._module, k, v) 69 | 70 | def __delattr__(self, k): 71 | if self.__is_mangled(k): 72 | del vars(self)[k] 73 | else: 74 | delattr(self.__module_proxy._module, k) 75 | 76 | class _ModuleProxy: 77 | def __init__(self, mod_name): 78 | self.mod_name = mod_name 79 | self.spec = spec = importlib.util.find_spec(mod_name) 80 | if spec is None: 81 | raise ModuleNotFoundError(f'No module named {mod_name!r}') 82 | self.path = self.spec.origin 83 | self._module = mod = importlib.util.module_from_spec(spec) 84 | self.module = ModuleReloadObjectProxy(self) 85 | spec.loader.exec_module(mod) 86 | sys.modules[mod_name] = mod 87 | self.last_mtime = self.mtime() 88 | 89 | def mtime(self): 90 | stat = os.stat(self.path) 91 | return stat.st_mtime 92 | 93 | def reload(self): 94 | mtime = self.mtime() 95 | if mtime > self.last_mtime: 96 | self._module = importlib.reload(self._module) 97 | self.last_mtime = mtime 98 | 99 | def ModuleProxy(mod_name): 100 | return _ModuleProxy(mod_name).module 101 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # encoding: utf-8 3 | 4 | import setuptools 5 | 6 | setuptools.setup( 7 | name='emote_collector', 8 | version='0.0.1', 9 | 10 | packages=[ 11 | 'emote_collector', 12 | 'emote_collector.utils', 13 | 'emote_collector.extensions', 14 | ], 15 | 16 | include_package_data=True, 17 | 18 | install_requires=[ 19 | 'aiocontextvars>=0.2.2', 20 | 'asyncpg', 21 | 'bot_bin[sql]>=1.1.0,<2.0.0', 22 | 'braceexpand', 23 | 'discord.py>=1.2.5,<2.0.0', 24 | 'humanize', 25 | 'jishaku>=1.17.0,<2.0.0', 26 | 'jinja2', 27 | 'ply', 28 | 'psutil', 29 | 'pygit2', 30 | 'wand', 31 | ], 32 | ) 33 | --------------------------------------------------------------------------------