/*
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 "node:crypto";
import { Request, Response, Router } from "express";
import { IsNull } from "typeorm";
import {
InteractionFailureReason,
InteractionSchema,
InteractionType,
InteractionCreateSchema,
InteractionData,
InteractionResolvedData,
ApplicationCommandOption,
ApplicationCommandOptionType,
InteractionResolvedPartialMember,
} from "@spacebar/schemas";
import { route } from "@spacebar/api/middlewares";
import { Guild, Member, Message, Channel, User, Role } from "@spacebar/database";
import { Config, emitEvent, getPermission, InteractionCreateEvent, InteractionFailureEvent, Snowflake } from "@spacebar/util";
import { pendingInteractions } from "@spacebar/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");
await emitEvent({
event: "INTERACTION_CREATE",
user_id: req.user_id,
data: {
id: interactionId,
nonce: body.nonce,
},
} satisfies InteractionCreateEvent);
const user = req.user;
const channel = await Channel.findOneOrFail({ where: { id: body.channel_id } });
(await channel.getUserPermissions({ user_id: req.user_id })).hasThrow("USE_APPLICATION_COMMANDS");
if (body.guild_id) await Member.IsInGuildOrFail(req.user_id, body.guild_id);
const interactionData: Partial = {
id: interactionId,
application_id: body.application_id,
channel_id: body.channel_id,
channel: channel.toJSON(),
type: body.type,
token: interactionToken,
version: 1,
entitlements: [],
authorizing_integration_owners: { "0": req.user_id },
attachment_size_limit: Config.get().cdn.maxAttachmentSize,
};
if (body.type === InteractionType.ApplicationCommand || body.type === InteractionType.MessageComponent || body.type === InteractionType.ModalSubmit) {
interactionData.data = body.data;
const typedData: InteractionData = interactionData.data as InteractionData;
typedData.resolved = await resolveInteractionDataRecursive(typedData.options, { guild_id: body.guild_id ?? null, channel_id: body.channel_id });
}
if (body.type != InteractionType.Ping) {
interactionData.locale = user?.settings?.locale;
}
if (body.guild_id) {
interactionData.context = 0;
interactionData.guild_id = body.guild_id;
interactionData.app_permissions = (await getPermission(body.application_id, body.guild_id, body.channel_id)).bitfield.toString();
const guild = await Guild.findOneOrFail({ where: { id: body.guild_id } });
const member = await Member.findOneOrFail({ where: { guild_id: body.guild_id, id: req.user_id }, relations: { user: true, roles: true } });
interactionData.guild = {
id: guild.id,
features: guild.features,
locale: guild.preferred_locale!,
};
interactionData.guild_locale = guild.preferred_locale;
interactionData.member = member.toPublicMember();
} else {
interactionData.user = user.toPublicUser();
interactionData.app_permissions = (await getPermission(body.application_id, "", body.channel_id)).bitfield.toString();
if (body.channel_id === body.application_id) {
interactionData.context = 1;
} else {
interactionData.context = 2;
}
}
if (body.type === InteractionType.MessageComponent || body.data.type === InteractionType.ModalSubmit) {
interactionData.message = (
await Message.findOneOrFail({
where: { id: body.message_id, flags: undefined },
relations: {
author: true,
webhook: true,
application: true,
mentions: true,
mention_roles: true,
mention_channels: true,
sticker_items: true,
attachments: true,
thread: {
recipients: {
user: true,
},
},
},
})
).toJSON();
}
await emitEvent({
event: "INTERACTION_CREATE",
user_id: body.application_id,
data: {
...interactionData,
member_id: req.user_id, // TODO: is this correct?
id: interactionId, // TODO: is this correct?
},
} satisfies InteractionCreateEvent);
const interactionTimeout = setTimeout(() => {
emitEvent({
event: "INTERACTION_FAILURE",
user_id: req.user_id,
data: {
id: interactionId,
nonce: body.nonce,
reason_code: InteractionFailureReason.TIMEOUT,
},
} satisfies InteractionFailureEvent);
}, 3000);
pendingInteractions.set(interactionId, {
timeout: interactionTimeout,
nonce: body.nonce,
applicationId: body.application_id,
userId: req.user_id,
guildId: body.guild_id,
channelId: body.channel_id,
type: body.type,
commandType: body.data.type,
commandName: body.data.name,
messageId: body.message_id,
});
res.sendStatus(204);
});
async function resolveInteractionDataRecursive(opts: ApplicationCommandOption[], scope: { guild_id: string | null; channel_id: string }): Promise {
const res: InteractionResolvedData = {};
for (const opt of opts) {
switch (opt.type) {
// resolvable types
case ApplicationCommandOptionType.USER:
res.users ??= {};
res.members ??= {};
// TODO: different schema, cba to fix right now
if ("value" in opt && !(opt.value in res.users)) {
if (!scope.guild_id) throw new Error("Cannot resolve users/members in non-guild context");
const member = await Member.findOneOrFail({ where: { id: opt.value as string, guild_id: scope.guild_id }, relations: { user: true, roles: true } });
res.members[opt.value as string] = { ...member.toPublicMember(), mute: undefined, deaf: undefined, user: undefined } as InteractionResolvedPartialMember;
res.users[opt.value as string] = member.user.toPartialUser();
}
break;
case ApplicationCommandOptionType.CHANNEL:
res.channels ??= {};
// TODO: different schema, cba to fix right now
if ("value" in opt && !(opt.value in res.channels)) {
if (scope.guild_id == null && opt.value != scope.channel_id) throw new Error("Cannot resolve other channels in non-guild context.");
const channel = await Channel.findOneOrFail({ where: { guild_id: scope.guild_id ?? IsNull(), id: opt.value as string } });
res.channels[opt.value as string] = channel.toJSON();
}
break;
case ApplicationCommandOptionType.ROLE:
res.roles ??= {};
// TODO: different schema, cba to fix right now
if ("value" in opt && !(opt.value in res.roles)) {
if (!scope.guild_id) throw new Error("Cannot resolve roles in non-guild context");
const role = await Role.findOneOrFail({ where: { guild_id: scope.guild_id, id: opt.value as string } });
res.roles[opt.value as string] = role.toJSON();
}
break;
// these can recurse
case ApplicationCommandOptionType.SUB_COMMAND:
case ApplicationCommandOptionType.SUB_COMMAND_GROUP:
if (opt.options?.length) {
const _res = await resolveInteractionDataRecursive(opt.options, scope);
if (_res.users) {
res.users ??= {};
res.users = { ...res.users, ..._res.users };
}
if (_res.roles) {
res.roles ??= {};
res.roles = { ...res.roles, ..._res.roles };
}
if (_res.channels) {
res.channels ??= {};
res.channels = { ...res.channels, ..._res.channels };
}
if (_res.attachments) {
res.attachments ??= {};
res.attachments = { ...res.attachments, ..._res.attachments };
}
if (_res.members) {
res.members ??= {};
res.members = { ...res.members, ..._res.members };
}
if (_res.messages) {
res.messages ??= {};
res.messages = { ...res.messages, ..._res.messages };
}
}
break;
// ignored
case ApplicationCommandOptionType.STRING:
case ApplicationCommandOptionType.INTEGER:
case ApplicationCommandOptionType.BOOLEAN:
break;
default:
throw new Error("Unknown application command option type " + opt.type);
}
}
return res;
}
export default router;