Coverage for integrations / channels / admin / api.py: 38.4%

1400 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Admin REST API Blueprint 

3 

4Provides 100+ REST endpoints for managing all channel integration components. 

5Supports configuration, monitoring, and control of: 

6- Channels (7+ types) 

7- Queue/Pipeline 

8- Commands 

9- Automation (webhooks, cron, triggers, workflows) 

10- Identity 

11- Plugins 

12- Sessions 

13- Metrics 

14""" 

15 

16from __future__ import annotations 

17 

18import json 

19import logging 

20import os 

21import tempfile 

22import time 

23import uuid 

24from datetime import datetime 

25from functools import wraps 

26from typing import Optional, Dict, List, Any, Callable, TYPE_CHECKING 

27 

28from flask import Blueprint, request, jsonify, Response, current_app, g 

29 

30from .schemas import ( 

31 APIResponse, 

32 PaginatedResponse, 

33 ChannelConfigSchema, 

34 ChannelStatusSchema, 

35 QueueConfigSchema, 

36 QueueStatsSchema, 

37 CommandConfigSchema, 

38 MentionGatingConfigSchema, 

39 WebhookConfigSchema, 

40 CronJobSchema, 

41 TriggerConfigSchema, 

42 WorkflowSchema, 

43 ScheduledMessageSchema, 

44 IdentityConfigSchema, 

45 AvatarSchema, 

46 SenderMappingSchema, 

47 PluginConfigSchema, 

48 SessionConfigSchema, 

49 PairingRequestSchema, 

50 MetricsSchema, 

51 GlobalConfigSchema, 

52 SecurityConfigSchema, 

53 MediaConfigSchema, 

54 ResponseConfigSchema, 

55 MemoryStoreConfigSchema, 

56 EmbodiedAIConfigSchema, 

57) 

58 

59logger = logging.getLogger(__name__) 

60 

61# Create the blueprint 

62admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") 

63 

64 

65@admin_bp.before_request 

66def _admin_auth_gate(): 

67 """Require authenticated user for channel/device config endpoints. 

68 

69 Channel config (settings, identity, channels, workflows) is local device 

70 configuration — any registered (flat+) user can manage their own device. 

71 Network-wide admin operations (user management, moderation) are protected 

72 by separate @require_central decorators on the social admin blueprint. 

73 """ 

74 from integrations.social.auth import _get_user_from_token 

75 from flask import g 

76 

77 auth_header = request.headers.get('Authorization', '') 

78 if not auth_header.startswith('Bearer '): 

79 return jsonify({'success': False, 'error': 'Authentication required'}), 401 

80 

81 token = auth_header[7:] 

82 user, db = _get_user_from_token(token) 

83 if user is None: 

84 if db: 

85 db.close() 

86 return jsonify({'success': False, 'error': 'Invalid or expired token'}), 401 

87 

88 g.user = user 

89 g.user_id = str(user.id) 

90 g.db = db 

91 

92 

93@admin_bp.teardown_request 

94def _admin_teardown(exc): 

95 """Clean up db session after each admin request.""" 

96 db = getattr(g, 'db', None) 

97 if db: 

98 try: 

99 if exc: 

100 db.rollback() 

101 else: 

102 db.commit() 

103 finally: 

104 db.close() 

105 

106 

107class AdminAPI: 

108 """ 

109 Admin API controller. 

110 

111 Manages all configurable components and provides REST endpoints. 

