mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(futo-backups-bot): allow up to three open tickets per user (#561)
This commit is contained in:
@@ -70,8 +70,9 @@ retention. yucca-api's scope stays pure account-linking.
|
||||
dashboard, mirroring yuctl's view-dashboard; the dashboard itself is o11y-owned) and an account summary from
|
||||
**`GET /internal/discord/users/:userId/summary`** (email, connections,
|
||||
repository count, last seen) — staff see it via Manage Threads on the
|
||||
support channel; the user cannot. One open ticket per user (membership scan
|
||||
of active threads); a second submit points at the existing thread.
|
||||
support channel; the user cannot. Up to `TICKET_USER_LIMIT`
|
||||
(3) open tickets per user (membership scan of active threads); at the limit
|
||||
a submit points at the existing threads.
|
||||
- **Close** (staff-only button): locks + archives the ticket thread and its
|
||||
staff sibling. The user keeps read access to their own closed ticket but
|
||||
cannot post or reopen; staff can unarchive via Manage Threads.
|
||||
|
||||
@@ -19,6 +19,7 @@ const schema = z.object({
|
||||
.transform((value) => value || 'https://grafana.futostatus.com'),
|
||||
|
||||
TICKET_RETENTION_DAYS: z.coerce.number().default(14),
|
||||
TICKET_USER_LIMIT: z.coerce.number().int().positive().default(3),
|
||||
|
||||
TRANSCRIPT_S3_ENDPOINT: z.string().default(''),
|
||||
TRANSCRIPT_S3_BUCKET: z.string().default(''),
|
||||
|
||||
@@ -114,9 +114,10 @@ export class DiscordRepository {
|
||||
await thread.send(content);
|
||||
}
|
||||
|
||||
async findOpenTicketThread(discordUserId: string): Promise<ThreadChannel | undefined> {
|
||||
async listOpenTicketThreads(discordUserId: string): Promise<ThreadChannel[]> {
|
||||
const channel = await this.supportChannel();
|
||||
const active = await channel.threads.fetchActive();
|
||||
const open: ThreadChannel[] = [];
|
||||
for (const thread of active.threads.values()) {
|
||||
if (thread.parentId !== channel.id || !thread.name.startsWith('ticket-')) {
|
||||
continue;
|
||||
@@ -130,10 +131,10 @@ export class DiscordRepository {
|
||||
throw error;
|
||||
});
|
||||
if (member) {
|
||||
return thread;
|
||||
open.push(thread);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return open;
|
||||
}
|
||||
|
||||
async findSupportThreadByName(name: string): Promise<AnyThreadChannel | undefined> {
|
||||
|
||||
@@ -116,15 +116,19 @@ describe(SupportService.name, () => {
|
||||
});
|
||||
|
||||
describe('ticket modal', () => {
|
||||
it('points a user with an open ticket at it instead of creating another', async () => {
|
||||
it('blocks a fourth concurrent ticket', async () => {
|
||||
mocks.api.getLink.mockResolvedValue(link);
|
||||
mocks.discord.findOpenTicketThread.mockResolvedValue(asThread({ id: 'thread-1', name: 'ticket-someone-6789' }));
|
||||
mocks.discord.listOpenTicketThreads.mockResolvedValue([
|
||||
asThread({ id: 'thread-1', name: 'ticket-someone-6789-a' }),
|
||||
asThread({ id: 'thread-2', name: 'ticket-someone-6789-b' }),
|
||||
asThread({ id: 'thread-3', name: 'ticket-someone-6789-c' }),
|
||||
]);
|
||||
const interaction = newModalInteraction('My backups are failing.');
|
||||
|
||||
await sut.handleInteraction(interaction);
|
||||
|
||||
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: expect.stringContaining('thread-1') }),
|
||||
expect.objectContaining({ content: expect.stringContaining('3 open tickets') }),
|
||||
);
|
||||
expect(mocks.discord.createTicketThread).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -132,17 +136,19 @@ describe(SupportService.name, () => {
|
||||
it('creates the ticket thread with a staff thread and replies with a pointer', async () => {
|
||||
mocks.api.getLink.mockResolvedValue(link);
|
||||
mocks.api.getUserSummary.mockResolvedValue(summary);
|
||||
mocks.discord.findOpenTicketThread.mockResolvedValue(void 0);
|
||||
const send = jest.fn();
|
||||
mocks.discord.createTicketThread.mockResolvedValue(asThread({ id: 'thread-1', send }));
|
||||
const interaction = newModalInteraction('My backups are failing.');
|
||||
|
||||
await sut.handleInteraction(interaction);
|
||||
|
||||
expect(mocks.discord.createTicketThread).toHaveBeenCalledWith('ticket-someone-6789', '123456789');
|
||||
expect(mocks.discord.createTicketThread).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^ticket-someone-6789-/),
|
||||
'123456789',
|
||||
);
|
||||
expect(send).toHaveBeenCalled();
|
||||
expect(mocks.discord.createStaffThread).toHaveBeenCalledWith(
|
||||
'staff-someone-6789',
|
||||
expect.stringMatching(/^staff-someone-6789-/),
|
||||
expect.stringContaining('someone@example.test'),
|
||||
);
|
||||
expect(mocks.discord.createStaffThread).toHaveBeenCalledWith(
|
||||
@@ -170,7 +176,6 @@ describe(SupportService.name, () => {
|
||||
|
||||
it('opens a ticket for an unlinked user without requiring a link', async () => {
|
||||
mocks.api.getLink.mockResolvedValue(null);
|
||||
mocks.discord.findOpenTicketThread.mockResolvedValue(void 0);
|
||||
const send = jest.fn();
|
||||
mocks.discord.createTicketThread.mockResolvedValue(asThread({ id: 'thread-9', send }));
|
||||
const interaction = newCommandInteraction(
|
||||
@@ -181,9 +186,12 @@ describe(SupportService.name, () => {
|
||||
await sut.handleInteraction(interaction);
|
||||
|
||||
expect(mocks.api.createLinkRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.discord.createTicketThread).toHaveBeenCalledWith('ticket-guest-5000', '555000');
|
||||
expect(mocks.discord.createTicketThread).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^ticket-guest-5000-/),
|
||||
'555000',
|
||||
);
|
||||
expect(mocks.discord.createStaffThread).toHaveBeenCalledWith(
|
||||
'staff-guest-5000',
|
||||
expect.stringMatching(/^staff-guest-5000-/),
|
||||
expect.stringContaining('No linked FUTO Backups account'),
|
||||
);
|
||||
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith(
|
||||
|
||||
@@ -186,9 +186,11 @@ export class SupportService implements OnApplicationBootstrap {
|
||||
}
|
||||
this.creating.add(interaction.user.id);
|
||||
try {
|
||||
const existing = await this.discord.findOpenTicketThread(interaction.user.id);
|
||||
if (existing) {
|
||||
await interaction.editReply({ content: `You already have an open ticket: <#${existing.id}>` });
|
||||
const open = await this.discord.listOpenTicketThreads(interaction.user.id);
|
||||
if (open.length >= env.TICKET_USER_LIMIT) {
|
||||
await interaction.editReply({
|
||||
content: `You already have ${open.length} open tickets: ${open.map((thread) => `<#${thread.id}>`).join(' ')}. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,9 +217,11 @@ export class SupportService implements OnApplicationBootstrap {
|
||||
}
|
||||
this.creating.add(target.id);
|
||||
try {
|
||||
const existing = await this.discord.findOpenTicketThread(target.id);
|
||||
if (existing) {
|
||||
await interaction.editReply({ content: `<@${target.id}> already has an open ticket: <#${existing.id}>` });
|
||||
const open = await this.discord.listOpenTicketThreads(target.id);
|
||||
if (open.length >= env.TICKET_USER_LIMIT) {
|
||||
await interaction.editReply({
|
||||
content: `<@${target.id}> already has ${open.length} open tickets: ${open.map((thread) => `<#${thread.id}>`).join(' ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -293,7 +297,7 @@ export class SupportService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
private ticketSuffix(username: string, discordUserId: string): string {
|
||||
return `${username.toLowerCase().replaceAll(/[^a-z0-9-]/g, '')}-${discordUserId.slice(-4)}`;
|
||||
return `${username.toLowerCase().replaceAll(/[^a-z0-9-]/g, '')}-${discordUserId.slice(-4)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
private staffNote(link: DiscordLink | null, summary: UserSummary | null): string {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked<RepositoryInterface<Disc
|
||||
ensurePinnedSupportMessage: jest.fn(),
|
||||
createTicketThread: jest.fn(),
|
||||
createStaffThread: jest.fn(),
|
||||
findOpenTicketThread: jest.fn(),
|
||||
listOpenTicketThreads: jest.fn().mockResolvedValue([]),
|
||||
findSupportThreadByName: jest.fn(),
|
||||
closeThread: jest.fn(),
|
||||
listClosedTicketThreads: jest.fn().mockResolvedValue([]),
|
||||
|
||||
Reference in New Issue
Block a user