Marching Forward - The 3-Part Automated Onboarding

We found ourselves faced with a challenge: automating the onboarding process for new recruits in our Arma 3 unit. Cool right? If you want to read more about this, check out this post: Operation: Re-Boot.

The automated onboarding process comprises three parts: joining the Discord server, participating in the BootCamp, and finally, the decision-making.

Now for the technologies used: a Linux server, Discord and my custom Discord Bot, a Discord webhook, Pythia, Arma 3 (obviously!), Flask, and Pocketbase.

Now, let me show you how each component plays its part in this nifty system.

Behold...

My diagram

The architecture

Looks like a spaghetti plate, doesn't it? But fear not, it's simpler than it seems. I'll dumb it down for you a lot (and maybe me in a few days).

Joining the Discord Server

Blue path

The journey begins when a new user joins the Discord server.

Blue Path (Arrow to Flask cut off)

The bot steps in at this point, setting up a private channel named after the user. The bot then asks for the user's Steam profile link. So far so good.

Why do we need the Steam profile link? Simplicity. It's just easier for users to find than their unique Steam ID. At least this was from our internal testing. The bot then extracts the Steam ID from the link, couples it with the user's Discord ID and the channel ID, and sends this bundle off to the Flask app. The Flask app then stores this data in the Pocketbase database.

Why is this necessary? So I can have a constant link between the discord user and his Arma 3 profile. But why? Should later decide to enhance the system, e.g. who led a fireteam during an operation I could use the same database for this. This enables me to have a full-blown analytic system for every mission we play. But for now, this is enough

Here's the corresponding code. Check the comments for explanations:

@commands.command(pass_context=True)
@commands.has_role("Applicant")
async def start(self, ctx: commands.Context):
    repeat_command = f"`Please try again with {ctx.clean_prefix}{ctx.invoked_with}`"
    author_id = ctx.author.id

    # Prompt the user to provide the URL to their Steam profile
    await ctx.send("For the first step, please provide the URL to your steam profile.")

    try:
        # Wait for the user's message containing the Steam URL
        steam_url_message = await self.bot.wait_for(
            "message",
            timeout=180,
            check=lambda message: message.author.id == author_id,
        )
    except asyncio.TimeoutError:
        await ctx.send(f"Timeout. {repeat_command}")
        return

    content = steam_url_message.content
    steam_url_regex = re.compile(r"https://steamcommunity\.com/(id|profiles)/[\w-]+")
    
    # Check if the provided content matches the Steam URL pattern
    if steam_url_match := steam_url_regex.search(content):
        steam_url = steam_url_match[0]
        steam_id = self.extract_steam_id(steam_url)
    else:
        await ctx.send(f"No valid Steam URL provided. {repeat_command}")
        return

    # Check if a valid Steam ID is obtained
    if id == 0:
        await ctx.send(f"No Id found. {repeat_command}")
        return

    # Send a POST request to the bootcamp API with the collected data
    response = requests.post(
        f"{BOOTCAMP_API}/bootcamp",
        json={
            "playerUID": steam_id,
            "discordUserId": str(author_id),
            "discordChannelId": str(ctx.channel.id),
            "playerName": "",
            "data": {},
        },
        headers={"Content-type": "application/json", "Accept": "application/json"},
        timeout=5,
    )

    # Check the response status code
    if response.status_code != 200:
        await self.bot.log_message(response.content, "error")
        await ctx.send(f"<@&{RECRUITER_ROLE_ID}> Something went wrong.")
        return

    success_message_template = 1111781960344424489
    success_message = await self.bot.config_channel.fetch_message(success_message_template)
    await ctx.send(success_message.content)


Bootcamp Mission

Orange path

With all the necessary information from the bot, our new user can jump into the BootCamp server and begin the mission.

Again, details about the BootCamp mission can be found in this post. A small summary: Applicants undergo several tasks which we track and store in the database.

Once they finish the mission, a request is fired to the Flask app. This triggers a webhook that posts the player's Steam ID on Discord. What a firework, right?

Anyway here is the little function to initialize the next step in the process:


@app.route("/bootcamp/<playerUID>/finished", methods=["GET"])
def finish_bootcamp(playerUID):
    record = collection.get_list(1, 1, {"filter": f"playerUID = {playerUID}"})

    if not record.items:
        return jsonify({"error": "Player not found"}), 404

    collection_item = record.items[0]
    data = collection_item.collection_id
    requests.post(WEBHOOK_URL, json={"content": data["playerUID"]})

    return jsonify({"message": f"Player {playerUID} has finished the bootcamp."})