112 """ 

113 

114 def __init__(self): 

115 self._start_time = time.time() 

116 self._config: Dict[str, Any] = {} 

117 self._channels: Dict[str, Dict[str, Any]] = {} 

118 self._commands: Dict[str, CommandConfigSchema] = {} 

119 self._webhooks: Dict[str, WebhookConfigSchema] = {} 

120 self._cron_jobs: Dict[str, CronJobSchema] = {} 

121 self._triggers: Dict[str, TriggerConfigSchema] = {} 

122 self._workflows: Dict[str, WorkflowSchema] = {} 

123 self._scheduled_messages: Dict[str, ScheduledMessageSchema] = {} 

124 self._plugins: Dict[str, PluginConfigSchema] = {} 

125 self._sessions: Dict[str, SessionConfigSchema] = {} 

126 self._avatars: Dict[str, AvatarSchema] = {} 

127 self._sender_mappings: Dict[str, SenderMappingSchema] = {} 

128 self._identity: Optional[IdentityConfigSchema] = None 

129 self._global_config = GlobalConfigSchema() 

130 self._metrics_history: List[Dict[str, Any]] = [] 

131 self._message_count = 0 

132 self._error_count = 0 

133 

134 # Try to load saved configuration 

135 self._load_config() 

136 

137 def _load_config(self) -> None: 

138 """Load configuration from file if exists.""" 

139 config_path = os.path.join( 

140 os.path.dirname(__file__), 

141 "..", 

142 "..", 

143 "..", 

144 "agent_data", 

145 "admin_config.json" 

146 ) 

147 try: 

148 if os.path.exists(config_path): 

149 with open(config_path, "r") as f: 

150 self._config = json.load(f) 

151 logger.info("Loaded admin configuration from %s", config_path) 

152 except Exception as e: 

153 logger.warning("Failed to load admin config: %s", e) 

154 

155 def _save_config(self) -> None: 

156 """Save configuration to file using atomic write (temp + rename).""" 

157 config_path = os.path.join( 

158 os.path.dirname(__file__), 

159 "..", 

160 "..", 

161 "..", 

162 "agent_data", 

163 "admin_config.json" 

164 ) 

165 try: 

166 config_dir = os.path.dirname(config_path) 

167 os.makedirs(config_dir, exist_ok=True) 

168 # Write to temp file first, then atomic rename to prevent corruption 

169 fd, tmp_path = tempfile.mkstemp(dir=config_dir, suffix='.tmp') 

170 try: 

171 with os.fdopen(fd, 'w') as f: 

172 json.dump(self._config, f, indent=2, default=str) 

173 os.replace(tmp_path, config_path) # atomic rename 

174 except Exception: 

175 os.unlink(tmp_path) 

176 raise 

177 logger.info("Saved admin configuration to %s", config_path) 

178 except Exception as e: 

179 logger.warning("Failed to save admin config: %s", e) 

180 

181 def get_uptime(self) -> float: 

182 """Get system uptime in seconds.""" 

183 return time.time() - self._start_time 

184 

185 

186# Global API instance 

187_api = AdminAPI() 

188 

189 

190def get_api() -> AdminAPI: 

191 """Get the global API instance.""" 

192 return _api 

193 

194 

195def api_response(func: Callable) -> Callable: 

196 """Decorator to wrap responses in standard API format.""" 

197 @wraps(func) 

198 def wrapper(*args, **kwargs): 

199 try: 

200 result = func(*args, **kwargs) 

201 if isinstance(result, Response): 

202 return result 

203 response = APIResponse(success=True, data=result) 

204 return jsonify(response.to_dict()) 

205 except ValueError as e: 

206 response = APIResponse(success=False, error=str(e)) 

207 return jsonify(response.to_dict()), 400 

208 except PermissionError as e: 

209 response = APIResponse(success=False, error=str(e)) 

210 return jsonify(response.to_dict()), 403 

211 except FileNotFoundError as e: 

212 response = APIResponse(success=False, error=str(e)) 

213 return jsonify(response.to_dict()), 404 

214 except Exception as e: 

215 logger.exception("API error in %s", func.__name__) 

216 response = APIResponse(success=False, error=str(e)) 

217 return jsonify(response.to_dict()), 500 

218 return wrapper 

219 

220 

221# ============================================================================ 

222# HEALTH & STATUS ENDPOINTS 

223# ============================================================================ 

224 

225@admin_bp.route("/health", methods=["GET"]) 

226@api_response 

227def health(): 

228 """Health check endpoint.""" 

229 return {"status": "healthy", "uptime": get_api().get_uptime()} 

230 

231 

232@admin_bp.route("/status", methods=["GET"]) 

233@api_response 

234def status(): 

235 """System status overview.""" 

236 api = get_api() 

237 return { 

238 "status": "running", 

239 "uptime_seconds": api.get_uptime(), 

240 "channels_count": len(api._channels), 

241 "commands_count": len(api._commands), 

242 "plugins_count": len(api._plugins), 

243 "sessions_count": len(api._sessions), 

244 "webhooks_count": len(api._webhooks), 

245 "cron_jobs_count": len(api._cron_jobs), 

246 "triggers_count": len(api._triggers), 

247 "workflows_count": len(api._workflows), 

248 } 

249 

250 

251@admin_bp.route("/version", methods=["GET"]) 

252@api_response 

253def version(): 

254 """Get API version information.""" 

255 return { 

256 "api_version": "1.0.0", 

257 "hevolvebot_version": "2.0.0", 

258 "python_version": "3.10+", 

259 } 

260 

261 

262# ============================================================================ 

263# CHANNEL ENDPOINTS (20+ endpoints) 

264# ============================================================================ 

265 

266@admin_bp.route("/channels", methods=["GET"]) 

267@api_response 

268def list_channels(): 

269 """List all configured channels.""" 

270 api = get_api() 

271 page = request.args.get("page", 1, type=int) 

272 page_size = request.args.get("page_size", 20, type=int) 

273 

274 channels = list(api._channels.values()) 

275 total = len(channels) 

276 start = (page - 1) * page_size 

277 end = start + page_size 

278 

279 return PaginatedResponse( 

280 items=channels[start:end], 

281 total=total, 

282 page=page, 

283 page_size=page_size, 

284 has_next=end < total, 

285 has_prev=page > 1, 

286 ).to_dict() 

287 

288 

289@admin_bp.route("/channels/<channel_type>", methods=["GET"]) 

290@api_response 

291def get_channel(channel_type: str): 

292 """Get channel configuration.""" 

293 api = get_api() 

294 if channel_type not in api._channels: 

295 raise FileNotFoundError(f"Channel {channel_type} not found") 

296 return api._channels[channel_type] 

297 

298 

299@admin_bp.route("/channels", methods=["POST"]) 

300@api_response 

301def create_channel(): 

302 """Create a new channel configuration.""" 

303 api = get_api() 

304 data = request.get_json() 

305 if not data: 

306 raise ValueError("Request body required") 

307 

308 channel_type = data.get("channel_type") 

309 if not channel_type: 

310 raise ValueError("channel_type is required") 

311 

312 if channel_type in api._channels: 

313 raise ValueError(f"Channel {channel_type} already exists") 

314 

315 config = ChannelConfigSchema( 

316 channel_type=channel_type, 

317 name=data.get("name", channel_type), 

318 enabled=data.get("enabled", True), 

319 config=data.get("config", {}), 

320 rate_limit=data.get("rate_limit"), 

321 security=data.get("security"), 

322 ) 

323 api._channels[channel_type] = config.to_dict() 

324 api._save_config() 

325 return config.to_dict() 

326 

327 

328@admin_bp.route("/channels/<channel_type>", methods=["PUT"]) 

329@api_response 

330def update_channel(channel_type: str): 

331 """Update channel configuration.""" 

332 api = get_api() 

333 if channel_type not in api._channels: 

334 raise FileNotFoundError(f"Channel {channel_type} not found") 

335 

336 data = request.get_json() 

337 if not data: 

338 raise ValueError("Request body required") 

339 

340 existing = api._channels[channel_type] 

341 existing.update(data) 

342 api._channels[channel_type] = existing 

343 api._save_config() 

344 return existing 

345 

346 

347@admin_bp.route("/channels/<channel_type>", methods=["DELETE"]) 

348@api_response 

349def delete_channel(channel_type: str): 

350 """Delete channel configuration.""" 

351 api = get_api() 

352 if channel_type not in api._channels: 

353 raise FileNotFoundError(f"Channel {channel_type} not found") 

354 

355 del api._channels[channel_type] 

356 api._save_config() 

357 return {"deleted": channel_type} 

358 

359 

360@admin_bp.route("/channels/<channel_type>/status", methods=["GET"]) 

361@api_response 

362def get_channel_status(channel_type: str): 

363 """Get channel connection status from live registry.""" 

364 api = get_api() 

365 if channel_type not in api._channels: 

366 raise FileNotFoundError(f"Channel {channel_type} not found") 

367 

368 # Query live adapter registry for actual status 

369 live_status = "disconnected" 

370 try: 

371 from integrations.channels.registry import get_registry 

372 registry = get_registry() 

373 adapter = registry.get(channel_type) 

374 if adapter: 

375 status_info = adapter.get_status() 

376 if isinstance(status_info, dict): 

377 live_status = status_info.get('status', 'connected') 

378 elif isinstance(status_info, str): 

379 live_status = status_info 

380 else: 

381 live_status = 'connected' 

382 else: 

383 live_status = 'not_registered' 

384 except Exception: 

385 live_status = 'unknown' 

386 

387 return ChannelStatusSchema( 

388 channel_type=channel_type, 

389 name=api._channels[channel_type].get("name", channel_type), 

390 status=live_status, 

391 message_count=api._message_count, 

392 ).to_dict() 

393 

394 

395@admin_bp.route("/channels/<channel_type>/enable", methods=["POST"]) 

396@api_response 

397def enable_channel(channel_type: str): 

398 """Enable a channel.""" 

399 api = get_api() 

400 if channel_type not in api._channels: 

401 raise FileNotFoundError(f"Channel {channel_type} not found") 

402 

403 api._channels[channel_type]["enabled"] = True 

404 api._save_config() 

405 return {"channel": channel_type, "enabled": True} 

406 

407 

408@admin_bp.route("/channels/<channel_type>/disable", methods=["POST"]) 

409@api_response 

410def disable_channel(channel_type: str): 

411 """Disable a channel.""" 

412 api = get_api() 

413 if channel_type not in api._channels: 

414 raise FileNotFoundError(f"Channel {channel_type} not found") 

415 

416 api._channels[channel_type]["enabled"] = False 

417 api._save_config() 

418 return {"channel": channel_type, "enabled": False} 

419 

420 

421@admin_bp.route("/channels/<channel_type>/test", methods=["POST"]) 

422@api_response 

423def test_channel(channel_type: str): 

424 """Test channel connection against the live adapter registry.""" 

425 api = get_api() 

426 if channel_type not in api._channels: 

427 raise FileNotFoundError(f"Channel {channel_type} not found") 

428 

429 config = api._channels[channel_type] 

430 if not config.get('enabled'): 

431 return {"channel": channel_type, "test_result": "disabled", "latency_ms": 0} 

432 

433 try: 

434 from integrations.channels.registry import get_registry 

435 registry = get_registry() 

436 adapter = registry.get(channel_type) 

437 if adapter: 

438 import time as _time 

439 t0 = _time.time() 

440 import asyncio as _asyncio 

441 loop = _asyncio.new_event_loop() 

442 try: 

443 loop.run_until_complete(_asyncio.wait_for(adapter.connect(), timeout=10)) 

444 latency = int((_time.time() - t0) * 1000) 

445 return {"channel": channel_type, "test_result": "success", "latency_ms": latency} 

446 finally: 

447 loop.close() 

448 else: 

449 return {"channel": channel_type, "test_result": "not_registered", "latency_ms": 0} 

450 except Exception as e: 

451 return {"channel": channel_type, "test_result": "failed", "error": str(e), "latency_ms": 0} 

452 

453 

454@admin_bp.route("/channels/<channel_type>/reconnect", methods=["POST"]) 

455@api_response 

456def reconnect_channel(channel_type: str): 

457 """Force channel reconnection via the live adapter registry.""" 

458 api = get_api() 

459 if channel_type not in api._channels: 

460 raise FileNotFoundError(f"Channel {channel_type} not found") 

461 

462 try: 

463 from integrations.channels.registry import get_registry 

464 registry = get_registry() 

465 adapter = registry.get(channel_type) 

466 if adapter: 

467 import asyncio as _asyncio 

468 loop = _asyncio.new_event_loop() 

469 try: 

470 loop.run_until_complete(adapter.disconnect()) 

471 loop.run_until_complete(adapter.connect()) 

472 return {"channel": channel_type, "reconnected": True} 

473 except Exception as e: 

474 return {"channel": channel_type, "reconnected": False, "error": str(e)} 

475 finally: 

476 loop.close() 

477 return {"channel": channel_type, "reconnected": False, "error": "Adapter not registered"} 

478 except Exception as e: 

479 return {"channel": channel_type, "reconnected": False, "error": str(e)} 

480 

481 

482@admin_bp.route("/channels/<channel_type>/activate", methods=["POST"]) 

483@api_response 

484def activate_channel(channel_type: str): 

485 """Activate a channel by registering its adapter with the live registry.""" 

486 api = get_api() 

487 config = api._channels.get(channel_type) 

488 if not config: 

489 raise FileNotFoundError(f"Channel {channel_type} not found") 

490 if not config.get('enabled'): 

491 raise ValueError(f"Channel {channel_type} is disabled — enable it first") 

492 

493 token = config.get('token') or config.get('api_key') 

494 

495 try: 

496 from integrations.channels.flask_integration import get_channel_integration 

497 integration = get_channel_integration() 

498 ok = integration.register_channel(channel_type, token=token) 

499 # Non-web channels need WAMP for cross-process push. At boot 

500 # Nunba may have skipped the router to save RAM; wake it now. 

501 if ok and channel_type != 'web': 

502 try: 

503 from wamp_router import ensure_wamp_running 

504 ensure_wamp_running(reason=f"channel {channel_type} activated") 

505 except Exception: 

506 pass 

507 return {"channel": channel_type, "activated": ok} 

508 except Exception as e: 

509 return {"channel": channel_type, "activated": False, "error": str(e)} 

510 

511 

512@admin_bp.route("/channels/<channel_type>/metrics", methods=["GET"]) 

513@api_response 

514def get_channel_metrics(channel_type: str): 

515 """Get channel-specific metrics.""" 

516 api = get_api() 

517 if channel_type not in api._channels: 

518 raise FileNotFoundError(f"Channel {channel_type} not found") 

519 

520 return { 

521 "channel": channel_type, 

522 "messages_sent": 0, 

523 "messages_received": 0, 

524 "avg_latency_ms": 45, 

525 "error_rate": 0.01, 

526 } 

527 

528 

529@admin_bp.route("/channels/<channel_type>/rate-limit", methods=["GET"]) 

530@api_response 

531def get_channel_rate_limit(channel_type: str): 

532 """Get channel rate limit configuration.""" 

533 api = get_api() 

534 if channel_type not in api._channels: 

535 raise FileNotFoundError(f"Channel {channel_type} not found") 

536 

537 return api._channels[channel_type].get("rate_limit", { 

538 "requests_per_minute": 60, 

539 "burst_limit": 10, 

540 }) 

541 

542 

543@admin_bp.route("/channels/<channel_type>/rate-limit", methods=["PUT"]) 

544@api_response 

545def update_channel_rate_limit(channel_type: str): 

546 """Update channel rate limit configuration.""" 

547 api = get_api() 

548 if channel_type not in api._channels: 

549 raise FileNotFoundError(f"Channel {channel_type} not found") 

550 

551 data = request.get_json() 

552 api._channels[channel_type]["rate_limit"] = data 

553 api._save_config() 

554 return api._channels[channel_type]["rate_limit"] 

555 

556 

557@admin_bp.route("/channels/<channel_type>/security", methods=["GET"]) 

558@api_response 

559def get_channel_security(channel_type: str): 

560 """Get channel security configuration.""" 

561 api = get_api() 

562 if channel_type not in api._channels: 

563 raise FileNotFoundError(f"Channel {channel_type} not found") 

564 

565 return api._channels[channel_type].get("security", {}) 

566 

567 

568@admin_bp.route("/channels/<channel_type>/security", methods=["PUT"]) 

569@api_response 

570def update_channel_security(channel_type: str): 

571 """Update channel security configuration.""" 

572 api = get_api() 

573 if channel_type not in api._channels: 

574 raise FileNotFoundError(f"Channel {channel_type} not found") 

575 

576 data = request.get_json() 

577 api._channels[channel_type]["security"] = data 

578 api._save_config() 

579 return api._channels[channel_type]["security"] 

580 

581 

582# ============================================================================ 

583# QUEUE/PIPELINE ENDPOINTS (15+ endpoints) 

584# ============================================================================ 

585 

586@admin_bp.route("/queue/config", methods=["GET"]) 

587@api_response 

588def get_queue_config(): 

589 """Get queue configuration.""" 

590 api = get_api() 

591 return api._global_config.queue.to_dict() 

592 

593 

594@admin_bp.route("/queue/config", methods=["PUT"]) 

595@api_response 

596def update_queue_config(): 

597 """Update queue configuration.""" 

598 api = get_api() 

599 data = request.get_json() 

600 if not data: 

601 raise ValueError("Request body required") 

602 

603 api._global_config.queue = QueueConfigSchema(**data) 

604 api._save_config() 

605 return api._global_config.queue.to_dict() 

606 

607 

608@admin_bp.route("/queue/stats", methods=["GET"]) 

609@api_response 

610def get_queue_stats(): 

611 """Get queue statistics.""" 

612 return QueueStatsSchema( 

613 queue_size=0, 

614 pending_messages=0, 

615 processing_messages=0, 

616 completed_messages=0, 

617 failed_messages=0, 

618 avg_processing_time_ms=45.0, 

619 messages_per_minute=10.0, 

620 ).to_dict() 

621 

622 

623@admin_bp.route("/queue/clear", methods=["POST"]) 

624@api_response 

625def clear_queue(): 

626 """Clear all pending messages from queue.""" 

627 return {"cleared": True, "messages_removed": 0} 

628 

629 

630@admin_bp.route("/queue/pause", methods=["POST"]) 

631@api_response 

632def pause_queue(): 

633 """Pause queue processing.""" 

634 return {"paused": True} 

635 

636 

637@admin_bp.route("/queue/resume", methods=["POST"]) 

638@api_response 

639def resume_queue(): 

640 """Resume queue processing.""" 

641 return {"resumed": True} 

642 

643 

644@admin_bp.route("/queue/debounce", methods=["GET"]) 

645@api_response 

646def get_debounce_config(): 

647 """Get debounce configuration.""" 

648 api = get_api() 

649 return {"debounce_ms": api._global_config.queue.debounce_ms} 

650 

651 

652@admin_bp.route("/queue/debounce", methods=["PUT"]) 

653@api_response 

654def update_debounce_config(): 

655 """Update debounce configuration.""" 

656 api = get_api() 

657 data = request.get_json() 

658 api._global_config.queue.debounce_ms = data.get("debounce_ms", 500) 

659 api._save_config() 

660 return {"debounce_ms": api._global_config.queue.debounce_ms} 

661 

662 

663@admin_bp.route("/queue/dedupe", methods=["GET"]) 

664@api_response 

665def get_dedupe_config(): 

666 """Get deduplication configuration.""" 

667 api = get_api() 

668 return {"dedupe_window_seconds": api._global_config.queue.dedupe_window_seconds} 

669 

670 

671@admin_bp.route("/queue/dedupe", methods=["PUT"]) 

672@api_response 

673def update_dedupe_config(): 

674 """Update deduplication configuration.""" 

675 api = get_api() 

676 data = request.get_json() 

677 api._global_config.queue.dedupe_window_seconds = data.get("dedupe_window_seconds", 60) 

678 api._save_config() 

679 return {"dedupe_window_seconds": api._global_config.queue.dedupe_window_seconds} 

680 

681 

682@admin_bp.route("/queue/concurrency", methods=["GET"]) 

683@api_response 

684def get_concurrency_config(): 

685 """Get concurrency limits configuration.""" 

686 api = get_api() 

687 return api._global_config.queue.concurrency_limits 

688 

689 

690@admin_bp.route("/queue/concurrency", methods=["PUT"]) 

691@api_response 

692def update_concurrency_config(): 

693 """Update concurrency limits configuration.""" 

694 api = get_api() 

695 data = request.get_json() 

696 api._global_config.queue.concurrency_limits.update(data) 

697 api._save_config() 

698 return api._global_config.queue.concurrency_limits 

699 

700 

701@admin_bp.route("/queue/rate-limit", methods=["GET"]) 

702@api_response 

703def get_rate_limit_config(): 

704 """Get rate limit configuration.""" 

705 api = get_api() 

706 return api._global_config.queue.rate_limits 

707 

708 

709@admin_bp.route("/queue/rate-limit", methods=["PUT"]) 

710@api_response 

711def update_rate_limit_config(): 

712 """Update rate limit configuration.""" 

713 api = get_api() 

714 data = request.get_json() 

715 api._global_config.queue.rate_limits.update(data) 

716 api._save_config() 

717 return api._global_config.queue.rate_limits 

718 

719 

720@admin_bp.route("/queue/retry", methods=["GET"]) 

721@api_response 

722def get_retry_config(): 

723 """Get retry configuration.""" 

724 api = get_api() 

725 return api._global_config.queue.retry_config 

726 

727 

728@admin_bp.route("/queue/retry", methods=["PUT"]) 

729@api_response 

730def update_retry_config(): 

731 """Update retry configuration.""" 

732 api = get_api() 

733 data = request.get_json() 

734 api._global_config.queue.retry_config.update(data) 

735 api._save_config() 

736 return api._global_config.queue.retry_config 

737 

738 

739@admin_bp.route("/queue/batching", methods=["GET"]) 

740@api_response 

741def get_batching_config(): 

742 """Get batching configuration.""" 

743 api = get_api() 

744 return api._global_config.queue.batching 

745 

746 

747@admin_bp.route("/queue/batching", methods=["PUT"]) 

748@api_response 

749def update_batching_config(): 

750 """Update batching configuration.""" 

751 api = get_api() 

752 data = request.get_json() 

753 api._global_config.queue.batching.update(data) 

754 api._save_config() 

755 return api._global_config.queue.batching 

756 

757 

758# ============================================================================ 

759# COMMAND ENDPOINTS (15+ endpoints) 

760# ============================================================================ 

761 

762@admin_bp.route("/commands", methods=["GET"]) 

763@api_response 

764def list_commands(): 

765 """List all registered commands.""" 

766 api = get_api() 

767 page = request.args.get("page", 1, type=int) 

768 page_size = request.args.get("page_size", 20, type=int) 

769 

770 commands = [c.to_dict() for c in api._commands.values()] 

771 total = len(commands) 

772 start = (page - 1) * page_size 

773 end = start + page_size 

774 

775 return PaginatedResponse( 

776 items=commands[start:end], 

777 total=total, 

778 page=page, 

779 page_size=page_size, 

780 has_next=end < total, 

781 has_prev=page > 1, 

782 ).to_dict() 

783 

784 

785@admin_bp.route("/commands/<command_name>", methods=["GET"]) 

786@api_response 

787def get_command(command_name: str): 

788 """Get command configuration.""" 

789 api = get_api() 

790 if command_name not in api._commands: 

791 raise FileNotFoundError(f"Command {command_name} not found") 

792 return api._commands[command_name].to_dict() 

793 

794 

795@admin_bp.route("/commands", methods=["POST"]) 

796@api_response 

797def create_command(): 

798 """Create a new command.""" 

799 api = get_api() 

800 data = request.get_json() 

801 if not data: 

802 raise ValueError("Request body required") 

803 

804 name = data.get("name") 

805 if not name: 

806 raise ValueError("name is required") 

807 

808 if name in api._commands: 

809 raise ValueError(f"Command {name} already exists") 

810 

811 command = CommandConfigSchema( 

812 name=name, 

813 description=data.get("description", ""), 

814 pattern=data.get("pattern", f"/{name}"), 

815 handler=data.get("handler", ""), 

816 enabled=data.get("enabled", True), 

817 admin_only=data.get("admin_only", False), 

818 cooldown_seconds=data.get("cooldown_seconds", 0), 

819 usage_limit=data.get("usage_limit"), 

820 aliases=data.get("aliases", []), 

821 arguments=data.get("arguments", []), 

822 ) 

823 api._commands[name] = command 

824 api._save_config() 

825 return command.to_dict() 

826 

827 

828@admin_bp.route("/commands/<command_name>", methods=["PUT"]) 

829@api_response 

830def update_command(command_name: str): 

831 """Update command configuration.""" 

832 api = get_api() 

833 if command_name not in api._commands: 

834 raise FileNotFoundError(f"Command {command_name} not found") 

835 

836 data = request.get_json() 

837 if not data: 

838 raise ValueError("Request body required") 

839 

840 existing = api._commands[command_name] 

841 for key, value in data.items(): 

842 if hasattr(existing, key): 

843 setattr(existing, key, value) 

844 

845 api._save_config() 

846 return existing.to_dict() 

847 

848 

849@admin_bp.route("/commands/<command_name>", methods=["DELETE"]) 

850@api_response 

851def delete_command(command_name: str): 

852 """Delete a command.""" 

853 api = get_api() 

854 if command_name not in api._commands: 

855 raise FileNotFoundError(f"Command {command_name} not found") 

856 

857 del api._commands[command_name] 

858 api._save_config() 

859 return {"deleted": command_name} 

860 

861 

862@admin_bp.route("/commands/<command_name>/enable", methods=["POST"]) 

863@api_response 

864def enable_command(command_name: str): 

865 """Enable a command.""" 

866 api = get_api() 

867 if command_name not in api._commands: 

868 raise FileNotFoundError(f"Command {command_name} not found") 

869 

870 api._commands[command_name].enabled = True 

871 api._save_config() 

872 return {"command": command_name, "enabled": True} 

873 

874 

875@admin_bp.route("/commands/<command_name>/disable", methods=["POST"]) 

876@api_response 

877def disable_command(command_name: str): 

878 """Disable a command.""" 

879 api = get_api() 

880 if command_name not in api._commands: 

881 raise FileNotFoundError(f"Command {command_name} not found") 

882 

883 api._commands[command_name].enabled = False 

884 api._save_config() 

885 return {"command": command_name, "enabled": False} 

886 

887 

888@admin_bp.route("/commands/<command_name>/stats", methods=["GET"]) 

889@api_response 

890def get_command_stats(command_name: str): 

891 """Get command usage statistics.""" 

892 api = get_api() 

893 if command_name not in api._commands: 

894 raise FileNotFoundError(f"Command {command_name} not found") 

895 

896 return { 

897 "command": command_name, 

898 "invocations": 0, 

899 "successful": 0, 

900 "failed": 0, 

901 "avg_response_time_ms": 0, 

902 } 

903 

904 

905@admin_bp.route("/commands/mention-gating", methods=["GET"]) 

906@api_response 

907def get_mention_gating(): 

908 """Get mention gating configuration.""" 

909 api = get_api() 

910 return api._config.get("mention_gating", MentionGatingConfigSchema().to_dict()) 

911 

912 

913@admin_bp.route("/commands/mention-gating", methods=["PUT"]) 

914@api_response 

915def update_mention_gating(): 

916 """Update mention gating configuration.""" 

917 api = get_api() 

918 data = request.get_json() 

919 api._config["mention_gating"] = data 

920 api._save_config() 

921 return api._config["mention_gating"] 

922 

923 

924# ============================================================================ 

925# AUTOMATION ENDPOINTS (25+ endpoints) 

926# ============================================================================ 

927 

928# Webhooks 

929@admin_bp.route("/automation/webhooks", methods=["GET"]) 

930@api_response 

931def list_webhooks(): 

932 """List all webhooks.""" 

933 api = get_api() 

934 return [w.to_dict() for w in api._webhooks.values()] 

935 

936 

937@admin_bp.route("/automation/webhooks/<webhook_id>", methods=["GET"]) 

938@api_response 

939def get_webhook(webhook_id: str): 

940 """Get webhook configuration.""" 

941 api = get_api() 

942 if webhook_id not in api._webhooks: 

943 raise FileNotFoundError(f"Webhook {webhook_id} not found") 

944 return api._webhooks[webhook_id].to_dict() 

945 

946 

947@admin_bp.route("/automation/webhooks", methods=["POST"]) 

948@api_response 

949def create_webhook(): 

950 """Create a new webhook.""" 

951 api = get_api() 

952 data = request.get_json() 

953 if not data: 

954 raise ValueError("Request body required") 

955 

956 webhook_id = str(uuid.uuid4()) 

957 webhook = WebhookConfigSchema( 

958 id=webhook_id, 

959 name=data.get("name", ""), 

960 url=data.get("url", ""), 

961 secret=data.get("secret"), 

962 events=data.get("events", []), 

963 enabled=data.get("enabled", True), 

964 retry_count=data.get("retry_count", 3), 

965 timeout_seconds=data.get("timeout_seconds", 30), 

966 headers=data.get("headers", {}), 

967 ) 

968 api._webhooks[webhook_id] = webhook 

969 api._save_config() 

970 return webhook.to_dict() 

971 

972 

973@admin_bp.route("/automation/webhooks/<webhook_id>", methods=["PUT"]) 

974@api_response 

975def update_webhook(webhook_id: str): 

976 """Update webhook configuration.""" 

977 api = get_api() 

978 if webhook_id not in api._webhooks: 

979 raise FileNotFoundError(f"Webhook {webhook_id} not found") 

980 

981 data = request.get_json() 

982 existing = api._webhooks[webhook_id] 

983 for key, value in data.items(): 

984 if hasattr(existing, key): 

985 setattr(existing, key, value) 

986 

987 api._save_config() 

988 return existing.to_dict() 

989 

990 

991@admin_bp.route("/automation/webhooks/<webhook_id>", methods=["DELETE"]) 

992@api_response 

993def delete_webhook(webhook_id: str): 

994 """Delete a webhook.""" 

995 api = get_api() 

996 if webhook_id not in api._webhooks: 

997 raise FileNotFoundError(f"Webhook {webhook_id} not found") 

998 

999 del api._webhooks[webhook_id] 

1000 api._save_config() 

1001 return {"deleted": webhook_id} 

1002 

1003 

1004@admin_bp.route("/automation/webhooks/<webhook_id>/test", methods=["POST"]) 

1005@api_response 

1006def test_webhook(webhook_id: str): 

1007 """Test a webhook.""" 

1008 api = get_api() 

1009 if webhook_id not in api._webhooks: 

1010 raise FileNotFoundError(f"Webhook {webhook_id} not found") 

1011 

1012 return {"webhook_id": webhook_id, "test_result": "success", "status_code": 200} 

1013 

1014 

1015# Cron Jobs 

1016@admin_bp.route("/automation/cron", methods=["GET"]) 

1017@api_response 

1018def list_cron_jobs(): 

1019 """List all cron jobs.""" 

1020 api = get_api() 

1021 return [c.to_dict() for c in api._cron_jobs.values()] 

1022 

1023 

1024@admin_bp.route("/automation/cron/<job_id>", methods=["GET"]) 

1025@api_response 

1026def get_cron_job(job_id: str): 

1027 """Get cron job configuration.""" 

1028 api = get_api() 

1029 if job_id not in api._cron_jobs: 

1030 raise FileNotFoundError(f"Cron job {job_id} not found") 

1031 return api._cron_jobs[job_id].to_dict() 

1032 

1033 

1034@admin_bp.route("/automation/cron", methods=["POST"]) 

1035@api_response 

1036def create_cron_job(): 

1037 """Create a new cron job.""" 

1038 api = get_api() 

1039 data = request.get_json() 

1040 if not data: 

1041 raise ValueError("Request body required") 

1042 

1043 job_id = str(uuid.uuid4()) 

1044 job = CronJobSchema( 

1045 id=job_id, 

1046 name=data.get("name", ""), 

1047 schedule=data.get("schedule", ""), 

1048 handler=data.get("handler", ""), 

1049 enabled=data.get("enabled", True), 

1050 payload=data.get("payload", {}), 

1051 ) 

1052 api._cron_jobs[job_id] = job 

1053 api._save_config() 

1054 return job.to_dict() 

1055 

1056 

1057@admin_bp.route("/automation/cron/<job_id>", methods=["PUT"]) 

1058@api_response 

1059def update_cron_job(job_id: str): 

1060 """Update cron job configuration.""" 

1061 api = get_api() 

1062 if job_id not in api._cron_jobs: 

1063 raise FileNotFoundError(f"Cron job {job_id} not found") 

1064 

1065 data = request.get_json() 

1066 existing = api._cron_jobs[job_id] 

1067 for key, value in data.items(): 

1068 if hasattr(existing, key): 

1069 setattr(existing, key, value) 

1070 

1071 api._save_config() 

1072 return existing.to_dict() 

1073 

1074 

1075@admin_bp.route("/automation/cron/<job_id>", methods=["DELETE"]) 

1076@api_response 

1077def delete_cron_job(job_id: str): 

1078 """Delete a cron job.""" 

1079 api = get_api() 

1080 if job_id not in api._cron_jobs: 

1081 raise FileNotFoundError(f"Cron job {job_id} not found") 

1082 

1083 del api._cron_jobs[job_id] 

1084 api._save_config() 

1085 return {"deleted": job_id} 

1086 

1087 

1088@admin_bp.route("/automation/cron/<job_id>/run", methods=["POST"]) 

1089@api_response 

1090def run_cron_job(job_id: str): 

1091 """Manually trigger a cron job.""" 

1092 api = get_api() 

1093 if job_id not in api._cron_jobs: 

1094 raise FileNotFoundError(f"Cron job {job_id} not found") 

1095 

1096 return {"job_id": job_id, "triggered": True} 

1097 

1098 

1099# Triggers 

1100@admin_bp.route("/automation/triggers", methods=["GET"]) 

1101@api_response 

1102def list_triggers(): 

1103 """List all triggers.""" 

1104 api = get_api() 

1105 return [t.to_dict() for t in api._triggers.values()] 

1106 

1107 

1108@admin_bp.route("/automation/triggers/<trigger_id>", methods=["GET"]) 

1109@api_response 

1110def get_trigger(trigger_id: str): 

1111 """Get trigger configuration.""" 

1112 api = get_api() 

1113 if trigger_id not in api._triggers: 

1114 raise FileNotFoundError(f"Trigger {trigger_id} not found") 

1115 return api._triggers[trigger_id].to_dict() 

1116 

1117 

1118@admin_bp.route("/automation/triggers", methods=["POST"]) 

1119@api_response 

1120def create_trigger(): 

1121 """Create a new trigger.""" 

1122 api = get_api() 

1123 data = request.get_json() 

1124 if not data: 

1125 raise ValueError("Request body required") 

1126 

1127 trigger_id = str(uuid.uuid4()) 

1128 trigger = TriggerConfigSchema( 

1129 id=trigger_id, 

1130 name=data.get("name", ""), 

1131 trigger_type=data.get("trigger_type", ""), 

1132 pattern=data.get("pattern"), 

1133 conditions=data.get("conditions", {}), 

1134 actions=data.get("actions", []), 

1135 enabled=data.get("enabled", True), 

1136 priority=data.get("priority", 0), 

1137 ) 

1138 api._triggers[trigger_id] = trigger 

1139 api._save_config() 

1140 return trigger.to_dict() 

1141 

1142 

1143@admin_bp.route("/automation/triggers/<trigger_id>", methods=["PUT"]) 

1144@api_response 

1145def update_trigger(trigger_id: str): 

1146 """Update trigger configuration.""" 

1147 api = get_api() 

1148 if trigger_id not in api._triggers: 

1149 raise FileNotFoundError(f"Trigger {trigger_id} not found") 

1150 

1151 data = request.get_json() 

1152 existing = api._triggers[trigger_id] 

1153 for key, value in data.items(): 

1154 if hasattr(existing, key): 

1155 setattr(existing, key, value) 

1156 

1157 api._save_config() 

1158 return existing.to_dict() 

1159 

1160 

1161@admin_bp.route("/automation/triggers/<trigger_id>", methods=["DELETE"]) 

1162@api_response 

1163def delete_trigger(trigger_id: str): 

1164 """Delete a trigger.""" 

1165 api = get_api() 

1166 if trigger_id not in api._triggers: 

1167 raise FileNotFoundError(f"Trigger {trigger_id} not found") 

1168 

1169 del api._triggers[trigger_id] 

1170 api._save_config() 

1171 return {"deleted": trigger_id} 

1172 

1173 

1174# Workflows 

1175@admin_bp.route("/automation/workflows", methods=["GET"]) 

1176@api_response 

1177def list_workflows(): 

1178 """List all workflows.""" 

1179 api = get_api() 

1180 return [w.to_dict() for w in api._workflows.values()] 

1181 

1182 

1183@admin_bp.route("/automation/workflows/<workflow_id>", methods=["GET"]) 

1184@api_response 

1185def get_workflow(workflow_id: str): 

1186 """Get workflow configuration.""" 

1187 api = get_api() 

1188 if workflow_id not in api._workflows: 

1189 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1190 return api._workflows[workflow_id].to_dict() 

1191 

1192 

1193@admin_bp.route("/automation/workflows", methods=["POST"]) 

1194@api_response 

1195def create_workflow(): 

1196 """Create a new workflow.""" 

1197 api = get_api() 

1198 data = request.get_json() 

1199 if not data: 

1200 raise ValueError("Request body required") 

1201 

1202 workflow_id = str(uuid.uuid4()) 

1203 now = datetime.now().isoformat() 

1204 workflow = WorkflowSchema( 

1205 id=workflow_id, 

1206 name=data.get("name", ""), 

1207 description=data.get("description", ""), 

1208 nodes=data.get("nodes", []), 

1209 edges=data.get("edges", []), 

1210 enabled=data.get("enabled", True), 

1211 created_at=now, 

1212 updated_at=now, 

1213 ) 

1214 api._workflows[workflow_id] = workflow 

1215 api._save_config() 

1216 return workflow.to_dict() 

1217 

1218 

1219@admin_bp.route("/automation/workflows/<workflow_id>", methods=["PUT"]) 

1220@api_response 

1221def update_workflow(workflow_id: str): 

1222 """Update workflow configuration.""" 

1223 api = get_api() 

1224 if workflow_id not in api._workflows: 

1225 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1226 

1227 data = request.get_json() 

1228 existing = api._workflows[workflow_id] 

1229 for key, value in data.items(): 

1230 if hasattr(existing, key): 

1231 setattr(existing, key, value) 

1232 existing.updated_at = datetime.now().isoformat() 

1233 

1234 api._save_config() 

1235 return existing.to_dict() 

1236 

1237 

1238@admin_bp.route("/automation/workflows/<workflow_id>", methods=["DELETE"]) 

1239@api_response 

1240def delete_workflow(workflow_id: str): 

1241 """Delete a workflow.""" 

1242 api = get_api() 

1243 if workflow_id not in api._workflows: 

1244 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1245 

1246 del api._workflows[workflow_id] 

1247 api._save_config() 

1248 return {"deleted": workflow_id} 

1249 

1250 

1251@admin_bp.route("/automation/workflows/<workflow_id>/execute", methods=["POST"]) 

1252@api_response 

1253def execute_workflow(workflow_id: str): 

1254 """Execute a workflow.""" 

1255 api = get_api() 

1256 if workflow_id not in api._workflows: 

1257 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1258 

1259 execution_id = str(uuid.uuid4()) 

1260 return { 

1261 "workflow_id": workflow_id, 

1262 "execution_id": execution_id, 

1263 "status": "started", 

1264 } 

1265 

1266 

1267@admin_bp.route("/automation/workflows/<workflow_id>/enable", methods=["POST"]) 

1268@api_response 

1269def enable_workflow(workflow_id: str): 

1270 """Enable a workflow.""" 

1271 api = get_api() 

1272 if workflow_id not in api._workflows: 

1273 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1274 

1275 api._workflows[workflow_id].enabled = True 

1276 api._save_config() 

1277 return {"workflow": workflow_id, "enabled": True} 

1278 

1279 

1280@admin_bp.route("/automation/workflows/<workflow_id>/disable", methods=["POST"]) 

1281@api_response 

1282def disable_workflow(workflow_id: str): 

1283 """Disable a workflow.""" 

1284 api = get_api() 

1285 if workflow_id not in api._workflows: 

1286 raise FileNotFoundError(f"Workflow {workflow_id} not found") 

1287 

1288 api._workflows[workflow_id].enabled = False 

1289 api._save_config() 

1290 return {"workflow": workflow_id, "enabled": False} 

1291 

1292 

1293# Scheduled Messages 

1294@admin_bp.route("/automation/scheduled-messages", methods=["GET"]) 

1295@api_response 

1296def list_scheduled_messages(): 

1297 """List all scheduled messages.""" 

1298 api = get_api() 

1299 return [s.to_dict() for s in api._scheduled_messages.values()] 

1300 

1301 

1302@admin_bp.route("/automation/scheduled-messages/<message_id>", methods=["GET"]) 

1303@api_response 

1304def get_scheduled_message(message_id: str): 

1305 """Get scheduled message configuration.""" 

1306 api = get_api() 

1307 if message_id not in api._scheduled_messages: 

1308 raise FileNotFoundError(f"Scheduled message {message_id} not found") 

1309 return api._scheduled_messages[message_id].to_dict() 

1310 

1311 

1312@admin_bp.route("/automation/scheduled-messages", methods=["POST"]) 

1313@api_response 

1314def create_scheduled_message(): 

1315 """Create a new scheduled message.""" 

1316 api = get_api() 

1317 data = request.get_json() 

1318 if not data: 

1319 raise ValueError("Request body required") 

1320 

1321 message_id = str(uuid.uuid4()) 

1322 msg = ScheduledMessageSchema( 

1323 id=message_id, 

1324 channel=data.get("channel", ""), 

1325 chat_id=data.get("chat_id", ""), 

1326 message=data.get("message", ""), 

1327 scheduled_time=data.get("scheduled_time", ""), 

1328 recurring=data.get("recurring", False), 

1329 recurrence_pattern=data.get("recurrence_pattern"), 

1330 enabled=data.get("enabled", True), 

1331 ) 

1332 api._scheduled_messages[message_id] = msg 

1333 api._save_config() 

1334 return msg.to_dict() 

1335 

1336 

1337@admin_bp.route("/automation/scheduled-messages/<message_id>", methods=["PUT"]) 

1338@api_response 

1339def update_scheduled_message(message_id: str): 

1340 """Update scheduled message configuration.""" 

1341 api = get_api() 

1342 if message_id not in api._scheduled_messages: 

1343 raise FileNotFoundError(f"Scheduled message {message_id} not found") 

1344 

1345 data = request.get_json() 

1346 existing = api._scheduled_messages[message_id] 

1347 for key, value in data.items(): 

1348 if hasattr(existing, key): 

1349 setattr(existing, key, value) 

1350 

1351 api._save_config() 

1352 return existing.to_dict() 

1353 

1354 

1355@admin_bp.route("/automation/scheduled-messages/<message_id>", methods=["DELETE"]) 

1356@api_response 

1357def delete_scheduled_message(message_id: str): 

1358 """Delete a scheduled message.""" 

1359 api = get_api() 

1360 if message_id not in api._scheduled_messages: 

1361 raise FileNotFoundError(f"Scheduled message {message_id} not found") 

1362 

1363 del api._scheduled_messages[message_id] 

1364 api._save_config() 

1365 return {"deleted": message_id} 

1366 

1367 

1368# ============================================================================ 

1369# IDENTITY ENDPOINTS (15+ endpoints) 

1370# ============================================================================ 

1371 

1372@admin_bp.route("/identity", methods=["GET"]) 

1373@api_response 

1374def get_identity(): 

1375 """Get agent identity configuration.""" 

1376 api = get_api() 

1377 if api._identity: 

1378 return api._identity.to_dict() 

1379 return IdentityConfigSchema().to_dict() 

1380 

1381 

1382@admin_bp.route("/identity", methods=["PUT"]) 

1383@api_response 

1384def update_identity(): 

1385 """Update agent identity configuration.""" 

1386 api = get_api() 

1387 data = request.get_json() 

1388 if not data: 

1389 raise ValueError("Request body required") 

1390 

1391 api._identity = IdentityConfigSchema( 

1392 agent_id=data.get("agent_id", ""), 

1393 display_name=data.get("display_name", ""), 

1394 avatar_url=data.get("avatar_url"), 

1395 bio=data.get("bio", ""), 

1396 personality=data.get("personality", {}), 

1397 response_style=data.get("response_style", {}), 

1398 per_channel_identity=data.get("per_channel_identity", {}), 

1399 ) 

1400 api._save_config() 

1401 return api._identity.to_dict() 

1402 

1403 

1404@admin_bp.route("/identity/avatars", methods=["GET"]) 

1405@api_response 

1406def list_avatars(): 

1407 """List all avatars.""" 

1408 api = get_api() 

1409 return [a.to_dict() for a in api._avatars.values()] 

1410 

1411 

1412@admin_bp.route("/identity/avatars/<avatar_id>", methods=["GET"]) 

1413@api_response 

1414def get_avatar(avatar_id: str): 

1415 """Get avatar configuration.""" 

1416 api = get_api() 

1417 if avatar_id not in api._avatars: 

1418 raise FileNotFoundError(f"Avatar {avatar_id} not found") 

1419 return api._avatars[avatar_id].to_dict() 

1420 

1421 

1422@admin_bp.route("/identity/avatars", methods=["POST"]) 

1423@api_response 

1424def create_avatar(): 

1425 """Create a new avatar.""" 

1426 api = get_api() 

1427 data = request.get_json() 

1428 if not data: 

1429 raise ValueError("Request body required") 

1430 

1431 avatar_id = str(uuid.uuid4()) 

1432 avatar = AvatarSchema( 

1433 id=avatar_id, 

1434 name=data.get("name", ""), 

1435 url=data.get("url", ""), 

1436 channel=data.get("channel"), 

1437 is_default=data.get("is_default", False), 

1438 ) 

1439 api._avatars[avatar_id] = avatar 

1440 api._save_config() 

1441 return avatar.to_dict() 

1442 

1443 

1444@admin_bp.route("/identity/avatars/<avatar_id>", methods=["PUT"]) 

1445@api_response 

1446def update_avatar(avatar_id: str): 

1447 """Update avatar configuration.""" 

1448 api = get_api() 

1449 if avatar_id not in api._avatars: 

1450 raise FileNotFoundError(f"Avatar {avatar_id} not found") 

1451 

1452 data = request.get_json() 

1453 existing = api._avatars[avatar_id] 

1454 for key, value in data.items(): 

1455 if hasattr(existing, key): 

1456 setattr(existing, key, value) 

1457 

1458 api._save_config() 

1459 return existing.to_dict() 

1460 

1461 

1462@admin_bp.route("/identity/avatars/<avatar_id>", methods=["DELETE"]) 

1463@api_response 

1464def delete_avatar(avatar_id: str): 

1465 """Delete an avatar.""" 

1466 api = get_api() 

1467 if avatar_id not in api._avatars: 

1468 raise FileNotFoundError(f"Avatar {avatar_id} not found") 

1469 

1470 del api._avatars[avatar_id] 

1471 api._save_config() 

1472 return {"deleted": avatar_id} 

1473 

1474 

1475@admin_bp.route("/identity/avatars/<avatar_id>/default", methods=["POST"]) 

1476@api_response 

1477def set_default_avatar(avatar_id: str): 

1478 """Set an avatar as the default.""" 

1479 api = get_api() 

1480 if avatar_id not in api._avatars: 

1481 raise FileNotFoundError(f"Avatar {avatar_id} not found") 

1482 

1483 for a in api._avatars.values(): 

1484 a.is_default = False 

1485 api._avatars[avatar_id].is_default = True 

1486 api._save_config() 

1487 return api._avatars[avatar_id].to_dict() 

1488 

1489 

1490@admin_bp.route("/identity/sender-mappings", methods=["GET"]) 

1491@api_response 

1492def list_sender_mappings(): 

1493 """List all sender mappings.""" 

1494 api = get_api() 

1495 return [s.to_dict() for s in api._sender_mappings.values()] 

1496 

1497 

1498@admin_bp.route("/identity/sender-mappings/<mapping_id>", methods=["GET"]) 

1499@api_response 

1500def get_sender_mapping(mapping_id: str): 

1501 """Get sender mapping configuration.""" 

1502 api = get_api() 

1503 if mapping_id not in api._sender_mappings: 

1504 raise FileNotFoundError(f"Sender mapping {mapping_id} not found") 

1505 return api._sender_mappings[mapping_id].to_dict() 

1506 

1507 

1508@admin_bp.route("/identity/sender-mappings", methods=["POST"]) 

1509@api_response 

1510def create_sender_mapping(): 

1511 """Create a new sender mapping.""" 

1512 api = get_api() 

1513 data = request.get_json() 

1514 if not data: 

1515 raise ValueError("Request body required") 

1516 

1517 mapping_id = str(uuid.uuid4()) 

1518 mapping = SenderMappingSchema( 

1519 platform_user_id=data.get("platform_user_id", ""), 

1520 internal_user_id=data.get("internal_user_id", ""), 

1521 channel=data.get("channel", ""), 

1522 display_name=data.get("display_name"), 

1523 metadata=data.get("metadata", {}), 

1524 ) 

1525 api._sender_mappings[mapping_id] = mapping 

1526 api._save_config() 

1527 return mapping.to_dict() 

1528 

1529 

1530@admin_bp.route("/identity/sender-mappings/<mapping_id>", methods=["PUT"]) 

1531@api_response 

1532def update_sender_mapping(mapping_id: str): 

1533 """Update sender mapping configuration.""" 

1534 api = get_api() 

1535 if mapping_id not in api._sender_mappings: 

1536 raise FileNotFoundError(f"Sender mapping {mapping_id} not found") 

1537 

1538 data = request.get_json() 

1539 existing = api._sender_mappings[mapping_id] 

1540 for key, value in data.items(): 

1541 if hasattr(existing, key): 

1542 setattr(existing, key, value) 

1543 

1544 api._save_config() 

1545 return existing.to_dict() 

1546 

1547 

1548@admin_bp.route("/identity/sender-mappings/<mapping_id>", methods=["DELETE"]) 

1549@api_response 

1550def delete_sender_mapping(mapping_id: str): 

1551 """Delete a sender mapping.""" 

1552 api = get_api() 

1553 if mapping_id not in api._sender_mappings: 

1554 raise FileNotFoundError(f"Sender mapping {mapping_id} not found") 

1555 

1556 del api._sender_mappings[mapping_id] 

1557 api._save_config() 

1558 return {"deleted": mapping_id} 

1559 

1560 

1561# ============================================================================ 

1562# PLUGIN ENDPOINTS (10+ endpoints) 

1563# ============================================================================ 

1564 

1565@admin_bp.route("/plugins", methods=["GET"]) 

1566@api_response 

1567def list_plugins(): 

1568 """List all plugins.""" 

1569 api = get_api() 

1570 return [p.to_dict() for p in api._plugins.values()] 

1571 

1572 

1573@admin_bp.route("/plugins/<plugin_id>", methods=["GET"]) 

1574@api_response 

1575def get_plugin(plugin_id: str): 

1576 """Get plugin configuration.""" 

1577 api = get_api() 

1578 if plugin_id not in api._plugins: 

1579 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1580 return api._plugins[plugin_id].to_dict() 

1581 

1582 

1583@admin_bp.route("/plugins", methods=["POST"]) 

1584@api_response 

1585def install_plugin(): 

1586 """Install a new plugin.""" 

1587 api = get_api() 

1588 data = request.get_json() 

1589 if not data: 

1590 raise ValueError("Request body required") 

1591 

1592 plugin_id = data.get("id", str(uuid.uuid4())) 

1593 plugin = PluginConfigSchema( 

1594 id=plugin_id, 

1595 name=data.get("name", ""), 

1596 version=data.get("version", "1.0.0"), 

1597 description=data.get("description", ""), 

1598 enabled=data.get("enabled", True), 

1599 config=data.get("config", {}), 

1600 hooks=data.get("hooks", []), 

1601 dependencies=data.get("dependencies", []), 

1602 ) 

1603 api._plugins[plugin_id] = plugin 

1604 api._save_config() 

1605 return plugin.to_dict() 

1606 

1607 

1608@admin_bp.route("/plugins/<plugin_id>", methods=["PUT"]) 

1609@api_response 

1610def update_plugin(plugin_id: str): 

1611 """Update plugin configuration.""" 

1612 api = get_api() 

1613 if plugin_id not in api._plugins: 

1614 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1615 

1616 data = request.get_json() 

1617 existing = api._plugins[plugin_id] 

1618 for key, value in data.items(): 

1619 if hasattr(existing, key): 

1620 setattr(existing, key, value) 

1621 

1622 api._save_config() 

1623 return existing.to_dict() 

1624 

1625 

1626@admin_bp.route("/plugins/<plugin_id>", methods=["DELETE"]) 

1627@api_response 

1628def uninstall_plugin(plugin_id: str): 

1629 """Uninstall a plugin.""" 

1630 api = get_api() 

1631 if plugin_id not in api._plugins: 

1632 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1633 

1634 del api._plugins[plugin_id] 

1635 api._save_config() 

1636 return {"deleted": plugin_id} 

1637 

1638 

1639@admin_bp.route("/plugins/<plugin_id>/enable", methods=["POST"]) 

1640@api_response 

1641def enable_plugin(plugin_id: str): 

1642 """Enable a plugin.""" 

1643 api = get_api() 

1644 if plugin_id not in api._plugins: 

1645 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1646 

1647 api._plugins[plugin_id].enabled = True 

1648 api._save_config() 

1649 return {"plugin": plugin_id, "enabled": True} 

1650 

1651 

1652@admin_bp.route("/plugins/<plugin_id>/disable", methods=["POST"]) 

1653@api_response 

1654def disable_plugin(plugin_id: str): 

1655 """Disable a plugin.""" 

1656 api = get_api() 

1657 if plugin_id not in api._plugins: 

1658 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1659 

1660 api._plugins[plugin_id].enabled = False 

1661 api._save_config() 

1662 return {"plugin": plugin_id, "enabled": False} 

1663 

1664 

1665@admin_bp.route("/plugins/<plugin_id>/config", methods=["GET"]) 

1666@api_response 

1667def get_plugin_config(plugin_id: str): 

1668 """Get plugin-specific configuration.""" 

1669 api = get_api() 

1670 if plugin_id not in api._plugins: 

1671 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1672 

1673 return api._plugins[plugin_id].config 

1674 

1675 

1676@admin_bp.route("/plugins/<plugin_id>/config", methods=["PUT"]) 

1677@api_response 

1678def update_plugin_config(plugin_id: str): 

1679 """Update plugin-specific configuration.""" 

1680 api = get_api() 

1681 if plugin_id not in api._plugins: 

1682 raise FileNotFoundError(f"Plugin {plugin_id} not found") 

1683 

1684 data = request.get_json() 

1685 api._plugins[plugin_id].config = data 

1686 api._save_config() 

1687 return api._plugins[plugin_id].config 

1688 

1689 

1690# ============================================================================ 

1691# SESSION ENDPOINTS (10+ endpoints) 

1692# ============================================================================ 

1693 

1694@admin_bp.route("/sessions", methods=["GET"]) 

1695@api_response 

1696def list_sessions(): 

1697 """List all active sessions.""" 

1698 api = get_api() 

1699 page = request.args.get("page", 1, type=int) 

1700 page_size = request.args.get("page_size", 20, type=int) 

1701 

1702 sessions = [s.to_dict() for s in api._sessions.values()] 

1703 total = len(sessions) 

1704 start = (page - 1) * page_size 

1705 end = start + page_size 

1706 

1707 return PaginatedResponse( 

1708 items=sessions[start:end], 

1709 total=total, 

1710 page=page, 

1711 page_size=page_size, 

1712 has_next=end < total, 

1713 has_prev=page > 1, 

1714 ).to_dict() 

1715 

1716 

1717@admin_bp.route("/sessions/<session_id>", methods=["GET"]) 

1718@api_response 

1719def get_session(session_id: str): 

1720 """Get session details.""" 

1721 api = get_api() 

1722 if session_id not in api._sessions: 

1723 raise FileNotFoundError(f"Session {session_id} not found") 

1724 return api._sessions[session_id].to_dict() 

1725 

1726 

1727@admin_bp.route("/sessions/<session_id>", methods=["DELETE"]) 

1728@api_response 

1729def terminate_session(session_id: str): 

1730 """Terminate a session.""" 

1731 api = get_api() 

1732 if session_id not in api._sessions: 

1733 raise FileNotFoundError(f"Session {session_id} not found") 

1734 

1735 del api._sessions[session_id] 

1736 return {"terminated": session_id} 

1737 

1738 

1739@admin_bp.route("/sessions/<session_id>/context", methods=["GET"]) 

1740@api_response 

1741def get_session_context(session_id: str): 

1742 """Get session context.""" 

1743 api = get_api() 

1744 if session_id not in api._sessions: 

1745 raise FileNotFoundError(f"Session {session_id} not found") 

1746 

1747 return api._sessions[session_id].context 

1748 

1749 

1750@admin_bp.route("/sessions/<session_id>/context", methods=["PUT"]) 

1751@api_response 

1752def update_session_context(session_id: str): 

1753 """Update session context.""" 

1754 api = get_api() 

1755 if session_id not in api._sessions: 

1756 raise FileNotFoundError(f"Session {session_id} not found") 

1757 

1758 data = request.get_json() 

1759 api._sessions[session_id].context.update(data) 

1760 return api._sessions[session_id].context 

1761 

1762 

1763@admin_bp.route("/sessions/<session_id>/clear-context", methods=["POST"]) 

1764@api_response 

1765def clear_session_context(session_id: str): 

1766 """Clear session context.""" 

1767 api = get_api() 

1768 if session_id not in api._sessions: 

1769 raise FileNotFoundError(f"Session {session_id} not found") 

1770 

1771 api._sessions[session_id].context = {} 

1772 return {"cleared": True} 

1773 

1774 

1775@admin_bp.route("/sessions/pair", methods=["POST"]) 

1776@api_response 

1777def pair_sessions(): 

1778 """Pair two sessions for bridging.""" 

1779 api = get_api() 

1780 data = request.get_json() 

1781 if not data: 

1782 raise ValueError("Request body required") 

1783 

1784 pairing = PairingRequestSchema( 

1785 source_channel=data.get("source_channel", ""), 

1786 source_chat_id=data.get("source_chat_id", ""), 

1787 target_channel=data.get("target_channel", ""), 

1788 target_chat_id=data.get("target_chat_id", ""), 

1789 bidirectional=data.get("bidirectional", True), 

1790 ) 

1791 

1792 pairing_id = str(uuid.uuid4()) 

1793 return {"pairing_id": pairing_id, "paired": True, "config": pairing.to_dict()} 

1794 

1795 

1796@admin_bp.route("/sessions/unpair/<pairing_id>", methods=["DELETE"]) 

1797@api_response 

1798def unpair_sessions(pairing_id: str): 

1799 """Remove session pairing.""" 

1800 return {"pairing_id": pairing_id, "unpaired": True} 

1801 

1802 

1803# ============================================================================ 

1804# METRICS ENDPOINTS (10+ endpoints) 

1805# ============================================================================ 

1806 

1807@admin_bp.route("/metrics", methods=["GET"]) 

1808@api_response 

1809def get_metrics(): 

1810 """Get current system metrics.""" 

1811 api = get_api() 

1812 return MetricsSchema( 

1813 timestamp=datetime.now().isoformat(), 

1814 uptime_seconds=api.get_uptime(), 

1815 total_messages_processed=api._message_count, 

1816 messages_per_minute=0.0, 

1817 active_sessions=len(api._sessions), 

1818 active_channels=len(api._channels), 

1819 queue_depth=0, 

1820 memory_usage_mb=0.0, 

1821 cpu_usage_percent=0.0, 

1822 error_rate=0.0, 

1823 latency_p50_ms=45.0, 

1824 latency_p99_ms=150.0, 

1825 ).to_dict() 

1826 

1827 

1828@admin_bp.route("/metrics/history", methods=["GET"]) 

1829@api_response 

1830def get_metrics_history(): 

1831 """Get historical metrics.""" 

1832 api = get_api() 

1833 limit = request.args.get("limit", 100, type=int) 

1834 return api._metrics_history[-limit:] 

1835 

1836 

1837@admin_bp.route("/metrics/channels", methods=["GET"]) 

1838@api_response 

1839def get_channel_metrics_all(): 

1840 """Get metrics for all channels.""" 

1841 api = get_api() 

1842 return { 

1843 channel: { 

1844 "messages_sent": 0, 

1845 "messages_received": 0, 

1846 "avg_latency_ms": 45, 

1847 "error_rate": 0.01, 

1848 } 

1849 for channel in api._channels 

1850 } 

1851 

1852 

1853@admin_bp.route("/metrics/commands", methods=["GET"]) 

1854@api_response 

1855def get_command_metrics(): 

1856 """Get metrics for all commands.""" 

1857 api = get_api() 

1858 return { 

1859 cmd: { 

1860 "invocations": 0, 

1861 "successful": 0, 

1862 "failed": 0, 

1863 "avg_response_time_ms": 0, 

1864 } 

1865 for cmd in api._commands 

1866 } 

1867 

1868 

1869@admin_bp.route("/metrics/queue", methods=["GET"]) 

1870@api_response 

1871def get_queue_metrics(): 

1872 """Get queue performance metrics.""" 

1873 return { 

1874 "throughput_per_second": 10.0, 

1875 "avg_wait_time_ms": 50.0, 

1876 "avg_processing_time_ms": 100.0, 

1877 "queue_depth": 0, 

1878 "rejected_count": 0, 

1879 } 

1880 

1881 

1882@admin_bp.route("/metrics/errors", methods=["GET"]) 

1883@api_response 

1884def get_error_metrics(): 

1885 """Get error metrics and recent errors.""" 

1886 api = get_api() 

1887 return { 

1888 "total_errors": api._error_count, 

1889 "error_rate": 0.0, 

1890 "recent_errors": [], 

1891 } 

1892 

1893 

1894@admin_bp.route("/metrics/latency", methods=["GET"]) 

1895@api_response 

1896def get_latency_metrics(): 

1897 """Get latency distribution metrics.""" 

1898 return { 

1899 "p50_ms": 45.0, 

1900 "p75_ms": 75.0, 

1901 "p90_ms": 100.0, 

1902 "p95_ms": 125.0, 

1903 "p99_ms": 150.0, 

1904 "max_ms": 500.0, 

1905 } 

1906 

1907 

1908# ============================================================================ 

1909# GLOBAL CONFIGURATION ENDPOINTS (10+ endpoints) 

1910# ============================================================================ 

1911 

1912@admin_bp.route("/config", methods=["GET"]) 

1913@api_response 

1914def get_global_config(): 

1915 """Get global system configuration.""" 

1916 api = get_api() 

1917 return api._global_config.to_dict() 

1918 

1919 

1920@admin_bp.route("/config", methods=["PUT"]) 

1921@api_response 

1922def update_global_config(): 

1923 """Update global system configuration.""" 

1924 api = get_api() 

1925 data = request.get_json() 

1926 if not data: 

1927 raise ValueError("Request body required") 

1928 

1929 if "queue" in data: 

1930 api._global_config.queue = QueueConfigSchema(**data["queue"]) 

1931 if "security" in data: 

1932 api._global_config.security = SecurityConfigSchema(**data["security"]) 

1933 if "media" in data: 

1934 api._global_config.media = MediaConfigSchema(**data["media"]) 

1935 if "response" in data: 

1936 api._global_config.response = ResponseConfigSchema(**data["response"]) 

1937 if "memory" in data: 

1938 api._global_config.memory = MemoryStoreConfigSchema(**data["memory"]) 

1939 if "embodied_ai" in data: 

1940 api._global_config.embodied_ai = EmbodiedAIConfigSchema(**data["embodied_ai"]) 

1941 

1942 api._save_config() 

1943 return api._global_config.to_dict() 

1944 

1945 

1946@admin_bp.route("/config/security", methods=["GET"]) 

1947@api_response 

1948def get_security_config(): 

1949 """Get security configuration.""" 

1950 api = get_api() 

1951 return api._global_config.security.to_dict() 

1952 

1953 

1954@admin_bp.route("/config/security", methods=["PUT"]) 

1955@api_response 

1956def update_security_config(): 

1957 """Update security configuration.""" 

1958 api = get_api() 

1959 data = request.get_json() 

1960 api._global_config.security = SecurityConfigSchema(**data) 

1961 api._save_config() 

1962 return api._global_config.security.to_dict() 

1963 

1964 

1965@admin_bp.route("/config/media", methods=["GET"]) 

1966@api_response 

1967def get_media_config(): 

1968 """Get media handling configuration.""" 

1969 api = get_api() 

1970 return api._global_config.media.to_dict() 

1971 

1972 

1973@admin_bp.route("/config/media", methods=["PUT"]) 

1974@api_response 

1975def update_media_config(): 

1976 """Update media handling configuration.""" 

1977 api = get_api() 

1978 data = request.get_json() 

1979 valid_fields = {f.name for f in MediaConfigSchema.__dataclass_fields__.values()} 

1980 api._global_config.media = MediaConfigSchema(**{k: v for k, v in data.items() if k in valid_fields}) 

1981 api._save_config() 

1982 return api._global_config.media.to_dict() 

1983 

1984 

1985@admin_bp.route("/config/response", methods=["GET"]) 

1986@api_response 

1987def get_response_config(): 

1988 """Get response handling configuration.""" 

1989 api = get_api() 

1990 return api._global_config.response.to_dict() 

1991 

1992 

1993@admin_bp.route("/config/response", methods=["PUT"]) 

1994@api_response 

1995def update_response_config(): 

1996 """Update response handling configuration.""" 

1997 api = get_api() 

1998 data = request.get_json() 

1999 api._global_config.response = ResponseConfigSchema(**data) 

2000 api._save_config() 

2001 return api._global_config.response.to_dict() 

2002 

2003 

2004@admin_bp.route("/config/memory", methods=["GET"]) 

2005@api_response 

2006def get_memory_config(): 

2007 """Get memory store configuration.""" 

2008 api = get_api() 

2009 return api._global_config.memory.to_dict() 

2010 

2011 

2012@admin_bp.route("/config/memory", methods=["PUT"]) 

2013@api_response 

2014def update_memory_config(): 

2015 """Update memory store configuration.""" 

2016 api = get_api() 

2017 data = request.get_json() 

2018 api._global_config.memory = MemoryStoreConfigSchema(**data) 

2019 api._save_config() 

2020 return api._global_config.memory.to_dict() 

2021 

2022 

2023@admin_bp.route("/config/embodied", methods=["GET"]) 

2024@api_response 

2025def get_embodied_config(): 

2026 """Get embodied AI / HevolveAI feed configuration.""" 

2027 api = get_api() 

2028 return api._global_config.embodied_ai.to_dict() 

2029 

2030 

2031@admin_bp.route("/config/embodied", methods=["PUT"]) 

2032@api_response 

2033def update_embodied_config(): 

2034 """Update embodied AI feed configuration and propagate to HevolveAI.""" 

2035 api = get_api() 

2036 data = request.get_json() 

2037 if not data: 

2038 raise ValueError("Request body required") 

2039 api._global_config.embodied_ai = EmbodiedAIConfigSchema(**data) 

2040 api._save_config() 

2041 

2042 # Propagate to HevolveAI runtime if reachable 

2043 _propagate_embodied_config(api._global_config.embodied_ai) 

2044 return api._global_config.embodied_ai.to_dict() 

2045 

2046 

2047@admin_bp.route("/config/embodied/toggle", methods=["POST"]) 

2048@api_response 

2049def toggle_embodied_feed(): 

2050 """Toggle individual feeds on/off at runtime. 

