diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index 987493f9..71c2a168 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -32,6 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [ "GET /invites/", // Routes with a seperate auth system /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth + /^POST \/interactions\/\d+\/\w+\/callback/, // Public information endpoints "GET /ping", "GET /gateway", @@ -72,11 +73,7 @@ declare global { } } -export async function Authentication( - req: Request, - res: Response, - next: NextFunction, -) { +export async function Authentication(req: Request, res: Response, next: NextFunction) { if (req.method === "OPTIONS") return res.sendStatus(204); const url = req.url.replace(API_PREFIX, ""); if ( @@ -104,8 +101,7 @@ export async function Authentication( }) ) return next(); - if (!req.headers.authorization) - return next(new HTTPError("Missing Authorization Header", 401)); + if (!req.headers.authorization) return next(new HTTPError("Missing Authorization Header", 401)); try { const { decoded, user } = await checkToken(req.headers.authorization); diff --git a/src/api/routes/interactions/#interaction_id/#interaction_token/callback.ts b/src/api/routes/interactions/#interaction_id/#interaction_token/callback.ts new file mode 100644 index 00000000..4ce69446 --- /dev/null +++ b/src/api/routes/interactions/#interaction_id/#interaction_token/callback.ts @@ -0,0 +1,90 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { InteractionCallbackSchema, InteractionCallbackType, MessageType } from "@spacebar/schemas"; +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { ApplicationCommand, emitEvent, InteractionSuccessEvent, Message, MessageCreateEvent, pendingInteractions } from "@spacebar/util"; + +const router = Router({ mergeParams: true }); + +router.post("/", route({}), async (req: Request, res: Response) => { + const body = req.body as InteractionCallbackSchema; + + const interactionId = req.params.interaction_id; + const interaction = pendingInteractions.get(req.params.interaction_id); + + if (!interaction) { + return; + } + + clearTimeout(interaction.timeout); + + emitEvent({ + event: "INTERACTION_SUCCESS", + user_id: interaction?.userId, + data: { + id: interactionId, + nonce: interaction?.nonce, + }, + } as InteractionSuccessEvent); + + switch (body.type) { + case InteractionCallbackType.PONG: + // TODO + break; + case InteractionCallbackType.ACKNOWLEDGE: + // Deprected + break; + case InteractionCallbackType.CHANNEL_MESSAGE: + // TODO + break; + case InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE: + // TODO + break; + case InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: + // TODO + break; + case InteractionCallbackType.DEFERRED_UPDATE_MESSAGE: + // TODO + break; + case InteractionCallbackType.UPDATE_MESSAGE: + // TODO + break; + case InteractionCallbackType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: + // TODO + break; + case InteractionCallbackType.MODAL: + // TODO + break; + case InteractionCallbackType.PREMIUM_REQUIRED: + // Deprecated + break; + case InteractionCallbackType.IFRAME_MODAL: + // TODO + break; + case InteractionCallbackType.LAUNCH_ACTIVITY: + // TODO + break; + } + + pendingInteractions.delete(interactionId); + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/interactions/index.ts b/src/api/routes/interactions/index.ts new file mode 100644 index 00000000..815d2b86 --- /dev/null +++ b/src/api/routes/interactions/index.ts @@ -0,0 +1,82 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { randomBytes } from "crypto"; +import { InteractionSchema } from "@spacebar/schemas"; +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { Application, ApplicationCommand, emitEvent, InteractionCreateEvent, InteractionFailureEvent, Snowflake } from "@spacebar/util"; +import { pendingInteractions } from "../../../util/imports/Interactions"; + +const router = Router({ mergeParams: true }); + +router.post("/", route({}), async (req: Request, res: Response) => { + const body = req.body as InteractionSchema; + + const interactionId = Snowflake.generate(); + const interactionToken = randomBytes(24).toString("base64url"); + + emitEvent({ + event: "INTERACTION_CREATE", + user_id: req.user_id, + data: { + id: interactionId, + nonce: body.nonce, + }, + } as InteractionCreateEvent); + + emitEvent({ + event: "INTERACTION_CREATE", + user_id: body.application_id, + data: { + channel_id: body.channel_id, + guild_id: body.guild_id, + id: interactionId, + member_id: req.user_id, + token: interactionToken, + type: body.type, + nonce: body.nonce, + }, + } as InteractionCreateEvent); + + const interactionTimeout = setTimeout(() => { + emitEvent({ + event: "INTERACTION_FAILURE", + user_id: req.user_id, + data: { + id: interactionId, + nonce: body.nonce, + reason_code: 2, // when types are done: InteractionFailureReason.TIMEOUT, + }, + } as InteractionFailureEvent); + }, 3000); + + pendingInteractions.set(interactionId, { + timeout: interactionTimeout, + nonce: body.nonce, + userId: req.user_id, + guildId: body.guild_id, + channelId: body.channel_id, + type: body.type, + commandName: body.data.name, + }); + + res.sendStatus(204); +}); + +export default router; diff --git a/src/schemas/api/bots/InteractionCallbackSchema.ts b/src/schemas/api/bots/InteractionCallbackSchema.ts new file mode 100644 index 00000000..bbf68e80 --- /dev/null +++ b/src/schemas/api/bots/InteractionCallbackSchema.ts @@ -0,0 +1,7 @@ +import { MessageCreateSchema } from "@spacebar/schemas"; +import { InteractionCallbackType } from "./InteractionCallbackType"; + +export interface InteractionCallbackSchema { + type: InteractionCallbackType; + data: MessageCreateSchema; +} diff --git a/src/schemas/api/bots/InteractionCallbackType.ts b/src/schemas/api/bots/InteractionCallbackType.ts new file mode 100644 index 00000000..2a90b3ce --- /dev/null +++ b/src/schemas/api/bots/InteractionCallbackType.ts @@ -0,0 +1,14 @@ +export enum InteractionCallbackType { + PONG = 1, + ACKNOWLEDGE = 2, // Deprecated + CHANNEL_MESSAGE = 3, // Deprecated + CHANNEL_MESSAGE_WITH_SOURCE = 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_UPDATE_MESSAGE = 6, + UPDATE_MESSAGE = 7, + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, + MODAL = 9, + PREMIUM_REQUIRED = 10, // Deprecated + IFRAME_MODAL = 11, + LAUNCH_ACTIVITY = 12, +} diff --git a/src/schemas/api/bots/InteractionSchema.ts b/src/schemas/api/bots/InteractionSchema.ts new file mode 100644 index 00000000..e2fa4cd4 --- /dev/null +++ b/src/schemas/api/bots/InteractionSchema.ts @@ -0,0 +1,28 @@ +import { ApplicationCommandOption, Snowflake, UploadAttachmentRequestSchema } from "@spacebar/schemas"; +import { ApplicationCommandType } from "./ApplicationCommandSchema"; + +export interface InteractionSchema { + type: ApplicationCommandType; + application_id: Snowflake; + guild_id?: Snowflake; + channel_id: Snowflake; + message_id?: Snowflake; + message_flags?: number; + session_id?: string; + data: InteractionData; + files?: object[]; // idk the type + nonce?: string; + analytics_location?: string; + section_name?: string; + source?: string; +} + +interface InteractionData { + application_command: object; + attachments: UploadAttachmentRequestSchema[]; + id: string; + name: string; + options: ApplicationCommandOption[]; + type: number; + version: string; +} diff --git a/src/schemas/api/bots/SendableApplicationCommandDataSchema.ts b/src/schemas/api/bots/SendableApplicationCommandDataSchema.ts new file mode 100644 index 00000000..7dfbbd00 --- /dev/null +++ b/src/schemas/api/bots/SendableApplicationCommandDataSchema.ts @@ -0,0 +1,14 @@ +import { Snowflake } from "@spacebar/util"; +import { ApplicationCommandOption } from "../developers"; +import { ApplicationCommandType } from "./ApplicationCommandSchema"; + +export interface SendableApplicationCommandDataSchema { + id: Snowflake; + type?: ApplicationCommandType; + name: string; + version: Snowflake; + application_command?: object; + options?: ApplicationCommandOption[]; + target_id?: Snowflake; + attachments?: object[]; // idk the type +} diff --git a/src/schemas/api/bots/SendableMessageComponentDataSchema.ts b/src/schemas/api/bots/SendableMessageComponentDataSchema.ts new file mode 100644 index 00000000..6581fa7c --- /dev/null +++ b/src/schemas/api/bots/SendableMessageComponentDataSchema.ts @@ -0,0 +1,10 @@ +import { Snowflake } from "@spacebar/util"; +import { MessageComponentType } from "../messages"; +import { ApplicationCommandType } from "./ApplicationCommandSchema"; + +export interface SendableMessageComponentDataSchema { + component_type?: MessageComponentType; + type?: ApplicationCommandType; + custom_id: string; + values?: Snowflake[] | string[]; +} diff --git a/src/schemas/api/bots/SendableModalSubmitDataSchema.ts b/src/schemas/api/bots/SendableModalSubmitDataSchema.ts new file mode 100644 index 00000000..33c4b00a --- /dev/null +++ b/src/schemas/api/bots/SendableModalSubmitDataSchema.ts @@ -0,0 +1,9 @@ +import { UploadAttachmentRequestSchema } from "@spacebar/schemas"; +import { Attachment, Snowflake } from "@spacebar/util"; + +export interface SendableModalSubmitDataSchema { + id: Snowflake; + custom_id: string; + // components: ModalSubmitComponentData[]; // TODO: do this + attachments?: UploadAttachmentRequestSchema[]; +} diff --git a/src/schemas/api/bots/index.ts b/src/schemas/api/bots/index.ts index 6c8c46b9..b9d0b396 100644 --- a/src/schemas/api/bots/index.ts +++ b/src/schemas/api/bots/index.ts @@ -17,3 +17,9 @@ */ export * from "./ApplicationCommandCreateSchema"; export * from "./ApplicationCommandSchema"; +export * from "./InteractionSchema"; +export * from "./InteractionCallbackSchema"; +export * from "./InteractionCallbackType"; +export * from "./SendableApplicationCommandDataSchema"; +export * from "./SendableMessageComponentDataSchema"; +export * from "./SendableModalSubmitDataSchema"; diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index 6a383f21..9efcebcd 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -29,7 +29,7 @@ import { Webhook } from "./Webhook"; import { Sticker } from "./Sticker"; import { Attachment } from "./Attachment"; import { NewUrlUserSignatureData } from "../Signing"; -import { ActionRowComponent, Embed, MessageType, PartialMessage, Poll, Reaction } from "@spacebar/schemas"; +import { ActionRowComponent, ApplicationCommandType, Embed, MessageType, PartialMessage, Poll, Reaction } from "@spacebar/schemas"; @Entity({ name: "messages", @@ -177,8 +177,16 @@ export class Message extends BaseClass { id: string; type: InteractionType; name: string; - user_id: string; // the user who invoked the interaction - // user: User; // TODO: autopopulate user + }; + + @Column({ type: "simple-json", nullable: true }) + interaction_metadata?: { + id: string; + type: InteractionType; + user_id: string; + authorizing_integration_owners: object; + name: string; + command_type: ApplicationCommandType; }; @Column({ type: "simple-json", nullable: true }) @@ -231,7 +239,7 @@ export class Message extends BaseClass { channel_id: this.channel_id!, type: this.type, content: this.content!, - author: {...this.author!, avatar: this.author?.avatar ?? null }, + author: { ...this.author!, avatar: this.author?.avatar ?? null }, flags: this.flags, application_id: this.application_id, //channel: this.channel, // TODO: ephemeral DM channels diff --git a/src/util/imports/Interactions.ts b/src/util/imports/Interactions.ts new file mode 100644 index 00000000..d4770a49 --- /dev/null +++ b/src/util/imports/Interactions.ts @@ -0,0 +1,32 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { ApplicationCommandType } from "@spacebar/schemas"; +import { Snowflake } from "@spacebar/util"; + +interface PendingInteraction { + timeout: NodeJS.Timeout; + userId: string; + channelId?: string; + guildId?: string; + nonce?: string; + type: ApplicationCommandType; + commandName: string; +} + +export const pendingInteractions = new Map(); diff --git a/src/util/imports/index.ts b/src/util/imports/index.ts index 4bc5a6c5..d9477646 100644 --- a/src/util/imports/index.ts +++ b/src/util/imports/index.ts @@ -19,3 +19,4 @@ export * from "./OrmUtils"; export * from "./Erlpack"; export * from "./Jimp"; +export * from "./Interactions"; diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts index 8be0dd39..ac0d5443 100644 --- a/src/util/interfaces/Event.ts +++ b/src/util/interfaces/Event.ts @@ -35,6 +35,7 @@ import { ReadyUserGuildSettingsEntries, ReadyPrivateChannel, GuildOrUnavailable, + Snowflake, } from "@spacebar/util"; import { JsonValue } from "@protobuf-ts/runtime"; import { ApplicationCommand, GuildCreateResponse, PartialEmoji, PublicMember, PublicUser, PublicVoiceState, RelationshipType, UserPrivate } from "@spacebar/schemas"; @@ -132,10 +133,7 @@ export interface ReadyEventData { _trace?: string[]; // trace of the request, used for debugging } -export type TraceNode = - | { micros: number; calls: TraceNode[] } - | { micros: number } - | string; +export type TraceNode = { micros: number; calls: TraceNode[] } | { micros: number } | string; export type TraceRoot = [string, { micros: number; calls: TraceNode[] }]; @@ -511,7 +509,29 @@ export interface ApplicationCommandDeleteEvent extends Event { export interface InteractionCreateEvent extends Event { event: "INTERACTION_CREATE"; - data: Interaction; + data: + | Interaction + | { + id: Snowflake; + nonce?: string; + }; +} + +export interface InteractionSuccessEvent extends Event { + event: "INTERACTION_SUCCESS"; + data: { + id: Snowflake; + nonce: string; + }; +} + +export interface InteractionFailureEvent extends Event { + event: "INTERACTION_FAILURE"; + data: { + id: Snowflake; + nonce?: string; + reason_code: number; // TODO: types? + }; } export interface MessageAckEvent extends Event { @@ -615,6 +635,8 @@ export type EventData = | ApplicationCommandUpdateEvent | ApplicationCommandDeleteEvent | InteractionCreateEvent + | InteractionSuccessEvent + | InteractionFailureEvent | MessageAckEvent | RelationshipAddEvent | RelationshipRemoveEvent; @@ -663,6 +685,8 @@ export enum EVENTEnum { UserConnectionsUpdate = "USER_CONNECTIONS_UPDATE", WebhooksUpdate = "WEBHOOKS_UPDATE", InteractionCreate = "INTERACTION_CREATE", + InteractionSuccess = "INTERACTION_SUCCESS", + InteractionFailure = "INTERACTION_FAILURE", VoiceStateUpdate = "VOICE_STATE_UPDATE", VoiceServerUpdate = "VOICE_SERVER_UPDATE", ApplicationCommandCreate = "APPLICATION_COMMAND_CREATE", @@ -716,6 +740,8 @@ export type EVENT = | "USER_NOTE_UPDATE" | "WEBHOOKS_UPDATE" | "INTERACTION_CREATE" + | "INTERACTION_SUCCESS" + | "INTERACTION_FAILURE" | "VOICE_STATE_UPDATE" | "VOICE_SERVER_UPDATE" | "STREAM_CREATE" diff --git a/src/util/migration/postgres/1760694225225-message_interaction_metadata.ts b/src/util/migration/postgres/1760694225225-message_interaction_metadata.ts new file mode 100644 index 00000000..317361da --- /dev/null +++ b/src/util/migration/postgres/1760694225225-message_interaction_metadata.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MessageInteractionMetadata1760694225225 implements MigrationInterface { + name = "MessageInteractionMetadata1760694225225"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" ADD "interaction_metadata" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "interaction_metadata"`); + } +}