├── .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 
(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
to the last message.
38 | ec/react :speedtest: hello there
will react with
to the most recent message containing "hello there".
39 | ec/react speedtest @Someone
will react with
to the last message by Someone.
40 | ec/react ;speedtest; -2
will react with
to the second-to-last message.
41 | ec/react speedtest 462092903540457473
will react with
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 |
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 |
--------------------------------------------------------------------------------