WriteFreely as a Knowledgebase for Discord

Right click discord messages and save them to a blog.

WriteFreely as a Knowledgebase for Discord
Photo by Edu Grande / Unsplash

I run FreedomAcademy with my friends, it's a Discord server that helps people build discipline and overcome addictions.

https://www.joinfreedomacademy.com

It's a wholesome place, with everyone helping each other get better. In the process, there's a lot of advice that's sent around, I wanted something blog-like to house useful messages.

Our options were:

  • Pinned Messages: Discord-exclusive, with a limit of 50 per channel
  • Starboard: Discord-exclusive, community-curated, hard to search

Our current system relied on the starboard, there weren't any faults with it per say, but I wanted to play around!

I looked at a few projects: Ghost, Microfeed, WriteFreely, and others.

Ghost is great software, but it felt like overkill. Microfeed looked like too much effort to setup, so I went with the simplest of them all WriteFreely.

https://writefreely.org

WriteFreely is a light-weight writing platform that works out of the box. It supports multiple blogs (great for discord, we can have one for each user), and ActivityPub - allowing it to mesh well with the Fediverse. The latter was icing on the cake.


WriteFreely API

The idea is to take a Discord message and turn it into a blogpost:

Viewing a single post
The Post Feed

WriteFreely categorizes posts into 'Collections', to us, they correspond to authors. All of your messages will be grouped under a collection bearing your name.

As posts are no longer on Discord, Users might want to hide their posts from the internet. We want to archive knowledge (the same way a pin or a starboard would), and respect their privacy. For this, we want to let authors toggle their collection's visibility between public and private.

So here's what needs to be done:

  1. Create a collection for the author if it doesn't exist
  2. Create a new post for the message in the collection
  3. Add an option for a user to toggle their collection's privacy

Authenticating

First, we want an access token. This is returned after logging in with a username and password,

async def get_writefreely_access_token(username: str, password: str) -> str:
    """Log in to the configured WriteFreely instance and return an access token."""

    async with aiohttp.ClientSession() as session:
        async with session.post(
            f"https://fa.rambhat.la/api/auth/login",
            json={
                "alias": username,
                "pass": password,
            },
            headers={"content-type": "application/json"},
        ) as response:
            auth_data = await response.json()

            WRITEFREELY_ACCESS_TOKEN = auth_data["data"][
                "access_token"
            ]  # No error handling. Live by the Sword. Die by the Sword. lol

    return WRITEFREELY_ACCESS_TOKEN

The returned token will be sent in future requests in the authorization header:

headers = {"Authorization": f"Token {WRITEFREELY_ACCESS_TOKEN}"}

Creating Collections (Blogs)

Collections are translated to Blogs on the Website, we want to create one for each author. Here's what they look like:

Blog List

Collections have titles and aliases, the title corresponds to the author's username with an alias from their discord ID.

First, let's try retrieving the collection from the ID through the /collections/{id} endpoint. If successful, the response will contain a data key that we're interested in. If the collection doesn't exist, we'll get a non-200 status code, or a non-json content-type. In either of these cases, we'll have to create a new collection first.

A POST Request to /collections with the following JSON Data will create the collection and return its data.

{"alias": id, "title": title}

Creating a Post

The next step is to create a post to the author's collection. (This is the second step after a message is sent to be archived).

A POST Request to /collections/{id}/posts with the following data

{
  "body": body,
  "title": title,
  "created": "%Y-%m-%dT%H:%M:%SZ"
}

is all we need!

Toggling Collection Visibility

This isn't triggered during the archival process, but when the user modifies their Bot preferences.

When this happens, we first want to create a collection for them if it doesn't exist, and then change the collection's visibility.

This was particularly difficult to implement, due to an error in the documentation.

content-type must be application/x-www-form-urlencoded

The docs specify application/json, which fails :/

An additional authentication cookie may be required.
These haven't expired in the 8 months I've used them, I'm hoping they don't expire lol

A POST Request to /collections/{id} with the following data

{
  "visibility": ("1" if visible else "2")
},  # 1: Public, 2: Private.

and headers

{
  "content-type": "application/x-www-form-urlencoded",
  "Cookie": "your_cookie_here"  # After logging in on the website, open developer tools, make a request, and copy the value of the `Cookie` header
}

That's it!

Discord App Menu (Message Command)

I wanted to implement something using the Discord App Menu, this was a good opportunity.

Right clicking a message, going to apps, and hitting archive post (faster to do than to type it haha) should trigger our functions.

Discord calls these 'Message Commands', we can use disnake's @commands.message_command decorator to implement the same.

@commands.message_command(name="Archive Post")
async def archive_post(
    self, inter: disnake.MessageCommandInteraction, message: disnake.Message
):
    await inter.response.defer(ephemeral=True)

    config = read_config()

    post_body = message.content

    collection = await get_or_create_writefreely_collection(
        message.author.id, message.author.name
    )
    post_id = await create_writefreely_collection_post(
        message.author.id,
        post_body,
        created_at=message.created_at,
    )

    await message.add_reaction("🗃️")
    await inter.send(
        embed=disnake.Embed(
            title="Created Post", description=f"Post ID: `{post_id}`"
        ),
        ephemeral=True,
    )

Though, attached files and images aren't carried over. Our solution at FA was to link to any attached files at the end of the post.


That's it!


Deployment Data

{
  "LiveURL": "https://fa.rambhat.la",
  "Repository": "https://gist.github.com/TheOnlyWayUp/d136a9afc6de7413040427316c474d6a"
}