Skip to content

Commit eee2e86

Browse files
committed
feat: add DM notification preferences and code detection alerts
Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent 63ae784 commit eee2e86

17 files changed

Lines changed: 1190 additions & 13 deletions

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,7 @@ jobs:
4747
uses: pento/lcov-coverage-check@972428b8d5b3fbd8230df23ca4190ecce29979cd # v3.1.0
4848
with:
4949
path: "src/"
50+
ignore-patterns: |
51+
**/*.test.ts
52+
**/test/**
5053
github-token: ${{ secrets.GITHUB_TOKEN }}

IDEAS/TODO.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ Priority is rated from an end-user perspective:
2222

2323
## Notifications & UX
2424

25-
- 🔴 **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.
26-
- 🔴 **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.
25+
- **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.
26+
- **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.
2727
- 🔴 **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.
2828
- 🟢 **Paginated `/codes`** — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience.
2929

bunfig.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[test]
22
preload = ["./src/test/setup.ts"]
3+
coverageIgnorePatterns = ["**/*.test.ts", "**/test/**"]

src/bot/bot.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as autoredeemCommand from './commands/autoredeem';
1818
import * as helpCommand from './commands/help';
1919
import * as inventoryCommand from './commands/inventory';
2020
import * as makepublicCommand from './commands/makepublic';
21+
import * as notificationsCommand from './commands/notifications';
2122
import * as openCommand from './commands/open';
2223
import * as redeemCommand from './commands/redeem';
2324
import * as setupCommand from './commands/setup';
@@ -59,6 +60,7 @@ const commands = [
5960
helpCommand,
6061
inventoryCommand,
6162
makepublicCommand,
63+
notificationsCommand,
6264
openCommand,
6365
redeemCommand,
6466
setupCommand,
@@ -195,10 +197,47 @@ client.on(Events.MessageCreate, async (message) => {
195197
// Deduplicate in case the same code appears multiple times in one message.
196198
const uniqueCodes = [...new Set(foundCodes)];
197199

198-
// Persist all found codes to pending_codes immediately so /catchup can
199-
// recover them if auto-redeem has no enabled users or the API fails.
200-
for (const code of uniqueCodes) {
201-
await codeManager.addPendingCode(code);
200+
// Persist all found codes to pending_codes in a single batch INSERT.
201+
// onConflictDoNothing gives at-most-once insert semantics and eliminates
202+
// the TOCTOU race of a pre-read snapshot under concurrent MessageCreate events.
203+
// Only newly inserted codes trigger DM notifications.
204+
const newCodes = await codeManager.addNewPendingCodes(uniqueCodes);
205+
206+
// DM users who opted in for code-detection notifications (independent of autoredeem).
207+
// A single bulk query resolves per-recipient redeemed status, then a short delay
208+
// between sends avoids bursting Discord DM rate limits. The message author is
209+
// excluded since they can already see the code in the channel.
210+
// Note: users who have both dmOnCode and dmOnSuccess enabled will receive two
211+
// DMs per code — one here and one from autoRedeemer on successful redemption.
212+
if (newCodes.length > 0) {
213+
const dmIds = await userManager.getDiscordIdsWithDmOnCode();
214+
const recipientIds = dmIds.filter((id) => id !== message.author.id);
215+
if (recipientIds.length > 0) {
216+
void (async () => {
217+
try {
218+
const redeemedMap = await codeManager.getRedeemedCodesByUsers(newCodes, recipientIds);
219+
for (let i = 0; i < recipientIds.length; i++) {
220+
const id = recipientIds[i]!;
221+
try {
222+
const alreadyRedeemed = redeemedMap.get(id) ?? new Set<string>();
223+
const unredeemedCodes = newCodes.filter((c) => !alreadyRedeemed.has(c));
224+
if (unredeemedCodes.length === 0) continue;
225+
const codeList = unredeemedCodes.map((c) => `\`${c}\``).join(', ');
226+
const label = `New code${unredeemedCodes.length > 1 ? 's' : ''} detected: ${codeList}`;
227+
const u = await client.users.fetch(id);
228+
await u.send(`🔔 ${label}`);
229+
} catch { /* DM delivery failure is non-critical */ }
230+
finally {
231+
if (i < recipientIds.length - 1) {
232+
await new Promise<void>((resolve) => setTimeout(resolve, 500));
233+
}
234+
}
235+
}
236+
} catch (err) {
237+
logger.error('Unexpected error in code-detection DM fan-out:', err);
238+
}
239+
})();
240+
}
202241
}
203242

