Merge branch 'webhooks' into 'master'

Webhooks

Closes #41

See merge request litecord/litecord!27
This commit is contained in:
Luna 2019-03-19 23:47:41 +00:00
commit 982ac6f05b
13 changed files with 603 additions and 72 deletions

View File

@ -194,8 +194,9 @@ async def create_message(channel_id: int, actual_guild_id: int,
return message_id return message_id
async def _guild_text_mentions(payload: dict, guild_id: int, async def msg_guild_text_mentions(payload: dict, guild_id: int,
mentions_everyone: bool, mentions_here: bool): mentions_everyone: bool, mentions_here: bool):
"""Calculates mention data side-effects."""
channel_id = int(payload['channel_id']) channel_id = int(payload['channel_id'])
# calculate the user ids we'll bump the mention count for # calculate the user ids we'll bump the mention count for
@ -249,7 +250,7 @@ async def _guild_text_mentions(payload: dict, guild_id: int,
""", user_id, channel_id) """, user_id, channel_id)
async def _msg_input() -> tuple: async def msg_create_request() -> tuple:
"""Extract the json input and any file information """Extract the json input and any file information
the client gave to us in the request. the client gave to us in the request.
@ -277,19 +278,21 @@ async def _msg_input() -> tuple:
return json_from_form, [v for k, v in files.items()] return json_from_form, [v for k, v in files.items()]
def _check_content(payload: dict, files: list): def msg_create_check_content(payload: dict, files: list, *, use_embeds=False):
"""Check if there is actually any content being sent to us.""" """Check if there is actually any content being sent to us."""
has_content = bool(payload.get('content', '')) has_content = bool(payload.get('content', ''))
has_embed = 'embed' in payload
has_files = len(files) > 0 has_files = len(files) > 0
embed_field = 'embeds' if use_embeds else 'embed'
has_embed = embed_field in payload
has_total_content = has_content or has_embed or has_files has_total_content = has_content or has_embed or has_files
if not has_total_content: if not has_total_content:
raise BadRequest('No content has been provided.') raise BadRequest('No content has been provided.')
async def _add_attachment(message_id: int, channel_id: int, async def msg_add_attachment(message_id: int, channel_id: int,
attachment_file) -> int: attachment_file) -> int:
"""Add an attachment to a message. """Add an attachment to a message.
@ -297,6 +300,12 @@ async def _add_attachment(message_id: int, channel_id: int,
---------- ----------
message_id: int message_id: int
The ID of the message getting the attachment. The ID of the message getting the attachment.
channel_id: int
The ID of the channel the message belongs to.
Exists because the attachment URL scheme contains
a channel id. The purpose is unknown, but we are
implementing Discord's behavior.
attachment_file: quart.FileStorage attachment_file: quart.FileStorage
quart FileStorage instance of the file. quart FileStorage instance of the file.
""" """
@ -362,10 +371,10 @@ async def _create_message(channel_id):
await channel_perm_check(user_id, channel_id, 'send_messages') await channel_perm_check(user_id, channel_id, 'send_messages')
actual_guild_id = guild_id actual_guild_id = guild_id
payload_json, files = await _msg_input() payload_json, files = await msg_create_request()
j = validate(payload_json, MESSAGE_CREATE) j = validate(payload_json, MESSAGE_CREATE)
_check_content(payload_json, files) msg_create_check_content(payload_json, files)
# TODO: check connection to the gateway # TODO: check connection to the gateway
@ -399,7 +408,7 @@ async def _create_message(channel_id):
# for each file given, we add it as an attachment # for each file given, we add it as an attachment
for pre_attachment in files: for pre_attachment in files:
await _add_attachment(message_id, channel_id, pre_attachment) await msg_add_attachment(message_id, channel_id, pre_attachment)
payload = await app.storage.get_message(message_id, user_id) payload = await app.storage.get_message(message_id, user_id)
@ -427,8 +436,8 @@ async def _create_message(channel_id):
""", message_id, channel_id, user_id) """, message_id, channel_id, user_id)
if ctype == ChannelType.GUILD_TEXT: if ctype == ChannelType.GUILD_TEXT:
await _guild_text_mentions(payload, guild_id, await msg_guild_text_mentions(
mentions_everyone, mentions_here) payload, guild_id, mentions_everyone, mentions_here)
return jsonify(payload) return jsonify(payload)

View File

@ -27,6 +27,7 @@ async def send_icon(scope, key, icon_hash, **kwargs):
"""Send an icon.""" """Send an icon."""
icon = await app.icons.generic_get( icon = await app.icons.generic_get(
scope, key, icon_hash, **kwargs) scope, key, icon_hash, **kwargs)
if not icon: if not icon:
return '', 404 return '', 404

View File

@ -17,67 +17,441 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from quart import Blueprint import secrets
import base64
from typing import Dict, Any, Optional
from quart import Blueprint, jsonify, current_app as app, request
from litecord.auth import token_check
from litecord.blueprints.checks import (
channel_check, channel_perm_check, guild_check, guild_perm_check
)
from litecord.schemas import (
validate, WEBHOOK_CREATE, WEBHOOK_UPDATE, WEBHOOK_MESSAGE_CREATE
)
from litecord.enums import ChannelType
from litecord.snowflake import get_snowflake
from litecord.utils import async_map
from litecord.errors import (
WebhookNotFound, Unauthorized, ChannelNotFound, BadRequest
)
from litecord.blueprints.channel.messages import (
msg_create_request, msg_create_check_content, msg_add_attachment,
msg_guild_text_mentions
)
from litecord.embed.sanitizer import fill_embed, fetch_raw_img
from litecord.embed.messages import process_url_embed, is_media_url
from litecord.utils import pg_set_json
from litecord.enums import MessageType
bp = Blueprint('webhooks', __name__) bp = Blueprint('webhooks', __name__)
async def get_webhook(webhook_id: int, *,
secure: bool=True) -> Optional[Dict[str, Any]]:
"""Get a webhook data"""
row = await app.db.fetchrow("""
SELECT id::text, guild_id::text, channel_id::text, creator_id,
name, avatar, token
FROM webhooks
WHERE id = $1
""", webhook_id)
if not row:
return None
drow = dict(row)
drow['user'] = await app.storage.get_user(row['creator_id'])
drow.pop('creator_id')
if not secure:
drow.pop('user')
drow.pop('guild_id')
return drow
async def _webhook_check(channel_id):
user_id = await token_check()
await channel_check(user_id, channel_id, only=ChannelType.GUILD_TEXT)
await channel_perm_check(user_id, channel_id, 'manage_webhooks')
return user_id
async def _webhook_check_guild(guild_id):
user_id = await token_check()
await guild_check(user_id, guild_id)
await guild_perm_check(user_id, guild_id, 'manage_webhooks')
return user_id
async def _webhook_check_fw(webhook_id):
"""Make a check from an incoming webhook id (fw = from webhook)."""
guild_id = await app.db.fetchval("""
SELECT guild_id FROM webhooks
WHERE id = $1
""", webhook_id)
if guild_id is None:
raise WebhookNotFound()
return (await _webhook_check_guild(guild_id)), guild_id
async def _webhook_many(where_clause, arg: int):
webhook_ids = await app.db.fetch(f"""
SELECT id
FROM webhooks
{where_clause}
""", arg)
webhook_ids = [r['id'] for r in webhook_ids]
return jsonify(
await async_map(get_webhook, webhook_ids)
)
async def webhook_token_check(webhook_id: int, webhook_token: str):
"""token_check() equivalent for webhooks."""
row = await app.db.fetchrow("""
SELECT guild_id, channel_id
FROM webhooks
WHERE id = $1 AND token = $2
""", webhook_id, webhook_token)
if row is None:
raise Unauthorized('webhook not found or unauthorized')
return row['guild_id'], row['channel_id']
async def _dispatch_webhook_update(guild_id: int, channel_id):
await app.dispatcher.dispatch('guild', guild_id, 'WEBHOOKS_UPDATE', {
'guild_id': str(guild_id),
'channel_id': str(channel_id)
})
@bp.route('/channels/<int:channel_id>/webhooks', methods=['POST']) @bp.route('/channels/<int:channel_id>/webhooks', methods=['POST'])
async def create_webhook(channel_id): async def create_webhook(channel_id: int):
pass """Create a webhook given a channel."""
user_id = await _webhook_check(channel_id)
j = validate(await request.get_json(), WEBHOOK_CREATE)
guild_id = await app.storage.guild_from_channel(channel_id)
webhook_id = get_snowflake()
# I'd say generating a full fledged token with itsdangerous is
# relatively wasteful since webhooks don't even have a password_hash,
# and we don't make a webhook in the users table either.
token = secrets.token_urlsafe(40)
webhook_icon = await app.icons.put(
'user', webhook_id, j.get('avatar'),
always_icon=True, size=(128, 128)
)
await app.db.execute(
"""
INSERT INTO webhooks
(id, guild_id, channel_id, creator_id, name, avatar, token)
VALUES
($1, $2, $3, $4, $5, $6, $7)
""",
webhook_id, guild_id, channel_id, user_id,
j['name'], webhook_icon.icon_hash, token
)
await _dispatch_webhook_update(guild_id, channel_id)
return jsonify(await get_webhook(webhook_id))
@bp.route('/channels/<int:channel_id>/webhooks', methods=['GET']) @bp.route('/channels/<int:channel_id>/webhooks', methods=['GET'])
async def get_channel_webhook(channel_id): async def get_channel_webhook(channel_id: int):
pass """Get a list of webhooks in a channel"""
await _webhook_check(channel_id)
return await _webhook_many('WHERE channel_id = $1', channel_id)
@bp.route('/guilds/<int:guild_id>/webhooks', methods=['GET']) @bp.route('/guilds/<int:guild_id>/webhooks', methods=['GET'])
async def get_guild_webhook(guild_id): async def get_guild_webhook(guild_id):
pass """Get all webhooks in a guild"""
await _webhook_check_guild(guild_id)
return await _webhook_many('WHERE guild_id = $1', guild_id)
@bp.route('/webhooks/<int:webhook_id>', methods=['GET']) @bp.route('/webhooks/<int:webhook_id>', methods=['GET'])
async def get_single_webhook(webhook_id): async def get_single_webhook(webhook_id):
pass """Get a single webhook's information."""
await _webhook_check_fw(webhook_id)
return await jsonify(await get_webhook(webhook_id))
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['GET']) @bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['GET'])
async def get_tokened_webhook(webhook_id, webhook_token): async def get_tokened_webhook(webhook_id, webhook_token):
pass """Get a webhook using its token."""
await webhook_token_check(webhook_id, webhook_token)
return await jsonify(await get_webhook(webhook_id, secure=False))
async def _update_webhook(webhook_id: int, j: dict):
if 'name' in j:
await app.db.execute("""
UPDATE webhooks
SET name = $1
WHERE id = $2
""", j['name'], webhook_id)
if 'channel_id' in j:
await app.db.execute("""
UPDATE webhooks
SET channel_id = $1
WHERE id = $2
""", j['channel_id'], webhook_id)
if 'avatar' in j:
new_icon = await app.icons.update(
'user', webhook_id, j['avatar'], always_icon=True, size=(128, 128)
)
await app.db.execute("""
UPDATE webhooks
SET icon = $1
WHERE id = $2
""", new_icon.icon_hash, webhook_id)
@bp.route('/webhooks/<int:webhook_id>', methods=['PATCH']) @bp.route('/webhooks/<int:webhook_id>', methods=['PATCH'])
async def modify_webhook(webhook_id): async def modify_webhook(webhook_id: int):
pass """Patch a webhook."""
_user_id, guild_id = await _webhook_check_fw(webhook_id)
j = validate(await request.get_json(), WEBHOOK_UPDATE)
if 'channel_id' in j:
# pre checks
chan = await app.storage.get_channel(j['channel_id'])
# short-circuiting should ensure chan isn't none
# by the time we do chan['guild_id']
if chan and chan['guild_id'] != str(guild_id):
raise ChannelNotFound('cant assign webhook to channel')
await _update_webhook(webhook_id, j)
webhook = await get_webhook(webhook_id)
# we don't need to cast channel_id to int since that isn't
# used in the dispatcher call
await _dispatch_webhook_update(guild_id, webhook['channel_id'])
return jsonify(webhook)
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['PATCH']) @bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['PATCH'])
async def modify_webhook_tokened(webhook_id, webhook_token): async def modify_webhook_tokened(webhook_id, webhook_token):
pass """Modify a webhook, using its token."""
guild_id, channel_id = await webhook_token_check(
webhook_id, webhook_token)
# forcefully pop() the channel id out of the schema
# instead of making another, for simplicity's sake
j = validate(await request.get_json(),
WEBHOOK_UPDATE.pop('channel_id'))
await _update_webhook(webhook_id, j)
await _dispatch_webhook_update(guild_id, channel_id)
return jsonify(await get_webhook(webhook_id, secure=False))
async def delete_webhook(webhook_id: int):
"""Delete a webhook."""
webhook = await get_webhook(webhook_id)
res = await app.db.execute("""
DELETE FROM webhooks
WHERE id = $1
""", webhook_id)
if res.lower() == 'delete 0':
raise WebhookNotFound()
# only casting the guild id since that's whats used
# on the dispatcher call.
await _dispatch_webhook_update(
int(webhook['guild_id']), webhook['channel_id']
)
@bp.route('/webhooks/<int:webhook_id>', methods=['DELETE']) @bp.route('/webhooks/<int:webhook_id>', methods=['DELETE'])
async def del_webhook(webhook_id): async def del_webhook(webhook_id):
pass """Delete a webhook."""
await _webhook_check_fw(webhook_id)
await delete_webhook(webhook_id)
return '', 204
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['DELETE']) @bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['DELETE'])
async def del_webhook_tokened(webhook_id, webhook_token): async def del_webhook_tokened(webhook_id, webhook_token):
pass """Delete a webhook, with its token."""
await webhook_token_check(webhook_id, webhook_token)
await delete_webhook(webhook_id)
return '', 204
async def create_message_webhook(guild_id, channel_id, webhook_id, data):
"""Create a message, but for webhooks only."""
message_id = get_snowflake()
async with app.db.acquire() as conn:
await pg_set_json(conn)
await conn.execute(
"""
INSERT INTO messages (id, channel_id, guild_id,
content, tts, mention_everyone, message_type, embeds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
message_id,
channel_id,
guild_id,
data['content'],
data['tts'],
data['everyone_mention'],
MessageType.DEFAULT.value,
data.get('embeds', [])
)
info = data['info']
await conn.execute("""
INSERT INTO message_webhook_info
(message_id, webhook_id, name, avatar)
VALUES
($1, $2, $3, $4)
""", message_id, webhook_id, info['name'], info['avatar'])
return message_id
async def _create_avatar(webhook_id: int, avatar_url):
"""Create an avatar for a webhook out of an avatar URL,
given when executing the webhook.
Litecord will query that URL via mediaproxy and store the data
via IconManager.
"""
if avatar_url.scheme not in ('http', 'https'):
raise BadRequest('invalid avatar url scheme')
if not is_media_url(avatar_url):
raise BadRequest('url is not media url')
resp, raw = await fetch_raw_img(avatar_url)
raw_b64 = base64.b64encode(raw).decode()
mime = resp.headers['content-type']
b64_data = f'data:{mime};base64,{raw_b64}'
icon = await app.icons.put(
'user', webhook_id, b64_data,
always_icon=True, size=(128, 128)
)
return icon.icon_hash
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['POST']) @bp.route('/webhooks/<int:webhook_id>/<webhook_token>', methods=['POST'])
async def execute_webhook(webhook_id, webhook_token): async def execute_webhook(webhook_id: int, webhook_token):
pass """Execute a webhook. Sends a message to the channel the webhook
is tied to."""
guild_id, channel_id = await webhook_token_check(webhook_id, webhook_token)
# TODO: ensure channel_id points to guild text channel
payload_json, files = await msg_create_request()
# NOTE: we really pop here instead of adding a kwarg
# to msg_create_request just because of webhooks.
# nonce isn't allowed on WEBHOOK_MESSAGE_CREATE
payload_json.pop('nonce')
j = validate(payload_json, WEBHOOK_MESSAGE_CREATE)
msg_create_check_content(j, files)
# webhooks don't need permissions.
mentions_everyone = '@everyone' in j['content']
mentions_here = '@here' in j['content']
given_embeds = j.get('embeds', [])
webhook = await get_webhook(webhook_id)
avatar = webhook['avatar']
if 'avatar_url' in j and j['avatar_url'] is not None:
avatar = await _create_avatar(webhook_id, j['avatar_url'])
message_id = await create_message_webhook(
guild_id, channel_id, webhook_id, {
'content': j.get('content', ''),
'tts': j.get('tts', False),
'everyone_mention': mentions_everyone or mentions_here,
'embeds': await async_map(fill_embed, given_embeds),
'info': {
'name': j.get('username', webhook['name']),
'avatar': avatar
}
}
)
for pre_attachment in files:
await msg_add_attachment(message_id, channel_id, pre_attachment)
payload = await app.storage.get_message(message_id)
await app.dispatcher.dispatch('channel', channel_id,
'MESSAGE_CREATE', payload)
# spawn embedder in the background, even when we're on a webhook.
app.sched.spawn(
process_url_embed(
app.config, app.storage, app.dispatcher, app.session,
payload
)
)
# we can assume its a guild text channel, so just call it
await msg_guild_text_mentions(
payload, guild_id, mentions_everyone, mentions_here)
# TODO: is it really 204?
return '', 204
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>/slack', @bp.route('/webhooks/<int:webhook_id>/<webhook_token>/slack',
methods=['POST']) methods=['POST'])
async def execute_slack_webhook(webhook_id, webhook_token): async def execute_slack_webhook(webhook_id, webhook_token):
pass """Execute a webhook but expecting Slack data."""
# TODO: know slack webhooks
await webhook_token_check(webhook_id, webhook_token)
@bp.route('/webhooks/<int:webhook_id>/<webhook_token>/github', methods=['POST']) @bp.route('/webhooks/<int:webhook_id>/<webhook_token>/github', methods=['POST'])
async def execute_github_webhook(webhook_id, webhook_token): async def execute_github_webhook(webhook_id, webhook_token):
pass """Execute a webhook but expecting GitHub data."""
# TODO: know github webhooks
await webhook_token_check(webhook_id, webhook_token)

View File

@ -25,6 +25,7 @@ from pathlib import Path
from logbook import Logger from logbook import Logger
from litecord.embed.sanitizer import proxify, fetch_metadata, fetch_embed from litecord.embed.sanitizer import proxify, fetch_metadata, fetch_embed
from litecord.embed.schemas import EmbedURL
log = Logger(__name__) log = Logger(__name__)
@ -85,6 +86,20 @@ async def _update_and_dispatch(payload, new_embeds, storage, dispatcher):
'channel', channel_id, 'MESSAGE_UPDATE', update_payload) 'channel', channel_id, 'MESSAGE_UPDATE', update_payload)
def is_media_url(url) -> bool:
"""Return if the given URL is a media url."""
if isinstance(url, EmbedURL):
parsed = url.parsed
else:
parsed = urllib.parse.urlparse(url)
path = Path(parsed.path)
extension = path.suffix.lstrip('.')
return extension in MEDIA_EXTENSIONS
async def insert_mp_embed(parsed, config, session): async def insert_mp_embed(parsed, config, session):
"""Insert mediaproxy embed.""" """Insert mediaproxy embed."""
embed = await fetch_embed(parsed, config=config, session=session) embed = await fetch_embed(parsed, config=config, session=session)
@ -125,14 +140,12 @@ async def process_url_embed(config, storage, dispatcher,
new_embeds = [] new_embeds = []
for url in urls: for url in urls:
parsed = urllib.parse.urlparse(url) url = EmbedURL(url)
path = Path(parsed.path)
extension = path.suffix.lstrip('.')
if extension in MEDIA_EXTENSIONS: if is_media_url(url):
embed = await insert_media_meta(url, config, session) embed = await insert_media_meta(url, config, session)
else: else:
embed = await insert_mp_embed(parsed, config, session) embed = await insert_mp_embed(url, config, session)
if not embed: if not embed:
continue continue

View File

@ -96,26 +96,32 @@ def proxify(url, *, config=None) -> str:
) )
async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]: def _mk_cfg_sess(config, session) -> tuple:
"""Fetch metadata for a url.""" if config is None:
config = app.config
if session is None: if session is None:
session = app.session session = app.session
if config is None: return config, session
config = app.config
if isinstance(url, str):
url = EmbedURL(url)
parsed = url.parsed
md_path = f'{parsed.scheme}/{parsed.netloc}{parsed.path}'
def _md_base(config) -> tuple:
md_base_url = config['MEDIA_PROXY'] md_base_url = config['MEDIA_PROXY']
proto = 'https' if config['IS_SSL'] else 'http' proto = 'https' if config['IS_SSL'] else 'http'
request_url = f'{proto}://{md_base_url}/meta/{md_path}' return proto, md_base_url
async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]:
"""Fetch metadata for a url."""
config, session = _mk_cfg_sess(config, session)
if not isinstance(url, EmbedURL):
url = EmbedURL(url)
proto, md_base_url = _md_base(config)
request_url = f'{proto}://{md_base_url}/meta/{url.to_md_path}'
async with session.get(request_url) as resp: async with session.get(request_url) as resp:
if resp.status != 200: if resp.status != 200:
@ -128,14 +134,36 @@ async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]:
return await resp.json() return await resp.json()
async def fetch_embed(parsed, *, config=None, session=None) -> dict: async def fetch_raw_img(url, *, config=None, session=None) -> Optional[tuple]:
"""Fetch metadata for a url."""
config, session = _mk_cfg_sess(config, session)
if not isinstance(url, EmbedURL):
url = EmbedURL(url)
proto, md_base_url = _md_base(config)
# NOTE: the img, instead of /meta/.
request_url = f'{proto}://{md_base_url}/img/{url.to_md_path}'
async with session.get(request_url) as resp:
if resp.status != 200:
body = await resp.text()
log.warning('failed to get img for {!r}: {} {!r}',
url, resp.status, body)
return None
return resp, await resp.read()
async def fetch_embed(url, *, config=None, session=None) -> dict:
"""Fetch an embed""" """Fetch an embed"""
config, session = _mk_cfg_sess(config, session)
if session is None: if not isinstance(url, EmbedURL):
session = app.session url = EmbedURL(url)
if config is None: parsed = url.parsed
config = app.config
# TODO: handle query string # TODO: handle query string
md_path = f'{parsed.scheme}/{parsed.netloc}{parsed.path}' md_path = f'{parsed.scheme}/{parsed.netloc}{parsed.path}'

View File

@ -31,18 +31,26 @@ class EmbedURL:
if parsed.scheme not in ('http', 'https', 'attachment'): if parsed.scheme not in ('http', 'https', 'attachment'):
raise ValueError('Invalid URL scheme') raise ValueError('Invalid URL scheme')
self.scheme = parsed.scheme
self.raw_url = url self.raw_url = url
self.parsed = parsed self.parsed = parsed
@property @property
def url(self): def url(self) -> str:
"""Return the URL.""" """Return the unparsed URL."""
return urllib.parse.urlunparse(self.parsed) return urllib.parse.urlunparse(self.parsed)
@property @property
def to_json(self): def to_json(self) -> str:
"""'json' version of the url."""
return self.url return self.url
@property
def to_md_path(self) -> str:
"""Convert the EmbedURL to a mediaproxy path."""
parsed = self.parsed
return f'{parsed.scheme}/{parsed.netloc}{parsed.path}'
EMBED_FOOTER = { EMBED_FOOTER = {
'text': { 'text': {

View File

@ -122,6 +122,10 @@ class MessageNotFound(NotFound):
error_code = 10008 error_code = 10008
class WebhookNotFound(NotFound):
error_code = 10015
class Ratelimited(LitecordError): class Ratelimited(LitecordError):
status_code = 429 status_code = 429

View File

@ -31,7 +31,7 @@ from .enums import (
MessageNotifications, ChannelType, VerificationLevel MessageNotifications, ChannelType, VerificationLevel
) )
from litecord.embed.schemas import EMBED_OBJECT from litecord.embed.schemas import EMBED_OBJECT, EmbedURL
log = Logger(__name__) log = Logger(__name__)
@ -677,3 +677,46 @@ VANITY_URL_PATCH = {
# TODO: put proper values in maybe an invite data type # TODO: put proper values in maybe an invite data type
'code': {'type': 'string', 'minlength': 5, 'maxlength': 30} 'code': {'type': 'string', 'minlength': 5, 'maxlength': 30}
} }
WEBHOOK_CREATE = {
'name': {
'type': 'string', 'minlength': 2, 'maxlength': 32,
'required': True
},
'avatar': {'type': 'b64_icon', 'required': False, 'nullable': False}
}
WEBHOOK_UPDATE = {
'name': {
'type': 'string', 'minlength': 2, 'maxlength': 32,
'required': False
},
# TODO: check if its b64_icon or string since the client
# could pass an icon hash instead.
'avatar': {'type': 'b64_icon', 'required': False, 'nullable': False},
'channel_id': {'coerce': int, 'required': False, 'nullable': False}
}
WEBHOOK_MESSAGE_CREATE = {
'content': {
'type': 'string',
'minlength': 0, 'maxlength': 2000, 'required': False
},
'tts': {'type': 'boolean', 'required': False},
'username': {
'type': 'string',
'minlength': 2, 'maxlength': 32, 'required': False
},
'avatar_url': {
'coerce': EmbedURL, 'required': False
},
'embeds': {
'type': 'list',
'required': False,
'schema': {'type': 'dict', 'schema': EMBED_OBJECT}
}
}

View File

@ -796,26 +796,51 @@ class Storage:
return res return res
async def _inject_author(self, res: dict): async def _inject_author(self, res: dict):
"""Inject a pseudo-user object when the message is made by a webhook.""" """Inject a pseudo-user object when the message is
author_id, webhook_id = res['author_id'], res['webhook_id'] made by a webhook."""
author_id = res['author_id']
if author_id is not None: # if author_id is None, we fetch webhook info
res['author'] = await self.get_user(res['author_id']) # from the message_webhook_info table.
res.pop('webhook_id') if author_id is None:
elif webhook_id is not None: # webhook information in a message when made by a webhook
res['author'] = { # is copied from the webhook table, or inserted by the webhook
'id': webhook_id, # itself. this causes a complete disconnect from the messages
'username': 'a', # table into the webhooks table.
'avatar': None wb_info = await self.db.fetchrow("""
SELECT webhook_id, name, avatar
FROM message_webhook_info
WHERE message_id = $1
""", int(res['id']))
if not wb_info:
log.warning('webhook info not found for msg {}',
res['id'])
wb_info = wb_info or {
'id': res['id'],
'bot': True,
'avatar': None,
'username': '<unknown webhook info>',
'discriminator': '0000',
} }
res['author'] = {
'id': str(wb_info['webhook_id']),
'bot': True,
'username': wb_info['name'],
'avatar': wb_info['avatar']
}
else:
res['author'] = await self.get_user(res['author_id'])
res.pop('author_id') res.pop('author_id')
async def get_message(self, message_id: int, async def get_message(self, message_id: int,
user_id: Optional[int] = None) -> Optional[Dict]: user_id: Optional[int] = None) -> Optional[Dict]:
"""Get a single message's payload.""" """Get a single message's payload."""
row = await self.fetchrow_with_json(""" row = await self.fetchrow_with_json("""
SELECT id::text, channel_id::text, author_id, webhook_id, content, SELECT id::text, channel_id::text, author_id, content,
created_at AS timestamp, edited_at AS edited_timestamp, created_at AS timestamp, edited_at AS edited_timestamp,
tts, mention_everyone, nonce, message_type, embeds tts, mention_everyone, nonce, message_type, embeds
FROM messages FROM messages

View File

@ -0,0 +1,2 @@
ALTER TABLE webhooks ALTER COLUMN avatar DROP NOT NULL;
ALTER TABLE webhooks ALTER COLUMN avatar SET DEFAULT NULL;

View File

@ -0,0 +1,17 @@
-- this is a tricky one. blame discord
-- first, remove all messages made by webhooks (safety check)
DELETE FROM messages WHERE author_id is null;
-- delete the column, removing the fkey. no connection anymore.
ALTER TABLE messages DROP COLUMN webhook_id;
-- add a message_webhook_info table. more on that in Storage._inject_author
CREATE TABLE IF NOT EXISTS message_webhook_info (
message_id bigint REFERENCES messages (id) PRIMARY KEY,
webhook_id bigint,
name text DEFAULT '<invalid>',
avatar text DEFAULT NULL
);

2
run.py
View File

@ -373,6 +373,8 @@ async def handle_litecord_err(err):
except AttributeError: except AttributeError:
pass pass
log.warning('error: {} {!r}', err.status_code, err.message)
return jsonify({ return jsonify({
'error': True, 'error': True,
'status': err.status_code, 'status': err.status_code,

View File

@ -542,7 +542,7 @@ CREATE TABLE IF NOT EXISTS webhooks (
creator_id bigint REFERENCES users (id), creator_id bigint REFERENCES users (id),
name text NOT NULL, name text NOT NULL,
avatar text NOT NULL, avatar text DEFAULT NULL,
-- Yes, we store the webhook's token -- Yes, we store the webhook's token
-- since they aren't users and there's no /api/login for them. -- since they aren't users and there's no /api/login for them.
@ -643,13 +643,9 @@ CREATE TABLE IF NOT EXISTS messages (
-- this is good for search. -- this is good for search.
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
-- those are mutually exclusive, only one of them -- if author is NULL -> message from webhook ->
-- can NOT be NULL at a time. -- fetch from message_webhook_info
-- if author is NULL -> message from webhook
-- if webhook is NULL -> message from author
author_id bigint REFERENCES users (id), author_id bigint REFERENCES users (id),
webhook_id bigint REFERENCES webhooks (id),
content text, content text,
@ -666,6 +662,15 @@ CREATE TABLE IF NOT EXISTS messages (
message_type int NOT NULL message_type int NOT NULL
); );
CREATE TABLE IF NOT EXISTS message_webhook_info (
message_id bigint REFERENCES messages (id) PRIMARY KEY,
webhook_id bigint,
name text DEFAULT '<invalid>',
avatar text DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS message_reactions ( CREATE TABLE IF NOT EXISTS message_reactions (
message_id bigint REFERENCES messages (id), message_id bigint REFERENCES messages (id),
user_id bigint REFERENCES users (id), user_id bigint REFERENCES users (id),