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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Admin REST API Blueprint
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"""
16from __future__ import annotations
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
28from flask import Blueprint, request, jsonify, Response, current_app, g
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)
59logger = logging.getLogger(__name__)
61# Create the blueprint
62admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
65@admin_bp.before_request
66def _admin_auth_gate():
67 """Require authenticated user for channel/device config endpoints.
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
77 auth_header = request.headers.get('Authorization', '')
78 if not auth_header.startswith('Bearer '):
79 return jsonify({'success': False, 'error': 'Authentication required'}), 401
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
88 g.user = user
89 g.user_id = str(user.id)
90 g.db = db
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()
107class AdminAPI:
108 """
109 Admin API controller.
111 Manages all configurable components and provides REST endpoints.
112 """
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
134 # Try to load saved configuration
135 self._load_config()
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)
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)
181 def get_uptime(self) -> float:
182 """Get system uptime in seconds."""
183 return time.time() - self._start_time
186# Global API instance
187_api = AdminAPI()
190def get_api() -> AdminAPI:
191 """Get the global API instance."""
192 return _api
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
221# ============================================================================
222# HEALTH & STATUS ENDPOINTS
223# ============================================================================
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()}
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 }
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 }
262# ============================================================================
263# CHANNEL ENDPOINTS (20+ endpoints)
264# ============================================================================
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)
274 channels = list(api._channels.values())
275 total = len(channels)
276 start = (page - 1) * page_size
277 end = start + page_size
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()
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]
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")
308 channel_type = data.get("channel_type")
309 if not channel_type:
310 raise ValueError("channel_type is required")
312 if channel_type in api._channels:
313 raise ValueError(f"Channel {channel_type} already exists")
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()
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")
336 data = request.get_json()
337 if not data:
338 raise ValueError("Request body required")
340 existing = api._channels[channel_type]
341 existing.update(data)
342 api._channels[channel_type] = existing
343 api._save_config()
344 return existing
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")
355 del api._channels[channel_type]
356 api._save_config()
357 return {"deleted": channel_type}
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")
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'
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()
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")
403 api._channels[channel_type]["enabled"] = True
404 api._save_config()
405 return {"channel": channel_type, "enabled": True}
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")
416 api._channels[channel_type]["enabled"] = False
417 api._save_config()
418 return {"channel": channel_type, "enabled": False}
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")
429 config = api._channels[channel_type]
430 if not config.get('enabled'):
431 return {"channel": channel_type, "test_result": "disabled", "latency_ms": 0}
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}
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")
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)}
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")
493 token = config.get('token') or config.get('api_key')
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)}
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")
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 }
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")
537 return api._channels[channel_type].get("rate_limit", {
538 "requests_per_minute": 60,
539 "burst_limit": 10,
540 })
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")
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"]
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")
565 return api._channels[channel_type].get("security", {})
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")
576 data = request.get_json()
577 api._channels[channel_type]["security"] = data
578 api._save_config()
579 return api._channels[channel_type]["security"]
582# ============================================================================
583# QUEUE/PIPELINE ENDPOINTS (15+ endpoints)
584# ============================================================================
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()
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")
603 api._global_config.queue = QueueConfigSchema(**data)
604 api._save_config()
605 return api._global_config.queue.to_dict()
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()
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}
630@admin_bp.route("/queue/pause", methods=["POST"])
631@api_response
632def pause_queue():
633 """Pause queue processing."""
634 return {"paused": True}
637@admin_bp.route("/queue/resume", methods=["POST"])
638@api_response
639def resume_queue():
640 """Resume queue processing."""
641 return {"resumed": True}
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}
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}
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}
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}
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
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
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
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
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
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
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
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
758# ============================================================================
759# COMMAND ENDPOINTS (15+ endpoints)
760# ============================================================================
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)
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
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()
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()
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")
804 name = data.get("name")
805 if not name:
806 raise ValueError("name is required")
808 if name in api._commands:
809 raise ValueError(f"Command {name} already exists")
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()
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")
836 data = request.get_json()
837 if not data:
838 raise ValueError("Request body required")
840 existing = api._commands[command_name]
841 for key, value in data.items():
842 if hasattr(existing, key):
843 setattr(existing, key, value)
845 api._save_config()
846 return existing.to_dict()
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")
857 del api._commands[command_name]
858 api._save_config()
859 return {"deleted": command_name}
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")
870 api._commands[command_name].enabled = True
871 api._save_config()
872 return {"command": command_name, "enabled": True}
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")
883 api._commands[command_name].enabled = False
884 api._save_config()
885 return {"command": command_name, "enabled": False}
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")
896 return {
897 "command": command_name,
898 "invocations": 0,
899 "successful": 0,
900 "failed": 0,
901 "avg_response_time_ms": 0,
902 }
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())
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"]
924# ============================================================================
925# AUTOMATION ENDPOINTS (25+ endpoints)
926# ============================================================================
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()]
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()
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")
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()
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")
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)
987 api._save_config()
988 return existing.to_dict()
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")
999 del api._webhooks[webhook_id]
1000 api._save_config()
1001 return {"deleted": webhook_id}
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")
1012 return {"webhook_id": webhook_id, "test_result": "success", "status_code": 200}
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()]
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()
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")
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()
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")
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)
1071 api._save_config()
1072 return existing.to_dict()
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")
1083 del api._cron_jobs[job_id]
1084 api._save_config()
1085 return {"deleted": job_id}
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")
1096 return {"job_id": job_id, "triggered": True}
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()]
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()
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")
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()
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")
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)
1157 api._save_config()
1158 return existing.to_dict()
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")
1169 del api._triggers[trigger_id]
1170 api._save_config()
1171 return {"deleted": trigger_id}
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()]
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()
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")
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()
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")
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()
1234 api._save_config()
1235 return existing.to_dict()
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")
1246 del api._workflows[workflow_id]
1247 api._save_config()
1248 return {"deleted": workflow_id}
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")
1259 execution_id = str(uuid.uuid4())
1260 return {
1261 "workflow_id": workflow_id,
1262 "execution_id": execution_id,
1263 "status": "started",
1264 }
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")
1275 api._workflows[workflow_id].enabled = True
1276 api._save_config()
1277 return {"workflow": workflow_id, "enabled": True}
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")
1288 api._workflows[workflow_id].enabled = False
1289 api._save_config()
1290 return {"workflow": workflow_id, "enabled": False}
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()]
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()
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")
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()
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")
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)
1351 api._save_config()
1352 return existing.to_dict()
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")
1363 del api._scheduled_messages[message_id]
1364 api._save_config()
1365 return {"deleted": message_id}
1368# ============================================================================
1369# IDENTITY ENDPOINTS (15+ endpoints)
1370# ============================================================================
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()
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")
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()
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()]
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()
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")
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()
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")
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)
1458 api._save_config()
1459 return existing.to_dict()
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")
1470 del api._avatars[avatar_id]
1471 api._save_config()
1472 return {"deleted": avatar_id}
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")
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()
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()]
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()
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")
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()
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")
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)
1544 api._save_config()
1545 return existing.to_dict()
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")
1556 del api._sender_mappings[mapping_id]
1557 api._save_config()
1558 return {"deleted": mapping_id}
1561# ============================================================================
1562# PLUGIN ENDPOINTS (10+ endpoints)
1563# ============================================================================
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()]
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()
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")
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()
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")
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)
1622 api._save_config()
1623 return existing.to_dict()
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")
1634 del api._plugins[plugin_id]
1635 api._save_config()
1636 return {"deleted": plugin_id}
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")
1647 api._plugins[plugin_id].enabled = True
1648 api._save_config()
1649 return {"plugin": plugin_id, "enabled": True}
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")
1660 api._plugins[plugin_id].enabled = False
1661 api._save_config()
1662 return {"plugin": plugin_id, "enabled": False}
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")
1673 return api._plugins[plugin_id].config
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")
1684 data = request.get_json()
1685 api._plugins[plugin_id].config = data
1686 api._save_config()
1687 return api._plugins[plugin_id].config
1690# ============================================================================
1691# SESSION ENDPOINTS (10+ endpoints)
1692# ============================================================================
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)
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
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()
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()
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")
1735 del api._sessions[session_id]
1736 return {"terminated": session_id}
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")
1747 return api._sessions[session_id].context
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")
1758 data = request.get_json()
1759 api._sessions[session_id].context.update(data)
1760 return api._sessions[session_id].context
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")
1771 api._sessions[session_id].context = {}
1772 return {"cleared": True}
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")
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 )
1792 pairing_id = str(uuid.uuid4())
1793 return {"pairing_id": pairing_id, "paired": True, "config": pairing.to_dict()}
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}
1803# ============================================================================
1804# METRICS ENDPOINTS (10+ endpoints)
1805# ============================================================================
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()
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:]
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 }
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 }
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 }
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 }
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 }
1908# ============================================================================
1909# GLOBAL CONFIGURATION ENDPOINTS (10+ endpoints)
1910# ============================================================================
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()
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")
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"])
1942 api._save_config()
1943 return api._global_config.to_dict()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
2047@admin_bp.route("/config/embodied/toggle", methods=["POST"])
2048@api_response
2049def toggle_embodied_feed():
2050 """Toggle individual feeds on/off at runtime.
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")
2059 feed = data["feed"]
2060 enabled = data.get("enabled", True)
2061 cfg = api._global_config.embodied_ai
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")
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()}
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.
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}")
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
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"}
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 = {}
2134 return {
2135 "config": api._global_config.embodied_ai.to_dict(),
2136 "hevolve_core_health": health,
2137 "learning_stats": stats,
2138 }
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)
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 }
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")
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"])
2205 if "channels" in data:
2206 api._channels = data["channels"]
2208 if "commands" in data:
2209 api._commands = {
2210 k: CommandConfigSchema(**v) for k, v in data["commands"].items()
2211 }
2213 if "webhooks" in data:
2214 api._webhooks = {
2215 k: WebhookConfigSchema(**v) for k, v in data["webhooks"].items()
2216 }
2218 if "cron_jobs" in data:
2219 api._cron_jobs = {
2220 k: CronJobSchema(**v) for k, v in data["cron_jobs"].items()
2221 }
2223 if "triggers" in data:
2224 api._triggers = {
2225 k: TriggerConfigSchema(**v) for k, v in data["triggers"].items()
2226 }
2228 if "workflows" in data:
2229 api._workflows = {
2230 k: WorkflowSchema(**v) for k, v in data["workflows"].items()
2231 }
2233 if "scheduled_messages" in data:
2234 api._scheduled_messages = {
2235 k: ScheduledMessageSchema(**v) for k, v in data["scheduled_messages"].items()
2236 }
2238 if "plugins" in data:
2239 api._plugins = {
2240 k: PluginConfigSchema(**v) for k, v in data["plugins"].items()
2241 }
2243 if "identity" in data and data["identity"]:
2244 api._identity = IdentityConfigSchema(**data["identity"])
2246 if "avatars" in data:
2247 api._avatars = {
2248 k: AvatarSchema(**v) for k, v in data["avatars"].items()
2249 }
2251 if "sender_mappings" in data:
2252 api._sender_mappings = {
2253 k: SenderMappingSchema(**v) for k, v in data["sender_mappings"].items()
2254 }
2256 api._save_config()
2257 return {"imported": True}
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}
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.
2297def _require_admin_user():
2298 """Raise PermissionError unless the authenticated caller is an admin.
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")
2309def _load_agent_daemon_snapshot():
2310 """Return a dict snapshot from the canonical AgentDaemon singleton.
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}
2327@admin_bp.route("/agents", methods=["GET"])
2328@api_response
2329def list_agents():
2330 """List every registered agent (user_type='agent').
2332 Query params::
2334 ?page=<int, 1-based> default 1
2335 ?per_page=<int> default 50, capped at 200
2337 Response shape::
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 }
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
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
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())
2383 now = datetime.utcnow()
2384 daemon_snapshot = _load_agent_daemon_snapshot()
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'
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 })
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 }
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
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.
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)
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.
2468 Response::
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 }
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.
2499 Response::
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 }