From 66883060bcb0f64efedc9d212210e0b77dc2f0a2 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:16:15 +0800 Subject: [PATCH] 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 --- agent_reach/channels/__init__.py | 2 + agent_reach/channels/discord.py | 97 ++++++++++++++++++++++++++++++++ tests/test_discord_channel.py | 87 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 agent_reach/channels/discord.py create mode 100644 tests/test_discord_channel.py diff --git a/agent_reach/channels/__init__.py b/agent_reach/channels/__init__.py index 4893590..5f8d5d8 100644 --- a/agent_reach/channels/__init__.py +++ b/agent_reach/channels/__init__.py @@ -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(), diff --git a/agent_reach/channels/discord.py b/agent_reach/channels/discord.py new file mode 100644 index 0000000..35cb83a --- /dev/null +++ b/agent_reach/channels/discord.py @@ -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 diff --git a/tests/test_discord_channel.py b/tests/test_discord_channel.py new file mode 100644 index 0000000..18fa939 --- /dev/null +++ b/tests/test_discord_channel.py @@ -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