From b80b3293a5cc50d4482ead0903d516652f83b27e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 04:31:37 +0300 Subject: Adds Webhook Option Adds webhook option on form creation, and adds validation. Updates `SCHEMA.md`. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 1 + backend/models/form.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index fdf7092..e733e53 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -58,3 +58,4 @@ class FormFeatures(Enum): OPEN = "OPEN" COLLECT_EMAIL = "COLLECT_EMAIL" DISABLE_ANTISPAM = "DISABLE_ANTISPAM" + WEBHOOK_ENABLED = "WEBHOOK_ENABLED" diff --git a/backend/models/form.py b/backend/models/form.py index cb58065..e6c56a4 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,5 +1,6 @@ import typing as t +import httpx from pydantic import BaseModel, Field, validator from backend.constants import FormFeatures @@ -8,6 +9,55 @@ from .question import Question PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] +class _WebHook(BaseModel): + """Schema model of discord webhooks.""" + url: str + message: str + + @validator("url") + def validate_url(cls, url: str) -> str: + """Checks if discord webhook urls are valid.""" + if not isinstance(url, str): + raise ValueError("Webhook URL must be a string.") + + if "discord.com/api/webhooks/" not in url: + raise ValueError("URL must be a discord webhook.") + + # Attempt to connect to URL + try: + httpx.get(url).raise_for_status() + + except httpx.RequestError as e: + # Catch exceptions in request format + raise ValueError( + f"Encountered error while trying to connect to url: `{e}`" + ) + + except httpx.HTTPStatusError as e: + # Catch exceptions in response + status = e.response.status_code + + if status == 401: + raise ValueError( + "Could not authenticate with target. Please check the webhook url." + ) + elif status == 404: + raise ValueError( + "Target could not find webhook url. Please check the webhook url." + ) + else: + raise ValueError( + f"Unknown error ({status}) while connecting to target: {e}" + ) + + return url + + +class _FormMeta(BaseModel): + """Schema model for form meta data.""" + webhook: _WebHook + + class Form(BaseModel): """Schema model for form.""" @@ -16,6 +66,7 @@ class Form(BaseModel): questions: list[Question] name: str description: str + meta: _FormMeta class Config: allow_population_by_field_name = True -- cgit v1.2.3 From 595c19ee50e638e081d43be1c2d0d6e0830676e2 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 10:08:20 +0300 Subject: Adds Frontend URL to Env Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- README.md | 1 + backend/constants.py | 1 + 2 files changed, 2 insertions(+) (limited to 'backend') diff --git a/README.md b/README.md index 671e6d5..3e38ef5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ You can also run forms-backend manually on the host. #### Environment variables Create a `.env` file with the same contents as the Docker section above and the following new variables: +- `FRONTEND_URL`: Forms frontend URL. - `DATABASE_URL`: MongoDB instance URI, in format `mongodb://(username):(password)@(database IP or domain):(port)`. - `MONGO_DB`: MongoDB database name, defaults to `pydis_forms`. diff --git a/backend/constants.py b/backend/constants.py index e733e53..11a14ef 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -6,6 +6,7 @@ import binascii # noqa from enum import Enum # noqa +FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com") DATABASE_URL = os.getenv("DATABASE_URL") MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms") -- cgit v1.2.3 From 20c72c4d7fe41632d04c619c4510364ba2674599 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 12:20:38 +0300 Subject: Makes Webhooks Optional Fixes a bug that would force you to do a dummy webhook object when creating a form. Fixes a bug that would force you to add an empty message for webhooks. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/models/form.py b/backend/models/form.py index e6c56a4..ef81616 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -12,7 +12,7 @@ PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] class _WebHook(BaseModel): """Schema model of discord webhooks.""" url: str - message: str + message: t.Optional[str] @validator("url") def validate_url(cls, url: str) -> str: @@ -55,7 +55,7 @@ class _WebHook(BaseModel): class _FormMeta(BaseModel): """Schema model for form meta data.""" - webhook: _WebHook + webhook: _WebHook = None class Form(BaseModel): -- cgit v1.2.3 From bbbfa152ba14f37d0cd5686ed4cdcfc7cd83514e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 12:48:04 +0300 Subject: Adds Webhook Sending Functionality Builds and sends a discord webhook on form submission. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- SCHEMA.md | 6 ++--- backend/routes/forms/submit.py | 60 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) (limited to 'backend') diff --git a/SCHEMA.md b/SCHEMA.md index 4daa5dd..b55635f 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -33,9 +33,9 @@ In this document: | `WEBHOOK_ENABLED` | The form should notify the webhook. Has no effect if no webhook is set. | ### Meta options -| Field | Description | Example | -| --------- | ----------------------------------- | ------------------------------------------------------------------------------------------ | -| `webhook` | Mapping of webhook url and message. | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "Hello World!"}` | +| Field | Description | Example | +| --------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `webhook` | Mapping of webhook url and message. Message can use `_USER_MENTION_` to mention the submitting user. | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "Hello World! _USER_MENTION_"}` | ### Form question diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 48ae4f6..7cd7576 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -9,11 +9,11 @@ import uuid import httpx import pydnsbl from pydantic import ValidationError +from starlette.background import BackgroundTask from starlette.requests import Request - from starlette.responses import JSONResponse -from backend.constants import HCAPTCHA_API_SECRET, FormFeatures +from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET from backend.models import Form, FormResponse from backend.route import Route @@ -108,11 +108,63 @@ class SubmitForm(Route): response_obj.dict(by_alias=True) ) + send_webhook = None + if FormFeatures.WEBHOOK_ENABLED.value in form.features: + send_webhook = BackgroundTask( + self.send_submission_webhook, + form=form, + response=response_obj + ) + return JSONResponse({ - "form": form.dict(), + "form": form.dict(admin=False), "response": response_obj.dict() - }) + }, background=send_webhook) + else: return JSONResponse({ "error": "Open form not found" }) + + @staticmethod + def send_submission_webhook(form: Form, response: FormResponse) -> None: + """Helper to send a submission message to a discord webhook.""" + # Stop if webhook is not available + if form.meta.webhook is None: + raise ValueError("Got empty webhook.") + + user = response.user + username = f"{user.username}#{user.discriminator}" if user else None + user_mention = f"<@{user.id}>" if user else f"{username or 'User'}" + + # Build Embed + embed = { + "title": "New Form Response", + "description": f"{user_mention} submitted a response.", + "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL + "timestamp": response.timestamp, + "color": 7506394, + } + + # Add author to embed + if user is not None: + embed["author"] = {"name": username} + + if user.avatar is not None: + url = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png" + embed["author"]["icon_url"] = url + + # Build Hook + hook = { + "embeds": [embed], + "allowed_mentions": {"parse": ["users", "roles"]}, + "username": form.name or "Python Discord Forms" + } + + # Set hook message + message = form.meta.webhook.message + if message: + hook["content"] = message.replace("_USER_MENTION_", f"<@{user.id}>") + + # Post hook + httpx.post(form.meta.webhook.url, json=hook).raise_for_status() -- cgit v1.2.3 From cbd38cebcc32940de14eaa4af3927430026e9cea Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 00:22:54 +0300 Subject: Sends Embed Asynchronously Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 2 +- backend/routes/forms/submit.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) (limited to 'backend') diff --git a/backend/models/form.py b/backend/models/form.py index 6c435e7..9a3a8a2 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -66,7 +66,7 @@ class Form(BaseModel): questions: list[Question] name: str description: str - meta: _FormMeta + meta: _FormMeta = _FormMeta() class Config: allow_population_by_field_name = True diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index c476468..538a5b9 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -152,7 +152,7 @@ class SubmitForm(Route): }) @staticmethod - def send_submission_webhook(form: Form, response: FormResponse) -> None: + async def send_submission_webhook(form: Form, response: FormResponse) -> None: """Helper to send a submission message to a discord webhook.""" # Stop if webhook is not available if form.meta.webhook is None: @@ -189,7 +189,9 @@ class SubmitForm(Route): # Set hook message message = form.meta.webhook.message if message: - hook["content"] = message.replace("_USER_MENTION_", f"<@{user.id}>") + hook["content"] = message.replace("_USER_MENTION_", user_mention) # Post hook - httpx.post(form.meta.webhook.url, json=hook).raise_for_status() + async with httpx.AsyncClient() as client: + r = await client.post(form.meta.webhook.url, json=hook) + r.raise_for_status() -- cgit v1.2.3 From df0a8a15c93dc238bfd55ee010a321b15f1e4179 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 04:51:44 +0300 Subject: Change Embed Description Co-authored-by: Joe Banks --- backend/routes/forms/submit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d8b178f..c50e4dd 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -165,7 +165,7 @@ class SubmitForm(Route): # Build Embed embed = { "title": "New Form Response", - "description": f"{user_mention} submitted a response.", + "description": f"{user_mention} submitted a response to `{form.name}`.", "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL "timestamp": response.timestamp, "color": 7506394, -- cgit v1.2.3 From 34c94baba9758ae300fe8641252c621768543abc Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 11:25:00 +0300 Subject: Uses Builtin User Class Uses builtin user formatting for username and mentions. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/authentication/user.py | 4 ++++ backend/routes/forms/submit.py | 26 +++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) (limited to 'backend') diff --git a/backend/authentication/user.py b/backend/authentication/user.py index 722c348..f40c68c 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -19,3 +19,7 @@ class User(BaseUser): def display_name(self) -> str: """Return username and discriminator as display name.""" return f"{self.payload['username']}#{self.payload['discriminator']}" + + @property + def discord_mention(self) -> str: + return f"<@{self.payload['id']}>" diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index c50e4dd..3b22155 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -138,7 +138,8 @@ class SubmitForm(Route): send_webhook = BackgroundTask( self.send_submission_webhook, form=form, - response=response_obj + response=response_obj, + request_user=request.user ) return JSONResponse({ @@ -152,30 +153,37 @@ class SubmitForm(Route): }, status_code=404) @staticmethod - async def send_submission_webhook(form: Form, response: FormResponse) -> None: + async def send_submission_webhook( + form: Form, + response: FormResponse, + request_user: Request.user + ) -> None: """Helper to send a submission message to a discord webhook.""" # Stop if webhook is not available if form.meta.webhook is None: raise ValueError("Got empty webhook.") + try: + mention = request_user.discord_mention + except AttributeError: + mention = "User" + user = response.user - username = f"{user.username}#{user.discriminator}" if user else None - user_mention = f"<@{user.id}>" if user else f"{username or 'User'}" # Build Embed embed = { "title": "New Form Response", - "description": f"{user_mention} submitted a response to `{form.name}`.", + "description": f"{mention} submitted a response to `{form.name}`.", "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL "timestamp": response.timestamp, "color": 7506394, } # Add author to embed - if user is not None: - embed["author"] = {"name": username} + if request_user.is_authenticated: + embed["author"] = {"name": request_user.display_name} - if user.avatar is not None: + if user and user.avatar: url = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png" embed["author"]["icon_url"] = url @@ -189,7 +197,7 @@ class SubmitForm(Route): # Set hook message message = form.meta.webhook.message if message: - hook["content"] = message.replace("_USER_MENTION_", user_mention) + hook["content"] = message.replace("_USER_MENTION_", mention) # Post hook async with httpx.AsyncClient() as client: -- cgit v1.2.3 From d1cb4200229a7811f21ce44dc427674e4f0b4ff3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 23:12:33 +0300 Subject: Runs Initial Validation Asynchronously Moves the validation code of webhook urls to an async function that is called by the route, to avoid blocking code. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 9 +++++ backend/models/form.py | 87 +++++++++++++++++++++++++++---------------- backend/routes/forms/index.py | 18 ++++++++- 3 files changed, 81 insertions(+), 33 deletions(-) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index f5e4304..bfcf261 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -62,3 +62,12 @@ class FormFeatures(Enum): COLLECT_EMAIL = "COLLECT_EMAIL" DISABLE_ANTISPAM = "DISABLE_ANTISPAM" WEBHOOK_ENABLED = "WEBHOOK_ENABLED" + + +class WebHook(Enum): + URL = "url" + MESSAGE = "message" + + +class Meta(Enum): + WEB_HOOK = WebHook diff --git a/backend/models/form.py b/backend/models/form.py index 9a3a8a2..96362d4 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -2,8 +2,9 @@ import typing as t import httpx from pydantic import BaseModel, Field, validator +from pydantic.error_wrappers import ErrorWrapper, ValidationError -from backend.constants import FormFeatures +from backend.constants import FormFeatures, Meta, WebHook from .question import Question PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] @@ -16,40 +17,10 @@ class _WebHook(BaseModel): @validator("url") def validate_url(cls, url: str) -> str: - """Checks if discord webhook urls are valid.""" - if not isinstance(url, str): - raise ValueError("Webhook URL must be a string.") - + """Validates URL parameter.""" if "discord.com/api/webhooks/" not in url: raise ValueError("URL must be a discord webhook.") - # Attempt to connect to URL - try: - httpx.get(url).raise_for_status() - - except httpx.RequestError as e: - # Catch exceptions in request format - raise ValueError( - f"Encountered error while trying to connect to url: `{e}`" - ) - - except httpx.HTTPStatusError as e: - # Catch exceptions in response - status = e.response.status_code - - if status == 401: - raise ValueError( - "Could not authenticate with target. Please check the webhook url." - ) - elif status == 404: - raise ValueError( - "Target could not find webhook url. Please check the webhook url." - ) - else: - raise ValueError( - f"Unknown error ({status}) while connecting to target: {e}" - ) - return url @@ -107,3 +78,55 @@ class Form(BaseModel): class FormList(BaseModel): __root__: t.List[Form] + + +async def validate_hook_url(url: str) -> t.Optional[ValidationError]: + """Validator for discord webhook urls.""" + async def validate(): + if not isinstance(url, str): + raise ValueError("Webhook URL must be a string.") + + if "discord.com/api/webhooks/" not in url: + raise ValueError("URL must be a discord webhook.") + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + + except httpx.RequestError as error: + # Catch exceptions in request format + raise ValueError( + f"Encountered error while trying to connect to url: `{error}`" + ) + + except httpx.HTTPStatusError as error: + # Catch exceptions in response + status = error.response.status_code + + if status == 401: + raise ValueError( + "Could not authenticate with target. Please check the webhook url." + ) + elif status == 404: + raise ValueError( + "Target could not find webhook url. Please check the webhook url." + ) + else: + raise ValueError( + f"Unknown error ({status}) while connecting to target: {error}" + ) + + return url + + # Validate, and return errors, if any + try: + await validate() + except Exception as e: + loc = ( + Meta.__name__.lower(), + WebHook.__name__.lower(), + WebHook.URL.value + ) + + return ValidationError([ErrorWrapper(e, loc=loc)], _WebHook) diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index d1373e4..0e1dee8 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -6,8 +6,10 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse -from backend.route import Route +from backend.constants import Meta, WebHook from backend.models import Form, FormList +from backend.models.form import validate_hook_url +from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api @@ -46,6 +48,20 @@ class FormsList(Route): """Create a new form.""" form_data = await request.json() + # Verify Webhook + try: + # Get url from request + path = (Meta.__name__.lower(), WebHook.__name__.lower(), WebHook.URL.value) + url = form_data[path[0]][path[1]][path[2]] + + # Validate URL + validation = await validate_hook_url(url) + if validation: + return JSONResponse(validation.errors(), status_code=422) + + except KeyError: + pass + form = Form(**form_data) if await request.state.db.forms.find_one({"_id": form.id}): -- cgit v1.2.3 From 5250e5407e5c229de3b31cf06f784d847aedc9ac Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 23:15:31 +0300 Subject: Adds Missing Annotation Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/models/form.py b/backend/models/form.py index 96362d4..d5e2ff5 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -82,7 +82,7 @@ class FormList(BaseModel): async def validate_hook_url(url: str) -> t.Optional[ValidationError]: """Validator for discord webhook urls.""" - async def validate(): + async def validate() -> t.Optional[str]: if not isinstance(url, str): raise ValueError("Webhook URL must be a string.") -- cgit v1.2.3 From 9d8ca77b1f18891cef88e9b8235b41172067f9b0 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 21 Dec 2020 04:41:27 +0300 Subject: Adds and Documents Webhook Message Variables Adds better parsing and formatting for webhook message variables, and documents them in SCHEMA.md. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- SCHEMA.md | 18 +++++++++++++++--- backend/routes/forms/submit.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) (limited to 'backend') diff --git a/SCHEMA.md b/SCHEMA.md index b55635f..fa5f247 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -33,9 +33,21 @@ In this document: | `WEBHOOK_ENABLED` | The form should notify the webhook. Has no effect if no webhook is set. | ### Meta options -| Field | Description | Example | -| --------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `webhook` | Mapping of webhook url and message. Message can use `_USER_MENTION_` to mention the submitting user. | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "Hello World! _USER_MENTION_"}` | +| Field | Description | Example | +| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `webhook` | Mapping of webhook url and message. Message can use certain [context variables](#webhook-variables). | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "{user} submitted a form."}` | + + +#### Webhook Variables +The following variables can be used in a webhook's message. The variables must be wrapped by braces (`{}`). + +| Name | Description | +| ------------- | ---------------------------------------------------------------------------- | +| `user` | A discord mention of the user submitting the form, or "User" if unavailable. | +| `response_id` | ID of the submitted response. | +| `form` | Name of the submitted form. | +| `form_id` | ID of the submitted form. | +| `time` | ISO submission timestamp. | ### Form question diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 3b22155..5c0cfdd 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -197,6 +197,18 @@ class SubmitForm(Route): # Set hook message message = form.meta.webhook.message if message: + # Available variables, see SCHEMA.md + ctx = { + "user": mention, + "response_id": response.id, + "form": form.name, + "form_id": form.id, + "time": response.timestamp, + } + + for key in ctx: + message = message.replace(f"{{{key}}}", str(ctx[key])) + hook["content"] = message.replace("_USER_MENTION_", mention) # Post hook -- cgit v1.2.3