From 1b17d26cd360201081822093808aae5f3fee837f Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sat, 18 Feb 2023 07:48:04 -0800 Subject: [PATCH] feat: improve the hello-action UI Signed-off-by: Raymond Feng --- package-lock.json | 2 +- package.json | 2 +- .../hello-action-ecdsa.acceptance.ts | 40 +++- .../hello-action-ed25519.acceptance.ts | 42 +++- src/actions/hello-action.controller.ts | 225 ++++++++++++++---- 5 files changed, 247 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index d2f920f..aeda94d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@collabland/models": "^0.24.0", "@loopback/core": "^4.0.8", "@loopback/rest": "^12.0.8", - "discord-api-types": "^0.37.32", + "discord-api-types": "^0.37.35", "discord.js": "^14.7.1", "tslib": "^2.0.0" }, diff --git a/package.json b/package.json index 1aafadb..577a5a2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@collabland/models": "^0.24.0", "@loopback/core": "^4.0.8", "@loopback/rest": "^12.0.8", - "discord-api-types": "^0.37.32", + "discord-api-types": "^0.37.35", "discord.js": "^14.7.1", "tslib": "^2.0.0" }, diff --git a/src/__tests__/acceptance/hello-action-ecdsa.acceptance.ts b/src/__tests__/acceptance/hello-action-ecdsa.acceptance.ts index 1804a88..486d874 100644 --- a/src/__tests__/acceptance/hello-action-ecdsa.acceptance.ts +++ b/src/__tests__/acceptance/hello-action-ecdsa.acceptance.ts @@ -87,16 +87,46 @@ describe('HelloAction - ecdsa', () => { options: [ { name: 'your-name', - description: "Name of person we're greeting", + description: 'Name of person we\'re greeting', type: 3, required: true, - }, - ], - }, + autocomplete: true + } + ] + } ]); expect(result.response).to.eql({ type: 4, - data: {content: 'Hello, John!', flags: 64}, + data: { + content: 'Hello, John!', + embeds: [ + { + title: 'Hello Action', + color: 16106056, + author: { + name: 'Collab.Land', + url: 'https://collab.land', + icon_url: 'https://cdn.discordapp.com/app-icons/715138531994894397/8a814f663844a69d22344dc8f4983de6.png' + }, + description: 'This is demo Collab.Land action that adds `/hello-action` command to your Discord server. Please click the `Count down` button below to proceed.', + url: 'https://github.com/abridged/collabland-hello-action/' + } + ], + components: [ + { + type: 1, + components: [ + { + type: 2, + label: 'Count down', + style: 1, + custom_id: 'hello-action:count-button' + } + ] + } + ], + flags: 64 + } }); }); }); diff --git a/src/__tests__/acceptance/hello-action-ed25519.acceptance.ts b/src/__tests__/acceptance/hello-action-ed25519.acceptance.ts index 0f798bb..5513333 100644 --- a/src/__tests__/acceptance/hello-action-ed25519.acceptance.ts +++ b/src/__tests__/acceptance/hello-action-ed25519.acceptance.ts @@ -21,7 +21,7 @@ describe('HelloAction - ed25519', () => { await app.stop(); }); - it('invokes action with ecdsa signature', async () => { + it('invokes action with ed25519 signature', async () => { const result = await client( app.restServer.url + '/hello-action', 'ed25519:' + signingKey, @@ -39,16 +39,46 @@ describe('HelloAction - ed25519', () => { options: [ { name: 'your-name', - description: "Name of person we're greeting", + description: 'Name of person we\'re greeting', type: 3, required: true, - }, - ], - }, + autocomplete: true + } + ] + } ]); expect(result.response).to.eql({ type: 4, - data: {content: 'Hello, John!', flags: 64}, + data: { + content: 'Hello, John!', + embeds: [ + { + title: 'Hello Action', + color: 16106056, + author: { + name: 'Collab.Land', + url: 'https://collab.land', + icon_url: 'https://cdn.discordapp.com/app-icons/715138531994894397/8a814f663844a69d22344dc8f4983de6.png' + }, + description: 'This is demo Collab.Land action that adds `/hello-action` command to your Discord server. Please click the `Count down` button below to proceed.', + url: 'https://github.com/abridged/collabland-hello-action/' + } + ], + components: [ + { + type: 1, + components: [ + { + type: 2, + label: 'Count down', + style: 1, + custom_id: 'hello-action:count-button' + } + ] + } + ], + flags: 64 + } }); }); }); diff --git a/src/actions/hello-action.controller.ts b/src/actions/hello-action.controller.ts index 3f57840..10f0aeb 100644 --- a/src/actions/hello-action.controller.ts +++ b/src/actions/hello-action.controller.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {sleep} from '@collabland/common'; +import {debugFactory, HttpErrors, sleep, stringify} from '@collabland/common'; import { APIChatInputApplicationCommandInteraction, APIInteractionResponse, @@ -20,11 +20,26 @@ import { InteractionType, MessageFlags, RESTPatchAPIWebhookWithTokenMessageJSONBody, - RESTPostAPIWebhookWithTokenJSONBody, } from '@collabland/discord'; import {MiniAppManifest} from '@collabland/models'; import {BindingScope, injectable} from '@loopback/core'; import {api} from '@loopback/rest'; +import { + ActionRowBuilder, + APIInteraction, + APIMessageComponentInteraction, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + InteractionResponseType, + MessageActionRowComponentBuilder, + APIApplicationCommandAutocompleteInteraction, + APIApplicationCommandAutocompleteResponse, + APIApplicationCommandBooleanOption, + APIApplicationCommandInteractionDataStringOption, +} from 'discord.js'; + +const debug = debugFactory('collabland:hello-action'); /** * HelloActionController is a LoopBack REST API controller that exposes endpoints @@ -34,9 +49,10 @@ import {api} from '@loopback/rest'; scope: BindingScope.SINGLETON, }) @api({basePath: '/hello-action'}) // Set the base path to `/hello-action` -export class HelloActionController extends BaseDiscordActionController { +export class HelloActionController extends BaseDiscordActionController { /** - * Expose metadata for the action + * Expose metadata for the action. The return value is used by Collab.Land `/test-flight` command + * or marketplace to list this action as a miniapp. * @returns */ async getMetadata(): Promise { @@ -69,66 +85,161 @@ export class HelloActionController extends BaseDiscordActionController, + ): Promise { + switch (request.data.name) { + case 'hello-action': { + /** + * Get the value of `your-name` argument for `/hello-action` + */ + const yourName = getCommandOptionValue(request, 'your-name'); + const message = `Hello, ${ + yourName ?? request.user?.username ?? 'World' + }!`; + + const appId = request.application_id; + const response: APIInteractionResponse = { + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: message, + embeds: [ + new EmbedBuilder() + .setTitle('Hello Action') + .setColor('#f5c248') + .setAuthor({ + name: 'Collab.Land', + url: 'https://collab.land', + iconURL: `https://cdn.discordapp.com/app-icons/${appId}/8a814f663844a69d22344dc8f4983de6.png`, + }) + .setDescription( + 'This is demo Collab.Land action that adds `/hello-action` ' + + 'command to your Discord server. Please click the `Count down` button below to proceed.', + ) + .setURL('https://github.com/abridged/collabland-hello-action/') + .toJSON(), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel(`Count down`) + .setStyle(ButtonStyle.Primary) + // Set the custom id to start with `hello-action:` + .setCustomId('hello-action:count-button'), + ) + .toJSON(), + ], + flags: MessageFlags.Ephemeral, + }, + }; + + // Return the 1st response to Discord + return response; + } + default: { + return buildSimpleResponse( + `Slash command ${request.data.name} is not implemented.`, + ); + } + } + } + + /** + * Handle the Discord message components including buttons * @param interaction - Discord interaction with Collab.Land action context * @returns - Discord interaction response */ - protected async handle( - interaction: DiscordActionRequest, + protected async handleMessageComponent( + request: DiscordActionRequest, ): Promise { - /** - * Get the value of `your-name` argument for `/hello-action` - */ - const yourName = getCommandOptionValue(interaction, 'your-name'); - const message = `Hello, ${ - yourName ?? interaction.user?.username ?? 'World' - }!`; - /** - * Build a simple Discord message private to the user - */ - const response: APIInteractionResponse = buildSimpleResponse(message, true); - /** - * Allow advanced followup messages - */ - this.followup(interaction, message).catch(err => { - console.error( - 'Fail to send followup message to interaction %s: %O', - interaction.id, - err, - ); - }); - // Return the 1st response to Discord - return response; + switch (request.data.custom_id) { + case 'hello-action:count-button': { + // Run count down in the background after 1 second + this.countDown(request).catch(err => { + console.error( + 'Fail to send followup message to interaction %s: %O', + request.id, + err, + ); + }); + } + } + // Instruct Discord that we'll edit the original message later on + return { + type: InteractionResponseType.DeferredMessageUpdate, + }; } - private async followup( - request: DiscordActionRequest, - message: string, + /** + * Run a countdown by updating the original message content + * @param request + */ + private async countDown( + request: DiscordActionRequest, ) { - const callback = request.actionContext?.callbackUrl; - if (callback != null) { - const followupMsg: RESTPostAPIWebhookWithTokenJSONBody = { - content: `Follow-up: **${message}**`, - flags: MessageFlags.Ephemeral, + await sleep(1000); + const message = request.message.content; + // 5 seconds count down + for (let i = 5; i > 0; i--) { + const updated: RESTPatchAPIWebhookWithTokenMessageJSONBody = { + content: `[${i}s]: **${message}**`, + components: [], // Remove the `Count down` button }; + await this.editMessage(request, updated, request.message.id); await sleep(1000); - let msg = await this.followupMessage(request, followupMsg); - await sleep(1000); - // 5 seconds count down - for (let i = 5; i > 0; i--) { - const updated: RESTPatchAPIWebhookWithTokenMessageJSONBody = { - content: `[${i}s]: **${message}**`, - }; - msg = await this.editMessage(request, updated, msg?.id); - await sleep(1000); - } - // Delete the follow-up message - await this.deleteMessage(request, msg?.id); + } + // Delete the follow-up message + await this.deleteMessage(request, request.message.id); + } + + protected async handleApplicationCommandAutoComplete( + interaction: DiscordActionRequest, + ): Promise { + debug('Autocomplete request: %O', interaction); + const option = interaction.data.options.find(o => { + return ( + o.name === 'your-name' && + o.type === ApplicationCommandOptionType.String && + o.focused + ); + }); + if (option?.type === ApplicationCommandOptionType.String) { + const candidates = [ + 'Ethereum', + 'Polygon', + 'Optimism', + 'Arbitrum', + 'Flow', + 'Solana', + 'Near', + 'Tezos', + 'Ronin', + 'Xrpl', + ]; + const prefix = option.value; + const choices = candidates + .filter(c => c.toLowerCase().startsWith(prefix.toLowerCase())) + .map(c => ({name: c, value: c})); + + const res: APIApplicationCommandAutocompleteResponse = { + type: InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices, + }, + }; + debug('Autocomplete response: %O', res); + return res; } } /** - * Build a list of supported Discord interactions + * Build a list of supported Discord interactions. The return value is used as filter so that + * Collab.Land can route the corresponding interactions to this action. * @returns */ private getSupportedInteractions(): DiscordInteractionPattern[] { @@ -138,6 +249,17 @@ export class HelloActionController extends BaseDiscordActionController