A Weird Imagination

React to reacting to Discord join message

Posted in

The problem#

I run a Discord community for a local hobby-focused friends group. Since it's theoretically people I know in person, I don't want it to be an open invite; I want confirmation that everyone that joins has a real in-person connection to the group. I handle by gating access to most of the channels behind a role that I grant to new users once I've identified them. As the group has grown, I am often not directly connected to everyone joining, so I wanted an easy way to grant trusted users the ability to do the same. The straightforward way is to just use Discord permissions to let other people assign roles, but I wanted something smoother and easier for non-technical users.

The solution#

YAGPDB ("Yet Another General Purpose Discord Bot") is an extremely flexible and configurable Discord bot that can be set up to do pretty much anything. For this purpose, we're deep in its customization features which include a programming language for running custom scripts in response to various triggers. Since people in the community tend to πŸ‘‹ react to the join message Discord generates when someone they know joins, I wanted to specifically make that the signal that a user is a real person. Here's a YAGPDB custom command that will do so when set up to be triggered by adding a reaction and configured with the desired allowed roles and filling in the configurable values at the top of the script:

{{/*
    Grant role to user when trusted user
    reacts to their join message.
    By Daniel Perelman <https://github.com/dperelman/>

    Loosely based on
    <https://yagpdb-cc.github.io/utilities/reaction-logs>
    by Satty9361 <https://github.com/Satty9361>
*/}}

{{/* Configurable values */}}
{{$logging_channel_id := }}
{{$roleName := }}
{{$adminUserId := }}
{{/* End of configurable values */}}

{{/* Actual CODE */}}
{{ if (and
        (eq .ReactionMessage.Type 7)
        (eq .Reaction.Emoji.APIName "πŸ‘‹")) }}
    {{$newUser := .ReactionMessage.Author}}
    {{$role := getRole $roleName}}
    {{$adminUserLink := (print "[" (userArg $adminUserId) "]"
        "(<https://discord.com/users/" $adminUserId ">)")}}
    {{$userLink := (print "[" .User "]"
        "(<https://discord.com/users/" .User.ID ">)")}}
    {{$newUserLink := (print "[" $newUser "]"
        "(<https://discord.com/users/" $newUser.ID ">)")}}
    {{ if targetHasRoleID $newUser.ID $role.ID }}
        {{/* sendMessage $logging_channel_id (print
            "DEBUG: " $userLink " would have granted `"
            $role.Name "` to " $newUserLink
            " but they already have that role.") */}}
    {{ else }}
        {{ giveRoleID $newUser.ID $role.ID }}
        {{ sendMessage $logging_channel_id (print
            $userLink " granted `" $role.Name "` to
            " $newUserLink " by reacting :wave: to their "
            "join message.") }}
        {{ sendDM (print
            "You have granted " $newUserLink " the role `"
            $role.Name "` by :wave: reacting to their "
            "join message. If this was in error, "
            "please contact " $adminUserLink ".") }}
    {{ end }}
{{ end }}

The details#

Scripting Discord#

Discord offers an extensibility interface for writing your own bots (apparently now known as "apps"), which act as programmatically controlled users that can be written in any language that can handle HTTP and WebSockets. But writing a separate bot for every feature you might want is awkward, so there exist various highly configurable general purpose bots. YAGPDB is one popular one that I was already using for a few things.

In addition to several common features with user-friendly configuration, YAGPDB supports custom commands, which are scripts it can run in reaction to various triggers including explicitly invoking the command or a new message or reaction. The scripting language used is YAGPDB's templates which are based on the Go template library. The same templates can be used elsewhere YAGPDB has a configurable text field. Note that while "template" implies we are generating a string, for our purposes, we actually only care about the side effects (which include generating some strings and sending them in messages).

Template language#

As is often the case with template languages, actually writing programs in YAGPDB's template language is a little awkward. Here's a few things I ran into writing this script:

  1. There's no string concatenation operator. Instead you can use the print function which will concatenate all of its arguments.

  2. . refers to the context variable, which is initially equivalent to $, the global context variable (i.e., the input to the template), but . can be reassigned to using the with operator.

  3. All operations are LISP-style function name first followed by arguments, including boolean operations like and and equality operators like eq.

  4. The function documentation describes the YAGPDB-specific functions.

Focusing on join message#

I started with the reaction-logs example script since it was the most similar to what I wanted: it reacts to a reaction on a message and it messages a different channel with information about that reaction.

My first change was to add debugging information to that message to figure out what I would need to know to write the script. In order to handle just the join message, I had to be able to identify whether a message is the join message and what user it is about. Looking at the list of fields of Message, I thought .Author, .Member, .Mentions. and .Type looked like they might be useful, so I added them to the print of the sendMessage command. .Type was 7 which, looking at the Message Types in the Dicord documentation, corresponds to USER_JOIN, which makes sense. The .Member and .Mentions fields were empty, but the .Author field had the User object of the user that joined.

The final piece of the setup was the example code from the reaction trigger documentation for filtering on which reaction was used. Putting that all together gives the first couple lines of the script:

{{ if (and
        (eq .ReactionMessage.Type 7)
        (eq .Reaction.Emoji.APIName "πŸ‘‹")) }}
    {{$newUser := .ReactionMessage.Author}}

While the mobile app (on Android at least) interprets links to https://discord.com/users/$id by opening up the user info pane in the app, the desktop app opens such links in the web browser. Which works okay if you're logged into Discord in the web browser, but isn't a great experience. I decided to use those links anyway, but that means they aren't really helpful to users on the desktop app.

Discord supports Markdown syntax for links: [link text](https://example.com). Normally Discord defaults to showing an embed preview of any link, which you can disable for a specific link by wrapping it in <>: e.g., <https://example.com>. For links with link text, you can still do that by wrapping the URL in <>: [link text](<https://example.com>).

That, along with the print command, gives the next few lines that build the links for the user reacting, the new user, and the admin contact.

Role manipulation#

The role functions handle most of the rest of the logic. {{$role := getRole $roleName}} gets the role from its name so the configuration at the top can be a human-readable name instead of an ID. (The How to Get IDs documentation page mentions you can use the listroles command to get the ID for the role and put that in the script instead. Since getRole is case-insensitive, if you have two roles that differ only in case, that may be necessary.) {{ if targetHasRoleID $newUser.ID $role.ID }} checks if the role is already assigned, in which case there's nothing to do. {{ giveRoleID $newUser.ID $role.ID }} actually assigns the role.

Announcing the action#

While the role could be assigned silently, I wanted it to be clear to both me and the person assigning the role that the role had been assigned. So the script both messages the channel identified by $logging_channel_id (viewable only by admins on my Discord) and the user who performed the action with a description of what happened and why.

Configuring the script#

For the script to work properly, it has to be set with the trigger type to be "Reaction" in "Added reactions only" mode. Since it only acts on join messages, it should be restricted to whichever channel the join messages are configured to appear in. To limit who can use it, some appropriate selection of roles should be required, possibly just the role it assigns.

The variables at the top of the script also have to be filled in with values. See the How to Get IDs documentation page for how to get those values.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.