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()} -

-
- -
- ))} +
+
+ {device.includes("Mobile") || device.includes("iOS") || device.includes("Android") ? ( + + ) : ( + + )} +
+
+
+

+ {browser} on {device} +

+ {isCurrent && ( + + Current + + )} +
+
+
+ + + {session.location || "Unknown Location"} • {session.ipAddress || "No IP logged"} + +
+
+ + + Accessed: {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