Advi-Sir Development Log

Composing the Blueprints!

An online friend of mine challenged me to make a scalable discord bot with a feature that has never been on any other discord bot within a time limit of 6 hours. The goal was to make a bot that can be hosted by anyone for free and should be neat looking.

So I started off by asking many other discord friends of mine (ah yes I have many friends despite my bad rep and sense of humor cough cough) of what feature I was to add. It just hit me out of nowhere that I should make a bot that gives advice, well to make it funnier, it may even give some stupid but still true advice.

Getting the JUICY STUFF

I then googled exactly this: advice api fun, and Google did its hacks and tricks and gave me the perfect thing that I was looking for! It’s the Advice Slip API it spits out the perfect response like so:

1
2
3
4
5
6
7
// GET https://api.adviceslip.com/advice
{
"slip":{
"advice":"Remedy tickly coughs with a drink of honey, lemon and water as hot as you can take.",
"slip_id":"170"
}
}

Gathering the building blocks

Since I love Python and I am always looking for excuses to make anything with it, I decided to go with discord.py which is a Discord wrapper written in Python. Also one more reason being that Python is one of the best languages to process and manipulate images as it provides a great range of libraries for this very topic.

And without any doubt I went with Pillow 7.1.1 as the Image Manipulation library of choice.

Constructing the Ship!

Now it was finally time to write some real code as I had already done enough research (which was the tougher part of the entire process).

Getting the advice

It was the most easy piece of code in the entire project, all I had to do was a GET request to the endpoint using Aiohttp, parse the response data which was json formatted and extract the slip_id and the advice

1
2
3
4
5
6
7
8
9
10
11
12
async def get_advice(self, session: ClientSession):
async with session.get("https://api.adviceslip.com/advice") as response:
if response.status == 200:
data = await response.json(content_type='text/html')
text = data["slip"]["advice"]
slip_id = data["slip"]["slip_id"]
return {
"text": text,
"slip_id": slip_id
}
else:
return None

Being a true man of culture

There exists 2 types of people:

  1. Dark Mode Lovers
    These are the cool ones and really want to save their eyesight.
  2. Light Mode Plebs
    Purely evil people who just hate humanity and enjoy torturing themselves.

And the userbase of this bot consisted of all these people, so like any intellectual being cough cough I too wanted to have a dark-mode and a light-mode for the advice slips that I was gonna generate.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import discord
from discord.ext import commands
from aiohttp import ClientSession
## Get the darkmode off a Firebase Firestore
async def darkmode(self, userid: str, session: ClientSession):
async with session.get("https://some-firebase-firestore-very-legit.cloudfunctions.net/api/mode", params={"uid": userid}) as response:
if response.status == 200:
data = await response.json()

return data["darkmode"]
else:
return False


## Also a way for users to toggle the dark-mode in form of a command
@commands.command(name="darkmode")
@commands.cooldown(5, 5)
async def dark_mode(self, ctx):
"""Toggle between darkmode and light mode. If you had darkmode on then after executing this command you will get advices in light mode and vice versa"""
darkmode = await self.darkmode(str(ctx.author.id), ctx.bot.session)
print(darkmode)
darkmode = "True" if darkmode == "False" else "False"
async with ctx.bot.session.post("https://some-firebase-firestore-very-legit.cloudfunctions.net/api/mode", data={"uid": str(ctx.author.id), "darkmode": darkmode}) as response:
data = await response.json()
stat = "ON" if data['darkmode'] == "True" else "OFF"
await ctx.send(f"Dark-Mode: `{stat}`")

Generating the image

Importing the necessary libraries

1
2
3
import os

from PIL import Image, ImageDraw, ImageFont

Wrapping the text within the bound of the slip, so that it doesn’t overflow which will cause ugly looking text.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def text_wrap(self, text, font, max_width):
lines = []

