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"`);
+ }
+}