feat: add Discord integration with chat_bridge abstraction layer

Introduces a vendor-agnostic chat platform architecture:

- chat_bridge/base.py: ChatPlatform ABC, ChatMessage, ChatThread
- chat_bridge/registry.py: PlatformRegistry singleton
- chat_bridge/invite_parser.py: QR + Ollama vision invite extraction
- chat_bridge/vendors/discord.py: DiscordVendor with native threads

Workflow: paste a screenshot of a Discord invite or QR code at
POST /discord/join → Timmy extracts the invite automatically.

Every Discord conversation gets its own thread, keeping channels clean.
Bot responds to @mentions and DMs, routes through Timmy agent.

43 new tests (base classes, registry, invite parser, vendor, routes).

https://claude.ai/code/session_01WU4h3cQQiouMwmgYmAgkMM
This commit is contained in:
Claude
2026-02-25 01:11:14 +00:00
parent 2c419a777d
commit 15596ca325
14 changed files with 1464 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ from dashboard.routes.swarm_internal import router as swarm_internal_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
logging.basicConfig(
level=logging.INFO,
@@ -108,8 +109,15 @@ async def lifespan(app: FastAPI):
from telegram_bot.bot import telegram_bot
await telegram_bot.start()
# Auto-start Discord bot and register in platform registry
from chat_bridge.vendors.discord import discord_bot
from chat_bridge.registry import platform_registry
platform_registry.register(discord_bot)
await discord_bot.start()
yield
await discord_bot.stop()
await telegram_bot.stop()
task.cancel()
try:
@@ -145,6 +153,7 @@ app.include_router(swarm_internal_router)
app.include_router(tools_router)
app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
@app.get("/", response_class=HTMLResponse)

View File

@@ -0,0 +1,140 @@
"""Dashboard routes for Discord bot setup, status, and invite-from-image.
Endpoints:
POST /discord/setup — configure bot token
GET /discord/status — connection state + guild count
POST /discord/join — paste screenshot → extract invite → join
GET /discord/oauth-url — get the bot's OAuth2 authorization URL
"""
from fastapi import APIRouter, File, Form, UploadFile
from pydantic import BaseModel
from typing import Optional
router = APIRouter(prefix="/discord", tags=["discord"])
class TokenPayload(BaseModel):
token: str
@router.post("/setup")
async def setup_discord(payload: TokenPayload):
"""Configure the Discord bot token and (re)start the bot.
Send POST with JSON body: {"token": "<your-bot-token>"}
Get the token from https://discord.com/developers/applications
"""
from chat_bridge.vendors.discord import discord_bot
token = payload.token.strip()
if not token:
return {"ok": False, "error": "Token cannot be empty."}
discord_bot.save_token(token)
if discord_bot.state.name == "CONNECTED":
await discord_bot.stop()
success = await discord_bot.start(token=token)
if success:
return {"ok": True, "message": "Discord bot connected successfully."}
return {
"ok": False,
"error": (
"Failed to start bot. Check that the token is correct and "
'discord.py is installed: pip install ".[discord]"'
),
}
@router.get("/status")
async def discord_status():
"""Return current Discord bot status."""
from chat_bridge.vendors.discord import discord_bot
return discord_bot.status().to_dict()
@router.post("/join")
async def join_from_image(
image: Optional[UploadFile] = File(None),
invite_url: Optional[str] = Form(None),
):
"""Extract a Discord invite from a screenshot or text and validate it.
Accepts either:
- An uploaded image (screenshot of invite or QR code)
- A plain text invite URL
The bot validates the invite and returns the OAuth2 URL for the
server admin to authorize the bot.
"""
from chat_bridge.invite_parser import invite_parser
from chat_bridge.vendors.discord import discord_bot
invite_info = None
# Try image first
if image and image.filename:
image_data = await image.read()
if image_data:
invite_info = await invite_parser.parse_image(image_data)
# Fall back to text
if not invite_info and invite_url:
invite_info = invite_parser.parse_text(invite_url)
if not invite_info:
return {
"ok": False,
"error": (
"No Discord invite found. "
"Paste a screenshot with a visible invite link or QR code, "
"or enter the invite URL directly."
),
}
# Validate the invite
valid = await discord_bot.join_from_invite(invite_info.code)
result = {
"ok": True,
"invite": {
"code": invite_info.code,
"url": invite_info.url,
"source": invite_info.source,
"platform": invite_info.platform,
},
"validated": valid,
}
# Include OAuth2 URL if bot is connected
oauth_url = discord_bot.get_oauth2_url()
if oauth_url:
result["oauth2_url"] = oauth_url
result["message"] = (
"Invite validated. Share this OAuth2 URL with the server admin "
"to add Timmy to the server."
)
else:
result["message"] = (
"Invite found but bot is not connected. "
"Configure a bot token first via /discord/setup."
)
return result
@router.get("/oauth-url")
async def discord_oauth_url():
"""Get the bot's OAuth2 authorization URL for adding to servers."""
from chat_bridge.vendors.discord import discord_bot
url = discord_bot.get_oauth2_url()
if url:
return {"ok": True, "url": url}
return {
"ok": False,
"error": "Bot is not connected. Configure a token first.",
}