Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion IDEAS/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Priority is rated from an end-user perspective:
- ✅ **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.
- **Paginated `/codes`** *(implemented)* — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience.

---

Expand Down
34 changes: 34 additions & 0 deletions src/bot/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as blacksmithCommand from './commands/blacksmith';
import * as deleteaccountCommand from './commands/deleteaccount';
import * as catchupCommand from './commands/catchup';
import * as codesCommand from './commands/codes';
import { buildCodesPage } from './commands/codes';
import * as autoredeemCommand from './commands/autoredeem';
import * as helpCommand from './commands/help';
import * as inventoryCommand from './commands/inventory';
Expand Down Expand Up @@ -181,6 +182,39 @@ client.on(Events.InteractionCreate, async (interaction) => {
}
});

// Event: Button interactions (e.g. /codes pagination)
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isButton()) return;

if (interaction.customId.startsWith('codes:')) {
const parts = interaction.customId.split(':');
const ownerId = parts[1];
const page = parseInt(parts[2] ?? '0', 10);

// Only the user who ran /codes can page through their results
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: '❌ These buttons are not for you.',
flags: MessageFlags.Ephemeral,
});
return;
}

try {
await interaction.deferUpdate();
const { embeds, components } = await buildCodesPage(ownerId, page);
await interaction.editReply({ embeds, components });
} catch (error) {
logger.error('[CODES] Button pagination error:', error);
await interaction.editReply({
content: '❌ Failed to load page.',
embeds: [],
components: [],
});
}
}
});

// Event: Message (code scanning)
client.on(Events.MessageCreate, async (message) => {
// Only scan in the specified channel if configured
Expand Down
172 changes: 95 additions & 77 deletions src/bot/commands/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,98 +3,116 @@ import {
ChatInputCommandInteraction,
EmbedBuilder,
MessageFlags,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
} from 'discord.js';
import { codeManager } from '../database/codeManager';
import { auditManager } from '../database/auditManager';

export const PAGE_SIZE = 5;

export const data = new SlashCommandBuilder()
.setName('codes')
.setDescription('Show your redeemed codes history (last 10)')
.addNumberOption((option) =>
option
.setName('count')
.setDescription('Number of codes to show (1-20)')
.setMinValue(1)
.setMaxValue(20)
.setRequired(false)
);
.setDescription('Show your redeemed codes history');

export async function buildCodesPage(
discordId: string,
page: number
): Promise<{ embeds: EmbedBuilder[]; components: ActionRowBuilder<ButtonBuilder>[] }> {
const total = await codeManager.getRedeemedCodeCount(discordId);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.max(0, Math.min(page, totalPages - 1));
const offset = safePage * PAGE_SIZE;

if (total === 0) {
const embed = new EmbedBuilder()
.setColor(0xffaa00)
.setTitle('📝 Redeemed Codes History')
.setDescription("You haven't redeemed any codes yet.");
return { embeds: [embed], components: [] };
}

export async function execute(interaction: ChatInputCommandInteraction) {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const redeemedCodes = await codeManager.getRedeemedCodeDetails(discordId, PAGE_SIZE, offset);

const embed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('📝 Your Redeemed Codes')
.setFooter({ text: `Page ${safePage + 1} of ${totalPages} · ${total} total` });

redeemedCodes.forEach((codeRow, index) => {
const statusLower = (codeRow.status || 'unknown').toLowerCase();
const statusEmoji =
{
success: '✅',
'code expired': '❌',
error: '⚠️',
}[statusLower] || '❓';

const dateStr = codeRow.redeemedAt
? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: 'Unknown';

const publicBadge = codeRow.isPublic ? ' 🌐' : '';

let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`;
fieldValue += `**Redeemed:** ${dateStr}\n`;

if (codeRow.lootDetail) {
try {
const loot = JSON.parse(codeRow.lootDetail);
if (loot && typeof loot === 'object') {
const lootParts = [];
if (loot.gold) lootParts.push(`Gold: ${loot.gold}`);
if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`);
if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`);
if (lootParts.length > 0) {
fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`;
}
}
} catch {
// Skip if loot detail is not valid JSON
}
}

const count = interaction.options.getNumber('count', false) || 10;
embed.addFields({
name: `${offset + index + 1}. ${codeRow.code}${publicBadge}`,
value: fieldValue,
inline: false,
});
});

// Log action
await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', { count });
const prevButton = new ButtonBuilder()
.setCustomId(`codes:${discordId}:${safePage - 1}`)
.setLabel('◀ Prev')
.setStyle(ButtonStyle.Secondary)
.setDisabled(safePage === 0);

const redeemedCodes = await codeManager.getRedeemedCodeDetails(interaction.user.id, count);
const nextButton = new ButtonBuilder()
.setCustomId(`codes:${discordId}:${safePage + 1}`)
.setLabel('Next ▶')
.setStyle(ButtonStyle.Secondary)
.setDisabled(safePage >= totalPages - 1);

