XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
"""
Discord bot powered by g4f (gpt4free).

Features:
- /ask command for one-shot questions
- /chat command for conversation with per-user history
- /clear to reset conversation history
- /model to show the configured model
- /tools to list, enable, and disable MCP tools
- MCP tool-calling loop: the model can call tools (web search, scraping,
  image generation, etc.) and the bot executes them via g4f's MCPServer,
  feeds the results back, and produces a final answer.
- Streaming responses edited in-place for a "typing" effect
- Configurable model and provider via environment variables
- Auto-translation: messages in designated channels are translated to English
- Honeypot: slash commands are silently blocked in designated channels
"""

from __future__ import annotations

import os
import logging
from collections import defaultdict, deque
from typing import Deque, Dict, List, Optional

import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv

from g4f.providers.any_provider import AnyProvider
from g4f.client import ClientFactory

from mcp_tools import MCPToolManager, ALL_AVAILABLE_TOOLS, SAFE_DEFAULT_TOOLS
from live_feed import LiveFeed

load_dotenv()

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
TOKEN = os.getenv("DISCORD_TOKEN")
MODEL = os.getenv("G4F_MODEL", "auto")
IMAGE_MODEL = os.getenv("G4F_IMAGE_MODEL", "flux")
SYSTEM_PROMPT = os.getenv(
    "G4F_SYSTEM_PROMPT",
    "You are a helpful, friendly Discord assistant. Keep answers concise "
    "and formatted with Discord markdown when useful. "
    "When a question needs fresh information, use the web_search tool. "
    "When asked to generate an image, use the image_generation tool.",
)
MAX_HISTORY = int(os.getenv("G4F_MAX_HISTORY", "12"))  # messages per user
PROXY = os.getenv("G4F_PROXY")  # optional, e.g. "socks5://127.0.0.1:1080"
MAX_TOOL_LOOPS = int(os.getenv("G4F_MAX_TOOL_LOOPS", "4"))  # safety cap

# ---------------------------------------------------------------------------
# Live feed configuration
# ---------------------------------------------------------------------------
# When set, the bot posts a live activity feed to this Discord channel:
# image thumbnails, tool calls, file edits, heavy token usage, server
# errors, new g4f.dev users, and periodic summaries.
LIVE_FEED_CHANNEL = int(os.getenv("G4F_LIVE_FEED_CHANNEL", "0") or "0")
API_BASE = os.getenv("G4F_API_BASE", "http://localhost:8080")
PUBLIC_BASE = os.getenv("G4F_PUBLIC_BASE", API_BASE)
PUBLIC_API_KEY = os.getenv("G4F_PUBLIC_API_KEY", "")
MEMBERS_BASE = os.getenv("G4F_MEMBERS_BASE", "https://auth.g4f.dev")
FEED_POLL_INTERVAL = int(os.getenv("G4F_FEED_POLL_INTERVAL", "15"))
HEAVY_TOKEN_THRESHOLD = int(os.getenv("G4F_HEAVY_TOKEN_THRESHOLD", "10000"))
FEED_SUMMARY_INTERVAL = int(os.getenv("G4F_FEED_SUMMARY_INTERVAL", "3600"))
FEED_MAX_POSTS_PER_CYCLE = int(os.getenv("G4F_FEED_MAX_POSTS_PER_CYCLE", "5"))

# Simple validation for runtime model names.
ALLOWED_MODEL_CHARS = set(
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:"
)

# Comma-separated list of tools to enable at startup (default: safe set).
_enabled_env = os.getenv("G4F_ENABLED_TOOLS", "")
ENABLED_TOOLS = (
    {t.strip() for t in _enabled_env.split(",") if t.strip()}
    if _enabled_env
    else set(SAFE_DEFAULT_TOOLS)
)

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("g4f-discord")

# ---------------------------------------------------------------------------
# g4f async client + MCP tool manager (shared across requests)
# ---------------------------------------------------------------------------
client = ClientFactory.create_async_client(provider="default",
                                           api_key=os.getenv("G4F_API_KEY"),
                                           media_provider=os.getenv("G4F_MEDIA_PROVIDER", AnyProvider))
mcp = MCPToolManager(enabled_tools=ENABLED_TOOLS)

