diff --git a/assets/images/logo_64x64.png b/assets/images/logo_64x64.png
new file mode 100644
index 0000000..a7ac2ee
Binary files /dev/null and b/assets/images/logo_64x64.png differ
diff --git a/bot/src/configs/schema.py b/bot/src/configs/schema.py
index eb0f528..a7501e1 100644
--- a/bot/src/configs/schema.py
+++ b/bot/src/configs/schema.py
@@ -209,6 +209,12 @@ class QueueSyncConfig(BaseModel):
api_key: Optional[str] = None
+class BotListConfig(BaseModel):
+ """Configuration for external bot list synchronisation."""
+
+ discord_bot_list_token: Optional[str] = None
+
+
class CacheConfig(BaseModel):
"""Caching behaviour for expensive operations."""
@@ -259,4 +265,5 @@ class AppConfig(BaseModel):
search_limits: SearchLimitsConfig = SearchLimitsConfig()
control_panel_api: ControlPanelAPIConfig = ControlPanelAPIConfig()
queue_sync: QueueSyncConfig = QueueSyncConfig()
+ bot_list: BotListConfig = BotListConfig()
security: SensitiveCommandConfig = SensitiveCommandConfig()
diff --git a/bot/src/configs/settings.py b/bot/src/configs/settings.py
index 0f198b1..5f29a7a 100644
--- a/bot/src/configs/settings.py
+++ b/bot/src/configs/settings.py
@@ -490,6 +490,10 @@ def _load_yaml(path: str) -> Dict:
api_key=queue_sync_key or CONFIG.queue_sync.api_key,
)
+bot_list_token = os.getenv("DISCORD_BOT_LIST_TOKEN")
+if bot_list_token:
+ CONFIG.bot_list.discord_bot_list_token = bot_list_token
+
def _ids_from_env(var_name: str) -> List[int]:
raw = os.getenv(var_name)
diff --git a/bot/src/main.py b/bot/src/main.py
index 396ee40..49ba7ab 100644
--- a/bot/src/main.py
+++ b/bot/src/main.py
@@ -52,6 +52,7 @@
from src.services.federation_service import FederationService
from src.services.predictive_health_service import PredictiveHealthService
from src.services.plugin_service import PluginService
+from src.services.bot_list_service import BotListService
from src.utils.logger import setup_logging
from src.utils.embeds import set_branding_resolver
from src.utils.plan_capabilities import load_plan_capabilities_async
@@ -124,6 +125,7 @@ def __init__(self, override_shard_count: Optional[int] = None):
self.latency_monitor = LatencyMonitor(bot_cast, sample_interval=2.0, max_samples=60)
self.status_api = StatusAPIService(bot_cast, CONFIG.status_api)
self.queue_sync = QueueSyncService(CONFIG.queue_sync, self.server_settings)
+ self.bot_list = BotListService(self, CONFIG.bot_list)
self._entrypoint_payloads: List[dict] = []
self._panel_parity_task: Optional[asyncio.Task] = None
self._prefix_cache: dict[int, tuple[str, float]] = {}
@@ -228,6 +230,8 @@ async def close(self):
await self.federation_service.close()
if hasattr(self, "predictive_health"):
await self.predictive_health.close()
+ if hasattr(self, "bot_list"):
+ await self.bot_list.close()
if hasattr(self, "status_api"):
await self.status_api.close()
@@ -286,6 +290,7 @@ async def setup_hook(self):
await self.queue_telemetry.start()
await self.queue_sync.start()
await self.status_api.start()
+ await self.bot_list.start()
await self.dj_permissions.start()
# search_cache is synchronous; no start required
await self.latency_monitor.start()
@@ -320,6 +325,7 @@ async def _sync_application_commands(self) -> None:
try:
await self.tree.sync()
self.logger.info("Slash commands synced.")
+ await self.bot_list.sync_commands()
return
except discord.HTTPException as exc:
if exc.status == 400 and exc.code == 50240:
@@ -378,6 +384,7 @@ async def _sync_preserving_entry_points(self, *, use_cached: bool = False) -> No
"Slash commands synced (%s preserved entry commands).",
len(preserved_payloads),
)
+ await self.bot_list.sync_commands()
async def _validate_panel_parity_on_startup(self) -> None:
"""Fetch control-panel settings for every guild and ensure we enforce them."""
diff --git a/bot/src/services/bot_list_service.py b/bot/src/services/bot_list_service.py
new file mode 100644
index 0000000..fe05950
--- /dev/null
+++ b/bot/src/services/bot_list_service.py
@@ -0,0 +1,83 @@
+"""Integration with external bot list platforms."""
+
+import logging
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from src.configs.schema import BotListConfig
+
+if TYPE_CHECKING:
+ from src.main import VectoBeat
+
+
+class BotListService:
+ """Handles synchronisation with external bot list APIs."""
+
+ BASE_URL = "https://discordbotlist.com/api/v1"
+
+ def __init__(self, bot: "VectoBeat", config: BotListConfig) -> None:
+ self.bot = bot
+ self.config = config
+ self.logger = logging.getLogger("VectoBeat.BotList")
+ self._session: Optional[aiohttp.ClientSession] = None
+
+ async def start(self) -> None:
+ """Initialise the session. Intentionally minimal."""
+ if not self.config.discord_bot_list_token:
+ self.logger.debug("DiscordBotList token missing; sync disabled.")
+ return
+ self._session = aiohttp.ClientSession(
+ headers={"Authorization": self.config.discord_bot_list_token}
+ )
+
+ async def close(self) -> None:
+ """Clean up the aiohttp session."""
+ if self._session:
+ await self._session.close()
+ self._session = None
+
+ async def sync_commands(self) -> None:
+ """Fetch application commands and push them to DiscordBotList."""
+ if not self._session or not self.config.discord_bot_list_token:
+ return
+
+ if not self.bot.application_id:
+ self.logger.warning("Cannot sync commands to DBL: application_id is missing.")
+ return
+
+ self.logger.info("Synchronising commands with DiscordBotList...")
+
+ try:
+ # We use the translated payloads if the bot has a translator, otherwise standard dicts.
+ # This aligns with how the bot performs bulk_upsert to Discord.
+ commands_list = self.bot.tree._get_all_commands()
+ translator = self.bot.tree.translator
+
+ payload: List[Dict[str, Any]] = []
+ for cmd in commands_list:
+ if translator:
+ cmd_data = await cmd.get_translated_payload(self.bot.tree, translator)
+ else:
+ cmd_data = cmd.to_dict(self.bot.tree)
+
+ # Strip unnecessary fields often rejected or ignored by bot lists
+ cmd_data.pop("integration_types", None)
+ cmd_data.pop("contexts", None)
+ payload.append(cmd_data)
+
+ url = f"{self.BASE_URL}/bots/{self.bot.application_id}/commands"
+ async with self._session.post(url, json=payload) as resp:
+ if resp.status == 204 or resp.status == 200:
+ self.logger.info("Successfully synced %d commands to DiscordBotList.", len(payload))
+ else:
+ text = await resp.text()
+ self.logger.error(
+ "Failed to sync commands to DiscordBotList (Status %d): %s",
+ resp.status,
+ text,
+ )
+ except Exception as exc:
+ self.logger.error("Unexpected error during DiscordBotList command sync: %s", exc)
diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx
index 6f26025..a266cdd 100644
--- a/frontend/app/account/page.tsx
+++ b/frontend/app/account/page.tsx
@@ -14,6 +14,11 @@ import {
LogOut,
Link2 as LinkIcon,
SlidersHorizontal,
+ Monitor,
+ Smartphone,
+ Globe,
+ MapPin,
+ Clock,
} from "lucide-react"
import {
SiDiscord,
@@ -2019,31 +2024,96 @@ const profileShareUrl = profileShareSlug
Loading sessions...
) : sessions.length ? (
- {sessions.map((session) => (
-
-
-
- {session.location || "Unknown location"}
- {session.id === currentSessionId && (
- This device
- )}
-
-
- {session.ipAddress || "No IP"} - Last active{" "}
- {new Date(session.lastActive).toLocaleString()}
-
-
-
+ )
+ })}
) : (
No other devices are currently signed in.
diff --git a/frontend/app/api/admin/connectivity/route.ts b/frontend/app/api/admin/connectivity/route.ts
index 37a5e1b..bccf7ee 100644
--- a/frontend/app/api/admin/connectivity/route.ts
+++ b/frontend/app/api/admin/connectivity/route.ts
@@ -118,24 +118,16 @@ export async function GET(request: NextRequest) {
const services: Service[] = await Promise.all(
SERVICE_KEYS.map(async (service) => {
const fromBot = BOT_ENV_KEYS.has(service.envKey)
- let envVal = null
- let origin = "missing"
+ let envVal = process.env[service.envKey] || null
+ let origin = "process.env"
- if (fromBot) {
- if (botEnv[service.envKey]) {
+ if (!envVal) {
+ if (fromBot && botEnv[service.envKey]) {
envVal = botEnv[service.envKey]
origin = "bot .env"
- } else if (process.env[service.envKey]) {
- envVal = process.env[service.envKey]
- origin = "process.env"
- }
- } else {
- if (frontendEnv[service.envKey]) {
+ } else if (frontendEnv[service.envKey]) {
envVal = frontendEnv[service.envKey]
origin = "frontend .env"
- } else if (process.env[service.envKey]) {
- envVal = process.env[service.envKey]
- origin = "process.env"
}
}
diff --git a/frontend/app/api/admin/subscriptions/route.ts b/frontend/app/api/admin/subscriptions/route.ts
index 2b747bd..29c6c26 100644
--- a/frontend/app/api/admin/subscriptions/route.ts
+++ b/frontend/app/api/admin/subscriptions/route.ts
@@ -87,10 +87,24 @@ export async function POST(request: NextRequest) {
let stripeSubscription: Stripe.Subscription
try {
+ let stripeProductId = updates.stripeProductId
+ if (!stripeProductId) {
+ const product = await stripe.products.create({
+ name: `${normalizedTier.toUpperCase()} — ${updates.name || updates.guildName || guildId}`,
+ metadata: {
+ tier: normalizedTier,
+ guildId,
+ source: "admin_portal_manual",
+ },
+ })
+ stripeProductId = product.id
+ }
+
stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
description: `Admin-created ${normalizedTier} subscription for guild ${guildId}`,
payment_behavior: "default_incomplete",
+ payment_settings: { save_default_payment_method: "on_subscription" },
collection_method: "charge_automatically",
metadata: {
discordId: targetDiscordId,
@@ -106,10 +120,8 @@ export async function POST(request: NextRequest) {
currency: defaultCurrency,
unit_amount: Math.round(monthlyPrice * 100),
recurring: { interval: "month" },
- product_data: {
- name: `${normalizedTier.toUpperCase()} — ${updates.name || updates.guildName || guildId}`,
- },
- } as any,
+ product: stripeProductId,
+ },
},
],
})
diff --git a/frontend/app/api/admin/system-credentials/route.ts b/frontend/app/api/admin/system-tokens/route.ts
similarity index 100%
rename from frontend/app/api/admin/system-credentials/route.ts
rename to frontend/app/api/admin/system-tokens/route.ts
diff --git a/frontend/app/api/bot/recommendations/route.ts b/frontend/app/api/bot/recommendations/route.ts
new file mode 100644
index 0000000..c057e93
--- /dev/null
+++ b/frontend/app/api/bot/recommendations/route.ts
@@ -0,0 +1,42 @@
+import { type NextRequest, NextResponse } from "next/server"
+import { authorizeRequest } from "@/lib/api-auth"
+import { getApiKeySecrets } from "@/lib/api-keys"
+import { getRecommendedTracks } from "@/lib/auto-queue"
+
+const SECRET_TYPES = ["status_api", "queue_sync"]
+
+export async function GET(request: NextRequest) {
+ const secrets = await getApiKeySecrets(SECRET_TYPES, { includeEnv: true })
+
+ if (!authorizeRequest(request, secrets, { allowLocalhost: true })) {
+ return NextResponse.json({ error: "unauthorized" }, { status: 401 })
+ }
+
+ const { searchParams } = new URL(request.url)
+ const seedTrackId = searchParams.get("seedTrackId")
+ const limitStr = searchParams.get("limit")
+ const limit = limitStr ? parseInt(limitStr, 10) : 5
+
+ if (!seedTrackId) {
+ return NextResponse.json({ error: "seedTrackId_required" }, { status: 400 })
+ }
+
+ try {
+ const tracks = await getRecommendedTracks(seedTrackId, limit)
+ return NextResponse.json({
+ success: true,
+ tracks: tracks.map(t => ({
+ id: t.id,
+ title: t.title,
+ artist: t.artist,
+ genre: t.genre,
+ source: t.source,
+ sourceId: t.sourceId,
+ metadata: t.metadata
+ }))
+ })
+ } catch (error) {
+ console.error("[BotAPI] Failed to fetch recommendations:", error)
+ return NextResponse.json({ error: "internal_error" }, { status: 500 })
+ }
+}
diff --git a/frontend/app/api/verify-session/route.ts b/frontend/app/api/verify-session/route.ts
index 85b9373..e79e53d 100644
--- a/frontend/app/api/verify-session/route.ts
+++ b/frontend/app/api/verify-session/route.ts
@@ -6,6 +6,7 @@ import { hashSessionToken } from "@/lib/session"
import { getUserSubscriptions, getUserRole, getUserSecurity, getSessionByHash, type SubscriptionSummary } from "@/lib/db"
import { normalizeTierId } from "@/lib/memberships"
import { apiClient } from "@/lib/api-client"
+import { getBotGuildPresence } from "@/lib/bot-status"
const resolveDiscordId = async (request: NextRequest) => {
const cookieStore = await cookies()
@@ -68,7 +69,7 @@ export async function GET(request: NextRequest) {
}
// --------------------------------------------------------------------------- helpers
-const mapDiscordGuilds = (raw: any[]): Array<{ id: string; name: string; hasBot: boolean; isAdmin: boolean }> => {
+const mapDiscordGuilds = (raw: any[], botGuilds: Set): Array<{ id: string; name: string; hasBot: boolean; isAdmin: boolean }> => {
return raw
.map((g) => {
if (!g || typeof g.id !== "string" || typeof g.name !== "string") return null
@@ -77,7 +78,7 @@ const mapDiscordGuilds = (raw: any[]): Array<{ id: string; name: string; hasBot:
return {
id: g.id,
name: g.name,
- hasBot: false,
+ hasBot: botGuilds.has(g.id),
isAdmin,
}
})
@@ -91,25 +92,30 @@ const resolveGuilds = async (verification: any): Promise => {
? (verification.user.guilds as ResolvedGuild[])
: []
const token = verification.token
- const existingById = new Map(existing.map((g: any) => [g.id, g]))
- if (!token) return existing
+
+ // Get real-time bot presence
+ const botGuilds = await getBotGuildPresence()
+
+ if (!token) {
+ // If no token, we can still update hasBot on existing guilds based on real-time presence
+ return existing.map(g => ({
+ ...g,
+ hasBot: botGuilds.has(g.id)
+ }))
+ }
+
try {
const data = await apiClient("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
})
if (!Array.isArray(data)) return existing
- const fresh = mapDiscordGuilds(data).map((guild) => {
- const prior = existingById.get(guild.id)
- return {
- ...guild,
- // We do not preserve prior.hasBot because it might be stale or incorrect (e.g. defaulted to true).
- // The frontend will determine actual bot presence via the bot status API and subscription data.
- hasBot: false,
- }
- })
+ const fresh = mapDiscordGuilds(data, botGuilds)
return fresh
} catch {
- return existing
+ return existing.map(g => ({
+ ...g,
+ hasBot: botGuilds.has(g.id)
+ }))
}
}
diff --git a/frontend/app/control-panel/admin/page.tsx b/frontend/app/control-panel/admin/page.tsx
index 9ad0f1e..70c02ed 100644
--- a/frontend/app/control-panel/admin/page.tsx
+++ b/frontend/app/control-panel/admin/page.tsx
@@ -823,7 +823,7 @@ export default function AdminControlPanelPage() {
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
- const payload = await apiClient(`/api/admin/system-credentials?discordId=${discordId}`, {
+ const payload = await apiClient(`/api/admin/system-tokens?discordId=${discordId}`, {
headers,
credentials: "include",
})
@@ -2562,16 +2562,20 @@ export default function AdminControlPanelPage() {
{ label: "Queue sync secret", key: "QUEUE_SYNC_API_KEY", searchKey: "queue_sync" },
{ label: "Log ingest token", key: "LOG_INGEST_TOKEN", searchKey: "telemetry" },
].map((item) => {
- // Check systemKeys state first if it applies to an API token
+ // 1. Check systemKeys state first if it applies to an API token
const systemKeyRecord = item.searchKey ? systemKeys.find((k) => k.id === item.searchKey) : null
- if (systemKeyRecord) {
- return {
- ...item,
- configured: systemKeyRecord.configured,
- }
+ if (systemKeyRecord && systemKeyRecord.configured) {
+ return { ...item, configured: true }
+ }
+
+ // 2. Check connectivity data (which comes from backend)
+ const connRecord = connectivity.find(c => c.key === item.key)
+ if (connRecord && connRecord.status !== "missing") {
+ return { ...item, configured: true }
}
- // Otherwise fall back to local environment and proxy alt overrides
- const value = envMap[item.key] || process.env[item.key]
+
+ // 3. Fallback to local environment entries (loaded from /api/admin/env)
+ const value = envMap[item.key] || (typeof process !== "undefined" ? process.env[item.key] : null)
return {
...item,
configured: Boolean(value),
diff --git a/frontend/components/home-metrics.tsx b/frontend/components/home-metrics.tsx
index 6ff17fd..abaa104 100644
--- a/frontend/components/home-metrics.tsx
+++ b/frontend/components/home-metrics.tsx
@@ -175,7 +175,7 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric
try {
console.log("[VectoBeat] Initializing metrics socket...")
await apiClient("/api/socket")
- socket = io({ path: "/api/socket" })
+ socket = io({ path: "/api/socket", transports: ["websocket"] })
socket.on("connect", () => {
console.log("[VectoBeat] Metrics socket connected")
@@ -229,7 +229,6 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric
(statsCopy ?? DEFAULT_STATS_COPY).siteViews.label,
(statsCopy ?? DEFAULT_STATS_COPY).activeServers.label,
(statsCopy ?? DEFAULT_STATS_COPY).activeUsers.label,
- (statsCopy ?? DEFAULT_STATS_COPY).activeChannels.label,
(statsCopy ?? DEFAULT_STATS_COPY).uptime.label,
]
const stats = targetLabels
diff --git a/frontend/components/stats-control-panel.tsx b/frontend/components/stats-control-panel.tsx
index ba0644f..a260e2e 100644
--- a/frontend/components/stats-control-panel.tsx
+++ b/frontend/components/stats-control-panel.tsx
@@ -85,7 +85,7 @@ export function StatsControlPanel({ initialData }: StatsControlPanelProps) {
try {
console.log("[VectoBeat] Initializing analytics socket...")
await apiClient("/api/socket")
- socket = io({ path: "/api/socket" })
+ socket = io({ path: "/api/socket", transports: ["websocket"] })
socket.on("connect", () => {
console.log("[VectoBeat] Analytics socket connected")
diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts
index ff19f31..f23aebd 100644
--- a/frontend/lib/auth.ts
+++ b/frontend/lib/auth.ts
@@ -1,6 +1,7 @@
import { NextRequest } from "next/server"
import { validateSessionHash, getStoredUserProfile, verifyUserApiKey, type StoredUserProfile } from "./db"
import { hashSessionToken } from "./session"
+import { resolveClientIp, resolveClientLocation } from "./request-metadata"
export const authBypassEnabled = () => {
return (
@@ -51,7 +52,13 @@ export const verifyRequestForUser = async (
}
const sessionHash = hashSessionToken(token)
- const isValid = await validate(discordId, sessionHash)
+
+ // Resolve session metadata
+ const ipAddress = resolveClientIp(request)
+ const userAgent = request.headers.get("user-agent")
+ const location = resolveClientLocation(request)
+
+ const isValid = await validate(discordId, sessionHash, { ipAddress, userAgent, location })
if (!isValid) {
return { valid: false, token: null, sessionHash: null, user: null }
}
diff --git a/frontend/lib/auto-queue.ts b/frontend/lib/auto-queue.ts
index 8f4f49d..598a99a 100644
--- a/frontend/lib/auto-queue.ts
+++ b/frontend/lib/auto-queue.ts
@@ -118,7 +118,7 @@ export async function getRecommendedTracks(trackId: string, limit = 5) {
if (!prisma) return []
try {
- // 1. Try to find tracks that often follow this one
+ // 1. Try to find tracks that often follow this one (Learned Relationships)
const recommendations = await prisma.musicRecommendation.findMany({
where: { fromTrackId: trackId },
orderBy: { weight: "desc" },
@@ -128,35 +128,60 @@ export async function getRecommendedTracks(trackId: string, limit = 5) {
},
})
- if (recommendations.length > 0) {
- return recommendations.map((r) => r.toTrack)
- }
+ let results = recommendations.map((r) => r.toTrack)
// 2. Fallback: Find tracks of the same genre
- const seedTrack = await prisma.musicTrack.findUnique({ where: { id: trackId } })
- if (seedTrack?.genre) {
- return prisma.musicTrack.findMany({
- where: {
- genre: seedTrack.genre,
- id: { not: trackId },
- },
- orderBy: { createdAt: "desc" },
- take: limit,
- })
+ if (results.length < limit) {
+ const seedTrack = await prisma.musicTrack.findUnique({ where: { id: trackId } })
+ if (seedTrack?.genre) {
+ const genreTracks = await prisma.musicTrack.findMany({
+ where: {
+ genre: seedTrack.genre,
+ id: { notIn: [trackId, ...results.map(r => r.id)] },
+ },
+ orderBy: { createdAt: "desc" }, // Favor newer additions for variety
+ take: limit - results.length,
+ })
+ results = [...results, ...genreTracks]
+ }
}
// 3. Deep fallback: Same artist
- if (seedTrack?.artist) {
- return prisma.musicTrack.findMany({
+ if (results.length < limit) {
+ const seedTrack = results.length === 0 ? await prisma.musicTrack.findUnique({ where: { id: trackId } }) : null
+ const artist = results.length > 0 ? results[0].artist : seedTrack?.artist
+
+ if (artist) {
+ const artistTracks = await prisma.musicTrack.findMany({
+ where: {
+ artist: artist,
+ id: { notIn: [trackId, ...results.map(r => r.id)] }
+ },
+ take: limit - results.length
+ })
+ results = [...results, ...artistTracks]
+ }
+ }
+
+ // 4. Final fallback: Global Popularity (Most played tracks)
+ if (results.length < limit) {
+ // We can approximate popularity by counting recommendations "to" these tracks
+ // or just pick random tracks from the library if no playback counts exist yet.
+ const popularTracks = await prisma.musicTrack.findMany({
where: {
- artist: seedTrack.artist,
- id: { not: trackId }
+ id: { notIn: [trackId, ...results.map(r => r.id)] }
},
- take: limit
+ orderBy: {
+ recommendationsTo: {
+ _count: "desc"
+ }
+ },
+ take: limit - results.length
})
+ results = [...results, ...popularTracks]
}
- return []
+ return results
} catch (error) {
console.error("[AutoQueue] Failed to get recommendations:", error)
handlePrismaError(error)
diff --git a/frontend/lib/bot-status.ts b/frontend/lib/bot-status.ts
index 432591d..73f2292 100644
--- a/frontend/lib/bot-status.ts
+++ b/frontend/lib/bot-status.ts
@@ -153,7 +153,7 @@ export const getBotStatus = async () => {
return cachedBotStatus.data
}
- const authTokens = await getApiKeySecrets(AUTH_TOKEN_TYPES, { includeEnv: false })
+ const authTokens = await getApiKeySecrets(AUTH_TOKEN_TYPES, { includeEnv: true })
const candidates = buildStatusCandidates()
if (candidates.length === 0) {
cachedBotStatus.expires = now + 10 * 1000
@@ -244,7 +244,7 @@ const buildControlCandidates = () => {
const postToBotControl = async (path: string, body: Record): Promise => {
const nowMs = Date.now()
- const tokens = await getApiKeySecrets(AUTH_TOKEN_TYPES, { includeEnv: false })
+ const tokens = await getApiKeySecrets(AUTH_TOKEN_TYPES, { includeEnv: true })
const candidates = buildControlCandidates()
let lastError: unknown = null
for (const endpoint of candidates) {
diff --git a/frontend/lib/db.ts b/frontend/lib/db.ts
index 4276975..4033ae7 100644
--- a/frontend/lib/db.ts
+++ b/frontend/lib/db.ts
@@ -1483,20 +1483,59 @@ export const revokeSessionByHash = async (discordId: string, sessionHash: string
}
}
-export const validateSessionHash = async (discordId: string, sessionHash: string) => {
+export const updateSessionActivity = async (discordId: string, sessionHash: string, details: { ipAddress?: string | null; userAgent?: string | null; location?: string | null }) => {
+ try {
+ const db = getPool()
+ if (!db) return
+
+ await db.userSession.updateMany({
+ where: {
+ discordId,
+ sessionHash,
+ revokedAt: null,
+ },
+ data: {
+ ipAddress: details.ipAddress || undefined,
+ userAgent: details.userAgent || undefined,
+ location: details.location || undefined,
+ lastActive: new Date(),
+ },
+ })
+ } catch (error) {
+ logDbError("[VectoBeat] Failed to update session activity:", error)
+ }
+}
+
+export const validateSessionHash = async (discordId: string, sessionHash: string, metadata?: { ipAddress?: string | null; userAgent?: string | null; location?: string | null }) => {
try {
const db = getPool()
if (!db) return true
- const count = await db.userSession.count({
+ const session = await db.userSession.findFirst({
where: {
discordId,
sessionHash,
revokedAt: null,
},
+ select: { id: true, lastActive: true, ipAddress: true, userAgent: true },
})
- return count > 0
+ if (!session) return false
+
+ // Update metadata if it has changed or if it's been more than 5 minutes since last update to avoid excessive writes
+ const now = Date.now()
+ const fiveMinutesAgo = now - 5 * 60 * 1000
+ const shouldUpdate =
+ !session.lastActive ||
+ session.lastActive.getTime() < fiveMinutesAgo ||
+ (metadata?.ipAddress && metadata.ipAddress !== session.ipAddress) ||
+ (metadata?.userAgent && metadata.userAgent !== session.userAgent)
+
+ if (shouldUpdate && metadata) {
+ void updateSessionActivity(discordId, sessionHash, metadata)
+ }
+
+ return true
} catch (error) {
logDbError("[VectoBeat] Failed to validate session hash:", error)
return false
diff --git a/frontend/lib/metrics.ts b/frontend/lib/metrics.ts
index 10a6077..f2da878 100644
--- a/frontend/lib/metrics.ts
+++ b/frontend/lib/metrics.ts
@@ -213,13 +213,18 @@ const buildBaseMetrics = async (): Promise => {
botStatus as {
listenerDetail?: Array<{ guildId: string; channelId: string; listeners: number; guildName?: string; channelName?: string }>
}
- ).listenerDetail!.map((entry) => ({
- guildId: typeof entry.guildId === "string" ? entry.guildId : "",
- guildName: typeof entry.guildName === "string" ? entry.guildName : undefined,
- channelId: typeof entry.channelId === "string" ? entry.channelId : "",
- channelName: typeof entry.channelName === "string" ? entry.channelName : undefined,
- listeners: normalizeNumber(entry.listeners),
- }))
+ ).listenerDetail!.map((entry) => {
+ const listeners = normalizeNumber(entry.listeners)
+ // Fallback: If individual channel reports 0 but we have total listeners,
+ // and we only have ONE connection, it's likely that connection has all listeners.
+ return {
+ guildId: typeof entry.guildId === "string" ? entry.guildId : "",
+ guildName: typeof entry.guildName === "string" ? entry.guildName : undefined,
+ channelId: typeof entry.channelId === "string" ? entry.channelId : "",
+ channelName: typeof entry.channelName === "string" ? entry.channelName : undefined,
+ listeners: listeners || 0,
+ }
+ })
: []
const rawCurrentListeners = normalizeNumber(
botStatus?.activePlayers ??
@@ -230,6 +235,19 @@ const buildBaseMetrics = async (): Promise => {
fallbackSnapshot?.activeListeners,
)
const activeUsers = normalizeNumber(rawCurrentListeners)
+
+ // If we have total listeners but individual counts are 0, and we only have one connection,
+ // assign total listeners to that connection.
+ if (activeUsers > 0 && listenerDetail.length === 1 && listenerDetail[0].listeners === 0) {
+ listenerDetail[0].listeners = activeUsers
+ } else if (activeUsers > 0 && listenerDetail.length > 1 && listenerDetail.every(d => d.listeners === 0)) {
+ // If multiple connections but all 0, distribute them roughly (best effort)
+ const perConnection = Math.floor(activeUsers / listenerDetail.length)
+ listenerDetail.forEach((d, i) => {
+ d.listeners = i === 0 ? perConnection + (activeUsers % listenerDetail.length) : perConnection
+ })
+ }
+
const totalStreams =
typeof usageTotals.totalStreams === "number"
? usageTotals.totalStreams
@@ -432,11 +450,6 @@ const buildHomeStats = (base: BaseMetrics, history: BotMetricHistoryEntry[] = []
value: uptimeLabel,
change: totals.responseTimeMs ? `Latency ${avgResponseLabel}` : "No telemetry",
},
- {
- label: "Active Channels",
- value: shortNumber(totals.voiceConnections),
- change: totals.voiceConnections ? "Active voice sessions" : "No telemetry",
- },
]
return {
@@ -582,12 +595,6 @@ const generateAnalytics = (base: BaseMetrics, botHistory: BotMetricHistoryEntry[
change: `${shortNumber(forumStats.posts24h)} posts last 24h`,
detail: `${forumStats.categories} categories · ${forumStats.events24h} events`,
},
- {
- label: "Voice Connections",
- value: shortNumber(totals.voiceConnections),
- change: totals.voiceConnections ? "Live listeners by channel" : "No active voice channels",
- detail: "Active bot sessions",
- },
]
const uptimePercentAnalytics = botHistory.length