Decision Making

Green path

On Discord, the bot listens for new messages in a specific channel. As soon as a message comes from this webhook, the decision-making process begins.

As you can see from the code above, I'm only sending the Steam ID. From this message, the bot fetches the user's BootCamp results from the Flask app. I opted for this method instead of posting the results directly through the webhook to sidestep Discord's character limit. Don't be confused, though. I still have to deal with the character limit, but since I'm already doing this with other functions on the bot I've decided to do the same again.

Green Path (Arrows to Flask cut off)

Here's the corresponding code for the whole process:

@commands.Cog.listener()
async def on_message(self, message: discord.Message):
    # Check if the message is from the specified webhook
    if message.webhook_id != WEBHOOK_ID:
        return

    # Make a request to the bootcamp API with the steam id from the webhook
    response = requests.get(f"{BOOTCAMP_API}/bootcamp/{message.content}")

    # Check the response status code
    if response.status_code != 200:
        await self.bot.log_message(response.content)
        return

    # Parse the bootcamp data from the response
    bootcamp_data = response.json()

    # Fetch the member and applicant channel based on the bootcamp data
    member: discord.User = await self.bot.fetch_user(bootcamp_data["discordUserId"])
    applicant_channel: discord.TextChannel = await self.bot.fetch_channel(
        bootcamp_data["discordChannelId"]
    )

    # Fetch the recruiter channel based on the channel ID
    recruiter_channel_id = 1087772403511341056
    recruiter_channel: discord.TextChannel = await self.bot.fetch_channel(
        recruiter_channel_id
    )

    # Format the member's name for display
    name_format = f"{member.mention} ({bootcamp_data['playerName']})"

    # Send the start message to the recruiter channel
    start_message = f"🔥🔥🔥 **START OF RESULTS FOR {name_format}** 🔥🔥🔥"
    await recruiter_channel.send(start_message)

    # Iterate over the result data and send formatted messages to the recruiter channel
    result_data = bootcamp_data["data"]
    for key, result in result_data.items():
        key, message = self._format_data(key, result)
        message_to_send = f"**{key}**:\n```{message}```"
        time.sleep(0.5)
        await recruiter_channel.send(message_to_send)

    # Send the finish message to the recruiter channel
    finish_message = f"🔥🔥🔥 **END OF RESULTS FOR {name_format}** 🔥🔥🔥"
    await recruiter_channel.send(finish_message)

    # Send a message the instructions on how to make the decision for the recruiters
    await recruiter_channel.send(
        f"<@&{RECRUITER_ROLE_ID}> Make your judgement. Use `$approve` or `$deny <reason | optional>` in {applicant_channel.mention}"
    )

    # Get the content of a notification message and send it to the applicant channel so the applicant knows we got the results
    msg = await self._get_content(1113182187655806987)
    await applicant_channel.send(msg.replace("{{user}}", member.mention))

        

This is what the results look like on Discord (and yes, I do use the light theme 😎):

From here, it's up to the recruiter to inspect the results and use the $approve or $deny command to decide on the user's fate.

In case of denial, the user is kicked from the server. Simple and effective.

In case of approval, the user sheds their Applicant role and dons the Recruit role. They are then announced as new recruits on our message-bord. Hurrey!!!

Regardless of the outcome, the user's private channel is archived for later reference. Just in case we need it for whatever reason.

Time is ticking

Kicking Applicants loop

A loop in our diagram shows that users have 14 days from joining the server to complete the process. Why?

This just ensures we don't accumulate inactive users. Nobody likes those anyway. Sitting there, doing nothing ಠ╭╮ಠ

Reflections and Future Plans

So far, it works. What else do I want?! Now we just need actually new people to come in and go through it. But this is the next step.

This might be more or less just a proof of concept, but the potential is huge. Especially when it is later more mature to be actually used by other units.

Conclusion

By integrating Discord, Pythia, Flask, and Pocketbase into a unique three-part system, we're revolutionizing how we welcome new recruits.

Do you have any thoughts on our new system? Ideas for improvements? We'd love to hear from you!

Looking forward, we aim to refine and expand this system, potentially sharing it with other Arma 3 units. Stay tuned for future updates.

For any questions hit me up on Twitter.