if (redeemedCodes.length === 0) {
const embed = new EmbedBuilder()
.setColor(0xffaa00)
.setTitle('📝 Redeemed Codes History')
.setDescription("You haven't redeemed any codes yet.");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(prevButton, nextButton);

await interaction.editReply({ embeds: [embed] });
return;
}
return { embeds: [embed], components: [row] };
}

const embed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('📝 Your Redeemed Codes')
.setDescription(`Showing your last ${redeemedCodes.length} redeemed codes`)
.setFooter({ text: `Total shown: ${redeemedCodes.length}` });

redeemedCodes.forEach((codeRow, index) => {
const statusLower = (codeRow.status || 'unknown').toLowerCase();
const statusEmoji =
{
success: '✅',
expired: '❌',
error: '⚠️',
}[statusLower] || '❓';

const dateStr = codeRow.redeemedAt
? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: 'Unknown';

const publicBadge = codeRow.isPublic ? ' 🌐 (Public)' : '';

let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`;
fieldValue += `**Redeemed:** ${dateStr}\n`;

if (codeRow.lootDetail) {
try {
const loot = JSON.parse(codeRow.lootDetail);
if (loot && typeof loot === 'object') {
const lootParts = [];
if (loot.gold) lootParts.push(`Gold: ${loot.gold}`);
if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`);
if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`);
if (lootParts.length > 0) {
fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`;
}
}
} catch {
// Skip if loot detail is not valid JSON
}
}
export async function execute(interaction: ChatInputCommandInteraction) {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });

embed.addFields({
name: `${index + 1}. ${codeRow.code}${publicBadge}`,
value: fieldValue,
inline: false,
});
});
await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', {});

await interaction.editReply({ embeds: [embed] });
const { embeds, components } = await buildCodesPage(interaction.user.id, 0);
await interaction.editReply({ embeds, components });
} catch (error) {
console.error('[CODES] Error:', error);

Expand Down
60 changes: 60 additions & 0 deletions src/bot/database/codeManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,66 @@ describe('getRedeemedCodeDetails', () => {
expect(details).toHaveLength(2);
expect(details.every((r) => r.discordId === USER_A)).toBe(true);
});

test('respects the limit parameter', async () => {
for (let i = 1; i <= 7; i++) {
await codeManager.addRedeemedCode(`CODE${String(i).padStart(4, '0')}ZZZZ`, USER_A, 'Success');
}
const details = await codeManager.getRedeemedCodeDetails(USER_A, 3);
expect(details).toHaveLength(3);
});

test('respects the offset parameter for page 2', async () => {
// Insert 7 codes — SQLite preserves insertion order for equal timestamps,
// but we want deterministic ordering so we verify via set membership.
const allCodes = ['AAAAAAAAAAAA', 'BBBBBBBBBBBB', 'CCCCCCCCCCCC', 'DDDDDDDDDDDD', 'EEEEEEEEEEEE', 'FFFFFFFFFFFF', 'GGGGGGGGGGGG'];
for (const code of allCodes) {
await codeManager.addRedeemedCode(code, USER_A, 'Success');
}
const page1 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 0);
const page2 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 5);
expect(page1).toHaveLength(5);
expect(page2).toHaveLength(2);
// Pages must not overlap
const page1Codes = new Set(page1.map((r) => r.code));
for (const row of page2) {
expect(page1Codes.has(row.code)).toBe(false);
}
});

test('returns empty array when offset exceeds total', async () => {
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
const details = await codeManager.getRedeemedCodeDetails(USER_A, 5, 100);
expect(details).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// getRedeemedCodeCount
// ---------------------------------------------------------------------------
describe('getRedeemedCodeCount', () => {
test('returns 0 when user has no codes', async () => {
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(0);
});

test('returns correct count for a single user', async () => {
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired');
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(2);
});

test('does not count codes belonging to another user', async () => {
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Success');
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(1);
});

test('counts all statuses (Success, Code Expired, etc.)', async () => {
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired');
await codeManager.addRedeemedCode('CODE3333CCCC', USER_A, 'Already Redeemed');
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(3);
});
});

// ---------------------------------------------------------------------------
Expand Down
12 changes: 11 additions & 1 deletion src/bot/database/codeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,23 @@ class CodeManager {
return results.map((r) => r.code);
}

async getRedeemedCodeDetails(discordId: string, limit: number = 10): Promise<RedeemedCodeRow[]> {
async getRedeemedCodeCount(discordId: string): Promise<number> {
const result = db
.select({ count: sql<number>`COUNT(*)` })
.from(redeemedCodes)
.where(eq(redeemedCodes.discordId, discordId))
.get();
return result?.count ?? 0;
}

async getRedeemedCodeDetails(discordId: string, limit: number = 10, offset: number = 0): Promise<RedeemedCodeRow[]> {
return db
.select()
.from(redeemedCodes)
.where(eq(redeemedCodes.discordId, discordId))
.orderBy(sql`${redeemedCodes.redeemedAt} DESC`)
.limit(limit)
.offset(offset)
.all();
}

Expand Down
Loading