feat(channels): add Discord channel (Exa search + public Invite API) (#230)
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>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user