mirror of https://gitlab.com/litecord/litecord.git
Merge branch 'webhooks' into 'master'
Webhooks Closes #41 See merge request litecord/litecord!27
This commit is contained in:
commit
982ac6f05b
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}'
|
||||||
|
|
|
||||||
|
|
@ -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': {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE webhooks ALTER COLUMN avatar DROP NOT NULL;
|
||||||
|
ALTER TABLE webhooks ALTER COLUMN avatar SET DEFAULT NULL;
|
||||||
|
|
@ -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
2
run.py
|
|
@ -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,
|
||||||
|
|
|
||||||
19
schema.sql
19
schema.sql
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue