WidgetBot
dash-widgetbot integrates Discord chat into your Dash applications using a pure Python hook-based architecture — no React build required.
- DiscordCrate — Floating chat button (the one in the bottom-right corner of this page)
- DiscordWidget — Inline iframe embed for placing chat anywhere in your layout
- Slash Commands — AI-powered
/ai,/ask,/gen,/statuscommands - Webhook API — Send messages to Discord from Python
Two layers, two deployment stories. The Crate/Widget is a client-side iframe with no server-side Discord API calls — drop it in and it just works in dev and prod. The slash-command/bot layer is where production pain lives (outbound proxies, rate limits, gunicorn tuning, global vs guild command scope). See the Production Deployment Guide section below for the full playbook.
Installation
pip install dash-widgetbot
pip install dash-widgetbot[bot,ai] # with Discord bot + Gemini AI
Overview
# File: docs/dash_widgetbot/introduction.py
import os
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from dash_widgetbot import discord_widget_container
component = dmc.Stack([
dmc.Alert(
children=dmc.Stack([
dmc.Text(
"The floating Discord button in the bottom-right corner of this page is a "
"DiscordCrate — a global chat widget that appears on every page. "
"Try clicking it to open the community chat!",
size="sm",
),
dmc.Text(
"Below is a DiscordWidget — an inline embed you can place anywhere in your layout.",
size="sm",
fw=500,
),
], gap="xs"),
title="Two Components",
color="indigo",
icon=DashIconify(icon="ic:baseline-discord", width=20),
),
dmc.SimpleGrid(
cols=2,
children=[
dmc.Paper(
dmc.Stack([
dmc.Group([
DashIconify(icon="tabler:message-circle", width=24, color="#5865f2"),
dmc.Text("DiscordCrate", fw=700, size="lg"),
], gap="xs"),
dmc.Text("Floating chat button", size="sm", c="dimmed"),
dmc.List([
dmc.ListItem("Global — appears on every page", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("Toggle, notify, navigate commands", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("Slash command bridge (/ai, /ask)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("Event listeners (sentMessage, signIn)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
], size="sm", spacing="xs"),
], gap="sm"),
withBorder=True, p="lg",
),
dmc.Paper(
dmc.Stack([
dmc.Group([
DashIconify(icon="tabler:layout-bottombar", width=24, color="#5865f2"),
dmc.Text("DiscordWidget", fw=700, size="lg"),
], gap="xs"),
dmc.Text("Inline iframe embed", size="sm", c="dimmed"),
dmc.List([
dmc.ListItem("Placed anywhere in your layout", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("No floating button", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("Read-only events (message, signIn)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
dmc.ListItem("No command dispatch", icon=DashIconify(icon="tabler:x", width=16, color="gray")),
], size="sm", spacing="xs"),
], gap="sm"),
withBorder=True, p="lg",
),
],
),
dmc.Paper(
discord_widget_container(
server=os.getenv("WIDGETBOT_SERVER", ""),
channel=os.getenv("WIDGETBOT_CHANNEL", ""),
width="100%",
height="350px",
),
withBorder=True, p="xs", radius="md",
),
], gap="md")
Inline Widget Embed
# File: docs/dash_widgetbot/widget_example.py
import os
import dash_mantine_components as dmc
from dash_widgetbot import discord_widget_container
component = dmc.Stack([
dmc.Text("Inline Discord Widget", fw=600, size="lg"),
dmc.Text(
"discord_widget_container() creates a cross-origin iframe pointing to the "
"WidgetBot shard. Unlike the Crate, it renders directly in your layout with "
"no floating button. Configure server, channel, dimensions, and shard URL.",
size="sm", c="dimmed",
),
dmc.Paper(
discord_widget_container(
server=os.getenv("WIDGETBOT_SERVER", ""),
channel=os.getenv("WIDGETBOT_CHANNEL", ""),
width="100%",
height="400px",
),
withBorder=True, p="xs", radius="md",
),
dmc.Code(
"""from dash_widgetbot import discord_widget_container
widget = discord_widget_container(
server=os.getenv("WIDGETBOT_SERVER"),
channel=os.getenv("WIDGETBOT_CHANNEL"),
width="100%",
height="400px",
shard="https://e-business.widgetbot.co", # optional custom shard
)""",
block=True,
),
], gap="md")
Sending Messages (Webhook)
# File: docs/dash_widgetbot/webhook_example.py
import os
import time
import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State, clientside_callback, no_update
from dash_iconify import DashIconify
from dash_widgetbot.webhook import send_webhook_message
component = dmc.Stack([
dmc.Text("Send Messages via Webhook", fw=600, size="lg"),
dmc.Text(
"Use send_webhook_message() to post messages to Discord from your Dash app. "
"Messages are sent via the Discord webhook API and appear in the channel.",
size="sm", c="dimmed",
),
dmc.Paper(
dmc.Stack([
dmc.Textarea(
id="wb-webhook-content",
label="Message",
placeholder="Type a message to send to Discord...",
value="Hello from pip-docs+ documentation!",
minRows=2,
autosize=True,
),
dmc.Group([
dmc.TextInput(
id="wb-webhook-username",
placeholder="Bot display name (optional)",
label="Username",
style={"flex": 1},
),
dmc.TextInput(
id="wb-webhook-avatar",
placeholder="https://cdn.discordapp.com/embed/avatars/0.png",
label="Avatar URL (optional)",
style={"flex": 1},
),
]),
dmc.Group([
dmc.Button(
"Send to Discord",
id="wb-webhook-send-btn",
leftSection=DashIconify(icon="tabler:send", width=18),
color="indigo",
loading=False,
),
html.Div(id="wb-webhook-result"),
]),
dmc.Text(
"Open the floating Discord chat (bottom-right) to see your message appear.",
size="xs", c="dimmed", fs="italic",
),
], gap="sm"),
withBorder=True, p="lg",
),
dmc.Code(
"""from dash_widgetbot.webhook import send_webhook_message
result = send_webhook_message(
"Hello from Dash!",
webhook_url=os.getenv("DISCORD_WEBHOOK_URL"), # auto from .env
username="My Bot", # optional display name
avatar_url="https://...", # optional avatar
thread_id="...", # optional thread
)
# result: {"success": True, "message_id": "123...", "status_code": 200}""",
block=True,
),
], gap="md")
@callback(
Output("wb-webhook-result", "children"),
Input("wb-webhook-send-btn", "n_clicks"),
State("wb-webhook-content", "value"),
State("wb-webhook-username", "value"),
State("wb-webhook-avatar", "value"),
running=[(Output("wb-webhook-send-btn", "loading"), True, False)],
prevent_initial_call=True,
)
def send_message(_n, content, username, avatar_url):
if not content:
return dmc.Badge("Enter a message first", color="yellow", variant="light", size="sm")
result = send_webhook_message(
content,
username=username or None,
avatar_url=avatar_url or None,
)
if result["success"]:
return dmc.Badge(
f"Sent! Open the Discord chat to see it.",
color="green", variant="light", size="sm",
)
return dmc.Badge(
f"Error: {result.get('error', 'Unknown')[:60]}",
color="red", variant="light", size="sm",
)
Command Bridge Patterns
# File: docs/dash_widgetbot/commands_example.py
import dash_mantine_components as dmc
from dash_iconify import DashIconify
component = dmc.Stack([
dmc.Text("Command Bridge Patterns", fw=600, size="lg"),
dmc.Text(
"The Crate is controlled via a store-based bridge. Python callbacks write "
"command dicts to a dcc.Store, and clientside JS dispatches them to the "
"Crate API. All helpers return command dicts with a _ts timestamp to "
"prevent Dash deduplication.",
size="sm", c="dimmed",
),
dmc.Alert(
"Try clicking the floating Discord button (bottom-right) and typing "
"/status to see the command bridge in action!",
title="Live Demo",
color="teal",
icon=DashIconify(icon="tabler:terminal-2", width=20),
),
dmc.SimpleGrid(
cols=2,
children=[
dmc.Paper(
dmc.Stack([
dmc.Text("Toggle Open/Close", fw=600, size="sm"),
dmc.Code(
"""from dash_widgetbot import STORE_IDS, crate_toggle
@callback(
Output(STORE_IDS["command"], "data",
allow_duplicate=True),
Input("my-button", "n_clicks"),
prevent_initial_call=True,
)
def toggle(_n):
return crate_toggle() # toggle
# return crate_toggle(True) # force open
# return crate_toggle(False) # force close""",
block=True,
),
], gap="xs"),
withBorder=True, p="md",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Show Notification", fw=600, size="sm"),
dmc.Code(
"""from dash_widgetbot import crate_notify
return crate_notify(
"Hello from Dash!",
timeout=5000, # auto-dismiss in 5s
avatar="https://...", # custom avatar
)""",
block=True,
),
], gap="xs"),
withBorder=True, p="md",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Navigate Channel", fw=600, size="sm"),
dmc.Code(
"""from dash_widgetbot import crate_navigate
# Switch to a different channel
return crate_navigate("CHANNEL_ID")
# Switch server + channel
return crate_navigate(
"CHANNEL_ID", guild="SERVER_ID"
)""",
block=True,
),
], gap="xs"),
withBorder=True, p="md",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Hide / Show", fw=600, size="sm"),
dmc.Code(
"""from dash_widgetbot import crate_hide, crate_show
# Hide the Crate button entirely
return crate_hide()
# Show it again
return crate_show()""",
block=True,
),
], gap="xs"),
withBorder=True, p="md",
),
],
),
dmc.Paper(
dmc.Stack([
dmc.Text("All Bridge Helpers", fw=600, size="sm"),
dmc.Table(
[
dmc.TableThead(dmc.TableTr([
dmc.TableTh("Helper"),
dmc.TableTh("Description"),
])),
dmc.TableTbody([
dmc.TableTr([dmc.TableTd(dmc.Code("crate_toggle(is_open)")), dmc.TableTd("Toggle or set open/closed")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_notify(content, timeout)")), dmc.TableTd("Show notification bubble")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_navigate(channel, guild)")), dmc.TableTd("Switch to a channel")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_send_message(msg)")), dmc.TableTd("Send message on behalf of user")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_hide() / crate_show()")), dmc.TableTd("Hide/show the button")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_login() / crate_logout()")), dmc.TableTd("Auth controls")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_update_options(**opts)")), dmc.TableTd("Update config at runtime")]),
dmc.TableTr([dmc.TableTd(dmc.Code("crate_set_color(var, val)")), dmc.TableTd("Set CSS variable")]),
]),
],
),
], gap="sm"),
withBorder=True, p="lg",
),
], gap="md")
Slash Commands & Event Bridge
# File: docs/dash_widgetbot/events_example.py
import dash_mantine_components as dmc
from dash_iconify import DashIconify
component = dmc.Stack([
dmc.Text("Slash Commands & Event Bridge", fw=600, size="lg"),
dmc.Text(
"WidgetBot doesn't support native Discord slash commands. dash-widgetbot "
"implements a message-parsing bridge that intercepts /command text, builds "
"a fake interaction, and dispatches to registered handlers.",
size="sm", c="dimmed",
),
dmc.Alert(
children=dmc.Stack([
dmc.Text("Try these in the floating Discord chat (bottom-right):", size="sm", fw=500),
dmc.List([
dmc.ListItem([dmc.Code("/status"), " — Show app info"]),
dmc.ListItem([dmc.Code("/ai <prompt>"), " — Generate AI content with Gemini"]),
dmc.ListItem([dmc.Code("/ask <question>"), " — Ask the AI a question"]),
dmc.ListItem([dmc.Code("/ai <prompt>"), " + attach an image — Multimodal AI"]),
], size="sm"),
], gap="xs"),
title="Live Slash Commands",
color="indigo",
icon=DashIconify(icon="tabler:terminal-2", width=20),
),
dmc.Paper(
dmc.Stack([
dmc.Text("How the Bridge Works", fw=600, size="sm"),
dmc.Code(
"""# 1. User types "/ai explain this image" in the Crate
# 2. WidgetBot fires sentMessage via postMessage
# 3. widgetbot_fix.js intercepts and forwards to Dash store:
window.dash_clientside.set_props('_widgetbot-crate-event', {
data: {type: 'sentMessage', content: '/ai explain...', ...}
})
# 4. Python callback parses the command:
@callback(
Output("_crate-slash-result", "data"),
Output("_widgetbot-crate-command", "data", allow_duplicate=True),
Input("_widgetbot-crate-event", "data"),
prevent_initial_call=True,
)
def _handle_crate_slash(event_data):
content = event_data.get("content", "")
if not content.startswith("/"):
return no_update, no_update
# Parse "/ai prompt" → cmd_name="ai", rest="prompt"
parts = content[1:].split(None, 1)
cmd_name, rest = parts[0], parts[1] if len(parts) > 1 else ""
# Build fake interaction and dispatch to handler...
# 5. Handler generates AI response via Gemini
# 6. Response posted to Discord channel via bot token
# 7. Message appears in the Crate widget""",
block=True,
),
], gap="sm"),
withBorder=True, p="lg",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Store IDs (Event System)", fw=600, size="sm"),
dmc.Text(
"add_discord_crate() returns 6 store IDs for the command/event bridge:",
size="sm", c="dimmed",
),
dmc.Table(
[
dmc.TableThead(dmc.TableTr([
dmc.TableTh("Key"),
dmc.TableTh("Store ID"),
dmc.TableTh("Direction"),
dmc.TableTh("Purpose"),
])),
dmc.TableTbody([
dmc.TableTr([dmc.TableTd("config"), dmc.TableTd(dmc.Code("_widgetbot-crate-config")), dmc.TableTd("Read"), dmc.TableTd("Initial config (read-only)")]),
dmc.TableTr([dmc.TableTd("command"), dmc.TableTd(dmc.Code("_widgetbot-crate-command")), dmc.TableTd("Write"), dmc.TableTd("Send commands to Crate")]),
dmc.TableTr([dmc.TableTd("event"), dmc.TableTd(dmc.Code("_widgetbot-crate-event")), dmc.TableTd("Read"), dmc.TableTd("Generic events (sentMessage, etc.)")]),
dmc.TableTr([dmc.TableTd("message"), dmc.TableTd(dmc.Code("_widgetbot-crate-message")), dmc.TableTd("Read"), dmc.TableTd("Incoming Discord messages")]),
dmc.TableTr([dmc.TableTd("user"), dmc.TableTd(dmc.Code("_widgetbot-crate-user")), dmc.TableTd("Read"), dmc.TableTd("Sign-in / sign-out state")]),
dmc.TableTr([dmc.TableTd("status"), dmc.TableTd(dmc.Code("_widgetbot-crate-status")), dmc.TableTd("Read"), dmc.TableTd("Ready + open state")]),
]),
],
),
], gap="sm"),
withBorder=True, p="lg",
),
], gap="md")
Setup & Configuration — Development
Baseline initialization order and environment variable contract. Suitable for local dev against a test guild with an ngrok (or cloudflared) tunnel for the interactions endpoint.
# File: docs/dash_widgetbot/setup_guide.py
import dash_mantine_components as dmc
from dash_iconify import DashIconify
component = dmc.Stack([
dmc.Text("Setup & Configuration", fw=600, size="lg"),
dmc.Alert(
children="All hook registrations must happen BEFORE dash.Dash() is created. "
"Hooks are processed at app creation time, not at request time.",
title="Critical: Initialization Order",
color="red",
icon=DashIconify(icon="tabler:alert-triangle", width=20),
),
dmc.Paper(
dmc.Stack([
dmc.Text("Initialization Order", fw=600, size="sm"),
dmc.Code(
"""from dotenv import load_dotenv
load_dotenv()
# 1. Register Crate hooks BEFORE Dash()
from dash_widgetbot import add_discord_crate
store_ids = add_discord_crate(
server=os.getenv("WIDGETBOT_SERVER"),
channel=os.getenv("WIDGETBOT_CHANNEL"),
color=os.getenv("WIDGETBOT_COLOR", "#5865f2"),
shard=os.getenv("WIDGETBOT_SHARD", ""),
)
# 2. Register interactions endpoint BEFORE Dash()
from dash_widgetbot import add_discord_interactions
if os.getenv("DISCORD_PUBLIC_KEY"):
add_discord_interactions()
# 3. Create Dash app AFTER hooks
import dash
app = dash.Dash(__name__)
# 4. Register command handlers AFTER app
from dash_widgetbot import register_command, sync_discord_endpoint
register_command("ask", my_handler, ephemeral=True)
sync_discord_endpoint()""",
block=True,
),
], gap="sm"),
withBorder=True, p="lg",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Environment Variables", fw=600, size="sm"),
dmc.Table(
[
dmc.TableThead(dmc.TableTr([
dmc.TableTh("Variable"),
dmc.TableTh("Required"),
dmc.TableTh("Description"),
])),
dmc.TableTbody([
dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_SERVER")), dmc.TableTd("Yes"), dmc.TableTd("Discord guild snowflake ID")]),
dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_CHANNEL")), dmc.TableTd("Yes"), dmc.TableTd("Default channel snowflake ID")]),
dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_COLOR")), dmc.TableTd("No"), dmc.TableTd("Button hex color (default #5865f2)")]),
dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_SHARD")), dmc.TableTd("No"), dmc.TableTd("Custom WidgetBot shard URL")]),
dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_PUBLIC_KEY")), dmc.TableTd("Bot"), dmc.TableTd("Ed25519 public key for slash commands")]),
dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_APPLICATION_ID")), dmc.TableTd("Bot"), dmc.TableTd("Discord application ID")]),
dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_BOT_TOKEN")), dmc.TableTd("Bot"), dmc.TableTd("Bot token for API calls")]),
dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_WEBHOOK_URL")), dmc.TableTd("Bot"), dmc.TableTd("Webhook URL for sending messages")]),
dmc.TableTr([dmc.TableTd(dmc.Code("GEMINI_API_KEY")), dmc.TableTd("AI"), dmc.TableTd("Google Gemini API key for /ai, /ask")]),
dmc.TableTr([dmc.TableTd(dmc.Code("GEMINI_MODEL")), dmc.TableTd("No"), dmc.TableTd("Gemini model name (default gemini-2.0-flash)")]),
]),
],
),
], gap="sm"),
withBorder=True, p="lg",
),
dmc.Paper(
dmc.Stack([
dmc.Text("Package Extras", fw=600, size="sm"),
dmc.Code(
"""pip install dash-widgetbot # Core (Crate + Widget)
pip install dash-widgetbot[bot] # + Discord bot (PyNaCl, requests)
pip install dash-widgetbot[ai] # + Gemini AI (google-genai)
pip install dash-widgetbot[realtime] # + Socket.IO transport
pip install dash-widgetbot[all] # Everything""",
block=True,
),
], gap="sm"),
withBorder=True, p="lg",
),
], gap="md")
Production Deployment Guide
Everything a dev run doesn't teach you. The Crate is trivial to ship; the slash-command/bot layer exposes a stack of production-only issues — Cloudflare IP blocks on shared PaaS IPs, urllib3<2 for HTTPS proxy compatibility, Docker-build vs runtime proxy scope, gunicorn graceful_timeout vs daemon-thread Discord follow-up PATCHes, Discord's per-app command-registration rate limit, and the global-vs-guild scope decision.
# File: docs/dash_widgetbot/production_guide.py
"""Production deployment guide for dash-widgetbot.
Captures the lessons learned deploying the Crate + slash-command layers to
a PaaS (Render, Railway, Fly.io) behind Cloudflare-fronted Discord APIs.
Read alongside `setup_guide.py` which covers the baseline env-var contract.
"""
import dash_mantine_components as dmc
from dash_iconify import DashIconify
def _alert(title, text, color, icon):
return dmc.Alert(
children=text,
title=title,
color=color,
icon=DashIconify(icon=icon, width=20),
)
def _paper(title, *children):
return dmc.Paper(
dmc.Stack([dmc.Text(title, fw=600, size="sm"), *children], gap="sm"),
withBorder=True,
p="lg",
)
_TWO_LAYERS_TABLE = dmc.Table(
[
dmc.TableThead(
dmc.TableTr(
[
dmc.TableTh(""),
dmc.TableTh("Layer 1 — Crate / Widget"),
dmc.TableTh("Layer 2 — Slash Commands / Bot"),
]
)
),
dmc.TableTbody(
[
dmc.TableTr([dmc.TableTd("Transport"), dmc.TableTd("Client-side iframe"), dmc.TableTd("HTTPS POST from Discord → your server")]),
dmc.TableTr([dmc.TableTd("Server-side Discord API calls"), dmc.TableTd("None"), dmc.TableTd("Command registration + follow-up PATCH")]),
dmc.TableTr([dmc.TableTd("Public endpoint required"), dmc.TableTd("No"), dmc.TableTd("Yes (HTTPS, valid cert, publicly reachable)")]),
dmc.TableTr([dmc.TableTd("Dev vs prod differences"), dmc.TableTd("Identical"), dmc.TableTd("Significant (proxy, gunicorn, registration strategy)")]),
dmc.TableTr([dmc.TableTd("Required env vars"), dmc.TableTd("WIDGETBOT_SERVER, WIDGETBOT_CHANNEL"), dmc.TableTd("+ DISCORD_PUBLIC_KEY, DISCORD_APPLICATION_ID, DISCORD_BOT_TOKEN")]),
dmc.TableTr([dmc.TableTd("Extras install"), dmc.TableTd("dash-widgetbot"), dmc.TableTd("dash-widgetbot[bot,ai]")]),
]
),
],
striped=True,
withTableBorder=True,
)
_TROUBLESHOOTING_TABLE = dmc.Table(
[
dmc.TableThead(
dmc.TableTr(
[
dmc.TableTh("Symptom"),
dmc.TableTh("Likely Cause"),
dmc.TableTh("Fix"),
]
)
),
dmc.TableTbody(
[
dmc.TableTr(
[
dmc.TableTd("429 response with Cloudflare HTML from Discord"),
dmc.TableTd("PaaS shared IP on Cloudflare blocklist"),
dmc.TableTd("Route discord.com through a proxy (QuotaGuard, Fixie, etc.) via HTTP(S)_PROXY + NO_PROXY"),
]
),
dmc.TableTr(
[
dmc.TableTd("SSL handshake error when proxy is set"),
dmc.TableTd("urllib3 v2 changed HTTPS proxy CONNECT behavior"),
dmc.TableTd("Pin urllib3<2 in requirements.txt"),
]
),
dmc.TableTr(
[
dmc.TableTd("Docker build fails reaching pip / npm registries"),
dmc.TableTd("Proxy env vars leak into build stage"),
dmc.TableTd("Unset HTTP_PROXY / HTTPS_PROXY at the top of the Dockerfile"),
]
),
dmc.TableTr(
[
dmc.TableTd("Gemini / other APIs slow, timing out, or burning proxy quota"),
dmc.TableTd("Global proxy routes non-Discord traffic"),
dmc.TableTd("Add those domains to the NO_PROXY list"),
]
),
dmc.TableTr(
[
dmc.TableTd("Worker killed mid-generation on deploy / restart"),
dmc.TableTd("gunicorn graceful_timeout too short for daemon PATCH"),
dmc.TableTd("gunicorn.conf.py → graceful_timeout=120, workers=1"),
]
),
dmc.TableTr(
[
dmc.TableTd("429 on command registration every deploy"),
dmc.TableTd("Re-PUTing identical payload on every boot"),
dmc.TableTd("Hash the payload, cache the signature, skip PUT when unchanged"),
]
),
dmc.TableTr(
[
dmc.TableTd("Slash commands appear in one guild but not another"),
dmc.TableTd("Guild-scoped registration"),
dmc.TableTd("Use the global endpoint /applications/{id}/commands — ~1h propagation"),
]
),
dmc.TableTr(
[
dmc.TableTd("Command fires but nothing posts back to channel"),
dmc.TableTd("Bot missing channel permissions"),
dmc.TableTd("Server Settings → Integrations → bot → Channels → grant Send Messages + Embed Links"),
]
),
dmc.TableTr(
[
dmc.TableTd("Dev Portal rejects Interactions Endpoint URL"),
dmc.TableTd("Server not listening, PyNaCl missing, or public key mismatch"),
dmc.TableTd("Confirm server is live, pip install dash-widgetbot[bot], DISCORD_PUBLIC_KEY matches the Dev Portal"),
]
),
]
),
],
striped=True,
withTableBorder=True,
)
component = dmc.Stack(
[
dmc.Text("Production Deployment & Troubleshooting", fw=600, size="lg"),
_alert(
"Two Independent Layers",
"The Crate / Widget iframe is a drop-in with no server-side Discord API calls — "
"it behaves identically in dev and prod. The Bot + slash-command layer is where "
"production-only issues live: outbound routing, rate limits, proxy config, and "
"worker-lifecycle timing.",
"blue",
"tabler:stack-2",
),
_paper("Layer 1 vs Layer 2", _TWO_LAYERS_TABLE),
_paper(
"Development — Slash-command setup",
dmc.List(
[
dmc.ListItem("Discord Dev Portal → New Application — save Application ID + Public Key"),
dmc.ListItem("Bot tab → add bot, generate token (DISCORD_BOT_TOKEN)"),
dmc.ListItem("OAuth2 → URL Generator → scopes: applications.commands + bot — invite to your test guild"),
dmc.ListItem("Expose localhost publicly: ngrok http 8504 (or cloudflared tunnel)"),
dmc.ListItem("Dev Portal → General Information → Interactions Endpoint URL → paste ngrok URL + /api/discord/interactions"),
dmc.ListItem("Discord sends a test PING — the save only sticks if Ed25519 verification passes (requires PyNaCl)"),
dmc.ListItem("Guild-scoped registration is fine here; it propagates instantly while iterating"),
],
size="sm",
),
),
_paper(
"Production — Outbound proxy for discord.com",
dmc.Text(
"Many PaaS hosts (Render, Railway, Heroku free tier, Fly.io edge) share IPs "
"Cloudflare has blocklisted. Symptoms: 429 responses with a Cloudflare HTML "
"error page, or a 1020 Access Denied. Route ONLY discord.com through a proxy "
"(QuotaGuard-style). Everything else goes direct — the NO_PROXY list matters.",
size="sm",
),
dmc.Code(
"""# run.py — runs BEFORE Dash() so hooks see the env
import os
_proxy_url = os.getenv("QUOTAGUARD_URL")
if _proxy_url:
os.environ["HTTP_PROXY"] = _proxy_url
os.environ["HTTPS_PROXY"] = _proxy_url
os.environ["NO_PROXY"] = ",".join([
"generativelanguage.googleapis.com", # Gemini direct
"googleapis.com",
"pypistats.org",
"pypi.org",
"github.com",
"api.github.com",
"registry.npmjs.org",
"localhost",
"127.0.0.1",
])""",
block=True,
),
dmc.Text(
"Companion fix 1: pin urllib3<2 in requirements.txt — v2 breaks HTTPS proxy SSL handshakes.",
size="sm",
c="dimmed",
),
dmc.Text(
"Companion fix 2: unset HTTP(S)_PROXY at the top of the Dockerfile so pip/npm registries are reachable during build.",
size="sm",
c="dimmed",
),
dmc.Code(
"""# Dockerfile (top of file)
ENV HTTP_PROXY=""
ENV HTTPS_PROXY=""
ENV http_proxy=""
ENV https_proxy=""
""",
block=True,
),
),
_paper(
"Production — Gunicorn tuning for daemon-thread handlers",
dmc.Text(
"Slash-command handlers return a type-5 deferred response in <50ms, then run "
"Gemini generation + Discord follow-up PATCH in a daemon thread (15-45s). "
"gunicorn's default graceful_timeout=30 kills these mid-PATCH on rolling deploys.",
size="sm",
),
dmc.Code(
"""# gunicorn.conf.py
workers = 1 # shared _command_handlers registry stays in one process
worker_class = "sync"
timeout = 120 # request timeout
graceful_timeout = 120 # SIGTERM → SIGKILL grace window for in-flight daemons
bind = "0.0.0.0:8550"
keepalive = 5
accesslog = "-"
errorlog = "-"
""",
block=True,
),
),
_paper(
"Production — Command registration strategy",
dmc.List(
[
dmc.ListItem("Use the GLOBAL endpoint PUT /applications/{app_id}/commands — not /guilds/{gid}/commands — so one registration covers every guild the bot joins"),
dmc.ListItem("Hash the command payload + app_id, cache the signature on disk (e.g. /tmp/pip_docs_discord_cmds.hash); skip the PUT when unchanged to avoid Discord 429s"),
dmc.ListItem("Expose DISCORD_FORCE_COMMAND_SYNC=1 as an escape hatch for the cache when intentionally re-registering"),
dmc.ListItem("Global registration propagates to guilds in up to ~1 hour on first publish / changes — plan around that cache window"),
dmc.ListItem("Run the PUT once at import time (before the Flask server starts), not inside a request handler"),
],
size="sm",
),
),
_paper("Troubleshooting matrix", _TROUBLESHOOTING_TABLE),
_alert(
"Sanity-check order for a broken production bot",
"1. Is /api/discord/interactions reachable over HTTPS? "
"2. Does the signature verify (PyNaCl installed, public key matches)? "
"3. Are outbound requests to discord.com returning 2xx (proxy + NO_PROXY)? "
"4. Are commands registered globally, and is it <1h since the last change? "
"5. Does the bot have Send Messages + Embed Links in the target channel?",
"teal",
"tabler:checklist",
),
],
gap="md",
)
Crate Properties — add_discord_crate()
| Property | Type | Default | Description |
|---|---|---|---|
server | string | Required | Discord server (guild) snowflake ID |
channel | string | "" | Default channel snowflake ID |
color | string | "#5865f2" | Button hex color |
location | list | None | [vertical, horizontal] position |
notifications | bool | True | Show notification bubbles |
indicator | bool | True | Show unread dot indicator |
timeout | int | 10000 | ms before notification auto-dismiss |
defer | bool | False | Lazy-load Crate |
pages | list | None | Route paths for visibility scoping |
prefix | string | "" | Namespace for multi-instance |
shard | string | "" | Custom shard URL |
Widget Properties — discord_widget_container()
| Property | Type | Default | Description |
|---|---|---|---|
server | string | Required | Discord server (guild) snowflake ID |
channel | string | "" | Channel snowflake ID |
width | string | "100%" | CSS width |
height | string | "600px" | CSS height |
shard | string | "" | Custom WidgetBot shard URL |
Contributing
Contributions to dash-widgetbot are welcome! Visit the GitHub repo for issues and feature requests.
License
MIT License.