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.

Discord Integrations

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 fields channel_name and message.
  • The @marshal_with is defining what field the API response should contain, which in this case is just message.
  • 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 with load_dotenv()
  • registers the MessageResource blueprint, defines the url namespace api/messages and allows only POST requests.
  • registers the blueprint to FlaskApiSpec which adds the blueprint documentation in swagger, available at localhost: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.

Discord Integrations

Discord Integrations

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.

Discord Integration's

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.