From e7569fd5055d6f094322a43fa32e9a6358a9865b Mon Sep 17 00:00:00 2001 From: Shigma Date: Sun, 2 Jul 2023 22:49:24 +0800 Subject: [PATCH] feat(discord): add basic support for slash commands --- adapters/discord/src/bot.ts | 53 ++++-- adapters/discord/src/index.ts | 3 +- adapters/discord/src/message.ts | 40 +++-- adapters/discord/src/types/index.ts | 17 +- adapters/discord/src/types/interaction.ts | 198 +++++++++++----------- adapters/discord/src/utils.ts | 112 ++++++++++-- adapters/discord/src/ws.ts | 4 +- packages/core/src/message.ts | 2 +- packages/core/src/universal.ts | 26 +++ 9 files changed, 301 insertions(+), 154 deletions(-) diff --git a/adapters/discord/src/bot.ts b/adapters/discord/src/bot.ts index 85c08e38..07daf54a 100644 --- a/adapters/discord/src/bot.ts +++ b/adapters/discord/src/bot.ts @@ -1,5 +1,6 @@ -import { Bot, Context, Fragment, h, Quester, Schema, SendOptions, Universal } from '@satorijs/satori' -import { adaptChannel, adaptGuild, adaptMessage, adaptUser, decodeRole, encodeRole } from './utils' +import { Bot, Context, Fragment, h, Logger, pick, Quester, Schema, SendOptions, Universal } from '@satorijs/satori' +import { decodeChannel, decodeGuild, decodeMessage, decodeRole, decodeUser, encodeRole } from './utils' +import * as Discord from './utils' import { DiscordMessageEncoder } from './message' import { Internal, Webhook } from './types' import { WsClient } from './ws' @@ -7,6 +8,8 @@ import { WsClient } from './ws' // @ts-ignore import { version } from '../package.json' +const logger = new Logger('discord') + export class DiscordBot extends Bot { static MessageEncoder = DiscordMessageEncoder @@ -58,7 +61,7 @@ export class DiscordBot extends Bot { async getSelf() { const data = await this.internal.getCurrentUser() - return adaptUser(data) + return decodeUser(data) } async deleteMessage(channelId: string, messageId: string) { @@ -79,35 +82,35 @@ export class DiscordBot extends Bot { async getMessage(channelId: string, messageId: string) { const data = await this.internal.getChannelMessage(channelId, messageId) - return await adaptMessage(this, data) + return await decodeMessage(this, data) } async getMessageList(channelId: string, before?: string) { // doesn't include `before` message // 从旧到新 const data = (await this.internal.getChannelMessages(channelId, { before, limit: 50 })).reverse() - return await Promise.all(data.map(data => adaptMessage(this, data))) + return await Promise.all(data.map(data => decodeMessage(this, data))) } async getUser(userId: string) { const data = await this.internal.getUser(userId) - return adaptUser(data) + return decodeUser(data) } async getGuildMemberList(guildId: string) { const data = await this.internal.listGuildMembers(guildId) - return data.map(v => adaptUser(v.user)) + return data.map(v => decodeUser(v.user)) } async getChannel(channelId: string) { const data = await this.internal.getChannel(channelId) - return adaptChannel(data) + return decodeChannel(data) } async getGuildMember(guildId: string, userId: string) { const member = await this.internal.getGuildMember(guildId, userId) return { - ...adaptUser(member.user), + ...decodeUser(member.user), nickname: member.nick, } } @@ -118,17 +121,17 @@ export class DiscordBot extends Bot { async getGuild(guildId: string) { const data = await this.internal.getGuild(guildId) - return adaptGuild(data) + return decodeGuild(data) } async getGuildList() { const data = await this.internal.getCurrentUserGuilds() - return data.map(adaptGuild) + return data.map(decodeGuild) } async getChannelList(guildId: string) { const data = await this.internal.getGuildChannels(guildId) - return data.map(adaptChannel) + return data.map(decodeChannel) } createReaction(channelId: string, messageId: string, emoji: string) { @@ -153,7 +156,7 @@ export class DiscordBot extends Bot { async getReactions(channelId: string, messageId: string, emoji: string) { const data = await this.internal.getReactions(channelId, messageId, emoji) - return data.map(adaptUser) + return data.map(decodeUser) } setGuildMemberRole(guildId: string, userId: string, roleId: string) { @@ -188,6 +191,30 @@ export class DiscordBot extends Bot { }) return this.sendMessage(channel.id, content, null, options) } + + async syncCommands(commands: Universal.Command[]) { + const local = Object.fromEntries(commands.map(cmd => [cmd.name, cmd] as const)) + const remote = Object.fromEntries((await this.internal.getGlobalApplicationCommands(this.selfId)) + .filter(cmd => cmd.type === Discord.ApplicationCommand.Type.CHAT_INPUT) + .map(cmd => [cmd.name, cmd] as const)) + + for (const key in { ...local, ...remote }) { + if (!local[key]) { + logger.debug('deleting command %s', key) + await this.internal.deleteGlobalApplicationCommand(this.selfId, remote[key].id) + continue + } + + const data = Discord.encodeCommand(local[key]) + logger.debug('upsert command: %s', local[key].name) + logger.debug(data, remote[key]) + if (!remote[key]) { + await this.internal.createGlobalApplicationCommand(this.selfId, data) + } else if (remote[key] && JSON.stringify(data) !== JSON.stringify(pick(remote[key], ['name', 'description', 'description_localizations', 'options']))) { + await this.internal.editGlobalApplicationCommand(this.selfId, remote[key].id, data) + } + } + } } export namespace DiscordBot { diff --git a/adapters/discord/src/index.ts b/adapters/discord/src/index.ts index 684d7901..edc1cdbe 100644 --- a/adapters/discord/src/index.ts +++ b/adapters/discord/src/index.ts @@ -1,11 +1,10 @@ import { DiscordBot } from './bot' -import * as Discord from './types' +import * as Discord from './utils' export { Discord } export * from './bot' export * from './message' -export * from './utils' export * from './ws' export default DiscordBot diff --git a/adapters/discord/src/message.ts b/adapters/discord/src/message.ts index 17a88a23..99ef4145 100644 --- a/adapters/discord/src/message.ts +++ b/adapters/discord/src/message.ts @@ -2,7 +2,7 @@ import { Dict, h, Logger, MessageEncoder, Quester, Schema, segment, Session, Uni import FormData from 'form-data' import { DiscordBot } from './bot' import { Channel, Message } from './types' -import { adaptMessage, sanitize } from './utils' +import { decodeMessage, sanitize } from './utils' type RenderMode = 'default' | 'figure' @@ -25,25 +25,35 @@ export class DiscordMessageEncoder extends MessageEncoder { private figure: h = null private mode: RenderMode = 'default' - async post(data?: any, headers?: any) { - try { - let url = `/channels/${this.channelId}/messages` - if (this.stack[0].author.nickname || this.stack[0].author.avatar || (this.stack[0].type === 'forward' && !this.stack[0].threadCreated)) { + private async getUrl() { + const input = this.options.session.discord + if (input?.t === 'INTERACTION_CREATE') { + // 消息交互 + return `/webhooks/${input.d.application_id}/${input.d.token}` + } else if (this.stack[0].type === 'forward' && this.stack[0].channel?.id) { + // 发送到子区 + if (this.stack[1].author.nickname || this.stack[1].author.avatar) { const webhook = await this.ensureWebhook() - url = `/webhooks/${webhook.id}/${webhook.token}?wait=true` + return `/webhooks/${webhook.id}/${webhook.token}?wait=true&thread_id=${this.stack[0].channel?.id}` + } else { + return `/channels/${this.stack[0].channel.id}/messages` } - if (this.stack[0].type === 'forward' && this.stack[0].channel?.id) { - // 发送到子区 - if (this.stack[1].author.nickname || this.stack[1].author.avatar) { - const webhook = await this.ensureWebhook() - url = `/webhooks/${webhook.id}/${webhook.token}?wait=true&thread_id=${this.stack[0].channel?.id}` - } else { - url = `/channels/${this.stack[0].channel.id}/messages` - } + } else { + if (this.stack[0].author.nickname || this.stack[0].author.avatar || (this.stack[0].type === 'forward' && !this.stack[0].threadCreated)) { + const webhook = await this.ensureWebhook() + return `/webhooks/${webhook.id}/${webhook.token}?wait=true` + } else { + return `/channels/${this.channelId}/messages` } + } + } + + async post(data?: any, headers?: any) { + try { + const url = await this.getUrl() const result = await this.bot.http.post(url, data, { headers }) const session = this.bot.session() - const message = await adaptMessage(this.bot, result, session) + const message = await decodeMessage(this.bot, result, session) session.app.emit(session, 'send', session) this.results.push(session) diff --git a/adapters/discord/src/types/index.ts b/adapters/discord/src/types/index.ts index aa6f025e..1fdf56b3 100644 --- a/adapters/discord/src/types/index.ts +++ b/adapters/discord/src/types/index.ts @@ -37,10 +37,13 @@ export type snowflake = string export type timestamp = string /** @see https://discord.com/developers/docs/reference#locales */ -export type Locale = - | 'da' | 'de' | 'en-GB' | 'en-US' | 'es-ES' - | 'fr' | 'hr' | 'it' | 'lt' | 'hu' - | 'nl' | 'no' | 'pl' | 'pt-BR' | 'ro' - | 'fi' | 'sv-SE' | 'vi' | 'tr' | 'cs' - | 'el' | 'bg' | 'ru' | 'uk' | 'hi' - | 'th' | 'zh-CN' | 'ja' | 'zh-TW' | 'ko' +export type Locale = typeof Locale[number] + +export const Locale = [ + 'da', 'de', 'en-GB', 'en-US', 'es-ES', + 'fr', 'hr', 'it', 'lt', 'hu', + 'nl', 'no', 'pl', 'pt-BR', 'ro', + 'fi', 'sv-SE', 'vi', 'tr', 'cs', + 'el', 'bg', 'ru', 'uk', 'hi', + 'th', 'zh-CN', 'ja', 'zh-TW', 'ko', +] as const diff --git a/adapters/discord/src/types/interaction.ts b/adapters/discord/src/types/interaction.ts index d0e63ad2..29acecfb 100644 --- a/adapters/discord/src/types/interaction.ts +++ b/adapters/discord/src/types/interaction.ts @@ -1,4 +1,5 @@ import { AllowedMentions, ApplicationCommand, Attachment, Channel, Component, ComponentType, Embed, GuildMember, integer, Internal, Message, Role, snowflake, User } from '.' +import * as Discord from '.' /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-structure */ export interface Interaction { @@ -7,7 +8,7 @@ export interface Interaction { /** id of the application this interaction is for */ application_id: snowflake /** the type of interaction */ - type: InteractionType + type: Interaction.Type /** the command data payload */ data?: InteractionData /** the guild it was sent from */ @@ -49,13 +50,29 @@ export namespace InteractionData { /** converted users + roles + channels */ resolved?: ResolvedData /** the params + values from the user */ - options?: ApplicationCommandInteractionDataOption[] + options?: ApplicationCommand.Option[] /** the id of the guild the command is registered to */ guild_id?: snowflake /** id of the user or message targeted by a user or message command */ target_id?: snowflake } + export namespace ApplicationCommand { + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-application-command-interaction-data-option-structure */ + export interface Option { + /** the name of the parameter */ + name: string + /** value of application command option type */ + type: Discord.ApplicationCommand.OptionType + /** the value of the pair */ + value?: any + /** present if this option is a group or subcommand */ + options?: Option[] + /** true if this option is the currently focused option for autocomplete */ + focused?: boolean + } + } + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-message-component-data-structure */ export interface MessageComponent { /** the custom_id of the component */ @@ -73,27 +90,15 @@ export namespace InteractionData { } } -/** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type */ -export enum InteractionType { - PING = 1, - APPLICATION_COMMAND = 2, - MESSAGE_COMPONENT = 3, - APPLICATION_COMMAND_AUTOCOMPLETE = 4, - MODAL_SUBMIT = 5, -} - -/** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-application-command-interaction-data-option-structure */ -export interface ApplicationCommandInteractionDataOption { - /** the name of the parameter */ - name: string - /** value of application command option type */ - type: ApplicationCommand.OptionType - /** the value of the pair */ - value?: any - /** present if this option is a group or subcommand */ - options?: ApplicationCommandInteractionDataOption[] - /** true if this option is the currently focused option for autocomplete */ - focused?: boolean +export namespace Interaction { + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type */ + export enum Type { + PING = 1, + APPLICATION_COMMAND = 2, + MESSAGE_COMPONENT = 3, + APPLICATION_COMMAND_AUTOCOMPLETE = 4, + MODAL_SUBMIT = 5, + } } /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-resolved-data-structure */ @@ -117,7 +122,7 @@ export interface MessageInteraction { /** id of the interaction */ id: snowflake /** the type of interaction */ - type: InteractionType + type: Interaction.Type /** the name of the application command */ name: string /** the user who invoked the interaction */ @@ -126,80 +131,81 @@ export interface MessageInteraction { member?: Partial } -/** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-response-structure */ -export interface InteractionResponse { - /** the type of response */ - type: InteractionCallbackType - /** an optional response message */ - data?: InteractionCallbackData -} - -/** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type */ -export enum InteractionCallbackType { - /** ACK a Ping */ - PONG = 1, - /** respond to an interaction with a message */ - CHANNEL_MESSAGE_WITH_SOURCE = 4, - /** ACK an interaction and edit a response later, the user sees a loading state */ - DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, - /** - * for components, ACK an interaction and edit the original message later; the user does not see a loading state - * (only valid for [component-based](https://discord.com/developers/docs/interactions/message-components) interactions) - */ - DEFERRED_UPDATE_MESSAGE = 6, - /** - * for components, edit the message the component was attached to - * (only valid for [component-based](https://discord.com/developers/docs/interactions/message-components) interactions) - */ - UPDATE_MESSAGE = 7, - /** respond to an autocomplete interaction with suggested choices */ - APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, - /** - * respond to an interaction with a popup modal - * (not available for `MODAL_SUBMIT` and `PING` interactions) - */ - MODAL = 9, -} +export namespace Interaction { + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-response-structure */ + export interface Response { + /** the type of response */ + type: CallbackType + /** an optional response message */ + data?: CallbackData + } -export type InteractionCallbackData = - | InteractionCallbackData.Messages - | InteractionCallbackData.Autocomplete - | InteractionCallbackData.Modal + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type */ + export enum CallbackType { + /** ACK a Ping */ + PONG = 1, + /** respond to an interaction with a message */ + CHANNEL_MESSAGE_WITH_SOURCE = 4, + /** ACK an interaction and edit a response later, the user sees a loading state */ + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + /** + * for components, ACK an interaction and edit the original message later; the user does not see a loading state + * (only valid for [component-based](https://discord.com/developers/docs/interactions/message-components) interactions) + */ + DEFERRED_UPDATE_MESSAGE = 6, + /** + * for components, edit the message the component was attached to + * (only valid for [component-based](https://discord.com/developers/docs/interactions/message-components) interactions) + */ + UPDATE_MESSAGE = 7, + /** respond to an autocomplete interaction with suggested choices */ + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, + /** + * respond to an interaction with a popup modal + * (not available for `MODAL_SUBMIT` and `PING` interactions) + */ + MODAL = 9, + } -export namespace InteractionCallbackData { + export type CallbackData = + | CallbackData.Messages + | CallbackData.Autocomplete + | CallbackData.Modal - /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-messages */ - export interface Messages { - /** is the response TTS */ - tts?: boolean - /** message content */ - content?: string - /** supports up to 10 embeds */ - embeds?: Embed[] - /** allowed mentions object */ - allowed_mentions?: AllowedMentions - /** interaction callback data flags */ - flags?: integer - /** message components */ - components?: Component[] - /** attachment objects with filename and description */ - attachments?: Partial[] - } + export namespace CallbackData { + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-messages */ + export interface Messages { + /** is the response TTS */ + tts?: boolean + /** message content */ + content?: string + /** supports up to 10 embeds */ + embeds?: Embed[] + /** allowed mentions object */ + allowed_mentions?: AllowedMentions + /** interaction callback data flags */ + flags?: integer + /** message components */ + components?: Component[] + /** attachment objects with filename and description */ + attachments?: Partial[] + } - /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-autocomplete */ - export interface Autocomplete { - /** autocomplete choices (max of 25 choices) */ - choices: ApplicationCommand.OptionChoice[] - } + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-autocomplete */ + export interface Autocomplete { + /** autocomplete choices (max of 25 choices) */ + choices: ApplicationCommand.OptionChoice[] + } - /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-modal */ - export interface Modal { - /** a developer-defined identifier for the modal, max 100 characters */ - custom_id: string - /** the title of the popup modal, max 45 characters */ - title: string - /** between 1 and 5 (inclusive) components that make up the modal */ - components: Component[] + /** https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-modal */ + export interface Modal { + /** a developer-defined identifier for the modal, max 100 characters */ + custom_id: string + /** the title of the popup modal, max 45 characters */ + title: string + /** between 1 and 5 (inclusive) components that make up the modal */ + components: Component[] + } } } @@ -218,17 +224,17 @@ declare module './internal' { * Create a response to an Interaction from the gateway. Takes an interaction response. This endpoint also supports file attachments similar to the webhook endpoints. Refer to Uploading Files for details on uploading files and multipart/form-data requests. * @see https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response */ - createInteractionResponse(interaction_id: snowflake, token: string, params: InteractionResponse): Promise + createInteractionResponse(interaction_id: snowflake, token: string, params: Interaction.Response): Promise /** * Returns the initial Interaction response. Functions the same as Get Webhook Message. * @see https://discord.com/developers/docs/interactions/receiving-and-responding#get-original-interaction-response */ - getOriginalInteractionResponse(application_id: snowflake, token: string): Promise + getOriginalInteractionResponse(application_id: snowflake, token: string): Promise /** * Edits the initial Interaction response. Functions the same as Edit Webhook Message. * @see https://discord.com/developers/docs/interactions/receiving-and-responding#edit-original-interaction-response */ - editOriginalInteractionResponse(application_id: snowflake, token: string): Promise + editOriginalInteractionResponse(application_id: snowflake, token: string): Promise /** * Deletes the initial Interaction response. Returns 204 No Content on success. * @see https://discord.com/developers/docs/interactions/receiving-and-responding#delete-original-interaction-response diff --git a/adapters/discord/src/utils.ts b/adapters/discord/src/utils.ts index 15c9adda..28b844e4 100644 --- a/adapters/discord/src/utils.ts +++ b/adapters/discord/src/utils.ts @@ -1,14 +1,16 @@ -import { h, Session, Universal } from '@satorijs/satori' +import { h, pick, Session, Universal } from '@satorijs/satori' import { DiscordBot } from './bot' import * as Discord from './types' +export * from './types' + export const sanitize = (val: string) => val .replace(/[\\*_`~|()\[\]]/g, '\\$&') .replace(/@everyone/g, () => '\\@everyone') .replace(/@here/g, () => '\\@here') -export const adaptUser = (user: Discord.User): Universal.User => ({ +export const decodeUser = (user: Discord.User): Universal.User => ({ userId: user.id, avatar: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`, username: user.username, @@ -16,18 +18,18 @@ export const adaptUser = (user: Discord.User): Universal.User => ({ isBot: user.bot || false, }) -export const adaptGuild = (data: Discord.Guild): Universal.Guild => ({ +export const decodeGuild = (data: Discord.Guild): Universal.Guild => ({ guildId: data.id, guildName: data.name, }) -export const adaptChannel = (data: Discord.Channel): Universal.Channel => ({ +export const decodeChannel = (data: Discord.Channel): Universal.Channel => ({ channelId: data.id, channelName: data.name, }) -export const adaptAuthor = (author: Discord.User): Universal.Author => ({ - ...adaptUser(author), +export const decodeAuthor = (author: Discord.User): Universal.Author => ({ + ...decodeUser(author), nickname: author.username, }) @@ -41,14 +43,14 @@ export const encodeRole = (role: Partial): Partial permissions: role.permissions && '' + role.permissions, }) -export async function adaptMessage(bot: DiscordBot, meta: Discord.Message, session: Partial = {}) { +export async function decodeMessage(bot: DiscordBot, meta: Discord.Message, session: Partial = {}) { const { platform } = bot - prepareMessage(session, meta) + setupMessage(session, meta) session.messageId = meta.id session.timestamp = new Date(meta.timestamp).valueOf() || Date.now() if (meta.author) { - session.author = adaptAuthor(meta.author) + session.author = decodeAuthor(meta.author) session.userId = meta.author.id } if (meta.member?.nick) { @@ -134,13 +136,19 @@ export async function adaptMessage(bot: DiscordBot, meta: Discord.Message, sessi return session as Universal.Message } -export function prepareMessage(session: Partial, data: Partial) { +export function setupMessage(session: Partial, data: Partial) { session.guildId = data.guild_id session.subtype = data.guild_id ? 'group' : 'private' session.channelId = data.channel_id } -function prepareReactionSession(session: Partial, data: any) { +type ReactionEvent = Partial< + & Discord.Reaction.Event.Add + & Discord.Reaction.Event.Remove + & Discord.Reaction.Event.RemoveAll + & Discord.Reaction.Event.RemoveEmoji> + +function setupReaction(session: Partial, data: ReactionEvent) { session.userId = data.user_id session.messageId = data.message_id session.guildId = data.guild_id @@ -153,6 +161,7 @@ function prepareReactionSession(session: Partial, data: any) { export async function adaptSession(bot: DiscordBot, input: Discord.GatewayPayload) { const session = bot.session() + session.discord = Object.assign(Object.create(bot.internal), input) if (input.t === 'MESSAGE_CREATE') { if (input.d.webhook_id) { const webhook = await bot.ensureWebhook(input.d.channel_id) @@ -162,7 +171,7 @@ export async function adaptSession(bot: DiscordBot, input: Discord.GatewayPayloa } } session.type = 'message' - await adaptMessage(bot, input.d, session) + await decodeMessage(bot, input.d, session) // dc 情况特殊 可能有 embeds 但是没有消息主体 // if (!session.content) return } else if (input.t === 'MESSAGE_UPDATE') { @@ -170,27 +179,38 @@ export async function adaptSession(bot: DiscordBot, input: Discord.GatewayPayloa const msg = await bot.internal.getChannelMessage(input.d.channel_id, input.d.id) // Unlike creates, message updates may contain only a subset of the full message object payload // https://discord.com/developers/docs/topics/gateway-events#message-update - await adaptMessage(bot, msg, session) + await decodeMessage(bot, msg, session) // if (!session.content) return } else if (input.t === 'MESSAGE_DELETE') { session.type = 'message-deleted' session.messageId = input.d.id - prepareMessage(session, input.d) + setupMessage(session, input.d) } else if (input.t === 'MESSAGE_REACTION_ADD') { session.type = 'reaction-added' - prepareReactionSession(session, input.d) + setupReaction(session, input.d) } else if (input.t === 'MESSAGE_REACTION_REMOVE') { session.type = 'reaction-deleted' session.subtype = 'one' - prepareReactionSession(session, input.d) + setupReaction(session, input.d) } else if (input.t === 'MESSAGE_REACTION_REMOVE_ALL') { session.type = 'reaction-deleted' session.subtype = 'all' - prepareReactionSession(session, input.d) + setupReaction(session, input.d) } else if (input.t === 'MESSAGE_REACTION_REMOVE_EMOJI') { session.type = 'reaction-deleted' session.subtype = 'emoji' - prepareReactionSession(session, input.d) + setupReaction(session, input.d) + } else if (input.t === 'INTERACTION_CREATE' && input.d.type === Discord.Interaction.Type.APPLICATION_COMMAND) { + await bot.internal.createInteractionResponse(input.d.id, input.d.token, { + type: Discord.Interaction.CallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + }) + session.type = 'message-command' + session.subtype = input.d.guild_id ? 'group' : 'private' + session.channelId = input.d.channel_id + session.guildId = input.d.guild_id + session.userId = input.d.member.user.id + session.messageId = input.d.id + session.content = '' } else if (input.t === 'CHANNEL_UPDATE') { session.type = 'channel-updated' session.guildId = input.d.guild_id @@ -201,3 +221,59 @@ export async function adaptSession(bot: DiscordBot, input: Discord.GatewayPayloa } return session } + +const types = { + text: Discord.ApplicationCommand.OptionType.STRING, + string: Discord.ApplicationCommand.OptionType.STRING, + boolean: Discord.ApplicationCommand.OptionType.BOOLEAN, + number: Discord.ApplicationCommand.OptionType.NUMBER, + integer: Discord.ApplicationCommand.OptionType.INTEGER, + posint: Discord.ApplicationCommand.OptionType.INTEGER, + user: Discord.ApplicationCommand.OptionType.STRING, + channel: Discord.ApplicationCommand.OptionType.STRING, + guild: Discord.ApplicationCommand.OptionType.STRING, +} + +export const encodeCommand = (cmd: Universal.Command): Discord.ApplicationCommand.Params.Create => ({ + name: cmd.name, + description: cmd.description[''] || '', + description_localizations: pick(cmd.description, Discord.Locale), + options: encodeCommandOptions(cmd), +}) + +export function encodeCommandOptions(cmd: Universal.Command): Discord.ApplicationCommand.Option[] { + let list: Discord.ApplicationCommand.Option[] = [] + if (cmd.children.length) { + list.push(...cmd.children.map(child => ({ + name: child.name.slice(cmd.name.length + 1), + type: child.children.length + ? Discord.ApplicationCommand.OptionType.SUB_COMMAND_GROUP + : Discord.ApplicationCommand.OptionType.SUB_COMMAND, + options: encodeCommandOptions(child), + description: cmd.description[''] || '', + description_localizations: pick(cmd.description, Discord.Locale), + }))) + } else { + for (const arg of cmd.arguments) { + list.push({ + name: arg.name.toLowerCase(), + description: arg.description[''] || '', + description_localizations: pick(arg.description, Discord.Locale), + type: types[arg.type] ?? types.text, + required: arg.required ?? false, + }) + } + for (const option of cmd.options) { + list.push({ + name: option.name.toLowerCase(), + description: option.description[''] || '', + description_localizations: pick(option.description, Discord.Locale), + type: types[option.type] ?? types.text, + required: option.required ?? false, + min_value: option.type === 'posint' ? 1 : undefined, + }) + } + } + list = list.sort((a, b) => +b.required - +a.required) + return list +} diff --git a/adapters/discord/src/ws.ts b/adapters/discord/src/ws.ts index f8206d6b..76557b6c 100644 --- a/adapters/discord/src/ws.ts +++ b/adapters/discord/src/ws.ts @@ -1,6 +1,6 @@ import { Adapter, Logger, Schema } from '@satorijs/satori' import { GatewayIntent, GatewayOpcode, GatewayPayload } from './types' -import { adaptSession, adaptUser } from './utils' +import { adaptSession, decodeUser } from './utils' import { DiscordBot } from './bot' const logger = new Logger('discord') @@ -78,7 +78,7 @@ export class WsClient extends Adapter.WsClient { if (parsed.t === 'READY') { this._sessionId = parsed.d.session_id this._resumeUrl = parsed.d.resume_gateway_url - const self: any = adaptUser(parsed.d.user) + const self: any = decodeUser(parsed.d.user) self.selfId = self.userId delete self.userId Object.assign(this.bot, self) diff --git a/packages/core/src/message.ts b/packages/core/src/message.ts index 23c0f31e..c8f9bc03 100644 --- a/packages/core/src/message.ts +++ b/packages/core/src/message.ts @@ -36,7 +36,7 @@ export abstract class MessageEncoder { author: this.bot, channelId: this.channelId, guildId: this.guildId, - subtype: this.options.session.subtype ?? (this.guildId ? 'group' : 'private'), + subtype: this.options.session?.subtype ?? (this.guildId ? 'group' : 'private'), }) defineProperty(this.session, this.bot.platform, Object.create(this.bot.internal)) await this.prepare() diff --git a/packages/core/src/universal.ts b/packages/core/src/universal.ts index d1eb14fb..835e9111 100644 --- a/packages/core/src/universal.ts +++ b/packages/core/src/universal.ts @@ -1,5 +1,6 @@ import segment from '@satorijs/element' import { SendOptions } from './session' +import { Dict } from 'cosmokit' export namespace Universal { export interface Methods { @@ -113,4 +114,29 @@ export namespace Universal { export interface Message extends MessageBase { subtype?: string } + + export interface Command { + name: string + aliases: string[] + description: Dict + arguments: Command.Argument[] + options: Command.Option[] + children: Command[] + } + + export namespace Command { + export interface Argument { + name: string + description: Dict + type: string + required: boolean + } + + export interface Option { + name: string + description: Dict + type: string + required: boolean + } + } }