diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 978fcd2..a333b07 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,4 +47,7 @@ jobs: uses: pento/lcov-coverage-check@972428b8d5b3fbd8230df23ca4190ecce29979cd # v3.1.0 with: path: "src/" + ignore-patterns: | + **/*.test.ts + **/test/** github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/IDEAS/TODO.md b/IDEAS/TODO.md index 0a0ac16..243011c 100644 --- a/IDEAS/TODO.md +++ b/IDEAS/TODO.md @@ -22,8 +22,8 @@ Priority is rated from an end-user perspective: ## Notifications & UX -- 🔴 **DM notifications for new codes** — Opt-in per user to get a DM when a code is *detected*, independent of whether autoredeem is on. Users who want to redeem manually currently have no way to know a code appeared. -- 🔴 **Notification preferences command** — Let users configure: DM on success, DM on failure, DM when a code they haven't claimed is about to expire. High value as DM spam is a common complaint with bots. +- ✅ **DM notifications for new codes** *(implemented)* — Opt-in per user to get a DM when a code is *detected*, independent of whether autoredeem is on. Users who want to redeem manually currently have no way to know a code appeared. +- ✅ **Notification preferences command** *(implemented)* — Let users configure: DM on success, DM on failure, DM when a code they haven't claimed is about to expire. High value as DM spam is a common complaint with bots. - 🔴 **Better blacksmith UX** — `/blacksmith` requires a raw hero ID. Inventory data from `getUserDetails` includes hero names — show a hero picker or list. Current UX is nearly unusable without looking up IDs externally. - 🟢 **Paginated `/codes`** — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience. diff --git a/bunfig.toml b/bunfig.toml index 3fc87eb..e3c0b95 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,3 @@ [test] preload = ["./src/test/setup.ts"] +coverageIgnorePatterns = ["**/*.test.ts", "**/test/**"] diff --git a/src/bot/bot.ts b/src/bot/bot.ts index 9eac49e..5ba1a6d 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -18,6 +18,7 @@ import * as autoredeemCommand from './commands/autoredeem'; import * as helpCommand from './commands/help'; import * as inventoryCommand from './commands/inventory'; import * as makepublicCommand from './commands/makepublic'; +import * as notificationsCommand from './commands/notifications'; import * as openCommand from './commands/open'; import * as redeemCommand from './commands/redeem'; import * as setupCommand from './commands/setup'; @@ -59,6 +60,7 @@ const commands = [ helpCommand, inventoryCommand, makepublicCommand, + notificationsCommand, openCommand, redeemCommand, setupCommand, @@ -195,10 +197,47 @@ client.on(Events.MessageCreate, async (message) => { // Deduplicate in case the same code appears multiple times in one message. const uniqueCodes = [...new Set(foundCodes)]; - // Persist all found codes to pending_codes immediately so /catchup can - // recover them if auto-redeem has no enabled users or the API fails. - for (const code of uniqueCodes) { - await codeManager.addPendingCode(code); + // Persist all found codes to pending_codes in a single batch INSERT. + // onConflictDoNothing gives at-most-once insert semantics and eliminates + // the TOCTOU race of a pre-read snapshot under concurrent MessageCreate events. + // Only newly inserted codes trigger DM notifications. + const newCodes = await codeManager.addNewPendingCodes(uniqueCodes); + + // DM users who opted in for code-detection notifications (independent of autoredeem). + // A single bulk query resolves per-recipient redeemed status, then a short delay + // between sends avoids bursting Discord DM rate limits. The message author is + // excluded since they can already see the code in the channel. + // Note: users who have both dmOnCode and dmOnSuccess enabled will receive two + // DMs per code — one here and one from autoRedeemer on successful redemption. + if (newCodes.length > 0) { + const dmIds = await userManager.getDiscordIdsWithDmOnCode(); + const recipientIds = dmIds.filter((id) => id !== message.author.id); + if (recipientIds.length > 0) { + void (async () => { + try { + const redeemedMap = await codeManager.getRedeemedCodesByUsers(newCodes, recipientIds); + for (let i = 0; i < recipientIds.length; i++) { + const id = recipientIds[i]!; + try { + const alreadyRedeemed = redeemedMap.get(id) ?? new Set(); + const unredeemedCodes = newCodes.filter((c) => !alreadyRedeemed.has(c)); + if (unredeemedCodes.length === 0) continue; + const codeList = unredeemedCodes.map((c) => `\`${c}\``).join(', '); + const label = `New code${unredeemedCodes.length > 1 ? 's' : ''} detected: ${codeList}`; + const u = await client.users.fetch(id); + await u.send(`🔔 ${label}`); + } catch { /* DM delivery failure is non-critical */ } + finally { + if (i < recipientIds.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } + } catch (err) { + logger.error('Unexpected error in code-detection DM fan-out:', err); + } + })(); + } } // Enqueue auto-redeem — serialized so overlapping MessageCreate events diff --git a/src/bot/commands/help.ts b/src/bot/commands/help.ts index dec16b5..9d33489 100644 --- a/src/bot/commands/help.ts +++ b/src/bot/commands/help.ts @@ -65,6 +65,12 @@ export async function execute(interaction: ChatInputCommandInteraction) { '`/blacksmith contract_type: hero_id: count:`\nUpgrade your heroes using contracts.', inline: false, }, + { + name: '🔔 Notifications', + value: + '`/notifications [dm_on_code:] [dm_on_success:] [dm_on_failure:]`\nConfigure DM notifications: get alerted when codes are detected, redeemed, or fail.', + inline: false, + }, { name: '⏮️ Backfill', value: '`/backfill [channel:]`\nRecover missed codes from message history (admin only).', diff --git a/src/bot/commands/notifications.test.ts b/src/bot/commands/notifications.test.ts new file mode 100644 index 0000000..c43260d --- /dev/null +++ b/src/bot/commands/notifications.test.ts @@ -0,0 +1,214 @@ +import { describe, test, expect, beforeAll, beforeEach, spyOn } from 'bun:test'; +import { MessageFlags } from 'discord.js'; +import { db, initializeDatabase } from '../database/db'; +import { users, redeemedCodes, pendingCodes, auditLog } from '../database/schema/index'; +import { userManager } from '../database/userManager'; +import { execute } from './notifications'; + +// --------------------------------------------------------------------------- +// Interaction mock helpers +// --------------------------------------------------------------------------- + +function makeInteraction( + userId: string, + options: Record = {} +) { + const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply'); + const replySpy = spyOn({ reply: async (_: unknown) => {} }, 'reply'); + + const interaction = { + user: { id: userId, tag: `user#${userId}` }, + deferred: false, + replied: false, + deferReply: async () => { (interaction as any).deferred = true; }, + editReply: editReplySpy, + reply: replySpy, + options: { + getBoolean: (name: string) => options[name] ?? null, + }, + } as any; + + return { interaction, editReplySpy, replySpy }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeAll(() => { + initializeDatabase(); +}); + +beforeEach(() => { + db.delete(auditLog).run(); + db.delete(pendingCodes).run(); + db.delete(redeemedCodes).run(); + db.delete(users).run(); +}); + +// --------------------------------------------------------------------------- +// No credentials +// --------------------------------------------------------------------------- + +describe('/notifications – no credentials', () => { + test('replies with error embed when user has no credentials', async () => { + const { interaction, editReplySpy } = makeInteraction('unknown-user'); + + await execute(interaction); + + expect(editReplySpy).toHaveBeenCalledTimes(1); + const reply = editReplySpy.mock.calls[0]![0] as any; + expect(reply.embeds[0].data.title).toContain('No Credentials Found'); + }); +}); + +// --------------------------------------------------------------------------- +// Show current settings (no options) +// --------------------------------------------------------------------------- + +describe('/notifications – show current settings', () => { + test('shows defaults when no options are provided', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const { interaction, editReplySpy } = makeInteraction('user-1'); + + await execute(interaction); + + expect(editReplySpy).toHaveBeenCalledTimes(1); + const reply = editReplySpy.mock.calls[0]![0] as any; + const embed = reply.embeds[0].data; + expect(embed.title).toContain('Notification Preferences'); + const fieldValues = embed.fields.map((f: any) => f.value); + // dmOnCode default false, dmOnSuccess default true, dmOnFailure default false + expect(fieldValues[0]).toContain('Off'); + expect(fieldValues[1]).toContain('On'); + expect(fieldValues[2]).toContain('Off'); + }); + + test('shows updated values after preferences have been changed', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { + dmOnCode: true, + dmOnSuccess: false, + dmOnFailure: true, + }); + const { interaction, editReplySpy } = makeInteraction('user-1'); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + const fieldValues = embed.fields.map((f: any) => f.value); + expect(fieldValues[0]).toContain('On'); + expect(fieldValues[1]).toContain('Off'); + expect(fieldValues[2]).toContain('On'); + }); +}); + +// --------------------------------------------------------------------------- +// Update preferences +// --------------------------------------------------------------------------- + +describe('/notifications – update preferences', () => { + test('enables dmOnCode and reflects it in the reply embed', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_code: true }); + + await execute(interaction); + + // Verify DB was updated + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnCode).toBe(true); + + // Verify reply embed shows correct state + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('Updated'); + const fieldValues = embed.fields.map((f: any) => f.value); + expect(fieldValues[0]).toContain('On'); // dmOnCode + expect(fieldValues[1]).toContain('On'); // dmOnSuccess unchanged (default true) + expect(fieldValues[2]).toContain('Off'); // dmOnFailure unchanged (default false) + }); + + test('disables dmOnSuccess', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_success: false }); + + await execute(interaction); + + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnSuccess).toBe(false); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + const fieldValues = embed.fields.map((f: any) => f.value); + expect(fieldValues[1]).toContain('Off'); + }); + + test('enables dmOnFailure', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_failure: true }); + + await execute(interaction); + + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnFailure).toBe(true); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + const fieldValues = embed.fields.map((f: any) => f.value); + expect(fieldValues[2]).toContain('On'); + }); + + test('updates all three prefs at once', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const { interaction } = makeInteraction('user-1', { + dm_on_code: true, + dm_on_success: false, + dm_on_failure: true, + }); + + await execute(interaction); + + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnCode).toBe(true); + expect(creds?.dmOnSuccess).toBe(false); + expect(creds?.dmOnFailure).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Error path +// --------------------------------------------------------------------------- + +describe('/notifications – error handling', () => { + test('uses editReply when already deferred on error', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + + const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_code: true }); + // Force an error after deferReply by making setNotificationPreferences throw + const spy = spyOn(userManager, 'setNotificationPreferences').mockRejectedValueOnce( + new Error('DB error') + ); + + await execute(interaction); + + expect(editReplySpy).toHaveBeenCalled(); + const reply = editReplySpy.mock.calls[0]![0] as any; + expect(reply.content).toContain('error'); + + spy.mockRestore(); + }); + + test('uses reply when not deferred on error', async () => { + const { interaction, editReplySpy, replySpy } = makeInteraction('user-1', { dm_on_code: true }); + + // deferReply throws so deferred stays false, getCredentials is never called + (interaction as any).deferReply = async () => { + throw new Error('interaction expired'); + }; + + await execute(interaction); + + expect(editReplySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalled(); + const reply = replySpy.mock.calls[0]![0] as any; + expect(reply.content).toContain('error'); + expect(reply.flags).toBe(MessageFlags.Ephemeral); + }); +}); diff --git a/src/bot/commands/notifications.ts b/src/bot/commands/notifications.ts new file mode 100644 index 0000000..8d3b2f3 --- /dev/null +++ b/src/bot/commands/notifications.ts @@ -0,0 +1,138 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { userManager } from '../database/userManager'; +import { auditManager } from '../database/auditManager'; +import logger from '../utils/logger'; + +export const data = new SlashCommandBuilder() + .setName('notifications') + .setDescription('Configure your DM notification preferences') + .addBooleanOption((option) => + option + .setName('dm_on_code') + .setDescription( + 'DM on new code detection. Tip: enabling dm_on_success too sends two DMs per code.' + ) + .setRequired(false) + ) + .addBooleanOption((option) => + option + .setName('dm_on_success') + .setDescription('DM when auto-redeem successfully redeems a code for you') + .setRequired(false) + ) + .addBooleanOption((option) => + option + .setName('dm_on_failure') + .setDescription('DM when auto-redeem fails to redeem a code for you') + .setRequired(false) + ); + +export async function execute(interaction: ChatInputCommandInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const credentials = await userManager.getCredentials(interaction.user.id); + if (!credentials) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('❌ No Credentials Found') + .setDescription('Please set up your Idle Champions credentials first using `/setup`'); + await interaction.editReply({ embeds: [embed] }); + return; + } + + const dmOnCode = interaction.options.getBoolean('dm_on_code'); + const dmOnSuccess = interaction.options.getBoolean('dm_on_success'); + const dmOnFailure = interaction.options.getBoolean('dm_on_failure'); + + // If no options were provided, show current settings + if (dmOnCode === null && dmOnSuccess === null && dmOnFailure === null) { + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('🔔 Notification Preferences') + .setDescription('Your current DM notification settings:') + .addFields( + { + name: '📣 DM on code detected', + value: credentials.dmOnCode ? '✅ On' : '❌ Off', + inline: true, + }, + { + name: '✅ DM on successful redeem', + value: credentials.dmOnSuccess ? '✅ On' : '❌ Off', + inline: true, + }, + { + name: '⚠️ DM on failed redeem', + value: credentials.dmOnFailure ? '✅ On' : '❌ Off', + inline: true, + } + ) + .setFooter({ text: 'Use /notifications with options to change these settings' }); + await interaction.editReply({ embeds: [embed] }); + return; + } + + const updates: { dmOnCode?: boolean; dmOnSuccess?: boolean; dmOnFailure?: boolean } = {}; + if (dmOnCode !== null) updates.dmOnCode = dmOnCode; + if (dmOnSuccess !== null) updates.dmOnSuccess = dmOnSuccess; + if (dmOnFailure !== null) updates.dmOnFailure = dmOnFailure; + + const updated = await userManager.setNotificationPreferences(interaction.user.id, updates); + if (!updated) { + await interaction.editReply({ content: '⚠️ Could not update preferences — your account may no longer exist.' }); + return; + } + await auditManager.logAction(interaction.user.id, 'NOTIFICATION_PREFS_UPDATED', updates); + + logger.info(`[NOTIFICATIONS] User ${interaction.user.tag} updated notification prefs: ${JSON.stringify(updates)}`); + + // Merge updates into current values for display + const current = { + dmOnCode: updates.dmOnCode ?? credentials.dmOnCode, + dmOnSuccess: updates.dmOnSuccess ?? credentials.dmOnSuccess, + dmOnFailure: updates.dmOnFailure ?? credentials.dmOnFailure, + }; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Notification Preferences Updated') + .addFields( + { + name: '📣 DM on code detected', + value: current.dmOnCode ? '✅ On' : '❌ Off', + inline: true, + }, + { + name: '✅ DM on successful redeem', + value: current.dmOnSuccess ? '✅ On' : '❌ Off', + inline: true, + }, + { + name: '⚠️ DM on failed redeem', + value: current.dmOnFailure ? '✅ On' : '❌ Off', + inline: true, + } + ); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + logger.error('[NOTIFICATIONS] Error:', error); + const msg = { content: '❌ An error occurred while handling your notification preferences.' }; + + try { + if (interaction.deferred || interaction.replied) { + await interaction.editReply(msg); + } else { + await interaction.reply({ ...msg, flags: MessageFlags.Ephemeral }); + } + } catch (replyError) { + logger.error('[NOTIFICATIONS] Failed to send error response:', replyError); + } + } +} diff --git a/src/bot/database/codeManager.test.ts b/src/bot/database/codeManager.test.ts index c266a60..a8268e4 100644 --- a/src/bot/database/codeManager.test.ts +++ b/src/bot/database/codeManager.test.ts @@ -313,6 +313,24 @@ describe('pending codes', () => { await codeManager.clearPendingCodes(USER_A); expect(await codeManager.getPendingCodes()).toEqual(['PEND5678EFGH']); }); + + test('addNewPendingCodes returns only newly inserted codes', async () => { + const result = await codeManager.addNewPendingCodes(['CODE1111AAAA', 'CODE2222BBBB']); + expect(result).toContain('CODE1111AAAA'); + expect(result).toContain('CODE2222BBBB'); + }); + + test('addNewPendingCodes skips already-present codes', async () => { + await codeManager.addPendingCode('CODE1111AAAA'); + const result = await codeManager.addNewPendingCodes(['CODE1111AAAA', 'CODE2222BBBB']); + expect(result).not.toContain('CODE1111AAAA'); + expect(result).toContain('CODE2222BBBB'); + }); + + test('addNewPendingCodes returns empty array for empty input', async () => { + const result = await codeManager.addNewPendingCodes([]); + expect(result).toEqual([]); + }); }); // --------------------------------------------------------------------------- @@ -367,3 +385,62 @@ describe('deleteUserRedeemedCodes', () => { expect(remaining[0]!.discordId).toBe(USER_B); }); }); + +// --------------------------------------------------------------------------- +// getRedeemedCodesByUsers +// --------------------------------------------------------------------------- +describe('getRedeemedCodesByUsers', () => { + test('returns empty map when codes list is empty', async () => { + const result = await codeManager.getRedeemedCodesByUsers([], [USER_A]); + expect(result.size).toBe(0); + }); + + test('returns empty map when discordIds list is empty', async () => { + await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success'); + const result = await codeManager.getRedeemedCodesByUsers(['CODE1234ABCD'], []); + expect(result.size).toBe(0); + }); + + test('returns redeemed codes per user for Success status', async () => { + await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success'); + const result = await codeManager.getRedeemedCodesByUsers(['CODE1234ABCD'], [USER_A, USER_B]); + expect(result.get(USER_A)?.has('CODE1234ABCD')).toBe(true); + expect(result.has(USER_B)).toBe(false); + }); + + test('includes Already Redeemed and Code Expired statuses', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Already Redeemed'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Code Expired'); + const result = await codeManager.getRedeemedCodesByUsers( + ['CODE1111AAAA', 'CODE2222BBBB'], + [USER_A, USER_B] + ); + expect(result.get(USER_A)?.has('CODE1111AAAA')).toBe(true); + expect(result.get(USER_B)?.has('CODE2222BBBB')).toBe(true); + }); + + test('excludes non-qualifying statuses like Invalid Parameters', async () => { + await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Invalid Parameters'); + const result = await codeManager.getRedeemedCodesByUsers(['CODE1234ABCD'], [USER_A]); + expect(result.has(USER_A)).toBe(false); + }); + + test('only returns codes that are in the requested codes list', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Success'); + const result = await codeManager.getRedeemedCodesByUsers(['CODE1111AAAA'], [USER_A]); + expect(result.get(USER_A)?.has('CODE1111AAAA')).toBe(true); + expect(result.get(USER_A)?.has('CODE2222BBBB')).toBe(false); + }); + + test('handles multiple codes per user correctly', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Already Redeemed'); + const result = await codeManager.getRedeemedCodesByUsers( + ['CODE1111AAAA', 'CODE2222BBBB'], + [USER_A] + ); + expect(result.get(USER_A)?.has('CODE1111AAAA')).toBe(true); + expect(result.get(USER_A)?.has('CODE2222BBBB')).toBe(true); + }); +}); diff --git a/src/bot/database/codeManager.ts b/src/bot/database/codeManager.ts index 18bd21c..55ba41f 100644 --- a/src/bot/database/codeManager.ts +++ b/src/bot/database/codeManager.ts @@ -1,4 +1,4 @@ -import { eq, ne, and, or, isNull, gt, sql, max } from 'drizzle-orm'; +import { eq, ne, and, or, isNull, gt, sql, max, inArray } from 'drizzle-orm'; import { db } from './db'; import { redeemedCodes, pendingCodes } from './schema/index'; @@ -74,6 +74,51 @@ class CodeManager { return result !== undefined; } + /** + * Batch variant of isCodeRedeemedByUser. Returns a map of discordId → set of codes + * that user has already redeemed (Success / Already Redeemed / Code Expired). + * Chunks discordIds to stay within SQLite's SQLITE_LIMIT_VARIABLE_NUMBER (default 999). + */ + async getRedeemedCodesByUsers(codes: string[], discordIds: string[]): Promise>> { + if (codes.length === 0 || discordIds.length === 0) return new Map(); + // Budget per query: codes.length (inArray) + 3 (status OR literals) + chunk size (discordIds inArray). + const SQLITE_MAX_PARAMS = 999; + const STATUS_PARAM_COUNT = 3; + const MIN_DISCORD_ID_CHUNK_SIZE = 1; + const maxCodesPerQuery = SQLITE_MAX_PARAMS - STATUS_PARAM_COUNT - MIN_DISCORD_ID_CHUNK_SIZE; + if (codes.length > maxCodesPerQuery) { + throw new Error( + `Too many codes provided to getRedeemedCodesByUsers: ${codes.length}. ` + + `This query supports at most ${maxCodesPerQuery} codes per call within SQLite's parameter limit of ${SQLITE_MAX_PARAMS}.` + ); + } + const chunkSize = SQLITE_MAX_PARAMS - codes.length - STATUS_PARAM_COUNT; + const result = new Map>(); + for (let i = 0; i < discordIds.length; i += chunkSize) { + const chunk = discordIds.slice(i, i + chunkSize); + const rows = db + .select({ code: redeemedCodes.code, discordId: redeemedCodes.discordId }) + .from(redeemedCodes) + .where( + and( + inArray(redeemedCodes.code, codes), + inArray(redeemedCodes.discordId, chunk), + or( + eq(redeemedCodes.status, 'Success'), + eq(redeemedCodes.status, 'Already Redeemed'), + eq(redeemedCodes.status, 'Code Expired') + ) + ) + ) + .all(); + for (const row of rows) { + if (!result.has(row.discordId)) result.set(row.discordId, new Set()); + result.get(row.discordId)!.add(row.code); + } + } + return result; + } + async isCodeRedeemedByUser(code: string, discordId: string): Promise { const result = db .select({ code: redeemedCodes.code }) @@ -211,8 +256,24 @@ class CodeManager { db.update(redeemedCodes).set({ isPublic: 0 }).where(eq(redeemedCodes.code, code)).run(); } - async addPendingCode(code: string, discordId?: string): Promise { - db.insert(pendingCodes).values({ code, discordId: discordId ?? null }).onConflictDoNothing().run(); + async addPendingCode(code: string, discordId?: string): Promise { + const rows = db.insert(pendingCodes).values({ code, discordId: discordId ?? null }).onConflictDoNothing().returning({ code: pendingCodes.code }).all(); + return rows.length > 0; + } + + /** + * Batch-insert multiple codes into pending_codes in a single query. + * Returns only the codes that were newly inserted (already-present codes are skipped via onConflictDoNothing). + */ + async addNewPendingCodes(codes: string[]): Promise { + if (codes.length === 0) return []; + const rows = db + .insert(pendingCodes) + .values(codes.map((code) => ({ code, discordId: null }))) + .onConflictDoNothing() + .returning({ code: pendingCodes.code }) + .all(); + return rows.map((r) => r.code); } async getPendingCodes(discordId?: string): Promise { diff --git a/src/bot/database/migrations/0005_grey_saracen.sql b/src/bot/database/migrations/0005_grey_saracen.sql new file mode 100644 index 0000000..9b53a81 --- /dev/null +++ b/src/bot/database/migrations/0005_grey_saracen.sql @@ -0,0 +1,3 @@ +ALTER TABLE `users` ADD `dm_on_code` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `dm_on_success` integer DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `dm_on_failure` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/bot/database/migrations/meta/0005_snapshot.json b/src/bot/database/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..9b0ebe7 --- /dev/null +++ b/src/bot/database/migrations/meta/0005_snapshot.json @@ -0,0 +1,386 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d00c22b9-bab8-4803-8a99-b812685d0a4e", + "prevId": "9bbf13d4-b4e1-407c-bedd-e76cf85ed9f7", + "tables": { + "users": { + "name": "users", + "columns": { + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_hash": { + "name": "user_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server": { + "name": "server", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_redeem": { + "name": "auto_redeem", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "dm_on_code": { + "name": "dm_on_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "dm_on_success": { + "name": "dm_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "dm_on_failure": { + "name": "dm_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_discord_id_users_discord_id_fk": { + "name": "audit_log_discord_id_users_discord_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "discord_id" + ], + "columnsTo": [ + "discord_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "redeemed_codes": { + "name": "redeemed_codes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loot_detail": { + "name": "loot_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "redeemed_codes_code_discord_id_unique": { + "name": "redeemed_codes_code_discord_id_unique", + "columns": [ + "code", + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "redeemed_codes_discord_id_users_discord_id_fk": { + "name": "redeemed_codes_discord_id_users_discord_id_fk", + "tableFrom": "redeemed_codes", + "tableTo": "users", + "columnsFrom": [ + "discord_id" + ], + "columnsTo": [ + "discord_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backfill_operations": { + "name": "backfill_operations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "initiated_by": { + "name": "initiated_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codes_found": { + "name": "codes_found", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "codes_redeemed": { + "name": "codes_redeemed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_codes": { + "name": "pending_codes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "found_at": { + "name": "found_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pending_codes_code_unique": { + "name": "pending_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pending_codes_discord_id_users_discord_id_fk": { + "name": "pending_codes_discord_id_users_discord_id_fk", + "tableFrom": "pending_codes", + "tableTo": "users", + "columnsFrom": [ + "discord_id" + ], + "columnsTo": [ + "discord_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/bot/database/migrations/meta/_journal.json b/src/bot/database/migrations/meta/_journal.json index 4c8168d..5f8d12e 100644 --- a/src/bot/database/migrations/meta/_journal.json +++ b/src/bot/database/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778747899562, "tag": "0004_curious_living_lightning", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1778826004740, + "tag": "0005_grey_saracen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/bot/database/schema/users.ts b/src/bot/database/schema/users.ts index 2f88f9f..0a84bb8 100644 --- a/src/bot/database/schema/users.ts +++ b/src/bot/database/schema/users.ts @@ -8,6 +8,9 @@ export const users = sqliteTable('users', { server: text(), instanceId: text(), autoRedeem: integer({ mode: 'boolean' }).notNull().default(true), + dmOnCode: integer({ mode: 'boolean' }).notNull().default(false), + dmOnSuccess: integer({ mode: 'boolean' }).notNull().default(true), + dmOnFailure: integer({ mode: 'boolean' }).notNull().default(false), createdAt: text().default(sql`CURRENT_TIMESTAMP`), updatedAt: text().default(sql`CURRENT_TIMESTAMP`), }); diff --git a/src/bot/database/userManager.test.ts b/src/bot/database/userManager.test.ts index f39e9f3..b462961 100644 --- a/src/bot/database/userManager.test.ts +++ b/src/bot/database/userManager.test.ts @@ -300,3 +300,102 @@ describe('migratePlaintextCredentials', () => { expect(creds?.userHash).toBe('hash-a'); }); }); + +// --------------------------------------------------------------------------- +// notification preference defaults +// --------------------------------------------------------------------------- +describe('notification preference defaults', () => { + test('dmOnCode defaults to false after saveCredentials', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnCode).toBe(false); + }); + + test('dmOnSuccess defaults to true after saveCredentials', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnSuccess).toBe(true); + }); + + test('dmOnFailure defaults to false after saveCredentials', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnFailure).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// setNotificationPreferences +// --------------------------------------------------------------------------- +describe('setNotificationPreferences', () => { + test('enables dmOnCode', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { dmOnCode: true }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnCode).toBe(true); + }); + + test('disables dmOnSuccess', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { dmOnSuccess: false }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnSuccess).toBe(false); + }); + + test('enables dmOnFailure', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { dmOnFailure: true }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnFailure).toBe(true); + }); + + test('updates multiple prefs at once', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { + dmOnCode: true, + dmOnSuccess: false, + dmOnFailure: true, + }); + const creds = await userManager.getCredentials('user-1'); + expect(creds?.dmOnCode).toBe(true); + expect(creds?.dmOnSuccess).toBe(false); + expect(creds?.dmOnFailure).toBe(true); + }); + + test('does not affect unspecified prefs', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.setNotificationPreferences('user-1', { dmOnCode: true }); + const creds = await userManager.getCredentials('user-1'); + // dmOnSuccess and dmOnFailure must remain at their defaults + expect(creds?.dmOnSuccess).toBe(true); + expect(creds?.dmOnFailure).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getDiscordIdsWithDmOnCode +// --------------------------------------------------------------------------- +describe('getDiscordIdsWithDmOnCode', () => { + test('returns empty array when no users exist', async () => { + expect(await userManager.getDiscordIdsWithDmOnCode()).toEqual([]); + }); + + test('returns only discord IDs of users with dmOnCode enabled', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + await userManager.saveCredentials({ discordId: 'user-2', userId: '222', userHash: 'hash-b' }); + await userManager.saveCredentials({ discordId: 'user-3', userId: '333', userHash: 'hash-c' }); + await userManager.setNotificationPreferences('user-1', { dmOnCode: true }); + await userManager.setNotificationPreferences('user-3', { dmOnCode: true }); + + const ids = await userManager.getDiscordIdsWithDmOnCode(); + expect(ids).toHaveLength(2); + expect(ids).toContain('user-1'); + expect(ids).toContain('user-3'); + expect(ids).not.toContain('user-2'); + }); + + test('returns empty when no users have dmOnCode enabled', async () => { + await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); + expect(await userManager.getDiscordIdsWithDmOnCode()).toEqual([]); + }); +}); diff --git a/src/bot/database/userManager.ts b/src/bot/database/userManager.ts index 44a23d1..db0913e 100644 --- a/src/bot/database/userManager.ts +++ b/src/bot/database/userManager.ts @@ -11,6 +11,15 @@ export interface UserCredentials { server?: string; instanceId?: string; autoRedeem?: boolean; + dmOnCode?: boolean; + dmOnSuccess?: boolean; + dmOnFailure?: boolean; +} + +export interface NotificationPreferences { + dmOnCode: boolean; + dmOnSuccess: boolean; + dmOnFailure: boolean; } function decryptField(value: string): string { @@ -25,6 +34,9 @@ function rowToCredentials(user: typeof users.$inferSelect): UserCredentials { server: user.server ?? undefined, instanceId: user.instanceId ?? undefined, autoRedeem: user.autoRedeem ?? true, + dmOnCode: user.dmOnCode ?? false, + dmOnSuccess: user.dmOnSuccess ?? true, + dmOnFailure: user.dmOnFailure ?? false, }; } @@ -98,6 +110,35 @@ class UserManager { return rows.map(rowToCredentials); } + /** + * Returns only the Discord IDs of users who have opted into code-detection DMs. + */ + async getDiscordIdsWithDmOnCode(): Promise { + const rows = db.select({ discordId: users.discordId }).from(users).where(eq(users.dmOnCode, true)).all(); + return rows.map((r) => r.discordId); + } + + /** + * Update notification preferences for a user. + * + * Note: this is a silent no-op when `discordId` does not exist in the + * database. The `/notifications` command guards against this by requiring + * `getCredentials` to succeed first. Direct callers must do the same. + */ + async setNotificationPreferences(discordId: string, prefs: Partial): Promise { + const update: Partial<{ dmOnCode: boolean; dmOnSuccess: boolean; dmOnFailure: boolean }> = {}; + if (prefs.dmOnCode !== undefined) update.dmOnCode = prefs.dmOnCode; + if (prefs.dmOnSuccess !== undefined) update.dmOnSuccess = prefs.dmOnSuccess; + if (prefs.dmOnFailure !== undefined) update.dmOnFailure = prefs.dmOnFailure; + if (Object.keys(update).length === 0) return false; + const rows = db.update(users) + .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(users.discordId, discordId)) + .returning({ discordId: users.discordId }) + .all(); + return rows.length > 0; + } + async getAllUsers(): Promise { const rows = db.select().from(users).orderBy(sql`${users.createdAt} DESC`).all(); return rows.map(rowToCredentials); diff --git a/src/bot/handlers/autoRedeemer.test.ts b/src/bot/handlers/autoRedeemer.test.ts index b327418..bc12dd5 100644 --- a/src/bot/handlers/autoRedeemer.test.ts +++ b/src/bot/handlers/autoRedeemer.test.ts @@ -1,10 +1,10 @@ -import { describe, test, expect, beforeAll, beforeEach, afterAll, spyOn } from 'bun:test'; +import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll, spyOn } from 'bun:test'; import { db, initializeDatabase } from '../database/db'; import { users, redeemedCodes, pendingCodes, auditLog } from '../database/schema/index'; import { userManager } from '../database/userManager'; import { codeManager } from '../database/codeManager'; import IdleChampionsApi from '../api/idleChampionsApi'; -import { autoRedeemForAllUsers } from './autoRedeemer'; +import { autoRedeemForAllUsers, setDiscordClient } from './autoRedeemer'; // --------------------------------------------------------------------------- // Fixtures @@ -353,3 +353,84 @@ describe('autoRedeemForAllUsers – getUserDetails SwitchServer', () => { expect(creds?.server).toBe(newServer); }); }); + +// --------------------------------------------------------------------------- +// DM notifications +// --------------------------------------------------------------------------- + +/** Build a minimal mock Discord client with a tracked send spy. */ +function makeMockClient() { + const sendSpy = spyOn({ send: async (_msg: string) => {} }, 'send'); + const mockUser = { send: sendSpy }; + const mockClient = { + users: { + fetch: async (_id: string) => mockUser, + }, + } as any; + return { mockClient, sendSpy }; +} + +describe('autoRedeemForAllUsers – dmOnSuccess', () => { + afterEach(() => { setDiscordClient(null); }); + + test('sends DM on success when dmOnSuccess is true (default)', async () => { + const { mockClient, sendSpy } = makeMockClient(); + setDiscordClient(mockClient); + + await addUser(USER_A); + await codeManager.addPendingCode(CODE); + submitCodeSpy.mockResolvedValue(makeSubmitResponse(0) as any); + + await autoRedeemForAllUsers([CODE]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]![0]).toContain(CODE); + }); + + test('does not send DM on success when dmOnSuccess is false', async () => { + const { mockClient, sendSpy } = makeMockClient(); + setDiscordClient(mockClient); + + await addUser(USER_A); + await userManager.setNotificationPreferences(USER_A, { dmOnSuccess: false }); + await codeManager.addPendingCode(CODE); + submitCodeSpy.mockResolvedValue(makeSubmitResponse(0) as any); + + await autoRedeemForAllUsers([CODE]); + + expect(sendSpy).not.toHaveBeenCalled(); + }); +}); + +describe('autoRedeemForAllUsers – dmOnFailure', () => { + afterEach(() => { setDiscordClient(null); }); + + test('sends failure DM when dmOnFailure is true', async () => { + const { mockClient, sendSpy } = makeMockClient(); + setDiscordClient(mockClient); + + await addUser(USER_A); + await userManager.setNotificationPreferences(USER_A, { dmOnFailure: true }); + await codeManager.addPendingCode(CODE); + // codeStatus 2 = InvalidParameters — not persisted, triggers failure branch + submitCodeSpy.mockResolvedValue(makeSubmitResponse(2) as any); + + await autoRedeemForAllUsers([CODE]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]![0]).toContain(CODE); + }); + + test('does not send failure DM when dmOnFailure is false (default)', async () => { + const { mockClient, sendSpy } = makeMockClient(); + setDiscordClient(mockClient); + + await addUser(USER_A); + await codeManager.addPendingCode(CODE); + submitCodeSpy.mockResolvedValue(makeSubmitResponse(2) as any); + + await autoRedeemForAllUsers([CODE]); + + expect(sendSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/handlers/autoRedeemer.ts b/src/bot/handlers/autoRedeemer.ts index bbeea34..c742716 100644 --- a/src/bot/handlers/autoRedeemer.ts +++ b/src/bot/handlers/autoRedeemer.ts @@ -10,8 +10,9 @@ let discordClient: Client | null = null; /** * Provide the Discord client so auto-redeemer can send DM notifications. * Call this once from bot.ts after the client is created. + * Pass `null` to clear the client. */ -export function setDiscordClient(client: Client): void { +export function setDiscordClient(client: Client | null): void { discordClient = client; } @@ -32,6 +33,20 @@ export function enqueueAutoRedeem(codes: string[]): void { .catch((error) => { logger.error('[AUTO REDEEMER] Unhandled error during auto-redeem:', error); }); } +/** + * Fire-and-forget DM to notify a user that a code could not be redeemed. + * Called from the GenericResponse failure path, the unexpected-shape guard, and + * the codeStatus else-branch (non-success, non-already-redeemed, non-expired statuses). + * Infrastructure failures (no server, invalid instance_id, etc.) do not trigger this. + */ +function sendFailureDm(discordId: string, code: string, reason: string): void { + if (!discordClient) return; + discordClient.users + .fetch(discordId) + .then((user) => user.send(`❌ Code \`${code}\` could not be redeemed: ${reason}.`)) + .catch(() => {}); +} + function randomDelay(): Promise { const ms = MIN_DELAY_MS + Math.floor(Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS + 1)); logger.debug(`[AUTO REDEEMER] Waiting ${ms}ms before next user`); @@ -160,12 +175,14 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr logger.error( `[AUTO REDEEMER] submitCode returned GenericResponse status=${generic.status} for code ${code}, user ${discordId}` ); + if (credentials.dmOnFailure) sendFailureDm(discordId, code, `Server Error (status ${generic.status})`); return; } } if (!(submitResponse instanceof Object && 'codeStatus' in submitResponse)) { logger.error(`[AUTO REDEEMER] Unexpected response for code ${code}, user ${discordId}`); + if (credentials.dmOnFailure) sendFailureDm(discordId, code, 'Unexpected API Response'); return; } @@ -206,7 +223,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr shouldBePublic ); - if (isSuccess && discordClient) { + if (isSuccess && discordClient && credentials.dmOnSuccess) { discordClient.users .fetch(discordId) .then((user) => user.send(`✅ Code \`${code}\` redeemed successfully!`)) @@ -222,6 +239,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr autoPublic: shouldBePublic, }); } else { + if (credentials.dmOnFailure) sendFailureDm(discordId, code, statusName); await auditManager.logAction(discordId, 'CODE_REDEEM_FAILED', { code, reason: statusName,