# Per-user conversation history: user_id -> deque of {"role", "content"}
histories: Dict[int, Deque[dict]] = defaultdict(lambda: deque(maxlen=MAX_HISTORY))

# ---------------------------------------------------------------------------
# Bot setup
# ---------------------------------------------------------------------------
intents = discord.Intents.default()
intents.message_content = True  # required to read user messages


# ---------------------------------------------------------------------------
# Honeypot: silently ignore slash commands in designated channels
# ---------------------------------------------------------------------------
class HoneypotTree(app_commands.CommandTree):
    """Custom command tree that blocks slash commands in honeypot channels."""

    async def interaction_check(
        self, interaction: discord.Interaction, /
    ) -> bool:
        if HONEYPOT_CHANNELS and interaction.channel_id in HONEYPOT_CHANNELS:
            if not interaction.response.is_done():
                await interaction.response.send_message(
                    "🚫 Commands are not allowed in this channel.",
                    ephemeral=True,
                )
            return False
        return True


bot = commands.Bot(command_prefix="!", intents=intents, tree_cls=HoneypotTree)


# ---------------------------------------------------------------------------
# Auto image generation from channel messages
# ---------------------------------------------------------------------------
# If enabled, the bot listens to messages in the given channel(s) and
# runs image generation.
#
# Env vars:
# - G4F_IMAGE_CHANNELS: comma-separated channel ids (required to enable)
_image_channels_env = os.getenv("G4F_IMAGE_CHANNELS", "")
IMAGE_CHANNELS = {
    int(x.strip())
    for x in _image_channels_env.split(",")
    if x.strip().isdigit()
}

# ---------------------------------------------------------------------------
# Auto-translation channels
# ---------------------------------------------------------------------------
# If enabled, the bot listens to messages in the given channel(s) and
# auto-translates them to English, posting the translation as a reply.
#
# Env vars:
# - G4F_TRANSLATE_CHANNELS: comma-separated channel ids (required to enable)
_translate_channels_env = os.getenv("G4F_TRANSLATE_CHANNELS", "")
TRANSLATE_CHANNELS = {
    int(x.strip())
    for x in _translate_channels_env.split(",")
    if x.strip().isdigit()
}

# ---------------------------------------------------------------------------
# Honeypot channels
# ---------------------------------------------------------------------------
# Slash commands are silently ignored in these channels.  Useful for
# channels where you don't want bot command spam (e.g. announcement or
# honeypot/trap channels).
#
# Env vars:
# - G4F_HONEYPOT_CHANNELS: comma-separated channel ids
_honeypot_channels_env = os.getenv("G4F_HONEYPOT_CHANNELS", "")
HONEYPOT_CHANNELS = {
    int(x.strip())
    for x in _honeypot_channels_env.split(",")
    if x.strip().isdigit()
}


