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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
4 changes: 2 additions & 2 deletions IDEAS/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[test]
preload = ["./src/test/setup.ts"]
coverageIgnorePatterns = ["**/*.test.ts", "**/test/**"]
47 changes: 43 additions & 4 deletions src/bot/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,7 @@ const commands = [
helpCommand,
inventoryCommand,
makepublicCommand,
notificationsCommand,
openCommand,
redeemCommand,
setupCommand,
Expand Down Expand Up @@ -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<string>();
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<void>((resolve) => setTimeout(resolve, 500));
}
Comment thread
BigMichi1 marked this conversation as resolved.
}
}
} catch (err) {
logger.error('Unexpected error in code-detection DM fan-out:', err);
}
})();
}
Comment thread
BigMichi1 marked this conversation as resolved.
Comment thread
BigMichi1 marked this conversation as resolved.
}
Comment thread
BigMichi1 marked this conversation as resolved.
Comment thread
BigMichi1 marked this conversation as resolved.

// Enqueue auto-redeem — serialized so overlapping MessageCreate events
Expand Down
6 changes: 6 additions & 0 deletions src/bot/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export async function execute(interaction: ChatInputCommandInteraction) {
'`/blacksmith contract_type:<type> hero_id:<id> count:<count>`\nUpgrade your heroes using contracts.',
inline: false,
},
{
name: '🔔 Notifications',
value:
'`/notifications [dm_on_code:<true|false>] [dm_on_success:<true|false>] [dm_on_failure:<true|false>]`\nConfigure DM notifications: get alerted when codes are detected, redeemed, or fail.',
inline: false,
},
{
name: '⏮️ Backfill',
value: '`/backfill [channel:<channel>]`\nRecover missed codes from message history (admin only).',
Expand Down
214 changes: 214 additions & 0 deletions src/bot/commands/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean | null> = {}
) {
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);
});
});
Loading
Loading