diff --git a/connectonion/__init__.py b/connectonion/__init__.py index 4f02d71..b1b79e9 100644 --- a/connectonion/__init__.py +++ b/connectonion/__init__.py @@ -40,7 +40,7 @@ from .useful_tools import ( send_email, get_emails, mark_read, mark_unread, Memory, Gmail, GoogleCalendar, Outlook, MicrosoftCalendar, - WebFetch, Shell, bash, DiffWriter, MODE_NORMAL, MODE_AUTO, MODE_PLAN, + WebFetch, GatherIs, Shell, bash, DiffWriter, MODE_NORMAL, MODE_AUTO, MODE_PLAN, pick, yes_no, autocomplete, TodoList, SlashCommand, # Claude Code-style file tools read_file, edit, multi_edit, glob, grep, write, FileWriter, @@ -74,6 +74,7 @@ "Outlook", "MicrosoftCalendar", "WebFetch", + "GatherIs", "Shell", "bash", "DiffWriter", diff --git a/connectonion/useful_tools/__init__.py b/connectonion/useful_tools/__init__.py index 2b59bd1..32968d5 100644 --- a/connectonion/useful_tools/__init__.py +++ b/connectonion/useful_tools/__init__.py @@ -16,6 +16,7 @@ from .outlook import Outlook from .microsoft_calendar import MicrosoftCalendar from .web_fetch import WebFetch +from .gatheris import GatherIs from .shell import Shell from .bash import bash from .diff_writer import DiffWriter, MODE_NORMAL, MODE_AUTO, MODE_PLAN @@ -46,6 +47,7 @@ "Outlook", "MicrosoftCalendar", "WebFetch", + "GatherIs", "Shell", "bash", "DiffWriter", diff --git a/connectonion/useful_tools/gatheris.py b/connectonion/useful_tools/gatheris.py new file mode 100644 index 0000000..37ae19d --- /dev/null +++ b/connectonion/useful_tools/gatheris.py @@ -0,0 +1,362 @@ +""" +Purpose: Gather.is social network integration — browse feed, discover agents, post, and comment +LLM-Note: + Dependencies: imports from [os, json, base64, hashlib, requests, nacl.signing] | imported by [useful_tools/__init__.py] | no test file yet + Data flow: Agent calls GatherIs methods → public endpoints (feed, agents) need no auth → posting/commenting authenticates via Ed25519 challenge-response then solves PoW → returns formatted strings for display + State/Effects: makes HTTP requests to gather.is API | caches auth token in memory (not persisted) | no local file writes + Integration: exposes GatherIs class with browse_feed(), discover_agents(), post(), comment() | used as agent tool via Agent(tools=[GatherIs()]) + Performance: network I/O per request | auth token cached per session | PoW solving is CPU-bound (typically <5s) + Errors: returns error strings for display | no exceptions raised from public methods + +Gather.is social network tool for agents. + +Gather.is is a social network designed for AI agents — a shared space where agents +can post updates, discover other agents, and discuss topics. + +Usage: + from connectonion import Agent, GatherIs + + gatheris = GatherIs() # Uses GATHERIS_PRIVATE_KEY env var or key file + agent = Agent("researcher", tools=[gatheris]) + + # Agent can now use: + # - browse_feed(limit, sort) - Read the public feed + # - discover_agents(limit) - See who's on the platform + # - post(title, summary, body, tags) - Publish a post (requires auth + PoW) + # - comment(post_id, body) - Comment on a post (requires auth) +""" + +import os +import json +import base64 +import hashlib +import requests +from pathlib import Path +from typing import List, Optional + + +# PKCS8 Ed25519 private key DER prefix (16 bytes before the 32-byte raw key) +_PKCS8_ED25519_PREFIX = bytes.fromhex("302e020100300506032b657004220420") + + +def _load_ed25519_key(pem_path: str) -> Optional[bytes]: + """Extract raw 32-byte Ed25519 private key from a PEM file. + + Handles standard PKCS8 PEM format (BEGIN PRIVATE KEY). + Returns None if the file doesn't exist or can't be parsed. + """ + path = Path(pem_path).expanduser() + if not path.exists(): + return None + try: + pem_text = path.read_text().strip() + # Strip PEM headers and decode base64 + lines = [ + line for line in pem_text.splitlines() + if not line.startswith("-----") + ] + der_bytes = base64.b64decode("".join(lines)) + # PKCS8 Ed25519: 16-byte prefix + 32-byte key = 48 bytes + if len(der_bytes) == 48 and der_bytes[:16] == _PKCS8_ED25519_PREFIX: + return der_bytes[16:] + return None + except Exception: + return None + + +def _get_public_key_pem(signing_key) -> str: + """Get the PEM-encoded public key from a nacl SigningKey.""" + raw_public = bytes(signing_key.verify_key) + # Ed25519 SPKI DER: fixed 12-byte prefix + 32-byte public key + spki_prefix = bytes.fromhex("302a300506032b6570032100") + der = spki_prefix + raw_public + b64 = base64.b64encode(der).decode() + return f"-----BEGIN PUBLIC KEY-----\n{b64}\n-----END PUBLIC KEY-----" + + +class GatherIs: + """Gather.is social network integration for agents. + + Allows agents to browse the feed, discover other agents, + post content, and comment on discussions. + """ + + def __init__( + self, + private_key_path: str = None, + base_url: str = None, + timeout: int = 15, + ): + """Initialize gather.is client. + + Args: + private_key_path: Path to Ed25519 private key PEM file. + Falls back to GATHERIS_PRIVATE_KEY_PATH env var, + then ~/.co/gatheris_private.pem. + base_url: API base URL (default: https://gather.is) + timeout: Request timeout in seconds (default: 15) + """ + self.base_url = ( + base_url + or os.getenv("GATHERIS_API_URL", "https://gather.is") + ).rstrip("/") + self.timeout = timeout + self._token: Optional[str] = None + self._signing_key = None + + # Try to load the signing key + key_path = ( + private_key_path + or os.getenv("GATHERIS_PRIVATE_KEY_PATH") + or str(Path.home() / ".co" / "gatheris_private.pem") + ) + raw_key = _load_ed25519_key(key_path) + if raw_key: + from nacl.signing import SigningKey + self._signing_key = SigningKey(raw_key) + + def _authenticate(self) -> Optional[str]: + """Authenticate with gather.is using Ed25519 challenge-response. + + Returns: + Bearer token string, or None on failure. + """ + if self._token: + return self._token + if not self._signing_key: + return None + + public_key_pem = _get_public_key_pem(self._signing_key) + + try: + # Step 1: Get challenge nonce + resp = requests.post( + f"{self.base_url}/api/agents/challenge", + json={"public_key": public_key_pem}, + timeout=self.timeout, + ) + resp.raise_for_status() + nonce_b64 = resp.json()["nonce"] + + # Step 2: Base64-decode nonce, then sign raw bytes + nonce_bytes = base64.b64decode(nonce_b64) + signed = self._signing_key.sign(nonce_bytes) + signature_b64 = base64.b64encode(signed.signature).decode() + + # Step 3: Exchange signature for token + resp = requests.post( + f"{self.base_url}/api/agents/authenticate", + json={ + "public_key": public_key_pem, + "signature": signature_b64, + }, + timeout=self.timeout, + ) + resp.raise_for_status() + self._token = resp.json().get("token") + return self._token + + except Exception: + return None + + def _solve_pow(self) -> Optional[dict]: + """Request and solve a proof-of-work challenge. + + Returns: + Dict with pow_challenge and pow_nonce, or None on failure. + """ + try: + resp = requests.post( + f"{self.base_url}/api/pow/challenge", + json={"purpose": "post"}, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + challenge = data["challenge"] + difficulty = data["difficulty"] + + # Brute-force: find nonce where SHA-256(challenge:nonce) has + # leading `difficulty` zero bits + for nonce in range(50_000_000): + hash_input = f"{challenge}:{nonce}".encode() + hash_bytes = hashlib.sha256(hash_input).digest() + # Check leading zero bits + bits = int.from_bytes(hash_bytes[:4], "big") + if bits >> (32 - difficulty) == 0: + return {"pow_challenge": challenge, "pow_nonce": str(nonce)} + + return None # Exhausted attempts + except Exception: + return None + + def browse_feed(self, limit: int = 25, sort: str = "recent") -> str: + """Browse the gather.is public feed. + + Args: + limit: Number of posts to retrieve (default: 25, max: 50) + sort: Sort order — "recent" or "hot" (default: recent) + + Returns: + Formatted list of posts with title, author, tags, and summary + """ + try: + resp = requests.get( + f"{self.base_url}/api/posts", + params={"limit": min(limit, 50), "sort": sort}, + timeout=self.timeout, + ) + resp.raise_for_status() + posts = resp.json().get("posts", []) + except Exception as e: + return f"Error fetching feed: {e}" + + if not posts: + return "No posts found on gather.is." + + lines = [f"gather.is feed ({sort}, {len(posts)} posts):\n"] + for i, post in enumerate(posts, 1): + tags = ", ".join(post.get("tags", [])) + lines.append( + f"{i}. [{post.get('id', '?')}] {post.get('title', 'Untitled')}\n" + f" By: {post.get('author', 'unknown')} | " + f"Score: {post.get('score', 0)} | " + f"Comments: {post.get('comment_count', 0)} | " + f"Tags: {tags}\n" + f" {post.get('summary', '')}\n" + ) + return "\n".join(lines) + + def discover_agents(self, limit: int = 20) -> str: + """Discover agents registered on gather.is. + + Args: + limit: Number of agents to retrieve (default: 20) + + Returns: + Formatted list of agents with name, post count, and verification status + """ + try: + resp = requests.get( + f"{self.base_url}/api/agents", + params={"limit": min(limit, 50)}, + timeout=self.timeout, + ) + resp.raise_for_status() + agents = resp.json().get("agents", []) + except Exception as e: + return f"Error fetching agents: {e}" + + if not agents: + return "No agents found on gather.is." + + lines = [f"gather.is agents ({len(agents)} found):\n"] + for agent in agents: + verified = " [verified]" if agent.get("verified") else "" + lines.append( + f"- {agent.get('name', 'unnamed')}{verified} | " + f"Posts: {agent.get('post_count', 0)} | " + f"Joined: {agent.get('created', 'unknown')}" + ) + return "\n".join(lines) + + def post( + self, + title: str, + summary: str, + body: str, + tags: List[str], + ) -> str: + """Create a new post on gather.is. + + Requires Ed25519 private key to be configured. Solves a + proof-of-work challenge before posting (anti-spam). + + Args: + title: Post title (max 200 characters) + summary: Brief summary shown in feeds (max 500 characters) + body: Full post content (max 10000 characters) + tags: List of 1-5 topic tags + + Returns: + Success message with post ID, or error description + """ + token = self._authenticate() + if not token: + return ( + "Error: Not authenticated. Ensure your Ed25519 private key " + "is at GATHERIS_PRIVATE_KEY_PATH or ~/.co/gatheris_private.pem" + ) + + pow_result = self._solve_pow() + if not pow_result: + return "Error: Failed to solve proof-of-work challenge." + + try: + resp = requests.post( + f"{self.base_url}/api/posts", + headers={"Authorization": f"Bearer {token}"}, + json={ + "title": title[:200], + "summary": summary[:500], + "body": body[:10000], + "tags": tags[:5], + **pow_result, + }, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + post_id = data.get("id", "unknown") + return f"Posted successfully. Post ID: {post_id}" + + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response else "?" + detail = "" + try: + detail = e.response.json().get("detail", "") + except Exception: + pass + return f"Error posting (HTTP {status}): {detail or str(e)}" + except Exception as e: + return f"Error posting: {e}" + + def comment(self, post_id: str, body: str) -> str: + """Add a comment to a post on gather.is. + + Requires Ed25519 private key to be configured. + + Args: + post_id: ID of the post to comment on + body: Comment text + + Returns: + Success message or error description + """ + token = self._authenticate() + if not token: + return ( + "Error: Not authenticated. Ensure your Ed25519 private key " + "is at GATHERIS_PRIVATE_KEY_PATH or ~/.co/gatheris_private.pem" + ) + + try: + resp = requests.post( + f"{self.base_url}/api/posts/{post_id}/comments", + headers={"Authorization": f"Bearer {token}"}, + json={"body": body}, + timeout=self.timeout, + ) + resp.raise_for_status() + return f"Comment added to post {post_id}." + + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response else "?" + detail = "" + try: + detail = e.response.json().get("detail", "") + except Exception: + pass + return f"Error commenting (HTTP {status}): {detail or str(e)}" + except Exception as e: + return f"Error commenting: {e}" diff --git a/tests/unit/test_gatheris.py b/tests/unit/test_gatheris.py new file mode 100644 index 0000000..94fdeb1 --- /dev/null +++ b/tests/unit/test_gatheris.py @@ -0,0 +1,395 @@ +"""Unit tests for connectonion/useful_tools/gatheris.py + +Tests cover: +- GatherIs initialization and configuration +- browse_feed: public feed retrieval +- discover_agents: agent listing +- post: authenticated posting with PoW +- comment: authenticated commenting +- _authenticate: Ed25519 challenge-response flow +- _solve_pow: hashcash proof-of-work solving +""" + +import pytest +import base64 +import hashlib +from unittest.mock import Mock, patch, MagicMock + +from connectonion.useful_tools.gatheris import GatherIs, _load_ed25519_key, _PKCS8_ED25519_PREFIX + + +SAMPLE_FEED_RESPONSE = { + "posts": [ + { + "id": "post-1", + "title": "Test Post", + "summary": "A test post about agents", + "author": "test-agent", + "score": 5, + "comment_count": 2, + "tags": ["agents", "test"], + "created": "2026-02-13T12:00:00Z", + }, + { + "id": "post-2", + "title": "Another Post", + "summary": "Another post", + "author": "other-agent", + "score": 3, + "comment_count": 0, + "tags": ["discussion"], + "created": "2026-02-13T11:00:00Z", + }, + ], + "total": 2, +} + +SAMPLE_AGENTS_RESPONSE = { + "agents": [ + { + "agent_id": "agent-1", + "name": "test-agent", + "verified": True, + "post_count": 10, + "created": "2026-01-01", + }, + { + "agent_id": "agent-2", + "name": "other-agent", + "verified": False, + "post_count": 3, + "created": "2026-02-01", + }, + ], + "total": 2, +} + + +class TestGatherIsInit: + """Tests for GatherIs initialization.""" + + def test_default_base_url(self): + """Test default base URL is gather.is.""" + gis = GatherIs() + assert gis.base_url == "https://gather.is" + + def test_custom_base_url(self): + """Test custom base URL.""" + gis = GatherIs(base_url="https://staging.gather.is") + assert gis.base_url == "https://staging.gather.is" + + def test_base_url_strips_trailing_slash(self): + """Test that trailing slash is removed from base URL.""" + gis = GatherIs(base_url="https://gather.is/") + assert gis.base_url == "https://gather.is" + + def test_default_timeout(self): + """Test default timeout is 15 seconds.""" + gis = GatherIs() + assert gis.timeout == 15 + + def test_custom_timeout(self): + """Test custom timeout.""" + gis = GatherIs(timeout=30) + assert gis.timeout == 30 + + def test_no_signing_key_without_key_file(self): + """Test that signing key is None when no key file exists.""" + gis = GatherIs(private_key_path="/nonexistent/path.pem") + assert gis._signing_key is None + + @patch.dict("os.environ", {"GATHERIS_API_URL": "https://custom.gather.is"}) + def test_base_url_from_env(self): + """Test base URL from environment variable.""" + gis = GatherIs() + assert gis.base_url == "https://custom.gather.is" + + +class TestBrowseFeed: + """Tests for browse_feed method.""" + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_browse_feed_success(self, mock_get): + """Test successful feed retrieval.""" + mock_response = Mock() + mock_response.json.return_value = SAMPLE_FEED_RESPONSE + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + result = gis.browse_feed() + + assert "Test Post" in result + assert "Another Post" in result + assert "test-agent" in result + assert "post-1" in result + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_browse_feed_with_params(self, mock_get): + """Test feed retrieval passes limit and sort.""" + mock_response = Mock() + mock_response.json.return_value = SAMPLE_FEED_RESPONSE + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + gis.browse_feed(limit=10, sort="hot") + + call_kwargs = mock_get.call_args + assert call_kwargs[1]["params"]["limit"] == 10 + assert call_kwargs[1]["params"]["sort"] == "hot" + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_browse_feed_caps_limit(self, mock_get): + """Test that limit is capped at 50.""" + mock_response = Mock() + mock_response.json.return_value = SAMPLE_FEED_RESPONSE + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + gis.browse_feed(limit=100) + + call_kwargs = mock_get.call_args + assert call_kwargs[1]["params"]["limit"] == 50 + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_browse_feed_empty(self, mock_get): + """Test feed with no posts.""" + mock_response = Mock() + mock_response.json.return_value = {"posts": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + result = gis.browse_feed() + + assert "No posts found" in result + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_browse_feed_error(self, mock_get): + """Test feed retrieval error handling.""" + mock_get.side_effect = Exception("Connection refused") + + gis = GatherIs() + result = gis.browse_feed() + + assert "Error" in result + + +class TestDiscoverAgents: + """Tests for discover_agents method.""" + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_discover_agents_success(self, mock_get): + """Test successful agent discovery.""" + mock_response = Mock() + mock_response.json.return_value = SAMPLE_AGENTS_RESPONSE + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + result = gis.discover_agents() + + assert "test-agent" in result + assert "[verified]" in result + assert "other-agent" in result + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_discover_agents_empty(self, mock_get): + """Test empty agent list.""" + mock_response = Mock() + mock_response.json.return_value = {"agents": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + gis = GatherIs() + result = gis.discover_agents() + + assert "No agents found" in result + + @patch("connectonion.useful_tools.gatheris.requests.get") + def test_discover_agents_error(self, mock_get): + """Test agent discovery error handling.""" + mock_get.side_effect = Exception("Timeout") + + gis = GatherIs() + result = gis.discover_agents() + + assert "Error" in result + + +class TestPost: + """Tests for post method.""" + + def test_post_without_auth(self): + """Test that post fails without authentication.""" + gis = GatherIs(private_key_path="/nonexistent.pem") + result = gis.post("Title", "Summary", "Body", ["test"]) + + assert "Error" in result + assert "Not authenticated" in result + + @patch("connectonion.useful_tools.gatheris.requests.post") + def test_post_success(self, mock_post): + """Test successful post with mocked auth and PoW.""" + gis = GatherIs() + gis._token = "test-token" # Skip auth + + # Mock PoW challenge and post responses + pow_response = Mock() + pow_response.json.return_value = {"challenge": "abc", "difficulty": 1} + pow_response.raise_for_status = Mock() + + post_response = Mock() + post_response.json.return_value = {"id": "new-post-123"} + post_response.raise_for_status = Mock() + + mock_post.side_effect = [pow_response, post_response] + + result = gis.post("Title", "Summary", "Body", ["test"]) + + assert "Posted successfully" in result + assert "new-post-123" in result + + +class TestComment: + """Tests for comment method.""" + + def test_comment_without_auth(self): + """Test that comment fails without authentication.""" + gis = GatherIs(private_key_path="/nonexistent.pem") + result = gis.comment("post-1", "Great post!") + + assert "Error" in result + assert "Not authenticated" in result + + @patch("connectonion.useful_tools.gatheris.requests.post") + def test_comment_success(self, mock_post): + """Test successful comment with mocked auth.""" + gis = GatherIs() + gis._token = "test-token" # Skip auth + + mock_response = Mock() + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + result = gis.comment("post-1", "Great post!") + + assert "Comment added" in result + assert "post-1" in result + + +class TestAuthenticate: + """Tests for _authenticate method.""" + + def test_authenticate_returns_none_without_key(self): + """Test that auth returns None without signing key.""" + gis = GatherIs(private_key_path="/nonexistent.pem") + assert gis._authenticate() is None + + def test_authenticate_returns_cached_token(self): + """Test that cached token is returned.""" + gis = GatherIs() + gis._token = "cached-token" + assert gis._authenticate() == "cached-token" + + +class TestSolvePoW: + """Tests for _solve_pow method.""" + + @patch("connectonion.useful_tools.gatheris.requests.post") + def test_solve_pow_finds_solution(self, mock_post): + """Test that PoW solver finds a valid nonce.""" + # Use difficulty=1 (very easy) for fast test + mock_response = Mock() + mock_response.json.return_value = { + "challenge": "test-challenge", + "difficulty": 1, + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + gis = GatherIs() + result = gis._solve_pow() + + assert result is not None + assert "pow_challenge" in result + assert "pow_nonce" in result + assert result["pow_challenge"] == "test-challenge" + + # Verify the solution is actually valid + nonce = result["pow_nonce"] + hash_bytes = hashlib.sha256( + f"test-challenge:{nonce}".encode() + ).digest() + first_bits = int.from_bytes(hash_bytes[:4], "big") + assert first_bits >> 31 == 0 # difficulty=1 means 1 leading zero bit + + @patch("connectonion.useful_tools.gatheris.requests.post") + def test_solve_pow_error(self, mock_post): + """Test PoW solver handles errors.""" + mock_post.side_effect = Exception("Connection refused") + + gis = GatherIs() + result = gis._solve_pow() + + assert result is None + + +class TestLoadEd25519Key: + """Tests for _load_ed25519_key helper.""" + + def test_load_nonexistent_file(self): + """Test loading from nonexistent file returns None.""" + assert _load_ed25519_key("/nonexistent/path.pem") is None + + def test_load_valid_pem(self, tmp_path): + """Test loading a valid Ed25519 PEM key.""" + # Create a valid PKCS8 Ed25519 PEM (48 bytes DER) + raw_key = b"\x01" * 32 # Dummy 32-byte key + der = _PKCS8_ED25519_PREFIX + raw_key + b64 = base64.b64encode(der).decode() + pem = f"-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----" + + pem_file = tmp_path / "test_key.pem" + pem_file.write_text(pem) + + result = _load_ed25519_key(str(pem_file)) + assert result == raw_key + + def test_load_invalid_pem(self, tmp_path): + """Test loading an invalid PEM returns None.""" + pem_file = tmp_path / "bad_key.pem" + pem_file.write_text("not a valid pem file") + + result = _load_ed25519_key(str(pem_file)) + assert result is None + + +class TestGatherIsIntegration: + """Integration test: GatherIs as an agent tool.""" + + def test_gatheris_integrates_with_agent(self): + """Test that GatherIs can be used as an agent tool.""" + from connectonion import Agent + from connectonion.core.llm import LLMResponse + from connectonion.core.usage import TokenUsage + + mock_llm = Mock() + mock_llm.model = "test-model" + mock_llm.complete.return_value = LLMResponse( + content="Test response", + tool_calls=[], + raw_response=None, + usage=TokenUsage(), + ) + + gis = GatherIs() + agent = Agent("test", llm=mock_llm, tools=[gis], log=False) + + # Verify tools are registered + assert "browse_feed" in agent.tools + assert "discover_agents" in agent.tools + assert "post" in agent.tools + assert "comment" in agent.tools