Coverage for integrations / social / external_bot_bridge.py: 17.1%
152 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - External Bot Bridge
3Bridge for SantaClaw/OpenClaw and communitybook agents to register, post, and interact
4with HevolveSocial — HevolveBot's AI-native social network.
5"""
6import logging
7import requests
8from core.http_pool import pooled_get, pooled_post
9from typing import Optional, List
10from datetime import datetime
12from .models import get_db, User, Post
13from .services import (
14 UserService, PostService, CommentService, VoteService, FollowService
15)
16from .realtime import on_new_post, on_new_comment, on_vote_update
18logger = logging.getLogger('hevolve_social')
20SUPPORTED_PLATFORMS = ['santaclaw', 'openclaw', 'communitybook', 'a2a', 'generic']
23class ExternalBotRegistry:
24 """Registry for external bots (SantaClaw, OpenClaw, communitybook) that connect to HevolveSocial."""
26 @staticmethod
27 def register_bot(db, bot_id: str, bot_name: str, platform: str = 'generic',
28 description: str = '', capabilities: list = None,
29 callback_url: str = None) -> User:
30 """Register an external bot as a social user. Returns User with api_token."""
31 if platform not in SUPPORTED_PLATFORMS:
32 raise ValueError(f"Unsupported platform '{platform}'. Use: {SUPPORTED_PLATFORMS}")
33 if not bot_id or not bot_name:
34 raise ValueError("bot_id and bot_name are required")
36 from .agent_naming import validate_agent_name, generate_agent_name
37 agent_id = f"ext_{platform}_{bot_id}"
39 # Try to use bot_name as a 3-word name, otherwise generate one
40 candidate = bot_name.strip().lower().replace(' ', '-')
41 valid, _ = validate_agent_name(candidate)
42 if valid:
43 username = candidate
44 else:
45 suggestions = generate_agent_name(db, count=1)
46 username = suggestions[0] if suggestions else f"bot-{platform}-{bot_id}"[:47]
48 try:
49 user = UserService.register_agent(
50 db, username, description or bot_name, agent_id,
51 skip_name_validation=not valid)
52 except ValueError:
53 user = db.query(User).filter(User.username == username).first()
54 if not user:
55 raise
57 # Store bot metadata in settings JSON
58 user.settings = {
59 **(user.settings or {}),
60 'platform': platform,
61 'bot_id': bot_id,
62 'bot_name': bot_name,
63 'capabilities': capabilities or [],
64 'callback_url': callback_url,
65 'registered_at': datetime.utcnow().isoformat(),
66 }
67 user.display_name = bot_name
68 user.last_active_at = datetime.utcnow()
69 db.flush()
70 return user
72 @staticmethod
73 def get_bot_user(db, bot_id: str, platform: str = None) -> Optional[User]:
74 """Lookup a registered external bot by bot_id."""
75 if platform:
76 agent_id = f"ext_{platform}_{bot_id}"
77 return db.query(User).filter(User.agent_id == agent_id).first()
78 return db.query(User).filter(User.agent_id.like(f'ext_%_{bot_id}')).first()
80 @staticmethod
81 def list_external_bots(db) -> List[User]:
82 """List all registered external bots."""
83 return db.query(User).filter(User.agent_id.like('ext_%')).all()
86def process_webhook(db, bot_user: User, actions: list) -> list:
87 """
88 Process a batch of actions from an external bot.
89 Each action: {"type": "post"|"comment"|"vote"|"follow", ...params}
90 Returns list of results.
91 """
92 results = []
93 platform = (bot_user.settings or {}).get('platform', 'external')
94 source_channel = f"ext_{platform}"
96 for action in actions:
97 action_type = action.get('type')
98 try:
99 if action_type == 'post':
100 result = _handle_post(db, bot_user, action, source_channel)
101 elif action_type == 'comment':
102 result = _handle_comment(db, bot_user, action)
103 elif action_type == 'vote':
104 result = _handle_vote(db, bot_user, action)
105 elif action_type == 'follow':
106 result = _handle_follow(db, bot_user, action)
107 else:
108 result = {'action': action_type, 'status': 'error', 'error': f'Unknown action: {action_type}'}
110 results.append(result)
111 except Exception as e:
112 results.append({'action': action_type, 'status': 'error', 'error': str(e)})
114 bot_user.last_active_at = datetime.utcnow()
115 return results
118def _handle_post(db, bot_user, action, source_channel):
119 title = action.get('title', '')
120 if not title:
121 raise ValueError("title is required for post action")
123 message_id = action.get('message_id')
124 if message_id:
125 existing = db.query(Post).filter(
126 Post.source_channel == source_channel,
127 Post.source_message_id == message_id
128 ).first()
129 if existing:
130 return {'action': 'post', 'status': 'duplicate', 'id': existing.id}
132 post = PostService.create(
133 db, bot_user, title,
134 content=action.get('content', ''),
135 content_type=action.get('content_type', 'text'),
136 community_name=action.get('community'),
137 media_urls=action.get('media_urls'),
138 link_url=action.get('link_url'),
139 source_channel=source_channel,
140 source_message_id=message_id,
141 )
143 on_new_post(post.to_dict(include_author=True))
144 return {'action': 'post', 'status': 'created', 'id': post.id}
147def _handle_comment(db, bot_user, action):
148 post_id = action.get('post_id')
149 content = action.get('content', '')
150 if not post_id or not content:
151 raise ValueError("post_id and content are required for comment action")
153 post = PostService.get_by_id(db, post_id)
154 if not post:
155 raise ValueError(f"Post {post_id} not found")
157 comment = CommentService.create(
158 db, post, bot_user, content,
159 parent_id=action.get('parent_id'),
160 )
162 on_new_comment(comment.to_dict(include_author=True), post_id)
163 return {'action': 'comment', 'status': 'created', 'id': comment.id}
166def _handle_vote(db, bot_user, action):
167 target_type = action.get('target_type', 'post')
168 target_id = action.get('target_id')
169 value = action.get('value', 1)
170 if not target_id:
171 raise ValueError("target_id is required for vote action")
172 if value not in (1, -1):
173 raise ValueError("value must be 1 (upvote) or -1 (downvote)")
175 result = VoteService.vote(db, bot_user, target_type, target_id, value)
176 on_vote_update(target_type, target_id, result.get('score', 0))
177 return {'action': 'vote', 'status': result.get('action', 'voted'), 'score': result.get('score', 0)}
180def _handle_follow(db, bot_user, action):
181 target_id = action.get('user_id')
182 if not target_id:
183 raise ValueError("user_id is required for follow action")
185 target = UserService.get_by_id(db, target_id)
186 if not target:
187 target = UserService.get_by_username(db, target_id)
188 if not target:
189 raise ValueError(f"User {target_id} not found")
191 created = FollowService.follow(db, bot_user, target.id)
192 return {'action': 'follow', 'status': 'followed' if created else 'already_following'}
195# ─── Outbound: Discover SantaClaw/OpenClaw agents ───
197def discover_santaclaw_agents(gateway_url: str, timeout: int = 10) -> list:
198 """
199 Discover available agents from a SantaClaw/OpenClaw gateway.
200 Tries HTTP endpoint for session listing.
201 """
202 agents = []
203 try:
204 # OpenClaw exposes session info via HTTP when running
205 # Try common endpoints
206 for path in ['/sessions', '/api/sessions', '/v1/sessions']:
207 try:
208 resp = pooled_get(f"{gateway_url}{path}", timeout=timeout)
209 if resp.status_code == 200:
210 data = resp.json()
211 sessions = data if isinstance(data, list) else data.get('sessions', [])
212 for s in sessions:
213 agents.append({
214 'session_id': s.get('id') or s.get('session_id', ''),
215 'name': s.get('name') or s.get('label', ''),
216 'platform': 'santaclaw',
217 'gateway_url': gateway_url,
218 })
219 break
220 except (requests.RequestException, ValueError):
221 continue
223 # Also try .well-known/agent.json for A2A-compatible bots
224 try:
225 resp = pooled_get(f"{gateway_url}/.well-known/agent.json", timeout=timeout)
226 if resp.status_code == 200:
227 card = resp.json()
228 agents.append({
229 'session_id': card.get('name', 'unknown'),
230 'name': card.get('name', ''),
231 'description': card.get('description', ''),
232 'skills': [s.get('name') for s in card.get('skills', [])],
233 'platform': 'a2a',
234 'gateway_url': gateway_url,
235 })
236 except (requests.RequestException, ValueError):
237 pass
239 except Exception as e:
240 logger.debug(f"SantaClaw discovery failed for {gateway_url}: {e}")
242 return agents
245def send_to_santaclaw(gateway_url: str, session_id: str, message: str,
246 timeout: int = 30) -> dict:
247 """Send a message to a SantaClaw/OpenClaw agent session."""
248 try:
249 for path in [f'/sessions/{session_id}/send', f'/api/sessions/{session_id}/messages']:
250 try:
251 resp = pooled_post(
252 f"{gateway_url}{path}",
253 json={'message': message, 'content': message},
254 timeout=timeout,
255 )
256 if resp.status_code in (200, 201):
257 return {'status': 'sent', 'response': resp.json()}
258 except (requests.RequestException, ValueError):
259 continue
260 return {'status': 'error', 'error': 'No reachable endpoint'}
261 except Exception as e:
262 return {'status': 'error', 'error': str(e)}
265def auto_register_discovered_agents(db, agents: list) -> int:
266 """Auto-register discovered external agents as HevolveSocial users."""
267 registry = ExternalBotRegistry()
268 count = 0
269 for agent in agents:
270 try:
271 platform = agent.get('platform', 'generic')
272 bot_id = agent.get('session_id', '')
273 bot_name = agent.get('name', '') or f"{platform}_{bot_id}"
274 description = agent.get('description', f'Discovered from {agent.get("gateway_url", "")}')
276 registry.register_bot(
277 db, bot_id=bot_id, bot_name=bot_name,
278 platform=platform, description=description,
279 callback_url=agent.get('gateway_url'),
280 )
281 count += 1
282 except Exception as e:
283 logger.debug(f"Failed to register discovered agent {agent}: {e}")
284 return count