diff --git a/README.md b/README.md
index 933277c8..f3e12f20 100644
--- a/README.md
+++ b/README.md
@@ -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) | [](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. |
-| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [](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) | [](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. |
+| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [](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.
diff --git a/apps/website/content/docs/plugins/index.mdx b/apps/website/content/docs/plugins/index.mdx
index f838f89b..0926ec71 100644
--- a/apps/website/content/docs/plugins/index.mdx
+++ b/apps/website/content/docs/plugins/index.mdx
@@ -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
diff --git a/apps/website/content/docs/plugins/plugin-discord/command-options.mdx b/apps/website/content/docs/plugins/plugin-discord/command-options.mdx
index 006fe440..64d9329c 100644
--- a/apps/website/content/docs/plugins/plugin-discord/command-options.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/command-options.mdx
@@ -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
@@ -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`.
@@ -38,13 +41,13 @@ 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
@@ -52,7 +55,7 @@ The names come from the command your bot's authors set up, so ask them which opt
## 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
diff --git a/apps/website/content/docs/plugins/plugin-discord/index.mdx b/apps/website/content/docs/plugins/plugin-discord/index.mdx
index 7bb4f518..ed39c6f3 100644
--- a/apps/website/content/docs/plugins/plugin-discord/index.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/index.mdx
@@ -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);
+```
+
+
+ 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.
+
## A complete example
@@ -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')) {
diff --git a/apps/website/content/docs/plugins/plugin-discord/parsers/embed.mdx b/apps/website/content/docs/plugins/plugin-discord/parsers/embed.mdx
index 4e10a886..fa0a8500 100644
--- a/apps/website/content/docs/plugins/plugin-discord/parsers/embed.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/parsers/embed.mdx
@@ -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
@@ -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)] });
}
```
- 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.
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.
diff --git a/apps/website/content/docs/plugins/plugin-discord/parsers/files.mdx b/apps/website/content/docs/plugins/plugin-discord/parsers/files.mdx
index bf7b0662..8eb2029a 100644
--- a/apps/website/content/docs/plugins/plugin-discord/parsers/files.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/parsers/files.mdx
@@ -49,7 +49,7 @@ The payload is required. `parse` splits it with the exported `split(payload, tru
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.
diff --git a/apps/website/content/docs/plugins/plugin-discord/parsers/index.mdx b/apps/website/content/docs/plugins/plugin-discord/parsers/index.mdx
index 4e1d464a..052bef7a 100644
--- a/apps/website/content/docs/plugins/plugin-discord/parsers/index.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/parsers/index.mdx
@@ -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
```
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/channel.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/channel.mdx
index 9329fdb7..343df0ab 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/channel.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/channel.mdx
@@ -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
@@ -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`. |
@@ -56,7 +53,7 @@ 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),
});
```
@@ -64,15 +61,22 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### 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)
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/guild.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/guild.mdx
index ce21e3fd..3b11d214 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/guild.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/guild.mdx
@@ -3,7 +3,7 @@ title: GuildTransformer
description: Expose a guild to a template.
---
-`GuildTransformer` exposes a discord.js `Guild`, including a few counts and a `random` key that picks a member each time it renders.
+`GuildTransformer` reads an `APIGuild` payload, including the roles it ships with and a few counts derived from them.
## For template authors
@@ -15,13 +15,13 @@ description: Expose a guild to a template.
{{guild.key}}
```
-The bare tag renders the mention. A parameter picks one field.
+The bare tag renders the guild name. A parameter picks one field.
### Examples
```yaml
-Welcome to {guild(name)}, member number {guild(memberCount)}.
-# Welcome to My Server, member number 1204.
+Welcome to {guild}, owned by <@{guild(ownerId)}>.
+# Welcome to My Server, owned by <@938716130720235601>.
```
### Fields
@@ -38,23 +38,15 @@ Welcome to {guild(name)}, member number {guild(memberCount)}.
| `ownerId` | The owner ID. |
| `createdAt` | Creation date as an ISO string. |
| `createdTimestamp` | Creation time in milliseconds. |
-| `large` | `true` or `false`. |
-| `memberCount` | The member count. |
-| `random` | A random member, rolled per tag. |
-| `roles` | The roles. |
+| `memberCount` | The approximate member count, when the payload was fetched with counts. |
+| `roles` | A mention per role. |
| `roleIds` | The role IDs. |
| `roleNames` | The role names. |
| `roleCount` | How many roles. |
-| `channels` | The channels. |
-| `channelIds` | The channel IDs. |
-| `channelNames` | The channel names. |
-| `channelCount` | How many channels. |
| `emojiCount` | How many emojis. |
| `stickerCount` | How many stickers. |
-| `bots` | How many bots. |
-| `humans` | How many humans. |
| `afkTimeout` | The AFK timeout. |
-| `afkChannel` | The AFK channel. |
+| `afkChannel` | A mention of the AFK channel. |
| `verificationLevel` | The verification level. |
A key that does not exist leaves the tag in the output as written, so a typo is visible rather than silent.
@@ -70,7 +62,7 @@ import { Interpreter, StrictVarsParser } from 'tagscript';
const ts = new Interpreter(new StrictVarsParser());
const response = await ts.run(template, {
- guild: new GuildTransformer(interaction.guild),
+ guild: new GuildTransformer(guild),
});
```
@@ -78,7 +70,7 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### Adding your own fields
-Pass a second argument to expose extra keys. A function receives the Guild 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 GuildTransformer(guild, {
@@ -87,7 +79,17 @@ new GuildTransformer(guild, {
});
```
-Counts such as `memberCount` and `roleCount` read from the cache, so they are only as complete as your intents and cache settings make them.
+Channels are a separate endpoint and are not part of a guild payload, and `memberCount` reads
+`approximate_member_count`, which Discord only sends when you fetch the guild with `with_counts`. Pass what
+you have:
+
+```ts showLineNumbers
+new GuildTransformer(guild, {
+ channelCount: channels.length,
+ channelNames: channels.map((channel) => channel.name).join(', '),
+ memberCount: cachedMemberCount,
+});
+```
### API reference
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/index.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/index.mdx
index 9d7b27ec..1dd3f65f 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/index.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/index.mdx
@@ -1,9 +1,9 @@
---
title: Transformers
-description: Expose discord.js structures to a template without exposing the objects themselves.
+description: Expose Discord payloads to a template without exposing the objects themselves.
---
-Each transformer wraps one discord.js structure and answers with a fixed list of keys. `{member.displayName}` works. There is no key that returns the client, the token, or a method, because the transformer only ever returns strings it built itself.
+Each transformer reads one raw Discord payload and answers with a fixed list of keys. `{member.displayName}` works. There is no key that returns the client, the token, or a method, because the transformer only ever returns strings it built itself.
```ts showLineNumbers
import { GuildTransformer, MemberTransformer } from '@tagscript/plugin-discord';
@@ -13,7 +13,7 @@ const ts = new Interpreter(new StrictVarsParser());
const response = await ts.run('Hi {member.displayName}, welcome to {guild(name)}!', {
member: new MemberTransformer(interaction.member),
- guild: new GuildTransformer(interaction.guild),
+ guild: new GuildTransformer(guild),
});
```
@@ -21,35 +21,61 @@ The names on the left, `member` and `guild` here, are what template authors type
## Available transformers
-| Page | Wraps | Notable keys |
-| ---------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------ |
-| [User](/plugins/plugin-discord/transformers/user) | `User` | `username`, `globalName`, `tag`, `displayAvatar`, `bot` |
-| [Member](/plugins/plugin-discord/transformers/member) | `GuildMember` | `displayName`, `nickname`, `joinedAt`, `topRole`, `roleNames` |
-| [Role](/plugins/plugin-discord/transformers/role) | `Role` | `color`, `permissions`, `memberCount`, `mentionable` |
-| [Channel](/plugins/plugin-discord/transformers/channel) | Guild channels | `topic`, `type`, `nsfw`, `parentName`, `slowmode` |
-| [Guild](/plugins/plugin-discord/transformers/guild) | `Guild` | `memberCount`, `ownerId`, `channelCount`, `random` |
-| [Interaction](/plugins/plugin-discord/transformers/interaction) | `CommandInteraction` | `commandName`, `channelId`, `guildId`, `locale` |
+| Page | Reads | Notable keys |
+| ---------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
+| [User](/plugins/plugin-discord/transformers/user) | `APIUser` | `username`, `globalName`, `tag`, `displayAvatar`, `bot` |
+| [Member](/plugins/plugin-discord/transformers/member) | `APIGuildMember` | `displayName`, `nickname`, `joinedAt`, `roleIds`, `roles` |
+| [Role](/plugins/plugin-discord/transformers/role) | `APIRole` | `color`, `permissions`, `position`, `mentionable` |
+| [Channel](/plugins/plugin-discord/transformers/channel) | `APIGuildChannel` | `topic`, `type`, `nsfw`, `parentId`, `slowmode` |
+| [Guild](/plugins/plugin-discord/transformers/guild) | `APIGuild` | `ownerId`, `roleNames`, `roleCount`, `emojiCount` |
+| [Interaction](/plugins/plugin-discord/transformers/interaction) | `APIApplicationCommandInteraction` | `commandName`, `channelId`, `guildId`, `locale` |
Every one of them exposes `id`, `mention` and `name` on top of its own keys, because `BaseTransformer` sets those three in its constructor.
+## What a payload does not carry
+
+A transformer sees exactly what Discord sent and nothing more. A member payload lists role IDs, not role objects. A role does not know who holds it. A guild payload has no channel list, and its `approximate_member_count` is only there when you fetched the guild with `with_counts`.
+
+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.
+
+```ts showLineNumbers
+const roles = guild.roles.filter((role) => member.roles.includes(role.id));
+
+new MemberTransformer(member, {
+ roleNames: roles.map((role) => role.name).join(', '),
+ topRole: roles.reduce((highest, role) => (role.position > highest.position ? role : highest)).name,
+ warnings: () => warningCountFor(member.user.id),
+});
+```
+
## Writing your own
-Extend `BaseTransformer` and override `updateSafeValues` to fill in the keys.
+Extend `BaseTransformer` and implement `resolveId`, `resolveMention` and `updateSafeValues`.
```ts showLineNumbers
import { BaseTransformer } from '@tagscript/plugin-discord';
-import type { Message } from 'discord.js';
+import type { APIMessage } from 'discord-api-types/v10';
+
+export class MessageTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return this.base.content;
+ }
-export class MessageTransformer extends BaseTransformer {
protected override updateSafeValues() {
this.safeValues.content = this.base.content;
this.safeValues.pinned = this.base.pinned;
- this.safeValues.attachmentCount = this.base.attachments.size;
+ this.safeValues.attachmentCount = this.base.attachments.length;
}
}
```
-A value can be a string, a number, a boolean, `null`, `undefined`, or a function taking the wrapped structure. Functions run when the tag renders, so use one for anything expensive or anything that changes during the render.
+`resolveId` fills `{thing.id}`, and `resolveMention` decides what a bare `{thing}` renders to: a mention for anything Discord can mention, and something readable for anything else.
+
+A value can be a string, a number, a boolean, `null`, `undefined`, or a function taking the payload. Functions run when the tag renders, so use one for anything expensive or anything that changes during the render.
Values render with a template literal, so keep them to primitives. Assigning an object gives the template `[object Object]`.
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/interaction.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/interaction.mdx
index 7eea519a..c4fd7c87 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/interaction.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/interaction.mdx
@@ -3,7 +3,7 @@ title: InteractionTransformer
description: Expose a slash command interaction to a template.
---
-`InteractionTransformer` exposes the interaction that triggered the tag, which lets a template mention which command was used and where.
+`InteractionTransformer` reads an `APIApplicationCommandInteraction` payload, which lets a template say which command was used and where.
## For template authors
@@ -15,13 +15,16 @@ description: Expose a slash command interaction to a template.
{{command.key}}
```
-The bare tag renders the mention. A parameter picks one field.
+The bare tag renders a command mention, which Discord turns into a clickable `/ping` in the client. A parameter picks one field.
### Examples
```yaml
You used the {command(commandName)} command.
# You used the ping command.
+
+Run {command} again to try.
+# Run again to try.
```
### Fields
@@ -29,7 +32,8 @@ You used the {command(commandName)} command.
| Key | Gives |
| --- | ----- |
| `id` | The interaction ID. |
-| `mention` | The command mention. |
+| `name` | The command name. |
+| `mention` | A clickable command mention. |
| `applicationId` | The application ID. |
| `channelId` | The channel ID. |
| `guildId` | The guild ID. |
@@ -59,10 +63,10 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### Adding your own fields
-Pass a second argument to expose extra keys. A function receives the CommandInteraction 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 InteractionTransformer(commandinteraction, {
+new InteractionTransformer(interaction, {
custom: 'a fixed value',
computed: (base) => `${base.id} was looked up at render time`,
});
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/member.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/member.mdx
index 6a9ed821..7771bb99 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/member.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/member.mdx
@@ -3,7 +3,7 @@ title: MemberTransformer
description: Expose a guild member to a template.
---
-`MemberTransformer` exposes a discord.js `GuildMember`, which is a user plus everything specific to one guild: nickname, roles, join date and timeout.
+`MemberTransformer` reads an `APIGuildMember` payload, which is a user plus everything specific to one guild: nickname, roles, join date and timeout.
## For template authors
@@ -30,25 +30,21 @@ Welcome {member}, you joined {date(R):{member(joinedTimestamp)}}.
| --- | ----- |
| `id` | The user ID. |
| `mention` | A mention. |
-| `name` | Empty, since `GuildMember` has no `name`. |
+| `name` | Empty, since a member payload has no `name`. |
| `username` | The username. |
| `discriminator` | The legacy discriminator. |
-| `tag` | Username and discriminator. |
-| `avatar` | The custom avatar URL, or empty. |
-| `displayAvatar` | The avatar URL with a fallback. |
+| `tag` | Username and discriminator, or just the username on a migrated account. |
+| `avatar` | The account avatar URL, or empty. |
+| `displayAvatar` | The avatar URL, falling back to the default avatar. |
| `nickname` | The guild nickname. |
-| `displayName` | Nickname if set, otherwise username. |
+| `displayName` | Nickname if set, then global name, then username. |
| `joinedAt` | Join date as an ISO string. |
| `joinedTimestamp` | Join time in milliseconds. |
| `createdAt` | Account creation date. |
| `createdTimestamp` | Account creation time in milliseconds. |
| `bot` | `true` or `false`. |
-| `color` | The highest role colour. |
-| `position` | The highest role position. |
-| `roles` | The roles. |
+| `roles` | A mention per role. |
| `roleIds` | The role IDs. |
-| `roleNames` | The role names. |
-| `topRole` | The highest role. |
| `timeoutUntil` | When a timeout ends. |
| `timeoutUntilTimestamp` | Timeout end in milliseconds. |
@@ -73,12 +69,24 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### Adding your own fields
-Pass a second argument to expose extra keys. A function receives the GuildMember 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 MemberTransformer(guildmember, {
+new MemberTransformer(member, {
custom: 'a fixed value',
- computed: (base) => `${base.id} was looked up at render time`,
+ computed: (base) => `${base.user.id} was looked up at render time`,
+});
+```
+
+A member payload lists role IDs and carries no guild ID, so role names, the top role, the member colour and
+the per-guild avatar all need the guild alongside the member. Work them out once and pass them in:
+
+```ts showLineNumbers
+const roles = guild.roles.filter((role) => member.roles.includes(role.id));
+
+new MemberTransformer(member, {
+ roleNames: roles.map((role) => role.name).join(', '),
+ topRole: roles.reduce((highest, role) => (role.position > highest.position ? role : highest)).name,
});
```
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/role.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/role.mdx
index 5d51c28b..e33826fb 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/role.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/role.mdx
@@ -3,7 +3,7 @@ title: RoleTransformer
description: Expose a role to a template.
---
-`RoleTransformer` exposes a discord.js `Role`.
+`RoleTransformer` reads an `APIRole` payload.
## For template authors
@@ -20,8 +20,8 @@ The bare tag renders the mention. A parameter picks one field.
### Examples
```yaml
-The {role(name)} role has {role(memberCount)} members.
-# The Moderator role has 4 members.
+The {role(name)} role sits at position {role(position)}.
+# The Moderator role sits at position 16.
```
### Fields
@@ -38,7 +38,6 @@ The {role(name)} role has {role(memberCount)} members.
| `permissions` | The permission names, comma separated. |
| `createdAt` | Creation date as an ISO string. |
| `createdTimestamp` | Creation time in milliseconds. |
-| `memberCount` | How many members hold the role. |
A key that does not exist leaves the tag in the output as written, so a typo is visible rather than silent.
@@ -53,7 +52,7 @@ import { Interpreter, StrictVarsParser } from 'tagscript';
const ts = new Interpreter(new StrictVarsParser());
const response = await ts.run(template, {
- role: interaction.guild.roles.cache.get(id) && new RoleTransformer(role),
+ role: new RoleTransformer(role),
});
```
@@ -61,7 +60,7 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### Adding your own fields
-Pass a second argument to expose extra keys. A function receives the Role 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 RoleTransformer(role, {
@@ -70,6 +69,14 @@ new RoleTransformer(role, {
});
```
+A role payload does not say who holds the role, so count the members yourself if a template needs it:
+
+```ts showLineNumbers
+new RoleTransformer(role, {
+ memberCount: members.filter((member) => member.roles.includes(role.id)).length,
+});
+```
+
### API reference
[RoleTransformer](/api/plugins/classes/RoleTransformer)
diff --git a/apps/website/content/docs/plugins/plugin-discord/transformers/user.mdx b/apps/website/content/docs/plugins/plugin-discord/transformers/user.mdx
index 0553dcde..ae57ffbf 100644
--- a/apps/website/content/docs/plugins/plugin-discord/transformers/user.mdx
+++ b/apps/website/content/docs/plugins/plugin-discord/transformers/user.mdx
@@ -3,7 +3,7 @@ title: UserTransformer
description: Expose a Discord user to a template.
---
-`UserTransformer` exposes a discord.js `User`. The template gets the fields below and nothing else, so there is no path from a template to the client or to any method on the user.
+`UserTransformer` reads an `APIUser` payload. The template gets the fields below and nothing else, so there is no path from a template to the client or to any method on the user.
## For template authors
@@ -30,11 +30,11 @@ Hi {user}, your account was made on {user(createdAt)}.
| --- | ----- |
| `id` | The user ID. |
| `mention` | A mention, the same as the bare tag. |
-| `name` | Empty for a user, since `User` has no `name`. |
+| `name` | Empty, since a user payload has no `name`. |
| `username` | The username. |
| `globalName` | The display name, or empty. |
| `discriminator` | The legacy discriminator. |
-| `tag` | Username and discriminator together. |
+| `tag` | Username and discriminator together, or just the username on a migrated account. |
| `avatar` | The custom avatar URL, or empty when they have none. |
| `displayAvatar` | The avatar URL, falling back to the default avatar. |
| `createdAt` | Account creation date as an ISO string. |
@@ -62,7 +62,7 @@ A [variable parser](/tagscript/parsers/variables) has to be registered for the t
### Adding your own fields
-Pass a second argument to expose extra keys. A function receives the User 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 UserTransformer(user, {
diff --git a/bun.lock b/bun.lock
index 0d8126a7..8beffdd6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -63,12 +63,13 @@
"packages/tagscript-plugin-discord": {
"name": "@tagscript/plugin-discord",
"version": "3.1.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.54",
+ },
"devDependencies": {
- "discord.js": "^14.27.0",
"tagscript": "workspace:^",
},
"peerDependencies": {
- "discord.js": "^14.0.0",
"tagscript": "*",
},
},
@@ -96,18 +97,6 @@
"@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="],
- "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="],
-
- "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
-
- "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
-
- "@discordjs/rest": ["@discordjs/rest@2.6.3", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.50", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg=="],
-
- "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
-
- "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
-
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -640,14 +629,8 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
- "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
-
"@sapphire/result": ["@sapphire/result@2.8.0", "", {}, "sha512-693yWouX+hR9uJm1Jgq0uSSjbSD3UrblMaxiuGbHPjSwzLCSZTcm0h3kvdVhq3o/yl4+oeAWW3hiaJ0TELuRJQ=="],
- "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
-
- "@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
-
"@sapphire/utilities": ["@sapphire/utilities@3.18.2", "", {}, "sha512-QGLdC9+pT74Zd7aaObqn0EUfq40c4dyTL65pFnkM6WO1QYN7Yg/s4CdH+CXmx0Zcu6wcfCWILSftXPMosJHP5A=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
@@ -758,8 +741,6 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
- "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
-
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.68.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="],
@@ -776,8 +757,6 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
- "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
-
"@yuku-analyzer/binding-android-arm64": ["@yuku-analyzer/binding-android-arm64@0.9.3", "", { "os": "android", "cpu": "arm64" }, "sha512-6dOwkawiJYtUUBchfjrFo7poz460Yg+aQNgr4lHUPZ2fNW+llpMEZkbznTq25BEmmiBGPJr+L15dzTvR8zP4vQ=="],
"@yuku-analyzer/binding-darwin-arm64": ["@yuku-analyzer/binding-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DTRoWK7AqNfshN+DcCS/s4n86br0ZusISeXLZWWNuvnZ3b9LCVXdWRVPlnMpEwsmSH3c3QSwL7MAOPyEHMe+FA=="],
@@ -1048,8 +1027,6 @@
"discord-api-types": ["discord-api-types@0.38.54", "", {}, "sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA=="],
- "discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="],
-
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="],
@@ -1480,8 +1457,6 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
- "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
-
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"longest": ["longest@2.0.1", "", {}, "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q=="],
@@ -1494,8 +1469,6 @@
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
- "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="],
-
"magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -1950,8 +1923,6 @@
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
- "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
-
"tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1984,8 +1955,6 @@
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
- "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
-
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
@@ -2046,8 +2015,6 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
- "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
-
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -2072,10 +2039,6 @@
"@commitlint/types/conventional-commits-parser": ["conventional-commits-parser@7.1.2", "", { "dependencies": { "@simple-libs/stream-utils": "^2.0.0", "argue-cli": "^3.1.0" }, "bin": { "conventional-commits-parser": "./dist/cli/index.js" } }, "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ=="],
- "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
-
- "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
-
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@fumadocs/cli/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
@@ -2118,8 +2081,6 @@
"@types/prompts/@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
- "@types/ws/@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
-
"bun-types/@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2208,8 +2169,6 @@
"@types/prompts/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
- "@types/ws/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
-
"bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
diff --git a/packages/tagscript-plugin-discord/README.md b/packages/tagscript-plugin-discord/README.md
index 08b69307..bda42fc4 100644
--- a/packages/tagscript-plugin-discord/README.md
+++ b/packages/tagscript-plugin-discord/README.md
@@ -2,7 +2,7 @@
# @tagscript/plugin-discord
-**discord.js parsers and transformers for [TagScript](https://www.npmjs.com/package/tagscript).**
+**Discord parsers and transformers for [TagScript](https://www.npmjs.com/package/tagscript).**
[](https://www.npmjs.com/package/@tagscript/plugin-discord)
[](https://www.npmjs.com/package/@tagscript/plugin-discord)
@@ -12,22 +12,44 @@
## What this is for
-[TagScript](https://www.npmjs.com/package/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.js structures to a template safely.
+[TagScript](https://www.npmjs.com/package/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.
-Two rules make it work:
+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.actions` and returns an empty string. Your code reads that object and chooses what to honour.
-- **Structures are never handed over.** A `GuildMember` 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.
+- **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`](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, discord.js included.
## Installation
-`@tagscript/plugin-discord` needs `tagscript` and `discord.js` alongside it:
+`@tagscript/plugin-discord` needs `tagscript` alongside it:
```sh
-npm install @tagscript/plugin-discord tagscript discord.js
+npm install @tagscript/plugin-discord tagscript
```
-Requires discord.js v14 and Node 16.9+. Ships ESM and CJS.
+Requires 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:
+
+```ts
+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, so hand it straight to [`EmbedBuilder.from()`](https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#from):
+
+```ts
+const embed = EmbedBuilder.from(response.actions.embed);
+```
## Actions
@@ -77,7 +99,7 @@ const cooldown = Math.max(response.actions.cooldown?.cooldown ?? 0, minimumCoold
await interaction.reply({
content: response.actions.silentResponse ? undefined : response.body!,
- embeds: response.actions.embed ? [new EmbedBuilder(response.actions.embed)] : [],
+ embeds: response.actions.embed ? [EmbedBuilder.from(response.actions.embed)] : [],
files: response.actions.files,
});
@@ -100,8 +122,8 @@ if (response.actions.deleteMessage) await message.delete();
Notes:
- `require` and `deny` collect 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.
-- `EmbedParser` accepts either a full JSON payload or one property per tag, and merges repeated tags. Colours go through `resolveColor`, which accepts `0x37b2cb`, `#ed4245`, `Red` or a raw number, and returns the input unchanged rather than throwing if it cannot resolve.
-- The output of `EmbedParser` is user-controlled, so validate it before handing it to `EmbedBuilder`.
+- `EmbedParser` accepts either a full JSON payload or one property per tag, and merges repeated tags. `image` and `thumbnail` become `{ "url": ... }`, `author` and `footer` take pipe separated parts, and colours go through `resolveColor`, which accepts `0x37b2cb`, `#ed4245`, `Red` or a raw number and returns the input unchanged rather than throwing if it cannot resolve.
+- The output of `EmbedParser` is user-controlled, so validate it before sending it.
- `{date}` takes one of Discord's timestamp styles as the parameter, one of `f`, `F`, `t`, `T` or `R`, and renders a real Discord timestamp. `{unix}` and `{currenttime}` render the current time in milliseconds.
## Transformers
@@ -116,43 +138,46 @@ 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(interaction.guild),
+ guild: new GuildTransformer(guildPayload),
});
```
`StrictVarsParser` (or `LooseVarsParser`) must be registered for these to resolve.
-| Transformer | Wraps | Notable keys |
-| ------------------------ | -------------------- | -------------------------------------------------------------------------------------- |
-| `UserTransformer` | `User` | `username`, `globalName`, `tag`, `displayAvatar`, `createdAt`, `bot` |
-| `MemberTransformer` | `GuildMember` | `displayName`, `nickname`, `joinedAt`, `topRole`, `roleNames`, `color`, `timeoutUntil` |
-| `RoleTransformer` | `Role` | `color`, `hoist`, `mentionable`, `position`, `permissions`, `memberCount` |
-| `ChannelTransformer` | Guild channels | `topic`, `type`, `nsfw`, `parentName`, `slowmode`, `position` |
-| `GuildTransformer` | `Guild` | `memberCount`, `ownerId`, `roleNames`, `channelCount`, `verificationLevel`, `random` |
-| `InteractionTransformer` | `CommandInteraction` | `commandName`, `commandId`, `channelId`, `guildId`, `locale` |
+| 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` |
-To expose extra values, pass a second argument. A function is called with the underlying structure at render time:
+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:
```ts
new MemberTransformer(member, {
- warnings: () => warningCountFor(member.id),
- isStaff: (base) => base.roles.cache.has(STAFF_ROLE_ID),
+ 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 override `updateSafeValues()` if you need a reusable one.
+Subclass `BaseTransformer` and implement `resolveId()`, `resolveMention()` and `updateSafeValues()` if you need a reusable one.
## Slash command options
-`resolveCommandOptions` turns an interaction's options into ready-to-seed transformers, so a template can reference whatever the user passed to the command:
+`resolveCommandOptions` turns an interaction's command data into ready-to-seed transformers, so a template can reference whatever the user passed to the command:
```ts
import { resolveCommandOptions } from '@tagscript/plugin-discord';
-const response = await ts.run(template, resolveCommandOptions(interaction.options));
+const response = await ts.run(template, resolveCommandOptions(interaction.data));
// {subCommand}, {member}, {some-option} ... are now available
```
+Options 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
diff --git a/packages/tagscript-plugin-discord/package.json b/packages/tagscript-plugin-discord/package.json
index 5388fefe..5fc516c6 100644
--- a/packages/tagscript-plugin-discord/package.json
+++ b/packages/tagscript-plugin-discord/package.json
@@ -1,12 +1,13 @@
{
"name": "@tagscript/plugin-discord",
"version": "3.1.1",
- "description": "discord.js parsers and transformers for TagScript: embeds, cooldowns, permissions and mentions from user-written templates.",
+ "description": "Library agnostic Discord parsers and transformers for TagScript: embeds, cooldowns, permissions and mentions from user-written templates.",
"keywords": [
"bot tag",
+ "discord",
+ "discord-api-types",
"discord-bot",
"discord.js",
- "discordjs",
"safe string",
"sandbox",
"string parser",
@@ -61,15 +62,16 @@
"test:watch": "bun test --watch",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
},
+ "dependencies": {
+ "discord-api-types": "^0.38.54"
+ },
"devDependencies": {
- "discord.js": "^14.27.0",
"tagscript": "workspace:^"
},
"peerDependencies": {
- "discord.js": "^14.0.0",
"tagscript": "*"
},
"engines": {
- "node": ">=v16.9.0"
+ "node": ">=18.0.0"
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Parsers/Embed.ts b/packages/tagscript-plugin-discord/src/lib/Parsers/Embed.ts
index c90e839c..73ee3eea 100644
--- a/packages/tagscript-plugin-discord/src/lib/Parsers/Embed.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Parsers/Embed.ts
@@ -2,7 +2,7 @@ import { BaseParser, split, type Context, type IParser, type Awaitable } from 't
import { resolveColor } from '../Utils';
-import type { EmbedData, APIEmbed } from 'discord.js';
+import type { APIEmbed } from 'discord-api-types/v10';
/**
* An embed tag will send an embed in the tag response.
@@ -37,21 +37,26 @@ import type { EmbedData, APIEmbed } from 'discord.js';
* {embed(title): Rules}
* {embed(description): Follow these rules to ensure a good experience in our server!}
* {embed(field): Rule 1|Respect everyone you speak to.|false}
+ * {embed(image): https://random-d.uk/api/randomimg}
+ * {embed(footer): Posted by the mods|https://random-d.uk/api/randomimg}
* ```
- * Developers need to construct the embed builder themselves with the output of the tag.
+ *
+ * The result is an {@link APIEmbed}, the shape Discord's API takes and the shape discord.js
+ * {@link https://discord.js.org/docs/packages/discord.js/main/EmbedBuilder:Class#from | EmbedBuilder.from} reads.
* @example
* ```ts showLineNumbers
- * const { Interpreter } = require("tagscript")
- * const { EmbedParser } = require("@tagscript/plugin-discord")
+ * import { EmbedBuilder } from 'discord.js';
+ * import { EmbedParser } from '@tagscript/plugin-discord';
+ * import { Interpreter } from 'tagscript';
*
- * const ts = new Interpreter(new EmbedParser())
- * const result = await ts.run('{embed: { "title": "Hello!", "description": "This is a test embed." }}')
+ * const ts = new Interpreter(new EmbedParser());
+ * const result = await ts.run('{embed: { "title": "Hello!", "description": "This is a test embed." }}');
*
- * // You might need to change the embed object before passing to `EmbedBuilder`. Changes such as change thumbnail and image value from string to object.
- * const embed = new EmbedBuilder(response.actions.embed);
+ * const embed = EmbedBuilder.from(result.actions.embed);
* ```
* @remarks
- * The return type depends on user's input. So it might not be `EmbedData | APIEmbed`. So use a typeguard to check.
+ * A template author picks both the property names and the values, so the result is typed `APIEmbed` for
+ * convenience and is not validated. Check it before you send it.
*/
export class EmbedParser extends BaseParser implements IParser {
public constructor() {
@@ -61,30 +66,45 @@ export class EmbedParser extends BaseParser implements IParser {
public async parse(ctx: Context) {
if (!ctx.tag.parameter) return this.returnEmbed(ctx, await this.parseEmbedJSON(ctx.tag.payload!));
- if (ctx.tag.payload!.startsWith('{') && ctx.tag.payload!.endsWith('}'))
- return this.returnEmbed(ctx, { [ctx.tag.parameter]: JSON.parse(ctx.tag.payload!) as unknown });
- if (ctx.tag.parameter === 'field') {
- const [name, value, inline] = split(ctx.tag.payload!);
- if (!name || !value) return '';
- return this.returnEmbed(ctx, {
- fields: [
- {
- name,
- value,
- inline: inline === 'true',
- },
- ],
- });
- }
+ const payload = ctx.tag.payload!;
- if (ctx.tag.parameter === 'color') {
- return this.returnEmbed(ctx, {
- // This can return number but it should be handled by the dev
- color: resolveColor(ctx.tag.payload!) as number,
- });
- }
+ if (payload.startsWith('{') && payload.endsWith('}'))
+ return this.returnEmbed(ctx, { [ctx.tag.parameter]: JSON.parse(payload) as unknown });
+
+ switch (ctx.tag.parameter) {
+ case 'field': {
+ const [name, value, inline] = split(payload);
+ if (!name || !value) return '';
+ return this.returnEmbed(ctx, { fields: [{ name, value, inline: inline === 'true' }] });
+ }
+
+ case 'color': {
+ // This can return a string but it should be handled by the dev
+ return this.returnEmbed(ctx, { color: resolveColor(payload) as number });
+ }
- return this.returnEmbed(ctx, { [ctx.tag.parameter]: ctx.tag.payload });
+ case 'image':
+ case 'thumbnail': {
+ return this.returnEmbed(ctx, { [ctx.tag.parameter]: { url: payload } });
+ }
+
+ case 'author': {
+ const [name, url, iconUrl] = split(payload);
+ if (!name) return '';
+ return this.returnEmbed(ctx, {
+ author: { name, ...(url && { url }), ...(iconUrl && { icon_url: iconUrl }) },
+ });
+ }
+
+ case 'footer': {
+ const [text, iconUrl] = split(payload);
+ if (!text) return '';
+ return this.returnEmbed(ctx, { footer: { text, ...(iconUrl && { icon_url: iconUrl }) } });
+ }
+
+ default:
+ return this.returnEmbed(ctx, { [ctx.tag.parameter]: payload });
+ }
}
/**
@@ -93,18 +113,17 @@ export class EmbedParser extends BaseParser implements IParser {
* @param payload - The payload to parse
* @returns
*/
- protected parseEmbedJSON(payload: string): Awaitable {
+ protected parseEmbedJSON(payload: string): Awaitable {
const parsedResult = JSON.parse(payload);
if (parsedResult.color) parsedResult.color = resolveColor(parsedResult.color);
return parsedResult;
}
- private returnEmbed(ctx: Context, data: APIEmbed | EmbedData): string {
- ctx.response.actions.embed ??= {} as EmbedData;
+ private returnEmbed(ctx: Context, data: APIEmbed): string {
+ ctx.response.actions.embed ??= {};
const { fields, ...rest } = data;
if (fields) ctx.response.actions.embed.fields = [...(ctx.response.actions.embed.fields ?? []), ...fields];
- // @ts-expect-error - The return type should be unknown
ctx.response.actions.embed = { ...ctx.response.actions.embed, ...rest };
return '';
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/Base.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/Base.ts
index 4ce1e5e0..5ecb08fb 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/Base.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/Base.ts
@@ -1,5 +1,3 @@
-import type { GuildChannel } from '../interfaces';
-import type { Role, User, GuildMember, Guild, CommandInteraction } from 'discord.js';
import type { Lexer, ITransformer } from 'tagscript';
export type outputResolvable = boolean | number | string | null | undefined;
@@ -14,22 +12,24 @@ export interface SafeValues {
}
/**
- * Transformer for {@link https://discord.js.org | discord.js} objects.
+ * Transformer for raw Discord API payloads, the objects typed by
+ * {@link https://discord-api-types.dev | discord-api-types}.
*
- * @typeParam T - The base type.
+ * Every subclass answers with a fixed list of keys, so a template can read `{member.displayName}` and has no
+ * way to reach a client, a token or a method.
+ *
+ * @typeParam T - The payload type.
*/
-export abstract class BaseTransformer<
- T extends CommandInteraction | Guild | GuildChannel | GuildMember | Role | User,
-> implements ITransformer {
+export abstract class BaseTransformer implements ITransformer {
protected base: T;
protected safeValues: SafeValues = {};
public constructor(base: T, safeValues: SafeValues = {}) {
this.base = base;
- this.safeValues.id = this.base.id;
- this.safeValues.mention = base.toString();
- this.safeValues.name = 'name' in base ? base.name : '';
+ this.safeValues.id = this.resolveId();
+ this.safeValues.mention = this.resolveMention();
+ this.safeValues.name = 'name' in base ? (base.name as outputResolvable) : '';
this.updateSafeValues();
this.safeValues = { ...this.safeValues, ...safeValues };
}
@@ -46,6 +46,16 @@ export abstract class BaseTransformer<
return this.safeValues;
}
+ /**
+ * The snowflake this payload is identified by. Read as `{thing.id}`.
+ */
+ protected abstract resolveId(): string;
+
+ /**
+ * What a bare `{thing}` renders to.
+ */
+ protected abstract resolveMention(): string;
+
protected updateSafeValues() {
//
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/Guild.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/Guild.ts
index be878409..fa4c89c2 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/Guild.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/Guild.ts
@@ -1,9 +1,12 @@
import { BaseTransformer } from './Base';
-import type { Guild } from 'discord.js';
+import { guildBannerURL, guildIconURL, guildSplashURL } from '../Utils/cdn';
+import { snowflakeDate, snowflakeTimestamp } from '../Utils/snowflake';
+
+import type { APIGuild } from 'discord-api-types/v10';
/**
- * Transformer for Discord {@link Guild}.
+ * Transformer for a Discord {@link APIGuild} payload.
*
* Properties:
* ```yaml
@@ -17,26 +20,24 @@ import type { Guild } from 'discord.js';
* ownerId: Gives guild owner id.
* createdAt: Gives guild create date.
* createdTimestamp: Gives guild create date in ms.
- * large: Gives true if the guild is large else false.
- * memberCount: Gives guild member count.
- * random: Gives random guild member.
- * roles: Gives guild roles.
+ * memberCount: Gives guild member count, when the payload was fetched with counts.
+ * roles: Mentions each guild role.
* roleIds: Gives guild roles ids.
* roleNames: Gives guild roles names.
* roleCount: Gives guild roles count.
- * channels: Gives guild channels.
- * channelIds: Gives guild channels ids.
- * channelNames: Gives guild channels names.
- * channelCount: Gives guild channels count.
* emojiCount: Gives guild emojis count.
* stickerCount: Gives guild stickers count.
- * bots: Gives guild bots count.
- * humans: Gives guild humans count.
* afkTimeout: Gives guild afk timeout.
- * afkChannel: Gives guild afk channel.
+ * afkChannel: Mentions the guild afk channel.
* verificationLevel: Gives guild verification level.
* ```
*
+ * @remarks
+ * You need to use `StrictVarsParser` parser to use this transformer.
+ *
+ * `memberCount` reads `approximate_member_count`, which Discord only sends when you ask for it with
+ * `with_counts`. Channels are a separate endpoint and are not part of a guild payload, so pass counts you
+ * want a template to see: `new GuildTransformer(guild, { channelCount: channels.length })`.
* @example
* ```ts showLineNumbers
* import { Interpreter, StrictVarsParser } from 'tagscript';
@@ -44,40 +45,37 @@ import type { Guild } from 'discord.js';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('server name: {guild.name}', { guild: new GuildTransformer(interaction.guild) });
+ * await ts.run('server name: {guild.name}', { guild: new GuildTransformer(guild) });
* // server name: My Server
* ```
- * @remarks
- * Some properties like `emojiCount`, `stickerCount`, `bots`, `humans` depends on cache so it might be inaccurate.
- * You need to use `StrictVarsParser` parser to use this transformer.
*/
-export class GuildTransformer extends BaseTransformer {
+export class GuildTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return this.base.name;
+ }
+
protected override updateSafeValues() {
this.safeValues.description = this.base.description;
- this.safeValues.icon = this.base.iconURL();
- this.safeValues.splash = this.base.splashURL();
- this.safeValues.banner = this.base.bannerURL();
+ this.safeValues.icon = this.base.icon ? guildIconURL(this.base.id, this.base.icon) : '';
+ this.safeValues.splash = this.base.splash ? guildSplashURL(this.base.id, this.base.splash) : '';
+ this.safeValues.banner = this.base.banner ? guildBannerURL(this.base.id, this.base.banner) : '';
this.safeValues.features = this.base.features.join(' ') || '`None`';
- this.safeValues.ownerId = this.base.ownerId;
- this.safeValues.createdAt = this.base.createdAt.toISOString();
- this.safeValues.createdTimestamp = this.base.createdTimestamp;
- this.safeValues.large = this.base.large;
- this.safeValues.memberCount = this.base.memberCount;
- this.safeValues.random = this.base.members.cache.random()?.toString() ?? '';
- this.safeValues.roles = this.base.roles.cache.map((role) => role).join(' ');
- this.safeValues.roleIds = this.base.roles.cache.map((role) => role.id).join(', ');
- this.safeValues.roleNames = this.base.roles.cache.map((role) => role.name).join(', ');
- this.safeValues.roleCount = this.base.roles.cache.size;
- this.safeValues.channels = this.base.channels.cache.map((channel) => channel).join(' ') || '`None`';
- this.safeValues.channelIds = this.base.channels.cache.map((channel) => channel.id).join(', ') || '`None`';
- this.safeValues.channelNames = this.base.channels.cache.map((channel) => channel.name).join(', ') || '`None`';
- this.safeValues.channelCount = this.base.channels.cache.size;
- this.safeValues.emojiCount = this.base.emojis.cache.size;
- this.safeValues.stickerCount = this.base.stickers.cache.size;
- this.safeValues.bots = this.base.members.cache.filter((member) => member.user.bot).size;
- this.safeValues.humans = this.base.members.cache.filter((member) => !member.user.bot).size;
- this.safeValues.afkTimeout = this.base.afkTimeout;
- this.safeValues.afkChannel = `${this.base.afkChannel}`;
- this.safeValues.verificationLevel = this.base.verificationLevel;
+ this.safeValues.ownerId = this.base.owner_id;
+ this.safeValues.createdAt = snowflakeDate(this.base.id);
+ this.safeValues.createdTimestamp = snowflakeTimestamp(this.base.id);
+ this.safeValues.memberCount = this.base.approximate_member_count ?? '';
+ this.safeValues.roles = this.base.roles.map((role) => `<@&${role.id}>`).join(' ');
+ this.safeValues.roleIds = this.base.roles.map((role) => role.id).join(', ');
+ this.safeValues.roleNames = this.base.roles.map((role) => role.name).join(', ');
+ this.safeValues.roleCount = this.base.roles.length;
+ this.safeValues.emojiCount = this.base.emojis.length;
+ this.safeValues.stickerCount = this.base.stickers?.length ?? 0;
+ this.safeValues.afkTimeout = this.base.afk_timeout;
+ this.safeValues.afkChannel = this.base.afk_channel_id ? `<#${this.base.afk_channel_id}>` : '';
+ this.safeValues.verificationLevel = this.base.verification_level;
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/GuildMember.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/GuildMember.ts
index 12bd44e9..2688e661 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/GuildMember.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/GuildMember.ts
@@ -1,9 +1,12 @@
import { BaseTransformer } from './Base';
-import type { GuildMember } from 'discord.js';
+import { defaultUserAvatarURL, userAvatarURL } from '../Utils/cdn';
+import { snowflakeDate, snowflakeTimestamp } from '../Utils/snowflake';
+
+import type { GuildMember } from '../interfaces';
/**
- * Transformer for Discord {@link GuildMember}.
+ * Transformer for a Discord {@link GuildMember} payload.
*
* Properties:
* ```yaml
@@ -11,28 +14,36 @@ import type { GuildMember } from 'discord.js';
* mention: Mentions the member.
* username: Gives username of the member.
* discriminator: Gives discriminator of the member
- * tag: Gives username#discriminator
+ * tag: Gives username#discriminator for legacy accounts, username otherwise.
* avatar: Gives member's custom avatar if they have one. Else it'll be an empty string.
* displayAvatar: Gives member's avatar URL if they have one else gives member's default avatar.
* nickname: Gives member's nickname.
- * displayName: Gives member's display name. (nickname if they have one else username)
+ * displayName: Gives member's display name. (nickname, then global name, then username)
* joinedAt: Gives member's join date.
* joinedTimestamp: Gives member's join date in ms
* createdAt: Gives member's account create date.
* createdTimestamp: Gives member's account created date in ms
* bot: Gives true if the member is a bot else false.
- * color: Gives member's highest role color.
- * position: Gives member's highest role position.
- * roles: Gives member's roles.
+ * roles: Mentions each of the member's roles.
* roleIds: Gives member's roles ids.
- * roleNames: Gives member's roles names.
- * topRole: Gives member's highest role name.
* timeoutUntil: Gives member's timeout until date.
* timeoutUntilTimestamp: Gives member's timeout until date in ms.
* ```
*
* @remarks
* You need to use `StrictVarsParser` parser to use this transformer.
+ *
+ * A member payload carries role ids, not role objects, and no guild id. Role names, the top role, the member
+ * colour and the per-guild avatar therefore need the guild alongside the member, so pass them yourself:
+ *
+ * ```ts showLineNumbers
+ * const roles = guild.roles.filter((role) => member.roles.includes(role.id));
+ *
+ * new MemberTransformer(member, {
+ * roleNames: roles.map((role) => role.name).join(', '),
+ * topRole: roles.reduce((highest, role) => (role.position > highest.position ? role : highest)).name,
+ * });
+ * ```
* @example
* ```ts showLineNumbers
* import { Interpreter, StrictVarsParser } from 'tagscript';
@@ -40,31 +51,39 @@ import type { GuildMember } from 'discord.js';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('Hi {member}', { member: new MemberTransformer(GuildMember) });
+ * await ts.run('Hi {member}', { member: new MemberTransformer(member) });
* // Hi <@758880890159235083>
* ```
*/
export class MemberTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.user.id;
+ }
+
+ protected resolveMention() {
+ return `<@${this.base.user.id}>`;
+ }
+
protected override updateSafeValues() {
- this.safeValues.username = this.base.user.username;
- this.safeValues.discriminator = this.base.user.discriminator;
- this.safeValues.tag = this.base.user.tag;
- this.safeValues.avatar = this.base.avatarURL();
- this.safeValues.displayAvatar = this.base.displayAvatarURL();
- this.safeValues.nickname = this.base.nickname;
- this.safeValues.displayName = this.base.displayName;
- this.safeValues.joinedAt = this.base.joinedAt?.toISOString() ?? '';
- this.safeValues.joinedTimestamp = this.base.joinedTimestamp;
- this.safeValues.createdAt = this.base.user.createdAt.toISOString();
- this.safeValues.createdTimestamp = this.base.user.createdTimestamp;
- this.safeValues.bot = this.base.user.bot;
- this.safeValues.color = this.base.roles.color?.hexColor ?? '';
- this.safeValues.position = this.base.roles.highest.position;
- this.safeValues.roles = this.base.roles.cache.map((role) => role).join(' ');
- this.safeValues.roleIds = this.base.roles.cache.map((role) => role.id).join(', ');
- this.safeValues.roleNames = this.base.roles.cache.map((role) => role.name).join(', ');
- this.safeValues.topRole = this.base.roles.highest.name;
- this.safeValues.timeoutUntil = this.base.communicationDisabledUntil?.toISOString() ?? '';
- this.safeValues.timeoutUntilTimestamp = this.base.communicationDisabledUntilTimestamp;
+ const { user } = this.base;
+
+ this.safeValues.username = user.username;
+ this.safeValues.discriminator = user.discriminator;
+ this.safeValues.tag = user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`;
+ this.safeValues.avatar = user.avatar ? userAvatarURL(user.id, user.avatar) : '';
+ this.safeValues.displayAvatar = this.safeValues.avatar || defaultUserAvatarURL(user.id);
+ this.safeValues.nickname = this.base.nick ?? '';
+ this.safeValues.displayName = this.base.nick ?? user.global_name ?? user.username;
+ this.safeValues.joinedAt = this.base.joined_at ?? '';
+ this.safeValues.joinedTimestamp = this.base.joined_at ? Date.parse(this.base.joined_at) : null;
+ this.safeValues.createdAt = snowflakeDate(user.id);
+ this.safeValues.createdTimestamp = snowflakeTimestamp(user.id);
+ this.safeValues.bot = user.bot ?? false;
+ this.safeValues.roles = this.base.roles.map((id) => `<@&${id}>`).join(' ');
+ this.safeValues.roleIds = this.base.roles.join(', ');
+ this.safeValues.timeoutUntil = this.base.communication_disabled_until ?? '';
+ this.safeValues.timeoutUntilTimestamp = this.base.communication_disabled_until
+ ? Date.parse(this.base.communication_disabled_until)
+ : null;
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/GuildTextBasedChannel.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/GuildTextBasedChannel.ts
index 998e0fb1..4b518282 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/GuildTextBasedChannel.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/GuildTextBasedChannel.ts
@@ -1,9 +1,22 @@
import { BaseTransformer } from './Base';
+import { snowflakeDate, snowflakeTimestamp } from '../Utils/snowflake';
+
import type { GuildChannel } from '../interfaces';
/**
- * Transformer for Discord {@link GuildChannel}
+ * The fields only some channel types carry. Reading them off one type covers every channel a guild can hold.
+ */
+type OptionalChannelFields = Partial<{
+ nsfw: boolean;
+ parent_id: string | null;
+ position: number;
+ rate_limit_per_user: number;
+ topic: string | null;
+}>;
+
+/**
+ * Transformer for a Discord {@link GuildChannel} payload.
*
* Properties:
* ```yaml
@@ -15,9 +28,6 @@ import type { GuildChannel } from '../interfaces';
* position: Gives channel position.
* nsfw: Gives true if the channel is nsfw else false.
* parentId: Gives channel parent id.
- * parentName: Gives channel parent name.
- * parentType: Gives channel parent type.
- * parentPosition: Gives channel parent position.
* createdAt: Gives channel create date.
* createdTimestamp: Gives channel create date in ms.
* slowmode: Gives channel slowmode.
@@ -25,6 +35,9 @@ import type { GuildChannel } from '../interfaces';
*
* @remarks
* You need to use `StrictVarsParser` parser to use this transformer.
+ *
+ * A channel payload carries `parentId` and nothing else about the category, so pass the category yourself if
+ * a template needs its name: `new ChannelTransformer(channel, { parentName: parent.name })`.
* @example
* ```ts showLineNumbers
* import { Interpreter, StrictVarsParser } from 'tagscript';
@@ -32,27 +45,29 @@ import type { GuildChannel } from '../interfaces';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('channel: {channel}', { channel: new ChannelTransformer(message.channel) });
+ * await ts.run('channel: {channel}', { channel: new ChannelTransformer(channel) });
* // channel: <#870354581115256852>
* ```
*/
export class ChannelTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return `<#${this.base.id}>`;
+ }
+
protected override updateSafeValues() {
- this.safeValues.topic = 'topic' in this.base ? this.base.topic : '';
- this.safeValues.type = this.base.type;
- this.safeValues.position = 'position' in this.base ? this.base.position : 0;
- this.safeValues.nsfw =
- 'nsfw' in this.base
- ? this.base.nsfw
- : this.base.parent && 'nsfw' in this.base.parent
- ? this.base.parent.nsfw
- : false;
- this.safeValues.parentId = this.base.parentId;
- this.safeValues.parentName = this.base.parent?.name ?? '';
- this.safeValues.parentType = this.base.parent?.type ?? '';
- this.safeValues.parentPosition = this.base.parent?.position ?? 0;
- this.safeValues.createdAt = this.base.createdAt?.toISOString() ?? '';
- this.safeValues.createdTimestamp = this.base.createdTimestamp;
- this.safeValues.slowmode = 'rateLimitPerUser' in this.base ? this.base.rateLimitPerUser : 0;
+ const channel = this.base as GuildChannel & OptionalChannelFields;
+
+ this.safeValues.topic = channel.topic ?? '';
+ this.safeValues.type = channel.type;
+ this.safeValues.position = channel.position ?? 0;
+ this.safeValues.nsfw = channel.nsfw ?? false;
+ this.safeValues.parentId = channel.parent_id ?? null;
+ this.safeValues.createdAt = snowflakeDate(channel.id);
+ this.safeValues.createdTimestamp = snowflakeTimestamp(channel.id);
+ this.safeValues.slowmode = channel.rate_limit_per_user ?? 0;
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/Interaction.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/Interaction.ts
index e128a090..a0fa4d75 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/Interaction.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/Interaction.ts
@@ -1,31 +1,54 @@
import { BaseTransformer } from './Base';
-import type { CommandInteraction } from 'discord.js';
+import type { APIApplicationCommandInteraction } from 'discord-api-types/v10';
/**
- * Transformer for Discord {@link CommandInteraction}
+ * Transformer for a Discord {@link APIApplicationCommandInteraction} payload.
+ *
+ * Properties:
+ * ```yaml
+ * id: Gives interaction id.
+ * name: Gives the command name.
+ * mention: Renders a clickable command mention.
+ * applicationId: Gives the application id.
+ * channelId: Gives the channel id.
+ * guildId: Gives the guild id.
+ * commandId: Gives the command id.
+ * commandName: Gives the command name.
+ * locale: Gives the user's locale.
+ * guildLocale: Gives the guild's locale.
+ * ```
*
* @remarks
* You need to use `StrictVarsParser` parser to use this transformer.
- * @example
+ * @example
* ```ts showLineNumbers
* import { Interpreter, StrictVarsParser } from 'tagscript';
* import { InteractionTransformer } from '@tagscript/plugin-discord';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('You've used the command `{command.name}`', { command: new InteractionTransformer(Role) });
- * // You've used the command `ping`
+ * await ts.run('You used the {command.commandName} command', { command: new InteractionTransformer(interaction) });
+ * // You used the ping command
* ```
*/
-export class InteractionTransformer extends BaseTransformer {
+export class InteractionTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return `${this.base.data.name}:${this.base.data.id}>`;
+ }
+
protected override updateSafeValues() {
- this.safeValues.applicationId = this.base.applicationId;
- this.safeValues.channelId = this.base.channelId;
- this.safeValues.guildId = this.base.guildId;
- this.safeValues.commandId = this.base.commandId;
- this.safeValues.commandName = this.base.commandName;
+ this.safeValues.name = this.base.data.name;
+ this.safeValues.applicationId = this.base.application_id;
+ this.safeValues.channelId = this.base.channel.id;
+ this.safeValues.guildId = this.base.guild_id ?? '';
+ this.safeValues.commandId = this.base.data.id;
+ this.safeValues.commandName = this.base.data.name;
this.safeValues.locale = this.base.locale;
- this.safeValues.guildLocale = this.base.guildLocale;
+ this.safeValues.guildLocale = this.base.guild_locale ?? '';
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/Role.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/Role.ts
index e1d9ca3b..45ef1ad5 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/Role.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/Role.ts
@@ -1,9 +1,22 @@
+import { PermissionFlagsBits } from 'discord-api-types/v10';
+
import { BaseTransformer } from './Base';
-import type { Role } from 'discord.js';
+import { snowflakeDate, snowflakeTimestamp } from '../Utils/snowflake';
+
+import type { APIRole } from 'discord-api-types/v10';
+
+const permissionNames = (permissions: string) => {
+ const bits = BigInt(permissions);
+
+ return Object.entries(PermissionFlagsBits)
+ .filter(([, bit]) => (bits & bit) === bit)
+ .map(([name]) => name)
+ .join(', ');
+};
/**
- * Transformer for Discord {@link Role}.
+ * Transformer for a Discord {@link APIRole} payload.
*
* Properties:
* ```yaml
@@ -17,11 +30,13 @@ import type { Role } from 'discord.js';
* permissions: Gives role permissions.
* createdAt: Gives role create date.
* createdTimestamp: Gives role create date in ms.
- * memberCount: Gives role member count.
* ```
*
* @remarks
* You need to use `StrictVarsParser` parser to use this transformer.
+ *
+ * A role payload does not say who holds the role, so pass a member count yourself if a template needs one:
+ * `new RoleTransformer(role, { memberCount: members.filter((member) => member.roles.includes(role.id)).length })`.
* @example
* ```ts showLineNumbers
* import { Interpreter, StrictVarsParser } from 'tagscript';
@@ -29,19 +44,26 @@ import type { Role } from 'discord.js';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('Ping {role}', { role: new RoleTransformer(Role) });
+ * await ts.run('Ping {role}', { role: new RoleTransformer(role) });
* // Ping <@&868430685231271966>
* ```
*/
-export class RoleTransformer extends BaseTransformer {
+export class RoleTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return `<@&${this.base.id}>`;
+ }
+
protected override updateSafeValues() {
this.safeValues.color = this.base.color.toString();
this.safeValues.hoist = this.base.hoist;
this.safeValues.mentionable = this.base.mentionable;
this.safeValues.position = this.base.position;
- this.safeValues.permissions = this.base.permissions.toArray().join(', ');
- this.safeValues.createdAt = this.base.createdAt.toISOString();
- this.safeValues.createdTimestamp = this.base.createdTimestamp;
- this.safeValues.memberCount = this.base.members.size;
+ this.safeValues.permissions = permissionNames(this.base.permissions);
+ this.safeValues.createdAt = snowflakeDate(this.base.id);
+ this.safeValues.createdTimestamp = snowflakeTimestamp(this.base.id);
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Transformer/User.ts b/packages/tagscript-plugin-discord/src/lib/Transformer/User.ts
index dd72d8c6..62703bf0 100644
--- a/packages/tagscript-plugin-discord/src/lib/Transformer/User.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Transformer/User.ts
@@ -1,9 +1,12 @@
import { BaseTransformer } from './Base';
-import type { User } from 'discord.js';
+import { defaultUserAvatarURL, userAvatarURL } from '../Utils/cdn';
+import { snowflakeDate, snowflakeTimestamp } from '../Utils/snowflake';
+
+import type { APIUser } from 'discord-api-types/v10';
/**
- * Transformer for Discord {@link User}.
+ * Transformer for a Discord {@link APIUser} payload.
*
* Properties:
* ```yaml
@@ -12,7 +15,7 @@ import type { User } from 'discord.js';
* globalName: Gives user's global name.
* username: Gives username of the user.
* discriminator: Gives discriminator of the user
- * tag: Gives username#discriminator
+ * tag: Gives username#discriminator for legacy accounts, username otherwise.
* avatar: Gives user's custom avatar if they have one. Else it'll be an empty string.
* displayAvatar: Gives user's avatar URL if they have one else gives user's default avatar.
* createdAt: Gives user's account create date.
@@ -29,20 +32,29 @@ import type { User } from 'discord.js';
*
* const ts = new Interpreter(new StrictVarsParser());
*
- * await ts.run('Hi {user}', { user: new UserTransformer(message.author) });
+ * await ts.run('Hi {user}', { user: new UserTransformer(user) });
* // Hi <@758880890159235083>
* ```
*/
-export class UserTransformer extends BaseTransformer {
+export class UserTransformer extends BaseTransformer {
+ protected resolveId() {
+ return this.base.id;
+ }
+
+ protected resolveMention() {
+ return `<@${this.base.id}>`;
+ }
+
protected override updateSafeValues() {
- this.safeValues.globalName = this.base.globalName;
+ this.safeValues.globalName = this.base.global_name;
this.safeValues.username = this.base.username;
this.safeValues.discriminator = this.base.discriminator;
- this.safeValues.tag = this.base.tag;
- this.safeValues.avatar = this.base.avatarURL();
- this.safeValues.displayAvatar = this.base.displayAvatarURL();
- this.safeValues.createdAt = this.base.createdAt.toISOString();
- this.safeValues.createdTimestamp = this.base.createdTimestamp;
- this.safeValues.bot = this.base.bot;
+ this.safeValues.tag =
+ this.base.discriminator === '0' ? this.base.username : `${this.base.username}#${this.base.discriminator}`;
+ this.safeValues.avatar = this.base.avatar ? userAvatarURL(this.base.id, this.base.avatar) : '';
+ this.safeValues.displayAvatar = this.safeValues.avatar || defaultUserAvatarURL(this.base.id);
+ this.safeValues.createdAt = snowflakeDate(this.base.id);
+ this.safeValues.createdTimestamp = snowflakeTimestamp(this.base.id);
+ this.safeValues.bot = this.base.bot ?? false;
}
}
diff --git a/packages/tagscript-plugin-discord/src/lib/Utils/CommandInteraction.ts b/packages/tagscript-plugin-discord/src/lib/Utils/CommandInteraction.ts
index b733db20..e89aee10 100644
--- a/packages/tagscript-plugin-discord/src/lib/Utils/CommandInteraction.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Utils/CommandInteraction.ts
@@ -1,98 +1,109 @@
-import {
- ApplicationCommandOptionType,
- BaseChannel,
- GuildMember,
- Role,
- User,
- type CommandInteractionOption,
- type CommandInteractionOptionResolver,
-} from 'discord.js';
+import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { IntegerTransformer, StringTransformer, type ITransformer } from 'tagscript';
import { ChannelTransformer, MemberTransformer, RoleTransformer, UserTransformer } from '../Transformer';
+import type {
+ APIApplicationCommandInteractionDataOption,
+ APIChatInputApplicationCommandInteractionData,
+ APIInteractionDataResolved,
+} from 'discord-api-types/v10';
+
+const userTransformer = (id: string, resolved: APIInteractionDataResolved) => {
+ const user = resolved.users?.[id];
+ if (!user) return null;
+
+ const member = resolved.members?.[id];
+ return member ? new MemberTransformer({ ...member, user }) : new UserTransformer(user);
+};
+
+/**
+ * Maps a slash command's options onto transformers, following subcommands into the same flat object.
+ *
+ * @param options - The options to map
+ * @param resolved - The interaction's resolved data, which holds the objects the options point at
+ * @param transformers - The object to write the transformers onto
+ * @param prefix - Prepended to every option name, used to namespace subcommand options
+ */
export const mapOptions = (
- options: readonly CommandInteractionOption[],
+ options: readonly APIApplicationCommandInteractionDataOption[],
+ resolved: APIInteractionDataResolved,
transformers: Record,
prefix = '',
) => {
- for (const data of options) {
- switch (data.type) {
+ for (const option of options) {
+ const name = prefix + option.name;
+
+ switch (option.type) {
case ApplicationCommandOptionType.SubcommandGroup:
- transformers.subCommandGroup = new StringTransformer(String(data.value ?? data.name));
- mapOptions(data.options!, transformers, `${data.name}-`);
+ transformers.subCommandGroup = new StringTransformer(option.name);
+ mapOptions(option.options, resolved, transformers, `${option.name}-`);
break;
case ApplicationCommandOptionType.Subcommand:
- transformers.subCommand = new StringTransformer(String(data.value ?? data.name));
- mapOptions(data.options!, transformers, `${prefix}${data.name}-`);
+ transformers.subCommand = new StringTransformer(option.name);
+ mapOptions(option.options ?? [], resolved, transformers, `${prefix}${option.name}-`);
break;
case ApplicationCommandOptionType.String:
- transformers[prefix + data.name] = new StringTransformer(String(data.value));
- break;
case ApplicationCommandOptionType.Boolean:
- transformers[prefix + data.name] = new StringTransformer(String(data.value));
+ transformers[name] = new StringTransformer(String(option.value));
break;
case ApplicationCommandOptionType.Integer:
- transformers[prefix + data.name] = new IntegerTransformer(data.value as `${number}`);
- break;
case ApplicationCommandOptionType.Number:
- transformers[prefix + data.name] = new IntegerTransformer(data.value as `${number}`);
+ transformers[name] = new IntegerTransformer(`${option.value}` as `${number}`);
break;
- case ApplicationCommandOptionType.Mentionable:
- transformers[prefix + data.name] =
- data.member instanceof GuildMember
- ? new MemberTransformer(data.member)
- : data.role instanceof Role
- ? new RoleTransformer(data.role)
- : data.user instanceof User
- ? new UserTransformer(data.user)
- : // FIXME: added only for test. Will be removed after rewriting these tests
- new StringTransformer(data.value as string);
+ case ApplicationCommandOptionType.User: {
+ const transformer = userTransformer(option.value, resolved);
+ if (transformer) transformers[name] = transformer;
break;
- case ApplicationCommandOptionType.User:
- transformers[prefix + data.name] =
- data.member instanceof GuildMember
- ? new MemberTransformer(data.member)
- : data.user
- ? new UserTransformer(data.user)
- : // FIXME: added only for test. Will be removed after rewriting these tests
- new StringTransformer(data.value as string);
+ }
+
+ case ApplicationCommandOptionType.Mentionable: {
+ const role = resolved.roles?.[option.value];
+ const transformer = role ? new RoleTransformer(role) : userTransformer(option.value, resolved);
+ if (transformer) transformers[name] = transformer;
break;
- case ApplicationCommandOptionType.Role:
- if (data.role instanceof Role) transformers[prefix + data.name] = new RoleTransformer(data.role);
+ }
+
+ case ApplicationCommandOptionType.Role: {
+ const role = resolved.roles?.[option.value];
+ if (role) transformers[name] = new RoleTransformer(role);
break;
- case ApplicationCommandOptionType.Channel:
- if (data.channel instanceof BaseChannel)
- transformers[prefix + data.name] = new ChannelTransformer(data.channel);
+ }
+
+ case ApplicationCommandOptionType.Channel: {
+ const channel = resolved.channels?.[option.value];
+ if (channel) transformers[name] = new ChannelTransformer(channel);
break;
- case ApplicationCommandOptionType.Attachment:
- transformers[prefix + data.name] = new StringTransformer(data.attachment!.url);
+ }
+
+ case ApplicationCommandOptionType.Attachment: {
+ const attachment = resolved.attachments?.[option.value];
+ if (attachment) transformers[name] = new StringTransformer(attachment.url);
+ }
}
}
};
/**
- *
- * Resolves {@link CommandInteractionOptionResolver} options to transformers.
+ * Turns a chat input command's data into transformers, ready to pass to `run` as seed variables. Whatever the
+ * user typed into the command becomes readable by name from the template.
*
* @example
* ```ts showLineNumbers
- * client.on('interactionCreate', async interaction => {
- * if (!interaction.isCommand()) return;
+ * client.on('interactionCreate', async (interaction) => {
+ * if (!interaction.isChatInputCommand()) return;
*
- * if (interaction.commandName === 'ping') {
- * const result = await ts.run(str, resolveCommandOptions(interaction.options));
- * await interaction.reply(result.body);
- * }
+ * const result = await ts.run(template, resolveCommandOptions(data));
+ * await interaction.reply(result.body);
* });
* ```
+ * @param data - The command data off the interaction payload
+ * @returns One transformer per option, keyed by option name
*/
-export const resolveCommandOptions = (options: Omit) => {
- const optionData = options.data;
-
+export const resolveCommandOptions = (data: APIChatInputApplicationCommandInteractionData) => {
const transformers: Record = {};
- mapOptions(optionData, transformers);
+ mapOptions(data.options ?? [], data.resolved ?? {}, transformers);
return transformers;
};
diff --git a/packages/tagscript-plugin-discord/src/lib/Utils/cdn.ts b/packages/tagscript-plugin-discord/src/lib/Utils/cdn.ts
new file mode 100644
index 00000000..d9a351a2
--- /dev/null
+++ b/packages/tagscript-plugin-discord/src/lib/Utils/cdn.ts
@@ -0,0 +1,15 @@
+import type { Snowflake } from 'discord-api-types/v10';
+
+const CDN = 'https://cdn.discordapp.com';
+
+const extensionFor = (hash: string) => (hash.startsWith('a_') ? 'gif' : 'webp');
+
+export const userAvatarURL = (id: Snowflake, hash: string) => `${CDN}/avatars/${id}/${hash}.${extensionFor(hash)}`;
+
+export const defaultUserAvatarURL = (id: Snowflake) => `${CDN}/embed/avatars/${Number((BigInt(id) >> 22n) % 6n)}.png`;
+
+export const guildIconURL = (id: Snowflake, hash: string) => `${CDN}/icons/${id}/${hash}.${extensionFor(hash)}`;
+
+export const guildSplashURL = (id: Snowflake, hash: string) => `${CDN}/splashes/${id}/${hash}.webp`;
+
+export const guildBannerURL = (id: Snowflake, hash: string) => `${CDN}/banners/${id}/${hash}.${extensionFor(hash)}`;
diff --git a/packages/tagscript-plugin-discord/src/lib/Utils/resolveColor.ts b/packages/tagscript-plugin-discord/src/lib/Utils/resolveColor.ts
index 551b47ed..8258b648 100644
--- a/packages/tagscript-plugin-discord/src/lib/Utils/resolveColor.ts
+++ b/packages/tagscript-plugin-discord/src/lib/Utils/resolveColor.ts
@@ -1,15 +1,63 @@
-import { resolveColor as DJSResolveColor, type ColorResolvable } from 'discord.js';
+/**
+ * The colour names Discord clients ship with, matching the names discord.js exposes.
+ */
+export const Colors = {
+ Default: 0x00_00_00,
+ White: 0xff_ff_ff,
+ Aqua: 0x1a_bc_9c,
+ Green: 0x57_f2_87,
+ Blue: 0x34_98_db,
+ Yellow: 0xfe_e7_5c,
+ Purple: 0x9b_59_b6,
+ LuminousVividPink: 0xe9_1e_63,
+ Fuchsia: 0xeb_45_9e,
+ Gold: 0xf1_c4_0f,
+ Orange: 0xe6_7e_22,
+ Red: 0xed_42_45,
+ Grey: 0x95_a5_a6,
+ Navy: 0x34_49_5e,
+ DarkAqua: 0x11_80_6a,
+ DarkGreen: 0x1f_8b_4c,
+ DarkBlue: 0x20_66_94,
+ DarkPurple: 0x71_36_8a,
+ DarkVividPink: 0xad_14_57,
+ DarkGold: 0xc2_7c_0e,
+ DarkOrange: 0xa8_43_00,
+ DarkRed: 0x99_2d_22,
+ DarkGrey: 0x97_9c_9f,
+ DarkerGrey: 0x7f_8c_8d,
+ LightGrey: 0xbc_c0_c0,
+ DarkNavy: 0x2c_3e_50,
+ Blurple: 0x58_65_f2,
+ Greyple: 0x99_aa_b5,
+ DarkButNotBlack: 0x2c_2f_33,
+ NotQuiteBlack: 0x23_27_2a,
+} as const satisfies Record;
+
+const HEX = /^(?:#|0x)?(?[\da-f]{6})$/i;
+const MAX = 0xff_ff_ff;
/**
- * Resolves a color to a number. This function doesn't throw for invalid colors but returns the input
+ * Resolves a colour to the integer Discord expects. Unresolvable input is returned untouched instead of
+ * throwing, so one bad colour in a template does not end the render.
+ *
+ * Accepts a name from {@link Colors}, `Random`, `#ed4245`, `0xed4245`, `ed4245` or a decimal number.
*
- * @param color - The color to resolve
- * @returns
+ * @param color - The colour to resolve
+ * @returns The colour as a number, or the input when it resolves to nothing
*/
-export const resolveColor = (color: string): number | string => {
- try {
- return Number(color) || DJSResolveColor(color as ColorResolvable);
- } catch {
- return color;
- }
+export const resolveColor = (color: number | string): number | string => {
+ if (typeof color === 'number') return color;
+
+ const trimmed = color.trim();
+ if (trimmed === 'Random') return Math.floor(Math.random() * (MAX + 1));
+ if (Object.hasOwn(Colors, trimmed)) return Colors[trimmed as keyof typeof Colors];
+
+ const hex = HEX.exec(trimmed)?.groups?.hex;
+ if (hex) return Number.parseInt(hex, 16);
+
+ const decimal = Number(trimmed);
+ if (trimmed !== '' && Number.isInteger(decimal) && decimal >= 0 && decimal <= MAX) return decimal;
+
+ return color;
};
diff --git a/packages/tagscript-plugin-discord/src/lib/Utils/snowflake.ts b/packages/tagscript-plugin-discord/src/lib/Utils/snowflake.ts
new file mode 100644
index 00000000..68f0376d
--- /dev/null
+++ b/packages/tagscript-plugin-discord/src/lib/Utils/snowflake.ts
@@ -0,0 +1,19 @@
+import type { Snowflake } from 'discord-api-types/v10';
+
+const DISCORD_EPOCH = 1_420_070_400_000n;
+
+/**
+ * Reads the creation time out of a snowflake.
+ *
+ * @param id - The snowflake to read
+ * @returns The creation time in milliseconds since the Unix epoch
+ */
+export const snowflakeTimestamp = (id: Snowflake) => Number((BigInt(id) >> 22n) + DISCORD_EPOCH);
+
+/**
+ * Reads the creation date out of a snowflake.
+ *
+ * @param id - The snowflake to read
+ * @returns The creation date as an ISO string
+ */
+export const snowflakeDate = (id: Snowflake) => new Date(snowflakeTimestamp(id)).toISOString();
diff --git a/packages/tagscript-plugin-discord/src/lib/interfaces/index.ts b/packages/tagscript-plugin-discord/src/lib/interfaces/index.ts
index 041651be..bfcc3aaa 100644
--- a/packages/tagscript-plugin-discord/src/lib/interfaces/index.ts
+++ b/packages/tagscript-plugin-discord/src/lib/interfaces/index.ts
@@ -1,5 +1,13 @@
-import type { EmbedData, APIEmbed, Channel, Guild } from 'discord.js';
import 'tagscript';
+import type {
+ APIEmbed,
+ APIGuildChannel,
+ APIGuildMember,
+ APIInteractionDataResolvedChannel,
+ APIInteractionDataResolvedGuildMember,
+ APIUser,
+ GuildChannelType,
+} from 'discord-api-types/v10';
declare module 'tagscript' {
export interface IActions {
@@ -8,10 +16,20 @@ declare module 'tagscript' {
message: string | null;
};
deleteMessage?: boolean;
- embed?: APIEmbed | EmbedData;
+ embed?: APIEmbed;
files?: string[];
silentResponse?: boolean;
}
}
-export type GuildChannel = Extract;
+/**
+ * A channel that lives in a guild, either the full payload or the trimmed one Discord attaches to an
+ * interaction's resolved data.
+ */
+export type GuildChannel = APIGuildChannel | APIInteractionDataResolvedChannel;
+
+/**
+ * A guild member payload with its user attached. Discord leaves `user` out of the members it resolves into an
+ * interaction, so add it back from `resolved.users` before handing a member to {@link MemberTransformer}.
+ */
+export type GuildMember = (APIGuildMember | APIInteractionDataResolvedGuildMember) & { user: APIUser };
diff --git a/packages/tagscript-plugin-discord/tests/Parsers/Embed.test.ts b/packages/tagscript-plugin-discord/tests/Parsers/Embed.test.ts
index 6044a06d..a7a4818f 100644
--- a/packages/tagscript-plugin-discord/tests/Parsers/Embed.test.ts
+++ b/packages/tagscript-plugin-discord/tests/Parsers/Embed.test.ts
@@ -97,6 +97,49 @@ describe('EmbedParser', () => {
});
});
+ test('GIVEN image or thumbnail as a bare url THEN wrap it in an object', async () => {
+ const iText = '{embed(image):https://example.com/image.png}';
+
+ expect((await ts.run(iText)).actions.embed).toStrictEqual({
+ image: {
+ url: 'https://example.com/image.png',
+ },
+ });
+
+ const tText = '{embed(thumbnail):https://example.com/image.png}';
+
+ expect((await ts.run(tText)).actions.embed).toStrictEqual({
+ thumbnail: {
+ url: 'https://example.com/image.png',
+ },
+ });
+ });
+
+ test('GIVEN author or footer as text THEN build the object Discord expects', async () => {
+ const aText = '{embed(author):Mahir}';
+
+ expect((await ts.run(aText)).actions.embed).toStrictEqual({
+ author: { name: 'Mahir' },
+ });
+
+ const aFullText = '{embed(author):Mahir|https://example.com|https://example.com/icon.png}';
+
+ expect((await ts.run(aFullText)).actions.embed).toStrictEqual({
+ author: { name: 'Mahir', url: 'https://example.com', icon_url: 'https://example.com/icon.png' },
+ });
+
+ const fText = '{embed(footer):Posted by the mods|https://example.com/icon.png}';
+
+ expect((await ts.run(fText)).actions.embed).toStrictEqual({
+ footer: { text: 'Posted by the mods', icon_url: 'https://example.com/icon.png' },
+ });
+ });
+
+ test('GIVEN an author or footer without the required part THEN set nothing', async () => {
+ expect((await ts.run('{embed(author):}')).actions.embed).toBeUndefined();
+ expect((await ts.run('{embed(footer):}')).actions.embed).toBeUndefined();
+ });
+
test('GIVEN color in JSON THEN resolve it to hex color', async () => {
const text = '{embed:{"color":"0x00ff00"}}';
diff --git a/packages/tagscript-plugin-discord/tests/Structures/Structures.ts b/packages/tagscript-plugin-discord/tests/Structures/Structures.ts
index 29ceecdf..a366f565 100644
--- a/packages/tagscript-plugin-discord/tests/Structures/Structures.ts
+++ b/packages/tagscript-plugin-discord/tests/Structures/Structures.ts
@@ -1,51 +1,44 @@
import {
ApplicationCommandOptionType,
ApplicationCommandType,
- ChatInputCommandInteraction,
- Client,
- Guild,
- GuildMember,
GuildMemberFlags,
InteractionType,
Locale,
- Role,
RoleFlags,
- TextChannel,
- User,
type APIApplicationCommandInteraction,
type APIAttachment,
- type APIChannel,
+ type APIChatInputApplicationCommandInteractionData,
type APIGuild,
+ type APIGuildChannel,
type APIGuildMember,
type APIRole,
type APIUser,
-} from 'discord.js';
+ type GuildChannelType,
+} from 'discord-api-types/v10';
-export const client = new Client({ intents: [] });
-
-const userObject: APIUser = {
+export const user: APIUser = {
id: '758880890159235083',
username: 'parbez',
global_name: 'Parbez',
- discriminator: '0000',
+ discriminator: '0',
avatar: '17ac5f89d5f8b08b5bbd6cc43c930399',
bot: false,
system: false,
mfa_enabled: false,
};
-const userObject2: APIUser = {
+export const user2: APIUser = {
id: '758880890159235081',
username: 'parbez2',
global_name: 'Parbez Two',
- discriminator: '0001',
- avatar: '17ac5f89d5f8b08b5bbd6cc43c930399',
+ discriminator: '0',
+ avatar: null,
bot: false,
system: false,
mfa_enabled: false,
};
-const roleObject: APIRole = {
+export const role: APIRole = {
unicode_emoji: null,
id: '933378013154906142',
name: '.',
@@ -63,7 +56,7 @@ const roleObject: APIRole = {
},
};
-const everyoneRoleObject: APIRole = {
+export const everyoneRole: APIRole = {
icon: null,
unicode_emoji: null,
id: '933368398996447292',
@@ -82,7 +75,7 @@ const everyoneRoleObject: APIRole = {
},
};
-const guildObject = {
+export const guild = {
id: '933368398996447292',
name: 'My Guild',
icon: '396ee43e3064f8ec805fede6f3bcdc6d',
@@ -94,8 +87,9 @@ const guildObject = {
verification_level: 0,
default_message_notifications: 0,
explicit_content_filter: 0,
- roles: [roleObject, everyoneRoleObject],
+ roles: [role, everyoneRole],
emojis: [],
+ stickers: [],
features: [],
mfa_level: 0,
system_channel_flags: 0,
@@ -106,27 +100,28 @@ const guildObject = {
preferred_locale: 'en-US',
nsfw_level: 0,
premium_progress_bar_enabled: false,
+ approximate_member_count: 1_204,
} as unknown as APIGuild;
-const memberObject: APIGuildMember = {
+export const member: APIGuildMember = {
roles: ['933378013154906142', '933368398996447292'],
joined_at: '2022-01-19T16:52:53.953Z',
deaf: false,
mute: false,
- user: userObject,
+ user,
flags: GuildMemberFlags.CompletedOnboarding,
};
-const channelObject: APIChannel = {
+export const channel: APIGuildChannel = {
id: '933395546138357800',
name: 'test',
type: 0,
topic: 'A test channel',
position: 1,
guild_id: '933368398996447292',
-};
+} as unknown as APIGuildChannel;
-const channel2Object: APIChannel = {
+export const channel2: APIGuildChannel = {
id: '870354581115256852',
name: 'test-1',
type: 0,
@@ -134,7 +129,7 @@ const channel2Object: APIChannel = {
nsfw: false,
rate_limit_per_user: 0,
guild_id: '933368398996447292',
-};
+} as unknown as APIGuildChannel;
export const attachment: APIAttachment = {
id: '933368398996447291',
@@ -143,138 +138,118 @@ export const attachment: APIAttachment = {
size: 4_096,
url: 'https://cdn.discordapp.com/avatars/903690362114158632/bc4edfabfde4397b2e93b598410fde6c.webp',
};
-const interactionObject: APIApplicationCommandInteraction = {
+
+export const commandData: APIChatInputApplicationCommandInteractionData = {
+ id: '938716130720235601',
+ name: 'ping',
+ type: ApplicationCommandType.ChatInput,
+ resolved: {
+ users: { [user2.id]: user2, [user.id]: user },
+ members: { [user.id]: { ...member, permissions: '8' } },
+ channels: {
+ [channel.id]: { id: channel.id, name: 'test', type: 0, permissions: '8' },
+ },
+ roles: { [role.id]: role },
+ attachments: { [attachment.id]: attachment },
+ },
+ options: [
+ {
+ name: 'sub-command',
+ type: ApplicationCommandOptionType.Subcommand,
+ options: [
+ {
+ name: 'member',
+ type: ApplicationCommandOptionType.User,
+ value: user.id,
+ },
+ ],
+ },
+ {
+ name: 'sub-command-group',
+ type: ApplicationCommandOptionType.SubcommandGroup,
+ options: [
+ {
+ name: 'sub-command',
+ type: ApplicationCommandOptionType.Subcommand,
+ options: [
+ {
+ name: 'channel',
+ type: ApplicationCommandOptionType.Channel,
+ value: channel.id,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'string',
+ type: ApplicationCommandOptionType.String,
+ value: 'Hello',
+ },
+ {
+ name: 'channel',
+ type: ApplicationCommandOptionType.Channel,
+ value: channel.id,
+ },
+ {
+ name: 'role',
+ type: ApplicationCommandOptionType.Role,
+ value: role.id,
+ },
+ {
+ name: 'mentionable',
+ type: ApplicationCommandOptionType.Mentionable,
+ value: role.id,
+ },
+ {
+ name: 'mentionable-2',
+ type: ApplicationCommandOptionType.Mentionable,
+ value: user2.id,
+ },
+ {
+ name: 'boolean',
+ type: ApplicationCommandOptionType.Boolean,
+ value: true,
+ },
+ {
+ name: 'number',
+ type: ApplicationCommandOptionType.Number,
+ value: 1.1,
+ },
+ {
+ name: 'integer',
+ type: ApplicationCommandOptionType.Integer,
+ value: 1,
+ },
+ {
+ name: 'attachment',
+ type: ApplicationCommandOptionType.Attachment,
+ value: attachment.id,
+ },
+ {
+ name: 'user',
+ type: ApplicationCommandOptionType.User,
+ value: user2.id,
+ },
+ ],
+};
+
+export const interaction: APIApplicationCommandInteraction = {
id: '933368398996447292',
application_id: '938716130720235601',
type: InteractionType.ApplicationCommand,
- data: {
- id: '938716130720235601',
- name: 'ping',
- type: ApplicationCommandType.ChatInput,
- resolved: {
- users: { '758880890159235081': userObject2, '758880890159235083': userObject },
- members: { '758880890159235083': { ...memberObject, permissions: '8' } },
- channels: {
- '933395546138357800': { ...channelObject, permissions: '8', name: 'test' },
- },
- roles: { '933378013154906142': roleObject },
- attachments: {
- '933368398996447291': attachment,
- },
- },
- options: [
- {
- name: 'sub-command',
- type: ApplicationCommandOptionType.Subcommand,
- options: [
- {
- name: 'member',
- type: ApplicationCommandOptionType.User,
- value: '758880890159235083',
- },
- ],
- },
- {
- name: 'sub-command-group',
- type: ApplicationCommandOptionType.SubcommandGroup,
- options: [
- {
- name: 'sub-command',
- type: ApplicationCommandOptionType.Subcommand,
- options: [
- {
- name: 'channel',
- type: ApplicationCommandOptionType.Channel,
- value: '933395546138357800',
- },
- ],
- },
- ],
- },
- {
- name: 'string',
- type: ApplicationCommandOptionType.String,
- value: 'Hello',
- },
- {
- name: 'channel',
- type: ApplicationCommandOptionType.Channel,
- value: '933395546138357800',
- },
- {
- name: 'role',
- type: ApplicationCommandOptionType.Role,
- value: '933378013154906142',
- },
- {
- name: 'mentionable',
- type: ApplicationCommandOptionType.Mentionable,
- value: '933378013154906142',
- },
- {
- name: 'mentionable-2',
- type: ApplicationCommandOptionType.Mentionable,
- value: '758880890159235081',
- },
- {
- name: 'boolean',
- type: ApplicationCommandOptionType.Boolean,
- value: true,
- },
- {
- name: 'number',
- type: ApplicationCommandOptionType.Number,
- value: 1.1,
- },
- {
- name: 'integer',
- type: ApplicationCommandOptionType.Integer,
- value: 1,
- },
- {
- name: 'attachment',
- type: ApplicationCommandOptionType.Attachment,
- value: '933368398996447291',
- },
- {
- name: 'user',
- type: ApplicationCommandOptionType.User,
- value: '758880890159235081',
- },
- ],
- },
+ data: commandData,
guild_id: '933368398996447292',
- channel_id: '933395546138357800',
- member: { ...memberObject, permissions: '8', user: userObject },
- user: userObject,
+ channel_id: channel.id,
+ member: { ...member, permissions: '8' },
+ user,
token: '',
version: 1,
locale: Locale.EnglishUS,
guild_locale: Locale.EnglishUS,
entitlements: [],
app_permissions: '8',
- channel: channelObject,
+ channel,
authorizing_integration_owners: {},
attachment_size_limit: 8_388_608,
-};
-
-// @ts-expect-error(2674) using protected constructor to test
-export const user: User = new User(client, userObject);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const guild: Guild = new Guild(client, guildObject);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const role: Role = new Role(client, roleObject, guild);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const member: GuildMember = new GuildMember(client, memberObject, guild);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const channel: TextChannel = new TextChannel(guild, channelObject, client);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const channel2: TextChannel = new TextChannel(guild, channel2Object, client);
-
-// @ts-expect-error(2674) using protected constructor to test
-export const interaction: ChatInputCommandInteraction = new ChatInputCommandInteraction(client, interactionObject);
+} as unknown as APIApplicationCommandInteraction;
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/GuildMember.test.ts b/packages/tagscript-plugin-discord/tests/Transformer/GuildMember.test.ts
index 84a8e0d7..d73c3b07 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/GuildMember.test.ts
+++ b/packages/tagscript-plugin-discord/tests/Transformer/GuildMember.test.ts
@@ -11,6 +11,13 @@ describe('MemberTransformer', () => {
test('GIVEN a member tag THEN return value from member variable', async () => {
expect((await ts.run('{member}', { member: new MemberTransformer(member) })).body).toBe('<@758880890159235083>');
expect((await ts.run('{member(nickname)}', { member: new MemberTransformer(member) })).body).toBe('');
+ expect((await ts.run('{member(displayName)}', { member: new MemberTransformer(member) })).body).toBe('Parbez');
+ });
+
+ test('GIVEN a nickname THEN prefer it for displayName', async () => {
+ const transformer = new MemberTransformer({ ...member, nick: 'Mahir' });
+
+ expect((await ts.run('{member(displayName)}', { member: transformer })).body).toBe('Mahir');
});
it('should match the snapshot', async () => {
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/User.test.ts b/packages/tagscript-plugin-discord/tests/Transformer/User.test.ts
index d948d052..022679a2 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/User.test.ts
+++ b/packages/tagscript-plugin-discord/tests/Transformer/User.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it, test } from 'bun:test';
import { Interpreter, StrictVarsParser } from 'tagscript';
import { UserTransformer } from '../../src';
-import { user } from '../Structures/Structures';
+import { user, user2 } from '../Structures/Structures';
const ts = new Interpreter(new StrictVarsParser());
@@ -13,8 +13,17 @@ describe('UserTransformer', () => {
expect((await ts.run('{user(username)}', { user: new UserTransformer(user) })).body).toBe('parbez');
expect((await ts.run('{user(a)}', { user: new UserTransformer(user) })).body).toBe('{user(a)}');
expect(
- (await ts.run('{user(b)}', { user: new UserTransformer(user, { b: (user) => user.defaultAvatarURL }) })).body,
- ).toBe('https://cdn.discordapp.com/embed/avatars/3.png');
+ (await ts.run('{user(b)}', { user: new UserTransformer(user, { b: (base) => base.global_name }) })).body,
+ ).toBe('Parbez');
+ });
+
+ test('GIVEN a user without an avatar THEN fall back to the default avatar', async () => {
+ const transformer = new UserTransformer(user2);
+
+ expect((await ts.run('{user(avatar)}', { user: transformer })).body).toBe('');
+ expect((await ts.run('{user(displayAvatar)}', { user: transformer })).body).toBe(
+ 'https://cdn.discordapp.com/embed/avatars/3.png',
+ );
});
it('should match the snapshot', async () => {
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Guild.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Guild.test.ts.snap
index 60120d45..a9d62e24 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Guild.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Guild.test.ts.snap
@@ -2,33 +2,25 @@
exports[`GuildTransformer should match the snapshot 1`] = `
{
- "afkChannel": "null",
+ "afkChannel": "",
"afkTimeout": 300,
- "banner": null,
- "bots": 0,
- "channelCount": 0,
- "channelIds": "\`None\`",
- "channelNames": "\`None\`",
- "channels": "\`None\`",
+ "banner": "",
"createdAt": "2022-01-19T14:32:47.467Z",
"createdTimestamp": 1642602767467,
"description": null,
"emojiCount": 0,
"features": "\`None\`",
- "humans": 0,
"icon": "https://cdn.discordapp.com/icons/933368398996447292/396ee43e3064f8ec805fede6f3bcdc6d.webp",
"id": "933368398996447292",
- "large": undefined,
- "memberCount": undefined,
+ "memberCount": 1204,
"mention": "My Guild",
"name": "My Guild",
"ownerId": "938716130720235601",
- "random": "",
"roleCount": 2,
"roleIds": "933378013154906142, 933368398996447292",
"roleNames": "., @everyone",
- "roles": "<@&933378013154906142> @everyone",
- "splash": null,
+ "roles": "<@&933378013154906142> <@&933368398996447292>",
+ "splash": "",
"stickerCount": 0,
"verificationLevel": 0,
}
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildMember.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildMember.test.ts.snap
index 1794a784..089fa48c 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildMember.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildMember.test.ts.snap
@@ -2,12 +2,11 @@
exports[`MemberTransformer should match the snapshot 1`] = `
{
- "avatar": null,
+ "avatar": "https://cdn.discordapp.com/avatars/758880890159235083/17ac5f89d5f8b08b5bbd6cc43c930399.webp",
"bot": false,
- "color": "",
"createdAt": "2020-09-25T02:41:43.539Z",
"createdTimestamp": 1601001703539,
- "discriminator": "0000",
+ "discriminator": "0",
"displayAvatar": "https://cdn.discordapp.com/avatars/758880890159235083/17ac5f89d5f8b08b5bbd6cc43c930399.webp",
"displayName": "Parbez",
"id": "758880890159235083",
@@ -15,15 +14,12 @@ exports[`MemberTransformer should match the snapshot 1`] = `
"joinedTimestamp": 1642611173953,
"mention": "<@758880890159235083>",
"name": "",
- "nickname": null,
- "position": 1,
- "roleIds": "933368398996447292, 933378013154906142",
- "roleNames": "@everyone, .",
- "roles": "@everyone <@&933378013154906142>",
+ "nickname": "",
+ "roleIds": "933378013154906142, 933368398996447292",
+ "roles": "<@&933378013154906142> <@&933368398996447292>",
"tag": "parbez",
"timeoutUntil": "",
"timeoutUntilTimestamp": null,
- "topRole": ".",
"username": "parbez",
}
`;
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildTextBasedChannel.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildTextBasedChannel.test.ts.snap
index 122e0f05..a5d09a27 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildTextBasedChannel.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/GuildTextBasedChannel.test.ts.snap
@@ -9,10 +9,7 @@ exports[`ChannelTransformer should match the snapshot 1`] = `
"name": "test",
"nsfw": false,
"parentId": null,
- "parentName": "",
- "parentPosition": 0,
- "parentType": "",
- "position": 0,
+ "position": 1,
"slowmode": 0,
"topic": "A test channel",
"type": 0,
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Interaction.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Interaction.test.ts.snap
index 449fd177..e99cc2f5 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Interaction.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Interaction.test.ts.snap
@@ -10,7 +10,7 @@ exports[`InteractionTransformer should match the snapshot 1`] = `
"guildLocale": "en-US",
"id": "933368398996447292",
"locale": "en-US",
- "mention": "/ping sub-command member:758880890159235083",
- "name": "",
+ "mention": "",
+ "name": "ping",
}
`;
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Role.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Role.test.ts.snap
index f2ee234d..f49de187 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Role.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/Role.test.ts.snap
@@ -7,11 +7,10 @@ exports[`RoleTransformer should match the snapshot 1`] = `
"createdTimestamp": 1642605059661,
"hoist": false,
"id": "933378013154906142",
- "memberCount": 0,
"mention": "<@&933378013154906142>",
"mentionable": false,
"name": ".",
"permissions": "Administrator",
- "position": 1,
+ "position": 16,
}
`;
diff --git a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/User.test.ts.snap b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/User.test.ts.snap
index 6c636b2c..a0df8d54 100644
--- a/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/User.test.ts.snap
+++ b/packages/tagscript-plugin-discord/tests/Transformer/__snapshots__/User.test.ts.snap
@@ -6,7 +6,7 @@ exports[`UserTransformer should match the snapshot 1`] = `
"bot": false,
"createdAt": "2020-09-25T02:41:43.539Z",
"createdTimestamp": 1601001703539,
- "discriminator": "0000",
+ "discriminator": "0",
"displayAvatar": "https://cdn.discordapp.com/avatars/758880890159235083/17ac5f89d5f8b08b5bbd6cc43c930399.webp",
"globalName": "Parbez",
"id": "758880890159235083",
diff --git a/packages/tagscript-plugin-discord/tests/Utils/CommandInteraction.test.ts b/packages/tagscript-plugin-discord/tests/Utils/CommandInteraction.test.ts
index 3e0b2ee4..96f4faa4 100644
--- a/packages/tagscript-plugin-discord/tests/Utils/CommandInteraction.test.ts
+++ b/packages/tagscript-plugin-discord/tests/Utils/CommandInteraction.test.ts
@@ -1,108 +1,15 @@
import { describe, expect, test } from 'bun:test';
-import { ApplicationCommandOptionType, type CommandInteractionOptionResolver } from 'discord.js';
import { Interpreter, StrictVarsParser } from 'tagscript';
import { resolveCommandOptions } from '../../src';
-import { attachment, channel, member, role, user } from '../Structures/Structures';
+import { commandData } from '../Structures/Structures';
const ts = new Interpreter(new StrictVarsParser());
describe('resolveCommandOptions', () => {
test('GIVEN interaction options THEN resolve transformers', async () => {
- const transformers = resolveCommandOptions({
- data: [
- {
- name: 'sub-command',
- type: ApplicationCommandOptionType.Subcommand,
- value: 'sub-command',
- options: [
- {
- name: 'member',
- type: ApplicationCommandOptionType.User,
- value: member.id,
- member,
- },
- ],
- },
- {
- name: 'sub-command-group',
- type: ApplicationCommandOptionType.SubcommandGroup,
- value: 'sub-command-group',
- options: [
- {
- name: 'sub-command',
- type: ApplicationCommandOptionType.Subcommand,
- value: 'sub-command',
- options: [
- {
- name: 'channel',
- type: ApplicationCommandOptionType.Channel,
- value: channel.id,
- channel,
- },
- ],
- },
- ],
- },
- {
- name: 'string',
- type: ApplicationCommandOptionType.String,
- value: 'Hello',
- },
- {
- name: 'channel',
- type: ApplicationCommandOptionType.Channel,
- value: channel.id,
- channel,
- },
- {
- name: 'role',
- type: ApplicationCommandOptionType.Role,
- value: role.id,
- role,
- },
- {
- name: 'mentionable',
- type: ApplicationCommandOptionType.Mentionable,
- value: role.id,
- role,
- },
- {
- name: 'mentionable-2',
- type: ApplicationCommandOptionType.Mentionable,
- value: user.id,
- user,
- },
- {
- name: 'boolean',
- type: ApplicationCommandOptionType.Boolean,
- value: true,
- },
- {
- name: 'number',
- type: ApplicationCommandOptionType.Number,
- value: 1.1,
- },
- {
- name: 'integer',
- type: ApplicationCommandOptionType.Integer,
- value: 1,
- },
- {
- name: 'attachment',
- type: ApplicationCommandOptionType.Attachment,
- value: attachment.id,
- attachment,
- },
- {
- name: 'user',
- type: ApplicationCommandOptionType.User,
- value: user.id,
- user,
- },
- ],
- } as unknown as Omit);
+ const transformers = resolveCommandOptions(commandData);
expect((await ts.run('{subCommand}', transformers)).body).toBe('sub-command');
expect((await ts.run('{subCommandGroup}', transformers)).body).toBe('sub-command-group');
@@ -112,13 +19,32 @@ describe('resolveCommandOptions', () => {
expect((await ts.run('{channel}', transformers)).body).toBe('<#933395546138357800>');
expect((await ts.run('{role}', transformers)).body).toBe('<@&933378013154906142>');
expect((await ts.run('{mentionable}', transformers)).body).toBe('<@&933378013154906142>');
- expect((await ts.run('{mentionable-2}', transformers)).body).toBe('<@758880890159235083>');
+ expect((await ts.run('{mentionable-2}', transformers)).body).toBe('<@758880890159235081>');
expect((await ts.run('{boolean}', transformers)).body).toBe('true');
expect((await ts.run('{number}', transformers)).body).toBe('1');
expect((await ts.run('{integer}', transformers)).body).toBe('1');
expect((await ts.run('{attachment}', transformers)).body).toBe(
'https://cdn.discordapp.com/avatars/903690362114158632/bc4edfabfde4397b2e93b598410fde6c.webp',
);
- expect((await ts.run('{user}', transformers)).body).toBe('<@758880890159235083>');
+ expect((await ts.run('{user}', transformers)).body).toBe('<@758880890159235081>');
+ });
+
+ test('GIVEN a user option with a resolved member THEN use the member transformer', async () => {
+ const transformers = resolveCommandOptions(commandData);
+
+ expect((await ts.run('{sub-command-member(displayName)}', transformers)).body).toBe('Parbez');
+ });
+
+ test('GIVEN an option Discord did not resolve THEN skip it', async () => {
+ const transformers = resolveCommandOptions({
+ ...commandData,
+ resolved: {},
+ });
+
+ expect(transformers.role).toBeUndefined();
+ expect(transformers.channel).toBeUndefined();
+ expect(transformers.user).toBeUndefined();
+ expect(transformers.attachment).toBeUndefined();
+ expect(transformers.mentionable).toBeUndefined();
});
});
diff --git a/packages/tagscript-plugin-discord/tests/Utils/resolveColor.test.ts b/packages/tagscript-plugin-discord/tests/Utils/resolveColor.test.ts
index 442960be..3cf478e5 100644
--- a/packages/tagscript-plugin-discord/tests/Utils/resolveColor.test.ts
+++ b/packages/tagscript-plugin-discord/tests/Utils/resolveColor.test.ts
@@ -1,21 +1,48 @@
import { describe, expect, test } from 'bun:test';
-import { resolveColor } from '../../src';
+import { Colors, resolveColor } from '../../src';
describe('ResolveColor', () => {
test('GIVEN a color name THEN return valid hex code', () => {
- expect(resolveColor('Red')).toBe(0xed4245);
+ expect(resolveColor('Red')).toBe(0xed_42_45);
+ expect(resolveColor('Default')).toBe(0);
});
test('GIVEN a hex code starts with # THEN return valid hex code', () => {
- expect(resolveColor('#FF0000')).toBe(0xff0000);
+ expect(resolveColor('#FF0000')).toBe(0xff_00_00);
});
test('GIVEN a hex code starts with 0x THEN return valid hex code', () => {
- expect(resolveColor('0xFF0000')).toBe(0xff0000);
+ expect(resolveColor('0xFF0000')).toBe(0xff_00_00);
+ });
+
+ test('GIVEN a bare hex code THEN return valid hex code', () => {
+ expect(resolveColor('ed4245')).toBe(0xed_42_45);
+ });
+
+ test('GIVEN a decimal string THEN return the number', () => {
+ expect(resolveColor('3650251')).toBe(3_650_251);
+ });
+
+ test('GIVEN a number THEN return it untouched', () => {
+ expect(resolveColor(0xed_42_45)).toBe(0xed_42_45);
+ });
+
+ test('GIVEN Random THEN return a color in range', () => {
+ const color = resolveColor('Random') as number;
+
+ expect(color).toBeGreaterThanOrEqual(0);
+ expect(color).toBeLessThanOrEqual(0xff_ff_ff);
});
test('GIVEN an invalid color THEN return the input', () => {
expect(resolveColor('invalid')).toBe('invalid');
+ expect(resolveColor('')).toBe('');
+ expect(resolveColor('toString')).toBe('toString');
+ expect(resolveColor('99999999')).toBe('99999999');
+ });
+
+ test('GIVEN the Colors record THEN match the Discord palette', () => {
+ expect(Colors.Blurple).toBe(0x58_65_f2);
});
});
diff --git a/packages/tagscript-plugin-discord/typedoc.json b/packages/tagscript-plugin-discord/typedoc.json
index f3502754..58372547 100644
--- a/packages/tagscript-plugin-discord/typedoc.json
+++ b/packages/tagscript-plugin-discord/typedoc.json
@@ -3,33 +3,22 @@
"readme": "./README.md",
"tsconfig": "./src/tsconfig.json",
"externalSymbolLinkMappings": {
- "discord.js": {
- "BaseChannel": "https://discord.js.org/docs/packages/discord.js/main/BaseChannel:Class",
- "ChatInputCommandInteraction": "https://discord.js.org/docs/packages/discord.js/main/ChatInputCommandInteraction:Class",
- "Client": "https://discord.js.org/docs/packages/discord.js/main/Client:Class",
- "CommandInteraction": "https://discord.js.org/docs/packages/discord.js/main/CommandInteraction:Class",
- "CommandInteractionOptionResolver": "https://discord.js.org/docs/packages/discord.js/main/CommandInteractionOptionResolver:Class",
- "Guild": "https://discord.js.org/docs/packages/discord.js/main/Guild:Class",
- "GuildMember": "https://discord.js.org/docs/packages/discord.js/main/GuildMember:Class",
- "Role": "https://discord.js.org/docs/packages/discord.js/main/Role:Class",
- "TextChannel": "https://discord.js.org/docs/packages/discord.js/main/TextChannel:Class",
- "User": "https://discord.js.org/docs/packages/discord.js/main/User:Class",
- "CommandInteractionOption": "https://discord.js.org/docs/packages/discord.js/main/CommandInteractionOption:Interface",
- "CacheType": "https://discord.js.org/docs/packages/discord.js/main/CacheType:TypeAlias",
- "Channel": "https://discord.js.org/docs/packages/discord.js/main/Channel:TypeAlias",
- "EmbedData": "https://discord.js.org/docs/packages/discord.js/main/EmbedData:Interface"
- },
"discord-api-types": {
- "ApplicationCommandOptionType": "https://discord-api-types.dev/api/discord-api-types-v10/enum/ApplicationCommandOptionType",
- "ApplicationCommandType": "https://discord-api-types.dev/api/discord-api-types-v10/enum/ApplicationCommandType",
- "InteractionType": "https://discord-api-types.dev/api/discord-api-types-v10/enum/InteractionType",
+ "APIApplicationCommandInteraction": "https://discord-api-types.dev/api/discord-api-types-v10#APIApplicationCommandInteraction",
+ "APIApplicationCommandInteractionDataOption": "https://discord-api-types.dev/api/discord-api-types-v10#APIApplicationCommandInteractionDataOption",
"APIAttachment": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIAttachment",
+ "APIChatInputApplicationCommandInteractionData": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIChatInputApplicationCommandInteractionData",
"APIEmbed": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIEmbed",
"APIGuild": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIGuild",
+ "APIGuildChannel": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIGuildChannel",
"APIGuildMember": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIGuildMember",
+ "APIInteractionDataResolved": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIInteractionDataResolved",
+ "APIInteractionDataResolvedChannel": "https://discord-api-types.dev/api/discord-api-types-v10#APIInteractionDataResolvedChannel",
+ "APIInteractionDataResolvedGuildMember": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIInteractionDataResolvedGuildMember",
"APIRole": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIRole",
- "APIApplicationCommandInteraction": "https://discord-api-types.dev/api/discord-api-types-v10#APIApplicationCommandInteraction",
- "APIChannel": "https://discord-api-types.dev/api/discord-api-types-v10#APIChannel"
+ "APIUser": "https://discord-api-types.dev/api/discord-api-types-v10/interface/APIUser",
+ "GuildChannelType": "https://discord-api-types.dev/api/discord-api-types-v10#GuildChannelType",
+ "Snowflake": "https://discord-api-types.dev/api/discord-api-types-v10#Snowflake"
}
},
"additionalModuleSources": []
diff --git a/packages/tagscript/README.md b/packages/tagscript/README.md
index 204e122f..cac99ecd 100644
--- a/packages/tagscript/README.md
+++ b/packages/tagscript/README.md
@@ -227,7 +227,7 @@ class UpperTransformer implements ITransformer {
## Related
-- [`@tagscript/plugin-discord`](https://www.npmjs.com/package/@tagscript/plugin-discord) for discord.js parsers and transformers.
+- [`@tagscript/plugin-discord`](https://www.npmjs.com/package/@tagscript/plugin-discord) for Discord parsers and transformers.
- Full documentation: **[tagscript.js.org](https://tagscript.js.org/)**
## Buy me some doughnuts