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:
Pnant
2026-03-31 15:16:15 +08:00
committed by GitHub
parent f4b3758478
commit 66883060bc
3 changed files with 186 additions and 0 deletions
+2
View File
@@ -24,6 +24,7 @@ from .xiaoyuzhou import XiaoyuzhouChannel
from .v2ex import V2EXChannel from .v2ex import V2EXChannel
from .xueqiu import XueqiuChannel from .xueqiu import XueqiuChannel
from .toutiao import ToutiaoChannel from .toutiao import ToutiaoChannel
from .discord import DiscordChannel
ALL_CHANNELS: List[Channel] = [ ALL_CHANNELS: List[Channel] = [
@@ -41,6 +42,7 @@ ALL_CHANNELS: List[Channel] = [
V2EXChannel(), V2EXChannel(),
XueqiuChannel(), XueqiuChannel(),
ToutiaoChannel(), ToutiaoChannel(),
DiscordChannel(),
RSSChannel(), RSSChannel(),
ExaSearchChannel(), ExaSearchChannel(),
WebChannel(), WebChannel(),
+97
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Tests for DiscordChannel."""
from unittest.mock import MagicMock, patch
import pytest
from agent_reach.channels.discord import DiscordChannel, _get_invite_code
@pytest.fixture
def ch():
return DiscordChannel()
class TestDiscordChannelAttributes:
def test_name(self, ch):
assert ch.name == "discord"
def test_tier(self, ch):
assert ch.tier == 0
def test_backends(self, ch):
assert ch.backends
class TestDiscordCanHandle:
def test_discord_gg(self, ch):
assert ch.can_handle("https://discord.gg/python")
def test_discord_com(self, ch):
assert ch.can_handle("https://discord.com/invite/rust")
def test_discord_channel_url(self, ch):
assert ch.can_handle("https://discord.com/channels/123/456")
def test_rejects_other(self, ch):
assert not ch.can_handle("https://www.slack.com")
class TestGetInviteCode:
def test_discord_gg(self):
assert _get_invite_code("https://discord.gg/python") == "python"
def test_discord_com_invite(self):
assert _get_invite_code("https://discord.com/invite/rust-lang") == "rust-lang"
def test_trailing_slash(self):
assert _get_invite_code("https://discord.gg/python/") == "python"
def test_no_invite_code(self):
assert _get_invite_code("https://discord.com/channels/123/456") == ""
class TestDiscordCheck:
def test_check_ok_with_exa(self, ch):
with patch("agent_reach.channels.discord._exa_available", return_value=True):
status, msg = ch.check()
assert status == "ok"
assert "Exa" in msg
def test_check_warn_without_exa(self, ch):
with patch("agent_reach.channels.discord._exa_available", return_value=False):
status, msg = ch.check()
assert status == "warn"
assert "Invite API" in msg
class TestDiscordRead:
def test_read_invite_url(self, ch):
mock_data = {
"guild": {"name": "Python", "description": "The Python community"},
"channel": {"name": "general"},
"approximate_member_count": 400000,
"approximate_presence_count": 30000,
}
mock_resp = MagicMock()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_resp.read.return_value = __import__("json").dumps(mock_data).encode()
with patch("urllib.request.urlopen", return_value=mock_resp):
result = ch.read("https://discord.gg/python")
assert "Python" in result
assert "400,000" in result
assert "general" in result