def _build_messages(user_id: int, user_content: str) -> List[dict]:
    """Return the full message list including system prompt and history."""
    messages: List[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
    messages.extend(histories[user_id])
    messages.append({"role": "user", "content": user_content})
    return messages


def _truncate(text: str, limit: int = 1900) -> str:
    """Truncate text to stay within Discord's 2000-char message limit."""
    return text if len(text) <= limit else text[:limit] + "…"


def _normalize_model_name(name: str) -> str:
    name = (name or "").strip()
    if not name:
        raise ValueError("Model name cannot be empty")
    if any(ch not in ALLOWED_MODEL_CHARS for ch in name):
        raise ValueError("Model name contains invalid characters")
    return name


# ---------------------------------------------------------------------------
# Completion helpers
# ---------------------------------------------------------------------------
async def _stream_response(
    interaction: discord.Interaction, messages: List[dict]
) -> str:
    """
    Stream a g4f completion (no tools) and edit the interaction response
    in-place. Returns the full accumulated text.
    """
    accumulated = ""
    last_sent = ""
    update_threshold = 80  # characters before each edit

    stream = client.chat.completions.create(
        model=MODEL,
        messages=messages,
        stream=True,
        proxy=PROXY,
    )

    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            accumulated += chunk.choices[0].delta.content
            if len(accumulated) - len(last_sent) >= update_threshold:
                last_sent = accumulated
                try:
                    await interaction.edit_original_response(
                        content=_truncate(accumulated) + " ▌"
                    )
                except discord.HTTPException:
                    pass

    return accumulated


async def _completion_with_tools(
    messages: List[dict],
    use_tools: bool,
) -> tuple[str, Optional[list]]:
    """
    Non-streaming completion that may return tool_calls.

    Returns (content, tool_calls) where tool_calls is None when no tools
    were requested or none were called.
    """
    kwargs: dict = {"model": MODEL, "messages": messages, "stream": False, "proxy": PROXY}
    if use_tools and mcp.definitions:
        kwargs["tools"] = mcp.definitions
        kwargs["tool_choice"] = "auto"

    response = await client.chat.completions.create(**kwargs)
    choice = response.choices[0]
    content = choice.message.content or ""
    tool_calls = getattr(choice.message, "tool_calls", None)
    return content, tool_calls


async def _send_streamed_text(interaction: discord.Interaction, text: str) -> None:
    """Send a block of text, editing in chunks for a typing effect."""
    if not text.strip():
        return
    for i in range(0, len(text), 120):
        sent = text[: i + 120]
        try:
            await interaction.edit_original_response(content=_truncate(sent) + " ▌")
        except discord.HTTPException:
            pass


async def _run_tool_loop(
    interaction: discord.Interaction,
    messages: List[dict],
    use_tools: bool,
) -> tuple[str, List[dict]]:
    """
    Run the full tool-calling loop.

    1. Ask the model for a completion (with tools available).
    2. If it returns tool_calls, execute them via the MCP server.
    3. Append the assistant message + tool results to the conversation.
    4. Repeat until the model stops calling tools or MAX_TOOL_LOOPS is hit.
    5. Stream the final answer into the interaction response.

    Returns (final_text, tool_result_messages).
    """
    tool_results_log: List[dict] = []

    if not (use_tools and mcp.definitions):
        # No tools — just stream a normal response.
        final = await _stream_response(interaction, messages)
        return final, tool_results_log

    # --- Tool-calling phase (non-streaming) ---
    working_messages = list(messages)

    for loop in range(MAX_TOOL_LOOPS):
        content, tool_calls = await _completion_with_tools(working_messages, use_tools=True)

        if not tool_calls:
            # No (more) tool calls — stream this final content to the user.
            if content.strip():
                await _send_streamed_text(interaction, content)
            return content, tool_results_log

        # Show the user that tools are running.
        names = ", ".join(tc.function.name for tc in tool_calls)
        try:
            await interaction.edit_original_response(
                content=f"🔧 Running tools: {names}…"
            )
        except discord.HTTPException:
            pass

        # Execute the tool calls via the MCP server.
        tool_results = await mcp.execute_tool_calls(tool_calls)
        tool_results_log.extend(tool_results)

        # Append the assistant's tool-call message + tool results to the
        # conversation so the model can see the outcomes.
        working_messages.append(
            {
                "role": "assistant",
                "content": content,
                "tool_calls": [
                    {
                        "id": getattr(tc, "id", f"call_{i}"),
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments,
                        },
                    }
                    for i, tc in enumerate(tool_calls)
                ],
            }
        )
        working_messages.extend(tool_results)

    # Exhausted the loop cap — do one final streaming pass without tools
    # so the model summarises what it learned.
    log.warning("Tool loop cap (%d) reached; generating final summary", MAX_TOOL_LOOPS)
    final = await _stream_response(interaction, working_messages)
    return final, tool_results_log


# ---------------------------------------------------------------------------
# Slash commands
# ---------------------------------------------------------------------------
@bot.tree.command(name="ask", description="Ask a one-shot question (no history).")
@app_commands.describe(
    question="Your question for the AI",
    tools="Allow the AI to use MCP tools (web search, etc.)",
)
async def ask(interaction: discord.Interaction, question: str, tools: bool = True):
    await interaction.response.defer(thinking=True)
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": question},
    ]
    try:
        reply, tool_results = await _run_tool_loop(interaction, messages, use_tools=tools)
    except Exception as e:
        log.exception("g4f request failed")
        await interaction.followup.send(f"⚠️ Error: {e}")
        return

    await _finalize_response(interaction, reply, tool_results)


