feat(futo-backups-bot): /beta-invite with personal links, claim batches, and optional role mention (#571)

This commit is contained in:
Antoine Lecompte
2026-08-26 13:41:04 -04:00
committed by GitHub
parent 6a37d2674a
commit 9abf625652
11 changed files with 554 additions and 16 deletions
+33
View File
@@ -65,6 +65,39 @@ unlinked ones link first. The role unlocks `#customer` (customer chat, also visi
Admin/Team/Yucca/FUTO). `#support` stays visible to everyone;
ticket threads live under it as before.
## Closed-beta invites
Staff (`DISCORD_STAFF_ROLE_ID`) hand out beta access from Discord with
**`/beta-invite`** — either `user:<user>` (the bot DMs a personal
link; if DMs are closed the invoker gets it ephemerally to pass on) or
`channel:<channel> limit:<n>` (the bot posts a **Claim your invite** button;
each click hands out one invite until the limit, then the button flips to a
disabled *All invites claimed*). An optional `mention:<role>` prefixes the
channel post with a role ping.
An invite is a `userAllowlist` row minted without an email (`email` is
nullable; `discordUserId` unique ties the claim to the Discord account —
one claim ever per account, and already-linked accounts are refused). Batches
live in `discordInviteBatches` (`maxClaims`, counted transactionally under
the same advisory-lock pattern as linking, so concurrent clicks cannot
overshoot). The raw `inviteCode` is never shown; the claimer gets
`https://<web>/login/invite?token=<nonce>` where the nonce is a 10-minute
single-use `discordLinkRequests` row pointing at the claim (`allowlistId`).
Re-clicking re-issues a fresh nonce for the *same* claim. Redeeming rides the
OIDC signup path (`discord_invite` cookie next to the classic invite-code
cookie) and — the anti-forwarding binding — **auto-creates the Discord link
to the claimer's Discord account**, so a forwarded link burns the claimer's
only claim and ties the new account to their Discord identity.
- Bot: **`POST /internal/discord/invites`** `{discordUserId,
discordUsername, batchId?}` → `{code, expiresAt, remaining}` (409
`ALREADY_LINKED` / `INVITE_USED` / `BATCH_EXHAUSTED`),
**`POST /internal/discord/invite-batches`**, and a `PATCH .../message` to
record the posted message id.
- Web: `/login/invite?token=` validates via the public
**`GET /api/discord/invites/:code`** and offers *Join the beta*; an expired
token points back at the Discord button.
## Tickets: Discord is the source of truth
No ticket table. A ticket is a **private thread** under the support channel;
@@ -5,6 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule';
import { DiscordRepository } from './repositories/discord.repository';
import { TranscriptStorageRepository } from './repositories/transcriptStorage.repository';
import { YuccaApiRepository } from './repositories/yuccaApi.repository';
import { InviteService } from './services/invite.service';
import { SupportService } from './services/support.service';
import { SweepService } from './services/sweep.service';
@@ -16,6 +17,7 @@ export const providers = [
DiscordRepository,
YuccaApiRepository,
TranscriptStorageRepository,
InviteService,
SupportService,
SweepService,
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
+1
View File
@@ -5,4 +5,5 @@ export enum ComponentId {
TicketModal = 'ticket-modal',
TicketDescription = 'ticket-description',
ClaimRole = 'claim-role',
ClaimInvite = 'claim-invite',
}
@@ -18,6 +18,7 @@ import { env } from 'src/env';
import { TranscriptMessage } from 'src/utils/transcript';
const THREAD_AUTO_ARCHIVE_MINUTES = 10_080;
const CANNOT_MESSAGE_USER = 50_007;
@Injectable()
export class DiscordRepository {
@@ -62,6 +63,25 @@ export class DiscordRepository {
.setName('claim-backups-role')
.setDescription('Claim the FUTO Backups customer role for your linked account')
.toJSON(),
new SlashCommandBuilder()
.setName('beta-invite')
.setDescription('Invite a user to the closed beta or post a claim button (staff only)')
.addUserOption((option) => option.setName('user').setDescription('DM a personal invite to this user'))
.addChannelOption((option) =>
option
.setName('channel')
.setDescription('Post a claim button in this channel')
.addChannelTypes(ChannelType.GuildText),
)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many invites the channel post hands out')
.setMinValue(1)
.setMaxValue(500),
)
.addRoleOption((option) => option.setName('mention').setDescription('Role to mention in the channel post'))
.toJSON(),
]);
}
@@ -71,9 +91,22 @@ export class DiscordRepository {
return [...messages.values()];
}
async sendMessage(channelId: string, message: MessageCreateOptions): Promise<void> {
async sendMessage(channelId: string, message: MessageCreateOptions): Promise<Message> {
const channel = await this.textChannel(channelId);
await channel.send(message);
return channel.send(message);
}
async sendDirectMessage(discordUserId: string, message: MessageCreateOptions): Promise<boolean> {
const user = await this.requireClient().users.fetch(discordUserId);
try {
await user.send(message);
return true;
} catch (error) {
if (error instanceof DiscordAPIError && error.code === CANNOT_MESSAGE_USER) {
return false;
}
throw error;
}
}
async addRoleToMember(discordUserId: string, roleId: string): Promise<void> {
@@ -13,6 +13,20 @@ const linkSchema = z.object({
discordUsername: z.string(),
});
const inviteBatchCreatedSchema = z.object({
id: z.string(),
});
const inviteCreatedSchema = z.object({
code: z.string(),
expiresAt: z.coerce.date(),
remaining: z.number().nullable(),
});
const conflictSchema = z.object({
message: z.string(),
});
const userSummarySchema = z.object({
id: z.string(),
name: z.string(),
@@ -26,6 +40,9 @@ const userSummarySchema = z.object({
export type DiscordLink = z.infer<typeof linkSchema>;
export type UserSummary = z.infer<typeof userSummarySchema>;
export type LinkRequestCreated = z.infer<typeof linkRequestCreatedSchema>;
export type InviteResult =
| ({ status: 'ok' } & z.infer<typeof inviteCreatedSchema>)
| { status: 'already-linked' | 'invite-used' | 'exhausted' };
@Injectable()
export class YuccaApiRepository {
@@ -59,6 +76,52 @@ export class YuccaApiRepository {
);
}
async createInviteBatch(
guildId: string,
channelId: string,
maxClaims: number,
createdByDiscordUserId: string,
): Promise<string> {
const response = await this.request('POST', '/api/internal/discord/invite-batches', {
guildId,
channelId,
maxClaims,
createdByDiscordUserId,
});
return inviteBatchCreatedSchema.parse(await response.json()).id;
}
async setInviteBatchMessage(batchId: string, messageId: string): Promise<void> {
await this.request('PATCH', `/api/internal/discord/invite-batches/${encodeURIComponent(batchId)}/message`, {
messageId,
});
}
async createInvite(discordUserId: string, discordUsername: string, batchId?: string): Promise<InviteResult> {
const response = await this.request(
'POST',
'/api/internal/discord/invites',
{ discordUserId, discordUsername, ...(batchId ? { batchId } : {}) },
[409],
);
if (response.status === 409) {
const { message } = conflictSchema.parse(await response.json());
switch (message) {
case 'ALREADY_LINKED': {
return { status: 'already-linked' };
}
case 'INVITE_USED': {
return { status: 'invite-used' };
}
case 'BATCH_EXHAUSTED': {
return { status: 'exhausted' };
}
}
throw new Error(`Unexpected invite conflict: ${message}`);
}
return { status: 'ok', ...inviteCreatedSchema.parse(await response.json()) };
}
async getUserSummary(userId: string): Promise<UserSummary> {
const response = await this.request('GET', `/api/internal/discord/users/${encodeURIComponent(userId)}/summary`);
return userSummarySchema.parse(await response.json());
@@ -0,0 +1,224 @@
import { ComponentId } from 'src/enum';
import { env } from 'src/env';
import { InviteService } from 'src/services/invite.service';
import { Mocks, newMocks } from '../../test/mocks';
const okInvite = { status: 'ok', code: 'token', expiresAt: new Date(Date.now() + 600_000), remaining: null } as const;
const newCommandInteraction = (options: Record<string, unknown>, overrides: object = {}) =>
({
isButton: () => false,
isModalSubmit: () => false,
isChatInputCommand: () => true,
commandName: 'beta-invite',
user: { id: 'staff-1', username: 'Staffer' },
guildId: 'guild-1',
options: {
getUser: jest.fn().mockReturnValue(options.user ?? null),
getChannel: jest.fn().mockReturnValue(options.channel ?? null),
getInteger: jest.fn().mockReturnValue(options.limit ?? null),
getRole: jest.fn().mockReturnValue(options.mention ?? null),
},
replied: false,
deferred: false,
reply: jest.fn().mockResolvedValue(void 0),
deferReply: jest.fn().mockResolvedValue(void 0),
editReply: jest.fn().mockResolvedValue(void 0),
member: { roles: ['staff-role'] as string[] },
...overrides,
}) as never;
const newClaimInteraction = (overrides: object = {}) =>
({
isButton: () => true,
isModalSubmit: () => false,
isChatInputCommand: () => false,
customId: `${ComponentId.ClaimInvite}:batch-1`,
user: { id: '123456789', username: 'Someone' },
message: { edit: jest.fn().mockResolvedValue(void 0) },
replied: false,
deferred: false,
reply: jest.fn().mockResolvedValue(void 0),
deferReply: jest.fn().mockResolvedValue(void 0),
editReply: jest.fn().mockResolvedValue(void 0),
...overrides,
}) as never;
describe(InviteService.name, () => {
let sut: InviteService;
let mocks: Mocks;
beforeEach(() => {
mocks = newMocks();
sut = new InviteService(mocks.logger as never, mocks.discord as never, mocks.api as never);
env.DISCORD_STAFF_ROLE_ID = 'staff-role';
});
describe('onInviteCommand', () => {
it('refuses non-staff invokers', async () => {
const interaction = newCommandInteraction({ user: { id: 'u1' } }, { member: { roles: [] as string[] } });
await sut.onInviteCommand(interaction);
expect((interaction as { reply: jest.Mock }).reply).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Only staff can send beta invites.' }),
);
expect(mocks.api.createInvite).not.toHaveBeenCalled();
});
it('requires exactly one of user and channel', async () => {
const both = newCommandInteraction({ user: { id: 'u1' }, channel: { id: 'c1' } });
const neither = newCommandInteraction({});
await sut.onInviteCommand(both);
await sut.onInviteCommand(neither);
for (const interaction of [both, neither]) {
expect((interaction as { reply: jest.Mock }).reply).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Pick either a user to DM or a channel to post in.' }),
);
}
expect(mocks.api.createInvite).not.toHaveBeenCalled();
});
it('DMs a personal invite link in user mode', async () => {
mocks.api.createInvite.mockResolvedValue(okInvite);
const interaction = newCommandInteraction({ user: { id: 'u1', username: 'target' } });
await sut.onInviteCommand(interaction);
expect(mocks.api.createInvite).toHaveBeenCalledWith('u1', 'target');
expect(mocks.discord.sendDirectMessage).toHaveBeenCalledWith(
'u1',
expect.objectContaining({ content: expect.stringContaining('closed beta') }),
);
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'Invite sent to <@u1>.',
});
});
it('hands the link to the invoker when the target has DMs closed', async () => {
mocks.api.createInvite.mockResolvedValue(okInvite);
mocks.discord.sendDirectMessage.mockResolvedValue(false);
const interaction = newCommandInteraction({ user: { id: 'u1', username: 'target' } });
await sut.onInviteCommand(interaction);
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: expect.stringContaining('/login/invite?token=token'),
});
});
it('reports an already-linked target', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'already-linked' });
const interaction = newCommandInteraction({ user: { id: 'u1', username: 'target' } });
await sut.onInviteCommand(interaction);
expect(mocks.discord.sendDirectMessage).not.toHaveBeenCalled();
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: '<@u1> already has a FUTO Backups account — no invite needed.',
});
});
it('requires a limit in channel mode', async () => {
const interaction = newCommandInteraction({ channel: { id: 'c1' } });
await sut.onInviteCommand(interaction);
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'Set a limit when posting invites to a channel.',
});
expect(mocks.api.createInviteBatch).not.toHaveBeenCalled();
});
it('posts a claim button and records the message id in channel mode', async () => {
mocks.api.createInviteBatch.mockResolvedValue('batch-1');
mocks.discord.sendMessage.mockResolvedValue({ id: 'message-1' } as never);
const interaction = newCommandInteraction({ channel: { id: 'c1' }, limit: 10 });
await sut.onInviteCommand(interaction);
expect(mocks.api.createInviteBatch).toHaveBeenCalledWith('guild-1', 'c1', 10, 'staff-1');
expect(mocks.discord.sendMessage).toHaveBeenCalledWith(
'c1',
expect.objectContaining({ content: expect.stringContaining('10 spots') }),
);
expect(mocks.api.setInviteBatchMessage).toHaveBeenCalledWith('batch-1', 'message-1');
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'Posted 10 invites in <#c1>.',
});
});
it('mentions the requested role in the channel post', async () => {
mocks.api.createInviteBatch.mockResolvedValue('batch-1');
mocks.discord.sendMessage.mockResolvedValue({ id: 'message-1' } as never);
const interaction = newCommandInteraction({ channel: { id: 'c1' }, limit: 5, mention: { id: 'role-1' } });
await sut.onInviteCommand(interaction);
expect(mocks.discord.sendMessage).toHaveBeenCalledWith(
'c1',
expect.objectContaining({ content: expect.stringMatching(/^<@&role-1> We're opening 5 spots/) }),
);
});
});
describe('onClaimInvite', () => {
it('replies with a personal link on a successful claim', async () => {
mocks.api.createInvite.mockResolvedValue({ ...okInvite, remaining: 3 });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect(mocks.api.createInvite).toHaveBeenCalledWith('123456789', 'Someone', 'batch-1');
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith(
expect.objectContaining({ content: expect.stringContaining("You're in!") }),
);
expect((interaction as { message: { edit: jest.Mock } }).message.edit).not.toHaveBeenCalled();
});
it('disables the claim button when the last invite is claimed', async () => {
mocks.api.createInvite.mockResolvedValue({ ...okInvite, remaining: 0 });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect((interaction as { message: { edit: jest.Mock } }).message.edit).toHaveBeenCalled();
});
it('reports exhaustion and disables the button', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'exhausted' });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect((interaction as { message: { edit: jest.Mock } }).message.edit).toHaveBeenCalled();
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'All invites have been claimed — keep an eye out for the next drop.',
});
});
it('turns an already-linked claimer away', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'already-linked' });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'You already have a FUTO Backups account — no invite needed.',
});
});
it('reports a spent invite', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'invite-used' });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: 'You already used your beta invite.',
});
});
});
});
@@ -0,0 +1,163 @@
import { LoggerRepository } from '@common/server/otel';
import { Injectable } from '@nestjs/common';
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
MessageFlags,
User,
} from 'discord.js';
import { ComponentId } from 'src/enum';
import { env } from 'src/env';
import { DiscordRepository } from 'src/repositories/discord.repository';
import { YuccaApiRepository } from 'src/repositories/yuccaApi.repository';
import { isStaff } from 'src/utils/staff';
@Injectable()
export class InviteService {
constructor(
private readonly logger: LoggerRepository,
private readonly discord: DiscordRepository,
private readonly api: YuccaApiRepository,
) {}
async onInviteCommand(interaction: ChatInputCommandInteraction): Promise<void> {
if (!isStaff(interaction)) {
await interaction.reply({ content: 'Only staff can send beta invites.', flags: MessageFlags.Ephemeral });
return;
}
const target = interaction.options.getUser('user');
const channel = interaction.options.getChannel('channel');
if (Boolean(target) === Boolean(channel)) {
await interaction.reply({
content: 'Pick either a user to DM or a channel to post in.',
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (target) {
return this.inviteUser(interaction, target);
}
return this.postBatch(
interaction,
channel!.id,
interaction.options.getInteger('limit'),
interaction.options.getRole('mention')?.id ?? null,
);
}
private async inviteUser(interaction: ChatInputCommandInteraction, target: User): Promise<void> {
const result = await this.api.createInvite(target.id, target.username);
if (result.status !== 'ok') {
const content =
result.status === 'invite-used'
? `<@${target.id}> already used their beta invite.`
: `<@${target.id}> already has a FUTO Backups account — no invite needed.`;
await interaction.editReply({ content });
return;
}
const url = this.inviteUrl(result.code);
const delivered = await this.discord.sendDirectMessage(target.id, {
content: "You're invited to the FUTO Backups closed beta! This personal link is valid for 10 minutes.",
components: [this.linkRow(url)],
});
await interaction.editReply({
content: delivered
? `Invite sent to <@${target.id}>.`
: `<@${target.id}> has DMs closed — pass this personal link along: ${url}`,
});
}
private async postBatch(
interaction: ChatInputCommandInteraction,
channelId: string,
limit: number | null,
mentionRoleId: string | null,
): Promise<void> {
if (!limit) {
await interaction.editReply({ content: 'Set a limit when posting invites to a channel.' });
return;
}
const batchId = await this.api.createInviteBatch(
interaction.guildId ?? env.DISCORD_GUILD_ID,
channelId,
limit,
interaction.user.id,
);
const message = await this.discord.sendMessage(channelId, {
content: `${mentionRoleId ? `<@&${mentionRoleId}> ` : ''}We're opening ${limit} spot${limit === 1 ? '' : 's'} in the FUTO Backups closed beta — first come, first served.`,
components: [this.claimRow(batchId, 'Claim your invite')],
});
// The message id is audit-only (disabling edits interaction.message), so a
// failure here must not report a live drop as failed and invite a repost.
await this.api
.setInviteBatchMessage(batchId, message.id)
.catch((error: unknown) => this.logger.warn(error, 'could not record the invite drop message id'));
await interaction.editReply({ content: `Posted ${limit} invites in <#${channelId}>.` });
}
async onClaimInvite(interaction: ButtonInteraction): Promise<void> {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const batchId = interaction.customId.slice(ComponentId.ClaimInvite.length + 1);
const result = await this.api.createInvite(interaction.user.id, interaction.user.username, batchId);
switch (result.status) {
case 'already-linked': {
await interaction.editReply({ content: 'You already have a FUTO Backups account — no invite needed.' });
return;
}
case 'invite-used': {
await interaction.editReply({ content: 'You already used your beta invite.' });
return;
}
case 'exhausted': {
await this.disableClaimButton(interaction, batchId);
await interaction.editReply({
content: 'All invites have been claimed — keep an eye out for the next drop.',
});
return;
}
}
await interaction.editReply({
content: "You're in! This personal link is valid for 10 minutes — claim again if it expires.",
components: [this.linkRow(this.inviteUrl(result.code))],
});
if (result.remaining === 0) {
await this.disableClaimButton(interaction, batchId);
}
}
private async disableClaimButton(interaction: ButtonInteraction, batchId: string): Promise<void> {
await interaction.message
.edit({ components: [this.claimRow(batchId, 'All invites claimed', true)] })
.catch((error: unknown) => this.logger.warn(error, 'could not disable the claim button'));
}
private inviteUrl(code: string): string {
return `${env.WEB_URL}/login/invite?token=${code}`;
}
private linkRow(url: string) {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Join the beta').setURL(url),
);
}
private claimRow(batchId: string, label: string, disabled = false) {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${ComponentId.ClaimInvite}:${batchId}`)
.setLabel(label)
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
);
}
}
@@ -1,5 +1,6 @@
import { ThreadChannel } from 'discord.js';
import { ComponentId } from 'src/enum';
import { InviteService } from 'src/services/invite.service';
import { SupportService } from 'src/services/support.service';
import { Mocks, newMocks } from '../../test/mocks';
@@ -84,7 +85,12 @@ describe(SupportService.name, () => {
beforeEach(() => {
mocks = newMocks();
sut = new SupportService(mocks.logger as never, mocks.discord as never, mocks.api as never);
sut = new SupportService(
mocks.logger as never,
mocks.discord as never,
mocks.api as never,
new InviteService(mocks.logger as never, mocks.discord as never, mocks.api as never),
);
});
describe('open ticket button', () => {
@@ -22,6 +22,8 @@ import { ComponentId } from 'src/enum';
import { env } from 'src/env';
import { DiscordRepository } from 'src/repositories/discord.repository';
import { DiscordLink, UserSummary, YuccaApiRepository } from 'src/repositories/yuccaApi.repository';
import { InviteService } from 'src/services/invite.service';
import { isStaff } from 'src/utils/staff';
const LINK_POLL_INTERVAL_MS = 5000;
const LINK_LOOKUP_TIMEOUT_MS = 2000;
@@ -51,6 +53,7 @@ export class SupportService implements OnApplicationBootstrap {
private readonly logger: LoggerRepository,
private readonly discord: DiscordRepository,
private readonly api: YuccaApiRepository,
private readonly invite: InviteService,
) {}
async onApplicationBootstrap() {
@@ -75,6 +78,9 @@ export class SupportService implements OnApplicationBootstrap {
async handleInteraction(interaction: Interaction): Promise<void> {
try {
if (interaction.isButton() && interaction.customId.startsWith(`${ComponentId.ClaimInvite}:`)) {
return await this.invite.onClaimInvite(interaction);
}
if (interaction.isButton()) {
switch (interaction.customId) {
case ComponentId.OpenTicket:
@@ -101,6 +107,9 @@ export class SupportService implements OnApplicationBootstrap {
if (interaction.isChatInputCommand() && interaction.commandName === 'claim-backups-role') {
return await this.onClaimRequested(interaction);
}
if (interaction.isChatInputCommand() && interaction.commandName === 'beta-invite') {
return await this.invite.onInviteCommand(interaction);
}
} catch (error) {
this.logger.error(error, 'failed to handle interaction');
if (!interaction.isButton() && !interaction.isModalSubmit() && !interaction.isChatInputCommand()) {
@@ -291,7 +300,7 @@ export class SupportService implements OnApplicationBootstrap {
}
private async onStaffTicket(interaction: ChatInputCommandInteraction) {
if (!this.isStaff(interaction)) {
if (!isStaff(interaction)) {
await interaction.reply({ content: 'Only staff can open tickets for users.', flags: MessageFlags.Ephemeral });
return;
}
@@ -352,7 +361,7 @@ export class SupportService implements OnApplicationBootstrap {
}
private async onStaffNotesRequested(interaction: ChatInputCommandInteraction) {
if (!this.isStaff(interaction)) {
if (!isStaff(interaction)) {
await interaction.reply({ content: 'Only staff can view staff notes.', flags: MessageFlags.Ephemeral });
return;
}
@@ -375,7 +384,7 @@ export class SupportService implements OnApplicationBootstrap {
}
private async onCloseRequested(interaction: ButtonInteraction) {
if (!this.isStaff(interaction)) {
if (!isStaff(interaction)) {
await interaction.reply({ content: 'Only staff can close tickets.', flags: MessageFlags.Ephemeral });
return;
}
@@ -400,16 +409,6 @@ export class SupportService implements OnApplicationBootstrap {
await this.discord.closeThread(thread);
}
private isStaff(interaction: ButtonInteraction | ChatInputCommandInteraction): boolean {
const roles = interaction.member?.roles;
if (!roles) {
return false;
}
return Array.isArray(roles)
? roles.includes(env.DISCORD_STAFF_ROLE_ID)
: roles.cache.has(env.DISCORD_STAFF_ROLE_ID);
}
private syncUsername(link: DiscordLink, user: User) {
if (!user.username || link.discordUsername === user.username) {
return;
@@ -0,0 +1,10 @@
import { ButtonInteraction, ChatInputCommandInteraction } from 'discord.js';
import { env } from 'src/env';
export const isStaff = (interaction: ButtonInteraction | ChatInputCommandInteraction): boolean => {
const roles = interaction.member?.roles;
if (!roles) {
return false;
}
return Array.isArray(roles) ? roles.includes(env.DISCORD_STAFF_ROLE_ID) : roles.cache.has(env.DISCORD_STAFF_ROLE_ID);
};
+4
View File
@@ -23,6 +23,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked<RepositoryInterface<Disc
addRoleToMember: jest.fn(),
listRecentMessages: jest.fn().mockResolvedValue([]),
sendMessage: jest.fn(),
sendDirectMessage: jest.fn().mockResolvedValue(true),
createTicketThread: jest.fn(),
createStaffThread: jest.fn(),
listOpenTicketThreads: jest.fn().mockResolvedValue([]),
@@ -39,6 +40,9 @@ export const newYuccaApiRepositoryMock = (): jest.Mocked<RepositoryInterface<Yuc
createLinkRequest: jest.fn(),
getLink: jest.fn(),
updateLinkUsername: jest.fn().mockResolvedValue(void 0),
createInviteBatch: jest.fn(),
setInviteBatchMessage: jest.fn().mockResolvedValue(void 0),
createInvite: jest.fn(),
getUserSummary: jest.fn(),
};
};