Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ BOT_NAME=Lucky
BOT_DESCRIPTION=A modern Discord bot for music playback and downloads
BOT_AVATAR_URL=
BOT_COLOR=0x5865F2
# BOT_PRESENCE_ACTIVITIES format:
# TYPE:template | TYPE:template??fallback | ...
# Supported activity types: PLAYING, WATCHING, LISTENING, COMPETING
# Supported tokens: {guildCount}, {memberCount}, {commandCount}, {activeMusicSessions}
# Use ??fallback to show alternate text when {activeMusicSessions} is 0.
# Example preserving the default 5-activity rotation:
# BOT_PRESENCE_ACTIVITIES=LISTENING:/play • High-fidelity music|WATCHING:{guildCount} servers managed|WATCHING:{memberCount} members protected|COMPETING:{activeMusicSessions} active music sessions??Fast and safe moderation|PLAYING:/help • {commandCount} commands
BOT_PRESENCE_ACTIVITIES=
BOT_PRESENCE_STATUS=online
BOT_WEBSITE=
BOT_SUPPORT_SERVER=
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **command availability hardening**: command bootstrap no longer depends on eager Prisma initialization from `moderationSettings`, so slash commands such as `/version` and `/autoplay` stay available even when unrelated shared service modules would otherwise fail during import.
- **version command accuracy**: `/version` now prefers the runtime package version (`npm_package_version`) and falls back to the root `package.json`, avoiding stale `packages/bot/package.json` version output after releases.

### Added

- **presence rotation templates**: `BOT_PRESENCE_ACTIVITIES` now customizes non-music presence rotation while preserving the default rotation when unset and keeping now-playing presence overrides intact.

### Changed

- **frontend shell/sidebar foundation**: strengthened the Lucky sidebar into a clearer guild command center with a more prominent active guild block, explicit readiness state, stronger nav grouping, and fuller active-route treatment while keeping route structure unchanged.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ packages/
### Bot
- Multi-platform music (YouTube, Spotify) with queue, shuffle, repeat, lyrics, autoplay
- Dynamic Discord presence rotation with live guild/member/session stats and command CTA
- Presence rotation is configurable through `BOT_PRESENCE_ACTIVITIES`, while now-playing still overrides rotation
- Autoplay recommendations use anti-repeat filtering with queue buffering so shuffle stays useful during autoplay
- Autoplay command recovers active guild queue from player cache fallback to avoid false queue-missing errors during active playback
- Autoplay toggles now respond immediately while queue replenishment runs in the background, reducing interaction timeout risk
Expand Down
27 changes: 14 additions & 13 deletions docs/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,22 @@ This document reflects what is currently shipped and running in production.

## Known Gaps / Future Work

| Area | Description | Complexity |
| ------------------ | ---------------------------------------------------- | ---------- |
| Autoplay diversity | Additional tuning of diversity heuristics | S |
| Presence/activity | Activity template customization | S |
| Area | Description | Complexity |
| ------------------ | ----------------------------------------------- | ---------- |
| Autoplay diversity | Additional tuning of diversity heuristics | S |
| Presence/activity | Dashboard/editor surface for activity templates | S |

---

## Env Vars Reference

| Var | Default | Purpose |
| ---------------------------------- | -------- | --------------------------------------------------------- |
| `SMART_SHUFFLE_STREAK_LIMIT` | `2` | Max consecutive tracks from one requester in smartshuffle |
| `AUTOPLAY_FEEDBACK_TTL_DAYS` | `30` | Feedback Redis entry TTL in days |
| `MUSIC_PROVIDER_FAILURE_THRESHOLD` | `2` | Failures before provider enters cooldown |
| `MUSIC_PROVIDER_COOLDOWN_MS` | `240000` | Provider cooldown duration in ms |
| `MUSIC_WATCHDOG_SCAN_INTERVAL_MS` | `60000` | Periodic orphan session scan interval |
| `QUEUE_RESCUE_PROBE_TIMEOUT_MS` | `5000` | Probe timeout for /queue rescue |
| `BOT_PRESENCE_STATUS` | `online` | Bot presence status for rotation and now-playing updates |
| Var | Default | Purpose |
| ---------------------------------- | --------- | ------------------------------------------------------------- |
| `SMART_SHUFFLE_STREAK_LIMIT` | `2` | Max consecutive tracks from one requester in smartshuffle |
| `AUTOPLAY_FEEDBACK_TTL_DAYS` | `30` | Feedback Redis entry TTL in days |
| `MUSIC_PROVIDER_FAILURE_THRESHOLD` | `2` | Failures before provider enters cooldown |
| `MUSIC_PROVIDER_COOLDOWN_MS` | `240000` | Provider cooldown duration in ms |
| `MUSIC_WATCHDOG_SCAN_INTERVAL_MS` | `60000` | Periodic orphan session scan interval |
| `QUEUE_RESCUE_PROBE_TIMEOUT_MS` | `5000` | Probe timeout for /queue rescue |
| `BOT_PRESENCE_STATUS` | `online` | Bot presence status for rotation and now-playing updates |
| `BOT_PRESENCE_ACTIVITIES` | `_unset_` | Optional env-driven templates for non-music presence rotation |
47 changes: 47 additions & 0 deletions packages/bot/src/handlers/clientHandler/presence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,27 @@ import { ActivityType } from 'discord.js'
import type { CustomClient } from '../../types'

