Using Flask API To Relay Messages to Discord Channels
I have been using Discord more often and I wanted find a way to push my own notifications to channels. Mostly to obtain daily content automatically without the need of googling it on a regular basis.
So I decided to create a Flask API service that relays messages to discord channels using their webhook integration. This way, I will be able to send notifications to discord from any other application I'll build in the future. Either regular applications, web scrapers or other API services.
Discord Integration
On Discord, go to Server Settings > Integrations and click Webhooks.
Click 'New Webhook', select the bot name and the channel to receive the messages from your service. Save the changes and copy the Webhook URL.
To allow the application to serve multiple channels, I dynamically inject the list of channels and their webhooks url's from a json configuration file into the application.
// config/discord_channels.json
{
"<your-channel-name>": {
"webhook": "<url>"
},
"<your-channel-name-2>": {
"webhook": "<url>"
},
}
I then feed this file into a Config
class, which is initialized when the application starts.
# config.py
import json
class Config:
"""
Class containing any necessary configuration data for the application
"""
def __init__(self):
with open("config/discord_channels.json") as file:
self.channels = json.load(file)
Now the list of channels will be available whenever you import an instance of Class
as shown in the example below.
from config import Config
config = Config()
channels_list = config.channels
Ability to send messages to multiple channels
Once the channels webhooks are loaded, I configured a service class that creates a discord Webhook
instance class based on the channel name we want to send the message to, which is defined in the API request.
# services/discord_webhook.py
from discord import Webhook, RequestsWebhookAdapter
from helpers.app_helper import config, logger
class DiscordWebHook(Webhook):
@classmethod
def from_url(cls, channel_name: str):
"""
Get webhook url from a channel name provided before a 'discord.Webhook' intialization
Args:
class_name (string): channel_name corresponding to correct webhook url
Returns:
Webhook: discord Webhook class instance
"""
channel_config = config.channels[channel_name]
return super().from_url(
url=channel_config["webhook"], adapter=RequestsWebhookAdapter()
)
The class_name
provides the channel name which is used to get the webhook url to initialize the Webhook
. This is then returned to the caller so it can be used in our API request and in any future modules using discord integrations.
Payload format
Next, using flask_apispec
, the endpoint POST /api/messages
is configured and expects two fields as in the example below.
{
"channel_name": "test_bot_channel",
"message": "Hello channel"
}
The API response and request data format is defined using marshmallow
.
# api/schemas/messages_schemas.py
from flask_marshmallow import Schema
from marshmallow import fields
class MessageRequestSchema(Schema):
channel_name = fields.Str()
message = fields.Str()
class MessageResponseSchema(Schema):
message = fields.Str()
API Blueprint
Now that the schemas are set, the actual API endpoint can be created, using the MethodResource
approach from flask_apispec
. This MessageResource
is registered as a blueprint in the Flask application initialization in app.py
.
# api/blueprints/messages.py
from discord import InvalidArgument
from discord.errors import HTTPException
import requests
from helpers.app_helper import logger
from flask_apispec import use_kwargs, marshal_with
from flask_apispec.views import MethodResource
from api.schemas.messages_schemas import MessageRequestSchema, MessageResponseSchema
from api.decorators.authentication import authenticated
from services.discord_webhook import DiscordWebHook
@marshal_with(MessageRequestSchema)
class MessageResource(MethodResource):
@use_kwargs(MessageRequestSchema)
@marshal_with(MessageResponseSchema)
@authenticated
def post(self, **kwargs):
try:
webhook = DiscordWebHook.from_url(kwargs["channel_name"])
webhook.send(kwargs["message"])
return {"message": "Message sent!"}, 200
except KeyError:
logger.warning(f"Channel {kwargs['channel_name']} not found!")
return {"message": f"Channel {kwargs['channel_name']} not found!"}, 400
except (InvalidArgument, HTTPException):
logger.error("Invalid webhook URL given!")
return {"message": "Server Error"}, 500
Here's a brief explanation of the MessageResource.post
method.
Decorators:
- The
@use_kwargs
decorator is forcing any service making a request to this endpoint to have the required fieldschannel_name
andmessage
. - The
@marshal_with
is defining what field the API response should contain, which in this case is justmessage
. - The
@authenticated
decorator is simply rejecting any request that is not authenticated.
In webhook = DiscordWebHook.from_url(kwargs["channel_name"])
a new discord webhook instance is initialized based on the channel_name the that the API requester provided. If the channel name isn't on the list, a HTTP 400
error message is thrown.
If the channel name is found the message is then sent to the channel in webhook.send(kwargs["message"])
. If the webhook url is wrong or there is a server error on discord, a HTTP 500
error message is thrown.
Authentication
To avoid this endpoint being available to everyone, if I end up deploying this service on the cloud, I added a basic bearer token authentication.
I am defining API_KEY
in .env
. To access environment variables I am using the package dotenv
which loads any environment variable from .env
into the application when calling load_dotenv()
.
API_KEY=your-unique-key
# app.py
# load environment variables
from dotenv import load_dotenv
load_dotenv()
# anywhere in your project an environment variable can be obtained by calling
os.getenv('API_KEY')
The @authenticated
decorator simply checks if an HTTP request has the Authorization
header with the correct API_KEY
.
# api/decorators/authentication.py
import os
from flask import request
from dotenv import load_dotenv
def authenticated(fn):
"""
Decorator to check if an HTTP request is done with a valid api key
"""
def valid_token(*args, **kwargs):
if request.headers.get("Authorization") == f"Bearer {os.getenv('API_KEY')}":
return fn(*args, **kwargs)
else:
return {"message": "Permission denied"}, 401
return valid_token
Starting the Flask Server
from flask import Flask, Blueprint
from flask_apispec import FlaskApiSpec
from dotenv import load_dotenv
from helpers.app_helper import logger
from api.blueprints.messages import MessageResource
def create_app():
# load environment variables
load_dotenv()
# init flask
app = Flask(__name__)
# register api endpoints
blueprint = Blueprint("messages", __name__, url_prefix="/api")
app.add_url_rule(
"/api/messages", view_func=MessageResource.as_view("messages"), methods=["POST"]
)
app.register_blueprint(blueprint)
# add endpoints to swagger
docs = FlaskApiSpec(app)
docs.register(MessageResource, endpoint="messages")
return app
In summary, create_app()
does the following:
- loads the environment variabled from
.env
withload_dotenv()
- registers the MessageResource blueprint, defines the url namespace
api/messages
and allows onlyPOST
requests. - registers the blueprint to
FlaskApiSpec
which adds the blueprint documentation in swagger, available atlocalhost:5000/swagger
.
To start flask run the following commands:
export FLASK_APP=app.py
flask run
The service is now ready to receive API rquests and relay the messages to discord.
Test
Using Postman the service can now be tested. Add the authorization header with the API_KEY set in .env
and provide the channel name and message to be sent in the body of the request.
Click send and a HTTP 200 response should be provided with the following response.
{
"message": "Message sent!"
}
And in the discord channel, the new message.
The service can now send messages to any channel that the API request needs, with the condition that the webhook url is added to config/discord_channels.json
.
Deployment
I don't intend to make this service available on the cloud for now. Instead, I will deploy it to my Raspberry PI home web server through Docker. The server will be initialized with Gunicorn, an Web Server Gateway Interface for python.
# Dockerfile
FROM python:3.9.5-slim
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pip install -r requirements.txt
CMD ["gunicorn","--workers=4", "wsgi:application", "-b", "0.0.0.0:5000"]
The gunicorn
command looks into wsgi.py
for an application
, which corresponds to the flask app initialized in app.py
Run docker build -t discord-micro-service:v1 .
to build the image and docker run -p 5000:5000 <image-id>
to start a container.
What's Next
I will probably work another project that performs scheduled tasks and will send notifications to discord. Either to indicate a task was successfully completed or simply to provide me with information collected.
This project is available on my github here, feel free to try it out and extend it to your needs.