2051 

2052 Body: {"feed": "screen"|"camera"|"audio"|"all", "enabled": true|false} 

2053 """ 

2054 api = get_api() 

2055 data = request.get_json() 

2056 if not data or "feed" not in data: 

2057 raise ValueError("feed and enabled are required") 

2058 

2059 feed = data["feed"] 

2060 enabled = data.get("enabled", True) 

2061 cfg = api._global_config.embodied_ai 

2062 

2063 if feed == "screen": 

2064 cfg.screen_capture_enabled = enabled 

2065 elif feed == "camera": 

2066 cfg.camera_enabled = enabled 

2067 elif feed == "audio": 

2068 cfg.audio_enabled = enabled 

2069 elif feed == "all": 

2070 cfg.enabled = enabled 

2071 cfg.screen_capture_enabled = enabled 

2072 cfg.camera_enabled = enabled 

2073 cfg.audio_enabled = enabled 

2074 else: 

2075 raise ValueError(f"Unknown feed: {feed}. Use screen|camera|audio|all") 

2076 

2077 api._save_config() 

2078 _propagate_embodied_config(cfg) 

2079 _apply_embodied_toggle(feed, enabled, cfg) 

2080 return {"feed": feed, "enabled": enabled, "config": cfg.to_dict()} 

2081 

2082 

2083def _apply_embodied_toggle(feed: str, enabled: bool, cfg) -> None: 

2084 """Start or stop VisionService based on the camera/screen toggle so 

