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
44 changes: 44 additions & 0 deletions packages/backend/src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion packages/backend/tests/unit/routes/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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')
Expand Down
163 changes: 163 additions & 0 deletions packages/bot/src/functions/general/commands/voterewards.ts
Original file line number Diff line number Diff line change
@@ -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<VoteState | null> {
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()] },
})
},
})