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:
-
There's no string concatenation operator. Instead you can use the
print
function which will concatenate all of its arguments. -
.
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 thewith
operator. -
All operations are LISP-style function name first followed by arguments, including boolean operations like
and
and equality operators likeeq
. -
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}}
Link syntax#
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.