From edc3cef97005d4e9b1b44aced9b2dbf1d1a7a49c Mon Sep 17 00:00:00 2001 From: parbez Date: Sun, 30 Aug 2026 14:48:58 +0530 Subject: [PATCH] refactor!: make discord plugin generic fixes #121 fixes #455 --- README.md | 8 +- apps/website/content/docs/plugins/index.mdx | 2 +- .../plugin-discord/command-options.mdx | 31 +- .../docs/plugins/plugin-discord/index.mdx | 44 ++- .../plugins/plugin-discord/parsers/embed.mdx | 30 +- .../plugins/plugin-discord/parsers/files.mdx | 2 +- .../plugins/plugin-discord/parsers/index.mdx | 2 +- .../plugin-discord/transformers/channel.mdx | 18 +- .../plugin-discord/transformers/guild.mdx | 38 +-- .../plugin-discord/transformers/index.mdx | 58 +++- .../transformers/interaction.mdx | 14 +- .../plugin-discord/transformers/member.mdx | 36 ++- .../plugin-discord/transformers/role.mdx | 19 +- .../plugin-discord/transformers/user.mdx | 8 +- bun.lock | 47 +-- packages/tagscript-plugin-discord/README.md | 75 +++-- .../tagscript-plugin-discord/package.json | 12 +- .../src/lib/Parsers/Embed.ts | 89 +++--- .../src/lib/Transformer/Base.ts | 30 +- .../src/lib/Transformer/Guild.ts | 82 +++--- .../src/lib/Transformer/GuildMember.ts | 79 +++-- .../lib/Transformer/GuildTextBasedChannel.ts | 57 ++-- .../src/lib/Transformer/Interaction.ts | 47 ++- .../src/lib/Transformer/Role.ts | 40 ++- .../src/lib/Transformer/User.ts | 36 ++- .../src/lib/Utils/CommandInteraction.ts | 129 +++++---- .../src/lib/Utils/cdn.ts | 15 + .../src/lib/Utils/resolveColor.ts | 68 ++++- .../src/lib/Utils/snowflake.ts | 19 ++ .../src/lib/interfaces/index.ts | 24 +- .../tests/Parsers/Embed.test.ts | 43 +++ .../tests/Structures/Structures.ts | 273 ++++++++---------- .../tests/Transformer/GuildMember.test.ts | 7 + .../tests/Transformer/User.test.ts | 15 +- .../__snapshots__/Guild.test.ts.snap | 18 +- .../__snapshots__/GuildMember.test.ts.snap | 14 +- .../GuildTextBasedChannel.test.ts.snap | 5 +- .../__snapshots__/Interaction.test.ts.snap | 4 +- .../__snapshots__/Role.test.ts.snap | 3 +- .../__snapshots__/User.test.ts.snap | 2 +- .../tests/Utils/CommandInteraction.test.ts | 120 ++------ .../tests/Utils/resolveColor.test.ts | 35 ++- .../tagscript-plugin-discord/typedoc.json | 31 +- packages/tagscript/README.md | 2 +- 44 files changed, 1003 insertions(+), 728 deletions(-) create mode 100644 packages/tagscript-plugin-discord/src/lib/Utils/cdn.ts create mode 100644 packages/tagscript-plugin-discord/src/lib/Utils/snowflake.ts 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) | [![npm](https://img.shields.io/npm/v/tagscript?style=flat-square&label=)](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. | -| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [![npm](https://img.shields.io/npm/v/@tagscript/plugin-discord?style=flat-square&label=)](https://www.npmjs.com/package/@tagscript/plugin-discord) | discord.js parsers and transformers for embeds, cooldowns, permissions and mentions. | +| Package | Version | Description | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [`tagscript`](./packages/tagscript) | [![npm](https://img.shields.io/npm/v/tagscript?style=flat-square&label=)](https://www.npmjs.com/package/tagscript) | The interpreter, plus the built-in parsers and transformers. No dependencies. | +| [`@tagscript/plugin-discord`](./packages/tagscript-plugin-discord) | [![npm](https://img.shields.io/npm/v/@tagscript/plugin-discord?style=flat-square&label=)](https://www.npmjs.com/package/@tagscript/plugin-discord) | Discord parsers and transformers for embeds, cooldowns, permissions and mentions. | `tagscript` ships ESM, CJS and an IIFE build (global `TagScript`), and has no runtime dependencies. 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).** [![npm](https://img.shields.io/npm/v/@tagscript/plugin-discord?color=crimson&logo=npm&style=flat-square)](https://www.npmjs.com/package/@tagscript/plugin-discord) [![npm downloads](https://img.shields.io/npm/dw/@tagscript/plugin-discord?style=flat-square)](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 ``; + } + 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