66883060bc
Zero-config channel (tier=0). Uses Discord Invite API to read public server info (no auth), and Exa via mcporter for content search. Tested: discord.gg/python returns 419K members, description, channel. 104 tests passing. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
98 lines
3.8 KiB
Python
98 lines
3.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Discord channel — search via Exa, read public server info via Invite API."""
|
||
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import urllib.request
|
||
from typing import Tuple
|
||
|
||
from .base import Channel
|
||
|
||
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||
_INVITE_API = "https://discord.com/api/v10/invites/{code}?with_counts=true"
|
||
|
||
|
||
def _exa_available() -> bool:
|
||
mcporter = shutil.which("mcporter")
|
||
if not mcporter:
|
||
return False
|
||
try:
|
||
r = subprocess.run(
|
||
[mcporter, "config", "list"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
return "exa" in r.stdout.lower()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _get_invite_code(url: str) -> str:
|
||
"""Extract invite code from discord.gg/xxx or discord.com/invite/xxx URLs."""
|
||
url = url.rstrip("/")
|
||
for prefix in ("discord.gg/", "discord.com/invite/"):
|
||
if prefix in url:
|
||
return url.split(prefix)[-1].split("/")[0].split("?")[0]
|
||
return ""
|
||
|
||
|
||
class DiscordChannel(Channel):
|
||
name = "discord"
|
||
description = "Discord 服务器信息与内容搜索"
|
||
backends = ["Exa via mcporter (搜索)", "Discord Invite API (服务器信息)"]
|
||
tier = 0
|
||
|
||
def can_handle(self, url: str) -> bool:
|
||
return "discord.gg" in url or "discord.com" in url
|
||
|
||
def check(self, config=None) -> Tuple[str, str]:
|
||
if _exa_available():
|
||
return "ok", "Discord 可用:Exa 搜索内容,Invite API 读取公开服务器信息"
|
||
return "warn", (
|
||
"Discord Invite API 可用(无需配置),但搜索功能需要 mcporter + Exa MCP。"
|
||
"运行 `agent-reach install --env=auto` 安装 Exa。"
|
||
)
|
||
|
||
def read(self, url: str) -> str:
|
||
"""读取 Discord 服务器公开信息(Invite API)或通过 Jina 读取页面。"""
|
||
code = _get_invite_code(url)
|
||
if code:
|
||
api_url = _INVITE_API.format(code=code)
|
||
req = urllib.request.Request(api_url, headers={"User-Agent": _UA})
|
||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
guild = data.get("guild", {})
|
||
channel = data.get("channel", {})
|
||
members = data.get("approximate_member_count", "未知")
|
||
online = data.get("approximate_presence_count", "未知")
|
||
return (
|
||
f"# {guild.get('name', '未知服务器')}\n\n"
|
||
f"**描述**: {guild.get('description') or '无'}\n"
|
||
f"**成员**: {members:,} 人(在线 {online:,})\n"
|
||
f"**频道**: #{channel.get('name', '未知')}\n"
|
||
f"**邀请链接**: {url}\n"
|
||
)
|
||
# Fallback: Jina Reader
|
||
if not url.startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
jina_url = f"https://r.jina.ai/{url}"
|
||
req = urllib.request.Request(
|
||
jina_url, headers={"User-Agent": _UA, "Accept": "text/plain"}
|
||
)
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
return resp.read().decode("utf-8")
|
||
|
||
def search(self, query: str, limit: int = 5) -> str:
|
||
"""通过 Exa 搜索 Discord 内容(服务器、帖子、讨论)。"""
|
||
mcporter = shutil.which("mcporter")
|
||
if not mcporter:
|
||
return "搜索需要 mcporter,请运行 `npm install -g mcporter` 安装。"
|
||
cmd = (
|
||
f"mcporter call 'exa.web_search_exa("
|
||
f"query: \"{query} site:discord.com OR site:discord.gg\", "
|
||
f"numResults: {limit}, "
|
||
f"includeDomains: [\"discord.com\", \"discord.gg\"])'"
|
||
)
|
||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||
return r.stdout or r.stderr
|