mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(futo-backups-bot): /beta-invite with personal links, claim batches, and optional role mention (#571)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user