@bot.tree.command(name="chat", description="Chat with conversation history.")
@app_commands.describe(
    message="Your message to the AI",
    tools="Allow the AI to use MCP tools (web search, etc.)",
)
async def chat(interaction: discord.Interaction, message: str, tools: bool = True):
    await interaction.response.defer(thinking=True)
    user_id = interaction.user.id
    messages = _build_messages(user_id, message)
    try:
        reply, tool_results = await _run_tool_loop(interaction, messages, use_tools=tools)
    except Exception as e:
        log.exception("g4f request failed")
        await interaction.followup.send(f"⚠️ Error: {e}")
        return

    await _finalize_response(interaction, reply, tool_results)

    # Store turn in history
    histories[user_id].append({"role": "user", "content": message})
    histories[user_id].append({"role": "assistant", "content": reply})


@bot.tree.command(name="clear", description="Clear your conversation history.")
async def clear(interaction: discord.Interaction):
    histories.pop(interaction.user.id, None)
    await interaction.response.send_message(
        "🧹 Your conversation history has been cleared.", ephemeral=True
    )


@bot.tree.command(name="model", description="Show the currently configured model.")
async def model(interaction: discord.Interaction):
    await interaction.response.send_message(
        f"Current model: `{MODEL}`", ephemeral=True
    )


@bot.tree.command(name="setmodel", description="Set the currently used model.")
@app_commands.describe(
    value="Model name/id",
)
async def setmodel(interaction: discord.Interaction, value: str):
    global MODEL
    try:
        MODEL = _normalize_model_name(value)
    except ValueError as e:
        await interaction.response.send_message(f"❌ {e}", ephemeral=True)
        return

    await interaction.response.send_message(
        f"✅ Model set to: `{MODEL}`", ephemeral=True
    )


@bot.tree.command(name="models", description="List available models (best effort).")
async def models(interaction: discord.Interaction):
    await interaction.response.defer(thinking=True, ephemeral=True)

    available = None
    try:
        available = client.models.get_all()
    except Exception as e:
        log.exception(e)

    if not available:
        await interaction.followup.send(
            "⚠️ Unable to fetch a model catalog from the current provider. "
            "Use `/setmodel` with a model id that works for your provider.",
            ephemeral=True,
        )
        return
    if isinstance(available, dict):
        lines = [f"• {n.get('owned_by', '') } `{n.get('model', k)}` ({n.get('requests', 0)})" for k, n in available.items() if not "requests" in n or n.get("requests") >= 5]
    else:
        lines = [f"• `{n}`" for n in available]
    msg = "**Available models:**\n" + "\n".join(lines)
    await interaction.followup.send(_truncate(msg, 1900), ephemeral=True)


@bot.tree.command(
    name="searchmodel",
    description="Search available models (best effort) by keyword.",
)
@app_commands.describe(
    query="Keyword to search in model names",
    limit="Max matches to return",
)
async def searchmodel(
    interaction: discord.Interaction,
    query: str,
    limit: Optional[int] = 10,
):
    await interaction.response.defer(thinking=True, ephemeral=True)

    query = (query or "").strip().lower()
    if not query:
        await interaction.followup.send("❌ Provide a non-empty `query`.", ephemeral=True)
        return

    try:
        available = client.models.get_all()
    except Exception as e:
        log.exception(e)
        await interaction.followup.send(
            "⚠️ Unable to fetch a model catalog from the current provider. "
            "Use `/setmodel` with a model id that works for your provider.",
            ephemeral=True,
        )
        return

    try:
        limit_val = int(limit) if limit is not None else 10
    except (TypeError, ValueError):
        limit_val = 10
    limit_val = max(1, min(limit_val, 30))

    matches: List[str] = []
    if isinstance(available, dict):
        items = list(available.items())
        # Try to match against common fields in the dict payload.
        for k, n in items:
            text = " ".join(
                str(x).lower()
                for x in (
                    n.get("model", k),
                    n.get("owned_by", ""),
                    n.get("id", ""),
                    n.get("object", ""),
                )
                if x is not None
            )
            if query in text:
                model_name = n.get("model", k)
                owned_by = n.get("owned_by", "") or ""
                reqs = n.get("requests", 0)
                matches.append(
                    f"• `{model_name}`"
                    + (f" ({owned_by})" if owned_by else "")
                    + (f" [{reqs}]" if isinstance(reqs, int) else "")
                )
    else:
        # If provider returns a list-like model catalog.
        for n in available:
            s = str(n).lower()
            if query in s:
                matches.append(f"• `{n}`")

    if not matches:
        await interaction.followup.send(
            f"No model matches found for `{query}`.",
            ephemeral=True,
        )
        return

    shown = matches[:limit_val]
    msg = f"**Matches for `{query}`:**\n" + "\n".join(shown)
    if len(matches) > limit_val:
        msg += f"\n…(showing first {limit_val} of {len(matches)})"
    await interaction.followup.send(_truncate(msg, 1900))


