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:
@@ -24,6 +24,7 @@ from .xiaoyuzhou import XiaoyuzhouChannel
|
||||
from .v2ex import V2EXChannel
|
||||
from .xueqiu import XueqiuChannel
|
||||
from .toutiao import ToutiaoChannel
|
||||
from .discord import DiscordChannel
|
||||
|
||||
|
||||
ALL_CHANNELS: List[Channel] = [
|
||||
@@ -41,6 +42,7 @@ ALL_CHANNELS: List[Channel] = [
|
||||
V2EXChannel(),
|
||||
XueqiuChannel(),
|
||||
ToutiaoChannel(),
|
||||
DiscordChannel(),
|
||||
RSSChannel(),
|
||||
ExaSearchChannel(),
|
||||
WebChannel(),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user