diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index 9f6cc295b..3a163969d 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -49,7 +49,51 @@ function verifyTopggAuth(req: Request): void { } } +function verifyInternalKey(req: Request): void { + const expected = process.env.LUCKY_NOTIFY_API_KEY + const provided = req.header('x-notify-key') + if (!expected || !provided || provided !== expected) { + throw AppError.unauthorized('invalid internal key') + } +} + +async function readVoteState( + redis: Redis, + userId: string, +): Promise<{ hasVoted: boolean; streak: number; nextVoteInSeconds: number }> { + const voteKey = `votes:${userId}` + const streakKey = `votes:streak:${userId}` + const [[, voteTs], [, streakRaw], [, ttl]] = (await redis + .pipeline() + .get(voteKey) + .get(streakKey) + .ttl(voteKey) + .exec()) as [ + [Error | null, string | null], + [Error | null, string | null], + [Error | null, number], + ] + return { + hasVoted: voteTs !== null, + streak: streakRaw ? Number(streakRaw) : 0, + nextVoteInSeconds: ttl > 0 ? ttl : 0, + } +} + export function setupWebhookRoutes(app: Express): void { + app.get( + '/api/internal/votes/:userId', + asyncHandler(async (req: Request, res: Response) => { + verifyInternalKey(req) + const { userId } = req.params + if (!userId || typeof userId !== 'string' || !/^\d+$/.test(userId)) { + throw AppError.badRequest('invalid userId') + } + const state = await readVoteState(getRedis(), userId) + res.status(200).json(state) + }), + ) + app.post( '/webhooks/topgg-votes', writeLimiter, diff --git a/packages/backend/tests/unit/routes/webhooks.test.ts b/packages/backend/tests/unit/routes/webhooks.test.ts index 8fbb1a3c2..9493fa19e 100644 --- a/packages/backend/tests/unit/routes/webhooks.test.ts +++ b/packages/backend/tests/unit/routes/webhooks.test.ts @@ -8,6 +8,8 @@ const pipelineMock = { set: jest.fn().mockReturnThis(), incr: jest.fn().mockReturnThis(), expire: jest.fn().mockReturnThis(), + get: jest.fn().mockReturnThis(), + ttl: jest.fn().mockReturnThis(), exec: pipelineExec, } @@ -41,16 +43,20 @@ function buildApp(): express.Express { beforeEach(() => { process.env.TOPGG_AUTH_TOKEN = 'valid-token' + process.env.LUCKY_NOTIFY_API_KEY = 'internal-key' process.env.REDIS_HOST = 'localhost' process.env.REDIS_PORT = '6379' - pipelineExec.mockClear() + pipelineExec.mockClear().mockResolvedValue([]) pipelineMock.set.mockClear() pipelineMock.incr.mockClear() pipelineMock.expire.mockClear() + pipelineMock.get.mockClear() + pipelineMock.ttl.mockClear() }) afterEach(() => { delete process.env.TOPGG_AUTH_TOKEN + delete process.env.LUCKY_NOTIFY_API_KEY delete process.env.REDIS_HOST delete process.env.REDIS_PORT }) @@ -98,6 +104,37 @@ describe('POST /webhooks/topgg-votes', () => { expect(res.status).toBe(400) }) + it('responds to GET /api/internal/votes/:userId with current state', async () => { + pipelineExec.mockResolvedValueOnce([ + [null, '1700000000000'], + [null, '7'], + [null, 3600], + ]) + const res = await request(buildApp()) + .get('/api/internal/votes/123') + .set('x-notify-key', 'internal-key') + expect(res.status).toBe(200) + expect(res.body).toEqual({ + hasVoted: true, + streak: 7, + nextVoteInSeconds: 3600, + }) + }) + + it('rejects GET with wrong internal key', async () => { + const res = await request(buildApp()) + .get('/api/internal/votes/123') + .set('x-notify-key', 'wrong') + expect(res.status).toBe(401) + }) + + it('rejects GET with non-numeric userId', async () => { + const res = await request(buildApp()) + .get('/api/internal/votes/abc') + .set('x-notify-key', 'internal-key') + expect(res.status).toBe(400) + }) + it('records a valid upvote in redis via pipeline', async () => { const res = await request(buildApp()) .post('/webhooks/topgg-votes') diff --git a/packages/bot/src/functions/general/commands/voterewards.ts b/packages/bot/src/functions/general/commands/voterewards.ts new file mode 100644 index 000000000..e6a0f1c32 --- /dev/null +++ b/packages/bot/src/functions/general/commands/voterewards.ts @@ -0,0 +1,163 @@ +import { SlashCommandBuilder, EmbedBuilder } from '@discordjs/builders' +import { COLOR } from '@lucky/shared/constants' +import { infoLog, errorLog } from '@lucky/shared/utils' +import Command from '../../../models/Command' +import { interactionReply } from '../../../utils/general/interactionReply' + +const TOP_GG_BOT_ID = '962198089161134131' +const TOP_GG_VOTE_URL = `https://top.gg/bot/${TOP_GG_BOT_ID}/vote` + +type VoteState = { + hasVoted: boolean + streak: number + nextVoteInSeconds: number +} + +type VoteTier = { + threshold: number + label: string + perk: string +} + +const TIERS: VoteTier[] = [ + { + threshold: 30, + label: 'Lucky Legend', + perk: 'Dashboard badge + custom autoplay weighting + priority support', + }, + { + threshold: 14, + label: 'Lucky Regular', + perk: 'Custom autoplay weighting + early access to new commands', + }, + { + threshold: 7, + label: 'Lucky Fan', + perk: 'Early access to new commands', + }, + { threshold: 1, label: 'Lucky Supporter', perk: 'Our thanks 💛' }, +] + +function tierFor(streak: number): VoteTier | null { + for (const t of TIERS) { + if (streak >= t.threshold) return t + } + return null +} + +function getBackendOrigin(): string | null { + const raw = process.env.WEBAPP_BACKEND_URL?.trim() + if (!raw) return null + try { + const parsed = new URL(raw) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + return parsed.origin + } catch { + return null + } +} + +async function fetchVoteState(userId: string): Promise { + const origin = getBackendOrigin() + const key = process.env.LUCKY_NOTIFY_API_KEY + if (!origin || !key) return null + try { + const resp = await fetch( + `${origin}/api/internal/votes/${encodeURIComponent(userId)}`, + { headers: { 'x-notify-key': key } }, + ) + if (!resp.ok) return null + return (await resp.json()) as VoteState + } catch (error) { + errorLog({ + message: 'voterewards: failed to fetch vote state', + data: { userId, error: String(error) }, + }) + return null + } +} + +function formatNextVoteIn(seconds: number): string { + if (seconds <= 0) return 'now' + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 0) return `in ${hours}h ${minutes}m` + return `in ${minutes}m` +} + +export default new Command({ + data: new SlashCommandBuilder() + .setName('voterewards') + .setDescription( + '💛 Check your Lucky vote streak and upcoming perks on top.gg.', + ), + category: 'general', + execute: async ({ interaction }) => { + infoLog({ + message: `voterewards requested by ${interaction.user.tag}`, + }) + + const state = await fetchVoteState(interaction.user.id) + + if (!state) { + const embed = new EmbedBuilder() + .setTitle('💛 Vote for Lucky on top.gg') + .setColor(COLOR.LUCKY_PURPLE) + .setDescription( + [ + 'Your vote streak unlocks perks — custom autoplay weighting, a dashboard badge, and more.', + '', + `[Vote on top.gg](${TOP_GG_VOTE_URL})`, + ].join('\n'), + ) + .setFooter({ + text: 'Vote tracking is offline right now — try again later.', + }) + await interactionReply({ + interaction, + content: { embeds: [embed.toJSON()] }, + }) + return + } + + const tier = tierFor(state.streak) + const voteLine = state.hasVoted + ? `🗳️ You voted recently — next vote ${formatNextVoteIn(state.nextVoteInSeconds)}` + : `🗳️ You can [vote now](${TOP_GG_VOTE_URL}) to start or extend your streak` + const tierLine = tier + ? `🏅 **${tier.label}** (${state.streak}-vote streak) — ${tier.perk}` + : `Vote to unlock your first perk at streak **1**.` + + const embed = new EmbedBuilder() + .setTitle('💛 Lucky Vote Rewards') + .setColor(COLOR.LUCKY_PURPLE) + .setDescription([voteLine, '', tierLine].join('\n')) + .addFields( + { + name: 'Current streak', + value: `${state.streak}`, + inline: true, + }, + { + name: 'Next tier', + value: (() => { + const nextTier = [...TIERS] + .reverse() + .find((t) => t.threshold > state.streak) + return nextTier + ? `${nextTier.label} at ${nextTier.threshold}` + : 'Max tier reached' + })(), + inline: true, + }, + ) + .setURL(TOP_GG_VOTE_URL) + + await interactionReply({ + interaction, + content: { embeds: [embed.toJSON()] }, + }) + }, +})