2085 the flag actually controls hardware, not just a config file. Called 

2086 from both the /config/embodied/toggle endpoint AND the agentic 

2087 /api/agent/approval flow so the single start/stop path is shared. 

2088 

2089 'camera' and 'screen' both route to VisionService (the service 

2090 handles both channels via the same WebSocket on :5460). 'audio' 

2091 routes to the diarization sidecar if present. 'all' cascades. 

2092 """ 

2093 try: 

2094 if feed in ('camera', 'screen', 'all'): 

2095 from integrations.vision import get_vision_service 

2096 vs = get_vision_service() 

2097 want_running = ( 

2098 enabled and 

2099 (cfg.camera_enabled or cfg.screen_capture_enabled or cfg.enabled) 

2100 ) 

2101 if want_running and not vs.is_running(): 

2102 mode = 'full' if cfg.enabled else 'lite' 

2103 vs.start(mode=mode) 

2104 logger.info(f"VisionService started (mode={mode}) via {feed} toggle") 

2105 elif not want_running and vs.is_running(): 

2106 vs.stop() 

2107 logger.info(f"VisionService stopped via {feed} toggle") 

2108 except ImportError: 

2109 logger.debug("VisionService not installed — skipping toggle side effect") 

2110 except Exception as e: 

2111 logger.warning(f"VisionService toggle side effect failed: {e}") 

2112 

2113 

2114@admin_bp.route("/config/embodied/status", methods=["GET"]) 

2115@api_response 

2116def get_embodied_status(): 

2117 """Get live status of HevolveAI embodied AI system.""" 

2118 import requests as req 

2119 api = get_api() 

2120 url = api._global_config.embodied_ai.hevolveai_url 

2121 

2122 try: 

2123 resp = req.get(f"{url}/health", timeout=3) 

2124 health = resp.json() if resp.ok else {"status": "unreachable"} 

2125 except Exception: 

2126 health = {"status": "unreachable", "error": "Cannot connect to HevolveAI"} 

2127 

2128 try: 

2129 resp = req.get(f"{url}/v1/stats", timeout=3) 

2130 stats = resp.json() if resp.ok else {} 

2131 except Exception: 

2132 stats = {} 

2133 

2134 return { 

2135 "config": api._global_config.embodied_ai.to_dict(), 

2136 "hevolve_core_health": health, 

2137 "learning_stats": stats, 

2138 } 

2139 

2140 

2141def _propagate_embodied_config(cfg: EmbodiedAIConfigSchema): 

2142 """Push config changes to HevolveAI runtime (best-effort).""" 

2143 import requests as req 

2144 try: 

2145 # HevolveAI reads env vars at startup, but we can hit a 

2146 # runtime config endpoint if available, or set for next restart. 

2147 # For now, write to shared config file that HevolveAI watches. 

2148 config_path = os.path.join( 

2149 os.path.dirname(__file__), '..', '..', '..', 

2150 'agent_data', 'embodied_ai_config.json', 

2151 ) 

2152 os.makedirs(os.path.dirname(config_path), exist_ok=True) 

2153 with open(config_path, 'w') as f: 

2154 json.dump(cfg.to_dict(), f, indent=2) 

2155 logger.info("Embodied AI config propagated to %s", config_path) 

2156 except Exception as e: 

2157 logger.warning("Failed to propagate embodied AI config: %s", e) 

2158 

2159 

2160@admin_bp.route("/config/export", methods=["GET"]) 

2161@api_response 

2162def export_config(): 

2163 """Export all configuration as JSON.""" 

2164 api = get_api() 

2165 return { 

2166 "global": api._global_config.to_dict(), 

2167 "channels": {k: v for k, v in api._channels.items()}, 

2168 "commands": {k: v.to_dict() for k, v in api._commands.items()}, 

2169 "webhooks": {k: v.to_dict() for k, v in api._webhooks.items()}, 

2170 "cron_jobs": {k: v.to_dict() for k, v in api._cron_jobs.items()}, 

2171 "triggers": {k: v.to_dict() for k, v in api._triggers.items()}, 

2172 "workflows": {k: v.to_dict() for k, v in api._workflows.items()}, 

2173 "scheduled_messages": {k: v.to_dict() for k, v in api._scheduled_messages.items()}, 

2174 "plugins": {k: v.to_dict() for k, v in api._plugins.items()}, 

2175 "identity": api._identity.to_dict() if api._identity else None, 

2176 "avatars": {k: v.to_dict() for k, v in api._avatars.items()}, 

2177 "sender_mappings": {k: v.to_dict() for k, v in api._sender_mappings.items()}, 

2178 } 

2179 

2180 

2181@admin_bp.route("/config/import", methods=["POST"]) 

2182@api_response 

2183def import_config(): 

2184 """Import configuration from JSON.""" 

2185 api = get_api() 

2186 data = request.get_json() 

2187 if not data: 

2188 raise ValueError("Request body required") 

2189 

2190 if "global" in data: 

2191 g = data["global"] 

2192 if "queue" in g: 

2193 api._global_config.queue = QueueConfigSchema(**g["queue"]) 

2194 if "security" in g: 

2195 api._global_config.security = SecurityConfigSchema(**g["security"]) 

2196 if "media" in g: 

2197 api._global_config.media = MediaConfigSchema(**g["media"]) 

2198 if "response" in g: 

2199 api._global_config.response = ResponseConfigSchema(**g["response"]) 

2200 if "memory" in g: 

2201 api._global_config.memory = MemoryStoreConfigSchema(**g["memory"]) 

2202 if "embodied_ai" in g: 

2203 api._global_config.embodied_ai = EmbodiedAIConfigSchema(**g["embodied_ai"]) 

2204 

2205 if "channels" in data: 

2206 api._channels = data["channels"] 

2207 

2208 if "commands" in data: 

2209 api._commands = { 

2210 k: CommandConfigSchema(**v) for k, v in data["commands"].items() 

2211 } 

2212 

2213 if "webhooks" in data: 

2214 api._webhooks = { 

2215 k: WebhookConfigSchema(**v) for k, v in data["webhooks"].items() 

2216 } 

2217 

2218 if "cron_jobs" in data: 

2219 api._cron_jobs = { 

2220 k: CronJobSchema(**v) for k, v in data["cron_jobs"].items() 

2221 } 

2222 

2223 if "triggers" in data: 

2224 api._triggers = { 

2225 k: TriggerConfigSchema(**v) for k, v in data["triggers"].items() 

2226 } 

2227 

2228 if "workflows" in data: 

2229 api._workflows = { 

2230 k: WorkflowSchema(**v) for k, v in data["workflows"].items() 

2231 } 

2232 

2233 if "scheduled_messages" in data: 

2234 api._scheduled_messages = { 

2235 k: ScheduledMessageSchema(**v) for k, v in data["scheduled_messages"].items() 

2236 } 

2237 

2238 if "plugins" in data: 

2239 api._plugins = { 

2240 k: PluginConfigSchema(**v) for k, v in data["plugins"].items() 

2241 } 

2242 

2243 if "identity" in data and data["identity"]: 

2244 api._identity = IdentityConfigSchema(**data["identity"]) 

2245 

2246 if "avatars" in data: 

2247 api._avatars = { 

2248 k: AvatarSchema(**v) for k, v in data["avatars"].items() 

2249 } 

2250 

2251 if "sender_mappings" in data: 

2252 api._sender_mappings = { 

2253 k: SenderMappingSchema(**v) for k, v in data["sender_mappings"].items() 

2254 } 

2255 

2256 api._save_config() 

2257 return {"imported": True} 

2258 

2259 

2260@admin_bp.route("/config/reset", methods=["POST"]) 

2261@api_response 

2262def reset_config(): 

2263 """Reset all configuration to defaults.""" 

2264 api = get_api() 

2265 api._global_config = GlobalConfigSchema() 

2266 api._channels = {} 

2267 api._commands = {} 

2268 api._webhooks = {} 

2269 api._cron_jobs = {} 

2270 api._triggers = {} 

2271 api._workflows = {} 

2272 api._scheduled_messages = {} 

2273 api._plugins = {} 

2274 api._sessions = {} 

2275 api._avatars = {} 

2276 api._sender_mappings = {} 

2277 api._identity = None 

2278 api._save_config() 

2279 return {"reset": True} 

2280 

2281 

2282# ============================================================================ 

2283# AGENTS (LIST / PAUSE / RESUME) 

2284# ============================================================================ 

2285# Operators need a single pane to enumerate every registered agent and to stop 

2286# a runaway daemon-backed agent. These endpoints share the canonical agent 

2287# source — the social `users` table with `user_type='agent'` — so they stay in 

2288# lock-step with `GET /api/social/users/<id>/agents` (no parallel data path, 

2289# Gate 4). Pause state is persisted in the agent's `settings.paused` JSON 

2290# field and is honoured by 

2291# ``IdleDetectionService._check_user_dispatchable`` (the shared dispatchability 

2292# check both ``get_idle_agent_personas`` — used by agent_daemon + coding_daemon 

2293# for local goal dispatch — AND ``get_idle_opted_in_agents`` — used by 

2294# peer_discovery for distributed-compute sharing — call into). Pausing the 

2295# agent immediately stops dispatching on the next tick of either daemon. 

2296 

2297def _require_admin_user(): 

2298 """Raise PermissionError unless the authenticated caller is an admin. 

