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
Binary file added assets/images/logo_64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions bot/src/configs/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions bot/src/configs/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions bot/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = {}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
83 changes: 83 additions & 0 deletions bot/src/services/bot_list_service.py
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 94 additions & 24 deletions frontend/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
LogOut,
Link2 as LinkIcon,
SlidersHorizontal,
Monitor,
Smartphone,
Globe,

Check warning on line 19 in frontend/app/account/page.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

'Globe' is defined but never used. Allowed unused vars must match /^_/u
MapPin,
Clock,
} from "lucide-react"
import {
SiDiscord,
Expand Down Expand Up @@ -2019,31 +2024,96 @@
<p className="text-sm text-foreground/60">Loading sessions...</p>
) : sessions.length ? (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border border-border/40 rounded-lg p-3"
>
<div>
<p className="font-semibold">
{session.location || "Unknown location"}
{session.id === currentSessionId && (
<span className="ml-2 text-xs text-primary font-semibold">This device</span>
)}
</p>
<p className="text-xs text-foreground/60">
{session.ipAddress || "No IP"} - Last active{" "}
{new Date(session.lastActive).toLocaleString()}
</p>
</div>
<button
onClick={() => handleSessionRevoke(session.id)}
className="px-4 py-2 border border-border/50 rounded-lg hover:bg-destructive/10 hover:text-destructive transition-colors text-sm"
{sessions.map((session) => {
const parseUA = (ua: string | null) => {
if (!ua) return { device: "Unknown Device", browser: "Unknown Browser" }
const browser = ua.includes("Firefox")
? "Firefox"
: ua.includes("Chrome")
? "Chrome"
: ua.includes("Safari")
? "Safari"
: ua.includes("Edge")
? "Edge"
: "Browser"
const device = ua.includes("Mobi") ? "Mobile" : "Desktop"
const os = ua.includes("Windows")
? "Windows"
: ua.includes("Mac")
? "macOS"
: ua.includes("Linux")
? "Linux"
: ua.includes("Android")
? "Android"
: ua.includes("iPhone")
? "iOS"
: ""
return { device: os ? `${device} (${os})` : device, browser }
}
const { device, browser } = parseUA(session.userAgent)
const isCurrent = session.id === currentSessionId

return (
<div
key={session.id}
className={`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border rounded-xl p-4 transition-all ${
isCurrent
? "border-primary/50 bg-primary/5 shadow-sm"
: "border-border/40 bg-background/50"
}`}
>
Log Out
</button>
</div>
))}
<div className="flex items-start gap-4">
<div
className={`mt-1 p-2 rounded-lg ${
isCurrent ? "bg-primary/20 text-primary" : "bg-card/50 text-foreground/60"
}`}
>
{device.includes("Mobile") || device.includes("iOS") || device.includes("Android") ? (
<Smartphone size={24} />
) : (
<Monitor size={24} />
)}
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-bold text-foreground">
{browser} on {device}
</p>
{isCurrent && (
<span className="px-2 py-0.5 rounded-full bg-primary/20 text-primary text-[10px] font-bold uppercase tracking-wider">
Current
</span>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs text-foreground/60">
<MapPin size={12} className="shrink-0" />
<span>
{session.location || "Unknown Location"} • {session.ipAddress || "No IP logged"}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-foreground/50">
<Clock size={12} className="shrink-0" />
<span>
Accessed: {new Date(session.lastActive).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<button
onClick={() => handleSessionRevoke(session.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
isCurrent
? "text-destructive hover:bg-destructive/10 border border-destructive/20"
: "text-foreground/70 hover:text-foreground border border-border/50 hover:bg-card/50"
}`}
>
{isCurrent ? "Log Out" : "Revoke Session"}
</button>
</div>
)
})}
</div>
) : (
<p className="text-sm text-foreground/60">No other devices are currently signed in.</p>
Expand Down
18 changes: 5 additions & 13 deletions frontend/app/api/admin/connectivity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
20 changes: 16 additions & 4 deletions frontend/app/api/admin/subscriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
},
],
})
Expand Down
Loading
Loading