Plugin Discord
@tagscript/plugin-discord
Discord parsers and transformers for TagScript.
What this is for
TagScript lets your users write templates without letting them touch your runtime. This plugin is the piece that makes that useful for a Discord bot: it adds tags for embeds, cooldowns, permissions and mentions, and transformers that expose Discord objects to a template safely.
Three rules make it work:
- Templates ask, your bot decides. No parser here sends a message, deletes a message or applies a cooldown. Each one records a request on
response.actionsand returns an empty string. Your code reads that object and chooses what to honour. - Structures are never handed over. A member reaches a template through
MemberTransformer, which exposes a fixed list of keys.{member.username}works; there is no path to the client, the token or any method. - Payloads in, payloads out. The only Discord dependency is
discord-api-types. Transformers read the raw objects Discord sends, andEmbedParserwrites anAPIEmbed. Any library that hands you those objects works, discord.js included.
Installation
@tagscript/plugin-discord needs tagscript alongside it:
npm install @tagscript/plugin-discord tagscriptRequires Node 18+. Ships ESM and CJS.
Working with discord.js
Nothing in this package imports discord.js, so the two meet at the API payload.
On the way in, transformers want the raw object, not the wrapper class. discord.js toJSON() will not do it: it returns a flattened camelCase blob and turns collections into ID arrays. Reach for the payload you already have instead. A raw gateway or REST response, @discordjs/core, or a client.rest.get(Routes.user(id)) call all give you one:
import { Routes, type APIUser } from 'discord-api-types/v10';
import { UserTransformer } from '@tagscript/plugin-discord';
const payload = (await client.rest.get(Routes.user(id))) as APIUser;
await ts.run('Hi {user}!', { user: new UserTransformer(payload) });On the way out, response.actions.embed is an APIEmbed, the same object EmbedBuilder.toJSON() produces, so hand it straight to EmbedBuilder.from():
const embed = EmbedBuilder.from(response.actions.embed);Actions
Register the parsers you want, run the user's template, then act on what came back.
import { CooldownParser, DeleteParser, EmbedParser, RequiredParser } from '@tagscript/plugin-discord';
import { Interpreter } from 'tagscript';
const ts = new Interpreter(new EmbedParser(), new CooldownParser(), new RequiredParser(), new DeleteParser());
const response = await ts.run(`
{embed(title):Server Rules}
{embed(color):0x37b2cb}
{embed(field):Rule 1|Be nice.|false}
{cooldown(30):Slow down, try again in {retryAfter}.}
{require(Moderator)}
{delete}
Posted {date:2020-01-01}
`);response.actions is now:
{
"embed": {
"title": "Server Rules",
"color": 3650251,
"fields": [{ "name": "Rule 1", "value": "Be nice.", "inline": false }],
},
"cooldown": { "cooldown": 30, "message": "Slow down, try again in {retryAfter}." },
"require": { "ids": ["Moderator"], "message": null },
"deleteMessage": true,
}Nothing has happened yet. Your handler decides:
if (response.actions.require && !hasAnyOf(member, response.actions.require.ids)) {
return interaction.reply(response.actions.require.message ?? 'You cannot use this tag.');
}
// Clamp what the template asked for to what this user is actually allowed
const cooldown = Math.max(response.actions.cooldown?.cooldown ?? 0, minimumCooldownFor(member));
await interaction.reply({
content: response.actions.silentResponse ? undefined : response.body!,
embeds: response.actions.embed ? [EmbedBuilder.from(response.actions.embed)] : [],
files: response.actions.files,
});
if (response.actions.deleteMessage) await message.delete();Parsers
| Parser | Aliases | Example | Sets on actions |
|---|---|---|---|
EmbedParser | embed | {embed(title):Rules} or {embed:{...json}} | embed |
CooldownParser | cooldown, cd | {cooldown(30):Try again in {retryAfter}.} | cooldown |
RequiredParser | require, allowlist, whitelist | {require(Moderator):Mods only.} | require, first tag wins |
DenyParser | deny, denylist, blacklist | {deny(#general):Not here.} | deny, first tag wins |
DeleteParser | delete, del | {delete} | deleteMessage: true |
SilentParser | silent | {silent} | silentResponse: true |
FilesParser | files | {files:https://a.png,https://b.png} | files, a list of URLs |
DateFormatParser | date, unix, currenttime | {date:2020-01-01} | Nothing; renders <t:1577836800:f> inline |
Notes:
requireanddenycollect whatever strings the user wrote: role names, channel names or IDs. Resolving them and enforcing the check is your job; the plugin deliberately does not guess.EmbedParseraccepts either a full JSON payload or one property per tag, and merges repeated tags.imageandthumbnailbecome{ "url": ... },authorandfootertake pipe separated parts, and colours go throughresolveColor, which accepts0x37b2cb,#ed4245,Redor a raw number and returns the input unchanged rather than throwing if it cannot resolve.- The output of
EmbedParseris user-controlled, so validate it before sending it. {date}takes one of Discord's timestamp styles as the parameter, one off,F,t,TorR, and renders a real Discord timestamp.{unix}and{currenttime}render the current time in milliseconds.
Transformers
Every transformer here exposes id, mention and name, plus its own keys. Use the bare name for the mention ({user} → <@758880890159235083>) or a parameter for a specific value ({user(username)}, or {user.username}).
import { MemberTransformer, UserTransformer } from '@tagscript/plugin-discord';
import { Interpreter, StrictVarsParser } from 'tagscript';
const ts = new Interpreter(new StrictVarsParser());
const response = await ts.run('Hi {member.displayName}, welcome to {guild}!', {
member: new MemberTransformer(interaction.member),
guild: new GuildTransformer(guildPayload),
});StrictVarsParser (or LooseVarsParser) must be registered for these to resolve.
| Transformer | Reads | Notable keys |
|---|---|---|
UserTransformer | APIUser | username, globalName, tag, displayAvatar, createdAt, bot |
MemberTransformer | APIGuildMember | displayName, nickname, joinedAt, roleIds, timeoutUntil |
RoleTransformer | APIRole | color, hoist, mentionable, position, permissions |
ChannelTransformer | APIGuildChannel | topic, type, nsfw, parentId, slowmode, position |
GuildTransformer | APIGuild | ownerId, roleNames, roleCount, emojiCount, verificationLevel |
InteractionTransformer | APIApplicationCommandInteraction | commandName, commandId, channelId, guildId, locale |
A payload only carries what Discord put in it. A member has role IDs but no role objects, a role does not know who holds it, and a guild payload has no channel list. Anything that needs a second object is yours to pass, through the same second argument you use for your own keys. A function is called with the payload at render time:
new MemberTransformer(member, {
warnings: () => warningCountFor(member.user.id),
topRole: roles.reduce((highest, role) => (role.position > highest.position ? role : highest)).name,
isStaff: (base) => base.roles.includes(STAFF_ROLE_ID),
});Subclass BaseTransformer and implement resolveId(), resolveMention() and updateSafeValues() if you need a reusable one.
Slash command options
resolveCommandOptions turns an interaction's command data into ready-to-seed transformers, so a template can reference whatever the user passed to the command:
import { resolveCommandOptions } from '@tagscript/plugin-discord';
const response = await ts.run(template, resolveCommandOptions(interaction.data));
// {subCommand}, {member}, {some-option} ... are now availableOptions are matched against data.resolved, which is where Discord puts the full user, member, role, channel and attachment objects. An option Discord did not resolve is skipped rather than guessed at.
Subcommand and subcommand-group options are flattened with a - separated prefix, so an option channel inside sub-command is reachable as {sub-command-channel}.
Related
tagscriptfor the interpreter and its built-in tags.- Full documentation: tagscript.js.org
Buy me some doughnuts
If you want to support me by donating, you can do so by using any of the following methods. Thank you very much in advance!
Contributors
Thanks goes to these wonderful people:
Classes
- BaseTransformer
- ChannelTransformer
- CooldownParser
- DateFormatParser
- DeleteParser
- DenyParser
- EmbedParser
- FilesParser
- GuildTransformer
- InteractionTransformer
- MemberTransformer
- RequiredParser
- RoleTransformer
- SilentParser
- UserTransformer