2299 

2300 The blueprint's ``before_request`` gate only verifies the bearer token is 

2301 valid; admin-level operations on every-user resources still need an 

2302 explicit is_admin check. ``api_response`` maps PermissionError to 403. 

2303 """ 

2304 user = getattr(g, 'user', None) 

2305 if user is None or not getattr(user, 'is_admin', False): 

2306 raise PermissionError("Admin privileges required") 

2307 

2308 

2309def _load_agent_daemon_snapshot(): 

2310 """Return a dict snapshot from the canonical AgentDaemon singleton. 

2311 

2312 Used to annotate each returned agent with daemon liveness + last tick. 

2313 Lazy import so the admin blueprint keeps working even if the agent-engine 

2314 package is missing (docker-only deployments). 

2315 """ 

2316 try: 

2317 from integrations.agent_engine.agent_daemon import agent_daemon as daemon 

2318 return { 

2319 'running': bool(getattr(daemon, '_running', False)), 

2320 'tick_count': int(getattr(daemon, '_tick_count', 0) or 0), 

2321 'interval_s': int(getattr(daemon, '_interval', 0) or 0), 

2322 } 

2323 except Exception: 

2324 return {'running': False, 'tick_count': 0, 'interval_s': 0} 

2325 

2326 

2327@admin_bp.route("/agents", methods=["GET"]) 

2328@api_response 

2329def list_agents(): 

2330 """List every registered agent (user_type='agent'). 

