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 .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(),
|
||||||
|
|||||||
@@ -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