# and return
if font.getsize(text)[0] <= max_width:
lines.append(text)
else:
# split the line by spaces to get words
words = text.split(' ')
i = 0
# append every word to a line while its width is shorter than image width
while i < len(words):
line = ''
while i < len(words) and font.getsize(line + words[i])[0] <= max_width:
line = line + words[i] + " "
i += 1
if not line:
line = words[i]
i += 1
# when the line gets longer than the max width do not append the word,
# add the line to the lines array
lines.append(line)
return lines

Finally drawing the text and passing the information such as image size and drawable area bounds to the helper functions. And also working with the mode received from the simple Firebase cloud functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def draw_text(self, text, name, darkmode):
# open the background file
img = Image.open(
'extensions/image/bg/light.png' if darkmode == "False" else 'extensions/image/bg/dark.png')
draw = ImageDraw.Draw(img)
# size() returns a tuple of (width, height)
image_size = img.size

# create the ImageFont instance
font_file_path = 'extensions/image/fonts/Harting_plain.ttf'
size = 40 if len(text) > 60 else 50
print(size)
font = ImageFont.truetype(font_file_path, size=size, encoding="unic")

# get shorter lines
lines = self.text_wrap(text, font, image_size[0] - 100)
# ['This could be a single line text ', 'but its too long to fit in one. ']
print(lines)

line_height = font.getsize('hg')[1]
lines.insert(0, name)
lines.insert(1, "-" * len(lines[1]))
lines += ["-" * len(lines[1]), " "]

# (50 , 90), (50, 489)
# (449, 90), (449, 489)
(x, y) = (50, 90)
for line in lines:
# draw the line on the image
draw.text(
(x, y), line, fill='rgb(0, 0, 0)' if darkmode == "False" else 'rgb(225, 225, 225)', font=font)

# update the y position so that we can use it for next line
y = y + line_height
# save the image
img.save(f'{name}.png', optimize=True)

Let the Ship Sail!

Finally we put together a command for the user to invoke. We call in our functions and give a nifty message before sending in the actual slip image.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@commands.command(aliases=["advise"])
@commands.cooldown(5, 5)
async def advice(self, ctx):
"""Generates an "Advice-Slip" for you!"""
advice = await self.get_advice(ctx.bot.session)
if advice:
message = await ctx.send("Generating your slip...")
async with ctx.typing():
darkmode = await self.darkmode(str(ctx.author.id), ctx.bot.session)
self.draw_text(advice["text"], advice["slip_id"], darkmode)
await ctx.channel.send(file=discord.File(f"{advice['slip_id']}.png"))
await message.delete()

try:
os.remove(f"{advice['slip_id']}.png")
except Exception as identifier:
pass

Directory Tree for the entire project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Folder PATH listing

| .firebaserc
| .gitignore
| Aptfile
| Bigtree.txt
| config.json
| firebase.json
| firestore.indexes.json
| firestore.rules
| LICENSE
| main.py
| Procfile
| README.md
| requirements.txt
+---extensions
+---help
| | help.py
| |
| \---__pycache__
+---image
| | advice.py
| |
| +---bg
| | avatar.png
| | CG_Grades_ View_5-4-2020.pdf
| | dark.png
| | light.png
| | testing.png
| |
| \---fonts
| Harting_plain.ttf
| veteran_typewriter.ttf
|
+---misc
| | botinfo.py
| | game.py
| | invite.py
| | ping.py
| |
| \---__pycache__
+---owner
| | eval.py
| | extensions.py
| | handlers.py
| | processes.py
| | serverlog.py
| |
| \---__pycache__
\---utils
`

Hosted this on heroku thus its completely free of cost for me to host this!

Treasure!

This was the final result!

The bot in action

Dynamic Exit!!

Since I had the last 30 mins out of the 6 hours dedicated for this nifty bot, I thought maybe making a small website won’t hurt that much haha.

For the readers

So this concludes my first public dev-log, because my mother forced me to do something recreational :p

I hope you had fun reading and learned something new, if you have anything to ask me feel free to message me at my discord: source#5843, peace out!