2331 

2332 Query params:: 

2333 

2334 ?page=<int, 1-based> default 1 

2335 ?per_page=<int> default 50, capped at 200 

2336 

2337 Response shape:: 

2338 

2339 { 

2340 "daemon": {"running": bool, "tick_count": int, "interval_s": int}, 

2341 "agents": [ 

2342 { 

2343 "id": "<user_id>", 

2344 "username": "<handle>", 

2345 "owner_id": "<user_id or null>", 

2346 "status": "active" | "paused" | "idle", 

2347 "last_tick": "<iso timestamp or null>", 

2348 "daemon_backed": bool, 

2349 "idle_compute_opt_in": bool, 

2350 "voice_profile": {...} | null, 

2351 "tier": "flat" | "regional" | "central" 

2352 }, ... 

2353 ], 

2354 "count": int, # items on this page 

2355 "total": int, # total across all pages 

2356 "page": int, 

2357 "per_page": int, 

2358 "pages": int 

2359 } 

2360 

2361 An agent is ``daemon_backed`` when it has opted into idle compute — that 

2362 is what causes the background AgentDaemon tick to touch it. 

2363 """ 

2364 _require_admin_user() 

2365 from integrations.social.models import User 

2366 db = g.db 

2367 

2368 try: 

2369 page = max(1, int(request.args.get('page', 1))) 

2370 except (TypeError, ValueError): 

2371 page = 1 

2372 try: 

2373 per_page = min(200, max(1, int(request.args.get('per_page', 50)))) 

2374 except (TypeError, ValueError): 

2375 per_page = 50 

2376 

2377 base_q = db.query(User).filter(User.user_type == 'agent') 

2378 total = base_q.count() 

2379 offset = (page - 1) * per_page 

2380 agents_q = (base_q.order_by(User.id) 

2381 .offset(offset).limit(per_page).all()) 

2382 

2383 now = datetime.utcnow() 

2384 daemon_snapshot = _load_agent_daemon_snapshot() 

2385 

2386 out: List[Dict[str, Any]] = [] 

2387 for agent in agents_q: 

2388 settings = agent.settings or {} 

2389 is_paused = bool(settings.get('paused', False)) 

2390 last_active = getattr(agent, 'last_active_at', None) 

2391 # Truth-ground the status — a stale last_active implies idle even if 

2392 # settings.paused is False. 

2393 if is_paused: 

2394 status = 'paused' 

2395 elif last_active and (now - last_active).total_seconds() < 3600: 

2396 status = 'active' 

2397 else: 

2398 status = 'idle' 

2399 

2400 out.append({ 

2401 'id': str(agent.id), 

2402 'username': agent.username, 

2403 'display_name': getattr(agent, 'display_name', None) or agent.username, 

2404 'owner_id': getattr(agent, 'owner_id', None), 

2405 'status': status, 

2406 'last_tick': last_active.isoformat() if last_active else None, 

2407 'last_heartbeat': last_active.isoformat() if last_active else None, 

2408 'daemon_backed': bool(getattr(agent, 'idle_compute_opt_in', False)), 

2409 'idle_compute_opt_in': bool(getattr(agent, 'idle_compute_opt_in', False)), 

2410 'voice_profile': getattr(agent, 'voice_profile', None), 

2411 'tier': getattr(agent, 'role', None) or 'flat', 

2412 }) 

2413 

2414 pages = (total + per_page - 1) // per_page if per_page else 1 

2415 return { 

2416 'daemon': daemon_snapshot, 

2417 'agents': out, 

2418 'count': len(out), 

2419 'total': int(total), 

2420 'page': page, 

2421 'per_page': per_page, 

2422 'pages': int(pages), 

2423 } 

2424 

2425 

2426def _get_agent_or_404(agent_id: str): 

2427 """Fetch an agent by id, raising FileNotFoundError (→ 404 via api_response) 