@bot.tree.command(name="image", description="Generate an image from a prompt.")
@app_commands.describe(
    prompt="What to generate",
    model="Optional image model (defaults to current /model)",
)
async def image(
    interaction: discord.Interaction,
    prompt: str,
    model: Optional[str] = None,
):
    await interaction.response.defer(thinking=True)
    try:
        m = _normalize_model_name(model) if model else IMAGE_MODEL
        generated = await _generate_image(prompt=prompt, model=m)
    except Exception as e:
        log.exception("Image generation failed")
        await interaction.followup.send(f"⚠️ Image generation failed: {e}")
        return

    if generated.startswith("http://") or generated.startswith("https://"):
        embed = discord.Embed(title="🖼️ Generated image")
        embed.set_image(url=generated)
        await interaction.followup.send(embed=embed)
    else:
        await interaction.followup.send(
            "🖼️ Generated image (non-URL result):\n"
            f"```{_truncate(generated, 1800)}```"
        )


@bot.tree.command(name="tools", description="List, enable, or disable MCP tools.")
@app_commands.describe(
    action="What to do (default: list)",
    name="Tool name (for enable/disable)",
)
@app_commands.choices(
    action=[
        app_commands.Choice(name="list", value="list"),
        app_commands.Choice(name="enable", value="enable"),
        app_commands.Choice(name="disable", value="disable"),
    ]
)
async def tools(
    interaction: discord.Interaction,
    action: Optional[app_commands.Choice[str]] = None,
    name: Optional[str] = None,
):
    value = action.value if action is not None else "list"

    if value == "list":
        enabled = mcp.enabled_names
        lines = ["**Enabled MCP tools:**"]
        if enabled:
            lines.extend(f"• `{t}`" for t in enabled)
        else:
            lines.append("_(none)_")
        lines.append("\n**Available (not enabled):**")
        disabled = sorted(ALL_AVAILABLE_TOOLS - set(enabled))
        if disabled:
            lines.extend(f"• `{t}`" for t in disabled)
        else:
            lines.append("_(all enabled)_")
        await interaction.response.send_message("\n".join(lines), ephemeral=True)

    elif value == "enable":
        if not name:
            await interaction.response.send_message(
                "Provide a tool `name` to enable.", ephemeral=True
            )
            return
        if mcp.enable(name):
            await interaction.response.send_message(
                f"✅ Enabled tool `{name}`.", ephemeral=True
            )
        else:
            await interaction.response.send_message(
                f"❌ Tool `{name}` not found. Use `/tools list` to see options.",
                ephemeral=True,
            )

    elif value == "disable":
        if not name:
            await interaction.response.send_message(
                "Provide a tool `name` to disable.", ephemeral=True
            )
            return
        if mcp.disable(name):
            await interaction.response.send_message(
                f"✅ Disabled tool `{name}`.", ephemeral=True
            )
        else:
            await interaction.response.send_message(
                f"❌ Tool `{name}` was not enabled.", ephemeral=True
            )


@bot.event
async def on_message(message: discord.Message):
    # Let slash commands etc. work as usual.
    await bot.process_commands(message)

    if message.author.bot:
        return

    # --- Auto-translation to English ---
    if TRANSLATE_CHANNELS and message.channel.id in TRANSLATE_CHANNELS:
        content = message.content.strip()
        if content:
            try:
                translation = await _translate_to_english(content)
                if translation and translation != content:
                    await message.reply(
                        f"🌐 **English:** {_truncate(translation, 1850)}",
                        mention_author=False,
                    )
            except Exception as e:
                log.exception("Auto translation failed")
        return  # don't fall through to image generation in translate channels

    # --- Auto image generation ---
    if not IMAGE_CHANNELS:
        return

    if message.channel.id not in IMAGE_CHANNELS:
        return

    if not message.content:
        return

    prompt = message.content.strip()
    if not prompt:
        return

    try:
        # Create a placeholder then edit/send final result.
        placeholder = await message.channel.send("🖼️ Generating…")
        generated = await _generate_image(prompt=prompt, model=IMAGE_MODEL)

        if generated.startswith("http://") or generated.startswith("https://"):
            embed = discord.Embed(title="🖼️ Generated image")
            embed.set_image(url=generated)
            await placeholder.edit(content="", embed=embed)
        else:
            await placeholder.edit(
                content="🖼️ Generated image (non-URL result):\n"
                + _truncate(generated, 1800)
            )
    except Exception as e:
        log.exception("Auto image generation failed")
        await message.channel.send(f"⚠️ Image generation failed: {e}")


