Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e185764
fix(player): harden stream bridge + add 57 missing tests (#815)
LucasSantana-Dev May 6, 2026
c4b9367
ci: extend workflow triggers to release/** branches
LucasSantana-Dev May 6, 2026
dcef5eb
Merge branch 'release/v2.10.0' into ci/release-branch-triggers
LucasSantana-Dev May 6, 2026
b823fcd
ci: add PR-Agent AI review and Socket.dev supply chain scan
LucasSantana-Dev May 7, 2026
282795b
feat(backend): add /invite UTM tracking route + update README messaging
LucasSantana-Dev May 7, 2026
57770db
fix: correct pr-agent model field + synchronize error-kill assertion
LucasSantana-Dev May 7, 2026
a2420c9
ci: add allow-warnings to socket.dev action, note GitHub App covers P…
LucasSantana-Dev May 7, 2026
9646c1b
feat(spotify): add getUserSavedTracks + likedTrackIds to user seeds
LucasSantana-Dev May 7, 2026
8ac328a
fix(backend): harden invite route + add test coverage
LucasSantana-Dev May 7, 2026
153f79e
ci: disable unavailable socketdev action + fix S5144 in getUserSavedT…
LucasSantana-Dev May 7, 2026
0aacb04
ci: replace unavailable SocketDev action with run step
LucasSantana-Dev May 7, 2026
949676f
test(spotify): add getUserSavedTracks coverage to fix SonarCloud gate
LucasSantana-Dev May 7, 2026
9ae3a9a
ci: trigger CI for PR #816 [skip ci-push]
LucasSantana-Dev May 7, 2026
646e7dd
fix(ci): fix YAML syntax error in Socket.dev scan step
LucasSantana-Dev May 7, 2026
94b498d
fix(autoplay): use Spotify genres as fallback when Last.fm is not lin…
LucasSantana-Dev May 7, 2026
4f1eac2
Revert "fix(autoplay): use Spotify genres as fallback when Last.fm is…
LucasSantana-Dev May 7, 2026
1ecc667
fix: address CodeRabbit review comments on PR #816
LucasSantana-Dev May 7, 2026
b9e60a6
fix: address review findings from /pr-review-toolkit:review-pr
LucasSantana-Dev May 7, 2026
613756c
test(invite): mock logAndSwallow + reset mockInfoLog between tests
LucasSantana-Dev May 7, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,7 @@ jobs:
with:
extra_args: --only-verified
continue-on-error: true

- name: Socket.dev supply chain scan
run: echo "Socket.dev scan -- GitHub App (apps/socket-security) covers PR-level blocking"
# SocketDev/socket-security-action@v1 is unavailable; removed to unblock CI
Comment thread
LucasSantana-Dev marked this conversation as resolved.
37 changes: 37 additions & 0 deletions .github/workflows/pr-agent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: PR Agent

on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
pr-agent:
name: AI Code Review
runs-on: ubuntu-latest
if: ${{ github.event.sender.type != 'Bot' && (github.event_name != 'issue_comment' || github.event.issue.pull_request != null) }}
Comment thread
LucasSantana-Dev marked this conversation as resolved.
permissions:
issues: write
pull-requests: write
contents: read
steps:
- name: PR Agent action
uses: Codium-ai/pr-agent@009ba5a116c4d3273368a6dc53a4efdb7904d519 # main
env:
OPENAI.KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI.API_TYPE: anthropic
OPENAI.MODEL: claude-sonnet-4-6
CONFIG.AI_PROVIDER: anthropic
Comment thread
LucasSantana-Dev marked this conversation as resolved.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github_action_config.auto_review: 'true'
github_action_config.auto_describe: 'true'
github_action_config.auto_improve: 'true'
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
</p>

<p align="center">
<b>Self-hosted Discord music bot + React dashboard.</b><br>
TypeScript monorepo · Discord.js 14 · Prisma 7 · ~2500 tests · Zero prod incidents.
<b>The Discord music bot that can't be shut down — because you host it.</b><br>
Self-hosted · Open-source · TypeScript monorepo · ~2500 tests · Zero prod incidents.
</p>

<p align="center">
<a href="https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496"><b>→ Invite Lucky</b></a> ·
<a href="https://lucky.lucassantana.tech/invite?utm_source=github&utm_medium=readme&utm_campaign=readme-badge"><b>→ Invite Lucky</b></a> ·
<a href="https://lucky.lucassantana.tech"><b>Dashboard</b></a> ·
<a href="./docs/ARCHITECTURE.md">Architecture</a> ·
<a href="./CHANGELOG.md">Changelog</a> ·
Expand All @@ -20,7 +20,7 @@

<p align="center">
<a href="https://github.com/LucasSantana-Dev/Lucky/actions/workflows/ci.yml"><img src="https://github.com/LucasSantana-Dev/Lucky/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496"><img src="https://img.shields.io/badge/Invite-Lucky%20Bot-5865F2?logo=discord&logoColor=white" alt="Invite Lucky" /></a>
<a href="https://lucky.lucassantana.tech/invite?utm_source=github&utm_medium=readme&utm_campaign=readme-badge"><img src="https://img.shields.io/badge/Invite-Lucky%20Bot-5865F2?logo=discord&logoColor=white" alt="Invite Lucky" /></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22.x-green.svg" alt="Node.js" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-blue.svg" alt="TypeScript" /></a>
<a href="https://discord.js.org/"><img src="https://img.shields.io/badge/Discord.js-14-purple.svg" alt="Discord.js" /></a>
Expand All @@ -33,7 +33,7 @@

Lucky is a production-grade Discord bot built as a TypeScript monorepo. Music player with autoplay + recommendations, full moderation suite, auto-mod presets, and a React 19 dashboard — all self-hostable via Docker. Ships with /artist and /album commands for Spotify listening stats, session save/restore, a leveling system with XP and role rewards, and Twitch stream notifications.

**Live at** [lucky.lucassantana.tech](https://lucky.lucassantana.tech) · [Invite to your server](https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496)
**Live at** [lucky.lucassantana.tech](https://lucky.lucassantana.tech) · [Invite to your server](https://lucky.lucassantana.tech/invite?utm_source=github&utm_medium=readme&utm_campaign=readme-badge)

---

Expand All @@ -51,13 +51,14 @@ Lucky is a production-grade Discord bot built as a TypeScript monorepo. Music pl

## Why Lucky?

Most Discord music bots are cloud-only black boxes. Lucky is different:
Groovy, Rythm, Hydra — the biggest Discord music bots all died. YouTube API enforcement can kill any cloud-only bot overnight. Lucky can't be shut down because you host it.

- **Self-hostable** — your data, your server, your rules. No dependency on a third-party service staying alive.
- **Shutdown-proof** — self-hosted means no third-party service can take your bot away. Your server, your uptime.
- **Full-stack** — bot + React 19 dashboard in one monorepo. Control music, moderation, and settings from a web interface.
- **Production-grade** — ~2500 tests, zero production incidents, Sentry monitoring, and SonarCloud quality gates.
- **Multi-source** — YouTube + Spotify + SoundCloud, not locked to one provider.
- **Smart autoplay** — personalized recommendations from listening history, not just random tracks.
- **No paywall** — every feature included, free forever. No premium tier, no upsells.

---

Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { requireGuildModuleAccess } from '../middleware/guildAccess'
import { errorHandler } from '../middleware/errorHandler'
import { setupHealthRoutes } from './health'
import { setupStatsRoutes } from './stats'
import { setupInviteRoute } from './invite'

type GuildGuardConfig = {
path: string
Expand Down Expand Up @@ -81,6 +82,7 @@ const routeSetups = [
]

export function setupRoutes(app: Express): void {
setupInviteRoute(app)
setupHealthRoutes(app)
setupStatsRoutes(app)
setupInternalNotifyRoutes(app)
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/routes/invite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Express, Request, Response } from 'express'
import { infoLog } from '@lucky/shared/utils'
import { logAndSwallow } from '@lucky/shared/utils/error'
import { apiLimiter } from '../middleware/rateLimit'

const DISCORD_INVITE_URL =
'https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496'

function toUtmString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}

export function setupInviteRoute(app: Express): void {
app.get('/invite', apiLimiter, (req: Request, res: Response) => {
const utm_source = toUtmString(req.query.utm_source)
const utm_medium = toUtmString(req.query.utm_medium)
const utm_campaign = toUtmString(req.query.utm_campaign)
const utm_content = toUtmString(req.query.utm_content)

try {
infoLog({
message: '[invite] click',
data: { utm_source, utm_medium, utm_campaign, utm_content },
})
} catch (err) {
logAndSwallow(err, 'invite.infoLog')
}

res.redirect(302, DISCORD_INVITE_URL)
})
}
9 changes: 8 additions & 1 deletion packages/backend/tests/unit/routes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Express } from 'express'

const setupHealthRoutes = jest.fn()
const setupStatsRoutes = jest.fn()
const setupInviteRoute = jest.fn()
const setupAuthRoutes = jest.fn()
const setupToggleRoutes = jest.fn()
const setupGuildRoutes = jest.fn()
Expand Down Expand Up @@ -41,6 +42,10 @@ jest.mock('../../../src/routes/stats', () => ({
setupStatsRoutes,
}))

jest.mock('../../../src/routes/invite', () => ({
setupInviteRoute,
}))

jest.mock('../../../src/routes/auth', () => ({
setupAuthRoutes,
}))
Expand Down Expand Up @@ -149,11 +154,12 @@ jest.mock('../../../src/middleware/errorHandler', () => ({

import { setupRoutes } from '../../../src/routes'

type MockApp = Pick<Express, 'use'>
type MockApp = Pick<Express, 'use' | 'get'>

describe('setupRoutes', () => {
const app: MockApp = {
use: jest.fn() as unknown as Express['use'],
get: jest.fn() as unknown as Express['get'],
}

beforeEach(() => {
Expand All @@ -169,6 +175,7 @@ describe('setupRoutes', () => {

expect(setupHealthRoutes).toHaveBeenCalledWith(app)
expect(setupStatsRoutes).toHaveBeenCalledWith(app)
expect(setupInviteRoute).toHaveBeenCalledWith(app)
expect(setupInternalNotifyRoutes).toHaveBeenCalledWith(app)
expect(setupWebhookPublicRoutes).toHaveBeenCalledWith(app)
expect(app.use).toHaveBeenCalledWith('/api/', apiLimiter)
Expand Down
92 changes: 92 additions & 0 deletions packages/backend/tests/unit/routes/invite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, test, expect, jest, beforeEach } from '@jest/globals'
import express from 'express'
import request from 'supertest'

const mockInfoLog = jest.fn()
const mockLogAndSwallow = jest.fn()

jest.mock('@lucky/shared/utils', () => ({
infoLog: (...args: unknown[]) => mockInfoLog(...args),
}))

jest.mock('@lucky/shared/utils/error', () => ({
logAndSwallow: (...args: unknown[]) => mockLogAndSwallow(...args),
}))

jest.mock('../../../src/middleware/rateLimit', () => ({
apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}))

import { setupInviteRoute } from '../../../src/routes/invite'

function buildApp() {
const app = express()
setupInviteRoute(app)
return app
}

describe('GET /invite', () => {
beforeEach(() => {
jest.clearAllMocks()
mockInfoLog.mockReset()
})

test('redirects to Discord invite URL', async () => {
const res = await request(buildApp()).get('/invite')
expect(res.status).toBe(302)
expect(res.headers.location).toContain('discord.com/oauth2/authorize')
})

test('logs UTM params when present', async () => {
await request(buildApp()).get(
'/invite?utm_source=github&utm_medium=readme&utm_campaign=test&utm_content=badge',
)
expect(mockInfoLog).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
utm_source: 'github',
utm_medium: 'readme',
utm_campaign: 'test',
utm_content: 'badge',
}),
}),
)
})

test('logs undefined for missing UTM params', async () => {
await request(buildApp()).get('/invite')
expect(mockInfoLog).toHaveBeenCalledWith(
expect.objectContaining({
data: {
utm_source: undefined,
utm_medium: undefined,
utm_campaign: undefined,
utm_content: undefined,
},
}),
)
})

test('coerces array UTM values to undefined', async () => {
await request(buildApp()).get('/invite?utm_source=a&utm_source=b')
const logged = (mockInfoLog.mock.calls[0] as [{ data: { utm_source: unknown } }])[0]
expect(logged.data.utm_source).toBeUndefined()
})

test('still redirects when infoLog throws', async () => {
mockInfoLog.mockImplementation(() => {
throw new Error('logging failure')
})
const res = await request(buildApp()).get('/invite')
expect(res.status).toBe(302)
expect(res.headers.location).toContain('discord.com')
})

test('never redirects to a user-supplied URL (no open redirect)', async () => {
const res = await request(buildApp()).get(
'/invite?redirect=https://evil.example.com',
)
expect(res.headers.location).not.toContain('evil.example.com')
expect(res.headers.location).toContain('discord.com')
})
})
5 changes: 3 additions & 2 deletions packages/bot/src/handlers/player/streamBridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ describe('streamViaYtDlp – process lifecycle', () => {
it('rejects and kills proc when process emits an error', async () => {
const proc = makeFakeProc()
mockSpawn.mockReturnValue(proc)
setImmediate(() => proc.emit('error', new Error('ENOENT yt-dlp')))
await expect(streamViaYtDlp(validUrl)).rejects.toThrow('ENOENT yt-dlp')
const promise = streamViaYtDlp(validUrl)
proc.emit('error', new Error('ENOENT yt-dlp'))
await expect(promise).rejects.toThrow('ENOENT yt-dlp')
expect(proc.kill).toHaveBeenCalled()
})

Expand Down
79 changes: 79 additions & 0 deletions packages/bot/src/spotify/spotifyApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getSpotifyRecommendations,
getArtistGenres,
getUserTopArtistsAndTracks,
getUserSavedTracks,
} from './spotifyApi'

type MockFetchResponse = {
Expand Down Expand Up @@ -836,4 +837,82 @@ describe('spotifyApi', () => {
expect(result).toBeNull()
})
})

describe('getUserSavedTracks', () => {
it('returns track ids from saved tracks response', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [
{ track: { id: 'track-1' } },
{ track: { id: 'track-2' } },
{ track: { id: 'track-3' } },
],
}),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual(['track-1', 'track-2', 'track-3'])
})

it('filters out items with missing track id', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [
{ track: { id: 'track-1' } },
{ track: {} },
{ track: null },
{},
],
}),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual(['track-1'])
})

it('returns empty array when response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false })
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual([])
})

it('returns empty array when json parse fails', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.reject(new Error('parse error')),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual([])
})

it('returns empty array when fetch throws', async () => {
fetchMock.mockRejectedValue(new Error('network error'))
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual([])
})

it('caps limit at 50', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

await getUserSavedTracks('token', 200)
const url = (fetchMock.mock.calls[0] as [string])[0]
expect(url).toContain('limit=50')
})
})
})
Loading
Loading