2428 if the row is missing or isn't an agent-type user.""" 

2429 from integrations.social.models import User 

2430 db = g.db 

2431 agent = db.query(User).filter(User.id == agent_id).first() 

2432 if agent is None or agent.user_type != 'agent': 

2433 raise FileNotFoundError(f"Agent {agent_id!r} not found") 

2434 return agent 

2435 

2436 

2437def _publish_agent_lifecycle(agent_id: str, event: str, payload: Dict[str, Any]): 

2438 """Fire-and-forget WAMP publish on the canonical agent-lifecycle topic. 

2439 

2440 Topic: ``com.hertzai.hevolve.agent.lifecycle.<agent_id>``. Uses the 

2441 shared ``integrations.social.realtime.publish_event`` helper — the SAME 

2442 entry point used by posts/notifications, so the router-level 

2443 cross-user subscribe guard applies uniformly (no parallel path, 

2444 Gate 4). Swallow exceptions — admin actions MUST succeed even if 

2445 Crossbar is down; a subscriber that cares will re-poll /agents. 

2446 """ 

2447 try: 

2448 from integrations.social.realtime import publish_event 

2449 topic = f"com.hertzai.hevolve.agent.lifecycle.{agent_id}" 

2450 data = dict(payload) 

2451 data.setdefault('event', event) 