# ---------------------------------------------------------------------------
# Response finalisation
# ---------------------------------------------------------------------------
async def _finalize_response(
    interaction: discord.Interaction,
    reply: str,
    tool_results: List[dict],
) -> None:
    """Edit the interaction response with the final reply + tool summary."""
    if not reply.strip():
        if tool_results:
            tool_text = MCPToolManager.format_tool_results_for_discord(tool_results)
            await interaction.edit_original_response(content=_truncate(tool_text))
        else:
            await interaction.edit_original_response(
                content="⚠️ The model returned an empty response."
            )
        return

    # If tools were used, append a collapsible summary.
    if tool_results:
        tool_text = MCPToolManager.format_tool_results_for_discord(tool_results)
        combined = f"{_truncate(reply)}\n\n{tool_text}"
        await interaction.edit_original_response(content=_truncate(combined, 1900))
    else:
        await interaction.edit_original_response(content=_truncate(reply))


# ---------------------------------------------------------------------------
# Translation
# ---------------------------------------------------------------------------
TRANSLATE_SYSTEM_PROMPT = (
    "You are a translation engine. Translate the user's message into English. "
    "Output ONLY the translation — no explanations, no notes, no quotes. "
    "If the text is already in English, output it unchanged."
)


async def _translate_to_english(text: str) -> str:
    """Translate *text* to English via g4f and return the translation."""
    messages = [
        {"role": "system", "content": TRANSLATE_SYSTEM_PROMPT},
        {"role": "user", "content": text},
    ]
    response = await client.chat.completions.create(
        model=MODEL,
        messages=messages,
        stream=False,
        proxy=PROXY,
    )
    return (response.choices[0].message.content or "").strip()


# ---------------------------------------------------------------------------
# Image generation
# ---------------------------------------------------------------------------
async def _generate_image(prompt: str, model: str) -> str:
    """Generate an image via g4f and return a URL or base64-like string."""
    result = await client.images.generate(
        prompt=prompt,
        model=model,
        proxy=PROXY,
        response_format="url",
    )
    return str(result.data[0].url)


# ---------------------------------------------------------------------------
# Lifecycle events
# ---------------------------------------------------------------------------
@bot.event
async def on_ready():
    log.info("Logged in as %s (id=%s)", bot.user, bot.user.id)
    log.info("MCP tools enabled: %s", mcp.enabled_names)
    try:
        synced = await bot.tree.sync()
        log.info("Synced %d slash commands", len(synced))
    except Exception:
        log.exception("Failed to sync slash commands")

    # Load the live feed cog if a channel was configured.
    if LIVE_FEED_CHANNEL:
        existing = bot.get_cog("LiveFeed")
        if existing is None:
            await bot.add_cog(
                LiveFeed(
                    bot=bot,
                    channel_id=LIVE_FEED_CHANNEL,
                    api_base=API_BASE,
                    public_base=PUBLIC_BASE,
                    api_key=PUBLIC_API_KEY,
                    members_base=MEMBERS_BASE or None,
                    poll_interval=FEED_POLL_INTERVAL,
                    heavy_token_threshold=HEAVY_TOKEN_THRESHOLD,
                    summary_interval=FEED_SUMMARY_INTERVAL,
                    max_posts_per_cycle=FEED_MAX_POSTS_PER_CYCLE,
                )
            )
            log.info("Live feed cog loaded → channel %s", LIVE_FEED_CHANNEL)
        else:
            log.info("Live feed cog already loaded")


def main():
    if not TOKEN:
        raise SystemExit(
            "DISCORD_TOKEN not set. Put it in .env or export it as an env var."
        )
    bot.run(TOKEN)


if __name__ == "__main__":
    main()