const mockGetBotPresenceStatus = jest.fn().mockReturnValue('online')
const mockGetBotPresenceActivities = jest.fn()

const mockDefaultPresenceActivityTemplates = [
{ type: ActivityType.Listening, template: '/play • High-fidelity music' },
{ type: ActivityType.Watching, template: '{guildCount} servers managed' },
{
type: ActivityType.Watching,
template: '{memberCount} members protected',
},
{
type: ActivityType.Competing,
template: '{activeMusicSessions} active music sessions',
fallback: 'Fast and safe moderation',
},
{ type: ActivityType.Playing, template: '/help • {commandCount} commands' },
]

jest.mock('../../utils/presenceStatus', () => ({
getBotPresenceStatus: mockGetBotPresenceStatus,
getBotPresenceActivities: mockGetBotPresenceActivities,
DEFAULT_PRESENCE_ACTIVITY_TEMPLATES: mockDefaultPresenceActivityTemplates,
}))

import {
Expand Down Expand Up @@ -48,6 +66,10 @@ function createMockClient(overrides?: Partial<CustomClient>): CustomClient {
describe('presence', () => {
beforeEach(() => {
jest.clearAllMocks()
mockGetBotPresenceActivities.mockReset()
mockGetBotPresenceActivities.mockReturnValue(
mockDefaultPresenceActivityTemplates,
)
})

describe('PRESENCE_ROTATION_INTERVAL_MS', () => {
Expand Down Expand Up @@ -179,6 +201,31 @@ describe('presence', () => {
expect(activities).toHaveLength(5)
})

it('should use configured templates instead of defaults', () => {
mockGetBotPresenceActivities.mockReturnValue([
{
type: ActivityType.Watching,
template: '{guildCount} guilds online',
},
{
type: ActivityType.Playing,
template: '/help • custom',
},
])

const activities = buildPresenceActivities({
guildCount: 42,
memberCount: 500,
commandCount: 50,
activeMusicSessions: 0,
})

expect(activities).toEqual([
{ type: ActivityType.Watching, name: '42 guilds online' },
{ type: ActivityType.Playing, name: '/help • custom' },
])
})

it('should include guild count in activities', () => {
const activities = buildPresenceActivities({
guildCount: 42,
Expand Down
66 changes: 52 additions & 14 deletions packages/bot/src/handlers/clientHandler/presence.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { ActivityType } from 'discord.js'
import type { CustomClient } from '../../types'
import { getBotPresenceStatus } from '../../utils/presenceStatus'
import {
getBotPresenceActivities,
getBotPresenceStatus,
} from '../../utils/presenceStatus'

type PresenceActivity = {
type: ActivityType
name: string
}

type PresenceRenderStats = {
guildCount: number
memberCount: number
commandCount: number
activeMusicSessions: number
}

const MAX_ACTIVITY_NAME_LENGTH = 128

export const PRESENCE_ROTATION_INTERVAL_MS = 45_000

export const nextPresenceIndex = (
Expand Down Expand Up @@ -46,6 +58,24 @@ export const getActiveMusicSessions = (client: CustomClient): number => {
return count
}

const renderPresenceActivityName = (
template: string,
stats: PresenceRenderStats,
): string =>
template
.replaceAll('{guildCount}', String(stats.guildCount))
.replaceAll('{memberCount}', String(stats.memberCount))
.replaceAll('{commandCount}', String(stats.commandCount))
.replaceAll('{activeMusicSessions}', String(stats.activeMusicSessions))

const truncatePresenceActivityName = (text: string): string => {
if (text.length <= MAX_ACTIVITY_NAME_LENGTH) {
return text
}

return text.slice(0, MAX_ACTIVITY_NAME_LENGTH - 1) + '…'
}

export const buildPresenceActivities = ({
guildCount,
memberCount,
Expand All @@ -56,19 +86,27 @@ export const buildPresenceActivities = ({
memberCount: number
commandCount: number
activeMusicSessions: number
}): PresenceActivity[] => [
{ type: ActivityType.Listening, name: '/play • High-fidelity music' },
{ type: ActivityType.Watching, name: `${guildCount} servers managed` },
{ type: ActivityType.Watching, name: `${memberCount} members protected` },
{
type: ActivityType.Competing,
name:
activeMusicSessions > 0
? `${activeMusicSessions} active music sessions`
: 'Fast and safe moderation',
},
{ type: ActivityType.Playing, name: `/help • ${commandCount} commands` },
]
}): PresenceActivity[] =>
getBotPresenceActivities().map((activity) => ({
type: activity.type,
name: truncatePresenceActivityName(
activity.template.includes('{activeMusicSessions}') &&
activity.fallback &&
activeMusicSessions === 0
? renderPresenceActivityName(activity.fallback, {
guildCount,
memberCount,
commandCount,
activeMusicSessions,
})
: renderPresenceActivityName(activity.template, {
guildCount,
memberCount,
commandCount,
activeMusicSessions,
}),
),
}))

export const setPresenceActivity = (
client: CustomClient,
Expand Down
59 changes: 51 additions & 8 deletions packages/bot/src/services/MusicPresenceService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,27 @@ const makeClient = () => ({
})

const originalPresenceStatus = process.env.BOT_PRESENCE_STATUS
const originalPresenceActivities = process.env.BOT_PRESENCE_ACTIVITIES

beforeEach(() => {
debugLogMock.mockReset()
jest.resetModules()
delete process.env.BOT_PRESENCE_STATUS
delete process.env.BOT_PRESENCE_ACTIVITIES
})

afterAll(() => {
if (originalPresenceStatus) {
process.env.BOT_PRESENCE_STATUS = originalPresenceStatus
return
} else {
delete process.env.BOT_PRESENCE_STATUS
}

delete process.env.BOT_PRESENCE_STATUS
if (originalPresenceActivities) {
process.env.BOT_PRESENCE_ACTIVITIES = originalPresenceActivities
} else {
delete process.env.BOT_PRESENCE_ACTIVITIES
}
})

describe('MusicPresenceService', () => {
Expand All @@ -42,7 +49,10 @@ describe('MusicPresenceService', () => {
const resume = jest.fn()
initMusicPresence(client as never, pause, resume)

setNowPlaying('guild-1', { title: 'Song', author: 'Artist' } as never)
setNowPlaying('guild-1', {
title: 'Song',
author: 'Artist',
} as never)

expect(pause).toHaveBeenCalledTimes(1)
expect(client.user.setPresence).toHaveBeenCalledWith({
Expand All @@ -61,7 +71,10 @@ describe('MusicPresenceService', () => {
const resume = jest.fn()
initMusicPresence(client as never, pause, resume)

setNowPlaying('guild-1', { title: 'Song', author: 'Artist' } as never)
setNowPlaying('guild-1', {
title: 'Song',
author: 'Artist',
} as never)

expect(client.user.setPresence).toHaveBeenCalledWith({
status: 'idle',
Expand All @@ -79,7 +92,32 @@ describe('MusicPresenceService', () => {
const resume = jest.fn()
initMusicPresence(client as never, pause, resume)

setNowPlaying('guild-1', { title: 'Song', author: 'Artist' } as never)
setNowPlaying('guild-1', {
title: 'Song',
author: 'Artist',
} as never)

expect(client.user.setPresence).toHaveBeenCalledWith({
status: 'online',
activities: [
{ type: ActivityType.Listening, name: 'Song — Artist' },
],
})
})

it('ignores rotation template config when setting now playing', () => {
process.env.BOT_PRESENCE_ACTIVITIES =
'WATCHING:{guildCount} guilds online|PLAYING:/help • custom'

const client = makeClient()
const pause = jest.fn()
const resume = jest.fn()
initMusicPresence(client as never, pause, resume)

setNowPlaying('guild-1', {
title: 'Song',
author: 'Artist',
} as never)

expect(client.user.setPresence).toHaveBeenCalledWith({
status: 'online',
Expand All @@ -97,9 +135,14 @@ describe('MusicPresenceService', () => {

const longTitle = 'T'.repeat(100)
const longAuthor = 'A'.repeat(100)
setNowPlaying('guild-1', { title: longTitle, author: longAuthor } as never)

const call = client.user.setPresence.mock.calls[0] as Array<{ activities: Array<{ name: string }> }>
setNowPlaying('guild-1', {
title: longTitle,
author: longAuthor,
} as never)

const call = client.user.setPresence.mock.calls[0] as Array<{
activities: Array<{ name: string }>
}>
const name: string = call[0].activities[0].name
expect(name.length).toBeLessThanOrEqual(128)
expect(name.endsWith('…')).toBe(true)
Expand Down
Loading