feat: interactions (incomplete)

This commit is contained in:
CyberL1 2025-10-17 13:01:36 +02:00 committed by Rory&
parent 863e7f6ff3
commit 81d4f1f310
15 changed files with 352 additions and 16 deletions

View File

@ -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);

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View File

@ -0,0 +1,7 @@
import { MessageCreateSchema } from "@spacebar/schemas";
import { InteractionCallbackType } from "./InteractionCallbackType";
export interface InteractionCallbackSchema {
type: InteractionCallbackType;
data: MessageCreateSchema;
}

View File

@ -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,
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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";

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Snowflake, PendingInteraction>();

View File

@ -19,3 +19,4 @@
export * from "./OrmUtils";
export * from "./Erlpack";
export * from "./Jimp";
export * from "./Interactions";

View File

@ -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"

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MessageInteractionMetadata1760694225225 implements MigrationInterface {
name = "MessageInteractionMetadata1760694225225";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "messages" ADD "interaction_metadata" text`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "interaction_metadata"`);
}
}