Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,10 @@ The template asked to post an embed, rate-limit itself, restrict itself to moder

## Packages

| Package | Version | Description |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [`tagscript`](./packages/tagscript) | [![npm](https://img.shields.io/npm/v/tagscript?style=flat-square&label=)](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. |
| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [![npm](https://img.shields.io/npm/v/@tagscript/plugin-discord?style=flat-square&label=)](https://www.npmjs.com/package/@tagscript/plugin-discord) | discord.js parsers and transformers for embeds, cooldowns, permissions and mentions. |
| Package | Version | Description |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| [`tagscript`](./packages/tagscript) | [![npm](https://img.shields.io/npm/v/tagscript?style=flat-square&label=)](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. |
| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [![npm](https://img.shields.io/npm/v/@tagscript/plugin-discord?style=flat-square&label=)](https://www.npmjs.com/package/@tagscript/plugin-discord) | Discord parsers and transformers for embeds, cooldowns, permissions and mentions. |

`tagscript` ships ESM, CJS and an IIFE build (global `TagScript`), and has no runtime dependencies.

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/plugins/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A plugin is a package of parsers and transformers built for one place TagScript

| Plugin | Adds |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [`@tagscript/plugin-discord`](/plugins/plugin-discord) | discord.js tags for embeds, cooldowns, permissions, attachments and timestamps, plus transformers for members, roles, channels and guilds. |
| [`@tagscript/plugin-discord`](/plugins/plugin-discord) | Discord tags for embeds, cooldowns, permissions, attachments and timestamps, plus transformers for members, roles, channels and guilds. |

## Writing one

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Command options
description: Turn slash command options into transformers a template can read.
---

`resolveCommandOptions` reads an interaction's options and hands back an object ready to pass as seed variables. Whatever the user typed into the command becomes available to the template by option name.
`resolveCommandOptions` reads a chat input command's data and hands back an object ready to pass as seed variables. Whatever the user typed into the command becomes available to the template by option name.

## For developers

Expand All @@ -13,18 +13,21 @@ import { Interpreter, StrictVarsParser } from 'tagscript';

const ts = new Interpreter(new StrictVarsParser());

const response = await ts.run(template, resolveCommandOptions(interaction.options));
const response = await ts.run(template, resolveCommandOptions(interaction.data));
```

Each option becomes a variable named after it. The type decides which transformer is used:
The argument is the `data` object off the interaction payload, typed `APIChatInputApplicationCommandInteractionData`. Each option becomes a variable named after it, and its type decides which transformer is used:

| Option type | Transformer |
| ------------------------ | ---------------------- |
| String, Boolean | `StringTransformer` |
| Integer, Number | `IntegerTransformer` |
| User, Mentionable (user) | `UserTransformer` or `MemberTransformer` |
| Role, Mentionable (role) | `RoleTransformer` |
| Channel | `ChannelTransformer` |
| Option type | Transformer |
| ------------------------ | ---------------------------------------- |
| String, Boolean | `StringTransformer` |
| Integer, Number | `IntegerTransformer` |
| User, Mentionable (user) | `MemberTransformer`, or `UserTransformer` outside a guild |
| Role, Mentionable (role) | `RoleTransformer` |
| Channel | `ChannelTransformer` |
| Attachment | `StringTransformer` holding the file URL |

An option's value is only ever an ID. The objects behind those IDs live in `data.resolved`, which Discord fills in for you, and that is where every transformer above reads from. An option missing from `resolved` is skipped, so the variable is absent rather than holding a bare snowflake.

Subcommands and subcommand groups are flattened with a `-` separated prefix, and the names themselves are exposed as `subCommand` and `subCommandGroup`.

Expand All @@ -38,21 +41,21 @@ Merge in anything else you want the template to see:

```ts showLineNumbers
const response = await ts.run(template, {
...resolveCommandOptions(interaction.options),
...resolveCommandOptions(interaction.data),
member: new MemberTransformer(interaction.member),
guild: new GuildTransformer(interaction.guild),
guild: new GuildTransformer(guild),
});
```

`mapOptions` is exported too, for the case where you have a `CommandInteractionOption[]` rather than the resolver.
`mapOptions` is exported too, for the case where you hold the options array and the resolved data separately.

## For template authors

The names come from the command your bot's authors set up, so ask them which options exist. A command with a `reason` option gives you `{reason}`, and inside a subcommand called `ban` it becomes `{ban-reason}`.

## Colours

`resolveColor` is also exported. It accepts `0x37b2cb`, `#ed4245`, a plain number, or a Discord colour name such as `Red`, and returns a number. Unlike the discord.js function it wraps, it returns the input unchanged instead of throwing when it cannot resolve a value, which is what keeps a bad colour in a template from ending the render.
`resolveColor` is also exported. It accepts `0x37b2cb`, `#ed4245`, `ed4245`, a plain number, `Random`, or a Discord colour name such as `Red`, and returns a number. It returns the input unchanged instead of throwing when it cannot resolve a value, which is what keeps a bad colour in a template from ending the render. The names it knows are exported as `Colors`.

## API reference

Expand Down
44 changes: 37 additions & 7 deletions apps/website/content/docs/plugins/plugin-discord/index.mdx
Original file line number Diff line number Diff line change
@@ -1,27 +1,57 @@
---
title: Discord plugin
description: discord.js parsers and transformers for TagScript.
description: Library agnostic Discord parsers and transformers for TagScript.
---

import { Callout } from 'fumadocs-ui/components/callout';

`@tagscript/plugin-discord` is what makes TagScript useful inside a Discord bot. It adds tags for embeds, cooldowns, permissions and timestamps, and transformers that let a template read a member, role, channel or guild.

Two rules shape the whole package.
Three rules shape the whole package.

**Templates ask, your bot decides.** No parser here sends a message, deletes anything or applies a cooldown. Each one records a request on `response.actions` and renders an empty string. Your code reads that object and picks what to honour.

**Structures stay behind a transformer.** A `GuildMember` reaches a template through `MemberTransformer`, which answers with a fixed list of keys. `{member.displayName}` works, and there is no key that hands back the client, the token, or a method.
**Structures stay behind a transformer.** A member reaches a template through `MemberTransformer`, which answers with a fixed list of keys. `{member.displayName}` works, and there is no key that hands back the client, the token, or a method.

**Payloads in, payloads out.** The only Discord dependency is [`discord-api-types`](https://discord-api-types.dev). Transformers read the raw objects Discord sends, and `EmbedParser` writes an `APIEmbed`. Any library that hands you those objects works.

## Installation

Install it alongside `tagscript` and `discord.js`:
Install it alongside `tagscript`:

```package-install
@tagscript/plugin-discord tagscript discord.js
@tagscript/plugin-discord tagscript
```

It needs discord.js v14 and Node 16.9 or newer, and ships ESM and CJS builds.
It needs Node 18 or newer, and ships ESM and CJS builds.

## Working with discord.js

Nothing here imports discord.js, so the two meet at the API payload.

On the way in, a transformer wants the raw object, not the wrapper class. discord.js `toJSON()` will not do: it returns a flattened camelCase blob and turns collections into ID arrays, so `roles` comes back as a list of IDs with the names gone. Use a payload you already have. Raw gateway events, `@discordjs/core`, an interaction's `data.resolved`, and REST calls through `client.rest` all give you one.

```ts showLineNumbers
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()`](https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#toJSON) produces. Hand it to [`EmbedBuilder.from()`](https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#from) and send it.

```ts showLineNumbers
const embed = EmbedBuilder.from(response.actions.embed);
```

<Callout type="info">
Everything below is written against raw payloads, which is what `@discordjs/core`, Oceanic, Seyfert and a
plain gateway connection hand you. If you are on discord.js, read `interaction.data` and
`interaction.data.resolved` off the payload you received rather than the structures discord.js built from
it.
</Callout>

## A complete example

Expand Down Expand Up @@ -86,7 +116,7 @@ if (cooldown && isOnCooldown(tagName, interaction.user.id)) {

await interaction.reply({
content: silentResponse ? undefined : response.body!,
embeds: embed ? [new EmbedBuilder(embed)] : [],
embeds: embed ? [EmbedBuilder.from(embed)] : [],
});

if (deleteMessage && channel.permissionsFor(client.user).has('ManageMessages')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,22 @@ The property form, easier to read and to edit one line at a time:
{embed(title):Rules}
{embed(description):Follow these to keep the server pleasant.}
{embed(field):Rule 1|Be nice.|false}
{embed(image):https://random-d.uk/api/randomimg}
{embed(footer):Posted by the mods|https://example.com/icon.png}
```

Repeated tags merge, so the four above build one embed. `field` takes a name, a value and whether it sits inline, split by pipes, and each `field` tag adds another one.
Repeated tags merge, so the six above build one embed.

Colours accept `0x37b2cb`, `#ed4245`, a plain number, or a Discord colour name such as `Red`.
Four properties take more than a plain value:

| Property | Written as | Becomes |
| -------- | ---------- | ------- |
| `field` | `name\|value\|inline` | Another entry in `fields`. |
| `image`, `thumbnail` | a URL | `{ "url": "..." }`. |
| `author` | `name\|url\|iconUrl`, the last two optional | `{ "name": ..., "url": ..., "icon_url": ... }`. |
| `footer` | `text\|iconUrl`, the icon optional | `{ "text": ..., "icon_url": ... }`. |

Colours accept `0x37b2cb`, `#ed4245`, `ed4245`, a plain number, `Random`, or a Discord colour name such as `Red`.

## For developers

Expand All @@ -70,19 +81,24 @@ The payload is required. `parse` merges into `ctx.response.actions.embed` and re

Colours go through the exported `resolveColor`, which returns the input unchanged instead of throwing when it cannot resolve, so a bad colour reaches you as a string rather than a number.

`response.actions.embed` is an `APIEmbed`: the shape Discord's API takes, the shape
[`EmbedBuilder.toJSON()`](https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#toJSON)
produces, and the shape [`EmbedBuilder.from()`](https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#from)
reads. Nothing needs reshaping on the way out.

```ts showLineNumbers
const response = await ts.run(template);

if (response.actions.embed) {
await interaction.reply({ embeds: [new EmbedBuilder(response.actions.embed)] } );
await interaction.reply({ embeds: [EmbedBuilder.from(response.actions.embed)] });
}
```

<Callout type="warn">
The value is typed `APIEmbed | EmbedData`, but it is assembled from text a user wrote, so the type is a
convenience and not a guarantee. A template can set any property name to any string. Validate it before
handing it to `EmbedBuilder`, which throws on malformed input, and check field counts and lengths
against Discord's limits.
The value is typed `APIEmbed`, but it is assembled from text a user wrote, so the type is a convenience
and not a guarantee. A template can set any property name to any string. Validate it before handing it to
`EmbedBuilder`, which throws on malformed input, and check field counts and lengths against Discord's
limits.
</Callout>

The JSON form is parsed by the `protected parseEmbedJSON` method. Subclass `EmbedParser` and override it to validate, to strip properties you do not allow, or to resolve image URLs yourself.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The payload is required. `parse` splits it with the exported `split(payload, tru

<Callout type="warn">
The array holds raw strings from a template. Nothing checks that they are URLs, that the host is one you
trust, or that the file is a reasonable size. Passing them to discord.js unchecked makes your bot fetch
trust, or that the file is a reasonable size. Passing them to your Discord library unchecked makes your bot fetch
whatever a template author names, including addresses on your own network. Match them against an
allowlist of hosts before you send.
</Callout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ The plugin declaration-merges `IActions`, so importing it types the fields above
```ts showLineNumbers
import '@tagscript/plugin-discord';

response.actions.embed; // APIEmbed | EmbedData | undefined
response.actions.embed; // APIEmbed | undefined
response.actions.cooldown; // { cooldown: number; message: string | null } | undefined
```
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: ChannelTransformer
description: Expose a guild channel to a template.
---

`ChannelTransformer` exposes a guild channel. Fields that only some channel types have, such as `topic` and `slowmode`, fall back to an empty string or `0` rather than failing.
`ChannelTransformer` reads a guild channel payload, either a full `APIGuildChannel` or the trimmed one Discord puts in an interaction's resolved data. Fields that only some channel types have, such as `topic` and `slowmode`, fall back to an empty string or `0` rather than failing.

## For template authors

Expand Down Expand Up @@ -36,9 +36,6 @@ Please move this to {channel}.
| `position` | The position in the channel list. |
| `nsfw` | `true` or `false`. |
| `parentId` | The category ID. |
| `parentName` | The category name. |
| `parentType` | The category type. |
| `parentPosition` | The category position. |
| `createdAt` | Creation date as an ISO string. |
| `createdTimestamp` | Creation time in milliseconds. |
| `slowmode` | Slowmode in seconds, or `0`. |
Expand All @@ -56,23 +53,30 @@ import { Interpreter, StrictVarsParser } from 'tagscript';
const ts = new Interpreter(new StrictVarsParser());

const response = await ts.run(template, {
channel: new ChannelTransformer(message.channel),
channel: new ChannelTransformer(channel),
});
```

A [variable parser](/tagscript/parsers/variables) has to be registered for the tag to resolve.

### Adding your own fields

Pass a second argument to expose extra keys. A function receives the GuildChannel and runs when the tag renders.
Pass a second argument to expose extra keys. A function receives the payload and runs when the tag renders.

```ts showLineNumbers
new ChannelTransformer(guildchannel, {
new ChannelTransformer(channel, {
custom: 'a fixed value',
computed: (base) => `${base.id} was looked up at render time`,
});
```

A channel payload carries `parentId` and nothing else about the category, so pass the category yourself if a
template needs its name:

```ts showLineNumbers
new ChannelTransformer(channel, { parentName: parent.name, parentType: parent.type });
```

### API reference

[ChannelTransformer](/api/plugins/classes/ChannelTransformer)
Loading
Loading