diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py
index 41850e2..2e15825 100644
--- a/litecord/blueprints/channel/messages.py
+++ b/litecord/blueprints/channel/messages.py
@@ -194,8 +194,9 @@ async def create_message(channel_id: int, actual_guild_id: int,
return message_id
-async def _guild_text_mentions(payload: dict, guild_id: int,
- mentions_everyone: bool, mentions_here: bool):
+async def msg_guild_text_mentions(payload: dict, guild_id: int,
+ mentions_everyone: bool, mentions_here: bool):
+ """Calculates mention data side-effects."""
channel_id = int(payload['channel_id'])
# 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)
-async def _msg_input() -> tuple:
+async def msg_create_request() -> tuple:
"""Extract the json input and any file information
the client gave to us in the request.
@@ -277,26 +278,34 @@ async def _msg_input() -> tuple:
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."""
has_content = bool(payload.get('content', ''))
- has_embed = 'embed' in payload
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
if not has_total_content:
raise BadRequest('No content has been provided.')
-async def _add_attachment(message_id: int, channel_id: int,
- attachment_file) -> int:
+async def msg_add_attachment(message_id: int, channel_id: int,
+ attachment_file) -> int:
"""Add an attachment to a message.
Parameters
----------
message_id: int
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
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')
actual_guild_id = guild_id
- payload_json, files = await _msg_input()
+ payload_json, files = await msg_create_request()
j = validate(payload_json, MESSAGE_CREATE)
- _check_content(payload_json, files)
+ msg_create_check_content(payload_json, files)
# 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 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)
@@ -427,8 +436,8 @@ async def _create_message(channel_id):
""", message_id, channel_id, user_id)
if ctype == ChannelType.GUILD_TEXT:
- await _guild_text_mentions(payload, guild_id,
- mentions_everyone, mentions_here)
+ await msg_guild_text_mentions(
+ payload, guild_id, mentions_everyone, mentions_here)
return jsonify(payload)
diff --git a/litecord/blueprints/icons.py b/litecord/blueprints/icons.py
index 7434bad..1525f2b 100644
--- a/litecord/blueprints/icons.py
+++ b/litecord/blueprints/icons.py
@@ -27,6 +27,7 @@ async def send_icon(scope, key, icon_hash, **kwargs):
"""Send an icon."""
icon = await app.icons.generic_get(
scope, key, icon_hash, **kwargs)
+
if not icon:
return '', 404
diff --git a/litecord/blueprints/webhooks.py b/litecord/blueprints/webhooks.py
index 06253aa..fb91af0 100644
--- a/litecord/blueprints/webhooks.py
+++ b/litecord/blueprints/webhooks.py
@@ -17,67 +17,441 @@ along with this program. If not, see .
"""
-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__)
+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//webhooks', methods=['POST'])
-async def create_webhook(channel_id):
- pass
+async def create_webhook(channel_id: int):
+ """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//webhooks', methods=['GET'])
-async def get_channel_webhook(channel_id):
- pass
+async def get_channel_webhook(channel_id: int):
+ """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//webhooks', methods=['GET'])
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/', methods=['GET'])
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//', methods=['GET'])
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/', methods=['PATCH'])
-async def modify_webhook(webhook_id):
- pass
+async def modify_webhook(webhook_id: int):
+ """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//', methods=['PATCH'])
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/', methods=['DELETE'])
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//', methods=['DELETE'])
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//', methods=['POST'])
-async def execute_webhook(webhook_id, webhook_token):
- pass
+async def execute_webhook(webhook_id: int, webhook_token):
+ """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///slack',
methods=['POST'])
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///github', methods=['POST'])
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)
diff --git a/litecord/embed/messages.py b/litecord/embed/messages.py
index f13b60e..3025945 100644
--- a/litecord/embed/messages.py
+++ b/litecord/embed/messages.py
@@ -25,6 +25,7 @@ from pathlib import Path
from logbook import Logger
from litecord.embed.sanitizer import proxify, fetch_metadata, fetch_embed
+from litecord.embed.schemas import EmbedURL
log = Logger(__name__)
@@ -85,6 +86,20 @@ async def _update_and_dispatch(payload, new_embeds, storage, dispatcher):
'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):
"""Insert mediaproxy embed."""
embed = await fetch_embed(parsed, config=config, session=session)
@@ -125,14 +140,12 @@ async def process_url_embed(config, storage, dispatcher,
new_embeds = []
for url in urls:
- parsed = urllib.parse.urlparse(url)
- path = Path(parsed.path)
- extension = path.suffix.lstrip('.')
+ url = EmbedURL(url)
- if extension in MEDIA_EXTENSIONS:
+ if is_media_url(url):
embed = await insert_media_meta(url, config, session)
else:
- embed = await insert_mp_embed(parsed, config, session)
+ embed = await insert_mp_embed(url, config, session)
if not embed:
continue
diff --git a/litecord/embed/sanitizer.py b/litecord/embed/sanitizer.py
index ef912c4..e3b4515 100644
--- a/litecord/embed/sanitizer.py
+++ b/litecord/embed/sanitizer.py
@@ -96,26 +96,32 @@ def proxify(url, *, config=None) -> str:
)
-async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]:
- """Fetch metadata for a url."""
+def _mk_cfg_sess(config, session) -> tuple:
+ if config is None:
+ config = app.config
if session is None:
session = app.session
- if config is None:
- config = app.config
+ return config, session
- 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']
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:
if resp.status != 200:
@@ -128,14 +134,36 @@ async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]:
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"""
+ config, session = _mk_cfg_sess(config, session)
- if session is None:
- session = app.session
+ if not isinstance(url, EmbedURL):
+ url = EmbedURL(url)
- if config is None:
- config = app.config
+ parsed = url.parsed
# TODO: handle query string
md_path = f'{parsed.scheme}/{parsed.netloc}{parsed.path}'
diff --git a/litecord/embed/schemas.py b/litecord/embed/schemas.py
index 61452c6..819e072 100644
--- a/litecord/embed/schemas.py
+++ b/litecord/embed/schemas.py
@@ -31,18 +31,26 @@ class EmbedURL:
if parsed.scheme not in ('http', 'https', 'attachment'):
raise ValueError('Invalid URL scheme')
+ self.scheme = parsed.scheme
self.raw_url = url
self.parsed = parsed
@property
- def url(self):
- """Return the URL."""
+ def url(self) -> str:
+ """Return the unparsed URL."""
return urllib.parse.urlunparse(self.parsed)
@property
- def to_json(self):
+ def to_json(self) -> str:
+ """'json' version of the 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 = {
'text': {
diff --git a/litecord/errors.py b/litecord/errors.py
index 64c0ebf..76f937c 100644
--- a/litecord/errors.py
+++ b/litecord/errors.py
@@ -122,6 +122,10 @@ class MessageNotFound(NotFound):
error_code = 10008
+class WebhookNotFound(NotFound):
+ error_code = 10015
+
+
class Ratelimited(LitecordError):
status_code = 429
diff --git a/litecord/schemas.py b/litecord/schemas.py
index c52cf8f..980a7ff 100644
--- a/litecord/schemas.py
+++ b/litecord/schemas.py
@@ -31,7 +31,7 @@ from .enums import (
MessageNotifications, ChannelType, VerificationLevel
)
-from litecord.embed.schemas import EMBED_OBJECT
+from litecord.embed.schemas import EMBED_OBJECT, EmbedURL
log = Logger(__name__)
@@ -677,3 +677,46 @@ VANITY_URL_PATCH = {
# TODO: put proper values in maybe an invite data type
'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}
+ }
+}
diff --git a/litecord/storage.py b/litecord/storage.py
index 72812a7..600e486 100644
--- a/litecord/storage.py
+++ b/litecord/storage.py
@@ -796,26 +796,51 @@ class Storage:
return res
async def _inject_author(self, res: dict):
- """Inject a pseudo-user object when the message is made by a webhook."""
- author_id, webhook_id = res['author_id'], res['webhook_id']
+ """Inject a pseudo-user object when the message is
+ made by a webhook."""
+ author_id = res['author_id']
- if author_id is not None:
- res['author'] = await self.get_user(res['author_id'])
- res.pop('webhook_id')
- elif webhook_id is not None:
- res['author'] = {
- 'id': webhook_id,
- 'username': 'a',
- 'avatar': None
+ # if author_id is None, we fetch webhook info
+ # from the message_webhook_info table.
+ if author_id is None:
+ # webhook information in a message when made by a webhook
+ # is copied from the webhook table, or inserted by the webhook
+ # itself. this causes a complete disconnect from the messages
+ # table into the webhooks table.
+ 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': '',
+ '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')
async def get_message(self, message_id: int,
user_id: Optional[int] = None) -> Optional[Dict]:
"""Get a single message's payload."""
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,
tts, mention_everyone, nonce, message_type, embeds
FROM messages
diff --git a/manage/cmd/migration/scripts/15_drop_nullable_webhook_avatar.sql b/manage/cmd/migration/scripts/15_drop_nullable_webhook_avatar.sql
new file mode 100644
index 0000000..a6e110f
--- /dev/null
+++ b/manage/cmd/migration/scripts/15_drop_nullable_webhook_avatar.sql
@@ -0,0 +1,2 @@
+ALTER TABLE webhooks ALTER COLUMN avatar DROP NOT NULL;
+ALTER TABLE webhooks ALTER COLUMN avatar SET DEFAULT NULL;
diff --git a/manage/cmd/migration/scripts/16_messages_webhooks.sql b/manage/cmd/migration/scripts/16_messages_webhooks.sql
new file mode 100644
index 0000000..fc8ba66
--- /dev/null
+++ b/manage/cmd/migration/scripts/16_messages_webhooks.sql
@@ -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 '',
+ avatar text DEFAULT NULL
+);
+
diff --git a/run.py b/run.py
index f8bf1c7..ef5e118 100644
--- a/run.py
+++ b/run.py
@@ -373,6 +373,8 @@ async def handle_litecord_err(err):
except AttributeError:
pass
+ log.warning('error: {} {!r}', err.status_code, err.message)
+
return jsonify({
'error': True,
'status': err.status_code,
diff --git a/schema.sql b/schema.sql
index 32fa564..6b93dd7 100644
--- a/schema.sql
+++ b/schema.sql
@@ -542,7 +542,7 @@ CREATE TABLE IF NOT EXISTS webhooks (
creator_id bigint REFERENCES users (id),
name text NOT NULL,
- avatar text NOT NULL,
+ avatar text DEFAULT NULL,
-- Yes, we store the webhook's token
-- 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.
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
- -- those are mutually exclusive, only one of them
- -- can NOT be NULL at a time.
-
- -- if author is NULL -> message from webhook
- -- if webhook is NULL -> message from author
+ -- if author is NULL -> message from webhook ->
+ -- fetch from message_webhook_info
author_id bigint REFERENCES users (id),
- webhook_id bigint REFERENCES webhooks (id),
content text,
@@ -666,6 +662,15 @@ CREATE TABLE IF NOT EXISTS messages (
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 '',
+ avatar text DEFAULT NULL
+);
+
CREATE TABLE IF NOT EXISTS message_reactions (
message_id bigint REFERENCES messages (id),
user_id bigint REFERENCES users (id),