PluralKit/src/pluralkit/bot/proxy.py

245 lines
9.5 KiB
Python
Raw Normal View History

import asyncio
import re
import discord
from io import BytesIO
from typing import Optional
from pluralkit import db
from pluralkit.bot import utils, channel_logger
from pluralkit.bot.channel_logger import ChannelLogger
from pluralkit.member import Member
from pluralkit.system import System
class ProxyError(Exception):
pass
2018-11-13 14:01:24 +01:00
def fix_webhook(webhook: discord.Webhook) -> discord.Webhook:
# Workaround for https://github.com/Rapptz/discord.py/issues/1242 and similar issues (#1150)
webhook._adapter.store_user = webhook._adapter._store_user
webhook._adapter.http = None
return webhook
2018-10-27 22:00:41 +02:00
async def get_or_create_webhook_for_channel(conn, bot_user: discord.User, channel: discord.TextChannel):
2018-10-27 22:00:41 +02:00
# First, check if we have one saved in the DB
webhook_from_db = await db.get_webhook(conn, channel.id)
if webhook_from_db:
webhook_id, webhook_token = webhook_from_db
session = channel._state.http._session
hook = discord.Webhook.partial(webhook_id, webhook_token, adapter=discord.AsyncWebhookAdapter(session))
hook._adapter.store_user = hook._adapter._store_user
2018-11-13 14:01:24 +01:00
return fix_webhook(hook)
2018-10-27 22:00:41 +02:00
try:
# If not, we check to see if there already exists one we've missed
for existing_hook in await channel.webhooks():
existing_hook_creator = existing_hook.user.id if existing_hook.user else None
is_mine = existing_hook.name == "PluralKit Proxy Webhook" and existing_hook_creator == bot_user.id
if is_mine:
# We found one we made, let's add that to the DB just to be sure
await db.add_webhook(conn, channel.id, existing_hook.id, existing_hook.token)
return fix_webhook(existing_hook)
# If not, we create one and save it
created_webhook = await channel.create_webhook(name="PluralKit Proxy Webhook")
except discord.Forbidden:
raise ProxyError(
"PluralKit does not have the \"Manage Webhooks\" permission, and thus cannot proxy your message. Please contact a server administrator.")
2018-11-13 13:18:41 +01:00
2018-10-27 22:00:41 +02:00
await db.add_webhook(conn, channel.id, created_webhook.id, created_webhook.token)
2018-11-13 14:01:24 +01:00
return fix_webhook(created_webhook)
2018-10-27 22:00:41 +02:00
async def make_attachment_file(message: discord.Message):
if not message.attachments:
return None
2018-10-27 22:00:41 +02:00
first_attachment = message.attachments[0]
# Copy the file data to the buffer
# TODO: do this without buffering... somehow
bio = BytesIO()
await first_attachment.save(bio)
return discord.File(bio, first_attachment.filename)
def fix_clyde(name: str) -> str:
# Discord doesn't allow any webhook username to contain the word "Clyde"
# So replace "Clyde" with "C lyde" (except with a hair space, hence \u200A)
# Zero-width spacers are ignored by Discord and will still trigger the error
return re.sub("(c)(lyde)", "\\1\u200A\\2", name, flags=re.IGNORECASE)
async def send_proxy_message(conn, original_message: discord.Message, system: System, member: Member,
inner_text: str, logger: ChannelLogger, bot_user: discord.User):
2018-10-27 22:00:41 +02:00
# Send the message through the webhook
webhook = await get_or_create_webhook_for_channel(conn, bot_user, original_message.channel)
# Bounds check the combined name to avoid silent erroring
full_username = "{} {}".format(member.name, system.tag or "").strip()
full_username = fix_clyde(full_username)
if len(full_username) < 2:
raise ProxyError(
"The webhook's name, `{}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.".format(
full_username))
if len(full_username) > 32:
raise ProxyError(
"The webhook's name, `{}`, is longer than 32 characters, and thus cannot be proxied. Please change the member name or use a shorter system tag.".format(
full_username))
try:
sent_message = await webhook.send(
content=inner_text,
username=full_username,
avatar_url=member.avatar_url,
file=await make_attachment_file(original_message),
wait=True
)
except discord.NotFound:
# The webhook we got from the DB doesn't actually exist
# This can happen if someone manually deletes it from the server
# If we delete it from the DB then call the function again, it'll re-create one for us
# (lol, lazy)
await db.delete_webhook(conn, original_message.channel.id)
await send_proxy_message(conn, original_message, system, member, inner_text, logger, bot_user)
return
2018-10-27 22:00:41 +02:00
# Save the proxied message in the database
await db.add_message(conn, sent_message.id, original_message.channel.id, member.id,
2018-10-27 22:00:41 +02:00
original_message.author.id)
2018-12-09 16:33:57 +01:00
# Log it in the log channel if possible
await logger.log_message_proxied(
conn,
original_message.channel.guild.id,
original_message.channel.name,
original_message.channel.id,
original_message.author.name,
original_message.author.discriminator,
original_message.author.id,
member.name,
member.hid,
member.avatar_url,
system.name,
system.hid,
inner_text,
sent_message.attachments[0].url if sent_message.attachments else None,
sent_message.created_at,
sent_message.id
)
# And finally, gotta delete the original.
# We wait half a second or so because if the client receives the message deletion
# event before the message actually gets confirmed sent on their end, the message
# doesn't properly get deleted for them, leading to duplication
try:
await asyncio.sleep(0.5)
await original_message.delete()
except discord.Forbidden:
raise ProxyError(
"PluralKit does not have permission to delete user messages. Please contact a server administrator.")
2018-12-09 16:33:57 +01:00
except discord.NotFound:
# Sometimes some other thing will delete the original message before PK gets to it
# This is not a problem - message gets deleted anyway :)
# Usually happens when Tupperware and PK conflict
pass
2018-10-27 22:00:41 +02:00
async def try_proxy_message(conn, message: discord.Message, logger: ChannelLogger, bot_user: discord.User) -> bool:
# Don't bother proxying in DMs
2018-10-27 22:00:41 +02:00
if isinstance(message.channel, discord.abc.PrivateChannel):
return False
# Get the system associated with the account, if possible
system = await System.get_by_account(conn, message.author.id)
if not system:
return False
# Match on the members' proxy tags
proxy_match = await system.match_proxy(conn, message.content)
2018-10-27 22:00:41 +02:00
if not proxy_match:
return False
member, inner_message = proxy_match
2018-10-27 22:00:41 +02:00
# Make sure no @everyones slip through
# Webhooks implicitly have permission to mention @everyone so we have to enforce that manually
inner_message = utils.sanitize(inner_message)
2018-10-27 22:00:41 +02:00
# If we don't have an inner text OR an attachment, we cancel because the hook can't send that
# Strip so it counts a string of solely spaces as blank too
if not inner_message.strip() and not message.attachments:
2018-10-27 22:00:41 +02:00
return False
# So, we now have enough information to successfully proxy a message
async with conn.transaction():
try:
await send_proxy_message(conn, message, system, member, inner_message, logger, bot_user)
except ProxyError as e:
# First, try to send the error in the channel it was triggered in
# Failing that, send the error in a DM.
# Failing *that*... give up, I guess.
try:
await message.channel.send("\u274c {}".format(str(e)))
except discord.Forbidden:
try:
await message.author.send("\u274c {}".format(str(e)))
except discord.Forbidden:
pass
2018-10-27 22:00:41 +02:00
return True
async def handle_deleted_message(conn, client: discord.Client, message_id: int,
message_content: Optional[str], logger: channel_logger.ChannelLogger) -> bool:
msg = await db.get_message(conn, message_id)
if not msg:
return False
channel = client.get_channel(msg.channel)
if not channel:
# Weird edge case, but channel *could* be deleted at this point (can't think of any scenarios it would be tho)
return False
await db.delete_message(conn, message_id)
await logger.log_message_deleted(
conn,
channel.guild.id,
channel.name,
msg.name,
msg.hid,
msg.avatar_url,
msg.system_name,
msg.system_hid,
message_content,
message_id
)
return True
async def try_delete_by_reaction(conn, client: discord.Client, message_id: int, reaction_user: int,
logger: channel_logger.ChannelLogger) -> bool:
# Find the message by the given message id or reaction user
msg = await db.get_message_by_sender_and_id(conn, message_id, reaction_user)
if not msg:
# Either the wrong user reacted or the message isn't a proxy message
# In either case - not our problem
return False
# Find the original message
original_message = await client.get_channel(msg.channel).get_message(message_id)
if not original_message:
# Message got deleted, possibly race condition, eh
return False
# Then delete the original message
await original_message.delete()
await handle_deleted_message(conn, client, message_id, original_message.content, logger)