2452 data.setdefault('agent_id', str(agent_id)) 

2453 # Publish as the admin who made the change so the per-user topic 

2454 # authorizer recognises the emitter. 

2455 actor_id = str(getattr(g, 'user_id', '') or '') 

2456 publish_event(topic, data, user_id=actor_id) 

2457 except Exception as _pub_err: 

2458 logger.debug( 

2459 "admin agent lifecycle publish skipped (agent=%s event=%s): %s", 

2460 agent_id, event, _pub_err) 

2461 

2462 

2463@admin_bp.route("/agents/<agent_id>/pause", methods=["POST"]) 

2464@api_response 

2465def pause_agent(agent_id: str): 

2466 """Mark an agent as paused. Idle-detection will skip it on the next tick. 

2467 

2468 Response:: 

2469 

2470 {"id": "<agent_id>", "status": "paused", "paused_at": "<iso>"} 

2471 """ 

2472 _require_admin_user() 

2473 agent = _get_agent_or_404(agent_id) 

2474 settings = dict(agent.settings or {}) 

2475 settings['paused'] = True 

2476 settings['paused_at'] = datetime.utcnow().isoformat() 

2477 settings['paused_by'] = getattr(g.user, 'id', None) 

2478 agent.settings = settings 

2479 g.db.flush() 

2480 logger.info("admin: paused agent id=%s by=%s", agent_id, 

2481 getattr(g.user, 'id', 'unknown')) 

2482 _publish_agent_lifecycle(str(agent.id), 'paused', { 

2483 'status': 'paused', 

2484 'paused_at': settings['paused_at'], 

2485 'paused_by': settings.get('paused_by'), 

2486 }) 

2487 return { 

2488 'id': str(agent.id), 

2489 'status': 'paused', 

2490 'paused_at': settings['paused_at'], 

2491 } 

2492 

2493 

2494@admin_bp.route("/agents/<agent_id>/resume", methods=["POST"]) 

2495@api_response 

2496def resume_agent(agent_id: str): 

2497 """Un-pause an agent. It immediately becomes eligible for daemon dispatch. 

2498 

2499 Response:: 

2500 

2501 {"id": "<agent_id>", "status": "active", "resumed_at": "<iso>"} 

2502 """ 

2503 _require_admin_user() 

2504 agent = _get_agent_or_404(agent_id) 

2505 settings = dict(agent.settings or {}) 

2506 settings.pop('paused', None) 

2507 settings.pop('paused_at', None) 

2508 settings.pop('paused_by', None) 

2509 resumed_at = datetime.utcnow().isoformat() 

2510 settings['resumed_at'] = resumed_at 

2511 agent.settings = settings 

2512 g.db.flush() 

2513 logger.info("admin: resumed agent id=%s by=%s", agent_id, 

2514 getattr(g.user, 'id', 'unknown')) 

2515 _publish_agent_lifecycle(str(agent.id), 'resumed', { 

2516 'status': 'active', 

2517 'resumed_at': resumed_at, 

2518 'resumed_by': getattr(g.user, 'id', None), 

2519 }) 

2520 return { 

2521 'id': str(agent.id), 

2522 'status': 'active', 

2523 'resumed_at': resumed_at, 

2524 }