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

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 

11 

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 

17 

18logger = logging.getLogger('hevolve_social') 

19 

20SUPPORTED_PLATFORMS = ['santaclaw', 'openclaw', 'communitybook', 'a2a', 'generic'] 

21 

22 

23class ExternalBotRegistry: 

24 """Registry for external bots (SantaClaw, OpenClaw, communitybook) that connect to HevolveSocial.""" 

25 

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") 

35 

36 from .agent_naming import validate_agent_name, generate_agent_name 

37 agent_id = f"ext_{platform}_{bot_id}" 

38 

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] 

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 

56 

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 

71 

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() 

79 

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() 

84 

85 

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}" 

95 

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}'} 

109 

110 results.append(result) 

111 except Exception as e: 

112 results.append({'action': action_type, 'status': 'error', 'error': str(e)}) 

113 

114 bot_user.last_active_at = datetime.utcnow() 

115 return results 

116 

117 

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") 

122 

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} 

131 

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 ) 

142 

143 on_new_post(post.to_dict(include_author=True)) 

144 return {'action': 'post', 'status': 'created', 'id': post.id} 

145 

146 

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") 

152 

153 post = PostService.get_by_id(db, post_id) 

154 if not post: 

155 raise ValueError(f"Post {post_id} not found") 

156 

157 comment = CommentService.create( 

158 db, post, bot_user, content, 

159 parent_id=action.get('parent_id'), 

160 ) 

161 

162 on_new_comment(comment.to_dict(include_author=True), post_id) 

163 return {'action': 'comment', 'status': 'created', 'id': comment.id} 

164 

165 

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)") 

174 

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)} 

178 

179 

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") 

184 

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") 

190 

191 created = FollowService.follow(db, bot_user, target.id) 

192 return {'action': 'follow', 'status': 'followed' if created else 'already_following'} 

193 

194 

195# ─── Outbound: Discover SantaClaw/OpenClaw agents ─── 

196 

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 

222 

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 

238 

239 except Exception as e: 

240 logger.debug(f"SantaClaw discovery failed for {gateway_url}: {e}") 

241 

242 return agents 

243 

244 

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)} 

263 

264 

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", "")}') 

275 

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