204243
// Enqueue auto-redeem — serialized so overlapping MessageCreate events

src/bot/commands/help.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export async function execute(interaction: ChatInputCommandInteraction) {
6565
'`/blacksmith contract_type:<type> hero_id:<id> count:<count>`\nUpgrade your heroes using contracts.',
6666
inline: false,
6767
},
68+
{
69+
name: '🔔 Notifications',
70+
value:
71+
'`/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.',
72+
inline: false,
73+
},
6874
{
6975
name: '⏮️ Backfill',
7076
value: '`/backfill [channel:<channel>]`\nRecover missed codes from message history (admin only).',
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, test, expect, beforeAll, beforeEach, spyOn } from 'bun:test';
2+
import { MessageFlags } from 'discord.js';
3+
import { db, initializeDatabase } from '../database/db';
4+
import { users, redeemedCodes, pendingCodes, auditLog } from '../database/schema/index';
5+
import { userManager } from '../database/userManager';
6+
import { execute } from './notifications';
7+
8+
// ---------------------------------------------------------------------------
9+
// Interaction mock helpers
10+
// ---------------------------------------------------------------------------
11+
12+
function makeInteraction(
13+
userId: string,
14+
options: Record<string, boolean | null> = {}
15+
) {
16+
const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply');
17+
const replySpy = spyOn({ reply: async (_: unknown) => {} }, 'reply');
18+
19+
const interaction = {
20+
user: { id: userId, tag: `user#${userId}` },
21+
deferred: false,
22+
replied: false,
23+
deferReply: async () => { (interaction as any).deferred = true; },
24+
editReply: editReplySpy,
25+
reply: replySpy,
26+
options: {
27+
getBoolean: (name: string) => options[name] ?? null,
28+
},
29+
} as any;
30+
31+
return { interaction, editReplySpy, replySpy };
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// Setup
36+
// ---------------------------------------------------------------------------
37+
38+
beforeAll(() => {
39+
initializeDatabase();
40+
});
41+
42+
beforeEach(() => {
43+
db.delete(auditLog).run();
44+
db.delete(pendingCodes).run();
45+
db.delete(redeemedCodes).run();
46+
db.delete(users).run();
47+
});
48+
49+
// ---------------------------------------------------------------------------
50+
// No credentials
51+
// ---------------------------------------------------------------------------
52+
53+
describe('/notifications – no credentials', () => {
54+
test('replies with error embed when user has no credentials', async () => {
55+
const { interaction, editReplySpy } = makeInteraction('unknown-user');
56+
57+
await execute(interaction);
58+
59+
expect(editReplySpy).toHaveBeenCalledTimes(1);
60+
const reply = editReplySpy.mock.calls[0]![0] as any;
61+
expect(reply.embeds[0].data.title).toContain('No Credentials Found');
62+
});
63+
});
64+
65+
// ---------------------------------------------------------------------------
66+
// Show current settings (no options)
67+
// ---------------------------------------------------------------------------
68+
69+
describe('/notifications – show current settings', () => {
70+
test('shows defaults when no options are provided', async () => {
71+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
72+
const { interaction, editReplySpy } = makeInteraction('user-1');
73+
74+
await execute(interaction);
75+
76+
expect(editReplySpy).toHaveBeenCalledTimes(1);
77+
const reply = editReplySpy.mock.calls[0]![0] as any;
78+
const embed = reply.embeds[0].data;
79+
expect(embed.title).toContain('Notification Preferences');
80+
const fieldValues = embed.fields.map((f: any) => f.value);
81+
// dmOnCode default false, dmOnSuccess default true, dmOnFailure default false
82+
expect(fieldValues[0]).toContain('Off');
83+
expect(fieldValues[1]).toContain('On');
84+
expect(fieldValues[2]).toContain('Off');
85+
});
86+
87+
test('shows updated values after preferences have been changed', async () => {
88+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
89+
await userManager.setNotificationPreferences('user-1', {
90+
dmOnCode: true,
91+
dmOnSuccess: false,
92+
dmOnFailure: true,
93+
});
94+
const { interaction, editReplySpy } = makeInteraction('user-1');
95+
96+
await execute(interaction);
97+
98+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
99+
const fieldValues = embed.fields.map((f: any) => f.value);
100+
expect(fieldValues[0]).toContain('On');
101+
expect(fieldValues[1]).toContain('Off');
102+
expect(fieldValues[2]).toContain('On');
103+
});
104+
});
105+
106+
// ---------------------------------------------------------------------------
107+
// Update preferences
108+
// ---------------------------------------------------------------------------
109+
110+
describe('/notifications – update preferences', () => {
111+
test('enables dmOnCode and reflects it in the reply embed', async () => {
112+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
113+
const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_code: true });
114+
115+
await execute(interaction);
116+
117+
// Verify DB was updated
118+
const creds = await userManager.getCredentials('user-1');
119+
expect(creds?.dmOnCode).toBe(true);
120+
121+
// Verify reply embed shows correct state
122+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
123+
expect(embed.title).toContain('Updated');
124+
const fieldValues = embed.fields.map((f: any) => f.value);
125+
expect(fieldValues[0]).toContain('On'); // dmOnCode
126+
expect(fieldValues[1]).toContain('On'); // dmOnSuccess unchanged (default true)
127+
expect(fieldValues[2]).toContain('Off'); // dmOnFailure unchanged (default false)
128+
});
129+
130+
test('disables dmOnSuccess', async () => {
131+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
132+
const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_success: false });
133+
134+
await execute(interaction);
135+
136+
const creds = await userManager.getCredentials('user-1');
137+
expect(creds?.dmOnSuccess).toBe(false);
138+
139+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
140+
const fieldValues = embed.fields.map((f: any) => f.value);
141+
expect(fieldValues[1]).toContain('Off');
142+
});
143+
144+
test('enables dmOnFailure', async () => {
145+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
146+
const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_failure: true });
147+
148+
await execute(interaction);
149+
150+
const creds = await userManager.getCredentials('user-1');
151+
expect(creds?.dmOnFailure).toBe(true);
152+
153+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
154+
const fieldValues = embed.fields.map((f: any) => f.value);
155+
expect(fieldValues[2]).toContain('On');
156+
});
157+
158+
test('updates all three prefs at once', async () => {
159+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
160+
const { interaction } = makeInteraction('user-1', {
161+
dm_on_code: true,
162+
dm_on_success: false,
163+
dm_on_failure: true,
164+
});
165+
166+
await execute(interaction);
167+
168+
const creds = await userManager.getCredentials('user-1');
169+
expect(creds?.dmOnCode).toBe(true);
170+
expect(creds?.dmOnSuccess).toBe(false);
171+
expect(creds?.dmOnFailure).toBe(true);
172+
});
173+
});
174+
175+
// ---------------------------------------------------------------------------
176+
// Error path
177+
// ---------------------------------------------------------------------------
178+
179+
describe('/notifications – error handling', () => {
180+
test('uses editReply when already deferred on error', async () => {
181+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
182+
183+
const { interaction, editReplySpy } = makeInteraction('user-1', { dm_on_code: true });
184+
// Force an error after deferReply by making setNotificationPreferences throw
185+
const spy = spyOn(userManager, 'setNotificationPreferences').mockRejectedValueOnce(
186+
new Error('DB error')
187+
);
188+
189+
await execute(interaction);
190+
191+
expect(editReplySpy).toHaveBeenCalled();
192+
const reply = editReplySpy.mock.calls[0]![0] as any;
193+
expect(reply.content).toContain('error');
194+
195+
spy.mockRestore();
196+
});
197+
198+
test('uses reply when not deferred on error', async () => {
199+
const { interaction, editReplySpy, replySpy } = makeInteraction('user-1', { dm_on_code: true });
200+
201+
// deferReply throws so deferred stays false, getCredentials is never called
202+
(interaction as any).deferReply = async () => {
203+
throw new Error('interaction expired');
204+
};
205+
206+
await execute(interaction);
207+
208+
expect(editReplySpy).not.toHaveBeenCalled();
209+
expect(replySpy).toHaveBeenCalled();
210+
const reply = replySpy.mock.calls[0]![0] as any;
211+
expect(reply.content).toContain('error');
212+
expect(reply.flags).toBe(MessageFlags.Ephemeral);
213+
});
214+
});

0 commit comments

Comments
 (0)