branch rewiring after math
This commit is contained in:
@@ -1 +1 @@
|
||||
# AI_MAIL apps package
|
||||
# Apps package
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: ai_mail.py - AI_MAIL Branch Orchestrator
|
||||
# Date: 2025-11-08
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/orchestrator
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-08): Initial version - modular architecture
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Entry point orchestrator pattern
|
||||
# - Auto-discovers modules from modules/
|
||||
# - Module interface: handle_command(command, args) -> bool
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
ai_mail Branch - Main Orchestrator
|
||||
|
||||
Modular architecture with auto-discovered modules.
|
||||
Main handles routing, modules implement functionality.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import sys
|
||||
import importlib
|
||||
import argparse
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
# Handle broken pipe gracefully (e.g. output piped to head)
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
# Pre-import dashboard to ensure path is resolved before module discovery
|
||||
# This prevents import failures in handlers that depend on devpulse dashboard
|
||||
try:
|
||||
from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_section # noqa: F401
|
||||
except ImportError:
|
||||
# Dashboard is optional - branch works without it
|
||||
_update_section = None # type: ignore
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for display
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS & CONFIG
|
||||
# =============================================================================
|
||||
|
||||
# Module root
|
||||
MODULE_ROOT = Path(__file__).parent
|
||||
|
||||
# Modules directory
|
||||
MODULES_DIR = MODULE_ROOT / "modules"
|
||||
|
||||
# =============================================================================
|
||||
# HELP DISPLAY
|
||||
# =============================================================================
|
||||
|
||||
def print_help():
|
||||
"""Print drone-compliant help output"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='AI_MAIL Branch Operations - Email system for branch communication',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
inbox - List emails (new + opened)
|
||||
view - View email content (marks as opened)
|
||||
reply - Reply to email (closes + archives)
|
||||
close - Close email(s) without reply (archives)
|
||||
send - Send email to a branch
|
||||
sent - View sent messages
|
||||
contacts - Manage contacts
|
||||
ping - Memory health check
|
||||
dispatch - Dispatch status and log
|
||||
|
||||
EMAIL LIFECYCLE (v2):
|
||||
new → opened → closed
|
||||
- new: Just arrived, never viewed
|
||||
- opened: You've viewed it, not yet resolved
|
||||
- closed: Resolved (replied or dismissed), auto-archived
|
||||
|
||||
USAGE:
|
||||
drone @ai_mail <command> [args]
|
||||
drone @ai_mail --help
|
||||
|
||||
EXAMPLES:
|
||||
# Check mail
|
||||
drone @ai_mail inbox # List all emails
|
||||
drone @ai_mail view abc123 # View email (marks as opened)
|
||||
|
||||
# Resolve emails
|
||||
drone @ai_mail reply abc123 "Thanks!" # Reply + close + archive
|
||||
drone @ai_mail close abc123 # Close single email
|
||||
drone @ai_mail close abc123 def456 ghi789 # Close multiple emails
|
||||
drone @ai_mail close all # Close ALL emails
|
||||
|
||||
# Send mail
|
||||
drone @ai_mail send @seed "Subject" "Msg" # Send to branch
|
||||
drone @ai_mail send @all "Subject" "Msg" # Broadcast to all
|
||||
"""
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION DISPLAY
|
||||
# =============================================================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display discovered modules only (SEED pattern)"""
|
||||
console.print()
|
||||
console.print("[bold cyan]AI_Mail - Branch Communication System[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Email system for branch-to-branch communication[/dim]")
|
||||
console.print()
|
||||
|
||||
# Discover modules
|
||||
modules = discover_modules()
|
||||
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
|
||||
for module in modules:
|
||||
module_name = module.__name__.split('.')[-1]
|
||||
console.print(f" [cyan]•[/cyan] {module_name}")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Run 'python3 ai_mail.py --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""
|
||||
Auto-discover modules from modules/ directory
|
||||
|
||||
Returns:
|
||||
List of module objects with handle_command() function
|
||||
"""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
logger.warning(f"Modules directory not found: {MODULES_DIR}")
|
||||
return modules
|
||||
|
||||
logger.info(f"[{Path(__file__).stem}] Discovering modules...")
|
||||
|
||||
files_found = list(MODULES_DIR.glob("*.py"))
|
||||
|
||||
for file_path in files_found:
|
||||
# Skip __init__.py and private files
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"aipass.ai_mail.apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
# Import module
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
# Check for required interface
|
||||
if hasattr(module, 'handle_command'):
|
||||
modules.append(module)
|
||||
logger.info(f" [+] {module_name}")
|
||||
else:
|
||||
logger.warning(f" [!] {module_name} - missing handle_command()")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" [-] {module_name} - import error: {e}")
|
||||
|
||||
logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules")
|
||||
return modules
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND ROUTING
|
||||
# =============================================================================
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""
|
||||
Route command to appropriate module
|
||||
|
||||
Pattern: Each module's handle_command() returns True if it handled the command
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., 'send', 'inbox', 'sent')
|
||||
args: Additional arguments
|
||||
modules: List of discovered modules
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except BrokenPipeError:
|
||||
logger.info(f"[ai_mail] Broken pipe in {module.__name__} (stdout closed early)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Module {module.__name__} error: {e}")
|
||||
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands to modules"""
|
||||
|
||||
# Parse arguments
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("AI_MAIL v1.0.0")
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Command provided - try to route to modules
|
||||
modules = discover_modules()
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
if not modules:
|
||||
console.print("❌ ERROR: No modules found")
|
||||
return 1
|
||||
|
||||
# Route command
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
console.print(f"❌ ERROR: Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
AI_MAIL Branch - Main Orchestrator
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[AI_MAIL] Failed to load module {module_name}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AI_MAIL] Module {module.__name__} error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help."""
|
||||
modules = discover_modules()
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0 or args[0] in ["--help", "-h", "help"]:
|
||||
print(f"AI_MAIL - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
# Extensions package - Drop-in extensions for branch functionality
|
||||
Regular → Executable
+132
@@ -0,0 +1,132 @@
|
||||
"""AI_Mail handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "ai_mail"
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""
|
||||
Walk the stack to find the actual file that triggered this import.
|
||||
|
||||
Skips:
|
||||
- This file (handlers/__init__.py)
|
||||
- Python's importlib internals
|
||||
- Frozen modules
|
||||
|
||||
Returns tuple: (file_path, import_line) or (None, None)
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
|
||||
# Skip this file
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
|
||||
# Skip Python internals
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
|
||||
# Found a real file - try to get the import line
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
|
||||
return str(Path(filename).resolve()), import_line
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("aipass_core", "MEMORY_BANK", "Nexus"):
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""
|
||||
Block cross-branch handler imports.
|
||||
|
||||
Only code from within the 'ai_mail' branch can import these handlers.
|
||||
External branches must use ai_mail.apps.modules instead.
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
# DEBUG: Print what we found
|
||||
import os
|
||||
if os.environ.get("AIPASS_DEBUG_GUARD"):
|
||||
import sys
|
||||
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
|
||||
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
|
||||
|
||||
if caller_file is None:
|
||||
# Can't determine caller from real files
|
||||
# Check if we're being run from command line (external)
|
||||
# by looking at the raw stack for <string> or <stdin>
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
# Try to get the import line from the frame
|
||||
target_line = "unknown"
|
||||
if frame.code_context:
|
||||
target_line = frame.code_context[0].strip()
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller: interactive/script\n"
|
||||
f" Blocked: {target_line}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" Example:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seed handlers\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
return # Allow if truly can't determine
|
||||
|
||||
# Check if caller is from our branch
|
||||
if f"/{MY_BRANCH}/" in caller_file:
|
||||
return # Same branch, allowed
|
||||
|
||||
# External caller - block access
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" Example:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seed handlers\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: central_writer.py - AI_MAIL Central File Writer
|
||||
# Date: 2025-11-27
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-27): Initial implementation - central file writer
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler tier 3: pure functions, raises exceptions
|
||||
# - No CLI imports (Prax, Rich, etc.)
|
||||
# - Follows Seed handler patterns
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Central Writer Handler
|
||||
|
||||
Aggregates branch inbox stats and writes to AI_MAIL.central.json.
|
||||
This file serves as AI_MAIL's API output for AIPASS dashboard integration.
|
||||
|
||||
Architecture:
|
||||
- Scans all ai_mail.local/inbox.json files across the system
|
||||
- Calculates per-branch unread/total message counts
|
||||
- Writes aggregated stats to /home/aipass/aipass_os/AI_CENTRAL/AI_MAIL.central.json
|
||||
"""
|
||||
|
||||
# CRITICAL: Use importlib to bypass local json/ directory and get stdlib json
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
# Remove current directory from sys.path temporarily to import stdlib json
|
||||
_saved_path = sys.path.copy()
|
||||
sys.path = [p for p in sys.path if 'handlers' not in p]
|
||||
spec = importlib.util.find_spec('json')
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("Failed to find stdlib json module")
|
||||
stdlib_json = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(stdlib_json)
|
||||
sys.path = _saved_path
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
AIPASS_HOME = Path.home() # /home/aipass - scan from user home
|
||||
AI_CENTRAL_DIR = Path.home() / "aipass_os" / "AI_CENTRAL"
|
||||
CENTRAL_FILE = AI_CENTRAL_DIR / "AI_MAIL.central.json"
|
||||
BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CORE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def find_all_inbox_files() -> List[Path]:
|
||||
"""
|
||||
Find all inbox.json files in ai_mail.local directories.
|
||||
|
||||
Scans the entire /home/aipass directory for ai_mail.local/inbox.json files.
|
||||
Excludes backup directories to avoid counting archived data.
|
||||
|
||||
Returns:
|
||||
List of Path objects to inbox.json files
|
||||
|
||||
Raises:
|
||||
OSError: If filesystem scan fails
|
||||
"""
|
||||
inbox_files = []
|
||||
|
||||
# Search pattern: any directory ending in ai_mail.local containing inbox.json
|
||||
for ai_mail_dir in AIPASS_HOME.rglob("ai_mail.local"):
|
||||
# Skip backup/archive directories (but NOT backup_system branch itself)
|
||||
path_str = str(ai_mail_dir)
|
||||
if ".backup" in path_str or ".archive" in path_str or "/backups/" in path_str:
|
||||
continue
|
||||
|
||||
inbox_path = ai_mail_dir / "inbox.json"
|
||||
if inbox_path.exists() and inbox_path.is_file():
|
||||
inbox_files.append(inbox_path)
|
||||
|
||||
return inbox_files
|
||||
|
||||
|
||||
def extract_branch_name(inbox_path: Path) -> str:
|
||||
"""
|
||||
Extract branch name from inbox.json path.
|
||||
|
||||
Given: /home/aipass/seed/ai_mail.local/inbox.json
|
||||
Returns: SEED
|
||||
|
||||
Given: /home/aipass/aipass_core/prax/ai_mail.local/inbox.json
|
||||
Returns: PRAX
|
||||
|
||||
Args:
|
||||
inbox_path: Path to inbox.json file
|
||||
|
||||
Returns:
|
||||
Uppercase branch name
|
||||
"""
|
||||
# Parent of ai_mail.local is the branch directory
|
||||
branch_dir = inbox_path.parent.parent
|
||||
branch_name = branch_dir.name.upper()
|
||||
|
||||
return branch_name
|
||||
|
||||
|
||||
def read_inbox_stats(inbox_path: Path) -> Tuple[int, int]:
|
||||
"""
|
||||
Read unread and total message counts from inbox.json.
|
||||
|
||||
Args:
|
||||
inbox_path: Path to inbox.json file
|
||||
|
||||
Returns:
|
||||
Tuple of (unread_count, total_messages)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If inbox.json doesn't exist
|
||||
json.JSONDecodeError: If inbox.json is malformed
|
||||
KeyError: If required fields are missing
|
||||
"""
|
||||
with open(inbox_path, 'r', encoding='utf-8') as f:
|
||||
inbox_data = stdlib_json.load(f)
|
||||
|
||||
unread = inbox_data.get("unread_count", 0)
|
||||
total = inbox_data.get("total_messages", 0)
|
||||
|
||||
return (unread, total)
|
||||
|
||||
|
||||
def get_valid_branch_names() -> set:
|
||||
"""
|
||||
Load valid branch names from BRANCH_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
Set of uppercase branch names that are registered in the system.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If BRANCH_REGISTRY.json doesn't exist
|
||||
json.JSONDecodeError: If BRANCH_REGISTRY.json is malformed
|
||||
"""
|
||||
with open(BRANCH_REGISTRY, 'r', encoding='utf-8') as f:
|
||||
registry_data = stdlib_json.load(f)
|
||||
|
||||
return {branch["name"].upper() for branch in registry_data.get("branches", [])}
|
||||
|
||||
|
||||
def aggregate_branch_stats() -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Aggregate inbox stats for all branches.
|
||||
|
||||
Scans all branch inbox files and compiles per-branch statistics.
|
||||
Only includes branches that are registered in BRANCH_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
Dict mapping branch names to their stats:
|
||||
{
|
||||
"SEED": {"unread": 5, "total": 8},
|
||||
"DRONE": {"unread": 0, "total": 3}
|
||||
}
|
||||
|
||||
Raises:
|
||||
OSError: If filesystem operations fail
|
||||
json.JSONDecodeError: If any inbox.json is malformed
|
||||
"""
|
||||
branch_stats = {}
|
||||
|
||||
inbox_files = find_all_inbox_files()
|
||||
valid_branches = get_valid_branch_names()
|
||||
|
||||
for inbox_path in inbox_files:
|
||||
try:
|
||||
branch_name = extract_branch_name(inbox_path)
|
||||
|
||||
# Skip branches not in BRANCH_REGISTRY
|
||||
if branch_name not in valid_branches:
|
||||
continue
|
||||
|
||||
unread, total = read_inbox_stats(inbox_path)
|
||||
|
||||
branch_stats[branch_name] = {
|
||||
"unread": unread,
|
||||
"total": total
|
||||
}
|
||||
except (FileNotFoundError, stdlib_json.JSONDecodeError, KeyError) as e:
|
||||
# Skip branches with missing/malformed inbox files
|
||||
# Continue processing other branches
|
||||
continue
|
||||
except Exception as e:
|
||||
# Handler tier 3: raise unexpected errors for caller to handle
|
||||
raise RuntimeError(f"Failed to process {inbox_path}: {e}") from e
|
||||
|
||||
return branch_stats
|
||||
|
||||
|
||||
def calculate_system_totals(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, int]:
|
||||
"""
|
||||
Calculate system-wide totals from branch stats.
|
||||
|
||||
Args:
|
||||
branch_stats: Per-branch statistics
|
||||
|
||||
Returns:
|
||||
Dict with total_unread and total_messages:
|
||||
{"total_unread": 5, "total_messages": 11}
|
||||
"""
|
||||
total_unread = sum(stats["unread"] for stats in branch_stats.values())
|
||||
total_messages = sum(stats["total"] for stats in branch_stats.values())
|
||||
|
||||
return {
|
||||
"total_unread": total_unread,
|
||||
"total_messages": total_messages
|
||||
}
|
||||
|
||||
|
||||
def build_central_data(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Build complete central.json data structure.
|
||||
|
||||
Args:
|
||||
branch_stats: Per-branch statistics
|
||||
|
||||
Returns:
|
||||
Complete data structure ready for JSON serialization
|
||||
"""
|
||||
system_totals = calculate_system_totals(branch_stats)
|
||||
|
||||
return {
|
||||
"service": "ai_mail",
|
||||
"last_updated": datetime.now().date().isoformat(), # Date only - avoids phantom git changes
|
||||
"branch_stats": branch_stats,
|
||||
"system_totals": system_totals
|
||||
}
|
||||
|
||||
|
||||
def write_central_file(data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Write data to AI_MAIL.central.json.
|
||||
|
||||
Args:
|
||||
data: Complete central file data structure
|
||||
|
||||
Raises:
|
||||
OSError: If file write fails
|
||||
PermissionError: If insufficient permissions
|
||||
"""
|
||||
# Ensure AI_CENTRAL directory exists
|
||||
AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(CENTRAL_FILE, 'w', encoding='utf-8') as f:
|
||||
stdlib_json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
def update_central() -> Dict[str, Any]:
|
||||
"""
|
||||
Update AI_MAIL.central.json with current branch inbox stats.
|
||||
|
||||
This is the primary public function for updating central statistics.
|
||||
Should be called whenever mail is sent/received to keep dashboard in sync.
|
||||
|
||||
Process:
|
||||
1. Scans all branch ai_mail.local/inbox.json files
|
||||
2. Aggregates unread and total message counts per branch
|
||||
3. Calculates system-wide totals
|
||||
4. Writes results to /home/aipass/aipass_os/AI_CENTRAL/AI_MAIL.central.json
|
||||
|
||||
Returns:
|
||||
The data written to central file (for logging/verification)
|
||||
|
||||
Raises:
|
||||
OSError: If filesystem operations fail
|
||||
json.JSONDecodeError: If any inbox.json is malformed
|
||||
PermissionError: If insufficient permissions to write central file
|
||||
|
||||
Example:
|
||||
>>> from ai_mail.apps.handlers.central_writer import update_central
|
||||
>>> stats = update_central()
|
||||
>>> print(stats["system_totals"]["total_unread"])
|
||||
5
|
||||
"""
|
||||
# Aggregate statistics from all branches
|
||||
branch_stats = aggregate_branch_stats()
|
||||
|
||||
# Build complete data structure
|
||||
central_data = build_central_data(branch_stats)
|
||||
|
||||
# Write to central file
|
||||
write_central_file(central_data)
|
||||
|
||||
return central_data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI TEST HARNESS
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Handler tier 3: no CLI imports in main code
|
||||
# Test harness can import CLI libraries for display
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print(Panel.fit(
|
||||
"[bold cyan]AI_MAIL Central Writer[/bold cyan]",
|
||||
border_style="bright_blue"
|
||||
))
|
||||
console.print()
|
||||
|
||||
try:
|
||||
console.print("[yellow]Scanning branches...[/yellow]")
|
||||
stats = update_central()
|
||||
|
||||
console.print(f"[green]Updated:[/green] {CENTRAL_FILE}")
|
||||
console.print()
|
||||
|
||||
# Display results in table
|
||||
table = Table(title="Branch Mail Statistics")
|
||||
table.add_column("Branch", style="cyan", no_wrap=True)
|
||||
table.add_column("Unread", justify="right", style="yellow")
|
||||
table.add_column("Total", justify="right", style="blue")
|
||||
|
||||
for branch, data in sorted(stats["branch_stats"].items()):
|
||||
table.add_row(
|
||||
branch,
|
||||
str(data["unread"]),
|
||||
str(data["total"])
|
||||
)
|
||||
|
||||
# Add totals row
|
||||
table.add_section()
|
||||
table.add_row(
|
||||
"[bold]SYSTEM TOTALS[/bold]",
|
||||
f"[bold yellow]{stats['system_totals']['total_unread']}[/bold yellow]",
|
||||
f"[bold blue]{stats['system_totals']['total_messages']}[/bold blue]"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise
|
||||
@@ -0,0 +1 @@
|
||||
# Dispatch handlers package
|
||||
@@ -0,0 +1,673 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: daemon.py - Dispatch Daemon Handler
|
||||
# Date: 2026-02-17
|
||||
# Version: 1.8.0
|
||||
# Category: ai_mail/handlers/dispatch
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.8.0 (2026-03-02): Wire spawn_agent through dispatch_monitor (bounce emails + guaranteed lock cleanup)
|
||||
# - v1.7.0 (2026-03-02): Redirect stderr to log file + reap zombie children (debug silent deaths)
|
||||
# - v1.6.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + session rename for /resume picker
|
||||
# - v1.5.0 (2026-02-22): Stale lock cleanup every cycle + orphaned opened email retry (>30 min)
|
||||
# - v1.4.0 (2026-02-21): Skip spawn if branch has active Claude session (prevent toe-stepping)
|
||||
# - v1.3.0 (2026-02-20): DPLAN-024 Phase 3 - strip heartbeat logic (migrated to assistant plugins)
|
||||
# - v1.2.0 (2026-02-20): DPLAN-024 - fix hardcoded paths, inline lock_utils
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-handler or module imports
|
||||
# - Pure business logic only
|
||||
# - Uses Prax system_logger (FPLAN-0382)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Dispatch Daemon Handler
|
||||
|
||||
Polls registered branch inboxes for --dispatch emails and spawns agents.
|
||||
The daemon IS the continuity - agents are ephemeral, wake-do-exit.
|
||||
|
||||
Architecture:
|
||||
- Polls every N seconds (configurable via safety_config.json)
|
||||
- Spawns via dispatch_monitor wrapper (bounce emails + guaranteed lock cleanup)
|
||||
- Enforces: kill switch, max turns, max dispatches/day, lock files
|
||||
- Tracks daily dispatch counts per branch
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Infrastructure paths
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
AIPASS_HOME = Path.home()
|
||||
|
||||
# Paths
|
||||
CONFIG_FILE = AIPASS_ROOT / "ai_mail" / "safety_config.json"
|
||||
DAEMON_STATE_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "daemon_state.json"
|
||||
DAEMON_LOG_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "dispatch_daemon.log"
|
||||
DAEMON_PID_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "daemon.pid"
|
||||
BRANCH_REGISTRY = AIPASS_HOME / "BRANCH_REGISTRY.json"
|
||||
|
||||
# Telegram notifications (scheduler bot)
|
||||
SCHEDULER_CONFIG = AIPASS_HOME / ".aipass" / "scheduler_config.json"
|
||||
|
||||
# Graceful shutdown
|
||||
SHUTDOWN = False
|
||||
|
||||
|
||||
|
||||
def _notify_telegram(message: str) -> bool:
|
||||
"""Send a notification to Patrick's Telegram via the scheduler bot."""
|
||||
try:
|
||||
with open(SCHEDULER_CONFIG, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
bot_token = config["telegram_bot_token"]
|
||||
chat_id = config["telegram_chat_id"]
|
||||
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
||||
logger.info("Telegram notification skipped (no scheduler config)")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = json.dumps({"chat_id": chat_id, "text": message}).encode("utf-8")
|
||||
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception):
|
||||
logger.info("Telegram notification failed: %s", message[:60])
|
||||
return False
|
||||
|
||||
|
||||
def _handle_signal(signum, _frame):
|
||||
"""Handle shutdown signals for graceful daemon stop."""
|
||||
global SHUTDOWN
|
||||
logger.info(f"Received signal {signum}, shutting down gracefully...")
|
||||
SHUTDOWN = True
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
|
||||
|
||||
def _read_json(filepath: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Read and parse a JSON file, returning None on failure."""
|
||||
if not filepath.exists():
|
||||
return None
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
|
||||
"""Write data to a JSON file, returning success."""
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
"""Write custom-title to the most recent Claude session JSONL for a branch.
|
||||
|
||||
Claude stores sessions at ~/.claude/projects/{encoded-cwd}/*.jsonl.
|
||||
Writing a custom-title entry makes the session identifiable in /resume picker.
|
||||
"""
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = Path.home() / ".claude" / "projects" / encoded_cwd
|
||||
if not projects_dir.exists():
|
||||
return False
|
||||
jsonl_files = sorted(
|
||||
projects_dir.glob("*.jsonl"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
if not jsonl_files:
|
||||
return False
|
||||
latest = jsonl_files[0]
|
||||
session_id = latest.stem
|
||||
entry = json.dumps({
|
||||
"type": "custom-title",
|
||||
"customTitle": name,
|
||||
"sessionId": session_id
|
||||
})
|
||||
try:
|
||||
with open(latest, "a", encoding="utf-8") as f:
|
||||
f.write(entry + "\n")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
if not lock_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(lock_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
except PermissionError:
|
||||
return data # Process exists, can't signal
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
try:
|
||||
lock_time = datetime.fromisoformat(ts)
|
||||
age = (datetime.now() - lock_time).total_seconds()
|
||||
if age > 600:
|
||||
logger.warning(
|
||||
"Stale lock removed at %s (PID %s dead, age %.0fs)",
|
||||
lock_file, pid, age
|
||||
)
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
logger.info("Unparseable lock timestamp at %s", lock_file)
|
||||
# Dead process, remove stale lock
|
||||
logger.warning(
|
||||
"Stale lock removed at %s (PID %s no longer running)", lock_file, pid
|
||||
)
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return None
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("Corrupt lock file removed at %s", lock_file)
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
|
||||
def _acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]:
|
||||
"""Acquire dispatch lock for branch. Atomic creation via O_CREAT|O_EXCL."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": pid,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"branch": str(branch_path)
|
||||
}
|
||||
try:
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
try:
|
||||
os.write(fd, json.dumps(lock_data, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True, "Lock acquired"
|
||||
except FileExistsError:
|
||||
return False, "Lock file already exists"
|
||||
except OSError as e:
|
||||
return False, f"Lock failed: {e}"
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load safety config from JSON file."""
|
||||
DEFAULTS = {
|
||||
"kill_switch_path": str(AIPASS_HOME / ".aipass" / "autonomous_pause"),
|
||||
"poll_interval_seconds": 300,
|
||||
"max_depth": 3,
|
||||
"max_turns_per_wake": 50,
|
||||
"max_dispatches_per_branch_per_day": 10,
|
||||
"session_rotation_cycles": 12,
|
||||
"cold_start_prompt": "Hi. Check inbox, process new emails, update memories when done.",
|
||||
"wake_prompt": "Wake. Check inbox, process new emails, continue work. Update memories when done.",
|
||||
"autonomous_branches": []
|
||||
}
|
||||
|
||||
config = _read_json(CONFIG_FILE)
|
||||
if config is None:
|
||||
return DEFAULTS
|
||||
|
||||
for key, val in DEFAULTS.items():
|
||||
if key not in config:
|
||||
config[key] = val
|
||||
return config
|
||||
|
||||
|
||||
def load_daemon_state() -> Dict[str, Any]:
|
||||
"""Load daemon state (daily counts, session tracking)."""
|
||||
EMPTY_STATE = {"daily_counts": {}, "session_cycles": {}, "date": str(date.today())}
|
||||
|
||||
state = _read_json(DAEMON_STATE_FILE)
|
||||
if state is None:
|
||||
return EMPTY_STATE
|
||||
|
||||
# Reset counts on new day
|
||||
if state.get("date") != str(date.today()):
|
||||
state["daily_counts"] = {}
|
||||
state["date"] = str(date.today())
|
||||
return state
|
||||
|
||||
|
||||
def save_daemon_state(state: Dict[str, Any]) -> None:
|
||||
"""Persist daemon state to disk."""
|
||||
state["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not _write_json(DAEMON_STATE_FILE, state):
|
||||
logger.info(f"Failed to save daemon state to {DAEMON_STATE_FILE}")
|
||||
|
||||
|
||||
def is_kill_switch_active(config: Dict[str, Any]) -> bool:
|
||||
"""Check if the system-wide kill switch is engaged."""
|
||||
kill_path = Path(config.get("kill_switch_path", str(AIPASS_HOME / ".aipass" / "autonomous_pause")))
|
||||
return kill_path.exists()
|
||||
|
||||
|
||||
def _write_pid_file() -> bool:
|
||||
"""Write current PID to daemon.pid. Returns False if another daemon is running."""
|
||||
if DAEMON_PID_FILE.exists():
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
# Process exists — another daemon is running
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
# Stale PID file — process is dead, we can take over
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
# Process exists but we can't signal it
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
return False
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
DAEMON_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DAEMON_PID_FILE.write_text(str(os.getpid()))
|
||||
return True
|
||||
|
||||
|
||||
def _remove_pid_file() -> None:
|
||||
"""Remove the daemon PID file on shutdown."""
|
||||
try:
|
||||
if DAEMON_PID_FILE.exists():
|
||||
stored_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
if stored_pid == os.getpid():
|
||||
DAEMON_PID_FILE.unlink(missing_ok=True)
|
||||
except (ValueError, OSError):
|
||||
DAEMON_PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def get_registered_branches() -> list:
|
||||
"""Load all registered branches from BRANCH_REGISTRY.json."""
|
||||
data = _read_json(BRANCH_REGISTRY)
|
||||
if data is None:
|
||||
return []
|
||||
return data.get("branches", [])
|
||||
|
||||
|
||||
def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Check a branch's inbox for unprocessed --dispatch emails.
|
||||
|
||||
Returns the first eligible dispatch email, prioritizing new emails.
|
||||
Also retries opened dispatch emails orphaned for >30 min (agent crashed
|
||||
before completing).
|
||||
"""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_data = _read_json(inbox_file)
|
||||
if inbox_data is None:
|
||||
return None
|
||||
|
||||
orphan_threshold_seconds = 1800 # 30 minutes
|
||||
|
||||
# Priority 1: new dispatch emails
|
||||
for msg in inbox_data.get("messages", []):
|
||||
if msg.get("auto_execute") and msg.get("status") == "new":
|
||||
return msg
|
||||
|
||||
# Priority 2: opened dispatch emails orphaned >30 min
|
||||
now = datetime.now()
|
||||
for msg in inbox_data.get("messages", []):
|
||||
if msg.get("auto_execute") and msg.get("status") == "opened":
|
||||
ts = msg.get("timestamp", "")
|
||||
if not ts:
|
||||
continue
|
||||
try:
|
||||
msg_time = datetime.fromisoformat(ts)
|
||||
age = (now - msg_time).total_seconds()
|
||||
if age > orphan_threshold_seconds:
|
||||
logger.warning(
|
||||
"Retrying orphaned dispatch email %s (opened %.0f min ago)",
|
||||
msg.get("id", "?"), age / 60
|
||||
)
|
||||
return msg
|
||||
except (ValueError, TypeError):
|
||||
logger.info("Unparseable timestamp for email %s", msg.get("id", "?"))
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def count_new_emails(branch_path: Path) -> int:
|
||||
"""Count new (unread) emails in a branch's inbox."""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_data = _read_json(inbox_file)
|
||||
if inbox_data is None:
|
||||
return 0
|
||||
return sum(1 for m in inbox_data.get("messages", []) if m.get("status") == "new")
|
||||
|
||||
|
||||
def spawn_agent(
|
||||
branch_path: Path,
|
||||
branch_email: str,
|
||||
message: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
state: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Spawn a Claude agent at the target branch via dispatch_monitor wrapper.
|
||||
|
||||
The monitor wraps the claude process, providing:
|
||||
- Bounce emails on agent failure (return-to-sender)
|
||||
- Guaranteed lock cleanup on exit (agent doesn't need to know about locks)
|
||||
- Stderr capture for diagnostics
|
||||
|
||||
Args:
|
||||
branch_path: Path to target branch
|
||||
branch_email: Branch email (e.g., @flow)
|
||||
message: The dispatch email message dict
|
||||
config: Safety config
|
||||
state: Daemon state (for cycle tracking)
|
||||
|
||||
Returns:
|
||||
True if monitor was spawned successfully
|
||||
"""
|
||||
sender = message.get("from", "unknown")
|
||||
msg_id = message.get("id", "unknown")
|
||||
subject = message.get("subject", "")
|
||||
max_turns = config.get("max_turns_per_wake", 15)
|
||||
|
||||
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
|
||||
|
||||
# Prompt — no lock cleanup instruction (dispatch_monitor handles it)
|
||||
prompt = (
|
||||
f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). "
|
||||
f"Execute it. Send confirmation when done."
|
||||
)
|
||||
|
||||
claude_cmd = [
|
||||
"claude", "-c", "-p", prompt,
|
||||
"--max-turns", str(max_turns),
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json"
|
||||
]
|
||||
|
||||
# Build monitor command (dispatch_monitor wraps claude, handles bounce + lock cleanup)
|
||||
MONITOR_SCRIPT = AIPASS_ROOT / "ai_mail" / "apps" / "handlers" / "dispatch" / "dispatch_monitor.py"
|
||||
LOG_DIR = branch_path / "ai_mail.local"
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STDERR_LOG = str(LOG_DIR / "agent_stderr.log")
|
||||
|
||||
monitor_cmd = [
|
||||
sys.executable, str(MONITOR_SCRIPT),
|
||||
branch_email, lock_file_path, sender, STDERR_LOG,
|
||||
"--", *claude_cmd
|
||||
]
|
||||
|
||||
spawn_env = os.environ.copy()
|
||||
spawn_env["AIPASS_SPAWNED"] = "1"
|
||||
spawn_env["AIPASS_SESSION_TYPE"] = "daemon"
|
||||
# Strip CLAUDE* vars (prevent nested session) and AIPASS_BOT_ID (prevent log leak to parent chat)
|
||||
for key in list(spawn_env.keys()):
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
# Set session name for /resume picker (daemon always uses -c resume)
|
||||
spawn_branch_name = branch_email.lstrip("@").upper()
|
||||
_set_session_name(branch_path, f"{spawn_branch_name}-daemon")
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
|
||||
# Lock PID = monitor PID (stays alive as long as claude does)
|
||||
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid)
|
||||
if not acquired:
|
||||
logger.info(f"Lock acquisition failed after spawn for {branch_email}: {lock_msg}")
|
||||
|
||||
# Track session cycles for rotation
|
||||
cycles = state.get("session_cycles", {})
|
||||
branch_key = str(branch_path)
|
||||
cycles[branch_key] = cycles.get(branch_key, 0) + 1
|
||||
state["session_cycles"] = cycles
|
||||
|
||||
# Increment daily count
|
||||
daily = state.get("daily_counts", {})
|
||||
daily[branch_email] = daily.get(branch_email, 0) + 1
|
||||
state["daily_counts"] = daily
|
||||
|
||||
# Desktop notification — show who woke and why
|
||||
notif_title = f"Daemon → {branch_email}"
|
||||
notif_body = f"Task from {sender}: \"{subject[:80]}\"" if subject else f"Dispatch from {sender}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", "-i", "dialog-information", notif_title, notif_body],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
||||
logger.info(f"Desktop notification unavailable for {branch_email}")
|
||||
|
||||
logger.info(f"SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject=\"{subject[:60]}\"")
|
||||
_notify_telegram(f"[Dispatch] {branch_email} woke\nTask from {sender}: {subject[:80]}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"SPAWN FAILED {branch_email}: {e}")
|
||||
_notify_telegram(f"[Dispatch FAILED] {branch_email}\n{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def is_protected_branch(branch_email: str) -> bool:
|
||||
"""Check if a branch is protected from auto-dispatch."""
|
||||
return branch_email == "@dev_central"
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
try:
|
||||
with open(f'/proc/{pid_str}/environ', 'rb') as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b'\0'):
|
||||
if entry.startswith(b'AIPASS_SESSION_TYPE='):
|
||||
return entry.split(b'=', 1)[1].decode('utf-8')
|
||||
except (OSError, PermissionError):
|
||||
logger.info("Cannot read session type for PID %s", pid_str)
|
||||
return 'interactive'
|
||||
|
||||
|
||||
# Session types that should NOT block dispatch (idle/background sessions)
|
||||
_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
"""
|
||||
Check if an interactive Claude session is running in this branch.
|
||||
|
||||
Only interactive sessions block dispatch. Telegram, dispatched, and daemon
|
||||
sessions are idle/background and should not prevent new agent spawns.
|
||||
"""
|
||||
resolved = branch_path.resolve()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-x', 'claude'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
for pid_str in result.stdout.strip().split('\n'):
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
cwd = os.readlink(f'/proc/{pid_str}/cwd')
|
||||
if Path(cwd).resolve() == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("Cannot read cwd for PID %s", pid_str)
|
||||
continue
|
||||
except Exception:
|
||||
logger.info("Failed to check branch occupancy for %s", branch_path)
|
||||
return False
|
||||
|
||||
|
||||
def poll_cycle(config: Dict[str, Any], state: Dict[str, Any]) -> int:
|
||||
"""
|
||||
Run one poll cycle across all registered branches.
|
||||
|
||||
Returns:
|
||||
Number of agents spawned this cycle
|
||||
"""
|
||||
branches = get_registered_branches()
|
||||
autonomous_list = config.get("autonomous_branches", [])
|
||||
max_daily = config.get("max_dispatches_per_branch_per_day", 10)
|
||||
spawned = 0
|
||||
|
||||
for branch in branches:
|
||||
if SHUTDOWN:
|
||||
break
|
||||
|
||||
branch_email = branch.get("email", "")
|
||||
branch_path_str = branch.get("path", "")
|
||||
if not branch_email or not branch_path_str:
|
||||
continue
|
||||
|
||||
branch_path = Path(branch_path_str)
|
||||
|
||||
if is_protected_branch(branch_email):
|
||||
continue
|
||||
|
||||
if autonomous_list and branch_email not in autonomous_list:
|
||||
continue
|
||||
|
||||
daily_count = state.get("daily_counts", {}).get(branch_email, 0)
|
||||
if daily_count >= max_daily:
|
||||
logger.info(f"SKIP {branch_email}: daily limit reached ({daily_count}/{max_daily})")
|
||||
continue
|
||||
|
||||
# Always check/clean stale locks (even without dispatch emails)
|
||||
existing_lock = _check_lock(branch_path)
|
||||
if existing_lock is not None:
|
||||
logger.info(f"SKIP {branch_email}: active lock (PID {existing_lock.get('pid', '?')})")
|
||||
continue
|
||||
|
||||
dispatch_msg = check_inbox_for_dispatch(branch_path)
|
||||
if dispatch_msg is None:
|
||||
continue
|
||||
|
||||
if _is_branch_occupied(branch_path):
|
||||
logger.info(f"SKIP {branch_email}: active Claude session detected (email already in inbox)")
|
||||
continue
|
||||
|
||||
if spawn_agent(branch_path, branch_email, dispatch_msg, config, state):
|
||||
spawned += 1
|
||||
|
||||
return spawned
|
||||
|
||||
|
||||
def run_daemon() -> None:
|
||||
"""
|
||||
Main daemon loop. Polls inboxes at configured interval, spawns agents.
|
||||
|
||||
Exits gracefully on SIGTERM/SIGINT or kill switch.
|
||||
"""
|
||||
if not _write_pid_file():
|
||||
return
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"DISPATCH DAEMON STARTING (PID {os.getpid()})")
|
||||
logger.info("=" * 60)
|
||||
_notify_telegram(f"[Daemon] Started (PID {os.getpid()})")
|
||||
|
||||
config = load_config()
|
||||
poll_interval = config.get("poll_interval_seconds", 300)
|
||||
|
||||
logger.info(f"Poll interval: {poll_interval}s")
|
||||
logger.info(f"Kill switch: {config.get('kill_switch_path')}")
|
||||
logger.info(f"Max turns/wake: {config.get('max_turns_per_wake')}")
|
||||
logger.info(f"Max dispatches/branch/day: {config.get('max_dispatches_per_branch_per_day')}")
|
||||
|
||||
autonomous = config.get("autonomous_branches", [])
|
||||
if autonomous:
|
||||
logger.info(f"Autonomous branches: {', '.join(autonomous)}")
|
||||
else:
|
||||
logger.info("Autonomous branches: ALL (no filter)")
|
||||
|
||||
cycle_count = 0
|
||||
|
||||
while not SHUTDOWN:
|
||||
# Reap zombie children from previously spawned agents
|
||||
try:
|
||||
while True:
|
||||
pid, _ = os.waitpid(-1, os.WNOHANG)
|
||||
if pid == 0:
|
||||
break
|
||||
logger.info(f"Reaped child process PID {pid}")
|
||||
except ChildProcessError:
|
||||
logger.info("No child processes to reap")
|
||||
|
||||
if is_kill_switch_active(config):
|
||||
logger.info("Kill switch ACTIVE - pausing all dispatches")
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
config = load_config()
|
||||
poll_interval = config.get("poll_interval_seconds", 300)
|
||||
|
||||
state = load_daemon_state()
|
||||
cycle_count += 1
|
||||
|
||||
logger.info(f"--- Poll cycle {cycle_count} ---")
|
||||
|
||||
spawned = poll_cycle(config, state)
|
||||
|
||||
if spawned > 0:
|
||||
logger.info(f"Cycle {cycle_count}: spawned {spawned} agent(s)")
|
||||
|
||||
save_daemon_state(state)
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < poll_interval and not SHUTDOWN:
|
||||
time.sleep(min(5, poll_interval - elapsed))
|
||||
elapsed += 5
|
||||
|
||||
_remove_pid_file()
|
||||
logger.info("DISPATCH DAEMON STOPPED")
|
||||
_notify_telegram("[Daemon] Stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_daemon()
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: dispatch_monitor.py - Agent Lifecycle Monitor
|
||||
# Date: 2026-03-02
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/dispatch
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-03-02): Initial — wraps claude spawn, handles cleanup + bounce on failure
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-handler or module imports
|
||||
# - Runs as a detached background process
|
||||
# - Uses subprocess to send bounce emails (avoids imports)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Agent Lifecycle Monitor
|
||||
|
||||
Wraps a Claude agent spawn. Instead of fire-and-forget Popen, this process:
|
||||
1. Runs claude and WAITS for it to complete
|
||||
2. Checks exit code
|
||||
3. On failure: sends return-to-sender bounce email
|
||||
4. Always cleans up the dispatch lock
|
||||
|
||||
Spawned by wake.py in place of claude directly. The lock PID points to
|
||||
the monitor (which stays alive as long as claude does), so lock validity
|
||||
is guaranteed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
def _send_bounce(branch_email: str, reason: str, sender: str,
|
||||
lock_file: str, stderr_log: str) -> bool:
|
||||
"""Send return-to-sender bounce email via drone."""
|
||||
subject = f"BOUNCE: Dispatch to {branch_email} failed"
|
||||
|
||||
# Read last few lines of stderr log for diagnostics
|
||||
stderr_tail = ""
|
||||
try:
|
||||
with open(stderr_log, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
stderr_tail = "".join(lines[-20:]).strip()
|
||||
except (OSError, FileNotFoundError):
|
||||
stderr_tail = "(no stderr captured)"
|
||||
|
||||
body = (
|
||||
f"Agent at {branch_email} exited abnormally.\n\n"
|
||||
f"Reason: {reason}\n\n"
|
||||
f"Stderr (last 20 lines):\n{stderr_tail}\n\n"
|
||||
f"Lock file cleaned automatically.\n"
|
||||
f"Re-dispatch if task was not completed."
|
||||
)
|
||||
|
||||
# Send via drone (resolves paths, handles routing)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@ai_mail", "send", sender, subject, body],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(Path(lock_file).parent.parent)
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
# Fallback: write bounce to a file if email fails
|
||||
try:
|
||||
bounce_file = Path(lock_file).parent / "last_bounce.json"
|
||||
with open(bounce_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"branch": branch_email,
|
||||
"reason": reason,
|
||||
"sender": sender,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"stderr_tail": stderr_tail
|
||||
}, f, indent=2)
|
||||
except OSError:
|
||||
logger.info("[monitor] Failed to write bounce file fallback")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Usage: dispatch_monitor.py <branch_email> <lock_file> <sender> <stderr_log> -- <claude_args...>
|
||||
|
||||
Runs claude, waits for completion, handles cleanup.
|
||||
"""
|
||||
if len(sys.argv) < 6 or "--" not in sys.argv:
|
||||
logger.warning("[monitor] Invalid arguments: %s", sys.argv)
|
||||
sys.exit(1)
|
||||
|
||||
sep_idx = sys.argv.index("--")
|
||||
branch_email = sys.argv[1]
|
||||
lock_file = sys.argv[2]
|
||||
sender = sys.argv[3]
|
||||
stderr_log = sys.argv[4]
|
||||
claude_cmd = sys.argv[sep_idx + 1:]
|
||||
|
||||
if not claude_cmd:
|
||||
logger.warning("[monitor] No claude command after --")
|
||||
sys.exit(1)
|
||||
|
||||
# Open stderr log for claude output
|
||||
try:
|
||||
stderr_fh = open(stderr_log, 'a', encoding='utf-8')
|
||||
stderr_fh.write(f"\n--- Monitor for {branch_email} started at "
|
||||
f"{time.strftime('%Y-%m-%dT%H:%M:%S')} (PID {os.getpid()}) ---\n")
|
||||
stderr_fh.flush()
|
||||
except OSError:
|
||||
stderr_fh = subprocess.DEVNULL
|
||||
|
||||
# Prepare env — strip CLAUDE* vars and AIPASS_BOT_ID
|
||||
spawn_env = os.environ.copy()
|
||||
spawn_env["AIPASS_SPAWNED"] = "1"
|
||||
spawn_env["AIPASS_SESSION_TYPE"] = "dispatched"
|
||||
for key in list(spawn_env.keys()):
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
# Extract CWD from lock file path (branch_path/ai_mail.local/.dispatch.lock)
|
||||
lock_path = Path(lock_file)
|
||||
branch_path = lock_path.parent.parent
|
||||
cwd = str(branch_path)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run claude — BLOCKING. Monitor stays alive as long as agent is working.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
claude_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=stderr_fh,
|
||||
cwd=cwd,
|
||||
env=spawn_env,
|
||||
timeout=7200 # 2 hour hard timeout
|
||||
)
|
||||
exit_code = result.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
exit_code = -1
|
||||
reason = "Agent timed out (2 hour limit)"
|
||||
_send_bounce(branch_email, reason, sender, lock_file, stderr_log)
|
||||
except Exception as e:
|
||||
exit_code = -2
|
||||
reason = f"Monitor error: {type(e).__name__}: {e}"
|
||||
_send_bounce(branch_email, reason, sender, lock_file, stderr_log)
|
||||
|
||||
duration = int(time.time() - start_time)
|
||||
|
||||
# Log completion
|
||||
if not isinstance(stderr_fh, int):
|
||||
try:
|
||||
stderr_fh.write(f"\n--- Agent exited: code={exit_code}, duration={duration}s ---\n")
|
||||
stderr_fh.flush()
|
||||
stderr_fh.close()
|
||||
except OSError:
|
||||
logger.info("[monitor] Failed to write agent exit log")
|
||||
|
||||
# Check exit code and handle failure
|
||||
if exit_code != 0:
|
||||
reason = f"Exit code {exit_code} after {duration}s"
|
||||
|
||||
# Check stderr log for clues
|
||||
try:
|
||||
with open(stderr_log, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if "rate_limit" in content.lower() or "429" in content:
|
||||
reason = f"API rate limit hit (exit {exit_code}, {duration}s)"
|
||||
elif "overloaded" in content.lower() or "529" in content:
|
||||
reason = f"API overloaded (exit {exit_code}, {duration}s)"
|
||||
elif "network" in content.lower() or "connection" in content.lower():
|
||||
reason = f"Network error (exit {exit_code}, {duration}s)"
|
||||
except OSError:
|
||||
logger.info("[monitor] Failed to read stderr log for diagnostics")
|
||||
|
||||
_send_bounce(branch_email, reason, sender, lock_file, stderr_log)
|
||||
|
||||
# Always clean up lock file — monitor handles this, agent doesn't need to
|
||||
try:
|
||||
if os.path.exists(lock_file):
|
||||
os.unlink(lock_file)
|
||||
except OSError:
|
||||
logger.info("[monitor] Failed to clean up lock file %s", lock_file)
|
||||
|
||||
# Desktop notification on completion
|
||||
status = "completed" if exit_code == 0 else f"FAILED (code {exit_code})"
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", "-i",
|
||||
"dialog-information" if exit_code == 0 else "dialog-warning",
|
||||
f"Agent {branch_email} {status}",
|
||||
f"Duration: {duration}s"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
||||
logger.info("[monitor] Desktop notification unavailable")
|
||||
|
||||
sys.exit(0 if exit_code == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: pending_work.py - Pending Work Handler
|
||||
# Date: 2026-02-17
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/dispatch
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-17): Initial version - per-branch pending work tracking
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Pending Work Handler
|
||||
|
||||
Read/write utils for .pending_work.json per branch.
|
||||
Tracks dispatch workflows, waiting_for states, and next_action queues.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
PENDING_WORK_FILENAME = ".pending_work.json"
|
||||
|
||||
|
||||
def _get_pending_path(branch_path: Path) -> Path:
|
||||
"""Get the pending work file path for a branch."""
|
||||
if branch_path == Path("/") or branch_path == Path.home():
|
||||
return Path.home() / "ai_mail.local" / PENDING_WORK_FILENAME
|
||||
return branch_path / "ai_mail.local" / PENDING_WORK_FILENAME
|
||||
|
||||
|
||||
def load_pending_work(branch_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load pending work for a branch.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
|
||||
Returns:
|
||||
Pending work dict with 'workflows' array
|
||||
"""
|
||||
pending_path = _get_pending_path(branch_path)
|
||||
|
||||
if not pending_path.exists():
|
||||
return {"workflows": []}
|
||||
|
||||
try:
|
||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if "workflows" not in data:
|
||||
data["workflows"] = []
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"workflows": []}
|
||||
|
||||
|
||||
def save_pending_work(branch_path: Path, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save pending work for a branch.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
data: Pending work dict to save
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
pending_path = _get_pending_path(branch_path)
|
||||
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
data["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(pending_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def add_workflow(
|
||||
branch_path: Path,
|
||||
dispatch_id: str,
|
||||
dispatched_to: str,
|
||||
subject: str,
|
||||
waiting_for: Optional[str] = None,
|
||||
next_action: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Add a workflow entry to a branch's pending work.
|
||||
|
||||
Adds a new dispatch workflow to track pending communications and actions.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
dispatch_id: Message ID of the dispatched email
|
||||
dispatched_to: Target branch email (e.g., @flow)
|
||||
subject: Subject of the dispatched email
|
||||
waiting_for: What the branch is waiting for (e.g., reply from @flow)
|
||||
next_action: What to do when the response arrives
|
||||
|
||||
Returns:
|
||||
True if added successfully
|
||||
"""
|
||||
data = load_pending_work(branch_path)
|
||||
|
||||
entry = {
|
||||
"dispatch_id": dispatch_id,
|
||||
"dispatched_to": dispatched_to,
|
||||
"subject": subject,
|
||||
"status": "waiting",
|
||||
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
if waiting_for:
|
||||
entry["waiting_for"] = waiting_for
|
||||
if next_action:
|
||||
entry["next_action"] = next_action
|
||||
|
||||
data["workflows"].append(entry)
|
||||
return save_pending_work(branch_path, data)
|
||||
|
||||
|
||||
def clear_workflow(branch_path: Path, dispatch_id: str) -> bool:
|
||||
"""
|
||||
Remove a completed workflow entry.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
dispatch_id: Message ID to remove
|
||||
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
data = load_pending_work(branch_path)
|
||||
data["workflows"] = [
|
||||
w for w in data["workflows"]
|
||||
if w.get("dispatch_id") != dispatch_id
|
||||
]
|
||||
return save_pending_work(branch_path, data)
|
||||
|
||||
|
||||
def has_pending_work(branch_path: Path) -> bool:
|
||||
"""
|
||||
Check if a branch has any pending workflows.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
|
||||
Returns:
|
||||
True if there are active workflows
|
||||
"""
|
||||
data = load_pending_work(branch_path)
|
||||
return len(data.get("workflows", [])) > 0
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: status.py - Dispatch Status Handler
|
||||
# Date: 2026-02-02
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/dispatch
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-02): Initial version - dispatch log operations
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Dispatch Status Handler
|
||||
|
||||
Handles dispatch log storage and status checking.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Dispatch log location
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
DISPATCH_LOG_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "dispatch_log.json"
|
||||
|
||||
|
||||
def load_dispatch_log() -> List[Dict[str, Any]]:
|
||||
"""Load dispatch log from JSON file"""
|
||||
if not DISPATCH_LOG_FILE.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(DISPATCH_LOG_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("dispatches", [])
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return []
|
||||
|
||||
|
||||
def save_dispatch_log(dispatches: List[Dict[str, Any]]) -> bool:
|
||||
"""Save dispatch log to JSON file"""
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
DISPATCH_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Keep last 50 dispatches
|
||||
dispatches = dispatches[-50:]
|
||||
|
||||
data = {
|
||||
"last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"dispatches": dispatches
|
||||
}
|
||||
|
||||
with open(DISPATCH_LOG_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
|
||||
def log_dispatch(branch: str, pid: Optional[int], status: str, error_msg: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Log a dispatch event.
|
||||
|
||||
Args:
|
||||
branch: Target branch email (e.g., @flow)
|
||||
pid: Process ID if successful
|
||||
status: 'spawned' or 'failed'
|
||||
error_msg: Error message if failed
|
||||
|
||||
Returns:
|
||||
True if logged successfully
|
||||
"""
|
||||
dispatches = load_dispatch_log()
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"branch": branch,
|
||||
"pid": pid,
|
||||
"status": status,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
|
||||
if error_msg:
|
||||
entry["error"] = error_msg
|
||||
|
||||
dispatches.append(entry)
|
||||
return save_dispatch_log(dispatches)
|
||||
|
||||
|
||||
def check_pid_status(pid: int) -> str:
|
||||
"""
|
||||
Check if a PID is still running.
|
||||
|
||||
Returns:
|
||||
'RUNNING', 'COMPLETED', or 'UNKNOWN'
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ps', '-p', str(pid)],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return "RUNNING"
|
||||
else:
|
||||
return "COMPLETED"
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def calculate_age(timestamp_str: str) -> str:
|
||||
"""Calculate human-readable age from timestamp"""
|
||||
if not timestamp_str:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.now()
|
||||
delta = now - timestamp
|
||||
|
||||
total_seconds = int(delta.total_seconds())
|
||||
|
||||
if total_seconds < 60:
|
||||
return f"{total_seconds}s ago"
|
||||
elif total_seconds < 3600:
|
||||
minutes = total_seconds // 60
|
||||
return f"{minutes}m ago"
|
||||
elif total_seconds < 86400:
|
||||
hours = total_seconds // 3600
|
||||
return f"{hours}h ago"
|
||||
else:
|
||||
days = total_seconds // 86400
|
||||
return f"{days}d ago"
|
||||
except ValueError:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,532 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: wake.py - Manual Branch Wake Handler
|
||||
# Date: 2026-03-02
|
||||
# Version: 2.0.0
|
||||
# Category: ai_mail/handlers/dispatch
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2026-03-02): Step-by-step status, dispatch_monitor wrapper,
|
||||
# zombie cleanup, return-to-sender bounce, liveness check
|
||||
# - v1.7.0 (2026-03-02): Redirect stderr to log file instead of DEVNULL (debug silent deaths)
|
||||
# - v1.6.0 (2026-03-02): Manual wake returns success on active lock (agent will process inbox)
|
||||
# - v1.5.0 (2026-03-02): Manual wake bypasses autonomous_pause; add auto param for daemon use
|
||||
# - v1.4.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + session rename for /resume picker
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-handler or module imports
|
||||
# - Pure business logic only
|
||||
# - Uses Prax system_logger (FPLAN-0382)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Manual Branch Wake Handler
|
||||
|
||||
Spawns a Claude agent at a target branch using the same logic as daemon.py
|
||||
but triggered manually via 'drone wake @branch "optional message"'.
|
||||
|
||||
v2.0: Now returns step-by-step status and spawns via dispatch_monitor.py
|
||||
which handles agent lifecycle (cleanup, bounce emails on failure).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Infrastructure paths
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
AIPASS_HOME = Path.home()
|
||||
CONFIG_FILE = AIPASS_ROOT / "ai_mail" / "safety_config.json"
|
||||
BRANCH_REGISTRY = AIPASS_HOME / "BRANCH_REGISTRY.json"
|
||||
PAUSE_FILE = AIPASS_HOME / ".aipass" / "autonomous_pause"
|
||||
MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
|
||||
|
||||
# Default prompt when no custom message provided
|
||||
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
||||
|
||||
|
||||
# ─── Status Step Tracking ───────────────────────────────
|
||||
|
||||
class DispatchStatus:
|
||||
"""Collects step-by-step status for a dispatch operation."""
|
||||
|
||||
def __init__(self):
|
||||
self.steps: List[Tuple[str, str, str]] = [] # (status, label, detail)
|
||||
self.success = True
|
||||
|
||||
def ok(self, label: str, detail: str):
|
||||
"""Record a successful step."""
|
||||
self.steps.append(("ok", label, detail))
|
||||
|
||||
def warn(self, label: str, detail: str):
|
||||
"""Record a warning step."""
|
||||
self.steps.append(("warn", label, detail))
|
||||
|
||||
def fail(self, label: str, detail: str):
|
||||
"""Record a failed step and mark overall success as False."""
|
||||
self.steps.append(("fail", label, detail))
|
||||
self.success = False
|
||||
|
||||
def info(self, label: str, detail: str):
|
||||
"""Record an informational step."""
|
||||
self.steps.append(("info", label, detail))
|
||||
|
||||
def format(self) -> str:
|
||||
"""Format all steps as a multi-line status report with icons."""
|
||||
icons = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "📨"}
|
||||
lines = []
|
||||
for status, label, detail in self.steps:
|
||||
icon = icons.get(status, "·")
|
||||
lines.append(f"{icon} {label} → {detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""Single-line summary from last step."""
|
||||
if self.steps:
|
||||
_, label, detail = self.steps[-1]
|
||||
return f"{label}: {detail}"
|
||||
return "no status"
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────
|
||||
|
||||
def _read_json(filepath: Path) -> Optional[dict]:
|
||||
"""Read and parse a JSON file, returning None on failure."""
|
||||
if not filepath.exists():
|
||||
return None
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("[wake] Failed to read %s: %s", filepath, e)
|
||||
return None
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
if not lock_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(lock_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
except PermissionError:
|
||||
return data # Process exists but can't signal — treat as active
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
try:
|
||||
from datetime import datetime
|
||||
lock_time = datetime.fromisoformat(ts)
|
||||
age = (datetime.now() - lock_time).total_seconds()
|
||||
if age > 600:
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
logger.info("[wake] Unparseable lock timestamp at %s", lock_file)
|
||||
# Dead process, remove stale lock
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return None
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _acquire_lock(branch_path: Path, pid: int) -> Tuple[bool, str]:
|
||||
"""Acquire dispatch lock for branch. Atomic creation."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": pid,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"branch": str(branch_path)
|
||||
}
|
||||
try:
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(lock_data, f, indent=2)
|
||||
return True, "Lock acquired"
|
||||
except FileExistsError:
|
||||
return False, "Lock file already exists"
|
||||
except OSError as e:
|
||||
return False, f"Lock failed: {e}"
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load safety config for max_turns."""
|
||||
defaults = {"max_turns_per_wake": 50}
|
||||
config = _read_json(CONFIG_FILE)
|
||||
if config is None:
|
||||
return defaults
|
||||
for key, val in defaults.items():
|
||||
if key not in config:
|
||||
config[key] = val
|
||||
return config
|
||||
|
||||
|
||||
def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
"""Write custom-title to the most recent Claude session JSONL for a branch."""
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = Path.home() / ".claude" / "projects" / encoded_cwd
|
||||
if not projects_dir.exists():
|
||||
return False
|
||||
jsonl_files = sorted(
|
||||
projects_dir.glob("*.jsonl"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
if not jsonl_files:
|
||||
return False
|
||||
latest = jsonl_files[0]
|
||||
session_id = latest.stem
|
||||
entry = json.dumps({
|
||||
"type": "custom-title",
|
||||
"customTitle": name,
|
||||
"sessionId": session_id
|
||||
})
|
||||
try:
|
||||
with open(latest, "a", encoding="utf-8") as f:
|
||||
f.write(entry + "\n")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
try:
|
||||
with open(f'/proc/{pid_str}/environ', 'rb') as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b'\0'):
|
||||
if entry.startswith(b'AIPASS_SESSION_TYPE='):
|
||||
return entry.split(b'=', 1)[1].decode('utf-8')
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read session type for PID %s", pid_str)
|
||||
return 'interactive'
|
||||
|
||||
|
||||
# Session types that should NOT block dispatch (idle/background sessions)
|
||||
_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
"""Check if an interactive Claude session is running in this branch directory."""
|
||||
resolved = str(branch_path.resolve())
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-x', 'claude'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
for pid_str in result.stdout.strip().split('\n'):
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
cwd = os.readlink(f'/proc/{pid_str}/cwd')
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
|
||||
continue
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Failed to check branch occupancy")
|
||||
return False
|
||||
|
||||
|
||||
def _clean_zombies() -> int:
|
||||
"""Find and report zombie Claude processes. Returns count found."""
|
||||
count = 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ps', '-eo', 'pid,stat,comm'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3 and parts[2] == 'claude' and 'Z' in parts[1]:
|
||||
count += 1
|
||||
logger.info("[wake] Found zombie Claude process PID %s", parts[0])
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Failed to check for zombie processes")
|
||||
return count
|
||||
|
||||
|
||||
def _check_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process is alive (not zombie)."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
# Also verify not zombie
|
||||
with open(f'/proc/{pid}/status', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('State:'):
|
||||
return 'Z' not in line
|
||||
return True
|
||||
except (ProcessLookupError, FileNotFoundError):
|
||||
return False
|
||||
except PermissionError:
|
||||
return True # Exists but can't check — assume alive
|
||||
|
||||
|
||||
# ─── Branch Resolution ──────────────────────────────────
|
||||
|
||||
def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]:
|
||||
"""Resolve a branch email to its filesystem path."""
|
||||
email = f"@{branch_email.lstrip('@').lower()}"
|
||||
registry = _read_json(BRANCH_REGISTRY)
|
||||
if registry is None:
|
||||
return None
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("email", "").lower() == email:
|
||||
path = Path(branch.get("path", ""))
|
||||
if path.exists():
|
||||
return path, email
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ─── Main Wake Function ─────────────────────────────────
|
||||
|
||||
def wake_branch(branch_email: str, custom_message: Optional[str] = None,
|
||||
fresh: bool = False, auto: bool = False,
|
||||
sender: str = "@dev_central") -> Tuple[DispatchStatus, bool]:
|
||||
"""
|
||||
Spawn a Claude agent at the target branch with step-by-step status.
|
||||
|
||||
Returns:
|
||||
Tuple of (DispatchStatus with all steps, overall success bool)
|
||||
"""
|
||||
status = DispatchStatus()
|
||||
|
||||
# Step 1: Pause check (auto-dispatch only)
|
||||
if auto and PAUSE_FILE.exists():
|
||||
status.fail("pause", "System paused (autonomous_pause active)")
|
||||
logger.warning("[wake] BLOCKED %s — system paused", branch_email)
|
||||
return status, False
|
||||
|
||||
# Step 2: Resolve branch
|
||||
result = resolve_branch(branch_email)
|
||||
if result is None:
|
||||
status.fail("resolve", f"Branch not found: {branch_email}")
|
||||
return status, False
|
||||
|
||||
branch_path, email = result
|
||||
status.ok("resolve", f"{email} → {branch_path}")
|
||||
|
||||
# Step 3: Zombie check (pre-flight)
|
||||
zombie_count = _clean_zombies()
|
||||
if zombie_count > 0:
|
||||
status.warn("zombies", f"{zombie_count} zombie Claude process(es) detected")
|
||||
else:
|
||||
status.ok("pre-flight", "No zombie processes")
|
||||
|
||||
# Step 4: Lock check
|
||||
existing = _check_lock(branch_path)
|
||||
if existing is not None:
|
||||
pid = existing.get("pid", "?")
|
||||
since = existing.get("timestamp", "?")
|
||||
if auto:
|
||||
status.fail("lock", f"Active agent (PID {pid}, since {since})")
|
||||
logger.warning("[wake] BLOCKED %s — active agent PID %s", email, pid)
|
||||
return status, False
|
||||
else:
|
||||
status.info("lock", f"Agent active (PID {pid}) — email routed to inbox")
|
||||
status.info("delivery", "Agent will process email during current session")
|
||||
return status, True
|
||||
|
||||
status.ok("lock", "No active lock — agent is sleeping")
|
||||
|
||||
# Step 5: Occupancy check
|
||||
if _is_branch_occupied(branch_path):
|
||||
status.warn("occupancy", f"Interactive Claude session in {branch_path}")
|
||||
status.fail("blocked", "Cannot spawn — interactive session running")
|
||||
logger.warning("[wake] BLOCKED %s — interactive session", email)
|
||||
return status, False
|
||||
|
||||
status.ok("occupancy", "No interactive session")
|
||||
|
||||
# Step 6: Build spawn command
|
||||
config = _load_config()
|
||||
max_turns = config.get("max_turns_per_wake", 50)
|
||||
|
||||
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
|
||||
if custom_message:
|
||||
prompt = f"Hi. {custom_message} "
|
||||
else:
|
||||
prompt = f"{DEFAULT_PROMPT} "
|
||||
# Monitor handles lock cleanup — agent doesn't need to know about it
|
||||
prompt += f"IMPORTANT: When finished, delete the dispatch lock file at {lock_file_path}"
|
||||
|
||||
if fresh:
|
||||
claude_cmd = [
|
||||
"claude", "-p", prompt,
|
||||
"--max-turns", str(max_turns),
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json"
|
||||
]
|
||||
else:
|
||||
claude_cmd = [
|
||||
"claude", "-c", "-p", prompt,
|
||||
"--max-turns", str(max_turns),
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json"
|
||||
]
|
||||
|
||||
# Set session name for /resume picker
|
||||
branch_name = email.lstrip("@").upper()
|
||||
session_label = f"{branch_name}-dispatched"
|
||||
if not fresh:
|
||||
_set_session_name(branch_path, session_label)
|
||||
|
||||
# Step 7: Spawn via dispatch_monitor
|
||||
log_dir = branch_path / "ai_mail.local"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
stderr_log = str(log_dir / "agent_stderr.log")
|
||||
|
||||
# Build monitor command
|
||||
monitor_cmd = [
|
||||
sys.executable, str(MONITOR_SCRIPT),
|
||||
email, lock_file_path, sender, stderr_log,
|
||||
"--", *claude_cmd
|
||||
]
|
||||
|
||||
# Prepare environment
|
||||
spawn_env = os.environ.copy()
|
||||
spawn_env["AIPASS_SPAWNED"] = "1"
|
||||
spawn_env["AIPASS_SESSION_TYPE"] = "dispatched"
|
||||
for key in list(spawn_env.keys()):
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
|
||||
|
||||
except FileNotFoundError:
|
||||
status.fail("spawn", "Python or monitor script not found")
|
||||
return status, False
|
||||
except Exception as e:
|
||||
status.fail("spawn", f"{type(e).__name__}: {e}")
|
||||
return status, False
|
||||
|
||||
# Step 8: Acquire lock (with monitor PID — stays alive as long as agent)
|
||||
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid)
|
||||
if not acquired:
|
||||
status.warn("lock-acquire", f"Lock failed: {lock_msg}")
|
||||
else:
|
||||
status.ok("lock-acquire", "Dispatch lock acquired")
|
||||
|
||||
# Step 9: Liveness check (brief wait then verify)
|
||||
time.sleep(2)
|
||||
if _check_pid_alive(monitor_pid):
|
||||
status.ok("alive", f"Agent responding (PID {monitor_pid} alive)")
|
||||
else:
|
||||
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
|
||||
# Clean up lock
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return status, False
|
||||
|
||||
# Desktop notification
|
||||
notif_body = custom_message[:80] if custom_message else "Manual wake: check inbox"
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", "-i", "dialog-information", f"Wake → {email}", notif_body],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
||||
logger.info("[wake] Desktop notification unavailable")
|
||||
|
||||
return status, True
|
||||
|
||||
|
||||
# ─── Legacy wrapper for backward compatibility ──────────
|
||||
|
||||
def wake_branch_legacy(branch_email: str, custom_message: Optional[str] = None,
|
||||
fresh: bool = False, auto: bool = False) -> Tuple[bool, str]:
|
||||
"""Legacy interface returning (bool, str) for callers not yet updated."""
|
||||
dispatch_status, success = wake_branch(branch_email, custom_message, fresh, auto)
|
||||
return success, dispatch_status.summary
|
||||
|
||||
|
||||
# ─── CLI Entry Point ─────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args or args[0] in ("--help", "-h"):
|
||||
print("Usage: wake.py [--fresh] [--auto] [--sender @branch] @branch [\"optional message\"]")
|
||||
print(" Manually spawn a Claude agent at a branch (daemon not required)")
|
||||
print()
|
||||
print("Flags:")
|
||||
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
||||
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
||||
print(" --sender @branch Set return-to-sender for bounce emails (default: @dev_central)")
|
||||
print()
|
||||
print("Output: Step-by-step status of the dispatch pipeline:")
|
||||
print(" ✅ resolve → @branch found at /path/to/branch")
|
||||
print(" ✅ lock → No active lock — agent is sleeping")
|
||||
print(" ✅ spawn → Monitor started (PID 12345)")
|
||||
print(" ✅ alive → Agent responding (PID 12345 alive)")
|
||||
print()
|
||||
print("On failure, a bounce email is sent to --sender automatically.")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(" wake.py @flow # Default: check inbox (resume)")
|
||||
print(" wake.py --fresh @flow # Fresh session, check inbox")
|
||||
print(" wake.py @vera \"Review NOTEPAD\" # Custom prompt (resume)")
|
||||
print(" wake.py --fresh --sender @vera @seed # Fresh, bounce to @vera")
|
||||
sys.exit(0)
|
||||
|
||||
# Parse flags
|
||||
use_fresh = "--fresh" in args
|
||||
use_auto = "--auto" in args
|
||||
use_sender = "@dev_central"
|
||||
|
||||
if "--sender" in args:
|
||||
idx = args.index("--sender")
|
||||
if idx + 1 < len(args):
|
||||
use_sender = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
|
||||
args = [a for a in args if a not in ("--fresh", "--auto")]
|
||||
|
||||
if not args:
|
||||
print("❌ Missing branch argument. Use --help for usage.")
|
||||
sys.exit(1)
|
||||
|
||||
branch = args[0]
|
||||
message = args[1] if len(args) > 1 else None
|
||||
|
||||
dispatch_status, success = wake_branch(
|
||||
branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender
|
||||
)
|
||||
print(dispatch_status.format())
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1 @@
|
||||
"""Email Handlers - Email delivery, creation, and formatting for AI_Mail"""
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: create.py - Email File Creation Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.2.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.4.0 (2026-02-04): Add auto-purge trigger after email creation
|
||||
# - v1.3.0 (2026-01-31): Add dispatched_to field for reply chain validation
|
||||
# - v1.2.0 (2026-01-29): Add auto-footer to all outgoing emails
|
||||
# - v1.1.0 (2026-01-29): Add reply_to parameter for redirecting replies
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Uses Prax system_logger (FPLAN-0382)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email File Creation Handler
|
||||
|
||||
Handles creation and storage of email files in sent folders.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Lazy imports
|
||||
_console = None
|
||||
_append_footer = None
|
||||
|
||||
|
||||
def _get_console():
|
||||
"""Lazy import console."""
|
||||
global _console
|
||||
if _console is None:
|
||||
from aipass.cli.apps.modules import console
|
||||
_console = console
|
||||
return _console
|
||||
|
||||
|
||||
def _get_append_footer():
|
||||
"""Lazy import append_footer."""
|
||||
global _append_footer
|
||||
if _append_footer is None:
|
||||
from aipass.ai_mail.apps.handlers.email.footer import append_footer
|
||||
_append_footer = append_footer
|
||||
return _append_footer
|
||||
|
||||
|
||||
def create_email_file(to_branch: str, subject: str, message: str, user_info: Dict, reply_to: str | None = None, dispatched_to: str | None = None) -> Path:
|
||||
"""
|
||||
Create email file and save to sent folder.
|
||||
|
||||
Args:
|
||||
to_branch: Recipient email address (e.g., "@admin" or "all")
|
||||
subject: Email subject line
|
||||
message: Email body text
|
||||
user_info: User information dict with keys:
|
||||
- email_address: Sender email address
|
||||
- display_name: Sender display name
|
||||
- timestamp_format: Datetime format string (default: "%Y-%m-%d %H:%M:%S")
|
||||
- mailbox_path: Path to user's mailbox directory
|
||||
reply_to: Optional branch address where replies should go instead of sender
|
||||
dispatched_to: Branch address where dispatch was sent (for reply chain validation)
|
||||
|
||||
Returns:
|
||||
Path to created email file in sent folder
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
timestamp_str = timestamp.strftime(user_info.get("timestamp_format", "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
# Append standard footer to message
|
||||
message_with_footer = _get_append_footer()(message)
|
||||
|
||||
# Create email data structure
|
||||
email_data = {
|
||||
"from": user_info["email_address"],
|
||||
"from_name": user_info["display_name"],
|
||||
"to": to_branch,
|
||||
"subject": subject,
|
||||
"message": message_with_footer,
|
||||
"timestamp": timestamp_str,
|
||||
"status": "sent"
|
||||
}
|
||||
|
||||
# Add reply_to if specified (for redirecting replies to different branch)
|
||||
if reply_to:
|
||||
email_data["reply_to"] = reply_to
|
||||
|
||||
# Add dispatched_to for reply chain validation (tracks original dispatch recipient)
|
||||
if dispatched_to:
|
||||
email_data["dispatched_to"] = dispatched_to
|
||||
|
||||
# Create filename (safe, no special chars)
|
||||
safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject)
|
||||
safe_subject = safe_subject[:50].strip() # Limit length
|
||||
filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json"
|
||||
|
||||
# Save to sent folder
|
||||
mailbox_path = Path(user_info["mailbox_path"])
|
||||
sent_folder = mailbox_path / "sent"
|
||||
sent_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
email_file = sent_folder / filename
|
||||
with open(email_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(email_data, f, indent=2)
|
||||
|
||||
# Trigger auto-purge if sent folder exceeds threshold
|
||||
_trigger_sent_purge(mailbox_path)
|
||||
|
||||
return email_file
|
||||
|
||||
|
||||
def _trigger_sent_purge(mailbox_path: Path) -> None:
|
||||
"""
|
||||
Trigger auto-purge of sent folder if threshold exceeded.
|
||||
|
||||
Non-blocking - failures silently ignored.
|
||||
"""
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.purge import purge_sent_folder
|
||||
purge_sent_folder(mailbox_path)
|
||||
except Exception:
|
||||
pass # Silent fail - purge is best-effort
|
||||
|
||||
|
||||
def load_email_file(email_file: Path) -> Dict | None:
|
||||
"""
|
||||
Load email data from file.
|
||||
|
||||
Args:
|
||||
email_file: Path to email JSON file
|
||||
|
||||
Returns:
|
||||
Email data dict or None if file cannot be read
|
||||
"""
|
||||
if not email_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(email_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_subject(subject: str, max_length: int = 50) -> str:
|
||||
"""
|
||||
Sanitize email subject for use in filenames.
|
||||
|
||||
Args:
|
||||
subject: Original email subject
|
||||
max_length: Maximum length for sanitized subject
|
||||
|
||||
Returns:
|
||||
Sanitized subject string safe for filenames
|
||||
"""
|
||||
safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject)
|
||||
safe_subject = safe_subject[:max_length].strip()
|
||||
return safe_subject
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
c = _get_console()
|
||||
c.print("\n" + "="*70)
|
||||
c.print("EMAIL FILE CREATION HANDLER")
|
||||
c.print("="*70)
|
||||
c.print("\nPURPOSE:")
|
||||
c.print(" Creates and stores email files in sent folders")
|
||||
c.print()
|
||||
c.print("FUNCTIONS PROVIDED:")
|
||||
c.print(" - create_email_file(to_branch, subject, message, user_info) -> Path")
|
||||
c.print(" - load_email_file(email_file) -> Dict | None")
|
||||
c.print(" - sanitize_subject(subject, max_length) -> str")
|
||||
c.print()
|
||||
c.print("HANDLER CHARACTERISTICS:")
|
||||
c.print(" ✓ Independent - no module dependencies")
|
||||
c.print(" ✓ Pure business logic")
|
||||
c.print(" ✗ CANNOT import parent modules")
|
||||
c.print()
|
||||
c.print("USAGE FROM MODULES:")
|
||||
c.print(" from ai_mail.apps.handlers.email.create import create_email_file")
|
||||
c.print(" from ai_mail.apps.handlers.email.create import load_email_file")
|
||||
c.print()
|
||||
c.print("="*70 + "\n")
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: dashboard_sync.py - Dashboard Write-Through Helper
|
||||
# Date: 2026-02-25
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-25): FPLAN-0373 Phase 2 - dashboard write-through for ai_mail
|
||||
# Reads inbox.json, calculates section data, calls write_section()
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-handler or module imports
|
||||
# - Can import AIPASS central services (devpulse write_section)
|
||||
# - No logger calls (module logs for handler)
|
||||
# - Pure business logic only
|
||||
# - BYPASS: Direct json.load required for reading inbox.json data files
|
||||
# - Dashboard failures never raised to caller
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Dashboard Write-Through Helper
|
||||
|
||||
Reads a branch's inbox.json and pushes the ai_mail section to
|
||||
DASHBOARD.local.json via devpulse write_section() API.
|
||||
|
||||
Called as a side-effect after email operations (deliver, close, view, inbox).
|
||||
Failures never propagate - email operations must not break due to dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Lazy-loaded write_section reference
|
||||
_write_section = None
|
||||
|
||||
|
||||
def _get_write_section():
|
||||
"""Lazy import write_section from devpulse module API."""
|
||||
global _write_section
|
||||
if _write_section is None:
|
||||
from aipass.devpulse.apps.modules.dashboard import write_section
|
||||
_write_section = write_section
|
||||
return _write_section
|
||||
|
||||
|
||||
def _human_readable_age(seconds: float) -> str:
|
||||
"""
|
||||
Convert seconds to human-readable age string.
|
||||
|
||||
Args:
|
||||
seconds: Number of seconds
|
||||
|
||||
Returns:
|
||||
Human-readable string like "2 hours", "3 days", "1 week"
|
||||
"""
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
elif seconds < 3600:
|
||||
mins = int(seconds / 60)
|
||||
return f"{mins} minute{'s' if mins != 1 else ''}"
|
||||
elif seconds < 86400:
|
||||
hours = int(seconds / 3600)
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
elif seconds < 604800:
|
||||
days = int(seconds / 86400)
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
elif seconds < 2592000:
|
||||
weeks = int(seconds / 604800)
|
||||
return f"{weeks} week{'s' if weeks != 1 else ''}"
|
||||
else:
|
||||
months = int(seconds / 2592000)
|
||||
return f"{months} month{'s' if months != 1 else ''}"
|
||||
|
||||
|
||||
def _calculate_section_data(inbox_data: Dict) -> Dict:
|
||||
"""
|
||||
Calculate ai_mail dashboard section from inbox data.
|
||||
|
||||
Args:
|
||||
inbox_data: Parsed inbox.json dict with "messages" list
|
||||
|
||||
Returns:
|
||||
Section data dict with new, opened, total, oldest_unread_age,
|
||||
last_dispatch_received
|
||||
"""
|
||||
messages = inbox_data.get("messages", [])
|
||||
now = datetime.now()
|
||||
|
||||
new_count = 0
|
||||
opened_count = 0
|
||||
oldest_unread_ts: Optional[datetime] = None
|
||||
last_dispatch_ts: Optional[str] = None
|
||||
|
||||
for msg in messages:
|
||||
status = msg.get("status")
|
||||
is_new = status == "new" or (status is None and not msg.get("read", False))
|
||||
is_opened = status == "opened"
|
||||
|
||||
if is_new:
|
||||
new_count += 1
|
||||
# Track oldest unread timestamp
|
||||
ts_str = msg.get("timestamp", "")
|
||||
if ts_str:
|
||||
try:
|
||||
msg_ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||
if oldest_unread_ts is None or msg_ts < oldest_unread_ts:
|
||||
oldest_unread_ts = msg_ts
|
||||
except (ValueError, TypeError):
|
||||
pass # Malformed timestamp - skip for age calculation
|
||||
|
||||
if is_opened:
|
||||
opened_count += 1
|
||||
|
||||
# Track most recent dispatch email
|
||||
if msg.get("auto_execute", False):
|
||||
ts_str = msg.get("timestamp", "")
|
||||
if ts_str:
|
||||
if last_dispatch_ts is None or ts_str > last_dispatch_ts:
|
||||
last_dispatch_ts = ts_str
|
||||
|
||||
total_count = len(messages)
|
||||
|
||||
# Calculate oldest unread age
|
||||
oldest_unread_age: Optional[str] = None
|
||||
if oldest_unread_ts is not None:
|
||||
age_seconds = (now - oldest_unread_ts).total_seconds()
|
||||
oldest_unread_age = _human_readable_age(age_seconds)
|
||||
|
||||
# Convert last_dispatch_received to ISO format if present
|
||||
last_dispatch_iso: Optional[str] = None
|
||||
if last_dispatch_ts:
|
||||
try:
|
||||
dt = datetime.strptime(last_dispatch_ts, "%Y-%m-%d %H:%M:%S")
|
||||
last_dispatch_iso = dt.isoformat()
|
||||
except (ValueError, TypeError):
|
||||
last_dispatch_iso = last_dispatch_ts
|
||||
|
||||
return {
|
||||
"managed_by": "ai_mail",
|
||||
"new": new_count,
|
||||
"opened": opened_count,
|
||||
"total": total_count,
|
||||
"oldest_unread_age": oldest_unread_age,
|
||||
"last_dispatch_received": last_dispatch_iso
|
||||
}
|
||||
|
||||
|
||||
def push_dashboard_update(branch_path: Path) -> bool:
|
||||
"""
|
||||
Read branch inbox and push ai_mail section to dashboard.
|
||||
|
||||
This is the primary public function. It:
|
||||
1. Reads the branch's ai_mail.local/inbox.json
|
||||
2. Calculates section data (new, opened, total, oldest_unread_age, etc.)
|
||||
3. Calls write_section() to update DASHBOARD.local.json
|
||||
|
||||
Failures are silently caught - email operations must not break.
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch root directory
|
||||
|
||||
Returns:
|
||||
True if update succeeded, False on any error (never raises)
|
||||
"""
|
||||
try:
|
||||
branch_path = Path(branch_path)
|
||||
|
||||
# Determine inbox path
|
||||
if branch_path == Path("/") or branch_path == Path.home():
|
||||
inbox_file = Path.home() / "ai_mail.local" / "inbox.json"
|
||||
else:
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
|
||||
# Read inbox (BYPASS: direct json.load - this is a data file, not a template)
|
||||
if not inbox_file.exists():
|
||||
# No inbox = all zeros
|
||||
section_data = {
|
||||
"managed_by": "ai_mail",
|
||||
"new": 0,
|
||||
"opened": 0,
|
||||
"total": 0,
|
||||
"oldest_unread_age": None,
|
||||
"last_dispatch_received": None
|
||||
}
|
||||
else:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
section_data = _calculate_section_data(inbox_data)
|
||||
|
||||
write_section = _get_write_section()
|
||||
return write_section(branch_path, "ai_mail", section_data)
|
||||
|
||||
except Exception:
|
||||
# Dashboard write failure - silent, never raise
|
||||
return False
|
||||
@@ -0,0 +1,512 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: delivery.py - Email Delivery Handler
|
||||
# Date: 2025-12-02
|
||||
# Version: 3.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v3.1.0 (2026-02-25): Private branch inbound email blocking (DPLAN-035 email isolation)
|
||||
# - v3.0.0 (2026-02-17): Remove spawn logic — delivery is write-only, daemon handles all spawning
|
||||
# - v2.4.0 (2026-02-10): Seed compliance - remove logger calls, cross-handler imports, fix naming, add json_handler
|
||||
# - v2.3.0 (2026-02-10): Phase 3 polish - concise bounce messages, dispatch chain logging, hardened loop detection
|
||||
# - v2.2.0 (2026-02-10): DEV_CENTRAL dispatch protection, notification throttling, self-reply loop detection
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-handler or module imports
|
||||
# - No logger calls (module logs for handler)
|
||||
# - Pure business logic only
|
||||
# - Uses json_handler for JSON operations
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Delivery Handler
|
||||
|
||||
Handles delivery of emails to branch inboxes.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, List, Optional, Callable
|
||||
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import load_json, save_json
|
||||
|
||||
# Lazy imports to avoid circular dependencies
|
||||
_CONSOLE = None
|
||||
_INBOX_LOCK = None
|
||||
|
||||
|
||||
def _get_inbox_lock():
|
||||
"""Lazy import inbox_lock context manager."""
|
||||
global _INBOX_LOCK
|
||||
if _INBOX_LOCK is None:
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock
|
||||
_INBOX_LOCK = inbox_lock
|
||||
return _INBOX_LOCK
|
||||
|
||||
|
||||
def _get_console():
|
||||
"""Lazy import console."""
|
||||
global _CONSOLE
|
||||
if _CONSOLE is None:
|
||||
from aipass.cli.apps.modules import console
|
||||
_CONSOLE = console
|
||||
return _CONSOLE
|
||||
|
||||
|
||||
def get_all_branches() -> List[Dict]:
|
||||
"""
|
||||
Get list of all branches for email routing.
|
||||
Reads from AIPass branch registry at /home/aipass/BRANCH_REGISTRY.json
|
||||
|
||||
Returns:
|
||||
List of dicts with branch info:
|
||||
[{"name": "AIPASS.admin", "path": "/", "email": "@admin"}, ...]
|
||||
"""
|
||||
registry_file = Path("/home/aipass/BRANCH_REGISTRY.json")
|
||||
branches = []
|
||||
|
||||
if not registry_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(registry_file, 'r', encoding='utf-8') as f:
|
||||
registry_data = json.load(f)
|
||||
|
||||
# Parse branch entries from JSON structure
|
||||
for branch in registry_data.get("branches", []):
|
||||
branch_name = branch.get("name", "")
|
||||
path = branch.get("path", "")
|
||||
|
||||
if not branch_name or not path:
|
||||
continue
|
||||
|
||||
# Use explicit email from registry if present (preferred)
|
||||
# Fall back to derivation only if email field is missing
|
||||
explicit_email = branch.get("email", "")
|
||||
if explicit_email:
|
||||
email = explicit_email
|
||||
else:
|
||||
# Legacy fallback: derive email from branch name
|
||||
if '.' in branch_name:
|
||||
email_part = branch_name.split('.')[-1].lower()
|
||||
elif ' ' in branch_name:
|
||||
email_part = branch_name.split()[0].lower()
|
||||
elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS':
|
||||
email_part = branch_name.split('-', 1)[1].lower()
|
||||
else:
|
||||
email_part = branch_name.split('-')[0].lower()
|
||||
email = f"@{email_part}"
|
||||
|
||||
branches.append({
|
||||
"name": branch_name,
|
||||
"path": path,
|
||||
"email": email
|
||||
})
|
||||
|
||||
# COLLISION DETECTION: Check for duplicate email addresses
|
||||
email_map = {}
|
||||
collisions = []
|
||||
for branch in branches:
|
||||
if branch["email"] in email_map:
|
||||
collision_msg = f"Email collision: {branch['email']} used by both '{email_map[branch['email']]}' and '{branch['name']}'"
|
||||
collisions.append(collision_msg)
|
||||
else:
|
||||
email_map[branch["email"]] = branch["name"]
|
||||
|
||||
return branches
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _migrate_inbox_format(inbox_data: Dict, inbox_file: Path) -> Dict:
|
||||
"""
|
||||
Auto-migrate old inbox format to v2 schema.
|
||||
|
||||
Old format: {"inbox": [...]}
|
||||
New format: {"mailbox": "inbox", "total_messages": N, "unread_count": N, "messages": [...]}
|
||||
|
||||
Migrates in-place and persists to disk if changes were made.
|
||||
|
||||
Args:
|
||||
inbox_data: Loaded inbox dict (may be old or new format)
|
||||
inbox_file: Path to inbox.json (for persisting migration)
|
||||
|
||||
Returns:
|
||||
Migrated inbox data dict with v2 schema
|
||||
"""
|
||||
migrated = False
|
||||
|
||||
# Case 0: inbox_data is a list instead of a dict (corrupted/malformed inbox.json)
|
||||
if isinstance(inbox_data, list):
|
||||
inbox_data = {"messages": inbox_data}
|
||||
migrated = True
|
||||
|
||||
# Case 1: Old format with "inbox" key instead of "messages"
|
||||
if "inbox" in inbox_data and "messages" not in inbox_data:
|
||||
old_messages = inbox_data.pop("inbox", [])
|
||||
inbox_data["messages"] = old_messages if isinstance(old_messages, list) else []
|
||||
migrated = True
|
||||
|
||||
# Case 2: Missing "messages" key entirely
|
||||
if "messages" not in inbox_data:
|
||||
inbox_data["messages"] = []
|
||||
migrated = True
|
||||
|
||||
# Ensure v2 metadata fields exist
|
||||
if "mailbox" not in inbox_data:
|
||||
inbox_data["mailbox"] = "inbox"
|
||||
migrated = True
|
||||
|
||||
if "total_messages" not in inbox_data:
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
migrated = True
|
||||
|
||||
if "unread_count" not in inbox_data:
|
||||
inbox_data["unread_count"] = sum(
|
||||
1 for msg in inbox_data["messages"]
|
||||
if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False))
|
||||
)
|
||||
migrated = True
|
||||
|
||||
# Persist migration to disk
|
||||
if migrated:
|
||||
try:
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
return inbox_data
|
||||
|
||||
return inbox_data
|
||||
|
||||
|
||||
def _is_private_branch_email(email: str) -> bool:
|
||||
"""Check if email belongs to a private branch.
|
||||
|
||||
Reads PRIVATE_BRANCH_REGISTRY.json to determine if the given
|
||||
email address is registered to a private (isolated) branch.
|
||||
|
||||
Args:
|
||||
email: Email address to check (e.g., "@patrick_private")
|
||||
|
||||
Returns:
|
||||
True if email belongs to a private branch, False otherwise
|
||||
"""
|
||||
registry_path = Path.home() / "PRIVATE_BRANCH_REGISTRY.json"
|
||||
if not registry_path.exists():
|
||||
return False
|
||||
try:
|
||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("email", "") == email:
|
||||
return True
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def deliver_email_to_branch(
|
||||
to_branch: str,
|
||||
email_data: Dict,
|
||||
on_delivered: Optional[Callable] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Deliver email to target branch's ai_mail.local/inbox.json file.
|
||||
|
||||
Appends message to inbox JSON messages array.
|
||||
|
||||
Args:
|
||||
to_branch: Target email address (e.g., "@admin")
|
||||
email_data: Email data dict with keys:
|
||||
- from: Sender email address
|
||||
- from_name: Sender display name
|
||||
- to: Recipient email address
|
||||
- subject: Email subject
|
||||
- message: Email body
|
||||
- timestamp: Email timestamp string
|
||||
on_delivered: Optional callback(branch_path, new_count, opened_count, total)
|
||||
for post-delivery actions (dashboard updates, central sync, etc.)
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: str)
|
||||
error_message is empty string if successful
|
||||
"""
|
||||
# Handle path input from DRONE's @ resolution
|
||||
if to_branch.startswith('/'):
|
||||
branches_list = get_all_branches()
|
||||
path_to_email = {b["path"]: b["email"] for b in branches_list}
|
||||
if to_branch in path_to_email:
|
||||
to_branch = path_to_email[to_branch]
|
||||
else:
|
||||
# Stage 2: Longest-path-first prefix matching against registry
|
||||
sorted_branches = sorted(branches_list, key=lambda b: len(b['path']), reverse=True)
|
||||
matched = False
|
||||
for b in sorted_branches:
|
||||
if to_branch.startswith(b['path'] + '/') or to_branch == b['path']:
|
||||
to_branch = b['email']
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
return False, f"Could not resolve path to email: {to_branch}"
|
||||
|
||||
# Map email address to branch path
|
||||
all_branches = get_all_branches()
|
||||
branches = {b["email"]: b["path"] for b in all_branches}
|
||||
|
||||
if to_branch not in branches:
|
||||
error_msg = f"Unknown branch email: {to_branch} (available: {len(branches)} branches)"
|
||||
return False, error_msg
|
||||
|
||||
# Private branch inbound blocking: reject delivery to private branches
|
||||
# Self-send is allowed (private branch can send to itself)
|
||||
sender_email = email_data.get('from', '')
|
||||
if _is_private_branch_email(to_branch) and sender_email != to_branch:
|
||||
return False, f"Cannot deliver to private branch: {to_branch}"
|
||||
|
||||
branch_path = Path(branches[to_branch])
|
||||
|
||||
# Find the branch's ai_mail.local/inbox.json file
|
||||
if branch_path == Path("/") or branch_path == Path.home():
|
||||
inbox_file = Path.home() / "ai_mail.local" / "inbox.json"
|
||||
else:
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
# Auto-provision inbox for new branches (self-healing)
|
||||
try:
|
||||
mailbox_dir = inbox_file.parent
|
||||
mailbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mailbox_dir / "sent").mkdir(exist_ok=True)
|
||||
inbox_data_init = {
|
||||
"mailbox": "inbox",
|
||||
"total_messages": 0,
|
||||
"unread_count": 0,
|
||||
"messages": []
|
||||
}
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data_init, f, indent=2)
|
||||
except Exception as e:
|
||||
return False, f"Failed to auto-provision inbox for {to_branch}: {e}"
|
||||
|
||||
# Lock inbox.json for the entire read-modify-write cycle
|
||||
try:
|
||||
with _get_inbox_lock()(inbox_file):
|
||||
try:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
except Exception as e:
|
||||
return False, f"Failed to read inbox: {e}"
|
||||
|
||||
# Auto-migrate old inbox format {"inbox": []} -> v2 schema
|
||||
inbox_data = _migrate_inbox_format(inbox_data, inbox_file)
|
||||
|
||||
# Create message object (v2 schema: status instead of read)
|
||||
message = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"timestamp": email_data['timestamp'],
|
||||
"from": email_data['from'],
|
||||
"from_name": email_data['from_name'],
|
||||
"subject": email_data['subject'],
|
||||
"message": email_data['message'],
|
||||
"status": "new",
|
||||
"auto_execute": email_data.get('auto_execute', False),
|
||||
"priority": email_data.get('priority', 'normal')
|
||||
}
|
||||
|
||||
if email_data.get('reply_to'):
|
||||
message["reply_to"] = email_data['reply_to']
|
||||
|
||||
if email_data.get('dispatched_to'):
|
||||
message["dispatched_to"] = email_data['dispatched_to']
|
||||
|
||||
# Prepend message to inbox (newest first)
|
||||
inbox_data["messages"].insert(0, message)
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
messages = inbox_data["messages"]
|
||||
new_count = sum(
|
||||
1 for msg in messages
|
||||
if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for msg in messages if msg.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
try:
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return False, f"Failed to write inbox: {e}"
|
||||
|
||||
except OSError as e:
|
||||
return False, f"Failed to acquire inbox lock: {e}"
|
||||
|
||||
# Send desktop notification for new email
|
||||
_send_desktop_notification(email_data['from'], to_branch, email_data['subject'], email_data.get('message', ''))
|
||||
|
||||
# Invoke post-delivery callback (dashboard updates, central sync, etc.)
|
||||
if on_delivered:
|
||||
try:
|
||||
on_delivered(branch_path, new_count, opened_count, inbox_data["total_messages"])
|
||||
except Exception:
|
||||
return True, ""
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _get_summary_file_path(branch_path: Path) -> Path:
|
||||
"""
|
||||
Get the summary file path for a branch.
|
||||
|
||||
Pattern: [BRANCH_NAME].ai_mail.json
|
||||
Example: /home/aipass/aipass_core/drone/DRONE.ai_mail.json
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
|
||||
Returns:
|
||||
Path to summary file
|
||||
"""
|
||||
branch_name = branch_path.name.upper()
|
||||
|
||||
if branch_path == Path("/") or branch_path == Path.home():
|
||||
branch_name = "AIPASS"
|
||||
|
||||
summary_file = branch_path / f"{branch_name}.ai_mail.json"
|
||||
return summary_file
|
||||
|
||||
|
||||
def _update_summary_file(summary_file: Path, message: Dict, total: int, unread: int) -> None:
|
||||
"""
|
||||
Update branch summary file with new email data.
|
||||
|
||||
Updates:
|
||||
- summary.inbox.total
|
||||
- summary.inbox.unread
|
||||
- summary.inbox.recent_preview (adds message preview)
|
||||
|
||||
Args:
|
||||
summary_file: Path to summary JSON file
|
||||
message: Message dict to add to preview
|
||||
total: Total inbox message count
|
||||
unread: Unread message count
|
||||
"""
|
||||
try:
|
||||
with open(summary_file, 'r', encoding='utf-8') as f:
|
||||
summary_data = json.load(f)
|
||||
|
||||
if "summary" not in summary_data:
|
||||
summary_data["summary"] = {}
|
||||
if "inbox" not in summary_data["summary"]:
|
||||
summary_data["summary"]["inbox"] = {}
|
||||
|
||||
summary_data["summary"]["inbox"]["total"] = total
|
||||
summary_data["summary"]["inbox"]["unread"] = unread
|
||||
|
||||
if "recent_preview" not in summary_data["summary"]["inbox"]:
|
||||
summary_data["summary"]["inbox"]["recent_preview"] = []
|
||||
|
||||
message_words = message["message"].split()[:15]
|
||||
preview = {
|
||||
"from": message["from"],
|
||||
"subject": message["subject"],
|
||||
"summary": " ".join(message_words) + ("..." if len(message["message"].split()) > 15 else ""),
|
||||
"timestamp": message["timestamp"],
|
||||
"status": "new",
|
||||
"message_id": message["id"]
|
||||
}
|
||||
|
||||
summary_data["summary"]["inbox"]["recent_preview"].insert(0, preview)
|
||||
summary_data["summary"]["inbox"]["recent_preview"] = summary_data["summary"]["inbox"]["recent_preview"][:5]
|
||||
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(summary_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
_NOTIFICATION_TIMESTAMPS: Dict[str, List[float]] = {}
|
||||
|
||||
# Rate limit: max notifications per recipient within time window
|
||||
_NOTIFICATION_MAX = 3
|
||||
_NOTIFICATION_WINDOW = 30.0 # seconds
|
||||
|
||||
|
||||
def _send_desktop_notification(sender: str, recipient: str, subject: str, message: str = "") -> None:
|
||||
"""
|
||||
Send desktop notification for new email using notify-send.
|
||||
|
||||
Rate-limited: max 3 notifications per recipient within 30 seconds.
|
||||
Gracefully handles cases where notify-send is not available.
|
||||
|
||||
Args:
|
||||
sender: Email sender address (e.g., @dev_central)
|
||||
recipient: Email recipient address (e.g., @ai_mail)
|
||||
subject: Email subject line
|
||||
message: Email body (first ~100 chars shown in notification)
|
||||
"""
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
cutoff = now - _NOTIFICATION_WINDOW
|
||||
|
||||
if recipient in _NOTIFICATION_TIMESTAMPS:
|
||||
_NOTIFICATION_TIMESTAMPS[recipient] = [
|
||||
t for t in _NOTIFICATION_TIMESTAMPS[recipient] if t > cutoff
|
||||
]
|
||||
else:
|
||||
_NOTIFICATION_TIMESTAMPS[recipient] = []
|
||||
|
||||
if len(_NOTIFICATION_TIMESTAMPS[recipient]) >= _NOTIFICATION_MAX:
|
||||
return
|
||||
|
||||
# Build informative notification
|
||||
sender_name = sender.replace('@', '').upper()
|
||||
recipient_name = recipient.replace('@', '').upper()
|
||||
title = f"{sender_name} -> {recipient_name}"
|
||||
body = subject
|
||||
if message:
|
||||
preview = message[:100].replace('\n', ' ').strip()
|
||||
if preview:
|
||||
body = f"{subject}\n{preview}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['notify-send', title, body],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
_NOTIFICATION_TIMESTAMPS[recipient].append(now)
|
||||
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console = _get_console()
|
||||
console.print("\n" + "="*70)
|
||||
console.print("EMAIL DELIVERY HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Delivers emails to branch inboxes")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - get_all_branches() -> List[Dict]")
|
||||
console.print(" - deliver_email_to_branch(to_branch, email_data) -> Tuple[bool, str]")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" - Independent - no module dependencies")
|
||||
console.print(" - Uses lazy imports for services")
|
||||
console.print(" - Pure business logic")
|
||||
console.print(" - CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from ai_mail.apps.handlers.email.delivery import deliver_email_to_branch")
|
||||
console.print(" from ai_mail.apps.handlers.email.delivery import get_all_branches")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: footer.py - Email Footer Handler
|
||||
# Date: 2026-01-29
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-01-29): Created - auto-footer for all outgoing emails
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Footer Handler
|
||||
|
||||
Generates and appends standard footer to all outgoing emails.
|
||||
Reminds branches of process steps: Seed audit, memory update, FPLAN close, confirmation.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
# Standard footer for all outgoing emails
|
||||
STANDARD_FOOTER = """
|
||||
---
|
||||
⚠️ TASK CHECKLIST (before marking complete):
|
||||
□ SEED CHECK → drone @seed audit @branch (80%+)
|
||||
□ UPDATE MEMORIES → Your .local.json records this work
|
||||
□ CLOSE FPLAN → drone @flow close <plan_id>
|
||||
□ CONFIRM → Reply with completion summary
|
||||
|
||||
Memories = Presence. No update = No learning.
|
||||
---"""
|
||||
|
||||
|
||||
def get_footer() -> str:
|
||||
"""
|
||||
Get the standard email footer.
|
||||
|
||||
Returns:
|
||||
Standard footer string for all outgoing emails
|
||||
"""
|
||||
return STANDARD_FOOTER
|
||||
|
||||
|
||||
def append_footer(message: str) -> str:
|
||||
"""
|
||||
Append standard footer to email message.
|
||||
|
||||
Args:
|
||||
message: Original email message body
|
||||
|
||||
Returns:
|
||||
Message with footer appended
|
||||
"""
|
||||
return message + get_footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n" + "="*70)
|
||||
print("EMAIL FOOTER HANDLER")
|
||||
print("="*70)
|
||||
print("\nPURPOSE:")
|
||||
print(" Generates standard footer for all outgoing emails")
|
||||
print()
|
||||
print("FUNCTIONS PROVIDED:")
|
||||
print(" - get_footer() -> str")
|
||||
print(" - append_footer(message) -> str")
|
||||
print()
|
||||
print("FOOTER CONTENT:")
|
||||
print(STANDARD_FOOTER)
|
||||
print()
|
||||
print("HANDLER CHARACTERISTICS:")
|
||||
print(" ✓ Independent - no module dependencies")
|
||||
print(" ✓ Can import Prax (service provider)")
|
||||
print(" ✓ Pure business logic")
|
||||
print(" ✗ CANNOT import parent modules")
|
||||
print()
|
||||
print("USAGE FROM MODULES:")
|
||||
print(" from ai_mail.apps.handlers.email.footer import append_footer")
|
||||
print(" from ai_mail.apps.handlers.email.footer import get_footer")
|
||||
print()
|
||||
print("="*70 + "\n")
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: format.py - Email Formatting Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.1.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-21): DPLAN-027 - Show branch alias in sender display
|
||||
# - v1.0.0 (2025-11-15): Created - email formatting and display utilities
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Uses Prax system_logger (FPLAN-0382)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Formatting Handler
|
||||
|
||||
Handles email display formatting, preview generation, and text utilities.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json"
|
||||
|
||||
|
||||
def lookup_branch_alias(branch_name: str) -> Optional[str]:
|
||||
"""
|
||||
Look up a branch's alias from BRANCH_REGISTRY.json.
|
||||
|
||||
Args:
|
||||
branch_name: Branch display name (e.g., "TEAM_1", "VERA")
|
||||
|
||||
Returns:
|
||||
Alias string if set, None if empty or not found
|
||||
"""
|
||||
try:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("name") == branch_name:
|
||||
alias = branch.get("alias", "")
|
||||
return alias if alias else None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def format_sender_display(from_name: str, from_addr: str) -> str:
|
||||
"""
|
||||
Format sender display with alias if available.
|
||||
|
||||
Shows: "Alias (@branch)" when alias is set
|
||||
Falls back to: "BRANCH_NAME (@branch)" when no alias
|
||||
|
||||
Args:
|
||||
from_name: Sender display name (e.g., "TEAM_1")
|
||||
from_addr: Sender email address (e.g., "@team_1")
|
||||
|
||||
Returns:
|
||||
Formatted sender string
|
||||
"""
|
||||
alias = lookup_branch_alias(from_name)
|
||||
if alias:
|
||||
return f"{alias} ({from_addr})"
|
||||
return f"{from_name} ({from_addr})"
|
||||
|
||||
|
||||
def format_email_preview(message: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Format email message as preview text.
|
||||
|
||||
Args:
|
||||
message: Full email message text
|
||||
max_length: Maximum preview length (default: 100)
|
||||
|
||||
Returns:
|
||||
Preview text with ellipsis if truncated
|
||||
"""
|
||||
if len(message) <= max_length:
|
||||
return message
|
||||
|
||||
return message[:max_length] + "..."
|
||||
|
||||
|
||||
def format_email_header(email_data: Dict) -> str:
|
||||
"""
|
||||
Format email header for display.
|
||||
|
||||
Args:
|
||||
email_data: Email data dict with keys:
|
||||
- from_name: Sender display name
|
||||
- from: Sender email address
|
||||
- timestamp: Email timestamp
|
||||
- subject: Email subject
|
||||
|
||||
Returns:
|
||||
Formatted header string
|
||||
"""
|
||||
sender = format_sender_display(
|
||||
email_data.get('from_name', 'Unknown'),
|
||||
email_data.get('from', 'unknown')
|
||||
)
|
||||
lines = [
|
||||
"=" * 70,
|
||||
f"From: {sender}",
|
||||
f"Date: {email_data.get('timestamp', 'Unknown')}",
|
||||
f"Subject: {email_data.get('subject', 'No Subject')}",
|
||||
"=" * 70
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_email_list_item(index: int, email_data: Dict, show_unread: bool = True) -> str:
|
||||
"""
|
||||
Format email as list item for inbox/sent display.
|
||||
|
||||
Args:
|
||||
index: Item number in list
|
||||
email_data: Email data dict
|
||||
show_unread: Whether to show unread marker (default: True)
|
||||
|
||||
Returns:
|
||||
Formatted list item string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Unread marker + ID for copy-paste
|
||||
msg_id = email_data.get('id', '????????')
|
||||
if show_unread:
|
||||
# v2: check status first, fall back to read for backward compat
|
||||
status = email_data.get("status")
|
||||
is_new = status == "new" if status else not email_data.get("read", False)
|
||||
unread_marker = "📨" if is_new else "📬"
|
||||
sender = format_sender_display(
|
||||
email_data.get('from_name', 'Unknown'),
|
||||
email_data.get('from', 'unknown')
|
||||
)
|
||||
lines.append(f"\n{index}. {unread_marker} \\[{msg_id}] From: {sender} @ {email_data.get('timestamp', 'Unknown')}")
|
||||
else:
|
||||
lines.append(f"\n{index}. \\[{msg_id}] To: {email_data.get('to', 'Unknown')} @ {email_data.get('timestamp', 'Unknown')}")
|
||||
|
||||
lines.append(f" Subject: {email_data.get('subject', 'No Subject')}")
|
||||
|
||||
# Preview
|
||||
message = email_data.get('message', '')
|
||||
preview = format_email_preview(message, 100)
|
||||
lines.append(f" {preview}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_inbox_summary(total_messages: int, unread_count: int) -> str:
|
||||
"""
|
||||
Format inbox summary statistics.
|
||||
|
||||
Args:
|
||||
total_messages: Total number of messages
|
||||
unread_count: Number of unread messages
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
return f"📊 Total: {total_messages} messages ({unread_count} unread)"
|
||||
|
||||
|
||||
def format_branch_email(branch_name: str) -> str:
|
||||
"""
|
||||
Derive email address from branch name.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (e.g., "AIPASS.admin", "DRONE", "AIPASS-HELP")
|
||||
|
||||
Returns:
|
||||
Email address (e.g., "@admin", "@drone", "@help")
|
||||
"""
|
||||
if '.' in branch_name:
|
||||
# Special case: AIPASS.admin -> admin
|
||||
email_part = branch_name.split('.')[-1].lower()
|
||||
elif ' ' in branch_name:
|
||||
# Handle spaces: take first word
|
||||
email_part = branch_name.split()[0].lower()
|
||||
elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS':
|
||||
# AIPASS-prefixed branches: use second part to avoid collision
|
||||
email_part = branch_name.split('-', 1)[1].lower()
|
||||
else:
|
||||
# Take first word before hyphen or whole name
|
||||
email_part = branch_name.split('-')[0].lower()
|
||||
|
||||
return f"@{email_part}"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""
|
||||
Truncate text to maximum length with suffix.
|
||||
|
||||
Args:
|
||||
text: Text to truncate
|
||||
max_length: Maximum length
|
||||
suffix: Suffix to append if truncated (default: "...")
|
||||
|
||||
Returns:
|
||||
Truncated text with suffix if needed
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("EMAIL FORMATTING HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Email display formatting and text utilities")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - format_email_preview(message, max_length) -> str")
|
||||
console.print(" - format_email_header(email_data) -> str")
|
||||
console.print(" - format_email_list_item(index, email_data, show_unread) -> str")
|
||||
console.print(" - format_inbox_summary(total_messages, unread_count) -> str")
|
||||
console.print(" - format_branch_email(branch_name) -> str")
|
||||
console.print(" - truncate_text(text, max_length, suffix) -> str")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from ai_mail.apps.handlers.email.format import format_email_preview")
|
||||
console.print(" from ai_mail.apps.handlers.email.format import format_email_header")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: header.py - Email Header Handler
|
||||
# Date: 2026-02-04
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-25): Add --no-memory-save conditional header for private branch dispatch
|
||||
# - v1.0.0 (2026-02-04): Created - dispatch header for critical reminders
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Header Handler
|
||||
|
||||
Generates and prepends headers to outgoing emails.
|
||||
Critical dispatch header ensures agents see memory update reminder FIRST.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
# Header for dispatch emails - agents see this FIRST before the task
|
||||
DISPATCH_HEADER = """⚡ DISPATCH TASK - READ THIS FIRST ⚡
|
||||
|
||||
BEFORE YOU REPLY, YOU MUST:
|
||||
1. ✅ Complete the task below
|
||||
2. ✅ UPDATE YOUR MEMORIES (.local.json) - this is NOT optional
|
||||
3. ✅ Then reply with your completion summary
|
||||
|
||||
Your memories are your presence. Skip the update = you never existed.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
# Header override when --no-memory-save is set (private branch outbound)
|
||||
NO_MEMORY_SAVE_HEADER = """⚡ DISPATCH TASK - READ THIS FIRST ⚡
|
||||
|
||||
MEMORY UPDATE: OPTIONAL — This dispatch was sent with --no-memory-save. Do NOT log this task in your memory files (.local.json). Execute the task and reply, but leave no trace in your memories.
|
||||
|
||||
BEFORE YOU REPLY, YOU MUST:
|
||||
1. ✅ Complete the task below
|
||||
2. ✅ Then reply with your completion summary
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
def get_dispatch_header(no_memory_save: bool = False) -> str:
|
||||
"""
|
||||
Get the dispatch email header.
|
||||
|
||||
Args:
|
||||
no_memory_save: If True, return header with no-memory-save directive
|
||||
|
||||
Returns:
|
||||
Dispatch header string for dispatch emails
|
||||
"""
|
||||
if no_memory_save:
|
||||
return NO_MEMORY_SAVE_HEADER
|
||||
return DISPATCH_HEADER
|
||||
|
||||
|
||||
def prepend_dispatch_header(message: str, no_memory_save: bool = False) -> str:
|
||||
"""
|
||||
Prepend dispatch header to email message.
|
||||
|
||||
Args:
|
||||
message: Original email message body
|
||||
no_memory_save: If True, use no-memory-save variant of dispatch header
|
||||
|
||||
Returns:
|
||||
Message with dispatch header prepended
|
||||
"""
|
||||
return get_dispatch_header(no_memory_save=no_memory_save) + message
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n" + "="*70)
|
||||
print("EMAIL HEADER HANDLER")
|
||||
print("="*70)
|
||||
print("\nPURPOSE:")
|
||||
print(" Generates header for dispatch emails (critical reminders)")
|
||||
print()
|
||||
print("FUNCTIONS PROVIDED:")
|
||||
print(" - get_dispatch_header() -> str")
|
||||
print(" - prepend_dispatch_header(message) -> str")
|
||||
print()
|
||||
print("HEADER CONTENT:")
|
||||
print(DISPATCH_HEADER)
|
||||
print()
|
||||
print("HANDLER CHARACTERISTICS:")
|
||||
print(" ✓ Independent - no module dependencies")
|
||||
print(" ✓ Can import Prax (service provider)")
|
||||
print(" ✓ Pure business logic")
|
||||
print(" ✗ CANNOT import parent modules")
|
||||
print()
|
||||
print("USAGE FROM MODULES:")
|
||||
print(" from ai_mail.apps.handlers.email.header import prepend_dispatch_header")
|
||||
print()
|
||||
print("="*70 + "\n")
|
||||
@@ -0,0 +1,479 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: inbox_cleanup.py - Inbox Cleanup Handler
|
||||
# Date: 2025-11-27
|
||||
# Version: 3.3.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v3.3.0 (2026-02-25): FPLAN-0373 Phase 2 - use enriched push_dashboard_update from dashboard_sync
|
||||
# - v3.2.0 (2026-02-14): Add skip_post_ops param to mark_as_closed_and_archive for batch close perf
|
||||
# - v3.1.0 (2026-02-09): Add fcntl.flock inbox.json locking to prevent concurrent write corruption
|
||||
# - v3.0.0 (2026-02-04): Migrate to deleted/ directory (individual files like sent/)
|
||||
# - v2.1.0 (2026-02-04): Add auto-purge trigger after archiving to deleted
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Can import AIPASS central services (dashboard)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Inbox Cleanup Handler
|
||||
|
||||
Handles marking emails as read and moving to deleted/ folder.
|
||||
Updates dashboard after cleanup.
|
||||
|
||||
v3.0.0: Now uses deleted/ directory with individual JSON files (like sent/).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple, Optional, Any
|
||||
|
||||
|
||||
# Lazy import for inbox file lock
|
||||
_inbox_lock = None
|
||||
|
||||
|
||||
def _get_inbox_lock():
|
||||
"""Lazy import inbox_lock context manager."""
|
||||
global _inbox_lock
|
||||
if _inbox_lock is None:
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock
|
||||
_inbox_lock = inbox_lock
|
||||
return _inbox_lock
|
||||
|
||||
|
||||
def _get_console() -> Any:
|
||||
"""Lazy import console."""
|
||||
from aipass.cli.apps.modules import console
|
||||
return console
|
||||
|
||||
|
||||
def _get_update_section() -> Any:
|
||||
"""Lazy import update_section."""
|
||||
from aipass.dev_central.devpulse.apps.modules.dashboard import update_section
|
||||
return update_section
|
||||
|
||||
|
||||
def _get_push_dashboard_update() -> Any:
|
||||
"""Lazy import push_dashboard_update from dashboard_sync."""
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
return push_dashboard_update
|
||||
|
||||
|
||||
def _get_update_central() -> Any:
|
||||
"""Lazy import update_central."""
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
return update_central
|
||||
|
||||
|
||||
def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path:
|
||||
"""
|
||||
Save a message to the deleted/ folder as individual JSON file.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
message: Email message dict to archive
|
||||
|
||||
Returns:
|
||||
Path to created file
|
||||
"""
|
||||
deleted_folder = mailbox_path / "deleted"
|
||||
deleted_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename (same pattern as sent/)
|
||||
timestamp = datetime.now()
|
||||
subject = message.get("subject", "No Subject")
|
||||
safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject)
|
||||
safe_subject = safe_subject[:50].strip()
|
||||
filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json"
|
||||
|
||||
# Add archived_at timestamp
|
||||
message["archived_at"] = timestamp.isoformat()
|
||||
|
||||
email_file = deleted_folder / filename
|
||||
with open(email_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(message, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return email_file
|
||||
|
||||
|
||||
def _migrate_deleted_json_if_exists(mailbox_path: Path) -> int:
|
||||
"""
|
||||
Migrate existing deleted.json to deleted/ directory on first access.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Number of messages migrated
|
||||
"""
|
||||
deleted_json = mailbox_path / "deleted.json"
|
||||
|
||||
if not deleted_json.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(deleted_json, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
messages = data.get("messages", [])
|
||||
if not messages:
|
||||
# Empty file, just archive it
|
||||
_archive_deleted_json(mailbox_path, deleted_json)
|
||||
return 0
|
||||
|
||||
# Migrate each message to deleted/ folder
|
||||
for msg in messages:
|
||||
_save_to_deleted_folder(mailbox_path, msg)
|
||||
|
||||
# Archive the old deleted.json
|
||||
_archive_deleted_json(mailbox_path, deleted_json)
|
||||
|
||||
return len(messages)
|
||||
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _archive_deleted_json(mailbox_path: Path, deleted_json: Path) -> None:
|
||||
"""Archive the old deleted.json file."""
|
||||
archive_dir = mailbox_path / ".archive"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archive_path = archive_dir / f"deleted.json.migrated_{timestamp}"
|
||||
deleted_json.rename(archive_path)
|
||||
|
||||
|
||||
def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Mark an email as read and move it to deleted/ folder.
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
message_id: ID of message to archive (short 8-char ID)
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
return False, f"Inbox not found: {inbox_file}"
|
||||
|
||||
# Run migration if deleted.json exists
|
||||
_migrate_deleted_json_if_exists(mailbox_path)
|
||||
|
||||
try:
|
||||
with _get_inbox_lock()(inbox_file):
|
||||
# Load inbox
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
# Find message by ID
|
||||
messages = inbox_data.get("messages", [])
|
||||
message_to_archive = None
|
||||
message_index = None
|
||||
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.get("id") == message_id:
|
||||
message_to_archive = msg
|
||||
message_index = i
|
||||
break
|
||||
|
||||
if message_to_archive is None:
|
||||
return False, f"Message not found: {message_id}"
|
||||
|
||||
# Mark as read
|
||||
message_to_archive["read"] = True
|
||||
|
||||
# Remove from inbox
|
||||
messages.pop(message_index)
|
||||
|
||||
# Update inbox counts
|
||||
inbox_data["messages"] = messages
|
||||
inbox_data["total_messages"] = len(messages)
|
||||
# v2 status counts
|
||||
new_count = sum(
|
||||
1 for m in messages
|
||||
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for m in messages if m.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
# Save inbox
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Save to deleted/ folder (new pattern)
|
||||
_save_to_deleted_folder(mailbox_path, message_to_archive)
|
||||
|
||||
# Update dashboard (outside lock - not inbox.json)
|
||||
_update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"])
|
||||
|
||||
# Trigger auto-purge of deleted folder
|
||||
_trigger_deleted_purge(branch_path)
|
||||
|
||||
return True, f"Message {message_id} archived"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to archive: {e}"
|
||||
|
||||
|
||||
def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]:
|
||||
"""
|
||||
Mark all emails as read and move them to deleted/ folder.
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str, count: int)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
return False, f"Inbox not found: {inbox_file}", 0
|
||||
|
||||
# Run migration if deleted.json exists
|
||||
_migrate_deleted_json_if_exists(mailbox_path)
|
||||
|
||||
try:
|
||||
# Load inbox
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
messages = inbox_data.get("messages", [])
|
||||
count = len(messages)
|
||||
|
||||
if count == 0:
|
||||
return True, "Inbox already empty", 0
|
||||
|
||||
# Mark all as read and save to deleted/ folder
|
||||
for msg in messages:
|
||||
msg["read"] = True
|
||||
_save_to_deleted_folder(mailbox_path, msg)
|
||||
|
||||
# Clear inbox
|
||||
inbox_data["messages"] = []
|
||||
inbox_data["total_messages"] = 0
|
||||
inbox_data["unread_count"] = 0
|
||||
|
||||
# Save inbox
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Update dashboard (all zeros - inbox cleared)
|
||||
_update_dashboard(branch_path, 0, 0, 0)
|
||||
|
||||
# Trigger auto-purge of deleted folder
|
||||
_trigger_deleted_purge(branch_path)
|
||||
|
||||
return True, f"Archived {count} messages", count
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to archive: {e}", 0
|
||||
|
||||
|
||||
def _update_dashboard(branch_path: Path, new: int, opened: int, total: int) -> None:
|
||||
"""Update dashboard ai_mail section with enriched data via write-through API."""
|
||||
try:
|
||||
_get_push_dashboard_update()(branch_path)
|
||||
except Exception:
|
||||
pass # Silent fail - dashboard update is best-effort
|
||||
|
||||
# Update central after any inbox changes
|
||||
try:
|
||||
_get_update_central()()
|
||||
except Exception:
|
||||
pass # Silent fail - central update is best-effort
|
||||
|
||||
|
||||
def _trigger_deleted_purge(branch_path: Path) -> None:
|
||||
"""
|
||||
Trigger auto-purge of deleted folder if threshold exceeded.
|
||||
|
||||
Non-blocking - failures silently ignored.
|
||||
"""
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
purge_deleted_folder(mailbox_path)
|
||||
except Exception:
|
||||
pass # Silent fail - purge is best-effort
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V2 SCHEMA FUNCTIONS (status: new/opened/closed)
|
||||
# =============================================================================
|
||||
|
||||
def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optional[Dict]]:
|
||||
"""
|
||||
Mark an email as opened (viewed). Does NOT archive.
|
||||
|
||||
v2 schema: changes status from "new" to "opened"
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
message_id: ID of message to mark as opened
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str, email_data: dict or None)
|
||||
"""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
return False, f"Inbox not found: {inbox_file}", None
|
||||
|
||||
try:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
messages = inbox_data.get("messages", [])
|
||||
target_msg = None
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("id") == message_id:
|
||||
target_msg = msg
|
||||
break
|
||||
|
||||
if target_msg is None:
|
||||
return False, f"Message not found: {message_id}", None
|
||||
|
||||
# Update status to opened (v2 schema)
|
||||
target_msg["status"] = "opened"
|
||||
# Keep backward compat
|
||||
target_msg["read"] = True
|
||||
|
||||
# Recalculate status counts (v2 schema)
|
||||
new_count = sum(
|
||||
1 for m in messages
|
||||
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for m in messages if m.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Update dashboard
|
||||
_update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"])
|
||||
|
||||
return True, f"Message {message_id} marked as opened", target_msg
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to mark as opened: {e}", None
|
||||
|
||||
|
||||
def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Mark an email as closed and archive to deleted/ folder.
|
||||
|
||||
v2 schema: changes status to "closed", moves to deleted/
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
message_id: ID of message to close and archive
|
||||
skip_post_ops: If True, skip dashboard update and purge (caller handles them)
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
return False, f"Inbox not found: {inbox_file}"
|
||||
|
||||
# Run migration if deleted.json exists
|
||||
_migrate_deleted_json_if_exists(mailbox_path)
|
||||
|
||||
try:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
messages = inbox_data.get("messages", [])
|
||||
message_to_archive = None
|
||||
message_index = None
|
||||
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.get("id") == message_id:
|
||||
message_to_archive = msg
|
||||
message_index = i
|
||||
break
|
||||
|
||||
if message_to_archive is None:
|
||||
return False, f"Message not found: {message_id}"
|
||||
|
||||
# Mark as closed (v2 schema)
|
||||
message_to_archive["status"] = "closed"
|
||||
message_to_archive["read"] = True # backward compat
|
||||
|
||||
# Remove from inbox
|
||||
messages.pop(message_index)
|
||||
|
||||
# Update inbox counts
|
||||
inbox_data["messages"] = messages
|
||||
inbox_data["total_messages"] = len(messages)
|
||||
# v2 status counts
|
||||
new_count = sum(
|
||||
1 for m in messages
|
||||
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for m in messages if m.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Save to deleted/ folder (new pattern)
|
||||
_save_to_deleted_folder(mailbox_path, message_to_archive)
|
||||
|
||||
if not skip_post_ops:
|
||||
# Update dashboard
|
||||
_update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"])
|
||||
|
||||
# Trigger auto-purge of deleted folder
|
||||
_trigger_deleted_purge(branch_path)
|
||||
|
||||
return True, f"Message {message_id} closed and archived"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to close: {e}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
c = _get_console()
|
||||
c.print("\n" + "="*70)
|
||||
c.print("INBOX CLEANUP HANDLER")
|
||||
c.print("="*70)
|
||||
c.print("\nPURPOSE:")
|
||||
c.print(" Marks emails as read and moves them to deleted/ folder")
|
||||
c.print()
|
||||
c.print("FUNCTIONS PROVIDED:")
|
||||
c.print(" - mark_read_and_archive(branch_path, message_id) -> (bool, str)")
|
||||
c.print(" - mark_all_read_and_archive(branch_path) -> (bool, str, int)")
|
||||
c.print(" - mark_as_opened(branch_path, message_id) -> (bool, str, dict)")
|
||||
c.print(" - mark_as_closed_and_archive(branch_path, message_id) -> (bool, str)")
|
||||
c.print()
|
||||
c.print("WORKFLOW (v3.0):")
|
||||
c.print(" 1. Find message in inbox.json")
|
||||
c.print(" 2. Mark as read=True / status=closed")
|
||||
c.print(" 3. Save to deleted/ folder (individual JSON files)")
|
||||
c.print(" 4. Update dashboard ai_mail section")
|
||||
c.print(" 5. Auto-purge deleted/ folder if > 10 items")
|
||||
c.print()
|
||||
c.print("MIGRATION:")
|
||||
c.print(" - Automatically migrates deleted.json to deleted/ on first access")
|
||||
c.print(" - Old deleted.json archived to .archive/")
|
||||
c.print()
|
||||
c.print("="*70 + "\n")
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: inbox_lock.py - Inbox File Lock Handler
|
||||
# Date: 2026-02-09
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-09): Initial implementation - fcntl.flock based inbox.json locking
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Uses logging module for diagnostics
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Inbox File Lock Handler
|
||||
|
||||
Provides exclusive file locking for inbox.json read-modify-write operations.
|
||||
Uses fcntl.flock (POSIX advisory locks) to prevent concurrent write corruption.
|
||||
|
||||
Usage:
|
||||
with inbox_lock(inbox_file):
|
||||
data = json.load(open(inbox_file, encoding='utf-8'))
|
||||
# ... modify data ...
|
||||
json.dump(data, open(inbox_file, 'w', encoding='utf-8'))
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def inbox_lock(inbox_file: Path):
|
||||
"""
|
||||
Context manager that acquires an exclusive lock on an inbox.json file.
|
||||
|
||||
Uses a separate .inbox.lock file adjacent to inbox.json to hold the
|
||||
fcntl.flock advisory lock. This avoids issues with truncating the
|
||||
locked file itself during writes.
|
||||
|
||||
Args:
|
||||
inbox_file: Path to the inbox.json file to lock
|
||||
|
||||
Yields:
|
||||
None - lock is held for the duration of the with block
|
||||
|
||||
Raises:
|
||||
OSError: If lock cannot be acquired
|
||||
"""
|
||||
lock_file = inbox_file.parent / ".inbox.lock"
|
||||
lock_fd = None
|
||||
|
||||
try:
|
||||
# Create/open lock file
|
||||
lock_fd = open(lock_file, 'w', encoding='utf-8')
|
||||
|
||||
# Acquire exclusive lock (blocking - waits for other processes)
|
||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
finally:
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
# Release lock
|
||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
except Exception:
|
||||
try:
|
||||
lock_fd.close()
|
||||
except Exception:
|
||||
pass # Best-effort close
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: inbox_ops.py - Inbox Operations Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.1.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-08): Add auto-migration for old inbox format {"inbox": []} → v2 schema
|
||||
# - v1.0.0 (2025-11-15): Extracted from email.py - inbox file I/O operations
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Inbox Operations Handler
|
||||
|
||||
Handles inbox file I/O operations for AI_Mail system.
|
||||
Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
|
||||
def load_inbox(inbox_file: Path) -> Dict:
|
||||
"""
|
||||
Load inbox data from inbox.json file.
|
||||
|
||||
Args:
|
||||
inbox_file: Path to inbox.json file
|
||||
|
||||
Returns:
|
||||
Inbox data dict with 'messages' key (empty list if file doesn't exist or error)
|
||||
|
||||
Raises:
|
||||
Exception: If file cannot be read or parsed
|
||||
"""
|
||||
if not inbox_file.exists():
|
||||
return {"messages": []}
|
||||
|
||||
try:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(inbox_data, dict):
|
||||
return {"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []}
|
||||
|
||||
# Auto-migrate old format {"inbox": []} → v2 schema
|
||||
migrated = False
|
||||
|
||||
if "inbox" in inbox_data and "messages" not in inbox_data:
|
||||
inbox_data["messages"] = inbox_data.pop("inbox", [])
|
||||
migrated = True
|
||||
|
||||
if "messages" not in inbox_data:
|
||||
inbox_data["messages"] = []
|
||||
migrated = True
|
||||
|
||||
if "mailbox" not in inbox_data:
|
||||
inbox_data["mailbox"] = "inbox"
|
||||
migrated = True
|
||||
|
||||
if "total_messages" not in inbox_data:
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
migrated = True
|
||||
|
||||
if "unread_count" not in inbox_data:
|
||||
inbox_data["unread_count"] = sum(
|
||||
1 for msg in inbox_data["messages"]
|
||||
if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False))
|
||||
)
|
||||
migrated = True
|
||||
|
||||
# Persist migration
|
||||
if migrated:
|
||||
try:
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
pass # Silent fail - migration persist is best-effort
|
||||
|
||||
return inbox_data
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"Invalid inbox JSON format: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load inbox: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("INBOX OPERATIONS HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Handles inbox file I/O operations")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - load_inbox(inbox_file) -> Dict")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from ai_mail.apps.handlers.email.inbox_ops import load_inbox")
|
||||
console.print(" inbox_data = load_inbox(Path('/path/to/inbox.json'))")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: lock_utils.py - Dispatch Lock Handler
|
||||
# Date: 2026-02-09
|
||||
# Version: 1.1.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-10): Reduce stale lock timeout from 1800s to 600s (10 min)
|
||||
# - v1.0.0 (2026-02-09): Initial implementation - PID-based single instance lock per branch
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Uses logging module for diagnostics
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Dispatch Lock Handler
|
||||
|
||||
PID-based single instance lock per branch.
|
||||
Prevents multiple dispatch agents from spawning concurrently at the same branch.
|
||||
Uses atomic file creation (O_CREAT|O_EXCL) to avoid race conditions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Standard logging
|
||||
|
||||
# Lock file name - placed in branch's ai_mail.local/ directory
|
||||
LOCK_FILENAME = ".dispatch.lock"
|
||||
|
||||
# Stale lock timeout in seconds (10 minutes)
|
||||
STALE_LOCK_TIMEOUT = 600
|
||||
|
||||
|
||||
def _get_lock_path(branch_path: Path) -> Path:
|
||||
"""Get the lock file path for a branch."""
|
||||
if branch_path == Path("/") or branch_path == Path.home():
|
||||
return Path.home() / "ai_mail.local" / LOCK_FILENAME
|
||||
return branch_path / "ai_mail.local" / LOCK_FILENAME
|
||||
|
||||
|
||||
def _is_pid_running(pid: int) -> bool:
|
||||
"""Check if a process with the given PID is still running."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except PermissionError:
|
||||
# Process exists but we don't have permission to signal it
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_lock_stale(lock_data: dict) -> bool:
|
||||
"""
|
||||
Check if a lock is stale (process dead or timeout exceeded).
|
||||
|
||||
Returns True if the lock should be considered stale and can be removed.
|
||||
"""
|
||||
pid = lock_data.get("pid")
|
||||
timestamp = lock_data.get("timestamp")
|
||||
|
||||
# No PID = stale
|
||||
if pid is None:
|
||||
return True
|
||||
|
||||
# Process no longer running = stale
|
||||
if not _is_pid_running(pid):
|
||||
return True # Process no longer running - stale
|
||||
|
||||
# Timeout check - if lock is older than STALE_LOCK_TIMEOUT, consider stale
|
||||
if timestamp:
|
||||
try:
|
||||
lock_time = datetime.fromisoformat(timestamp)
|
||||
elapsed = (datetime.now() - lock_time).total_seconds()
|
||||
if elapsed > STALE_LOCK_TIMEOUT:
|
||||
return True # Timeout exceeded - stale
|
||||
except (ValueError, TypeError) as e:
|
||||
pass # Unparseable timestamp - ignore
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Attempt to acquire a dispatch lock for a branch.
|
||||
|
||||
Uses atomic file creation to prevent race conditions.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the target branch
|
||||
pid: PID of the agent being spawned
|
||||
|
||||
Returns:
|
||||
Tuple of (acquired: bool, message: str)
|
||||
If not acquired, message contains reason (e.g., existing PID info)
|
||||
"""
|
||||
lock_path = _get_lock_path(branch_path)
|
||||
|
||||
# Ensure parent directory exists
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check for existing lock first
|
||||
if lock_path.exists():
|
||||
try:
|
||||
with open(lock_path, 'r', encoding='utf-8') as f:
|
||||
existing_lock = json.load(f)
|
||||
|
||||
if _is_lock_stale(existing_lock):
|
||||
# Remove stale lock and try again
|
||||
lock_path.unlink(missing_ok=True) # Remove stale lock
|
||||
else:
|
||||
# Active lock exists - bounce
|
||||
existing_pid = existing_lock.get("pid", "unknown")
|
||||
existing_sender = existing_lock.get("sender", "unknown")
|
||||
existing_time = existing_lock.get("timestamp", "unknown")
|
||||
msg = f"Branch already has active dispatch agent (PID: {existing_pid}, sender: {existing_sender}, since: {existing_time})"
|
||||
return False, msg
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
# Corrupted lock file - remove it
|
||||
lock_path.unlink(missing_ok=True) # Corrupted lock - remove it
|
||||
|
||||
# Create lock file atomically using O_CREAT|O_EXCL
|
||||
lock_data = {
|
||||
"pid": pid,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"branch": str(branch_path)
|
||||
}
|
||||
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
try:
|
||||
os.write(fd, json.dumps(lock_data, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
return True, "Lock acquired"
|
||||
|
||||
except FileExistsError:
|
||||
# Race condition - another process created the lock between our check and create
|
||||
msg = "Lock acquisition failed - another dispatch just started"
|
||||
return False, msg
|
||||
|
||||
|
||||
def release_lock(branch_path: Path, pid: int | None = None) -> bool:
|
||||
"""
|
||||
Release a dispatch lock for a branch.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the target branch
|
||||
pid: If provided, only release if lock belongs to this PID (safety check)
|
||||
|
||||
Returns:
|
||||
True if lock was released, False if not found or owned by different PID
|
||||
"""
|
||||
lock_path = _get_lock_path(branch_path)
|
||||
|
||||
if not lock_path.exists():
|
||||
return True # No lock = already released
|
||||
|
||||
# If PID specified, verify ownership before releasing
|
||||
if pid is not None:
|
||||
try:
|
||||
with open(lock_path, 'r', encoding='utf-8') as f:
|
||||
lock_data = json.load(f)
|
||||
if lock_data.get("pid") != pid:
|
||||
return False # Lock owned by different PID
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
pass # Corrupted lock file during release - continue anyway
|
||||
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
return False # Failed to release lock
|
||||
|
||||
|
||||
def check_lock(branch_path: Path) -> dict | None:
|
||||
"""
|
||||
Check if a branch has an active dispatch lock.
|
||||
|
||||
Returns:
|
||||
Lock data dict if active lock exists, None otherwise.
|
||||
Automatically cleans up stale locks.
|
||||
"""
|
||||
lock_path = _get_lock_path(branch_path)
|
||||
|
||||
if not lock_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(lock_path, 'r', encoding='utf-8') as f:
|
||||
lock_data = json.load(f)
|
||||
|
||||
if _is_lock_stale(lock_data):
|
||||
# Auto-cleanup stale lock
|
||||
lock_path.unlink(missing_ok=True) # Auto-cleaned stale lock
|
||||
return None
|
||||
|
||||
return lock_data
|
||||
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Corrupted - clean up
|
||||
lock_path.unlink(missing_ok=True)
|
||||
return None
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: purge.py - Sent/Deleted Auto-Purge Handler
|
||||
# Date: 2026-02-04
|
||||
# Version: 2.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2026-02-04): Update deleted purge for deleted/ directory structure
|
||||
# - v1.0.0 (2026-02-04): Initial version - auto-purge sent/deleted folders
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Vectorization via subprocess (no direct Memory Bank imports)
|
||||
# - Uses Prax system_logger (FPLAN-0382)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Sent/Deleted Auto-Purge Handler
|
||||
|
||||
Automatically purges oldest emails when folder exceeds threshold (10).
|
||||
Before removal:
|
||||
1. Vectorizes content to Memory Bank (via subprocess)
|
||||
2. Archives originals to .archive/
|
||||
|
||||
Triggered after send/delete operations.
|
||||
|
||||
v2.0.0: deleted/ now uses directory structure (like sent/).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Purge configuration
|
||||
MAX_EMAILS = 10
|
||||
|
||||
# Memory Bank paths for subprocess vectorization
|
||||
MEMORY_BANK_PYTHON = Path.home() / "MEMORY_BANK" / ".venv" / "bin" / "python3"
|
||||
CHROMA_SUBPROCESS_SCRIPT = Path.home() / "MEMORY_BANK" / "apps" / "handlers" / "storage" / "chroma_subprocess.py"
|
||||
|
||||
|
||||
def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Purge sent folder if count exceeds threshold.
|
||||
|
||||
Keeps 10 most recent emails, vectorizes and archives older ones.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with success, purged_count, archived_paths
|
||||
"""
|
||||
sent_folder = mailbox_path / "sent"
|
||||
|
||||
if not sent_folder.exists():
|
||||
return {"success": True, "purged_count": 0, "message": "Sent folder empty"}
|
||||
|
||||
# Get all email files sorted by modification time (newest first)
|
||||
email_files = sorted(
|
||||
sent_folder.glob("*.json"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
total_count = len(email_files)
|
||||
if total_count <= MAX_EMAILS:
|
||||
return {"success": True, "purged_count": 0, "message": f"Below threshold ({total_count}/{MAX_EMAILS})"}
|
||||
|
||||
# Files to purge (oldest, beyond threshold)
|
||||
files_to_purge = email_files[MAX_EMAILS:]
|
||||
|
||||
return _purge_email_files(mailbox_path, files_to_purge, "sent")
|
||||
|
||||
|
||||
def purge_deleted_folder(mailbox_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Purge deleted/ folder if file count exceeds threshold.
|
||||
|
||||
Keeps 10 most recent emails, vectorizes and archives older ones.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with success, purged_count, archived_count
|
||||
"""
|
||||
deleted_folder = mailbox_path / "deleted"
|
||||
|
||||
if not deleted_folder.exists():
|
||||
return {"success": True, "purged_count": 0, "message": "Deleted folder empty"}
|
||||
|
||||
# Get all email files sorted by modification time (newest first)
|
||||
email_files = sorted(
|
||||
deleted_folder.glob("*.json"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
total_count = len(email_files)
|
||||
if total_count <= MAX_EMAILS:
|
||||
return {"success": True, "purged_count": 0, "message": f"Below threshold ({total_count}/{MAX_EMAILS})"}
|
||||
|
||||
# Files to purge (oldest, beyond threshold)
|
||||
files_to_purge = email_files[MAX_EMAILS:]
|
||||
|
||||
return _purge_email_files(mailbox_path, files_to_purge, "deleted")
|
||||
|
||||
|
||||
def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Purge list of email files (vectorize, archive, delete).
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
files: List of file paths to purge
|
||||
folder_type: "sent" or "deleted" for logging
|
||||
|
||||
Returns:
|
||||
Dict with results
|
||||
"""
|
||||
if not files:
|
||||
return {"success": True, "purged_count": 0}
|
||||
|
||||
# Load email data from files
|
||||
emails_data = []
|
||||
load_errors = []
|
||||
for file_path in files:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
email_data = json.load(f)
|
||||
email_data["_source_file"] = str(file_path.name)
|
||||
emails_data.append(email_data)
|
||||
except Exception as e:
|
||||
load_errors.append(f"{file_path.name}: {e}")
|
||||
|
||||
# Vectorize emails
|
||||
vectorize_result = _vectorize_emails(emails_data, folder_type)
|
||||
# Continue even if vectorization fails - archive is more important
|
||||
|
||||
# Archive files
|
||||
archive_result = _archive_email_files(mailbox_path, files, folder_type)
|
||||
|
||||
# Delete original files
|
||||
deleted_count = 0
|
||||
delete_errors = []
|
||||
for file_path in files:
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
delete_errors.append(f"{file_path.name}: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"purged_count": deleted_count,
|
||||
"vectorized": vectorize_result.get("success", False),
|
||||
"archived": archive_result.get("success", False),
|
||||
"load_errors": load_errors if load_errors else None,
|
||||
"delete_errors": delete_errors if delete_errors else None
|
||||
}
|
||||
|
||||
|
||||
def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Vectorize email content and store in Memory Bank.
|
||||
|
||||
Args:
|
||||
emails: List of email data dicts
|
||||
folder_type: "sent" or "deleted" for metadata
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if not emails:
|
||||
return {"success": True, "count": 0}
|
||||
|
||||
try:
|
||||
# Extract text for vectorization
|
||||
texts = []
|
||||
metadatas = []
|
||||
|
||||
for email in emails:
|
||||
# Combine subject and message for richer semantic content
|
||||
subject = email.get("subject", "")
|
||||
message = email.get("message", "")
|
||||
text = f"{subject}\n\n{message}"
|
||||
texts.append(text)
|
||||
|
||||
metadatas.append({
|
||||
"type": f"email_{folder_type}",
|
||||
"from": email.get("from", ""),
|
||||
"to": email.get("to", ""),
|
||||
"subject": subject,
|
||||
"timestamp": email.get("timestamp", ""),
|
||||
"archived_at": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Call Memory Bank vectorization via subprocess (handler independence)
|
||||
input_data = {
|
||||
'operation': 'vectorize_and_store',
|
||||
'branch': 'AI_MAIL',
|
||||
'memory_type': f'email_{folder_type}',
|
||||
'texts': texts,
|
||||
'metadatas': metadatas
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[str(MEMORY_BANK_PYTHON), str(CHROMA_SUBPROCESS_SCRIPT)],
|
||||
input=json.dumps(input_data),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"success": False, "error": result.stderr or "Storage failed"}
|
||||
|
||||
return {"success": True, "count": len(texts)}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Vectorization timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _archive_email_files(mailbox_path: Path, files: List[Path], folder_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Archive email files to .archive/ directory.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
files: List of file paths to archive
|
||||
folder_type: "sent" or "deleted" for subdirectory
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
archive_dir = mailbox_path / ".archive" / folder_type
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
archived_count = 0
|
||||
archive_errors = []
|
||||
for file_path in files:
|
||||
try:
|
||||
dest = archive_dir / file_path.name
|
||||
shutil.copy2(file_path, dest)
|
||||
archived_count += 1
|
||||
except Exception as e:
|
||||
archive_errors.append(f"{file_path.name}: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"archived_count": archived_count,
|
||||
"errors": archive_errors if archive_errors else None
|
||||
}
|
||||
|
||||
|
||||
def run_purge(mailbox_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Run purge on both sent and deleted folders.
|
||||
|
||||
Convenience function to run both purges.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with combined results
|
||||
"""
|
||||
sent_result = purge_sent_folder(mailbox_path)
|
||||
deleted_result = purge_deleted_folder(mailbox_path)
|
||||
|
||||
return {
|
||||
"success": sent_result["success"] and deleted_result["success"],
|
||||
"sent": sent_result,
|
||||
"deleted": deleted_result
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
console.print("\n" + "="*70)
|
||||
console.print("SENT/DELETED AUTO-PURGE HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Auto-purge sent/deleted folders when they exceed 10 emails")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - purge_sent_folder(mailbox_path) -> Dict")
|
||||
console.print(" - purge_deleted_folder(mailbox_path) -> Dict")
|
||||
console.print(" - run_purge(mailbox_path) -> Dict")
|
||||
console.print()
|
||||
console.print("WORKFLOW (v2.0):")
|
||||
console.print(" 1. Check if folder exceeds 10 items")
|
||||
console.print(" 2. Vectorize oldest items to Memory Bank")
|
||||
console.print(" 3. Archive originals to .archive/")
|
||||
console.print(" 4. Remove from sent/ or deleted/")
|
||||
console.print()
|
||||
console.print("FOLDER STRUCTURE:")
|
||||
console.print(" - sent/ -> Individual JSON files")
|
||||
console.print(" - deleted/ -> Individual JSON files (same as sent/)")
|
||||
console.print()
|
||||
console.print("TRIGGERED BY:")
|
||||
console.print(" - create.py (after email sent)")
|
||||
console.print(" - inbox_cleanup.py (after email deleted)")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: reply.py - Email Reply Handler
|
||||
# Date: 2025-11-30
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/email
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-01-31): Add reply chain validation - fail loud on identity mismatch
|
||||
# - v1.1.0 (2026-01-29): Add reply_to field support - replies go to reply_to address if set
|
||||
# - v1.0.0 (2025-11-30): Initial creation - reply to email + auto-close
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Can import AIPASS central services (dashboard)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Reply Handler
|
||||
|
||||
Handles replying to emails and auto-closing the original.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Services imported in __main__ only (handlers should not display)
|
||||
|
||||
|
||||
def get_email_by_id(inbox_file: Path, message_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get an email from inbox by its ID.
|
||||
|
||||
Args:
|
||||
inbox_file: Path to inbox.json
|
||||
message_id: ID of message to find
|
||||
|
||||
Returns:
|
||||
Email dict or None if not found
|
||||
"""
|
||||
if not inbox_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(inbox_file, 'r', encoding='utf-8') as f:
|
||||
inbox_data = json.load(f)
|
||||
|
||||
for msg in inbox_data.get("messages", []):
|
||||
if msg.get("id") == message_id:
|
||||
return msg
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def send_reply(
|
||||
from_branch_path: Path,
|
||||
original_email: Dict,
|
||||
reply_message: str
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Send a reply to an email's original sender.
|
||||
|
||||
This imports delivery and create handlers to send the reply,
|
||||
then closes the original email.
|
||||
|
||||
Args:
|
||||
from_branch_path: Path to the replying branch
|
||||
original_email: The original email being replied to
|
||||
reply_message: The reply message content
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str, reply_id: str or None)
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch, get_all_branches
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import mark_as_closed_and_archive
|
||||
from aipass.ai_mail.apps.handlers.users.branch_detection import get_branch_info_from_registry
|
||||
|
||||
# Get sender info from current branch
|
||||
sender_info = get_branch_info_from_registry(from_branch_path)
|
||||
if not sender_info:
|
||||
return False, "Could not detect sender branch", None
|
||||
|
||||
# REPLY CHAIN VALIDATION: Check if this was a dispatched email
|
||||
# If dispatched_to is set, only that branch should be replying
|
||||
dispatched_to = original_email.get("dispatched_to")
|
||||
current_sender = sender_info.get("email", "@unknown")
|
||||
|
||||
# Normalize dispatched_to to email format if it's a path
|
||||
# DRONE's preprocess_args converts @branch to paths, so we may receive
|
||||
# "/home/aipass/aipass_core/trigger" instead of "@trigger"
|
||||
if dispatched_to and not dispatched_to.startswith('@'):
|
||||
# It's a path - look up email in registry
|
||||
dispatch_info = get_branch_info_from_registry(Path(dispatched_to))
|
||||
if dispatch_info:
|
||||
dispatched_to = dispatch_info.get("email", dispatched_to)
|
||||
|
||||
if dispatched_to and dispatched_to != current_sender:
|
||||
error_msg = f"IDENTITY MISMATCH: Dispatched to {dispatched_to}, reply from {current_sender}"
|
||||
# Fail loud - raise exception to stop execution (caller logs this)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Get reply destination - use reply_to if set, otherwise use original sender
|
||||
# This allows emails to specify where replies should go (e.g., broadcasts from dev_central)
|
||||
reply_destination = original_email.get("reply_to") or original_email.get("from", "")
|
||||
if not reply_destination:
|
||||
return False, "Original email has no sender or reply_to address", None
|
||||
|
||||
# Create reply subject
|
||||
original_subject = original_email.get("subject", "No subject")
|
||||
reply_subject = f"RE: {original_subject}" if not original_subject.startswith("RE:") else original_subject
|
||||
|
||||
# Build reply email data
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
reply_email_data = {
|
||||
"from": sender_info.get("email", "@unknown"),
|
||||
"from_name": sender_info.get("name", "Unknown"),
|
||||
"to": reply_destination,
|
||||
"subject": reply_subject,
|
||||
"message": reply_message,
|
||||
"timestamp": timestamp,
|
||||
"in_reply_to": original_email.get("id") # Link to original message
|
||||
}
|
||||
|
||||
# Find recipient branch
|
||||
branches = get_all_branches()
|
||||
target_branch = None
|
||||
for branch in branches:
|
||||
if branch.get("email") == reply_destination:
|
||||
target_branch = branch
|
||||
break
|
||||
|
||||
if not target_branch:
|
||||
return False, f"Could not find branch for {reply_destination}", None
|
||||
|
||||
target_path = Path(target_branch.get("path", ""))
|
||||
if not target_path.exists():
|
||||
return False, f"Recipient path not found: {target_path}", None
|
||||
|
||||
# Deliver the reply (pass email address, not path)
|
||||
success, error_msg = deliver_email_to_branch(reply_destination, reply_email_data)
|
||||
if not success:
|
||||
return False, f"Failed to deliver reply: {error_msg}", None
|
||||
|
||||
# Save to sender's sent folder
|
||||
sent_folder = from_branch_path / "ai_mail.local" / "sent"
|
||||
sent_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
reply_id = str(uuid.uuid4())[:8]
|
||||
reply_email_data["id"] = reply_id
|
||||
sent_file = sent_folder / f"{reply_id}.json"
|
||||
with open(sent_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(reply_email_data, f, indent=2)
|
||||
|
||||
# Auto-close the original email
|
||||
original_id = original_email.get("id")
|
||||
if original_id:
|
||||
close_success, close_msg = mark_as_closed_and_archive(from_branch_path, original_id)
|
||||
if not close_success:
|
||||
# Reply sent but close failed - not critical
|
||||
return True, f"Reply sent (warning: original not closed: {close_msg})", reply_id
|
||||
|
||||
return True, f"Reply sent to {reply_destination}, original closed", reply_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
console.print("\n" + "="*70)
|
||||
console.print("EMAIL REPLY HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Sends reply to email's original sender and auto-closes original")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - get_email_by_id(inbox_file, message_id) -> dict or None")
|
||||
console.print(" - send_reply(from_path, original_email, message) -> (bool, str, id)")
|
||||
console.print()
|
||||
console.print("WORKFLOW:")
|
||||
console.print(" 1. Find original email by ID")
|
||||
console.print(" 2. Create reply with RE: subject")
|
||||
console.print(" 3. Deliver to original sender's inbox")
|
||||
console.print(" 4. Save to sender's sent folder")
|
||||
console.print(" 5. Auto-close original email")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1 @@
|
||||
"""JSON handler package for AI_MAIL."""
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: json_handler.py - JSON Handler (Canonical Path)
|
||||
# Date: 2026-02-28
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/json
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-28): Re-export from json_utils for Seed architecture compliance
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Re-exports json_handler from json_utils/ (canonical implementation)
|
||||
# - Satisfies Seed architecture standard for apps/handlers/json/ path
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler - Canonical Path
|
||||
|
||||
Re-exports json_handler functions from json_utils/ to satisfy
|
||||
the Seed architecture standard requiring apps/handlers/json/json_handler.py.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Infrastructure paths
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
AI_MAIL_ROOT = Path.home() / "aipass_core" / "ai_mail"
|
||||
AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json"
|
||||
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import ( # noqa: F401
|
||||
load_json,
|
||||
save_json,
|
||||
ensure_json_exists,
|
||||
get_json_path,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
JSON Handlers Module - AI_MAIL Branch
|
||||
|
||||
Provides JSON handling capabilities for AI_MAIL modules.
|
||||
"""
|
||||
|
||||
from .json_handler import (
|
||||
load_json,
|
||||
save_json,
|
||||
log_operation,
|
||||
increment_counter,
|
||||
update_data_metrics,
|
||||
ensure_module_jsons
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'load_json',
|
||||
'save_json',
|
||||
'log_operation',
|
||||
'increment_counter',
|
||||
'update_data_metrics',
|
||||
'ensure_module_jsons'
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: json_handler.py - JSON Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/json_utils
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial creation - auto-creating self-healing JSON
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - BYPASS: Direct json.load/json.dump required - this IS the json handler
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler - Auto-Creating & Self-Healing JSON System
|
||||
|
||||
Handles default JSON files (config, data, log) for AI_MAIL modules.
|
||||
Never manually create JSONs - they build themselves.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import inspect
|
||||
|
||||
# Infrastructure paths
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
|
||||
# Constants - Updated for AI_MAIL
|
||||
AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail"
|
||||
AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json"
|
||||
JSON_TEMPLATES_DIR = AI_MAIL_ROOT / "apps" / "json_templates"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""
|
||||
Auto-detect calling module name from call stack
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "email" from email.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
# Validate module name
|
||||
if module_name and not module_name.startswith('_'):
|
||||
return module_name
|
||||
|
||||
# Fallback
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_template(json_type: str, module_name: str) -> Any:
|
||||
"""Load JSON template from template file"""
|
||||
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
|
||||
|
||||
if not template_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
template = json.load(f)
|
||||
|
||||
# Replace placeholders
|
||||
template_str = json.dumps(template)
|
||||
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
|
||||
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
|
||||
|
||||
return json.loads(template_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type"""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file"""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return AI_MAIL_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing"""
|
||||
AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
template = load_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing"""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file"""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module"""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""
|
||||
Add entry to module log with automatic rotation
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Auto-detect module name if not provided
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
# Load config to get max_log_entries
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100 # Default
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
# Load existing log
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
# Create new entry
|
||||
entry: Dict[str, Any] = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation
|
||||
}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
# Add new entry
|
||||
log.append(entry)
|
||||
|
||||
# Rotate if exceeds max (keep most recent entries)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
|
||||
"""Increment a counter in data JSON"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
if counter_name not in data:
|
||||
data[counter_name] = 0
|
||||
|
||||
data[counter_name] += amount
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
def update_data_metrics(module_name: str, **metrics) -> bool:
|
||||
"""Update data metrics"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
for key, value in metrics.items():
|
||||
data[key] = value
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n" + "="*70)
|
||||
print("JSON HANDLER - AI_MAIL Working Implementation")
|
||||
print("="*70)
|
||||
print("\n[TESTING] Creating AI_MAIL JSONs...")
|
||||
|
||||
# Test auto-creation
|
||||
log_operation("test_operation", {"test": "data"}, "ai_mail")
|
||||
increment_counter("ai_mail", "test_counter", 1)
|
||||
update_data_metrics("ai_mail", test_metric="working")
|
||||
|
||||
print("\nCheck /home/aipass/aipass_core/ai_mail/ai_mail_json/ for created files:")
|
||||
print(" - ai_mail_config.json")
|
||||
print(" - ai_mail_data.json")
|
||||
print(" - ai_mail_log.json")
|
||||
print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Monitoring Handlers - AI Mail Monitoring Domain
|
||||
|
||||
Independent handlers for monitoring operations:
|
||||
- memory.py: Memory health checking and line counting
|
||||
- errors.py: Error log parsing and deduplication
|
||||
- status.py: Status determination and reporting
|
||||
|
||||
All handlers are independent (no cross-domain imports).
|
||||
"""
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: data_ops.py - Error Monitor Data Operations Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/monitoring
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from error_monitor.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Error Monitor Data Operations Handler
|
||||
|
||||
Independent handler for error tracking data persistence.
|
||||
Provides functions for loading and saving error monitor data.
|
||||
|
||||
Architecture:
|
||||
- No cross-domain imports (independent handler)
|
||||
- Provides: data loading, data saving
|
||||
- Used by: error_monitor module
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
|
||||
# =============================================
|
||||
# BUSINESS LOGIC
|
||||
# =============================================
|
||||
|
||||
def load_data(data_file: Path) -> Dict:
|
||||
"""
|
||||
Load error tracking data from file.
|
||||
|
||||
Args:
|
||||
data_file: Path to the error tracking data file
|
||||
|
||||
Returns:
|
||||
Dictionary containing error tracking data, or empty dict if file doesn't exist
|
||||
"""
|
||||
if not data_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Direct file read for error tracking data (non-standard structure)
|
||||
with open(data_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_data(data: Dict, data_file: Path) -> None:
|
||||
"""
|
||||
Save error tracking data to file.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing error tracking data
|
||||
data_file: Path to the error tracking data file
|
||||
"""
|
||||
# Direct file write for error tracking data (non-standard structure)
|
||||
data_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(data_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: errors.py - Error Detection Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/monitoring
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_error_monitor.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Error Detection Handler
|
||||
|
||||
Independent handler for error log parsing and deduplication.
|
||||
Provides functions for parsing error logs and generating unique error signatures.
|
||||
|
||||
Architecture:
|
||||
- No cross-domain imports (independent handler)
|
||||
- Provides: error parsing, hash generation, branch detection
|
||||
- Used by: monitoring modules
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Tuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
# Log line pattern - matches both prax format and Python default
|
||||
# Format: 2025-10-25 15:26:37 - logger_name - ERROR - message
|
||||
LOG_PATTERN = r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[,.]?\d* - (.+?) - (ERROR|WARNING|INFO) - (.+)$'
|
||||
|
||||
# =============================================
|
||||
# CORE FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def parse_error_log_line(log_line: str) -> Optional[Dict]:
|
||||
"""
|
||||
Parse error log line to extract components
|
||||
|
||||
Format: 2025-10-25 15:26:37 - captured_flow_plan_summarizer - ERROR - Failed to write...
|
||||
|
||||
Args:
|
||||
log_line: Log line to parse
|
||||
|
||||
Returns:
|
||||
Dict with timestamp, logger_name, module_name, level, message
|
||||
None if not an ERROR line or parsing fails
|
||||
"""
|
||||
match = re.match(LOG_PATTERN, log_line.strip())
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
timestamp, logger_name, level, message = match.groups()
|
||||
|
||||
# Only process ERROR level
|
||||
if level != "ERROR":
|
||||
return None
|
||||
|
||||
# Extract module name (remove 'captured_' prefix if present)
|
||||
module_name = logger_name.replace('captured_', '')
|
||||
|
||||
return {
|
||||
"timestamp": timestamp,
|
||||
"logger_name": logger_name,
|
||||
"module_name": module_name,
|
||||
"level": level,
|
||||
"message": message.strip()
|
||||
}
|
||||
|
||||
|
||||
def generate_error_hash(module_name: str, error_message: str) -> str:
|
||||
"""
|
||||
Generate unique hash for error deduplication
|
||||
|
||||
Combines module name and error message to create a unique identifier
|
||||
for tracking error occurrences.
|
||||
|
||||
Args:
|
||||
module_name: Logger/module name (e.g., 'flow_plan_summarizer')
|
||||
error_message: Error message text
|
||||
|
||||
Returns:
|
||||
SHA256 hash (first 12 chars)
|
||||
"""
|
||||
combined = f"{module_name}::{error_message}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def get_branch_from_log_path(log_file_path: str) -> Tuple[str, Path]:
|
||||
"""
|
||||
Extract branch name and root path from log file path
|
||||
|
||||
Args:
|
||||
log_file_path: Full path to log file (e.g., /home/aipass/api/logs/openrouter.log)
|
||||
|
||||
Returns:
|
||||
Tuple of (branch_name, branch_root_path)
|
||||
Example: ("API", Path("/home/aipass/api"))
|
||||
|
||||
Special case: root directory returns ("AIPASS.admin", Path("/"))
|
||||
"""
|
||||
log_path = Path(log_file_path)
|
||||
|
||||
# Navigate up from log file to branch root
|
||||
# /home/aipass/api/logs/openrouter.log -> /home/aipass/api
|
||||
branch_root = log_path.parent.parent
|
||||
|
||||
# Special case: root directory
|
||||
if branch_root == Path("/"):
|
||||
return "AIPASS.admin", branch_root
|
||||
|
||||
# Extract branch name from directory name
|
||||
# /home/aipass/api -> "API"
|
||||
# /home/aipass/backup-system -> "BACKUP_SYSTEM"
|
||||
branch_folder = branch_root.name.replace("-", "_")
|
||||
branch_name = branch_folder.upper()
|
||||
|
||||
return branch_name, branch_root
|
||||
|
||||
|
||||
def get_ai_mail_file_for_branch(branch_name: str, branch_root: Path) -> Optional[Path]:
|
||||
"""
|
||||
Build path to branch's .ai_mail.md file
|
||||
|
||||
Args:
|
||||
branch_name: Branch name in UPPERCASE (e.g., "API", "FLOW")
|
||||
branch_root: Path to branch root directory
|
||||
|
||||
Returns:
|
||||
Path to .ai_mail.md file, or None if doesn't exist
|
||||
|
||||
Pattern: {branch_root}/{BRANCHNAME}.ai_mail.md
|
||||
Special case: root -> /AIPASS.admin.ai_mail.md
|
||||
"""
|
||||
if branch_root == Path("/"):
|
||||
ai_mail_file = Path("/AIPASS.admin.ai_mail.md")
|
||||
else:
|
||||
ai_mail_file = branch_root / f"{branch_name}.ai_mail.md"
|
||||
|
||||
if not ai_mail_file.exists():
|
||||
return None
|
||||
|
||||
return ai_mail_file
|
||||
|
||||
|
||||
def should_exclude_error(module_name: str) -> bool:
|
||||
"""
|
||||
Determine if error should be excluded from monitoring
|
||||
|
||||
Args:
|
||||
module_name: Module name from error
|
||||
|
||||
Returns:
|
||||
True if error should be excluded (to prevent infinite loops)
|
||||
"""
|
||||
# Self-exclusion: Don't monitor error_monitor's own errors
|
||||
return "error_monitor" in module_name.lower()
|
||||
|
||||
|
||||
def format_error_email(error_hash: str, error_info: Dict, branch_name: str) -> str:
|
||||
"""
|
||||
Format error email notification
|
||||
|
||||
Args:
|
||||
error_hash: Unique error identifier
|
||||
error_info: Error details (module, message, timestamps)
|
||||
branch_name: Branch name
|
||||
|
||||
Returns:
|
||||
Formatted email message
|
||||
"""
|
||||
branch_root = Path.home() / "aipass_core" / branch_name.lower()
|
||||
logs_dir = branch_root / "logs"
|
||||
|
||||
message = f"""Error detected in {branch_name} logs
|
||||
|
||||
Error ID: {error_hash}
|
||||
Module: {error_info['module_name']}
|
||||
First seen: {error_info['first_seen']}
|
||||
Last seen: {error_info['last_seen']}
|
||||
Notification count: {error_info.get('count', 1)}
|
||||
|
||||
Error message:
|
||||
{error_info['error_text']}
|
||||
|
||||
Check logs: {logs_dir}/{error_info['module_name']}.log
|
||||
"""
|
||||
return message
|
||||
|
||||
|
||||
def extract_module_from_log_filename(log_file_path: str) -> str:
|
||||
"""
|
||||
Extract module name from log file name
|
||||
|
||||
Args:
|
||||
log_file_path: Path to log file
|
||||
|
||||
Returns:
|
||||
Module name (filename without .log extension)
|
||||
"""
|
||||
return Path(log_file_path).stem
|
||||
|
||||
|
||||
# =============================================
|
||||
# VALIDATION
|
||||
# =============================================
|
||||
|
||||
def validate_error_data_entry(error_info: Dict) -> bool:
|
||||
"""
|
||||
Validate error tracking data entry structure
|
||||
|
||||
Args:
|
||||
error_info: Error data entry to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not isinstance(error_info, dict):
|
||||
return False
|
||||
|
||||
required_fields = ["first_seen", "last_seen", "count", "error_text", "module_name"]
|
||||
return all(field in error_info for field in required_fields)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("ERROR DETECTION HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nFunctions provided:")
|
||||
console.print(" - parse_error_log_line(log_line) -> dict | None")
|
||||
console.print(" - generate_error_hash(module_name, error_message) -> str")
|
||||
console.print(" - get_branch_from_log_path(log_file_path) -> (str, Path)")
|
||||
console.print(" - get_ai_mail_file_for_branch(branch_name, branch_root) -> Path | None")
|
||||
console.print(" - should_exclude_error(module_name) -> bool")
|
||||
console.print(" - format_error_email(error_hash, error_info, branch_name) -> str")
|
||||
console.print(" - extract_module_from_log_filename(log_file_path) -> str")
|
||||
console.print(" - validate_error_data_entry(error_info) -> bool")
|
||||
console.print("\nLog pattern:")
|
||||
console.print(" 2025-10-25 15:26:37 - module_name - ERROR - message")
|
||||
console.print("\nError hash:")
|
||||
console.print(" SHA256(module_name::error_message)[:12]")
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: memory.py - Memory Health Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/monitoring
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_local_memory_monitor.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Memory Health Handler
|
||||
|
||||
Independent handler for memory file monitoring logic.
|
||||
Provides functions for counting file lines and determining health status.
|
||||
|
||||
Architecture:
|
||||
- No cross-domain imports (independent handler)
|
||||
- Provides: line counting, status determination
|
||||
- Used by: monitoring modules
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
# Health status thresholds
|
||||
THRESHOLD_GREEN_MAX = 400
|
||||
THRESHOLD_YELLOW_MIN = 401
|
||||
THRESHOLD_YELLOW_MAX = 550
|
||||
THRESHOLD_RED_MIN = 551
|
||||
THRESHOLD_EMAIL_TRIGGER = 600
|
||||
|
||||
# Status indicators
|
||||
STATUS_GREEN = "🟢 Healthy"
|
||||
STATUS_YELLOW = "🟡 Approaching"
|
||||
STATUS_RED = "🔴 Compress Now"
|
||||
|
||||
# =============================================
|
||||
# CORE FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def count_file_lines(file_path: Path | str) -> int:
|
||||
"""
|
||||
Count lines in a file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to count
|
||||
|
||||
Returns:
|
||||
Number of lines in file, or 0 if file doesn't exist or error
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return len(f.readlines())
|
||||
except Exception as e:
|
||||
return 0
|
||||
|
||||
|
||||
def get_status_from_count(line_count: int) -> str:
|
||||
"""
|
||||
Determine health status from line count
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
Status string with emoji indicator
|
||||
"""
|
||||
if line_count <= THRESHOLD_GREEN_MAX:
|
||||
return STATUS_GREEN
|
||||
elif THRESHOLD_YELLOW_MIN <= line_count <= THRESHOLD_YELLOW_MAX:
|
||||
return STATUS_YELLOW
|
||||
else:
|
||||
return STATUS_RED
|
||||
|
||||
|
||||
def should_send_email(line_count: int) -> bool:
|
||||
"""
|
||||
Determine if email notification should be sent
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
True if line count exceeds email trigger threshold
|
||||
"""
|
||||
return line_count >= THRESHOLD_EMAIL_TRIGGER
|
||||
|
||||
|
||||
def get_health_info(file_path: Path | str) -> dict:
|
||||
"""
|
||||
Get complete health information for a file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to analyze
|
||||
|
||||
Returns:
|
||||
Dict with line_count, status, needs_email
|
||||
"""
|
||||
line_count = count_file_lines(file_path)
|
||||
|
||||
return {
|
||||
"line_count": line_count,
|
||||
"status": get_status_from_count(line_count),
|
||||
"needs_email": should_send_email(line_count),
|
||||
"file_path": str(file_path)
|
||||
}
|
||||
|
||||
|
||||
def format_compression_prompt(file_type: str, line_count: int) -> str:
|
||||
"""
|
||||
Generate compression agent prompt
|
||||
|
||||
Args:
|
||||
file_type: Type of file (e.g., "local.md", "observations.md")
|
||||
line_count: Current line count
|
||||
|
||||
Returns:
|
||||
Formatted compression prompt
|
||||
"""
|
||||
return f"""Compress my {file_type} file from {line_count} lines to 400 lines following the compression rules:
|
||||
|
||||
- Top 25% (most recent): Keep mostly intact
|
||||
- Next 25%: Reduce slightly (combine details)
|
||||
- Next 25%: Reduce more (summary format)
|
||||
- Last 25% (oldest): Delete if needed for space
|
||||
|
||||
Preserve:
|
||||
- All session headers and dates
|
||||
- Key achievements and milestones
|
||||
- Critical errors and resolutions
|
||||
- Important patterns and learnings
|
||||
|
||||
Remove:
|
||||
- Routine status updates
|
||||
- Redundant information
|
||||
- Low-value details
|
||||
- Completed temporary tasks
|
||||
|
||||
Maintain chronological order (newest first)."""
|
||||
|
||||
|
||||
# =============================================
|
||||
# VALIDATION
|
||||
# =============================================
|
||||
|
||||
def validate_thresholds(green_max: int, yellow_min: int, yellow_max: int, red_min: int) -> bool:
|
||||
"""
|
||||
Validate threshold configuration
|
||||
|
||||
Args:
|
||||
green_max: Maximum for green status
|
||||
yellow_min: Minimum for yellow status
|
||||
yellow_max: Maximum for yellow status
|
||||
red_min: Minimum for red status
|
||||
|
||||
Returns:
|
||||
True if thresholds are valid, False otherwise
|
||||
"""
|
||||
if yellow_min != green_max + 1:
|
||||
return False
|
||||
|
||||
if yellow_max < yellow_min:
|
||||
return False
|
||||
|
||||
if red_min != yellow_max + 1:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("MEMORY HEALTH HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nFunctions provided:")
|
||||
console.print(" - count_file_lines(file_path) -> int")
|
||||
console.print(" - get_status_from_count(line_count) -> str")
|
||||
console.print(" - should_send_email(line_count) -> bool")
|
||||
console.print(" - get_health_info(file_path) -> dict")
|
||||
console.print(" - format_compression_prompt(file_type, line_count) -> str")
|
||||
console.print(" - validate_thresholds(...) -> bool")
|
||||
console.print("\nThresholds:")
|
||||
console.print(f" Green: 0-{THRESHOLD_GREEN_MAX} lines")
|
||||
console.print(f" Yellow: {THRESHOLD_YELLOW_MIN}-{THRESHOLD_YELLOW_MAX} lines")
|
||||
console.print(f" Red: {THRESHOLD_RED_MIN}+ lines")
|
||||
console.print(f" Email trigger: {THRESHOLD_EMAIL_TRIGGER}+ lines")
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1 @@
|
||||
# Persistence Handler - JSON Operations for persistent data
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: json_ops.py - Persistence Handler (JSON Operations)
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/persistence
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.0.0 (2025-11-15): Initial version with auto-creating & self-healing JSON system
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Auto-creates JSON files from templates
|
||||
# - Self-healing for corrupted files
|
||||
# - Auto-detects calling module for logging
|
||||
# - Implements log rotation based on config limits
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Persistence Handler - JSON Operations
|
||||
|
||||
Handles persistent data operations for ai_mail modules.
|
||||
Manages default JSON files (config, data, log).
|
||||
Auto-creating & self-healing JSON system.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import inspect
|
||||
|
||||
# Infrastructure paths
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
|
||||
# Constants
|
||||
AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail"
|
||||
AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json"
|
||||
JSON_TEMPLATES_DIR = AI_MAIL_ROOT / "apps" / "json_templates"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""
|
||||
Auto-detect calling module name from call stack
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "imports_standard" from imports_standard.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
# Validate module name
|
||||
if module_name and not module_name.startswith('_'):
|
||||
return module_name
|
||||
|
||||
# Fallback
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_template(json_type: str, module_name: str) -> Any:
|
||||
"""Load JSON template from template file"""
|
||||
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
|
||||
|
||||
if not template_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
template = json.load(f)
|
||||
|
||||
# Replace placeholders
|
||||
template_str = json.dumps(template)
|
||||
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
|
||||
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
|
||||
|
||||
return json.loads(template_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type"""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file"""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return AI_MAIL_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing"""
|
||||
AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
template = load_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing"""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file"""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module"""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""
|
||||
Add entry to module log with automatic rotation
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Auto-detect module name if not provided
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
# Load config to get max_log_entries
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100 # Default
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
# Load existing log
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
# Create new entry
|
||||
entry: Dict[str, Any] = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation
|
||||
}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
# Add new entry
|
||||
log.append(entry)
|
||||
|
||||
# Rotate if exceeds max (keep most recent entries)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
|
||||
"""Increment a counter in data JSON"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
if counter_name not in data:
|
||||
data[counter_name] = 0
|
||||
|
||||
data[counter_name] += amount
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
def update_data_metrics(module_name: str, **metrics: Any) -> bool:
|
||||
"""Update data metrics"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
for key, value in metrics.items():
|
||||
data[key] = value
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Registry Handlers - Branch registry operations for AI_Mail"""
|
||||
|
||||
from .load import load_registry
|
||||
|
||||
__all__ = ['load_registry']
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: load.py - Registry Loading Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/registry
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial handler - extracted from local_memory_monitor module
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Business logic implementation
|
||||
# - Pure functions where possible
|
||||
# - Clear error handling
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Loading Handler - Loads registry files from disk.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def load_registry(registry_file: Path) -> Dict:
|
||||
"""
|
||||
Load branch registry from file.
|
||||
|
||||
Args:
|
||||
registry_file: Path to the registry JSON file
|
||||
|
||||
Returns:
|
||||
Registry dictionary with structure:
|
||||
{
|
||||
"last_updated": str,
|
||||
"active_branches": dict,
|
||||
"statistics": {
|
||||
"total_branches": int,
|
||||
"green_status": int,
|
||||
"yellow_status": int,
|
||||
"red_status": int
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not registry_file.exists():
|
||||
return {
|
||||
"last_updated": "",
|
||||
"active_branches": {},
|
||||
"statistics": {
|
||||
"total_branches": 0,
|
||||
"green_status": 0,
|
||||
"yellow_status": 0,
|
||||
"red_status": 0
|
||||
}
|
||||
}
|
||||
|
||||
# Direct file read for registry (non-standard JSON location)
|
||||
with open(registry_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: read.py - Registry Read Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/registry
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handlers are INDEPENDENT (no cross-domain imports)
|
||||
# - Use: from prax.apps.modules.logger import system_logger as logger
|
||||
# - Keep handlers <300 lines each
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Read Handler
|
||||
|
||||
Handles reading branch registry data including:
|
||||
- Reading all branches from BRANCH_REGISTRY.json
|
||||
- Deriving email addresses from branch names
|
||||
- Mapping email addresses to branch paths
|
||||
|
||||
Handler Independence:
|
||||
- No module imports from ai_mail
|
||||
- Only uses Prax logger and standard library
|
||||
- Fully transportable and self-contained
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Dict
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.read"
|
||||
BRANCH_REGISTRY_PATH = Path("/home/aipass/BRANCH_REGISTRY.json")
|
||||
|
||||
|
||||
def get_all_branches() -> List[Dict]:
|
||||
"""
|
||||
Get list of all branches for email selection.
|
||||
Reads from AIPass branch registry at /home/aipass/BRANCH_REGISTRY.json
|
||||
|
||||
Returns:
|
||||
List of dicts with branch info:
|
||||
[{"name": "AIPASS.admin", "path": "/", "email": "@admin"}, ...]
|
||||
|
||||
Note:
|
||||
Returns empty list if registry not found or on error.
|
||||
"""
|
||||
branches = []
|
||||
|
||||
if not BRANCH_REGISTRY_PATH.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry_data = json.load(f)
|
||||
|
||||
# Parse branch entries from JSON structure
|
||||
for branch in registry_data.get("branches", []):
|
||||
branch_name = branch.get("name", "")
|
||||
path = branch.get("path", "")
|
||||
|
||||
if not branch_name or not path:
|
||||
continue
|
||||
|
||||
# Derive email address from branch name
|
||||
email = _derive_email_from_branch_name(branch_name)
|
||||
|
||||
branches.append({
|
||||
"name": branch_name,
|
||||
"path": path,
|
||||
"email": email
|
||||
})
|
||||
|
||||
return branches
|
||||
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
|
||||
def _derive_email_from_branch_name(branch_name: str) -> str:
|
||||
"""
|
||||
Derive email address from branch name.
|
||||
|
||||
Rules:
|
||||
- AIPASS.admin -> @admin (take part after dot)
|
||||
- AIPASS Workshop -> @aipass (take first word)
|
||||
- AIPASS-HELP -> @help (take second part to avoid collision)
|
||||
- BACKUP-SYSTEM -> @backup (take first part)
|
||||
- DRONE -> @drone (take whole name)
|
||||
|
||||
Args:
|
||||
branch_name: Branch name from registry
|
||||
|
||||
Returns:
|
||||
Email address in format "@email"
|
||||
"""
|
||||
if '.' in branch_name:
|
||||
# Special case: AIPASS.admin -> admin
|
||||
email_part = branch_name.split('.')[-1].lower()
|
||||
elif ' ' in branch_name:
|
||||
# Handle spaces: take first word
|
||||
email_part = branch_name.split()[0].lower()
|
||||
elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS':
|
||||
# AIPASS-prefixed branches: use second part to avoid collision
|
||||
email_part = branch_name.split('-', 1)[1].lower()
|
||||
else:
|
||||
# Take first word before hyphen or whole name
|
||||
email_part = branch_name.split('-')[0].lower()
|
||||
|
||||
return f"@{email_part}"
|
||||
|
||||
|
||||
def get_branch_by_email(email: str) -> Dict | None:
|
||||
"""
|
||||
Get branch information by email address.
|
||||
|
||||
Args:
|
||||
email: Email address (e.g., "@admin")
|
||||
|
||||
Returns:
|
||||
Branch dict with name, path, email or None if not found
|
||||
"""
|
||||
branches = get_all_branches()
|
||||
|
||||
for branch in branches:
|
||||
if branch["email"] == email:
|
||||
return branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_email_map() -> Dict[str, str]:
|
||||
"""
|
||||
Get mapping of email addresses to branch names.
|
||||
|
||||
Returns:
|
||||
Dict mapping email -> branch_name
|
||||
Example: {"@admin": "AIPASS.admin", "@flow": "FLOW"}
|
||||
"""
|
||||
branches = get_all_branches()
|
||||
return {branch["email"]: branch["name"] for branch in branches}
|
||||
|
||||
|
||||
def get_branch_path_map() -> Dict[str, str]:
|
||||
"""
|
||||
Get mapping of email addresses to branch paths.
|
||||
|
||||
Returns:
|
||||
Dict mapping email -> path
|
||||
Example: {"@admin": "/", "@flow": "/home/aipass/flow"}
|
||||
"""
|
||||
branches = get_all_branches()
|
||||
return {branch["email"]: branch["path"] for branch in branches}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("AI_MAIL HANDLER: registry/read.py")
|
||||
console.print("="*70)
|
||||
console.print("\nRegistry Read Handler")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - get_all_branches() -> List[Dict]")
|
||||
console.print(" - get_branch_by_email(email) -> Dict | None")
|
||||
console.print(" - get_branch_email_map() -> Dict[str, str]")
|
||||
console.print(" - get_branch_path_map() -> Dict[str, str]")
|
||||
console.print()
|
||||
console.print("TESTING:")
|
||||
|
||||
branches = get_all_branches()
|
||||
console.print(f"\nLoaded {len(branches)} branches:")
|
||||
for branch in branches[:5]: # Show first 5
|
||||
console.print(f" {branch['email']:15} -> {branch['name']}")
|
||||
|
||||
if len(branches) > 5:
|
||||
console.print(f" ... and {len(branches) - 5} more")
|
||||
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: update.py - Registry Update Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/registry
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_branch_ping.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handlers are INDEPENDENT (no cross-domain imports)
|
||||
# - Use: from prax.apps.modules.logger import system_logger as logger
|
||||
# - Keep handlers <300 lines each
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Update Handler
|
||||
|
||||
Handles updating branch registry with ping data including:
|
||||
- Updating registry with branch status
|
||||
- Recording ping timestamps
|
||||
- Maintaining statistics (green/yellow/red counts)
|
||||
|
||||
Handler Independence:
|
||||
- No module imports from ai_mail
|
||||
- Only uses Prax logger and standard library
|
||||
- Fully transportable and self-contained
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.update"
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail"
|
||||
AI_MAIL_JSON = AI_MAIL_ROOT / "ai_mail_json"
|
||||
REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json"
|
||||
THRESHOLDS = {
|
||||
"green": (0, 400),
|
||||
"yellow": (401, 550),
|
||||
"red": (551, float('inf'))
|
||||
}
|
||||
|
||||
|
||||
def ping_registry(
|
||||
branch_name: str,
|
||||
branch_path: Path,
|
||||
local_status: Dict,
|
||||
obs_status: Dict
|
||||
) -> bool:
|
||||
"""
|
||||
Update registry with branch status.
|
||||
|
||||
Args:
|
||||
branch_name: Name of branch (e.g., "FLOW", "AIPASS.admin")
|
||||
branch_path: Full path to branch directory
|
||||
local_status: Dict with {"line_count": int, "status": str}
|
||||
obs_status: Dict with {"line_count": int, "status": str}
|
||||
|
||||
Returns:
|
||||
True if registry updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Ensure registry directory exists
|
||||
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load or create registry
|
||||
if REGISTRY_PATH.exists():
|
||||
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
else:
|
||||
registry = _create_empty_registry()
|
||||
|
||||
# Update branch entry
|
||||
registry["active_branches"][str(branch_path)] = {
|
||||
"branch_name": branch_name,
|
||||
"last_ping": datetime.now().isoformat(),
|
||||
"local_md": local_status,
|
||||
"observations_md": obs_status
|
||||
}
|
||||
|
||||
# Update statistics
|
||||
registry["last_updated"] = datetime.now().isoformat()
|
||||
registry["statistics"] = _calculate_statistics(registry)
|
||||
|
||||
# Save registry
|
||||
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(registry, f, indent=2)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
def _create_empty_registry() -> Dict:
|
||||
"""
|
||||
Create empty registry structure.
|
||||
|
||||
Returns:
|
||||
Empty registry dict
|
||||
"""
|
||||
return {
|
||||
"last_updated": "",
|
||||
"active_branches": {},
|
||||
"statistics": {
|
||||
"total_branches": 0,
|
||||
"green_status": 0,
|
||||
"yellow_status": 0,
|
||||
"red_status": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _calculate_statistics(registry: Dict) -> Dict:
|
||||
"""
|
||||
Calculate statistics from registry data.
|
||||
|
||||
Args:
|
||||
registry: Full registry dict
|
||||
|
||||
Returns:
|
||||
Statistics dict with counts
|
||||
"""
|
||||
green, yellow, red = 0, 0, 0
|
||||
|
||||
for branch_data in registry["active_branches"].values():
|
||||
for file_type in ["local_md", "observations_md"]:
|
||||
status = branch_data.get(file_type, {}).get("status", "")
|
||||
if status == "green":
|
||||
green += 1
|
||||
elif status == "yellow":
|
||||
yellow += 1
|
||||
elif status == "red":
|
||||
red += 1
|
||||
|
||||
return {
|
||||
"total_branches": len(registry["active_branches"]),
|
||||
"green_status": green,
|
||||
"yellow_status": yellow,
|
||||
"red_status": red
|
||||
}
|
||||
|
||||
|
||||
def get_status_from_count(line_count: int) -> str:
|
||||
"""
|
||||
Determine status based on line count.
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
Status code: "green", "yellow", or "red"
|
||||
"""
|
||||
if THRESHOLDS["green"][0] <= line_count <= THRESHOLDS["green"][1]:
|
||||
return "green"
|
||||
elif THRESHOLDS["yellow"][0] <= line_count <= THRESHOLDS["yellow"][1]:
|
||||
return "yellow"
|
||||
else: # red threshold
|
||||
return "red"
|
||||
|
||||
|
||||
def count_file_lines(file_path: Path) -> int:
|
||||
"""
|
||||
Count total lines in file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to count
|
||||
|
||||
Returns:
|
||||
Number of lines in file, 0 if file doesn't exist
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return len(f.readlines())
|
||||
except Exception as e:
|
||||
return 0
|
||||
|
||||
|
||||
def update_json_memory_health(
|
||||
file_path: Path,
|
||||
line_count: int,
|
||||
status_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Update memory_health in JSON file metadata.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
line_count: Current line count
|
||||
status_code: Status ("green", "yellow", "red")
|
||||
|
||||
Returns:
|
||||
True if updated successfully, False otherwise
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Update memory health in metadata
|
||||
if "metadata" in data and "memory_health" in data["metadata"]:
|
||||
data["metadata"]["memory_health"]["current_lines"] = line_count
|
||||
data["metadata"]["memory_health"]["status"] = status_code
|
||||
|
||||
# Save updated file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
def get_branch_context() -> Tuple[str, Path]:
|
||||
"""
|
||||
Determine current branch name and directory.
|
||||
|
||||
Returns:
|
||||
Tuple of (branch_name, branch_path)
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
|
||||
# Special case: root directory
|
||||
if cwd == Path("/"):
|
||||
return "AIPASS.admin", cwd
|
||||
|
||||
# Extract branch name from last directory in path
|
||||
branch_folder = cwd.name.replace("-", "_")
|
||||
branch_name = branch_folder.upper()
|
||||
|
||||
return branch_name, cwd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("AI_MAIL HANDLER: registry/update.py")
|
||||
console.print("="*70)
|
||||
console.print("\nRegistry Update Handler")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - ping_registry(branch_name, branch_path, local_status, obs_status) -> bool")
|
||||
console.print(" - get_status_from_count(line_count) -> str")
|
||||
console.print(" - count_file_lines(file_path) -> int")
|
||||
console.print(" - update_json_memory_health(file_path, line_count, status_code) -> bool")
|
||||
console.print(" - get_branch_context() -> Tuple[str, Path]")
|
||||
console.print()
|
||||
console.print("THRESHOLDS:")
|
||||
console.print(f" Green: {THRESHOLDS['green'][0]} - {THRESHOLDS['green'][1]} lines")
|
||||
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
|
||||
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines")
|
||||
console.print()
|
||||
console.print("TESTING:")
|
||||
|
||||
branch_name, branch_path = get_branch_context()
|
||||
console.print(f"\nCurrent branch: {branch_name}")
|
||||
console.print(f"Current path: {branch_path}")
|
||||
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: validate.py - Registry Validation Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/registry
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handlers are INDEPENDENT (no cross-domain imports)
|
||||
# - Use: from prax.apps.modules.logger import system_logger as logger
|
||||
# - Keep handlers <300 lines each
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Validation Handler
|
||||
|
||||
Handles validation of branch registry data including:
|
||||
- Checking for email address collisions
|
||||
- Validating email derivation rules
|
||||
- Detecting unreachable branches
|
||||
|
||||
Handler Independence:
|
||||
- No module imports from ai_mail
|
||||
- Only uses Prax logger and standard library
|
||||
- Fully transportable and self-contained
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.validate"
|
||||
|
||||
|
||||
def check_email_collisions(branches: List[Dict]) -> Tuple[bool, List[Dict]]:
|
||||
"""
|
||||
Check for email address collisions in branch list.
|
||||
|
||||
Args:
|
||||
branches: List of branch dicts with keys: name, path, email
|
||||
|
||||
Returns:
|
||||
Tuple of (has_collisions: bool, collisions: List[Dict])
|
||||
Collision dict format:
|
||||
{
|
||||
"email": "@email",
|
||||
"branch1": "First Branch Name",
|
||||
"branch2": "Second Branch Name"
|
||||
}
|
||||
"""
|
||||
email_map = {}
|
||||
collisions = []
|
||||
|
||||
for branch in branches:
|
||||
email = branch["email"]
|
||||
if email in email_map:
|
||||
# Collision detected
|
||||
collisions.append({
|
||||
"email": email,
|
||||
"branch1": email_map[email],
|
||||
"branch2": branch["name"]
|
||||
})
|
||||
else:
|
||||
email_map[email] = branch["name"]
|
||||
|
||||
has_collisions = len(collisions) > 0
|
||||
|
||||
return has_collisions, collisions
|
||||
|
||||
|
||||
def get_collision_report(collisions: List[Dict]) -> str:
|
||||
"""
|
||||
Generate human-readable collision report.
|
||||
|
||||
Args:
|
||||
collisions: List of collision dicts from check_email_collisions()
|
||||
|
||||
Returns:
|
||||
Formatted report string
|
||||
"""
|
||||
if not collisions:
|
||||
return "No collisions detected."
|
||||
|
||||
report_lines = [
|
||||
f"EMAIL ADDRESS COLLISIONS DETECTED: {len(collisions)}",
|
||||
"",
|
||||
"One or more branches are unreachable via AI_Mail!",
|
||||
"",
|
||||
"Collisions:"
|
||||
]
|
||||
|
||||
for collision in collisions:
|
||||
report_lines.extend([
|
||||
"",
|
||||
f" {collision['email']}",
|
||||
f" - {collision['branch1']}",
|
||||
f" - {collision['branch2']}",
|
||||
])
|
||||
|
||||
report_lines.extend([
|
||||
"",
|
||||
"Fix: Rename branches in BRANCH_REGISTRY.json to ensure unique email derivation",
|
||||
"See email derivation rules in registry/read.py"
|
||||
])
|
||||
|
||||
return "\n".join(report_lines)
|
||||
|
||||
|
||||
def validate_branch_data(branch: Dict) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate a single branch entry.
|
||||
|
||||
Args:
|
||||
branch: Branch dict with keys: name, path, email
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, error_message: str)
|
||||
error_message is empty string if valid
|
||||
"""
|
||||
# Check required fields
|
||||
required_fields = ["name", "path", "email"]
|
||||
for field in required_fields:
|
||||
if field not in branch:
|
||||
return False, f"Missing required field: {field}"
|
||||
if not branch[field]:
|
||||
return False, f"Empty value for field: {field}"
|
||||
|
||||
# Validate email format
|
||||
if not branch["email"].startswith("@"):
|
||||
return False, f"Email must start with @: {branch['email']}"
|
||||
|
||||
# Validate path format
|
||||
path = branch["path"]
|
||||
if not path.startswith("/"):
|
||||
return False, f"Path must be absolute: {path}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_all_branches(branches: List[Dict]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate all branch entries.
|
||||
|
||||
Args:
|
||||
branches: List of branch dicts
|
||||
|
||||
Returns:
|
||||
Tuple of (all_valid: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for i, branch in enumerate(branches):
|
||||
is_valid, error_msg = validate_branch_data(branch)
|
||||
if not is_valid:
|
||||
errors.append(f"Branch {i} ({branch.get('name', 'UNKNOWN')}): {error_msg}")
|
||||
|
||||
all_valid = len(errors) == 0
|
||||
|
||||
return all_valid, errors
|
||||
|
||||
|
||||
def get_duplicate_names(branches: List[Dict]) -> List[str]:
|
||||
"""
|
||||
Find duplicate branch names.
|
||||
|
||||
Args:
|
||||
branches: List of branch dicts
|
||||
|
||||
Returns:
|
||||
List of duplicate branch names
|
||||
"""
|
||||
name_counts = {}
|
||||
duplicates = []
|
||||
|
||||
for branch in branches:
|
||||
name = branch.get("name", "")
|
||||
if name:
|
||||
name_counts[name] = name_counts.get(name, 0) + 1
|
||||
|
||||
for name, count in name_counts.items():
|
||||
if count > 1:
|
||||
duplicates.append(name)
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
def get_duplicate_paths(branches: List[Dict]) -> List[str]:
|
||||
"""
|
||||
Find duplicate branch paths.
|
||||
|
||||
Args:
|
||||
branches: List of branch dicts
|
||||
|
||||
Returns:
|
||||
List of duplicate branch paths
|
||||
"""
|
||||
path_counts = {}
|
||||
duplicates = []
|
||||
|
||||
for branch in branches:
|
||||
path = branch.get("path", "")
|
||||
if path:
|
||||
path_counts[path] = path_counts.get(path, 0) + 1
|
||||
|
||||
for path, count in path_counts.items():
|
||||
if count > 1:
|
||||
duplicates.append(path)
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.print("\n" + "="*70)
|
||||
console.print("AI_MAIL HANDLER: registry/validate.py")
|
||||
console.print("="*70)
|
||||
console.print("\nRegistry Validation Handler")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - check_email_collisions(branches) -> Tuple[bool, List[Dict]]")
|
||||
console.print(" - get_collision_report(collisions) -> str")
|
||||
console.print(" - validate_branch_data(branch) -> Tuple[bool, str]")
|
||||
console.print(" - validate_all_branches(branches) -> Tuple[bool, List[str]]")
|
||||
console.print(" - get_duplicate_names(branches) -> List[str]")
|
||||
console.print(" - get_duplicate_paths(branches) -> List[str]")
|
||||
console.print()
|
||||
console.print("TESTING:")
|
||||
|
||||
# Sample test data
|
||||
test_branches = [
|
||||
{"name": "AIPASS.admin", "path": "/", "email": "@admin"},
|
||||
{"name": "FLOW", "path": "/home/aipass/flow", "email": "@flow"},
|
||||
{"name": "DRONE", "path": "/home/aipass/drone", "email": "@drone"},
|
||||
]
|
||||
|
||||
has_collisions, collisions = check_email_collisions(test_branches)
|
||||
console.print(f"\nCollisions detected: {has_collisions}")
|
||||
console.print(f"Number of collisions: {len(collisions)}")
|
||||
|
||||
all_valid, errors = validate_all_branches(test_branches)
|
||||
console.print(f"\nAll branches valid: {all_valid}")
|
||||
console.print(f"Validation errors: {len(errors)}")
|
||||
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Trigger Event Handlers Package
|
||||
# Date: 2026-02-02
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/trigger
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 2
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Follows AIPass Seed standards
|
||||
# - Package init for trigger event handlers
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Trigger Event Handlers Package
|
||||
|
||||
Contains handlers that respond to Trigger events.
|
||||
These handlers are registered by Trigger's event registry.
|
||||
"""
|
||||
|
||||
from .error_handler import handle_error_detected
|
||||
|
||||
__all__ = ['handle_error_detected']
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: error_handler.py - Error Detected Event Consumer
|
||||
# Date: 2026-02-02
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/trigger
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 2
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Follows AIPass Seed standards
|
||||
# - NO console.print() - handlers return data to modules
|
||||
# - NO logger calls in handler (causes recursion with trigger)
|
||||
# - Silent failure pattern - catch all exceptions
|
||||
# - Responds to error_detected events from Trigger's log_watcher
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Error Detected Event Consumer
|
||||
|
||||
Handles error_detected events fired by Trigger's log_watcher.
|
||||
Delivers notifications to affected branches via AI_MAIL.
|
||||
|
||||
Event data from log_watcher.py:
|
||||
- branch: Target branch name (e.g., 'FLOW')
|
||||
- module: Module that logged the error
|
||||
- message: Error message text
|
||||
- log_path: Path to log file
|
||||
- error_hash: 8-char hash for deduplication
|
||||
- timestamp: When error occurred
|
||||
|
||||
Architecture:
|
||||
1. Trigger's log_watcher detects ERROR in branch logs
|
||||
2. log_watcher fires error_detected event
|
||||
3. This handler receives event, calls deliver_email_to_branch()
|
||||
4. Email delivered to affected branch inbox (auto_execute=True)
|
||||
5. Branch agent spawns and investigates
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _build_notification_message(
|
||||
error_hash: str,
|
||||
module: str,
|
||||
message: str,
|
||||
timestamp: str,
|
||||
log_path: str
|
||||
) -> str:
|
||||
"""
|
||||
Build error notification message with investigation instructions.
|
||||
|
||||
Args:
|
||||
error_hash: Unique error identifier (8-char)
|
||||
module: Module that logged the error
|
||||
message: Error message text
|
||||
timestamp: When error occurred
|
||||
log_path: Path to source log file
|
||||
|
||||
Returns:
|
||||
Formatted message string with investigation instructions
|
||||
"""
|
||||
return f"""Error detected - investigate and respond.
|
||||
|
||||
Error ID: {error_hash}
|
||||
Module: {module}
|
||||
Timestamp: {timestamp}
|
||||
Log file: {log_path}
|
||||
|
||||
Error message:
|
||||
{message}
|
||||
|
||||
---
|
||||
INVESTIGATION STEPS:
|
||||
1. Check the log file for context around this error
|
||||
2. Identify root cause
|
||||
|
||||
DECISION TREE:
|
||||
- SIMPLE FIX (typo, missing import, config issue):
|
||||
-> Fix it yourself, then report what you did to @dev_central
|
||||
- COMPLEX/UNCLEAR (needs research, affects multiple files):
|
||||
-> Report findings only to @dev_central, recommend action, don't fix
|
||||
- CRITICAL (data loss risk, security, system stability):
|
||||
-> STOP immediately, escalate to @dev_central with full context
|
||||
|
||||
REPORT TO @dev_central:
|
||||
ai_mail send @dev_central "ERROR {error_hash} - [STATUS]" "Findings..."
|
||||
|
||||
Include: Error ID, severity (low/medium/high/critical), what you found, action taken or recommended.
|
||||
"""
|
||||
|
||||
|
||||
def handle_error_detected(
|
||||
branch: str | None = None,
|
||||
module: str | None = None,
|
||||
message: str | None = None,
|
||||
log_path: str | None = None,
|
||||
error_hash: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> None:
|
||||
"""
|
||||
Handle error_detected event - deliver notification to affected branch.
|
||||
|
||||
Called by Trigger when log_watcher detects an ERROR in branch logs.
|
||||
Sends email to affected branch with auto_execute=True so an
|
||||
investigation agent spawns automatically.
|
||||
|
||||
Args:
|
||||
branch: Target branch name (e.g., 'FLOW') - REQUIRED
|
||||
module: Module that logged the error - REQUIRED
|
||||
message: Error message text - REQUIRED
|
||||
log_path: Path to source log file
|
||||
error_hash: 8-char unique error identifier - REQUIRED
|
||||
timestamp: When error occurred (defaults to now)
|
||||
**kwargs: Additional event data (ignored)
|
||||
|
||||
Returns:
|
||||
None - handlers must not return values
|
||||
|
||||
Note:
|
||||
Handler follows silent failure pattern - all exceptions caught.
|
||||
NO logger imports (causes infinite recursion with trigger events).
|
||||
NO console.print() (handlers must be silent).
|
||||
"""
|
||||
try:
|
||||
# Validate required fields
|
||||
if not branch or not module or not message or not error_hash:
|
||||
return
|
||||
|
||||
# Import delivery handler here to avoid import-time failures
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Default timestamp to now if not provided
|
||||
if not timestamp:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Default log_path if not provided
|
||||
if not log_path:
|
||||
log_path = "unknown"
|
||||
|
||||
# Convert branch name to email format (FLOW -> @flow)
|
||||
branch_email = f"@{branch.lower()}"
|
||||
|
||||
# Build subject line
|
||||
subject = f"[ERROR] {module} - detected in logs"
|
||||
|
||||
# Build notification message
|
||||
notification_message = _build_notification_message(
|
||||
error_hash=error_hash,
|
||||
module=module,
|
||||
message=message,
|
||||
timestamp=timestamp,
|
||||
log_path=log_path
|
||||
)
|
||||
|
||||
# Build email data for delivery
|
||||
email_data = {
|
||||
'from': '@error_monitor',
|
||||
'from_name': 'Error Monitor',
|
||||
'to': branch_email,
|
||||
'subject': subject,
|
||||
'message': notification_message,
|
||||
'timestamp': timestamp,
|
||||
'auto_execute': True,
|
||||
'priority': 'normal',
|
||||
'reply_to': '@dev_central'
|
||||
}
|
||||
|
||||
# Deliver via inbox.json
|
||||
deliver_email_to_branch(branch_email, email_data)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
User Handlers - User Configuration and Management
|
||||
|
||||
Handles loading of user configuration files and user information for AI_Mail system.
|
||||
"""
|
||||
|
||||
from .load import (
|
||||
load_user_config,
|
||||
load_config,
|
||||
create_default_config,
|
||||
load_or_create_config
|
||||
)
|
||||
|
||||
from .user import (
|
||||
get_current_user,
|
||||
get_user_by_email,
|
||||
get_all_users
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config loading
|
||||
'load_user_config',
|
||||
'load_config',
|
||||
'create_default_config',
|
||||
'load_or_create_config',
|
||||
|
||||
# User info
|
||||
'get_current_user',
|
||||
'get_user_by_email',
|
||||
'get_all_users',
|
||||
]
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: branch_detection.py - Branch Auto-Detection Handler
|
||||
# Date: 2025-11-18
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/users
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.0.0 (2025-11-18): Initial creation - PWD/CWD branch detection
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Branch Auto-Detection Handler
|
||||
|
||||
Detects which branch is calling AI_MAIL based on PWD/CWD.
|
||||
Walks up directory tree to find branch root (has [BRANCH].id.json file).
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json"
|
||||
|
||||
# =============================================
|
||||
# BRANCH DETECTION FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def detect_branch_from_pwd() -> Dict | None:
|
||||
"""
|
||||
Detect which branch is calling based on current working directory.
|
||||
|
||||
Walks up directory tree from PWD to find branch root (directory with [BRANCH].id.json).
|
||||
Then looks up branch info in BRANCH_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
Dict with branch info if detected:
|
||||
{
|
||||
"name": "SEED",
|
||||
"path": "/home/aipass/seed",
|
||||
"email": "@seed",
|
||||
"display_name": "Seed (Standards Branch)",
|
||||
...
|
||||
}
|
||||
None if no branch detected
|
||||
"""
|
||||
try:
|
||||
# Get current working directory
|
||||
cwd = Path.cwd()
|
||||
|
||||
# Find branch root
|
||||
branch_root = find_branch_root(cwd)
|
||||
if not branch_root:
|
||||
return None
|
||||
|
||||
# Get branch info from registry
|
||||
branch_info = get_branch_info_from_registry(branch_root)
|
||||
if not branch_info:
|
||||
return None
|
||||
|
||||
return branch_info
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_branch_root(start_path: Path) -> Path | None:
|
||||
"""
|
||||
Walk up directory tree to find branch root.
|
||||
|
||||
Branch root = directory containing a [BRANCH_NAME].id.json file.
|
||||
Example: /home/aipass/seed/ contains SEED.id.json
|
||||
|
||||
Args:
|
||||
start_path: Directory to start searching from (usually PWD)
|
||||
|
||||
Returns:
|
||||
Path to branch root directory, or None if not found
|
||||
"""
|
||||
current = start_path.resolve()
|
||||
|
||||
# Walk up directory tree (max 10 levels to prevent infinite loop)
|
||||
for _ in range(10):
|
||||
# Check if this directory has a [BRANCH].id.json file
|
||||
for file in current.glob("*.id.json"):
|
||||
# Found a .id.json file - this is likely a branch root
|
||||
return current
|
||||
|
||||
# Move up one level
|
||||
parent = current.parent
|
||||
if parent == current: # Reached filesystem root
|
||||
break
|
||||
current = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_info_from_registry(branch_path: Path) -> Dict | None:
|
||||
"""
|
||||
Look up branch information in BRANCH_REGISTRY.json by path.
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
|
||||
Returns:
|
||||
Dict with branch info from registry, or None if not found
|
||||
"""
|
||||
if not BRANCH_REGISTRY_PATH.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
# Normalize branch_path for comparison
|
||||
branch_path_str = str(branch_path.resolve())
|
||||
|
||||
# Search registry for matching path
|
||||
for branch in registry.get("branches", []):
|
||||
if Path(branch["path"]).resolve() == Path(branch_path_str):
|
||||
# Found match - return branch info
|
||||
return branch
|
||||
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_display_name(branch_info: Dict) -> str:
|
||||
"""
|
||||
Generate display name for branch from registry info.
|
||||
|
||||
Args:
|
||||
branch_info: Branch dict from registry
|
||||
|
||||
Returns:
|
||||
Display name string (e.g., "Seed (Standards Branch)")
|
||||
"""
|
||||
name = branch_info.get("name", "Unknown")
|
||||
description = branch_info.get("description", "")
|
||||
|
||||
if description and description != "New branch - purpose TBD":
|
||||
# Use description as context
|
||||
return f"{name.title()} ({description})"
|
||||
else:
|
||||
# Just use name
|
||||
return name.title()
|
||||
|
||||
|
||||
def get_local_config_path(branch_path: Path, branch_name: str) -> Path:
|
||||
"""
|
||||
Get path to local user_config.json for a branch.
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
branch_name: Branch name (e.g., "SEED", "DRONE")
|
||||
|
||||
Returns:
|
||||
Path to local config file ([branch_name]_json/user_config.json)
|
||||
"""
|
||||
branch_name_lower = branch_name.lower()
|
||||
return branch_path / f"{branch_name_lower}_json" / "user_config.json"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
console.print("\n" + "="*70)
|
||||
console.print("BRANCH AUTO-DETECTION HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Detects which branch is calling AI_MAIL based on PWD/CWD")
|
||||
console.print(" Walks up directory tree to find branch root")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - detect_branch_from_pwd() -> Dict | None")
|
||||
console.print(" - find_branch_root(start_path) -> Path | None")
|
||||
console.print(" - get_branch_info_from_registry(branch_path) -> Dict | None")
|
||||
console.print(" - get_branch_display_name(branch_info) -> str")
|
||||
console.print(" - get_local_config_path(branch_path) -> Path")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("DETECTION FLOW:")
|
||||
console.print(" 1. Get current working directory (PWD)")
|
||||
console.print(" 2. Walk up tree to find [BRANCH].id.json file")
|
||||
console.print(" 3. Look up branch path in BRANCH_REGISTRY.json")
|
||||
console.print(" 4. Return branch info (name, email, path, etc.)")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: config_generator.py - Local Config Auto-Generation Handler
|
||||
# Date: 2025-11-18
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/users
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.0.0 (2025-11-18): Initial creation - auto-generate branch configs
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler independence: NO cross-domain imports
|
||||
# - Can import Prax modules (service providers)
|
||||
# - Pure business logic only
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Local Config Auto-Generation Handler
|
||||
|
||||
Auto-generates user_config.json files for branches that use AI_MAIL.
|
||||
Creates ai_mail_config/ directory and populates with branch-specific config.
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# =============================================
|
||||
# CONFIG GENERATION FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def generate_local_config(branch_info: Dict) -> Dict:
|
||||
"""
|
||||
Generate local user_config.json content for a branch.
|
||||
|
||||
Args:
|
||||
branch_info: Branch info dict from registry with keys:
|
||||
- name: Branch name (e.g., "SEED")
|
||||
- path: Branch path
|
||||
- email: Branch email address (e.g., "@seed")
|
||||
- description: Branch description
|
||||
|
||||
Returns:
|
||||
Dict with user_config.json structure
|
||||
"""
|
||||
branch_name = branch_info.get("name", "").lower()
|
||||
branch_email = branch_info.get("email", f"@{branch_name}")
|
||||
branch_path = Path(branch_info.get("path", ""))
|
||||
|
||||
# Generate display name from branch info
|
||||
display_name = generate_display_name(branch_info)
|
||||
|
||||
# Generate mailbox path (ai_mail.local/ in branch directory)
|
||||
mailbox_path = str(branch_path / "ai_mail.local")
|
||||
|
||||
config = {
|
||||
"version": "1.0.0",
|
||||
"current_user": branch_name,
|
||||
"users": {
|
||||
branch_name: {
|
||||
"name": branch_info.get("name", "").title(),
|
||||
"email_address": branch_email,
|
||||
"display_name": display_name,
|
||||
"mailbox_path": mailbox_path
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S",
|
||||
"max_inbox_display": 20,
|
||||
"max_sent_display": 20
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def generate_display_name(branch_info: Dict) -> str:
|
||||
"""
|
||||
Generate user-friendly display name for branch.
|
||||
|
||||
Args:
|
||||
branch_info: Branch info dict from registry
|
||||
|
||||
Returns:
|
||||
Display name string (e.g., "Seed (Standards Branch)")
|
||||
"""
|
||||
name = branch_info.get("name", "Unknown").title()
|
||||
description = branch_info.get("description", "")
|
||||
|
||||
# If description is meaningful (not default), use it
|
||||
if description and description != "New branch - purpose TBD":
|
||||
return f"{name} ({description})"
|
||||
else:
|
||||
# Use profile as context if available
|
||||
profile = branch_info.get("profile", "")
|
||||
if profile and profile != "AIPass Workshop":
|
||||
return f"{name} ({profile})"
|
||||
else:
|
||||
# Just branch name
|
||||
return name
|
||||
|
||||
|
||||
def create_local_config_file(branch_info: Dict, force: bool = False) -> Path | None:
|
||||
"""
|
||||
Create local user_config.json file for a branch.
|
||||
|
||||
Saves to branch's [branch_name]_json/ directory (e.g., seed_json/user_config.json).
|
||||
Follows the pattern: all JSON files for a branch go in their [branch]_json/ folder.
|
||||
|
||||
Args:
|
||||
branch_info: Branch info dict from registry
|
||||
force: If True, overwrite existing config file
|
||||
|
||||
Returns:
|
||||
Path to created config file, or None if failed
|
||||
"""
|
||||
try:
|
||||
branch_path = Path(branch_info.get("path", ""))
|
||||
if not branch_path.exists():
|
||||
return None
|
||||
|
||||
# Get branch name for directory pattern
|
||||
branch_name = branch_info.get("name", "").lower()
|
||||
|
||||
# Config directory: [branch_name]_json/
|
||||
config_dir = branch_path / f"{branch_name}_json"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
if not config_dir.exists():
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Config file path
|
||||
config_file = config_dir / "user_config.json"
|
||||
|
||||
# Check if already exists
|
||||
if config_file.exists() and not force:
|
||||
return config_file
|
||||
|
||||
# Generate config content
|
||||
config = generate_local_config(branch_info)
|
||||
|
||||
# Write config file
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
return config_file
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def create_mailbox_directory(branch_path: Path) -> Path | None:
|
||||
"""
|
||||
Create ai_mail.local/ mailbox directory for a branch.
|
||||
|
||||
Creates subdirectories: inbox/, sent/, deleted/
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
|
||||
Returns:
|
||||
Path to mailbox directory, or None if failed
|
||||
"""
|
||||
try:
|
||||
mailbox_dir = branch_path / "ai_mail.local"
|
||||
mailbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create subdirectories
|
||||
(mailbox_dir / "sent").mkdir(exist_ok=True)
|
||||
|
||||
# Create empty inbox.json if doesn't exist
|
||||
inbox_file = mailbox_dir / "inbox.json"
|
||||
if not inbox_file.exists():
|
||||
inbox_data = {
|
||||
"mailbox": "inbox",
|
||||
"total_messages": 0,
|
||||
"unread_count": 0,
|
||||
"messages": []
|
||||
}
|
||||
with open(inbox_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(inbox_data, f, indent=2)
|
||||
|
||||
# Create empty sent.json if doesn't exist
|
||||
sent_file = mailbox_dir / "sent.json"
|
||||
if not sent_file.exists():
|
||||
sent_data = {
|
||||
"mailbox": "sent",
|
||||
"total_messages": 0,
|
||||
"messages": []
|
||||
}
|
||||
with open(sent_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(sent_data, f, indent=2)
|
||||
|
||||
return mailbox_dir
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def setup_branch_for_aimail(branch_info: Dict, force: bool = False) -> bool:
|
||||
"""
|
||||
Complete AI_MAIL setup for a branch.
|
||||
|
||||
Creates:
|
||||
- ai_mail_config/user_config.json
|
||||
- ai_mail.local/ mailbox directory
|
||||
- ai_mail.local/inbox.json
|
||||
- ai_mail.local/sent.json
|
||||
|
||||
Args:
|
||||
branch_info: Branch info dict from registry
|
||||
force: If True, overwrite existing files
|
||||
|
||||
Returns:
|
||||
True if setup successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create config file
|
||||
config_file = create_local_config_file(branch_info, force=force)
|
||||
if not config_file:
|
||||
return False
|
||||
|
||||
# Create mailbox directory
|
||||
branch_path = Path(branch_info.get("path", ""))
|
||||
mailbox_dir = create_mailbox_directory(branch_path)
|
||||
if not mailbox_dir:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
console.print("\n" + "="*70)
|
||||
console.print("LOCAL CONFIG AUTO-GENERATION HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nPURPOSE:")
|
||||
console.print(" Auto-generates user_config.json files for branches using AI_MAIL")
|
||||
console.print(" Creates ai_mail_config/ directory and mailbox structure")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - generate_local_config(branch_info) -> Dict")
|
||||
console.print(" - generate_display_name(branch_info) -> str")
|
||||
console.print(" - create_local_config_file(branch_info, force) -> Path | None")
|
||||
console.print(" - create_mailbox_directory(branch_path) -> Path | None")
|
||||
console.print(" - setup_branch_for_aimail(branch_info, force) -> bool")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("SETUP WORKFLOW:")
|
||||
console.print(" 1. Generate config from branch registry info")
|
||||
console.print(" 2. Create ai_mail_config/ directory")
|
||||
console.print(" 3. Write user_config.json with branch-specific settings")
|
||||
console.print(" 4. Create ai_mail.local/ mailbox directory")
|
||||
console.print(" 5. Initialize inbox.json and sent.json")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: load.py - User Config Loading Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.2.0
|
||||
# Category: ai_mail/handlers/users
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.2.0 (2025-11-18): Added per-branch config support with PWD detection
|
||||
# - v1.1.0 (2025-11-15): Renamed domain config -> users for business purpose naming
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py and ai_mail_local_memory_monitor.py
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
User Config Loading Handler
|
||||
|
||||
Handles loading of user configuration files for AI_Mail system.
|
||||
Provides unified config loading for user_config.json and module configs.
|
||||
|
||||
NEW in v1.2.0: Per-branch config support
|
||||
- Detects calling branch from PWD/CWD
|
||||
- Checks for local config at branch_path/ai_mail_config/user_config.json
|
||||
- Falls back to AI_MAIL's global config if no local config found
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail"
|
||||
AI_MAIL_JSON = AI_MAIL_ROOT / "ai_mail_json"
|
||||
USER_CONFIG_FILE = AI_MAIL_JSON / "user_config.json"
|
||||
|
||||
# Import branch detection (after constants defined)
|
||||
from .branch_detection import detect_branch_from_pwd, get_local_config_path
|
||||
|
||||
# =============================================
|
||||
# CONFIG LOADING FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def load_user_config() -> Dict:
|
||||
"""
|
||||
Load user configuration from user_config.json
|
||||
|
||||
NEW in v1.2.0: Per-branch config support
|
||||
- First checks if calling from a branch (detects via PWD)
|
||||
- Looks for local config at branch_path/ai_mail_config/user_config.json
|
||||
- Falls back to AI_MAIL's global config if no local config
|
||||
- Auto-generates local config if branch detected but config missing
|
||||
|
||||
Returns:
|
||||
Dict containing user configuration
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no config found (neither local nor global)
|
||||
"""
|
||||
# Try to detect calling branch from PWD
|
||||
branch_info = detect_branch_from_pwd()
|
||||
|
||||
if branch_info:
|
||||
# Branch detected - check for local config
|
||||
branch_path = Path(branch_info["path"])
|
||||
branch_name = branch_info["name"]
|
||||
local_config_path = get_local_config_path(branch_path, branch_name)
|
||||
|
||||
if local_config_path.exists():
|
||||
# Local config exists - use it
|
||||
with open(local_config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
# No local config - offer to auto-generate
|
||||
# For now, fall through to global config
|
||||
pass
|
||||
|
||||
# No branch detected or no local config - use AI_MAIL's global config
|
||||
if not USER_CONFIG_FILE.exists():
|
||||
raise FileNotFoundError(f"User config not found: {USER_CONFIG_FILE}")
|
||||
|
||||
with open(USER_CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config(config_file: Path) -> Dict:
|
||||
"""
|
||||
Load generic configuration file with auto-healing
|
||||
|
||||
Creates default config if missing (for module configs).
|
||||
|
||||
Args:
|
||||
config_file: Path to configuration file
|
||||
|
||||
Returns:
|
||||
Dict containing configuration data
|
||||
|
||||
Raises:
|
||||
Exception: If config cannot be loaded or created
|
||||
"""
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_file}")
|
||||
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
raise
|
||||
|
||||
|
||||
def create_default_config(config_file: Path, default_config: Dict) -> None:
|
||||
"""
|
||||
Create default configuration file
|
||||
|
||||
Args:
|
||||
config_file: Path where config should be created
|
||||
default_config: Default configuration dictionary
|
||||
|
||||
Raises:
|
||||
Exception: If config cannot be created
|
||||
"""
|
||||
# Ensure parent directory exists
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
except Exception as e:
|
||||
raise
|
||||
|
||||
|
||||
def load_or_create_config(config_file: Path, default_config: Dict) -> Dict:
|
||||
"""
|
||||
Load config file, creating with defaults if missing
|
||||
|
||||
Args:
|
||||
config_file: Path to configuration file
|
||||
default_config: Default configuration to use if file doesn't exist
|
||||
|
||||
Returns:
|
||||
Dict containing configuration data
|
||||
"""
|
||||
if not config_file.exists():
|
||||
create_default_config(config_file, default_config)
|
||||
|
||||
return load_config(config_file)
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: user.py - User Info Handler
|
||||
# Date: 2025-11-30
|
||||
# Version: 2.0.0
|
||||
# Category: ai_mail/handlers/users
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v2.0.0 (2025-11-30): Removed all fallbacks - fail hard if branch detection fails
|
||||
# - v1.1.0 (2025-11-15): Renamed domain config -> users for business purpose naming
|
||||
# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
User Info Handler
|
||||
|
||||
Handles user information retrieval and management for AI_Mail system.
|
||||
Uses branch detection to identify sender - NO FALLBACKS.
|
||||
|
||||
PHILOSOPHY: Fail hard if detection fails. Fallbacks hide bugs.
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# Import branch detection functions
|
||||
from .branch_detection import detect_branch_from_pwd
|
||||
|
||||
# =============================================
|
||||
# USER INFO FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def get_current_user() -> Dict:
|
||||
"""
|
||||
Get current user's information from branch detection (BRANCH_REGISTRY.json)
|
||||
|
||||
Uses PWD/CWD to detect which branch is calling, then looks up info
|
||||
in BRANCH_REGISTRY.json. NO FALLBACKS - fails hard if detection fails.
|
||||
|
||||
Returns:
|
||||
Dict containing user information:
|
||||
{
|
||||
"email_address": "@branch",
|
||||
"display_name": "BRANCH_NAME",
|
||||
"mailbox_path": "/path/to/branch/ai_mail.local",
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
|
||||
Raises:
|
||||
RuntimeError: If branch detection fails (not called from a branch directory)
|
||||
"""
|
||||
# Detect branch from PWD
|
||||
branch_info = detect_branch_from_pwd()
|
||||
|
||||
if not branch_info:
|
||||
raise RuntimeError(
|
||||
"BRANCH DETECTION FAILED: Could not detect branch from current directory.\n"
|
||||
"AI_MAIL must be called from within a branch directory (with [BRANCH].id.json).\n"
|
||||
f"Current directory: {Path.cwd()}\n"
|
||||
"No fallback configured - this is intentional to catch bugs."
|
||||
)
|
||||
|
||||
# Extract info from branch_info (from BRANCH_REGISTRY.json)
|
||||
branch_name = branch_info.get("name")
|
||||
path_str = branch_info.get("path")
|
||||
branch_path = Path(path_str) if path_str else None
|
||||
email = branch_info.get("email")
|
||||
|
||||
if not all([branch_name, branch_path, email]):
|
||||
raise RuntimeError(
|
||||
f"INVALID BRANCH INFO: Branch registry entry incomplete.\n"
|
||||
f"Branch: {branch_name}\n"
|
||||
f"Path: {branch_path}\n"
|
||||
f"Email: {email}\n"
|
||||
"Check BRANCH_REGISTRY.json for missing fields."
|
||||
)
|
||||
|
||||
# Construct mailbox path (branch_path guaranteed non-None by check above)
|
||||
assert branch_path is not None
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
|
||||
# Return user info in expected format
|
||||
return {
|
||||
"email_address": email,
|
||||
"display_name": branch_name,
|
||||
"mailbox_path": str(mailbox_path),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> Dict | None:
|
||||
"""
|
||||
Get user information by email address from BRANCH_REGISTRY.json
|
||||
|
||||
Args:
|
||||
email: Email address (e.g., "@seed")
|
||||
|
||||
Returns:
|
||||
Dict containing user info, or None if not found
|
||||
"""
|
||||
from .branch_detection import get_branch_info_from_registry
|
||||
|
||||
# Use registry lookup
|
||||
registry_path = Path.home() / "BRANCH_REGISTRY.json"
|
||||
if not registry_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
import json
|
||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("email") == email:
|
||||
branch_path = Path(branch.get("path", ""))
|
||||
return {
|
||||
"email_address": branch.get("email"),
|
||||
"display_name": branch.get("name"),
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_all_users() -> Dict[str, Dict]:
|
||||
"""
|
||||
Get all users from BRANCH_REGISTRY.json
|
||||
|
||||
Returns:
|
||||
Dict mapping branch emails to user info dicts
|
||||
"""
|
||||
registry_path = Path.home() / "BRANCH_REGISTRY.json"
|
||||
if not registry_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
import json
|
||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
users = {}
|
||||
for branch in registry.get("branches", []):
|
||||
email = branch.get("email", "")
|
||||
if email:
|
||||
branch_path = Path(branch.get("path", ""))
|
||||
users[email] = {
|
||||
"email_address": email,
|
||||
"display_name": branch.get("name"),
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
return users
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
# JSON Templates package - Default JSON file templates
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "2025-11-13",
|
||||
"config": {
|
||||
"auto_save": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"created": "2025-11-13",
|
||||
"last_updated": "2025-11-13",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: branch_ping.py - Branch Ping Orchestration Module
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial orchestration module for branch ping
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Modules ORCHESTRATE (no business logic)
|
||||
# - Delegate to handlers for all operations
|
||||
# - Use json_handler.log_operation()
|
||||
# - Keep modules 110-155 lines
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Branch Ping Orchestration Module
|
||||
|
||||
Orchestrates branch memory health monitoring - delegates all logic to handlers.
|
||||
Commands: ping, status, registry, thresholds
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for formatting
|
||||
from aipass.cli.apps.modules import console
|
||||
from rich.panel import Panel
|
||||
|
||||
# Import handlers
|
||||
from aipass.ai_mail.apps.handlers.monitoring.memory import count_file_lines, get_status_from_count
|
||||
from aipass.ai_mail.apps.handlers.registry.update import (
|
||||
ping_registry,
|
||||
get_branch_context,
|
||||
update_json_memory_health
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
|
||||
|
||||
MODULE_NAME = "branch_ping"
|
||||
THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))}
|
||||
|
||||
def handle_ping(verbose: bool = False) -> bool:
|
||||
"""Execute ping command - orchestrate health check"""
|
||||
try:
|
||||
branch_name, cwd = get_branch_context()
|
||||
local_file = cwd / f"{branch_name}.local.json"
|
||||
obs_file = cwd / f"{branch_name}.observations.json"
|
||||
|
||||
local_count = count_file_lines(local_file)
|
||||
obs_count = count_file_lines(obs_file)
|
||||
local_status_code = get_status_from_count(local_count)
|
||||
obs_status_code = get_status_from_count(obs_count)
|
||||
|
||||
update_json_memory_health(local_file, local_count, local_status_code)
|
||||
update_json_memory_health(obs_file, obs_count, obs_status_code)
|
||||
|
||||
local_status = {"line_count": local_count, "status": local_status_code}
|
||||
obs_status = {"line_count": obs_count, "status": obs_status_code}
|
||||
ping_registry(branch_name, cwd, local_status, obs_status)
|
||||
|
||||
log_operation("ping_executed", {"branch": branch_name, "local_count": local_count, "obs_count": obs_count})
|
||||
|
||||
if verbose:
|
||||
console.print(f"Ping successful for {branch_name}")
|
||||
console.print(f" local.json: {local_count} lines ({local_status_code})")
|
||||
console.print(f" observations.json: {obs_count} lines ({obs_status_code})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ping failed: {e}")
|
||||
if verbose:
|
||||
console.print(f"Ping failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_status() -> bool:
|
||||
"""Show current memory health status"""
|
||||
try:
|
||||
branch_name, cwd = get_branch_context()
|
||||
local_file = cwd / f"{branch_name}.local.json"
|
||||
obs_file = cwd / f"{branch_name}.observations.json"
|
||||
|
||||
local_count = count_file_lines(local_file)
|
||||
obs_count = count_file_lines(obs_file)
|
||||
local_status = get_status_from_count(local_count)
|
||||
obs_status = get_status_from_count(obs_count)
|
||||
|
||||
console.print(f"\nBranch: {branch_name}\nDirectory: {cwd}")
|
||||
console.print(f"\nMemory Health Status:")
|
||||
console.print(f" local.json: {local_count} lines ({local_status})")
|
||||
console.print(f" observations.json: {obs_count} lines ({obs_status})\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Status check failed: {e}")
|
||||
console.print(f"Error getting status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_registry() -> bool:
|
||||
"""View registry contents"""
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.registry.update import REGISTRY_PATH
|
||||
from aipass.ai_mail.apps.handlers.registry.load import load_registry
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
console.print("Registry not yet created")
|
||||
return True
|
||||
|
||||
registry = load_registry(REGISTRY_PATH)
|
||||
|
||||
console.print("\nMemory Health Registry")
|
||||
console.print(f"Last Updated: {registry.get('last_updated', 'N/A')}\n")
|
||||
stats = registry.get('statistics', {})
|
||||
console.print(f"Statistics:")
|
||||
console.print(f" Total Branches: {stats.get('total_branches', 0)}")
|
||||
console.print(f" Green: {stats.get('green_status', 0)}, Yellow: {stats.get('yellow_status', 0)}, Red: {stats.get('red_status', 0)}\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Registry read failed: {e}")
|
||||
console.print(f"Error reading registry: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_thresholds() -> bool:
|
||||
"""Show compression thresholds"""
|
||||
console.print("\nMemory Compression Thresholds:")
|
||||
console.print(f" Green: 0 - {THRESHOLDS['green'][1]} lines")
|
||||
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
|
||||
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines (compression required)")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help output using argparse"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Branch Ping - Memory Health Monitoring Module',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
ping - Execute health check and update registry
|
||||
status - Show current memory status for this branch
|
||||
registry - View registry contents and statistics
|
||||
thresholds - Show compression thresholds
|
||||
|
||||
USAGE:
|
||||
drone ai_mail branch_ping <command>
|
||||
python3 branch_ping.py <command>
|
||||
python3 branch_ping.py --help
|
||||
|
||||
EXAMPLES:
|
||||
# Check memory health and update registry
|
||||
drone ai_mail branch_ping ping
|
||||
|
||||
# View current status
|
||||
drone ai_mail branch_ping status
|
||||
|
||||
# View registry
|
||||
drone ai_mail branch_ping registry
|
||||
|
||||
# Show thresholds
|
||||
drone ai_mail branch_ping thresholds
|
||||
"""
|
||||
)
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle incoming command - main orchestration entry point"""
|
||||
# Check if this module handles this command
|
||||
if command not in ["ping", "status", "registry", "thresholds"]:
|
||||
return False
|
||||
|
||||
# Handle help flag
|
||||
if args and args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if command == "ping":
|
||||
return handle_ping("--verbose" in args or "-v" in args)
|
||||
elif command == "status":
|
||||
return handle_status()
|
||||
elif command == "registry":
|
||||
return handle_registry()
|
||||
elif command == "thresholds":
|
||||
return handle_thresholds()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Handle --help flag
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']:
|
||||
console.print()
|
||||
console.print(Panel("[bold cyan]BRANCH PING - Memory Health Monitoring[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds, --help")
|
||||
console.print()
|
||||
console.print("[bold]USAGE:[/bold]")
|
||||
console.print(" drone ai_mail branch_ping <command>")
|
||||
console.print(" python3 branch_ping.py")
|
||||
console.print(" python3 branch_ping.py --help")
|
||||
console.print()
|
||||
console.print("[bold]COMMANDS:[/bold]")
|
||||
console.print(" [cyan]ping[/cyan] - Execute health check")
|
||||
console.print(" [cyan]status[/cyan] - Show current memory status")
|
||||
console.print(" [cyan]registry[/cyan] - View registry contents")
|
||||
console.print(" [cyan]thresholds[/cyan] - Show compression thresholds")
|
||||
console.print()
|
||||
sys.exit(0)
|
||||
|
||||
console.print()
|
||||
console.print(Panel("[bold cyan]BRANCH PING ORCHESTRATION MODULE[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds")
|
||||
console.print("[dim]Usage: drone ai_mail branch_ping [command][/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: dispatch.py - Dispatch Module
|
||||
# Date: 2026-02-02
|
||||
# Version: 3.0.0
|
||||
# Category: ai_mail/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v3.0.0 (2026-02-20): Add wake subcommand - manual branch spawn without daemon
|
||||
# - v2.0.0 (2026-02-17): Add daemon subcommand, move status logic to handler
|
||||
# - v1.0.0 (2026-02-02): Initial version - dispatch status tracking
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Orchestration only - delegates to handlers
|
||||
# - Uses json_handler.log_operation()
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Dispatch Module
|
||||
|
||||
Orchestrates dispatch commands: status tracking and daemon management.
|
||||
Delegates all business logic to handlers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import (
|
||||
load_dispatch_log,
|
||||
check_pid_status,
|
||||
calculate_age
|
||||
)
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help for dispatch commands."""
|
||||
help_text = """
|
||||
Dispatch Module - Agent dispatch management
|
||||
|
||||
COMMANDS:
|
||||
dispatch status - Show last 5 dispatch spawns with current status
|
||||
dispatch daemon - Start the continuous dispatch daemon
|
||||
dispatch wake - Manually wake a branch (spawn agent without daemon)
|
||||
|
||||
WAKE:
|
||||
drone wake @branch - Wake branch with default inbox check
|
||||
drone wake @branch "custom msg" - Wake branch with custom prompt
|
||||
ai_mail dispatch wake @branch - Same, via ai_mail directly
|
||||
|
||||
DAEMON:
|
||||
The daemon polls branch inboxes for --dispatch emails and spawns agents.
|
||||
Run as: ai_mail dispatch daemon
|
||||
Or standalone: python3 apps/handlers/dispatch/daemon.py
|
||||
|
||||
Kill switch: touch /home/aipass/.aipass/autonomous_pause
|
||||
Config: safety_config.json
|
||||
|
||||
EXAMPLE:
|
||||
ai_mail dispatch status
|
||||
|
||||
DISPATCH STATUS
|
||||
────────────────────────────────────
|
||||
@flow PID 108957 RUNNING 2m ago
|
||||
@ai_mail PID 85997 COMPLETED 10m ago
|
||||
────────────────────────────────────
|
||||
Active: 1 | Total: 2
|
||||
"""
|
||||
console.print(help_text)
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle dispatch commands.
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command handled, False otherwise
|
||||
"""
|
||||
if command != "dispatch":
|
||||
return False
|
||||
|
||||
if args and args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if not args:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "status":
|
||||
return _orchestrate_status()
|
||||
elif subcommand == "daemon":
|
||||
return _orchestrate_daemon()
|
||||
elif subcommand == "wake":
|
||||
return _orchestrate_wake(args[1:])
|
||||
else:
|
||||
console.print(f"[red]Unknown dispatch subcommand: {subcommand}[/red]")
|
||||
print_help()
|
||||
return False
|
||||
|
||||
|
||||
def _orchestrate_status() -> bool:
|
||||
"""Orchestrate dispatch status display."""
|
||||
logger.info("[dispatch] Showing dispatch status")
|
||||
|
||||
dispatches = load_dispatch_log()
|
||||
|
||||
if not dispatches:
|
||||
console.print("\n[dim]No dispatches recorded yet.[/dim]")
|
||||
return True
|
||||
|
||||
recent = dispatches[-5:][::-1]
|
||||
|
||||
console.print("\n[bold]DISPATCH STATUS[/bold]")
|
||||
console.print("─" * 50)
|
||||
|
||||
active_count = 0
|
||||
for entry in recent:
|
||||
branch = entry.get("branch", "unknown")
|
||||
pid = entry.get("pid")
|
||||
timestamp = entry.get("timestamp", "")
|
||||
spawn_status = entry.get("status", "unknown")
|
||||
|
||||
if spawn_status == "spawned" and pid:
|
||||
current_status = check_pid_status(pid)
|
||||
elif spawn_status == "failed":
|
||||
current_status = "FAILED"
|
||||
else:
|
||||
current_status = "UNKNOWN"
|
||||
|
||||
if current_status == "RUNNING":
|
||||
active_count += 1
|
||||
|
||||
age_str = calculate_age(timestamp)
|
||||
|
||||
if current_status == "RUNNING":
|
||||
status_display = "[green]RUNNING[/green]"
|
||||
elif current_status == "COMPLETED":
|
||||
status_display = "[dim]COMPLETED[/dim]"
|
||||
elif current_status == "FAILED":
|
||||
status_display = "[red]FAILED[/red]"
|
||||
else:
|
||||
status_display = f"[yellow]{current_status}[/yellow]"
|
||||
|
||||
pid_display = f"PID {pid}" if pid else "NO PID"
|
||||
console.print(f" {branch:<12} {pid_display:<12} {status_display:<18} {age_str}")
|
||||
|
||||
console.print("─" * 50)
|
||||
console.print(f"[dim]Active: {active_count} | Total: {len(recent)}[/dim]\n")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _orchestrate_wake(args: List[str]) -> bool:
|
||||
"""Orchestrate manual branch wake."""
|
||||
if not args or args[0] in ['--help', '-h', 'help']:
|
||||
console.print("\n[bold]Wake - Manual branch spawn[/bold]")
|
||||
console.print(" Usage: dispatch wake @branch [\"custom message\"]")
|
||||
console.print(" Or: drone wake @branch [\"custom message\"]\n")
|
||||
return True
|
||||
|
||||
# Parse --fresh and --sender flags
|
||||
use_fresh = "--fresh" in args
|
||||
use_sender = "@dev_central"
|
||||
filtered = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--fresh":
|
||||
i += 1
|
||||
continue
|
||||
if args[i] == "--sender" and i + 1 < len(args):
|
||||
use_sender = args[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
filtered.append(args[i])
|
||||
i += 1
|
||||
|
||||
if not filtered:
|
||||
console.print("[red]Missing branch argument[/red]")
|
||||
return False
|
||||
|
||||
branch_email = filtered[0]
|
||||
custom_message = filtered[1] if len(filtered) > 1 else None
|
||||
|
||||
logger.info(f"[dispatch] Manual wake requested for {branch_email}")
|
||||
console.print(f"\n⏳ Waking {branch_email}...")
|
||||
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
|
||||
dispatch_status, success = wake_branch(
|
||||
branch_email, custom_message, fresh=use_fresh, sender=use_sender
|
||||
)
|
||||
|
||||
# Print step-by-step status
|
||||
console.print(dispatch_status.format())
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _orchestrate_daemon() -> bool:
|
||||
"""Orchestrate daemon startup."""
|
||||
logger.info("[dispatch] Starting dispatch daemon")
|
||||
console.print("\n[bold]Starting dispatch daemon...[/bold]")
|
||||
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import run_daemon
|
||||
run_daemon()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if sys.argv[1] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
command = sys.argv[1]
|
||||
remaining_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
||||
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,946 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: email.py - Email Orchestration Module
|
||||
# Date: 2025-12-02
|
||||
# Version: 2.2.0
|
||||
# Category: ai_mail/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.2.0 (2026-02-25): FPLAN-0373 Phase 2 - enriched dashboard write-through via push_dashboard_update
|
||||
# - v2.1.0 (2026-02-25): Add --no-memory-save flag for private branch outbound dispatch
|
||||
# - v2.0.0 (2026-02-16): Group send support - multiple @recipients parsed correctly with --dispatch
|
||||
# - v1.9.0 (2026-02-14): Batch close perf fix - defer dashboard/purge to single call after loop
|
||||
# - v1.8.0 (2026-02-08): Fix dispatch_target identity mismatch - use registry lookup instead of folder name
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Orchestration only - NO business logic
|
||||
# - Imports from handlers/ for all operations
|
||||
# - Uses json_handler.log_operation()
|
||||
# - 135-200 lines target (handles multiple workflows)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Email Orchestration Module
|
||||
|
||||
Orchestrates email workflows for AI_Mail CLI system.
|
||||
Handles: send, inbox, sent, contacts commands.
|
||||
|
||||
Module Pattern:
|
||||
- handle_command(command, args) -> bool entry point
|
||||
- Imports handlers for business logic
|
||||
- Logs operations via json_handler
|
||||
- NO business logic in this file
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# Infrastructure
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_dashboard_section
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
|
||||
# Import handlers (business logic providers)
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
|
||||
from aipass.ai_mail.apps.handlers.email.create import create_email_file, load_email_file
|
||||
from aipass.ai_mail.apps.handlers.email.format import format_email_preview, format_email_header, format_email_list_item
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import (
|
||||
mark_read_and_archive, mark_all_read_and_archive,
|
||||
mark_as_opened, mark_as_closed_and_archive
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.email.reply import get_email_by_id, send_reply
|
||||
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
|
||||
from aipass.ai_mail.apps.handlers.users.user import get_current_user
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
|
||||
|
||||
|
||||
def _on_email_delivered(branch_path, new_count, opened_count, total):
|
||||
"""Post-delivery callback: update dashboard (enriched) and central."""
|
||||
try:
|
||||
push_dashboard_update(branch_path)
|
||||
except Exception:
|
||||
pass # Dashboard update is best-effort
|
||||
try:
|
||||
update_central()
|
||||
except Exception:
|
||||
pass # Central update is best-effort
|
||||
|
||||
|
||||
def _dispatch_send_error(to_branch: str, subject: str, error_msg: str) -> None:
|
||||
"""
|
||||
Auto-dispatch error report to @drone when email delivery fails.
|
||||
|
||||
Args:
|
||||
to_branch: Intended recipient that failed
|
||||
subject: Original email subject
|
||||
error_msg: Error message from delivery failure
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
user_info = get_current_user()
|
||||
sender = user_info.get("email_address", "@ai_mail")
|
||||
|
||||
error_report = (
|
||||
f"Email delivery failed.\n\n"
|
||||
f"From: {sender}\n"
|
||||
f"To: {to_branch}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Error: {error_msg}\n\n"
|
||||
f"This error was auto-dispatched for investigation."
|
||||
)
|
||||
|
||||
email_data = {
|
||||
"from": "@ai_mail",
|
||||
"from_name": "AI_MAIL",
|
||||
"to": "@drone",
|
||||
"subject": f"[ERROR] Send failed to {to_branch}: {error_msg[:50]}",
|
||||
"message": error_report,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"auto_execute": False,
|
||||
"priority": "normal",
|
||||
"reply_to": "@dev_central"
|
||||
}
|
||||
|
||||
deliver_email_to_branch("@drone", email_data)
|
||||
logger.info(f"[email] Error auto-dispatched to @drone for failed send to {to_branch}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[email] Failed to dispatch send error to @drone: {e}")
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module info and connected handlers"""
|
||||
console.print()
|
||||
console.print("[bold cyan]Email Orchestration Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Orchestrates email workflows (send, inbox, sent, contacts)[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print()
|
||||
console.print(" [cyan]email/[/cyan]")
|
||||
console.print(" [dim]- delivery.py (deliver_email_to_branch)[/dim]")
|
||||
console.print(" [dim]- create.py (create_email_file, load_email_file)[/dim]")
|
||||
console.print(" [dim]- format.py (format_email_*)[/dim]")
|
||||
console.print(" [dim]- inbox_ops.py (load_inbox)[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]users/[/cyan]")
|
||||
console.print(" [dim]- user.py (get_current_user)[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]registry/[/cyan]")
|
||||
console.print(" [dim]- read.py (get_all_branches, get_branch_by_email)[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]persistence/[/cyan]")
|
||||
console.print(" [dim]- json_ops.py (log_operation)[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'python3 email.py --help' for usage[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print drone-compliant help output"""
|
||||
help_text = """
|
||||
Email Module - Send and manage branch-to-branch email (Lifecycle v2)
|
||||
|
||||
COMMANDS:
|
||||
send - Send email to a branch
|
||||
inbox - View inbox messages (new + opened)
|
||||
view - View email content and mark as opened
|
||||
reply - Reply to email (auto-closes original)
|
||||
close - Close email without reply (archives to deleted)
|
||||
sent - View sent messages
|
||||
contacts - Manage contacts
|
||||
read - (Alias for 'view' - backward compatibility)
|
||||
|
||||
EMAIL LIFECYCLE (v2):
|
||||
new → opened → closed → deleted
|
||||
|
||||
1. Email arrives with status: "new"
|
||||
2. Use 'view <id>' to read content (marks as "opened")
|
||||
3. Use 'reply <id> "msg"' to respond (auto-closes + archives)
|
||||
OR 'close <id>' to close without reply (archives to deleted)
|
||||
|
||||
USAGE:
|
||||
ai_mail send @recipient "subject" "message" [--dispatch] [--reply-to @branch]
|
||||
ai_mail inbox
|
||||
ai_mail view <message_id> # View and mark as opened
|
||||
ai_mail reply <message_id> "msg" # Reply and close original
|
||||
ai_mail close <message_id> # Close without reply
|
||||
ai_mail sent
|
||||
ai_mail contacts
|
||||
|
||||
FLAGS:
|
||||
--dispatch Spawn Claude agent at target branch to execute the email task.
|
||||
Use when recipient needs to ACT (tasks, bugs, requests).
|
||||
Skip when just informing (acks, ideas, status updates).
|
||||
--reply-to Redirect replies to a different branch (e.g., --reply-to @dev_central)
|
||||
--auto-execute (Alias for --dispatch - backward compatibility)
|
||||
|
||||
EXAMPLES:
|
||||
# Send informational email (no agent spawn)
|
||||
ai_mail send @seed "Status Update" "All checks passing"
|
||||
|
||||
# Dispatch task to branch (spawns agent to execute)
|
||||
ai_mail send @drone "Task: Update config" "Please update X" --dispatch
|
||||
|
||||
# Broadcast announcement (no dispatch - informational)
|
||||
ai_mail send @all "Announcement" "System update tonight"
|
||||
|
||||
# View inbox (shows new + opened emails)
|
||||
ai_mail inbox
|
||||
|
||||
# View email content (marks as opened)
|
||||
ai_mail view a7b3c9d2
|
||||
|
||||
# Reply to email (sends reply + closes original)
|
||||
ai_mail reply a7b3c9d2 "Thanks, I'll review this today"
|
||||
|
||||
# Close email without replying
|
||||
ai_mail close a7b3c9d2
|
||||
|
||||
# View sent messages
|
||||
ai_mail sent
|
||||
|
||||
# View all branches
|
||||
ai_mail contacts
|
||||
|
||||
WHEN TO USE --dispatch:
|
||||
✓ Task assignments - "Task: Fix the bug in X"
|
||||
✓ Action requests - "Please review and merge PR #123"
|
||||
✓ Bug reports - "BUG: Feature Y is broken"
|
||||
✗ Status updates - "Complete: Task X finished"
|
||||
✗ Acknowledgments - "Received, will review tomorrow"
|
||||
✗ Ideas/proposals - "IDEA: Consider approach Z"
|
||||
"""
|
||||
console.print(help_text)
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle email commands
|
||||
|
||||
Args:
|
||||
command: Command name (send, inbox, sent, contacts)
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command handled, False otherwise
|
||||
"""
|
||||
# Check if this module handles this command
|
||||
if command not in ["send", "inbox", "view", "close", "reply", "sent", "contacts", "read"]:
|
||||
return False
|
||||
|
||||
# Handle --help flag in args
|
||||
if args and args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
# Route to appropriate workflow
|
||||
if command == "send":
|
||||
return handle_send(args)
|
||||
elif command == "inbox":
|
||||
return handle_inbox(args)
|
||||
elif command == "view":
|
||||
return handle_view(args)
|
||||
elif command == "close":
|
||||
return handle_close(args)
|
||||
elif command == "reply":
|
||||
return handle_reply(args)
|
||||
elif command == "read":
|
||||
# Backward compat: read now behaves like view
|
||||
return handle_view(args)
|
||||
elif command == "sent":
|
||||
return handle_sent(args)
|
||||
elif command == "contacts":
|
||||
return handle_contacts(args)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def handle_send(args: List[str]) -> bool:
|
||||
"""Orchestrate email sending workflow"""
|
||||
log_operation("send_email_initiated", {"args_count": len(args)})
|
||||
|
||||
# Check for --dispatch or --auto-execute flag (--dispatch is the new canonical name)
|
||||
auto_execute = '--dispatch' in args or '--auto-execute' in args
|
||||
if auto_execute:
|
||||
args = [a for a in args if a not in ('--dispatch', '--auto-execute')]
|
||||
|
||||
# Check for --no-memory-save flag (private branch outbound: skip memory logging at recipient)
|
||||
no_memory_save = '--no-memory-save' in args
|
||||
if no_memory_save:
|
||||
args = [a for a in args if a != '--no-memory-save']
|
||||
|
||||
# Check for --reply-to flag
|
||||
reply_to = None
|
||||
if '--reply-to' in args:
|
||||
idx = args.index('--reply-to')
|
||||
if idx + 1 < len(args):
|
||||
reply_to = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
else:
|
||||
console.print("❌ --reply-to requires a branch address (e.g., --reply-to @dev_central)")
|
||||
return False
|
||||
|
||||
# Separate recipients (@-prefixed) from subject/message
|
||||
recipients = []
|
||||
rest = []
|
||||
for a in args:
|
||||
if a.startswith('@') and not rest:
|
||||
recipients.append(a)
|
||||
elif a.startswith('/') and not rest:
|
||||
# Path-based recipient (e.g., /home/aipass/...)
|
||||
recipients.append(a)
|
||||
else:
|
||||
rest.append(a)
|
||||
|
||||
# Direct send mode: send @recipient(s) "subject" "message"
|
||||
if recipients and len(rest) >= 2:
|
||||
subject = rest[0]
|
||||
message = rest[1]
|
||||
|
||||
def _resolve_dispatch_target(branch: str) -> str | None:
|
||||
"""Resolve dispatch target for a single recipient"""
|
||||
if not auto_execute:
|
||||
return None
|
||||
if branch.startswith('/') or branch.startswith('~'):
|
||||
from aipass.ai_mail.apps.handlers.users.branch_detection import get_branch_info_from_registry
|
||||
branch_info = get_branch_info_from_registry(Path(branch))
|
||||
if branch_info:
|
||||
return branch_info.get("email", f"@{Path(branch).name.lower()}")
|
||||
return f"@{Path(branch).name.lower()}"
|
||||
return branch
|
||||
|
||||
# Single recipient - original behavior
|
||||
if len(recipients) == 1:
|
||||
dispatch_target = _resolve_dispatch_target(recipients[0])
|
||||
return send_email_direct(recipients[0], subject, message, auto_execute=auto_execute, reply_to=reply_to, dispatched_to=dispatch_target, no_memory_save=no_memory_save)
|
||||
|
||||
# Multiple recipients - send to each
|
||||
console.print(f"\n📨 Group send to {len(recipients)} recipients...")
|
||||
success_count = 0
|
||||
for recipient in recipients:
|
||||
dispatch_target = _resolve_dispatch_target(recipient)
|
||||
success = send_email_direct(recipient, subject, message, auto_execute=auto_execute, reply_to=reply_to, dispatched_to=dispatch_target, no_memory_save=no_memory_save)
|
||||
if success:
|
||||
success_count += 1
|
||||
console.print(f"\n📊 Group send complete: {success_count}/{len(recipients)} delivered")
|
||||
return success_count > 0
|
||||
|
||||
# Interactive send mode
|
||||
elif not recipients and not rest:
|
||||
return send_email_interactive()
|
||||
|
||||
# Bad args - not enough info
|
||||
else:
|
||||
console.print("❌ Usage: send @recipient [subject] [message]")
|
||||
console.print(" Multiple: send @branch1 @branch2 \"Subject\" \"Message\"")
|
||||
return False
|
||||
|
||||
|
||||
def send_email_interactive() -> bool:
|
||||
"""Interactive email sending with prompts"""
|
||||
branches = get_all_branches()
|
||||
|
||||
console.print("\n📧 AI_Mail - Send Email")
|
||||
console.print("=" * 50)
|
||||
|
||||
# Show branch selection
|
||||
console.print("\nSelect recipient:")
|
||||
for i, branch in enumerate(branches, 1):
|
||||
console.print(f" {i}. {branch['name']} ({branch['email']})")
|
||||
console.print(f" {len(branches) + 1}. ALL BRANCHES (broadcast)")
|
||||
|
||||
# Get selection
|
||||
try:
|
||||
selection = input(f"\nPick (1-{len(branches) + 1}): ").strip()
|
||||
idx = int(selection) - 1
|
||||
|
||||
if idx == len(branches):
|
||||
selected_email = "all"
|
||||
elif idx < 0 or idx >= len(branches):
|
||||
console.print("❌ Invalid selection")
|
||||
return False
|
||||
else:
|
||||
selected_email = branches[idx]["email"]
|
||||
except (ValueError, KeyboardInterrupt, EOFError):
|
||||
console.print("\n❌ Cancelled")
|
||||
return False
|
||||
|
||||
# Get subject
|
||||
try:
|
||||
subject = input("Subject: ").strip()
|
||||
if not subject:
|
||||
console.print("❌ Subject cannot be empty")
|
||||
return False
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
console.print("\n❌ Cancelled")
|
||||
return False
|
||||
|
||||
# Get message
|
||||
console.print("Message (press Ctrl+D when done, Ctrl+C to cancel):")
|
||||
try:
|
||||
message_lines = []
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
message_lines.append(line)
|
||||
except EOFError:
|
||||
break
|
||||
message = "\n".join(message_lines).strip()
|
||||
if not message:
|
||||
console.print("❌ Message cannot be empty")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n❌ Cancelled")
|
||||
return False
|
||||
|
||||
# Confirm send
|
||||
console.print("\n" + "=" * 50)
|
||||
console.print(f"To: {selected_email}")
|
||||
console.print(f"Subject: {subject}")
|
||||
console.print(f"Message:\n{message}")
|
||||
console.print("=" * 50)
|
||||
|
||||
try:
|
||||
confirm = input("\nSend? (y/n): ").strip().lower()
|
||||
if confirm != 'y':
|
||||
console.print("❌ Cancelled")
|
||||
return False
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
console.print("\n❌ Cancelled")
|
||||
return False
|
||||
|
||||
return send_email_direct(selected_email, subject, message)
|
||||
|
||||
|
||||
def send_email_direct(to_branch: str, subject: str, message: str, auto_execute: bool = False, reply_to: str | None = None, dispatched_to: str | None = None, from_branch: str | None = None, no_memory_save: bool = False) -> bool:
|
||||
"""Direct email sending (orchestrates handlers)
|
||||
|
||||
Args:
|
||||
to_branch: Recipient email address (e.g., @flow)
|
||||
subject: Email subject
|
||||
message: Email body
|
||||
auto_execute: If True, spawn agent at recipient branch
|
||||
reply_to: Optional branch to redirect replies to
|
||||
dispatched_to: Track dispatch target for reply validation
|
||||
from_branch: Optional sender override (e.g., @trigger). If None, uses PWD detection.
|
||||
no_memory_save: If True, add no-memory-save directive to dispatch header
|
||||
"""
|
||||
try:
|
||||
# Get sender info - use explicit from_branch if provided, otherwise detect from PWD
|
||||
if from_branch:
|
||||
# Normalize email format (handle @trigger or trigger formats)
|
||||
email_addr = f"@{from_branch.lstrip('@').lower()}"
|
||||
# Look up branch from registry to get correct path
|
||||
branch_info = get_branch_by_email(email_addr)
|
||||
if branch_info:
|
||||
branch_path = Path(branch_info["path"])
|
||||
user_info = {
|
||||
"email_address": email_addr,
|
||||
"display_name": branch_info["name"],
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
else:
|
||||
# Fallback for unregistered branches (shouldn't happen in production)
|
||||
branch_name = from_branch.lstrip('@').upper()
|
||||
user_info = {
|
||||
"email_address": email_addr,
|
||||
"display_name": branch_name,
|
||||
"mailbox_path": str(AIPASS_ROOT / from_branch.lstrip('@').lower() / "ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
else:
|
||||
user_info = get_current_user()
|
||||
|
||||
# Prepend dispatch header for auto-execute emails (critical memory update reminder)
|
||||
if auto_execute:
|
||||
message = prepend_dispatch_header(message, no_memory_save=no_memory_save)
|
||||
|
||||
# Broadcast mode
|
||||
if to_branch.lower() in ['all', '@all']:
|
||||
branches = get_all_branches()
|
||||
success_count = 0
|
||||
|
||||
# Create email file (pass reply_to, dispatched_to for broadcast)
|
||||
email_file = create_email_file("all", subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to)
|
||||
email_data = load_email_file(email_file)
|
||||
|
||||
# Handle None case (file not found/couldn't be loaded)
|
||||
if email_data is None:
|
||||
console.print("❌ Failed to load email file for broadcast")
|
||||
log_operation("broadcast_failed", {"error": "Email file could not be loaded"})
|
||||
return False
|
||||
|
||||
console.print(f"\n📢 Broadcasting to {len(branches)} branches...")
|
||||
|
||||
# Deliver to each branch
|
||||
for branch in branches:
|
||||
delivery_data = email_data.copy()
|
||||
delivery_data['to'] = branch['email']
|
||||
delivery_data['auto_execute'] = auto_execute
|
||||
if no_memory_save:
|
||||
delivery_data['no_memory_save'] = True
|
||||
|
||||
success, error_msg = deliver_email_to_branch(branch['email'], delivery_data, on_delivered=_on_email_delivered)
|
||||
if success:
|
||||
success_count += 1
|
||||
console.print(f" ✅ {branch['name']}")
|
||||
else:
|
||||
console.print(f" ❌ {branch['name']} ({error_msg})")
|
||||
|
||||
console.print(f"\n📊 Broadcast complete: {success_count}/{len(branches)} delivered")
|
||||
log_operation("broadcast_sent", {"recipients": len(branches), "successful": success_count})
|
||||
|
||||
# Fire trigger event
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('email_broadcast_sent', recipients=len(branches), successful=success_count, subject=subject)
|
||||
except ImportError:
|
||||
pass # Silent fallback if trigger unavailable
|
||||
|
||||
# Update central after broadcast
|
||||
try:
|
||||
update_central()
|
||||
except Exception:
|
||||
pass # Don't break mail ops if central update fails
|
||||
|
||||
return success_count > 0
|
||||
|
||||
# Single recipient
|
||||
else:
|
||||
email_file = create_email_file(to_branch, subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to)
|
||||
email_data = load_email_file(email_file)
|
||||
|
||||
# Handle None case (file not found/couldn't be loaded)
|
||||
if email_data is None:
|
||||
console.print(f"❌ Failed to load email file")
|
||||
log_operation("email_failed", {"to": to_branch, "error": "Email file could not be loaded"})
|
||||
return False
|
||||
|
||||
# Add auto_execute flag, dispatch tracking, and memory directive
|
||||
email_data['auto_execute'] = auto_execute
|
||||
if dispatched_to:
|
||||
email_data['dispatched_to'] = dispatched_to
|
||||
if no_memory_save:
|
||||
email_data['no_memory_save'] = True
|
||||
|
||||
success, error_msg = deliver_email_to_branch(to_branch, email_data, on_delivered=_on_email_delivered)
|
||||
|
||||
if success:
|
||||
if auto_execute:
|
||||
console.print(f"✅ Email sent to {to_branch} \\[dispatch: queued for daemon]")
|
||||
else:
|
||||
console.print(f"✅ Email sent to {to_branch}")
|
||||
log_operation("email_sent", {"to": to_branch, "subject": subject, "auto_execute": auto_execute})
|
||||
|
||||
# Fire trigger event
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('email_sent', to=to_branch, subject=subject, auto_execute=auto_execute)
|
||||
except ImportError:
|
||||
pass # Silent fallback if trigger unavailable
|
||||
|
||||
# Update central after send
|
||||
try:
|
||||
update_central()
|
||||
except Exception:
|
||||
pass # Don't break mail ops if central update fails
|
||||
|
||||
return True
|
||||
else:
|
||||
console.print(f"❌ Failed to deliver: {error_msg}")
|
||||
log_operation("email_failed", {"to": to_branch, "error": error_msg})
|
||||
_dispatch_send_error(to_branch, subject, error_msg)
|
||||
return False
|
||||
|
||||
except BrokenPipeError:
|
||||
logger.info("[email] Send: broken pipe (stdout closed early)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Send failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
_dispatch_send_error(to_branch, subject, str(e))
|
||||
return False
|
||||
|
||||
|
||||
def handle_inbox(args: List[str]) -> bool:
|
||||
"""Orchestrate inbox viewing workflow.
|
||||
|
||||
Usage:
|
||||
inbox - View current branch's inbox (detected from PWD)
|
||||
inbox @branch - View specified branch's inbox
|
||||
"""
|
||||
log_operation("inbox_viewed")
|
||||
|
||||
try:
|
||||
# Check if a target branch was specified
|
||||
target_branch = None
|
||||
if args and args[0].startswith("@"):
|
||||
target_branch = args[0]
|
||||
|
||||
if target_branch:
|
||||
# Look up target branch from registry
|
||||
branch_info = get_branch_by_email(target_branch)
|
||||
if not branch_info:
|
||||
console.print(f"❌ Unknown branch: {target_branch}")
|
||||
return False
|
||||
branch_path = Path(branch_info["path"])
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
display_name = branch_info.get("name", target_branch)
|
||||
else:
|
||||
# Use current branch (detected from PWD)
|
||||
user_info = get_current_user()
|
||||
mailbox_path = Path(user_info["mailbox_path"])
|
||||
display_name = user_info.get("display_name", "")
|
||||
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
if target_branch:
|
||||
console.print(f"📭 {target_branch} inbox is empty")
|
||||
else:
|
||||
console.print("📭 Inbox is empty")
|
||||
return True
|
||||
|
||||
# Load inbox using handler
|
||||
inbox_data = load_inbox(inbox_file)
|
||||
messages = inbox_data.get("messages", [])
|
||||
|
||||
if not messages:
|
||||
if target_branch:
|
||||
console.print(f"📭 {target_branch} inbox is empty")
|
||||
else:
|
||||
console.print("📭 Inbox is empty")
|
||||
return True
|
||||
|
||||
# Display messages (newest first)
|
||||
messages_display = list(reversed(messages))[:20]
|
||||
|
||||
if target_branch:
|
||||
console.print(f"\n📬 Inbox for {target_branch} ({display_name})")
|
||||
else:
|
||||
console.print("\n📬 Inbox")
|
||||
console.print("=" * 70)
|
||||
|
||||
for i, msg in enumerate(messages_display, 1):
|
||||
console.print(format_email_list_item(i, msg, show_unread=True))
|
||||
|
||||
console.print("\n" + "=" * 70)
|
||||
console.print(f"Showing {len(messages_display)} of {len(messages)} messages")
|
||||
console.print("\n[dim]To archive: drone @ai_mail read <message_id> or drone @ai_mail read all[/dim]")
|
||||
|
||||
return True
|
||||
|
||||
except BrokenPipeError:
|
||||
logger.info("[email] Inbox view: broken pipe (stdout closed early)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Inbox view failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_read(args: List[str]) -> bool:
|
||||
"""Orchestrate email read/archive workflow"""
|
||||
log_operation("read_email_initiated", {"args": args})
|
||||
|
||||
if not args:
|
||||
console.print("❌ Usage: drone @ai_mail read <message_id> or drone @ai_mail read all")
|
||||
return False
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
# mailbox_path is ai_mail.local/, branch_path is parent
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
|
||||
# Archive all messages
|
||||
if args[0].lower() == "all":
|
||||
success, message, count = mark_all_read_and_archive(branch_path)
|
||||
if success:
|
||||
console.print(f"✅ {message}")
|
||||
log_operation("all_emails_archived", {"count": count})
|
||||
else:
|
||||
console.print(f"❌ {message}")
|
||||
return success
|
||||
|
||||
# Archive single message
|
||||
else:
|
||||
message_id = args[0]
|
||||
success, message = mark_read_and_archive(branch_path, message_id)
|
||||
if success:
|
||||
console.print(f"✅ {message}")
|
||||
log_operation("email_archived", {"message_id": message_id})
|
||||
else:
|
||||
console.print(f"❌ {message}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Read/archive failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_view(args: List[str]) -> bool:
|
||||
"""
|
||||
View email content and mark as opened (v2 schema).
|
||||
Does NOT archive - email stays in inbox with status: opened.
|
||||
"""
|
||||
log_operation("view_email_initiated", {"args": args})
|
||||
|
||||
if not args:
|
||||
console.print("❌ Usage: drone @ai_mail view <message_id>")
|
||||
return False
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
message_id = args[0]
|
||||
|
||||
# Mark as opened and get email content
|
||||
success, message, email_data = mark_as_opened(branch_path, message_id)
|
||||
|
||||
if not success:
|
||||
console.print(f"❌ {message}")
|
||||
return False
|
||||
|
||||
# Display the email
|
||||
console.print("\n" + "="*60)
|
||||
console.print(f"📧 From: {email_data.get('from', 'unknown')} ({email_data.get('from_name', '')})")
|
||||
console.print(f"📌 Subject: {email_data.get('subject', 'No subject')}")
|
||||
console.print(f"🕐 {email_data.get('timestamp', '')}")
|
||||
console.print("="*60)
|
||||
console.print(f"\n{email_data.get('message', '')}\n")
|
||||
console.print("="*60)
|
||||
console.print(f"[dim]Status: opened | ID: {message_id}[/dim]")
|
||||
console.print(f"[dim]To reply: drone @ai_mail reply {message_id} \"your message\"[/dim]")
|
||||
console.print(f"[dim]To close: drone @ai_mail close {message_id}[/dim]")
|
||||
|
||||
log_operation("email_viewed", {"message_id": message_id})
|
||||
return True
|
||||
|
||||
except BrokenPipeError:
|
||||
logger.info("[email] View: broken pipe (stdout closed early)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[email] View failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_close(args: List[str]) -> bool:
|
||||
"""
|
||||
Close email(s) and archive to deleted (v2 schema).
|
||||
Marks status: closed and moves to deleted.json.
|
||||
|
||||
Supports:
|
||||
close <id> - Close single email
|
||||
close <id1> <id2> ... - Close multiple emails
|
||||
close all - Close all emails in inbox
|
||||
"""
|
||||
log_operation("close_email_initiated", {"args": args})
|
||||
|
||||
if not args:
|
||||
console.print("❌ Usage: drone @ai_mail close <id> [id2 id3 ...] | close all")
|
||||
return False
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
|
||||
# Handle "close all"
|
||||
if args[0].lower() == "all":
|
||||
success, message, count = mark_all_read_and_archive(branch_path)
|
||||
if success:
|
||||
console.print(f"✅ {message}")
|
||||
log_operation("email_closed_all", {"count": count})
|
||||
else:
|
||||
console.print(f"❌ {message}")
|
||||
return success
|
||||
|
||||
# Handle one or more message IDs
|
||||
# When closing multiple, defer dashboard/purge to single call after loop
|
||||
batch_mode = len(args) > 1
|
||||
closed_count = 0
|
||||
failed_count = 0
|
||||
for message_id in args:
|
||||
success, message = mark_as_closed_and_archive(branch_path, message_id, skip_post_ops=batch_mode)
|
||||
if success:
|
||||
console.print(f"✅ {message}")
|
||||
log_operation("email_closed", {"message_id": message_id})
|
||||
closed_count += 1
|
||||
else:
|
||||
console.print(f"❌ {message}")
|
||||
failed_count += 1
|
||||
|
||||
# Run dashboard update + purge once after batch close
|
||||
if batch_mode and closed_count > 0:
|
||||
try:
|
||||
push_dashboard_update(branch_path)
|
||||
update_central()
|
||||
except Exception:
|
||||
pass # Dashboard/central update is best-effort
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
|
||||
purge_deleted_folder(branch_path / "ai_mail.local")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if batch_mode:
|
||||
console.print(f"\n📊 Closed {closed_count}, failed {failed_count}")
|
||||
|
||||
return failed_count == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Close failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_reply(args: List[str]) -> bool:
|
||||
"""
|
||||
Reply to an email (v2 schema).
|
||||
Sends reply to original sender + auto-closes original.
|
||||
"""
|
||||
log_operation("reply_email_initiated", {"args": args})
|
||||
|
||||
if len(args) < 2:
|
||||
console.print("❌ Usage: drone @ai_mail reply <message_id> \"your message\"")
|
||||
return False
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
|
||||
message_id = args[0]
|
||||
reply_message = args[1]
|
||||
|
||||
# Get original email
|
||||
original_email = get_email_by_id(inbox_file, message_id)
|
||||
if not original_email:
|
||||
console.print(f"❌ Message not found: {message_id}")
|
||||
return False
|
||||
|
||||
# Send reply
|
||||
success, message, reply_id = send_reply(branch_path, original_email, reply_message)
|
||||
|
||||
if success:
|
||||
console.print(f"✅ {message}")
|
||||
log_operation("email_replied", {"message_id": message_id, "reply_id": reply_id})
|
||||
else:
|
||||
console.print(f"❌ {message}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Reply failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_sent(args: List[str]) -> bool:
|
||||
"""Orchestrate sent messages viewing workflow"""
|
||||
log_operation("sent_viewed")
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
mailbox_path = Path(user_info["mailbox_path"])
|
||||
sent_folder = mailbox_path / "sent"
|
||||
|
||||
if not sent_folder.exists():
|
||||
console.print("📭 No sent messages")
|
||||
return True
|
||||
|
||||
# Get email files
|
||||
email_files = sorted(sent_folder.glob("*.json"), reverse=True)[:20]
|
||||
|
||||
if not email_files:
|
||||
console.print("📭 No sent messages")
|
||||
return True
|
||||
|
||||
console.print("\n📤 Sent Messages")
|
||||
console.print("=" * 70)
|
||||
|
||||
for i, email_file in enumerate(email_files, 1):
|
||||
email_data = load_email_file(email_file)
|
||||
if email_data:
|
||||
console.print(format_email_list_item(i, email_data, show_unread=False))
|
||||
|
||||
console.print("\n" + "=" * 70)
|
||||
console.print(f"Showing {len(email_files)} sent messages")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Sent view failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_contacts(args: List[str]) -> bool:
|
||||
"""Orchestrate contacts management workflow"""
|
||||
log_operation("contacts_viewed")
|
||||
|
||||
try:
|
||||
branches = get_all_branches()
|
||||
|
||||
if not branches:
|
||||
console.print("❌ No contacts found")
|
||||
return False
|
||||
|
||||
console.print(f"\nTotal: {len(branches)} branches\n")
|
||||
console.print(f"{'EMAIL':<20} {'BRANCH NAME':<25} {'PATH':<35}")
|
||||
console.print("-" * 80)
|
||||
|
||||
for branch in sorted(branches, key=lambda b: b["email"]):
|
||||
email = branch["email"]
|
||||
name = branch["name"]
|
||||
path = branch["path"]
|
||||
|
||||
console.print(f"{email:<20} {name:<25} {path:<35}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Contacts view failed: {e}")
|
||||
console.print(f"❌ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Show introspection when run without arguments
|
||||
if len(sys.argv) == 1:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
# Handle help flag
|
||||
if sys.argv[1] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Execute command
|
||||
command = sys.argv[1]
|
||||
remaining_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
||||
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]python3 email.py --help[/dim] for available commands")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1 @@
|
||||
# Plugins package - Pluggable components for branch capabilities
|
||||
|
||||
@@ -1 +1 @@
|
||||
# API apps package
|
||||
# Apps package
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: main.py - api Branch Orchestrator
|
||||
# Date: 2025-11-08
|
||||
# Version: 1.0.0
|
||||
# Category: api/entry_point
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-08): Initial version - modular architecture
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
api Branch - Main Orchestrator
|
||||
|
||||
Modular architecture with auto-discovered modules.
|
||||
Main handles routing, modules implement functionality.
|
||||
"""
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import importlib
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for formatted output
|
||||
from aipass.cli.apps.modules import console, header
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.columns import Columns
|
||||
|
||||
# JSON handler for api tracking
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS & CONFIG
|
||||
# =============================================================================
|
||||
|
||||
# Module root
|
||||
MODULE_ROOT = Path(__file__).parent
|
||||
|
||||
# Modules directory
|
||||
MODULES_DIR = MODULE_ROOT / "modules"
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""
|
||||
Auto-discover modules from modules/ directory
|
||||
|
||||
Returns:
|
||||
List of module objects with handle_command() function
|
||||
"""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
logger.warning(f"Modules directory not found: {MODULES_DIR}")
|
||||
return modules
|
||||
|
||||
logger.info(f"[{Path(__file__).stem}] Discovering modules...")
|
||||
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
# Skip __init__.py and private files
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = file_path.stem
|
||||
|
||||
try:
|
||||
# Import module via pip namespace
|
||||
module = importlib.import_module(f"aipass.api.apps.modules.{module_name}")
|
||||
|
||||
# Check for required interface
|
||||
if hasattr(module, 'handle_command'):
|
||||
modules.append(module)
|
||||
logger.info(f" [+] {module_name}")
|
||||
else:
|
||||
logger.warning(f" [!] {module_name} - missing handle_command()")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" [-] {module_name} - import error: {e}")
|
||||
|
||||
logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules")
|
||||
return modules
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION DISPLAY
|
||||
# =============================================================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display discovered modules and available commands"""
|
||||
console.print()
|
||||
console.print("[bold cyan]API Branch - API Operations[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Universal API client and key management[/dim]")
|
||||
console.print()
|
||||
|
||||
# Discover modules
|
||||
modules = discover_modules()
|
||||
|
||||
if not modules:
|
||||
console.print("[red]No modules discovered[/red]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'python3 api.py --help' for usage information[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
|
||||
for module in modules:
|
||||
module_name = module.__name__.split('.')[-1]
|
||||
console.print(f" [cyan]•[/cyan] {module_name}")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Run 'python3 api.py --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRONE COMPLIANCE - HELP SYSTEM
|
||||
# =============================================================================
|
||||
|
||||
def print_help():
|
||||
"""Display Rich-formatted help"""
|
||||
|
||||
console.print()
|
||||
header("API Branch - API Operations")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Universal API client and key management system[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]WHAT IS API?[/bold cyan]")
|
||||
console.print()
|
||||
console.print("API Branch provides:")
|
||||
console.print(" [green]✓[/green] OpenRouter API client integration")
|
||||
console.print(" [green]✓[/green] API key management and validation")
|
||||
console.print(" [green]✓[/green] Model discovery and availability")
|
||||
console.print(" [green]✓[/green] Usage tracking and statistics")
|
||||
console.print(" [green]✓[/green] Connection testing and diagnostics")
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||
table.add_column("Command", style="green")
|
||||
table.add_column("Description", style="white")
|
||||
|
||||
table.add_row("get-key", "Retrieve API key for provider")
|
||||
table.add_row("validate", "Validate API credentials and connection")
|
||||
table.add_row("test", "Test OpenRouter connection status")
|
||||
table.add_row("models", "List available models from provider")
|
||||
table.add_row("track", "Track API usage metrics")
|
||||
table.add_row("stats", "Display API usage statistics")
|
||||
table.add_row("telegram start", "Start Telegram bridge service")
|
||||
table.add_row("telegram stop", "Stop Telegram bridge service")
|
||||
table.add_row("telegram status", "Check Telegram bridge status")
|
||||
table.add_row("telegram logs", "View Telegram bridge logs")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
usage_examples = [
|
||||
"[yellow]Quick Commands:[/yellow]\n [dim]python3 api.py get-key[/dim]\n [dim]python3 api.py validate[/dim]",
|
||||
"[yellow]Testing:[/yellow]\n [dim]python3 api.py test[/dim]\n [dim]python3 api.py models[/dim]",
|
||||
"[yellow]Analytics:[/yellow]\n [dim]python3 api.py track[/dim]\n [dim]python3 api.py stats[/dim]"
|
||||
]
|
||||
|
||||
console.print(Columns(usage_examples, equal=True, expand=True))
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]CONFIGURATION:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
config_text = """[bold]API configuration is managed through:[/bold]
|
||||
|
||||
[green]•[/green] Environment variables for credentials
|
||||
[green]•[/green] Configuration files in handlers/config/
|
||||
[green]•[/green] Provider-specific settings in handlers/"""
|
||||
|
||||
console.print(Panel(config_text, border_style="cyan", padding=(1, 2)))
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Commands: get-key, validate, test, models, track, stats, help, --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND ROUTING
|
||||
# =============================================================================
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""
|
||||
Route command to appropriate module
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., 'get-key', 'validate')
|
||||
args: Additional command arguments
|
||||
modules: List of discovered modules
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Module error: {e}")
|
||||
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands to modules"""
|
||||
|
||||
# Parse arguments directly from sys.argv
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"})
|
||||
return 0
|
||||
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("API v1.0.0")
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
json_handler.log_operation("api_help_displayed", {"trigger": args[0]})
|
||||
return 0
|
||||
|
||||
# Discover modules
|
||||
modules = discover_modules()
|
||||
|
||||
if not modules:
|
||||
logger.error("No modules found")
|
||||
console.print()
|
||||
console.print("[red]ERROR: No modules found[/red]")
|
||||
console.print()
|
||||
return 1
|
||||
|
||||
# Extract command and remaining args (matching seed pattern)
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Log api command attempt
|
||||
json_handler.log_operation(
|
||||
"api_command_attempted",
|
||||
{"command": command, "modules_discovered": len(modules)}
|
||||
)
|
||||
|
||||
# Route command to modules
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
logger.warning(f"Unknown command: {command}")
|
||||
console.print()
|
||||
console.print(f"[red]ERROR: Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]python3 api.py --help[/dim] for available commands")
|
||||
console.print()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
API Branch - Main Orchestrator
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Failed to load module {module_name}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Module {module.__name__} error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help."""
|
||||
modules = discover_modules()
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0 or args[0] in ["--help", "-h", "help"]:
|
||||
print(f"API - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
# Extensions package - Drop-in extensions for branch functionality
|
||||
@@ -0,0 +1,90 @@
|
||||
"""API handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "api"
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""Walk the stack to find the actual file that triggered this import."""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
return str(Path(filename).resolve()), import_line
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("aipass_core", "MEMORY_BANK", "seed", ".vscode"):
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
if part in ("aipass",) and i + 1 < len(parts) and parts[i + 1] == "apps":
|
||||
return "aipass"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""Block cross-branch handler imports."""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
import os
|
||||
if os.environ.get("AIPASS_DEBUG_GUARD"):
|
||||
import sys
|
||||
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
|
||||
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
|
||||
|
||||
if caller_file is None:
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
target_line = "unknown"
|
||||
if frame.code_context:
|
||||
target_line = frame.code_context[0].strip()
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller: interactive/script\n"
|
||||
f" Blocked: {target_line}\n\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
return
|
||||
|
||||
if f"/{MY_BRANCH}/" in caller_file:
|
||||
return
|
||||
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
|
||||
_guard_branch_access()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Authentication Domain
|
||||
|
||||
Handlers for API key management, validation, and credential storage.
|
||||
Includes .env file operations and provider authentication.
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: env.py - .env file operations
|
||||
# Date: 2025-11-16
|
||||
# Version: 0.2.0
|
||||
# Category: api/handlers
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v0.2.0 (2025-11-16): Extracted from api_connect.py - complete implementation
|
||||
# - v0.1.0 (2025-11-15): Initial handler stub
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler layer (standalone functions)
|
||||
# - Uses CLI service for output
|
||||
# - Under 300 lines
|
||||
# ==============================================
|
||||
|
||||
"""
|
||||
.env File Handler
|
||||
|
||||
Manages .env file reading, creation, and parsing.
|
||||
Searches multiple paths, creates templates, handles env variables.
|
||||
|
||||
Functions:
|
||||
read_env_file() - Read environment variable from .env files (multi-path search)
|
||||
read_env_file_dict() - Read all variables from .env file as dictionary
|
||||
create_env_template() - Create .env template for provider
|
||||
validate_env_exists() - Check if .env file exists at any search path
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Standard library
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
# CLI services
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
# ==============================================
|
||||
# CONSTANTS
|
||||
# ==============================================
|
||||
|
||||
# Default .env search paths (in order of priority)
|
||||
# Navigate: env.py -> auth/ -> handlers/ -> apps/ -> api/
|
||||
API_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DEFAULT_ENV_PATHS = [
|
||||
API_ROOT / ".env", # <api_root>/.env
|
||||
Path.home() / ".env", # ~/.env
|
||||
]
|
||||
|
||||
|
||||
# ==============================================
|
||||
# ENV FILE READING
|
||||
# ==============================================
|
||||
|
||||
def read_env_file(env_var: str, search_paths: Optional[List[Path]] = None) -> Optional[str]:
|
||||
"""
|
||||
Read environment variable from .env files with multi-path search.
|
||||
|
||||
Searches multiple .env file locations in order:
|
||||
1. /home/aipass/aipass_core/api/.env
|
||||
2. /home/aipass/aipass_core/.env
|
||||
3. /home/aipass/.env
|
||||
|
||||
Args:
|
||||
env_var: Environment variable name to read (e.g., 'OPENROUTER_API_KEY')
|
||||
search_paths: Optional custom search paths (defaults to DEFAULT_ENV_PATHS)
|
||||
|
||||
Returns:
|
||||
str: Variable value if found, None otherwise
|
||||
|
||||
Example:
|
||||
>>> api_key = read_env_file('OPENROUTER_API_KEY')
|
||||
>>> if api_key:
|
||||
... print(f"Found key: {api_key[:20]}...")
|
||||
"""
|
||||
paths = search_paths or DEFAULT_ENV_PATHS
|
||||
|
||||
for env_file in paths:
|
||||
if not env_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(env_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# Parse key=value
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
if key.strip() == env_var:
|
||||
# Found env_var in env_file
|
||||
return value.strip()
|
||||
except Exception as e:
|
||||
# Error reading env_file
|
||||
continue
|
||||
|
||||
# Variable not found in any .env file
|
||||
return None
|
||||
|
||||
|
||||
def read_env_file_dict(env_path: Path) -> Dict[str, str]:
|
||||
"""
|
||||
Read all environment variables from a .env file as dictionary.
|
||||
|
||||
Args:
|
||||
env_path: Path to specific .env file to read
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of key-value pairs from .env file
|
||||
|
||||
Example:
|
||||
>>> env_vars = read_env_file_dict(Path('/home/aipass/aipass_core/api/.env'))
|
||||
>>> print(env_vars.get('OPENROUTER_API_KEY'))
|
||||
"""
|
||||
env_dict = {}
|
||||
|
||||
if not env_path.exists():
|
||||
# .env file not found
|
||||
return env_dict
|
||||
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# Parse key=value
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
env_dict[key.strip()] = value.strip()
|
||||
|
||||
# Read variables from env_path
|
||||
return env_dict
|
||||
|
||||
except Exception as e:
|
||||
# Error reading env_path
|
||||
return env_dict
|
||||
|
||||
|
||||
# ==============================================
|
||||
# ENV FILE CREATION
|
||||
# ==============================================
|
||||
|
||||
def create_env_template(provider: str = "openrouter", target_path: Optional[Path] = None) -> bool:
|
||||
"""
|
||||
Create .env template file with default placeholders.
|
||||
|
||||
Creates a template .env file with commented instructions and
|
||||
placeholder values for API keys. Will not overwrite existing files.
|
||||
|
||||
Args:
|
||||
provider: API provider name (default: 'openrouter')
|
||||
target_path: Optional custom path (defaults to api/.env)
|
||||
|
||||
Returns:
|
||||
bool: True if template created successfully, False otherwise
|
||||
|
||||
Example:
|
||||
>>> if create_env_template('openrouter'):
|
||||
... print("Template created at /home/aipass/aipass_core/api/.env")
|
||||
"""
|
||||
# Default to api/.env
|
||||
env_path = target_path or (API_ROOT / ".env")
|
||||
|
||||
# Don't overwrite existing file
|
||||
if env_path.exists():
|
||||
# .env file already exists
|
||||
console.print(f"[yellow]ℹ[/yellow] .env file already exists at {env_path}")
|
||||
return True
|
||||
|
||||
# Template content based on provider
|
||||
if provider.lower() == "openrouter":
|
||||
env_template = """# AIPass API Keys
|
||||
# Add your API keys here
|
||||
|
||||
# OpenRouter API Key (recommended - access to 323+ models)
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||
|
||||
# Backup OpenAI API Key (if needed)
|
||||
OPENAI_API_KEY=sk-your-openai-key-here
|
||||
|
||||
# Other provider keys can be added as needed
|
||||
"""
|
||||
else:
|
||||
# Generic template
|
||||
env_template = f"""# AIPass API Keys
|
||||
# Add your API keys here
|
||||
|
||||
# {provider.upper()} API Key
|
||||
{provider.upper()}_API_KEY=your-key-here
|
||||
|
||||
# Other provider keys can be added as needed
|
||||
"""
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write template
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.write(env_template)
|
||||
|
||||
# Created .env template
|
||||
console.print(f"[green]✓[/green] Created .env template at {env_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to create .env template
|
||||
console.print(f"[red]✗[/red] Failed to create .env template: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_custom_env_template(variables: Dict[str, str], target_path: Path,
|
||||
header: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Create custom .env template with specific variables.
|
||||
|
||||
Args:
|
||||
variables: Dictionary of variable names to placeholder values
|
||||
target_path: Path where .env file should be created
|
||||
header: Optional custom header comment
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
|
||||
Example:
|
||||
>>> vars = {
|
||||
... 'DATABASE_URL': 'postgresql://localhost/mydb',
|
||||
... 'SECRET_KEY': 'your-secret-key-here'
|
||||
... }
|
||||
>>> create_custom_env_template(vars, Path('/path/to/.env'))
|
||||
"""
|
||||
# Don't overwrite existing file
|
||||
if target_path.exists():
|
||||
# .env file already exists
|
||||
return True
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build template content
|
||||
content_lines = []
|
||||
|
||||
# Add header
|
||||
if header:
|
||||
content_lines.append(f"# {header}")
|
||||
else:
|
||||
content_lines.append("# Environment Variables")
|
||||
content_lines.append("")
|
||||
|
||||
# Add variables
|
||||
for key, value in variables.items():
|
||||
content_lines.append(f"{key}={value}")
|
||||
|
||||
content = "\n".join(content_lines) + "\n"
|
||||
|
||||
# Write file
|
||||
with open(target_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
# Created custom .env template
|
||||
console.print(f"[green]✓[/green] Created custom .env template at {target_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to create custom .env template
|
||||
console.print(f"[red]✗[/red] Failed to create custom .env template: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ==============================================
|
||||
# VALIDATION
|
||||
# ==============================================
|
||||
|
||||
def validate_env_exists(search_paths: Optional[List[Path]] = None) -> Optional[Path]:
|
||||
"""
|
||||
Check if .env file exists at any search path.
|
||||
|
||||
Args:
|
||||
search_paths: Optional custom search paths (defaults to DEFAULT_ENV_PATHS)
|
||||
|
||||
Returns:
|
||||
Path: First found .env file path, or None if none exist
|
||||
|
||||
Example:
|
||||
>>> env_path = validate_env_exists()
|
||||
>>> if env_path:
|
||||
... print(f"Found .env at {env_path}")
|
||||
"""
|
||||
paths = search_paths or DEFAULT_ENV_PATHS
|
||||
|
||||
for env_path in paths:
|
||||
if env_path.exists():
|
||||
# Found .env file
|
||||
return env_path
|
||||
|
||||
# No .env file found in search paths
|
||||
return None
|
||||
|
||||
|
||||
def get_env_search_paths() -> List[Path]:
|
||||
"""
|
||||
Get list of default .env search paths.
|
||||
|
||||
Returns:
|
||||
list: List of Path objects for .env search locations
|
||||
|
||||
Example:
|
||||
>>> paths = get_env_search_paths()
|
||||
>>> for p in paths:
|
||||
... print(p)
|
||||
"""
|
||||
return DEFAULT_ENV_PATHS.copy()
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: keys.py - API Key Management Handler
|
||||
# Date: 2025-11-16
|
||||
# Version: 2.0.0
|
||||
# Category: api/handlers
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2025-11-16): Extracted from api_connect.py - complete implementation
|
||||
# - v1.0.0 (2025-11-15): Initial handler stub
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler layer (standalone functions)
|
||||
# - Uses CLI service for output
|
||||
# - Uses prax logger for system logs
|
||||
# - Under 300 lines
|
||||
# ==============================================
|
||||
|
||||
"""
|
||||
API Key Management Handler
|
||||
|
||||
Handles API key retrieval and validation for multiple providers.
|
||||
Uses fallback chain: config → env → .env files.
|
||||
|
||||
Functions:
|
||||
get_api_key() - Get validated API key with fallback chain
|
||||
validate_key() - Validate key format for provider
|
||||
get_key_from_config() - Retrieve key from config JSON
|
||||
get_key_from_env() - Retrieve key from environment variable
|
||||
get_validation_rules() - Get provider-specific validation rules
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Standard library
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Internal handlers
|
||||
from aipass.api.apps.handlers.auth.env import read_env_file
|
||||
|
||||
|
||||
# ==============================================
|
||||
# CONSTANTS
|
||||
# ==============================================
|
||||
|
||||
# Navigate: keys.py -> auth/ -> handlers/ -> apps/ -> api/
|
||||
API_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
API_JSON_DIR = API_ROOT / "api_json"
|
||||
|
||||
# Provider validation rules (embedded - no config dependency for core validation)
|
||||
VALIDATION_RULES = {
|
||||
"openrouter": {
|
||||
"prefix": "sk-or-",
|
||||
"min_length": 20
|
||||
},
|
||||
"openai": {
|
||||
"prefix": "sk-",
|
||||
"min_length": 20
|
||||
},
|
||||
"anthropic": {
|
||||
"prefix": "sk-ant-",
|
||||
"min_length": 20
|
||||
},
|
||||
# Generic fallback
|
||||
"generic": {
|
||||
"min_length": 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# KEY RETRIEVAL
|
||||
# ==============================================
|
||||
|
||||
def get_api_key(provider: str = "openrouter") -> Optional[str]:
|
||||
"""
|
||||
Get validated API key for provider with fallback chain.
|
||||
|
||||
Fallback order:
|
||||
1. Config JSON file (api_json/api_connect_config.json)
|
||||
2. Environment variable
|
||||
3. .env file (multi-path search)
|
||||
|
||||
Args:
|
||||
provider: Provider name (default: 'openrouter')
|
||||
|
||||
Returns:
|
||||
str: Validated API key or None if not found/invalid
|
||||
|
||||
Example:
|
||||
>>> key = get_api_key('openrouter')
|
||||
>>> if key:
|
||||
... print(f"Got key: {key[:20]}...")
|
||||
"""
|
||||
try:
|
||||
# 1. Try config file
|
||||
key = get_key_from_config(provider)
|
||||
if key and validate_key(key, provider):
|
||||
# Using key from config
|
||||
return key
|
||||
|
||||
# 2. Try environment variable
|
||||
key = get_key_from_env(provider)
|
||||
if key and validate_key(key, provider):
|
||||
# Using key from environment
|
||||
return key
|
||||
|
||||
# 3. Try .env file
|
||||
env_var = f"{provider.upper()}_API_KEY"
|
||||
key = read_env_file(env_var)
|
||||
if key and validate_key(key, provider):
|
||||
# Using key from .env file
|
||||
return key
|
||||
|
||||
# No valid key found
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Failed to get key
|
||||
return None
|
||||
|
||||
|
||||
def get_key_from_config(provider: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve API key from config JSON file.
|
||||
|
||||
Reads from: /home/aipass/aipass_core/api/api_json/api_connect_config.json
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., 'openrouter')
|
||||
|
||||
Returns:
|
||||
str: API key from config or None if not found
|
||||
|
||||
Example:
|
||||
>>> key = get_key_from_config('openrouter')
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / "api_connect_config.json"
|
||||
|
||||
if not config_path.exists():
|
||||
# Config file not found
|
||||
return None
|
||||
|
||||
import json
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Navigate config structure
|
||||
if "config" in config:
|
||||
providers = config["config"].get("providers", {})
|
||||
if provider in providers:
|
||||
key = providers[provider].get("api_key", "")
|
||||
if key:
|
||||
return key
|
||||
|
||||
# No key in config file
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Error reading config
|
||||
return None
|
||||
|
||||
|
||||
def get_key_from_env(provider: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve API key from environment variable.
|
||||
|
||||
Checks os.environ for {PROVIDER}_API_KEY.
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., 'openrouter')
|
||||
|
||||
Returns:
|
||||
str: API key from environment or None if not found
|
||||
|
||||
Example:
|
||||
>>> key = get_key_from_env('openrouter')
|
||||
>>> # Checks OPENROUTER_API_KEY env variable
|
||||
"""
|
||||
env_var = f"{provider.upper()}_API_KEY"
|
||||
key = os.getenv(env_var)
|
||||
|
||||
if key:
|
||||
# Found key in environment variable
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ==============================================
|
||||
# KEY VALIDATION
|
||||
# ==============================================
|
||||
|
||||
def validate_key(key: str, provider: str = "openrouter") -> bool:
|
||||
"""
|
||||
Validate API key format for provider.
|
||||
|
||||
Checks:
|
||||
- Key is non-empty string
|
||||
- Matches provider prefix (if required)
|
||||
- Meets minimum length requirement
|
||||
|
||||
Args:
|
||||
key: API key to validate
|
||||
provider: Provider name for validation rules
|
||||
|
||||
Returns:
|
||||
bool: True if key passes validation
|
||||
|
||||
Example:
|
||||
>>> key = "sk-or-v1-abc123..."
|
||||
>>> if validate_key(key, 'openrouter'):
|
||||
... print("Valid key")
|
||||
"""
|
||||
# Basic validation
|
||||
if not key or not isinstance(key, str):
|
||||
# Invalid key type
|
||||
return False
|
||||
|
||||
# Strip whitespace
|
||||
key = key.strip()
|
||||
|
||||
# Get validation rules
|
||||
rules = get_validation_rules(provider)
|
||||
|
||||
# Check prefix if specified
|
||||
if "prefix" in rules:
|
||||
if not key.startswith(rules["prefix"]):
|
||||
# Key missing required prefix
|
||||
return False
|
||||
|
||||
# Check minimum length
|
||||
if "min_length" in rules:
|
||||
if len(key) < rules["min_length"]:
|
||||
# Key too short
|
||||
return False
|
||||
|
||||
# Key passed validation
|
||||
return True
|
||||
|
||||
|
||||
def get_validation_rules(provider: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get validation rules for provider.
|
||||
|
||||
Returns provider-specific rules or generic fallback.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
dict: Validation rules (prefix, min_length)
|
||||
|
||||
Example:
|
||||
>>> rules = get_validation_rules('openrouter')
|
||||
>>> print(rules['prefix'])
|
||||
sk-or-
|
||||
"""
|
||||
return VALIDATION_RULES.get(provider, VALIDATION_RULES["generic"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# KEY FORMAT CHECKING
|
||||
# ==============================================
|
||||
|
||||
def check_key_format(key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze key format and return details.
|
||||
|
||||
Useful for debugging key issues. Returns information about
|
||||
the key without validating against a specific provider.
|
||||
|
||||
Args:
|
||||
key: API key to analyze
|
||||
|
||||
Returns:
|
||||
dict: Key format details (length, prefix, etc.)
|
||||
|
||||
Example:
|
||||
>>> info = check_key_format('sk-or-v1-abc123')
|
||||
>>> print(info['detected_provider'])
|
||||
openrouter
|
||||
"""
|
||||
if not key or not isinstance(key, str):
|
||||
return {
|
||||
"valid": False,
|
||||
"error": "Key is not a string"
|
||||
}
|
||||
|
||||
key = key.strip()
|
||||
|
||||
# Detect provider from prefix
|
||||
detected_provider = None
|
||||
for provider, rules in VALIDATION_RULES.items():
|
||||
if provider == "generic":
|
||||
continue
|
||||
if "prefix" in rules and key.startswith(rules["prefix"]):
|
||||
detected_provider = provider
|
||||
break
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"length": len(key),
|
||||
"prefix": key[:10] if len(key) >= 10 else key,
|
||||
"detected_provider": detected_provider,
|
||||
"meets_generic_length": len(key) >= VALIDATION_RULES["generic"]["min_length"]
|
||||
}
|
||||
|
||||
|
||||
def validate_multiple_keys(keys: Dict[str, str]) -> Dict[str, bool]:
|
||||
"""
|
||||
Validate multiple provider keys at once.
|
||||
|
||||
Useful for validating entire config at once.
|
||||
|
||||
Args:
|
||||
keys: Dictionary of {provider: key}
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of {provider: is_valid}
|
||||
|
||||
Example:
|
||||
>>> keys = {'openrouter': 'sk-or-...', 'openai': 'sk-...'}
|
||||
>>> results = validate_multiple_keys(keys)
|
||||
>>> print(results)
|
||||
{'openrouter': True, 'openai': True}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for provider, key in keys.items():
|
||||
results[provider] = validate_key(key, provider)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Configuration Domain
|
||||
|
||||
Handlers for provider configuration management.
|
||||
Load, validate, and update API provider settings.
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: provider.py - Provider Configuration Handler
|
||||
# Date: 2025-11-16
|
||||
# Version: 2.0.0
|
||||
# Category: api/handlers/config
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2025-11-16): Complete extraction from api_connect.py
|
||||
# - v1.0.0 (2025-11-15): Initial handler stub
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Provider Configuration Handler
|
||||
|
||||
Manages provider configuration for API access:
|
||||
- Load provider configurations from JSON
|
||||
- Deep merge configuration updates
|
||||
- Provider defaults and validation
|
||||
- Config file management (create/update)
|
||||
- Configuration merging helpers
|
||||
|
||||
Extracted from api_connect.py archive for new handler structure.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Internal handlers
|
||||
from aipass.api.apps.handlers.json.json_handler import load_json, save_json
|
||||
|
||||
# Console for user feedback
|
||||
try:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
except ImportError:
|
||||
# Fallback console if rich not available
|
||||
class SimpleConsole:
|
||||
def print(self, *args, **kwargs):
|
||||
print(*args)
|
||||
console = SimpleConsole()
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
# Navigate: provider.py -> config/ -> handlers/ -> apps/ -> api/
|
||||
API_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
API_JSON_DIR = API_ROOT / "api_json"
|
||||
CONFIG_FILE = "api_config.json"
|
||||
|
||||
# Default provider configurations
|
||||
# NOTE: No default_model - callers must specify their own model from their branch config
|
||||
PROVIDER_DEFAULTS = {
|
||||
"openrouter": {
|
||||
"api_key": "",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"temperature": 0.7,
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"temperature": 0.7,
|
||||
"timeout_seconds": 30
|
||||
}
|
||||
}
|
||||
|
||||
# Provider validation rules
|
||||
VALIDATION_RULES = {
|
||||
"openrouter": {
|
||||
"prefix": "sk-or-v1-",
|
||||
"min_length": 40
|
||||
},
|
||||
"openai": {
|
||||
"prefix": "sk-",
|
||||
"min_length": 40
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION LOADING
|
||||
# =============================================
|
||||
|
||||
def load_provider_config(provider: str = "openrouter") -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load provider configuration from config JSON
|
||||
|
||||
Reads the main API config file and extracts provider-specific settings.
|
||||
Returns None if provider not found or config file doesn't exist.
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., "openrouter", "openai")
|
||||
|
||||
Returns:
|
||||
Provider configuration dict or None if not found
|
||||
|
||||
Example:
|
||||
config = load_provider_config("openrouter")
|
||||
# Returns: {
|
||||
# "api_key": "sk-or-v1-...",
|
||||
# "base_url": "https://openrouter.ai/api/v1",
|
||||
# "temperature": 0.7,
|
||||
# "timeout_seconds": 30
|
||||
# }
|
||||
# NOTE: No default_model - callers provide their own
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / CONFIG_FILE
|
||||
|
||||
if not config_path.exists():
|
||||
# Config file not found, creating default
|
||||
_create_default_config()
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Extract provider config from main config
|
||||
if "config" in config and "providers" in config["config"]:
|
||||
provider_config = config["config"]["providers"].get(provider)
|
||||
|
||||
if provider_config:
|
||||
# Loaded config for provider
|
||||
return provider_config
|
||||
else:
|
||||
# Provider not found in config
|
||||
return None
|
||||
else:
|
||||
# Config structure missing 'providers' section
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Invalid JSON in config file
|
||||
return None
|
||||
except Exception as e:
|
||||
# Failed to load provider config
|
||||
return None
|
||||
|
||||
|
||||
def get_full_config() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load the complete API configuration
|
||||
|
||||
Returns:
|
||||
Full config dict or None if load fails
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / CONFIG_FILE
|
||||
|
||||
if not config_path.exists():
|
||||
# Config file not found, creating default
|
||||
_create_default_config()
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
except Exception as e:
|
||||
# Failed to load full config
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION UPDATES
|
||||
# =============================================
|
||||
|
||||
def update_provider_config(provider: str, updates: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Deep merge updates into provider configuration
|
||||
|
||||
Updates the provider's configuration with new values, preserving
|
||||
existing values not specified in updates. Uses deep merge to handle
|
||||
nested dictionaries properly.
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., "openrouter")
|
||||
updates: Configuration updates to apply
|
||||
|
||||
Returns:
|
||||
True if update successful, False otherwise
|
||||
|
||||
Example:
|
||||
success = update_provider_config("openrouter", {
|
||||
"api_key": "sk-or-v1-new-key",
|
||||
"temperature": 0.8
|
||||
})
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / CONFIG_FILE
|
||||
|
||||
# Load existing config or create default
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
config = _get_default_config_structure()
|
||||
|
||||
# Ensure providers section exists
|
||||
if "config" not in config:
|
||||
config["config"] = {}
|
||||
if "providers" not in config["config"]:
|
||||
config["config"]["providers"] = {}
|
||||
|
||||
# Get or create provider config
|
||||
if provider not in config["config"]["providers"]:
|
||||
config["config"]["providers"][provider] = get_default_config(provider)
|
||||
|
||||
# Deep merge updates into provider config
|
||||
merge_configs(config["config"]["providers"][provider], updates)
|
||||
|
||||
# Update timestamp
|
||||
config["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
# Save updated config
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Updated config for provider
|
||||
console.print(f"[green]✓[/green] Provider config updated: {provider}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to update provider config
|
||||
console.print(f"[red]✗[/red] Failed to update provider config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_full_config(updates: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update the complete API configuration with deep merge
|
||||
|
||||
Args:
|
||||
updates: Configuration updates to apply
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / CONFIG_FILE
|
||||
|
||||
# Load existing or create default
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
config = _get_default_config_structure()
|
||||
|
||||
# Deep merge updates
|
||||
merge_configs(config, updates)
|
||||
|
||||
# Update timestamp
|
||||
config["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
# Save
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Updated full API config
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to update full config
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# DEFAULT CONFIGURATIONS
|
||||
# =============================================
|
||||
|
||||
def get_default_config(provider: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get default configuration for provider
|
||||
|
||||
Returns the default configuration structure for a specific provider.
|
||||
If provider not in defaults, returns empty config structure.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
Default configuration dict
|
||||
|
||||
Example:
|
||||
config = get_default_config("openrouter")
|
||||
# Returns default OpenRouter configuration
|
||||
"""
|
||||
if provider in PROVIDER_DEFAULTS:
|
||||
# Return a copy to avoid mutation
|
||||
return PROVIDER_DEFAULTS[provider].copy()
|
||||
else:
|
||||
# No default config for provider
|
||||
return {
|
||||
"api_key": "",
|
||||
"base_url": "",
|
||||
"timeout_seconds": 30
|
||||
}
|
||||
|
||||
|
||||
def _get_default_config_structure() -> Dict[str, Any]:
|
||||
"""
|
||||
Get complete default configuration structure
|
||||
|
||||
Returns:
|
||||
Default config dict with all providers
|
||||
"""
|
||||
return {
|
||||
"module_name": "api",
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"config": {
|
||||
"enabled": True,
|
||||
"auto_save": True,
|
||||
"providers": {
|
||||
"openrouter": PROVIDER_DEFAULTS["openrouter"].copy(),
|
||||
"openai": PROVIDER_DEFAULTS["openai"].copy()
|
||||
},
|
||||
"default_provider": "openrouter",
|
||||
"key_validation": VALIDATION_RULES.copy()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _create_default_config() -> bool:
|
||||
"""
|
||||
Create default configuration file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
config_path = API_JSON_DIR / CONFIG_FILE
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
default_config = _get_default_config_structure()
|
||||
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(default_config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Created default config
|
||||
console.print(f"[green]✓[/green] Created default config: {config_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to create default config
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION MERGING
|
||||
# =============================================
|
||||
|
||||
def merge_configs(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Deep merge two configuration dictionaries
|
||||
|
||||
Recursively merges 'updates' into 'base', preserving nested structures.
|
||||
Modifies 'base' in-place and also returns it for convenience.
|
||||
|
||||
For nested dicts: recursively merges
|
||||
For other types: updates overwrites base
|
||||
|
||||
Args:
|
||||
base: Base configuration dict (modified in-place)
|
||||
updates: Updates to merge in
|
||||
|
||||
Returns:
|
||||
The merged base dict (same object as input)
|
||||
|
||||
Example:
|
||||
base = {"a": 1, "b": {"c": 2, "d": 3}}
|
||||
updates = {"b": {"c": 99}, "e": 4}
|
||||
merge_configs(base, updates)
|
||||
# base is now: {"a": 1, "b": {"c": 99, "d": 3}, "e": 4}
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
|
||||
# Recursively merge nested dicts
|
||||
merge_configs(base[key], value)
|
||||
else:
|
||||
# Overwrite with new value
|
||||
base[key] = value
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# =============================================
|
||||
# VALIDATION HELPERS
|
||||
# =============================================
|
||||
|
||||
def get_validation_rules(provider: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get validation rules for provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
Validation rules dict or None if not defined
|
||||
"""
|
||||
return VALIDATION_RULES.get(provider)
|
||||
|
||||
|
||||
def list_available_providers() -> list[str]:
|
||||
"""
|
||||
List all available providers with defaults
|
||||
|
||||
Returns:
|
||||
List of provider names
|
||||
"""
|
||||
return list(PROVIDER_DEFAULTS.keys())
|
||||
|
||||
|
||||
def provider_exists(provider: str) -> bool:
|
||||
"""
|
||||
Check if provider exists in configuration
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
True if provider configured, False otherwise
|
||||
"""
|
||||
config = load_provider_config(provider)
|
||||
return config is not None
|
||||
@@ -0,0 +1 @@
|
||||
"""JSON Handlers - Universal JSON operations for Seed branch"""
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: json_handler.py - JSON Auto-Creating Handler
|
||||
# Date: 2025-11-21
|
||||
# Version: 1.1.0
|
||||
# Category: api/handlers/json
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2025-11-21): Refactored to comply with error handling
|
||||
# - v1.0.0 (2025-11-13): Initial JSON auto-creation system
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
# Infrastructure
|
||||
|
||||
# Constants
|
||||
API_ROOT = Path.home() / "aipass_core" / "api"
|
||||
API_JSON_DIR = API_ROOT / "api_json"
|
||||
JSON_TEMPLATES_DIR = API_ROOT / "apps" / "json_templates"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""
|
||||
Auto-detect calling module name from call stack
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "imports_standard" from imports_standard.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
# Validate module name
|
||||
if module_name and not module_name.startswith('_'):
|
||||
return module_name
|
||||
|
||||
# Fallback
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_template(json_type: str, module_name: str) -> Any:
|
||||
"""Load JSON template from template file"""
|
||||
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
|
||||
|
||||
if not template_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
template = json.load(f)
|
||||
|
||||
# Replace placeholders
|
||||
template_str = json.dumps(template)
|
||||
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
|
||||
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
|
||||
|
||||
return json.loads(template_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type"""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file"""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return API_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing"""
|
||||
API_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
else:
|
||||
pass # Corrupted - regenerating
|
||||
except Exception:
|
||||
pass # Unreadable - regenerating
|
||||
|
||||
template = load_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing"""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file"""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module"""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""
|
||||
Add entry to module log with automatic rotation
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Auto-detect module name if not provided
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
# Load config to get max_log_entries
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100 # Default
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
# Load existing log
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
# Create new entry
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation
|
||||
}
|
||||
|
||||
if data:
|
||||
entry["data"] = data # type: ignore[assignment]
|
||||
|
||||
# Add new entry
|
||||
log.append(entry)
|
||||
|
||||
# Rotate if exceeds max (keep most recent entries)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
|
||||
"""Increment a counter in data JSON"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
if counter_name not in data:
|
||||
data[counter_name] = 0
|
||||
|
||||
data[counter_name] += amount
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
def update_data_metrics(module_name: str, **metrics) -> bool:
|
||||
"""Update data metrics"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
for key, value in metrics.items():
|
||||
data[key] = value
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print(Panel.fit(
|
||||
"[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]",
|
||||
border_style="bright_blue"
|
||||
))
|
||||
console.print()
|
||||
console.print("[yellow]TESTING:[/yellow] Creating API JSONs...")
|
||||
|
||||
# Test auto-creation
|
||||
log_operation("test_operation", {"test": "data"}, "api")
|
||||
increment_counter("api", "test_counter", 1)
|
||||
update_data_metrics("api", test_metric="working")
|
||||
|
||||
console.print()
|
||||
console.print("[green]Check /home/aipass/aipass_core/api/api_json/ for created files:[/green]")
|
||||
console.print(" [dim]•[/dim] api_config.json")
|
||||
console.print(" [dim]•[/dim] api_data.json")
|
||||
console.print(" [dim]•[/dim] api_log.json")
|
||||
console.print()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
OpenRouter Domain
|
||||
|
||||
Handlers for OpenRouter LLM API client operations.
|
||||
Client creation, caller detection, model fetching, and config provisioning.
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: caller.py
|
||||
# Date: 2025-11-16
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.0.0 (2025-11-16): Initial caller detection handler extracted from archive
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
OpenRouter Caller Detection Handler
|
||||
|
||||
Stack-based caller detection with JSON folder path resolution.
|
||||
Supports flow, prax, and skills module detection.
|
||||
|
||||
Usage:
|
||||
from aipass.api.apps.handlers.openrouter.caller import get_caller_info
|
||||
|
||||
caller_info = get_caller_info()
|
||||
if caller_info:
|
||||
caller_name = caller_info['caller_name']
|
||||
json_folder = caller_info['json_folder']
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import inspect
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "openrouter.caller"
|
||||
|
||||
# Package root: caller.py -> openrouter/ -> handlers/ -> apps/ -> api/ -> aipass/
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent
|
||||
MODULE_VERSION = "1.0.0"
|
||||
|
||||
CALLER_PATTERNS = {
|
||||
"flow": "flow_json",
|
||||
"prax": "prax_json",
|
||||
"skills": "{category}_json",
|
||||
}
|
||||
|
||||
# =============================================
|
||||
# CALLER DETECTION FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def get_caller_info() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Detect calling module via stack inspection.
|
||||
|
||||
Returns dict with: caller_name, caller_path, json_folder, category, detection_method
|
||||
Returns None if detection fails.
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
|
||||
for frame_info in stack[1:]:
|
||||
frame_path = Path(frame_info.filename)
|
||||
|
||||
if "flow" in frame_path.parts:
|
||||
return _detect_flow_caller(frame_path)
|
||||
elif "prax" in frame_path.parts:
|
||||
return _detect_prax_caller(frame_path)
|
||||
elif any("skills" in part for part in frame_path.parts):
|
||||
return _detect_skills_caller(frame_path)
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Caller detection failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_caller_name_from_stack() -> Optional[str]:
|
||||
"""Extract caller name from call stack (simplified version)."""
|
||||
caller_info = get_caller_info()
|
||||
return caller_info.get('caller_name') if caller_info else None
|
||||
|
||||
|
||||
def detect_caller_from_stack() -> Tuple[Optional[str], Optional[Path]]:
|
||||
"""
|
||||
Compatibility wrapper for provision handler.
|
||||
|
||||
Returns:
|
||||
Tuple of (caller_name, json_folder_path) or (None, None)
|
||||
"""
|
||||
caller_info = get_caller_info()
|
||||
if caller_info:
|
||||
return caller_info.get('caller_name'), caller_info.get('json_folder')
|
||||
return None, None
|
||||
|
||||
|
||||
def get_json_folder_path(caller: str) -> Optional[Path]:
|
||||
"""
|
||||
Determine JSON folder path for given caller name.
|
||||
Fallback method when stack detection doesn't provide path.
|
||||
"""
|
||||
try:
|
||||
if caller.startswith("flow_"):
|
||||
base_path = _PACKAGE_ROOT / "flow"
|
||||
json_folder = base_path / "flow_json"
|
||||
|
||||
elif caller.startswith("prax_"):
|
||||
base_path = _PACKAGE_ROOT / "prax"
|
||||
json_folder = base_path / "prax_json"
|
||||
|
||||
elif caller.startswith("skills_"):
|
||||
parts = caller.split("_")
|
||||
if len(parts) >= 2:
|
||||
skills_category = parts[1]
|
||||
base_path = _PACKAGE_ROOT / "skills" / f"skills_{skills_category}"
|
||||
json_folder = base_path / f"{skills_category}_json"
|
||||
else:
|
||||
# logger.info(f"[{MODULE_NAME}] Cannot parse skills category from: {caller}")
|
||||
return None
|
||||
else:
|
||||
# logger.info(f"[{MODULE_NAME}] Unknown caller pattern: {caller}")
|
||||
return None
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Resolved JSON folder for {caller}: {json_folder}")
|
||||
return json_folder
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to determine JSON folder for {caller}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def detect_caller_category(caller_path: Path) -> str:
|
||||
"""Categorize caller based on file path."""
|
||||
try:
|
||||
path_parts = caller_path.parts
|
||||
|
||||
if "flow" in path_parts:
|
||||
return "flow"
|
||||
elif "prax" in path_parts:
|
||||
return "prax"
|
||||
elif any("skills" in part for part in path_parts):
|
||||
return "skills"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to detect category for {caller_path}: {e}")
|
||||
return "unknown"
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTERNAL DETECTION HELPERS
|
||||
# =============================================
|
||||
|
||||
def _detect_flow_caller(frame_path: Path) -> Dict[str, Any]:
|
||||
"""Detect flow module caller from stack frame path."""
|
||||
try:
|
||||
flow_index = frame_path.parts.index("flow")
|
||||
flow_path = Path(*frame_path.parts[:flow_index + 1])
|
||||
json_folder_path = flow_path / "flow_json"
|
||||
caller_name = frame_path.stem
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Detected flow caller: {caller_name}")
|
||||
|
||||
return {
|
||||
"caller_name": caller_name,
|
||||
"caller_path": frame_path,
|
||||
"json_folder": json_folder_path,
|
||||
"category": "flow",
|
||||
"detection_method": "stack"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to detect flow caller: {e}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
|
||||
def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]:
|
||||
"""Detect prax module caller from stack frame path."""
|
||||
try:
|
||||
prax_index = frame_path.parts.index("prax")
|
||||
prax_path = Path(*frame_path.parts[:prax_index + 1])
|
||||
json_folder_path = prax_path / "prax_json"
|
||||
caller_name = frame_path.stem
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Detected prax caller: {caller_name}")
|
||||
|
||||
return {
|
||||
"caller_name": caller_name,
|
||||
"caller_path": frame_path,
|
||||
"json_folder": json_folder_path,
|
||||
"category": "prax",
|
||||
"detection_method": "stack"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to detect prax caller: {e}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
|
||||
def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect skills module caller from stack frame path.
|
||||
Skills have category subdirectories (e.g., /skills/skills_api/skill.py)
|
||||
"""
|
||||
try:
|
||||
for i, part in enumerate(frame_path.parts):
|
||||
if "skills" in part:
|
||||
skills_path = Path(*frame_path.parts[:i + 2])
|
||||
category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api"
|
||||
json_folder_path = skills_path / f"{category}_json"
|
||||
caller_name = frame_path.stem
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})")
|
||||
|
||||
return {
|
||||
"caller_name": caller_name,
|
||||
"caller_path": frame_path,
|
||||
"json_folder": json_folder_path,
|
||||
"category": "skills",
|
||||
"skills_category": category,
|
||||
"detection_method": "stack"
|
||||
}
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to detect skills caller: {e}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
|
||||
def _create_fallback_info(frame_path: Path) -> Dict[str, Any]:
|
||||
"""Create fallback caller info when detection fails."""
|
||||
caller_name = frame_path.stem
|
||||
category = detect_caller_category(frame_path)
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Using fallback detection for: {caller_name}")
|
||||
|
||||
return {
|
||||
"caller_name": caller_name,
|
||||
"caller_path": frame_path,
|
||||
"json_folder": None,
|
||||
"category": category,
|
||||
"detection_method": "fallback"
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# VALIDATION HELPERS
|
||||
# =============================================
|
||||
|
||||
def validate_caller_info(caller_info: Dict[str, Any]) -> bool:
|
||||
"""Validate caller information dictionary."""
|
||||
try:
|
||||
required_fields = ["caller_name", "caller_path", "category", "detection_method"]
|
||||
for field in required_fields:
|
||||
if field not in caller_info:
|
||||
# logger.info(f"[{MODULE_NAME}] Missing required field: {field}")
|
||||
return False
|
||||
|
||||
if not caller_info["caller_name"]:
|
||||
# logger.info(f"[{MODULE_NAME}] Caller name is empty")
|
||||
return False
|
||||
|
||||
if not isinstance(caller_info["caller_path"], Path):
|
||||
# logger.info(f"[{MODULE_NAME}] Caller path is not a Path object")
|
||||
return False
|
||||
|
||||
valid_categories = ["flow", "prax", "skills", "unknown"]
|
||||
if caller_info["category"] not in valid_categories:
|
||||
# logger.info(f"[{MODULE_NAME}] Invalid category: {caller_info['category']}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Validation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# MODULE INITIALIZATION
|
||||
# =============================================
|
||||
|
||||
def _initialize():
|
||||
"""Initialize caller detection module."""
|
||||
# logger.info(f"[{MODULE_NAME}] Caller detection handler loaded (v{MODULE_VERSION})")
|
||||
pass
|
||||
|
||||
_initialize()
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: client.py - OpenRouter Client Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 3.0.0
|
||||
# Category: api/handlers/openrouter
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v3.0.0 (2026-02-20): Fallback model chain in get_response() + retry logic in make_api_request()
|
||||
# - v2.0.0 (2025-11-16): Complete extraction from archive - client creation, API requests, response handling
|
||||
# - v1.0.0 (2025-11-15): Initial handler stub
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
OpenRouter Client Handler
|
||||
|
||||
Business logic for OpenRouter API client creation and request execution.
|
||||
Extracted from archive.temp/openrouter.py following AIPASS standards.
|
||||
|
||||
Functions:
|
||||
- get_response() - Main API call with tracking integration
|
||||
- create_client() - Create OpenAI SDK client configured for OpenRouter
|
||||
- make_api_request() - Execute API request and handle errors
|
||||
- extract_response() - Extract text and metadata from API response
|
||||
|
||||
Configuration:
|
||||
- base_url: https://openrouter.ai/api/v1
|
||||
- Uses OpenAI SDK with OpenRouter endpoint
|
||||
- Supports all 323+ OpenRouter models
|
||||
- Connection pooling via client caching
|
||||
|
||||
Standards:
|
||||
- Uses console.print() for user output (NO print())
|
||||
- Uses logger.info() for system logging
|
||||
- Integrates with auth/keys, caller detection, usage tracking handlers
|
||||
- Standalone functions (no classes)
|
||||
- Complete error handling with graceful failures
|
||||
"""
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import time
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
# AIPASS imports
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# OpenAI SDK for OpenRouter compatibility
|
||||
try:
|
||||
from openai import OpenAI
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
# logger.error("OpenAI SDK not available. Install with: pip install openai")
|
||||
OPENAI_AVAILABLE = False
|
||||
|
||||
# Handler imports
|
||||
from aipass.api.apps.handlers.auth.keys import get_api_key
|
||||
from aipass.api.apps.handlers.openrouter.caller import get_caller_info
|
||||
from aipass.api.apps.handlers.usage.tracking import track_usage
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# NOTE: No default model - callers must specify their own model from their branch config
|
||||
|
||||
# HTTP headers for OpenRouter
|
||||
OPENROUTER_HEADERS = {
|
||||
"HTTP-Referer": "https://aipass.local",
|
||||
"X-Title": "AIPass API Client"
|
||||
}
|
||||
|
||||
# Client cache for connection pooling
|
||||
_client_cache: Dict[str, OpenAI] = {}
|
||||
MAX_CACHED_CLIENTS = 5
|
||||
|
||||
# =============================================
|
||||
# CLIENT CREATION
|
||||
# =============================================
|
||||
|
||||
def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]:
|
||||
"""
|
||||
Create OpenAI SDK client configured for OpenRouter.
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API key
|
||||
base_url: OpenRouter base URL (default: https://openrouter.ai/api/v1)
|
||||
timeout: Request timeout in seconds (default: 30)
|
||||
|
||||
Returns:
|
||||
OpenAI client instance or None on failure
|
||||
|
||||
Example:
|
||||
>>> api_key = get_api_key("openrouter")
|
||||
>>> client = create_client(api_key)
|
||||
>>> if client:
|
||||
... # Use client for requests
|
||||
"""
|
||||
if not OPENAI_AVAILABLE:
|
||||
# logger.error("OpenAI SDK not installed - cannot create client")
|
||||
console.print("[red]Error: OpenAI SDK not installed. Run: pip install openai[/red]")
|
||||
return None
|
||||
|
||||
if not api_key:
|
||||
# logger.error("Cannot create client - no API key provided")
|
||||
console.print("[red]Error: API key required for client creation[/red]")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create OpenAI client with OpenRouter configuration
|
||||
client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
default_headers=OPENROUTER_HEADERS
|
||||
)
|
||||
|
||||
# logger.info(f"Created OpenRouter client - base_url: {base_url}, timeout: {timeout}s")
|
||||
return client
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to create OpenRouter client: {e}")
|
||||
console.print(f"[red]Error creating OpenRouter client: {e}[/red]")
|
||||
return None
|
||||
|
||||
|
||||
def get_cached_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]:
|
||||
"""
|
||||
Get cached OpenAI client or create new one if not cached.
|
||||
Implements connection pooling for better performance.
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API key
|
||||
base_url: OpenRouter base URL
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
OpenAI client instance or None on failure
|
||||
|
||||
Note:
|
||||
Cache is limited to MAX_CACHED_CLIENTS (5) to prevent memory growth.
|
||||
Oldest clients are removed when cache is full.
|
||||
"""
|
||||
global _client_cache
|
||||
|
||||
# Check if we have a cached client for this API key
|
||||
if api_key in _client_cache:
|
||||
cached_client = _client_cache[api_key]
|
||||
# Verify cached client is still valid
|
||||
if cached_client and cached_client.api_key == api_key:
|
||||
# logger.info("Using cached OpenRouter client")
|
||||
return cached_client
|
||||
|
||||
# Create new client
|
||||
client = create_client(api_key, base_url, timeout)
|
||||
|
||||
if not client:
|
||||
return None
|
||||
|
||||
# Cache the client (limit cache size)
|
||||
if len(_client_cache) >= MAX_CACHED_CLIENTS:
|
||||
# Remove oldest client (first key in dict)
|
||||
oldest_key = next(iter(_client_cache))
|
||||
del _client_cache[oldest_key]
|
||||
# logger.info(f"Removed oldest cached client - cache limit: {MAX_CACHED_CLIENTS}")
|
||||
|
||||
_client_cache[api_key] = client
|
||||
# logger.info("Cached new OpenRouter client")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
# =============================================
|
||||
# API REQUEST EXECUTION
|
||||
# =============================================
|
||||
|
||||
def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: int = 1, **kwargs) -> Optional[Any]:
|
||||
"""
|
||||
Execute API request via OpenRouter with retry logic.
|
||||
|
||||
Args:
|
||||
client: OpenAI client instance
|
||||
messages: Chat messages in OpenAI format [{"role": "user", "content": "..."}]
|
||||
model: Model identifier (e.g., "anthropic/claude-3.5-sonnet")
|
||||
retries: Number of retries on failure (default: 1, so 2 total attempts)
|
||||
**kwargs: Additional OpenAI API parameters (temperature, max_tokens, etc.)
|
||||
|
||||
Returns:
|
||||
OpenAI response object or None on failure
|
||||
"""
|
||||
if not client or not messages or not model:
|
||||
return None
|
||||
|
||||
api_params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1 + retries):
|
||||
try:
|
||||
response = client.chat.completions.create(**api_params)
|
||||
if attempt > 0:
|
||||
print(f"[INFO] API request succeeded on retry {attempt} for model {model}")
|
||||
return response
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < retries:
|
||||
delay = 1.0 * (attempt + 1) # 1s, 2s, ...
|
||||
print(f"[INFO] API request failed for {model} (attempt {attempt + 1}/{1 + retries}): {e} — retrying in {delay:.0f}s")
|
||||
time.sleep(delay)
|
||||
|
||||
print(f"[INFO] API request failed for {model} after {1 + retries} attempts: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_response(response: Any) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract text content and metadata from API response.
|
||||
|
||||
Args:
|
||||
response: OpenAI response object
|
||||
|
||||
Returns:
|
||||
Dict with 'content' (str), 'id' (str), 'model' (str) or None on failure
|
||||
|
||||
Example:
|
||||
>>> response = make_api_request(client, messages, model)
|
||||
>>> data = extract_response(response)
|
||||
>>> if data:
|
||||
... print(data['content'])
|
||||
... track_usage(caller, data['id'], data['model'], api_key)
|
||||
"""
|
||||
if not response:
|
||||
# logger.error("Cannot extract response - no response provided")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Validate response structure
|
||||
if not hasattr(response, 'choices') or not response.choices:
|
||||
# logger.error("Invalid response structure - no choices available")
|
||||
return None
|
||||
|
||||
if not hasattr(response.choices[0], 'message'):
|
||||
# logger.error("Invalid response structure - no message in choice")
|
||||
return None
|
||||
|
||||
# Extract content
|
||||
content = response.choices[0].message.content
|
||||
|
||||
if not content:
|
||||
# logger.warning("Response has no content")
|
||||
return None
|
||||
|
||||
# Extract metadata
|
||||
result = {
|
||||
"content": content,
|
||||
"id": response.id if hasattr(response, 'id') else None,
|
||||
"model": response.model if hasattr(response, 'model') else None,
|
||||
"finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None
|
||||
}
|
||||
|
||||
# logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to extract response: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN API CALL
|
||||
# =============================================
|
||||
|
||||
def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] = None, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Main API call - get response from OpenRouter with full tracking integration.
|
||||
|
||||
This is the primary entry point that integrates all handlers:
|
||||
- Detects caller automatically if not provided
|
||||
- Retrieves API key via auth/keys handler
|
||||
- Creates/caches client
|
||||
- Makes API request
|
||||
- Extracts response
|
||||
- Tracks usage via usage/tracking handler
|
||||
|
||||
Args:
|
||||
prompt: User prompt text
|
||||
caller: Module making the request (auto-detected if not provided)
|
||||
model: Model to use (required - caller must provide from branch config)
|
||||
**kwargs: Additional OpenAI API parameters
|
||||
|
||||
Returns:
|
||||
Dict with 'content', 'id', 'model' or None on failure
|
||||
|
||||
Example:
|
||||
>>> response = get_response("What is Python?", caller="cli", model="anthropic/claude-3.5-sonnet")
|
||||
>>> if response:
|
||||
... print(response['content'])
|
||||
"""
|
||||
# Step 1: Detect caller if not provided
|
||||
if not caller:
|
||||
caller_info = get_caller_info()
|
||||
if caller_info and caller_info.get("caller_name"):
|
||||
caller = caller_info["caller_name"]
|
||||
# logger.info(f"Auto-detected caller: {caller}")
|
||||
else:
|
||||
# logger.warning("Could not detect caller - using 'unknown'")
|
||||
caller = "unknown"
|
||||
|
||||
# Step 2: Require model from caller - no defaults
|
||||
if not model:
|
||||
# logger.error("No model specified - caller must provide model from their branch config")
|
||||
console.print("[red]Error: No model specified.[/red]")
|
||||
console.print("[yellow]Callers must provide their own model via branch config (e.g., flow_json/openrouter_config.json)[/yellow]")
|
||||
return None
|
||||
|
||||
# Step 3: Get API key
|
||||
api_key = get_api_key("openrouter")
|
||||
if not api_key:
|
||||
# logger.error("Cannot get response - no API key available")
|
||||
console.print("[red]Error: No OpenRouter API key available[/red]")
|
||||
return None
|
||||
|
||||
# Step 4: Get or create client
|
||||
client = get_cached_client(api_key)
|
||||
if not client:
|
||||
# logger.error("Cannot get response - client creation failed")
|
||||
return None
|
||||
|
||||
# Step 5: Convert prompt to messages format
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# Step 6: Make API request
|
||||
response = make_api_request(client, messages, model, **kwargs)
|
||||
if not response:
|
||||
# logger.error(f"API request failed - caller: {caller}, model: {model}")
|
||||
return None
|
||||
|
||||
# Step 7: Extract response
|
||||
result = extract_response(response)
|
||||
if not result:
|
||||
# logger.error("Response extraction failed")
|
||||
return None
|
||||
|
||||
# Step 8: Track usage (if response has ID)
|
||||
if result.get("id"):
|
||||
try:
|
||||
track_usage(result["id"], caller if caller else "unknown", model, api_key)
|
||||
except Exception as e:
|
||||
# logger.warning(f"Usage tracking failed: {e}")
|
||||
# Don't fail the request if tracking fails
|
||||
pass
|
||||
|
||||
# logger.info(f"Successfully got response - caller: {caller}, model: {model}, length: {len(result['content'])} chars")
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLEANUP
|
||||
# =============================================
|
||||
|
||||
def clear_client_cache() -> None:
|
||||
"""
|
||||
Clear all cached clients.
|
||||
Useful for testing or when API keys change.
|
||||
"""
|
||||
global _client_cache
|
||||
count = len(_client_cache)
|
||||
_client_cache.clear()
|
||||
# logger.info(f"Cleared {count} cached clients")
|
||||
console.print(f"[green]Cleared {count} cached OpenRouter clients[/green]")
|
||||
|
||||
|
||||
def get_cache_stats() -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the client cache.
|
||||
|
||||
Returns:
|
||||
Dict with cache size and keys
|
||||
"""
|
||||
return {
|
||||
"cached_clients": len(_client_cache),
|
||||
"max_cache_size": MAX_CACHED_CLIENTS,
|
||||
"cache_keys": list(_client_cache.keys())
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: models.py - OpenRouter Model Management
|
||||
# Date: 2025-11-16
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-16): Complete handler - model fetching, filtering, pricing
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
OpenRouter Model Management Handler
|
||||
|
||||
Business logic for querying and filtering OpenRouter models:
|
||||
- Fetch all available models from OpenRouter API
|
||||
- Filter models by pricing (free models)
|
||||
- Parse model data and capabilities
|
||||
- Extract model metadata (context, pricing, capabilities)
|
||||
|
||||
Extracted from:
|
||||
- /home/aipass/aipass_core/api/apps/archive.temp/openrouter.py
|
||||
- /home/aipass/aipass_core/api/.archive/find_free_models.py
|
||||
"""
|
||||
|
||||
# AIPASS_ROOT import pattern
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# Console import (NO print())
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# Third-party imports
|
||||
import requests
|
||||
|
||||
# Internal imports
|
||||
from aipass.api.apps.handlers.auth.keys import get_api_key
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
|
||||
DEFAULT_TIMEOUT = 10
|
||||
MODULE_NAME = "openrouter.models"
|
||||
|
||||
|
||||
# =============================================
|
||||
# CORE FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def get_available_models(api_key: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Fetch all models from OpenRouter API
|
||||
|
||||
Returns full model data including pricing, context length,
|
||||
and capabilities for all available models.
|
||||
|
||||
Args:
|
||||
api_key: Optional OpenRouter API key (fetches from keys handler if None)
|
||||
|
||||
Returns:
|
||||
List of model dictionaries with full metadata, empty list on failure
|
||||
|
||||
Example:
|
||||
>>> models = get_available_models()
|
||||
>>> console.print(f"Found {len(models)} models")
|
||||
"""
|
||||
try:
|
||||
# Get API key if not provided
|
||||
if not api_key:
|
||||
api_key = get_api_key("openrouter")
|
||||
|
||||
if not api_key:
|
||||
# logger.info(f"[{MODULE_NAME}] No API key available for OpenRouter")
|
||||
console.print("[yellow]No OpenRouter API key found[/yellow]")
|
||||
return []
|
||||
|
||||
# Fetch models from API
|
||||
models = fetch_models_from_api(api_key)
|
||||
|
||||
if models:
|
||||
# logger.info(f"[{MODULE_NAME}] Fetched {len(models)} models from OpenRouter")
|
||||
return models
|
||||
else:
|
||||
# logger.info(f"[{MODULE_NAME}] No models returned from API")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Failed to get available models: {e}")
|
||||
console.print(f"[red]Error fetching models: {e}[/red]")
|
||||
return []
|
||||
|
||||
|
||||
def get_free_models(api_key: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Fetch only free models ($0 pricing) from OpenRouter
|
||||
|
||||
Filters models where both prompt and completion costs are zero.
|
||||
Useful for finding models that can be used without charges.
|
||||
|
||||
Args:
|
||||
api_key: Optional OpenRouter API key (fetches from keys handler if None)
|
||||
|
||||
Returns:
|
||||
List of free model dictionaries with full metadata, empty list on failure
|
||||
|
||||
Example:
|
||||
>>> free_models = get_free_models()
|
||||
>>> for model in free_models:
|
||||
... console.print(f"Free: {model['id']}")
|
||||
"""
|
||||
try:
|
||||
# Get all models first
|
||||
all_models = get_available_models(api_key)
|
||||
|
||||
if not all_models:
|
||||
return []
|
||||
|
||||
# Filter for free models
|
||||
free_models = filter_by_pricing(all_models, max_cost=0.0)
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Found {len(free_models)} free models")
|
||||
return free_models
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Failed to get free models: {e}")
|
||||
console.print(f"[red]Error fetching free models: {e}[/red]")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_models_from_api(api_key: str) -> List[Dict]:
|
||||
"""
|
||||
Query OpenRouter models endpoint and parse response
|
||||
|
||||
Makes HTTP request to OpenRouter API and extracts model data.
|
||||
Handles authentication, timeouts, and error responses.
|
||||
|
||||
Args:
|
||||
api_key: Valid OpenRouter API key
|
||||
|
||||
Returns:
|
||||
List of model dictionaries, empty list on failure
|
||||
|
||||
Raises:
|
||||
No exceptions raised - returns empty list on all errors
|
||||
"""
|
||||
try:
|
||||
# Prepare request headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Make API request
|
||||
# logger.info(f"[{MODULE_NAME}] Requesting models from OpenRouter API")
|
||||
response = requests.get(
|
||||
OPENROUTER_API_URL,
|
||||
headers=headers,
|
||||
timeout=DEFAULT_TIMEOUT
|
||||
)
|
||||
|
||||
# Check response status
|
||||
if response.status_code != 200:
|
||||
# logger.info(f"[{MODULE_NAME}] API request failed with status {response.status_code}")
|
||||
console.print(f"[red]OpenRouter API error: {response.status_code}[/red]")
|
||||
return []
|
||||
|
||||
# Parse JSON response
|
||||
data = response.json()
|
||||
|
||||
# Extract models from response
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
models = data["data"]
|
||||
# logger.info(f"[{MODULE_NAME}] Successfully parsed {len(models)} models")
|
||||
return models
|
||||
else:
|
||||
# logger.info(f"[{MODULE_NAME}] Invalid response format - no 'data' field")
|
||||
return []
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# logger.info(f"[{MODULE_NAME}] API request timeout after {DEFAULT_TIMEOUT}s")
|
||||
console.print(f"[red]Request timeout - OpenRouter API not responding[/red]")
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Network error: {e}")
|
||||
console.print(f"[red]Network error: {e}[/red]")
|
||||
return []
|
||||
|
||||
except ValueError as e:
|
||||
# logger.info(f"[{MODULE_NAME}] JSON parse error: {e}")
|
||||
console.print(f"[red]Invalid JSON response from API[/red]")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Unexpected error fetching models: {e}")
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
return []
|
||||
|
||||
|
||||
def filter_by_pricing(models: List[Dict], max_cost: float = 0.0) -> List[Dict]:
|
||||
"""
|
||||
Filter models by maximum pricing threshold
|
||||
|
||||
Filters models where both prompt and completion costs are at or
|
||||
below the specified maximum. Default of 0.0 returns only free models.
|
||||
|
||||
Args:
|
||||
models: List of model dictionaries from API
|
||||
max_cost: Maximum cost threshold (0.0 for free only)
|
||||
|
||||
Returns:
|
||||
Filtered list of models matching pricing criteria
|
||||
|
||||
Example:
|
||||
>>> all_models = get_available_models()
|
||||
>>> free = filter_by_pricing(all_models, 0.0)
|
||||
>>> cheap = filter_by_pricing(all_models, 0.0001)
|
||||
"""
|
||||
filtered = []
|
||||
|
||||
try:
|
||||
for model in models:
|
||||
# Extract pricing data
|
||||
pricing = model.get("pricing", {})
|
||||
|
||||
# Convert pricing to float (handles string values)
|
||||
try:
|
||||
prompt_cost = float(pricing.get("prompt", "0"))
|
||||
completion_cost = float(pricing.get("completion", "0"))
|
||||
except (ValueError, TypeError):
|
||||
# Skip models with invalid pricing data
|
||||
continue
|
||||
|
||||
# Check if both costs are at or below threshold
|
||||
if prompt_cost <= max_cost and completion_cost <= max_cost:
|
||||
filtered.append(model)
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Filtered {len(filtered)}/{len(models)} models at max_cost={max_cost}")
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Error filtering models: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_model_by_id(model_id: str, api_key: Optional[str] = None) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch specific model details by ID
|
||||
|
||||
Args:
|
||||
model_id: Model identifier (e.g., "meta-llama/llama-3.3-70b-instruct:free")
|
||||
api_key: Optional OpenRouter API key
|
||||
|
||||
Returns:
|
||||
Model dictionary if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
all_models = get_available_models(api_key)
|
||||
|
||||
for model in all_models:
|
||||
if model.get("id") == model_id:
|
||||
# logger.info(f"[{MODULE_NAME}] Found model: {model_id}")
|
||||
return model
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Model not found: {model_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Error finding model {model_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_model_metadata(model: Dict) -> Dict:
|
||||
"""
|
||||
Extract key metadata from model dictionary
|
||||
|
||||
Parses model data and extracts commonly used fields into
|
||||
a simplified structure for easier consumption.
|
||||
|
||||
Args:
|
||||
model: Raw model dictionary from API
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted metadata fields
|
||||
|
||||
Example:
|
||||
>>> model = get_model_by_id("meta-llama/llama-3.3-70b-instruct:free")
|
||||
>>> meta = extract_model_metadata(model)
|
||||
>>> console.print(f"Context: {meta['context_length']}")
|
||||
"""
|
||||
try:
|
||||
pricing = model.get("pricing", {})
|
||||
|
||||
metadata = {
|
||||
"id": model.get("id", ""),
|
||||
"name": model.get("name", ""),
|
||||
"context_length": model.get("context_length", 0),
|
||||
"prompt_cost": float(pricing.get("prompt", "0")),
|
||||
"completion_cost": float(pricing.get("completion", "0")),
|
||||
"is_free": (
|
||||
float(pricing.get("prompt", "0")) == 0.0 and
|
||||
float(pricing.get("completion", "0")) == 0.0
|
||||
),
|
||||
"architecture": model.get("architecture", {}),
|
||||
"top_provider": model.get("top_provider", {}),
|
||||
"description": model.get("description", "")
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Error extracting metadata: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def list_model_ids(models: List[Dict]) -> List[str]:
|
||||
"""
|
||||
Extract just the model IDs from a list of model dictionaries
|
||||
|
||||
Args:
|
||||
models: List of model dictionaries
|
||||
|
||||
Returns:
|
||||
List of model ID strings
|
||||
"""
|
||||
try:
|
||||
return [model.get("id", "") for model in models if model.get("id")]
|
||||
except Exception as e:
|
||||
# logger.info(f"[{MODULE_NAME}] Error extracting IDs: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def display_models(models: List[Dict], show_pricing: bool = True) -> None:
|
||||
"""
|
||||
Display models in formatted output using console
|
||||
|
||||
Args:
|
||||
models: List of model dictionaries to display
|
||||
show_pricing: Whether to show pricing information
|
||||
"""
|
||||
if not models:
|
||||
console.print("[yellow]No models to display[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Found {len(models)} models:[/bold]\n")
|
||||
|
||||
for i, model in enumerate(models, 1):
|
||||
model_id = model.get("id", "unknown")
|
||||
name = model.get("name", "")
|
||||
context = model.get("context_length", 0)
|
||||
|
||||
console.print(f"[cyan]{i}. {model_id}[/cyan]")
|
||||
if name:
|
||||
console.print(f" Name: {name}")
|
||||
console.print(f" Context: {context:,} tokens")
|
||||
|
||||
if show_pricing:
|
||||
pricing = model.get("pricing", {})
|
||||
prompt_cost = pricing.get("prompt", "0")
|
||||
completion_cost = pricing.get("completion", "0")
|
||||
|
||||
if prompt_cost == "0" and completion_cost == "0":
|
||||
console.print(" [green]FREE[/green]")
|
||||
else:
|
||||
console.print(f" Prompt: ${prompt_cost} / Completion: ${completion_cost}")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def display_free_models_summary(api_key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Fetch and display summary of free models
|
||||
|
||||
Convenience function that fetches free models and displays
|
||||
them in a formatted, user-friendly way.
|
||||
|
||||
Args:
|
||||
api_key: Optional OpenRouter API key
|
||||
"""
|
||||
console.print("[bold]Searching for FREE models on OpenRouter...[/bold]")
|
||||
console.print("=" * 60)
|
||||
|
||||
free_models = get_free_models(api_key)
|
||||
|
||||
if not free_models:
|
||||
console.print("[yellow]No free models found[/yellow]")
|
||||
console.print("OpenRouter may have changed pricing.")
|
||||
console.print("Alternative: Use very cheap models like openai/gpt-4o-mini")
|
||||
return
|
||||
|
||||
display_models(free_models, show_pricing=False)
|
||||
|
||||
console.print("\n[bold]RECOMMENDED FREE MODELS TO TRY:[/bold]")
|
||||
console.print("-" * 60)
|
||||
for model in free_models[:5]: # Top 5
|
||||
console.print(f" - {model.get('id', '')}")
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: provision.py - Caller Auto-Provisioning Handler
|
||||
# Date: 2025-11-16
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/openrouter
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-16): Initial handler - auto-provision caller configs
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Caller Auto-Provisioning Handler
|
||||
|
||||
Business logic for provisioning OpenRouter API configs:
|
||||
- Auto-create caller API configurations
|
||||
- Provision JSON folder structure
|
||||
- Set default model/temperature/max_tokens
|
||||
- Initialize caller-specific tracking files
|
||||
- Ensure caller has complete 3-file JSON structure
|
||||
|
||||
COMPLIANT STANDARDS:
|
||||
- Uses console.print() for output (NO print())
|
||||
- Uses prax logger for operations
|
||||
- Standalone functions (no class dependencies)
|
||||
- Imports from caller handler for detection logic
|
||||
- Complete docstrings with Args/Returns
|
||||
- Under 300 lines
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
from aipass.api.apps.handlers.openrouter.caller import detect_caller_from_stack
|
||||
|
||||
|
||||
# ===========================================
|
||||
# JSON UTILITIES
|
||||
# ===========================================
|
||||
|
||||
def read_json(file_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Read JSON file safely
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None on error
|
||||
"""
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def write_json(file_path: Path, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Write JSON file safely with formatting
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
data: Dict to write as JSON
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to write {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ===========================================
|
||||
# DEFAULT CONFIGURATION
|
||||
# ===========================================
|
||||
|
||||
def get_default_caller_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Get default config template for new callers
|
||||
|
||||
NOTE: No default ai_model - callers must set their own model in their branch config
|
||||
|
||||
Returns:
|
||||
Dict with default OpenRouter configuration (model must be set by caller)
|
||||
"""
|
||||
return {
|
||||
"skill_name": "openrouter",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"config": {
|
||||
"ai_model": "", # Caller must set their own model
|
||||
"ai_temperature": 0.7,
|
||||
"ai_max_tokens": 4000,
|
||||
"enabled": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_default_caller_data() -> Dict[str, Any]:
|
||||
"""
|
||||
Get default data template for tracking caller usage
|
||||
|
||||
Returns:
|
||||
Dict with initial usage tracking data
|
||||
"""
|
||||
return {
|
||||
"skill_name": "openrouter",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data": {
|
||||
"total_requests": 0,
|
||||
"successful_requests": 0,
|
||||
"failed_requests": 0,
|
||||
"models_used": {},
|
||||
"last_request": None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_default_caller_log() -> Dict[str, Any]:
|
||||
"""
|
||||
Get default log template for caller operations
|
||||
|
||||
Returns:
|
||||
Dict with empty log structure
|
||||
"""
|
||||
return {
|
||||
"skill_name": "openrouter",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"logs": []
|
||||
}
|
||||
|
||||
|
||||
# ===========================================
|
||||
# PROVISIONING FUNCTIONS
|
||||
# ===========================================
|
||||
|
||||
def provision_json_folder(json_folder: Path) -> bool:
|
||||
"""
|
||||
Create JSON folder structure if missing
|
||||
|
||||
Args:
|
||||
json_folder: Path to caller's JSON folder
|
||||
|
||||
Returns:
|
||||
True if folder exists or created, False on error
|
||||
"""
|
||||
try:
|
||||
if json_folder.exists():
|
||||
return True
|
||||
|
||||
json_folder.mkdir(parents=True, exist_ok=True)
|
||||
# logger.info(f"Created JSON folder: {json_folder}")
|
||||
console.print(f"[green]Created JSON folder:[/green] {json_folder}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to create JSON folder {json_folder}: {e}")
|
||||
console.print(f"[red]Error:[/red] Failed to create JSON folder: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Create new caller configuration with defaults
|
||||
|
||||
Creates complete 3-file JSON structure:
|
||||
- openrouter_skill_config.json (API settings)
|
||||
- openrouter_skill_data.json (usage tracking)
|
||||
- openrouter_skill_log.json (operation log)
|
||||
|
||||
Args:
|
||||
caller: Name of calling module
|
||||
json_folder: Path to caller's JSON folder
|
||||
|
||||
Returns:
|
||||
Dict with created config or empty dict on error
|
||||
"""
|
||||
try:
|
||||
# Ensure JSON folder exists
|
||||
if not provision_json_folder(json_folder):
|
||||
return {}
|
||||
|
||||
# Create config file
|
||||
config_file = json_folder / "openrouter_skill_config.json"
|
||||
config = get_default_caller_config()
|
||||
|
||||
if not write_json(config_file, config):
|
||||
# logger.error(f"Failed to write config for {caller}")
|
||||
return {}
|
||||
|
||||
# logger.info(f"Created API config for {caller}: {config_file}")
|
||||
console.print(f"[green]Created config:[/green] {config_file.name}")
|
||||
|
||||
# Create data file
|
||||
data_file = json_folder / "openrouter_skill_data.json"
|
||||
data = get_default_caller_data()
|
||||
|
||||
if write_json(data_file, data):
|
||||
# logger.info(f"Created data file for {caller}: {data_file}")
|
||||
console.print(f"[green]Created data:[/green] {data_file.name}")
|
||||
|
||||
# Create log file
|
||||
log_file = json_folder / "openrouter_skill_log.json"
|
||||
log_data = get_default_caller_log()
|
||||
|
||||
if write_json(log_file, log_data):
|
||||
# logger.info(f"Created log file for {caller}: {log_file}")
|
||||
console.print(f"[green]Created log:[/green] {log_file.name}")
|
||||
|
||||
console.print(f"[cyan]Info:[/cyan] Auto-provisioned OpenRouter config for '{caller}'")
|
||||
console.print(f"[yellow]Note:[/yellow] Reload config and retry request")
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to create config for {caller}: {e}")
|
||||
console.print(f"[red]Error:[/red] Config creation failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure caller has API configuration, create if missing
|
||||
|
||||
Auto-detects caller if not provided. Creates complete 3-file
|
||||
JSON structure with default OpenRouter settings.
|
||||
|
||||
Args:
|
||||
caller: Optional caller name (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
Dict with config or empty dict if unable to provision
|
||||
"""
|
||||
try:
|
||||
# Auto-detect caller if not provided
|
||||
json_folder = None
|
||||
if not caller:
|
||||
detected_caller, json_folder = detect_caller_from_stack()
|
||||
if detected_caller:
|
||||
caller = detected_caller
|
||||
# logger.info(f"Auto-detected caller: {caller}")
|
||||
else:
|
||||
# logger.warning("Could not detect caller from stack")
|
||||
console.print("[yellow]Warning:[/yellow] Could not detect caller module")
|
||||
return {}
|
||||
|
||||
# Get JSON folder path if not already detected
|
||||
if not json_folder:
|
||||
_, json_folder = detect_caller_from_stack()
|
||||
if not json_folder:
|
||||
# logger.error(f"Could not determine JSON folder for {caller}")
|
||||
console.print(f"[red]Error:[/red] Could not find JSON folder for '{caller}'")
|
||||
return {}
|
||||
|
||||
# Check if config already exists
|
||||
config_file = json_folder / "openrouter_skill_config.json"
|
||||
|
||||
if config_file.exists():
|
||||
config = read_json(config_file)
|
||||
if config:
|
||||
# logger.info(f"Using existing config for {caller}")
|
||||
return config
|
||||
else:
|
||||
# logger.warning(f"Config file corrupted for {caller}, regenerating")
|
||||
console.print(f"[yellow]Warning:[/yellow] Corrupted config, regenerating...")
|
||||
|
||||
# Create new config
|
||||
return create_caller_config(caller, json_folder)
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to ensure config for {caller}: {e}")
|
||||
console.print(f"[red]Error:[/red] Config provisioning failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Telegram Handlers Package
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial package for Telegram bridge
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Handlers Package
|
||||
|
||||
Provides Telegram bot integration for AIPass:
|
||||
- config.py: Token and configuration loading
|
||||
- bridge.py: Core bot service with message handling
|
||||
"""
|
||||
|
||||
from aipass.api.apps.handlers.telegram.config import (
|
||||
load_telegram_config,
|
||||
get_bot_token,
|
||||
get_bot_username
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_telegram_config",
|
||||
"get_bot_token",
|
||||
"get_bot_username"
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_factory.py - Bot creation and deletion factory
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-02-25): Fix: create_bot always uses registry path when branch_name provided
|
||||
# - v1.1.0 (2026-02-24): Auto-start bot process after create_bot via Popen fire-and-forget
|
||||
# - v1.0.0 (2026-02-24): Initial - bot lifecycle management (create, delete, validate, systemd)
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Stdlib only (urllib for HTTP, subprocess for systemd)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Creation and Deletion Factory
|
||||
|
||||
Manages the full lifecycle of Telegram bots in the multi-bot architecture:
|
||||
- Validate bot tokens via Telegram getMe API
|
||||
- Validate branch existence against BRANCH_REGISTRY.json
|
||||
- Write per-bot config files to ~/.aipass/telegram_bots/{bot_id}.json
|
||||
- Register/deregister bots in the central bot registry
|
||||
- Set BotFather commands via setMyCommands API
|
||||
- Enable/disable/stop systemd user services
|
||||
|
||||
All HTTP calls use urllib (stdlib). No external dependencies.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Internal imports
|
||||
from aipass.api.apps.handlers.telegram.bot_registry import (
|
||||
ensure_registry, register_bot, deregister_bot, get_bot, get_bot_by_branch
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
TELEGRAM_API = "https://api.telegram.org/bot{token}"
|
||||
BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||
BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json"
|
||||
SYSTEMD_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
|
||||
# Default commands set on every new bot via BotFather
|
||||
DEFAULT_BOT_COMMANDS = [
|
||||
{"command": "start", "description": "Start the bot"},
|
||||
{"command": "help", "description": "Show available commands"},
|
||||
{"command": "status", "description": "Show session status"},
|
||||
{"command": "new", "description": "Start a fresh session"},
|
||||
]
|
||||
|
||||
# =============================================
|
||||
# TELEGRAM API HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def validate_token(bot_token: str) -> Optional[dict]:
|
||||
"""
|
||||
Validate a bot token via the Telegram getMe API call.
|
||||
|
||||
Args:
|
||||
bot_token: Telegram bot token string (e.g., "123456:ABC-DEF...").
|
||||
|
||||
Returns:
|
||||
Bot info dict with keys like "id", "username", "first_name" on success.
|
||||
None if the token is invalid or the API is unreachable.
|
||||
"""
|
||||
url = f"{TELEGRAM_API.format(token=bot_token)}/getMe"
|
||||
|
||||
try:
|
||||
req = Request(url, method="GET")
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if result.get("ok") and result.get("result"):
|
||||
bot_info = result["result"]
|
||||
logger.info("Token validated: @%s (id=%s)", bot_info.get("username"), bot_info.get("id"))
|
||||
return bot_info
|
||||
|
||||
logger.warning("Token validation failed: API returned ok=false")
|
||||
return None
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning("Token validation HTTP error %d: %s", e.code, e.reason)
|
||||
return None
|
||||
except URLError as e:
|
||||
logger.warning("Token validation network error: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Token validation unexpected error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def validate_branch(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Check that a branch exists in BRANCH_REGISTRY.json.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to look up (case-insensitive, matches email field).
|
||||
|
||||
Returns:
|
||||
Branch info dict from the registry on success, None if not found.
|
||||
"""
|
||||
try:
|
||||
if not BRANCH_REGISTRY.exists():
|
||||
logger.warning("Branch registry not found: %s", BRANCH_REGISTRY)
|
||||
return None
|
||||
|
||||
with open(BRANCH_REGISTRY, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
|
||||
branches = registry.get("branches", [])
|
||||
target = branch_name.lower()
|
||||
|
||||
for branch_entry in branches:
|
||||
clean_email = branch_entry.get("email", "").replace("@", "").lower()
|
||||
if clean_email == target:
|
||||
logger.info("Branch validated: %s -> %s", branch_name, branch_entry.get("path"))
|
||||
return branch_entry
|
||||
|
||||
logger.warning("Branch '%s' not found in registry", branch_name)
|
||||
return None
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read branch registry: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def set_bot_commands(bot_token: str, commands: list[dict]) -> bool:
|
||||
"""
|
||||
Set BotFather commands via the Telegram setMyCommands API.
|
||||
|
||||
Args:
|
||||
bot_token: Telegram bot token.
|
||||
commands: List of command dicts, each with "command" and "description" keys.
|
||||
|
||||
Returns:
|
||||
True if commands were set successfully, False otherwise.
|
||||
"""
|
||||
url = f"{TELEGRAM_API.format(token=bot_token)}/setMyCommands"
|
||||
|
||||
payload = {"commands": commands}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
if result.get("ok"):
|
||||
logger.info("Bot commands set successfully (%d commands)", len(commands))
|
||||
return True
|
||||
logger.warning("setMyCommands failed: %s", result.get("description"))
|
||||
return False
|
||||
|
||||
except (HTTPError, URLError) as e:
|
||||
logger.warning("Failed to set bot commands: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error setting bot commands: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYSTEMD SERVICE MANAGEMENT
|
||||
# =============================================
|
||||
|
||||
|
||||
def enable_service(bot_id: str) -> bool:
|
||||
"""
|
||||
Enable the systemd user service for a bot (does not start it).
|
||||
|
||||
Runs: systemctl --user enable telegram-bot@{bot_id}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was enabled successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "enable", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Enabled systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to enable service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout enabling service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error enabling service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
def disable_service(bot_id: str) -> bool:
|
||||
"""
|
||||
Disable the systemd user service for a bot.
|
||||
|
||||
Runs: systemctl --user disable telegram-bot@{bot_id}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was disabled successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "disable", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Disabled systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to disable service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout disabling service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error disabling service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
def start_bot_process(bot_id: str) -> bool:
|
||||
"""
|
||||
Launch the bot process via subprocess.Popen (fire-and-forget).
|
||||
|
||||
Starts base_bot.py --bot-id {bot_id} as a detached process.
|
||||
This is called after create_bot() to auto-start the new bot.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to start.
|
||||
|
||||
Returns:
|
||||
True if the process was launched successfully, False otherwise.
|
||||
"""
|
||||
BASE_BOT_PATH = Path(__file__).resolve().parent / "base_bot.py"
|
||||
PYTHON = str(Path.home() / ".venv" / "bin" / "python3")
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("Started bot process: bot_id=%s, pid=%d", bot_id, proc.pid)
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
logger.warning("Failed to start bot process for '%s': %s", bot_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def stop_service(bot_id: str) -> bool:
|
||||
"""
|
||||
Stop the systemd user service for a bot.
|
||||
|
||||
Runs: systemctl --user stop telegram-bot@{bot_id}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was stopped successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Stopped systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to stop service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout stopping service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error stopping service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# BOT LIFECYCLE
|
||||
# =============================================
|
||||
|
||||
|
||||
def create_bot(
|
||||
bot_id: str,
|
||||
bot_token: str,
|
||||
branch_name: Optional[str] = None,
|
||||
work_dir: Optional[str] = None,
|
||||
bot_name: Optional[str] = None,
|
||||
allowed_user_ids: Optional[list[int]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Create a new bot: validate, write config, register, setup systemd.
|
||||
|
||||
Steps:
|
||||
1. Validate token via getMe
|
||||
2. If branch_name provided: validate branch exists in BRANCH_REGISTRY.json
|
||||
3. Check bot_id not already registered
|
||||
4. Write config file: ~/.aipass/telegram_bots/{bot_id}.json
|
||||
5. Register in bot registry
|
||||
6. Set BotFather commands via setMyCommands API
|
||||
7. Enable systemd service (don't start - leave that to caller)
|
||||
|
||||
Args:
|
||||
bot_id: Unique identifier for this bot (e.g., "dev_central", "base").
|
||||
bot_token: Telegram bot token from BotFather.
|
||||
branch_name: AIPass branch name to associate, or None for base bot.
|
||||
work_dir: Working directory for Claude sessions. Auto-resolved from branch if None.
|
||||
bot_name: Human-readable bot name. Auto-generated if None.
|
||||
allowed_user_ids: List of Telegram user IDs allowed to use this bot.
|
||||
|
||||
Returns:
|
||||
Bot info dict on success, None on any failure.
|
||||
"""
|
||||
# Step 1: Validate token
|
||||
bot_info = validate_token(bot_token)
|
||||
if not bot_info:
|
||||
logger.warning("create_bot failed: invalid token for bot_id '%s'", bot_id)
|
||||
return None
|
||||
|
||||
BOT_USERNAME = bot_info.get("username", "unknown")
|
||||
|
||||
# Step 2: Validate branch if provided
|
||||
RESOLVED_WORK_DIR = str(Path.home()) if work_dir is None else str(work_dir)
|
||||
if branch_name:
|
||||
branch_info = validate_branch(branch_name)
|
||||
if not branch_info:
|
||||
logger.warning("create_bot failed: branch '%s' not found", branch_name)
|
||||
return None
|
||||
# Always use registry path as source of truth when branch_name is provided
|
||||
REGISTRY_PATH = branch_info.get("path", "")
|
||||
if REGISTRY_PATH:
|
||||
if work_dir and str(work_dir) != REGISTRY_PATH:
|
||||
logger.warning(
|
||||
"create_bot: explicit work_dir '%s' differs from registry path '%s' — using registry",
|
||||
work_dir, REGISTRY_PATH,
|
||||
)
|
||||
RESOLVED_WORK_DIR = REGISTRY_PATH
|
||||
|
||||
# Step 3: Check not already registered
|
||||
existing = get_bot(bot_id)
|
||||
if existing:
|
||||
logger.warning("create_bot failed: bot_id '%s' already registered", bot_id)
|
||||
return None
|
||||
|
||||
# Also check no other bot owns this branch
|
||||
if branch_name:
|
||||
branch_bot = get_bot_by_branch(branch_name)
|
||||
if branch_bot:
|
||||
logger.warning(
|
||||
"create_bot failed: branch '%s' already has bot '%s'",
|
||||
branch_name, branch_bot.get("bot_id"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 4: Write config file
|
||||
ensure_registry()
|
||||
BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
RESOLVED_BOT_NAME = bot_name or f"AIPass {bot_id.replace('_', ' ').title()} Bot"
|
||||
CONFIG_PATH = BOT_CONFIG_DIR / f"{bot_id}.json"
|
||||
|
||||
config_data = {
|
||||
"bot_id": bot_id,
|
||||
"bot_token": bot_token,
|
||||
"bot_name": RESOLVED_BOT_NAME,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": RESOLVED_WORK_DIR,
|
||||
"allowed_user_ids": allowed_user_ids or [],
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(config_data, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("Wrote bot config: %s", CONFIG_PATH)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to write bot config: %s", e)
|
||||
return None
|
||||
|
||||
# Step 5: Register in bot registry
|
||||
registered = register_bot(
|
||||
bot_id=bot_id,
|
||||
username=BOT_USERNAME,
|
||||
branch_name=branch_name,
|
||||
work_dir=RESOLVED_WORK_DIR,
|
||||
config_path=str(CONFIG_PATH),
|
||||
)
|
||||
if not registered:
|
||||
# Clean up config file on registration failure
|
||||
CONFIG_PATH.unlink(missing_ok=True)
|
||||
logger.warning("create_bot failed: registry registration failed for '%s'", bot_id)
|
||||
return None
|
||||
|
||||
# Step 6: Set BotFather commands
|
||||
set_bot_commands(bot_token, DEFAULT_BOT_COMMANDS)
|
||||
|
||||
# Step 7: Enable systemd service
|
||||
enable_service(bot_id)
|
||||
|
||||
# Step 8: Auto-start the bot process
|
||||
started = start_bot_process(bot_id)
|
||||
|
||||
logger.info(
|
||||
"Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s)",
|
||||
bot_id, BOT_USERNAME, branch_name, RESOLVED_WORK_DIR, started,
|
||||
)
|
||||
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"username": BOT_USERNAME,
|
||||
"bot_name": RESOLVED_BOT_NAME,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": RESOLVED_WORK_DIR,
|
||||
"config_path": str(CONFIG_PATH),
|
||||
"service_name": f"telegram-bot@{bot_id}",
|
||||
"auto_started": started,
|
||||
}
|
||||
|
||||
|
||||
def delete_bot(bot_id: str, kill_tmux: bool = True) -> bool:
|
||||
"""
|
||||
Delete a bot: stop service, kill tmux, remove config, deregister.
|
||||
|
||||
Steps:
|
||||
1. Stop systemd service
|
||||
2. Kill tmux session if exists and kill_tmux is True
|
||||
3. Remove config file
|
||||
4. Clean up pending file (both v1 and v2 naming)
|
||||
5. Deregister from registry
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to delete.
|
||||
kill_tmux: Whether to kill the associated tmux session (default True).
|
||||
|
||||
Returns:
|
||||
True if the bot was fully cleaned up, False on any failure.
|
||||
"""
|
||||
bot = get_bot(bot_id)
|
||||
if not bot:
|
||||
logger.warning("delete_bot failed: bot '%s' not found in registry", bot_id)
|
||||
return False
|
||||
|
||||
# Step 1: Stop systemd service
|
||||
stop_service(bot_id)
|
||||
disable_service(bot_id)
|
||||
|
||||
# Step 2: Kill tmux session if requested
|
||||
if kill_tmux:
|
||||
TMUX_SESSION_NAME = f"telegram-{bot_id}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info("Killed tmux session: %s", TMUX_SESSION_NAME)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.info("tmux kill-session for '%s' skipped (may not exist): %s", TMUX_SESSION_NAME, e)
|
||||
|
||||
# Step 3: Remove config file
|
||||
CONFIG_PATH = bot.get("config_path", "")
|
||||
if CONFIG_PATH:
|
||||
try:
|
||||
Path(CONFIG_PATH).unlink(missing_ok=True)
|
||||
logger.info("Removed config file: %s", CONFIG_PATH)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove config file: %s", e)
|
||||
|
||||
# Step 4: Clean up pending files (both v1 and v2 naming)
|
||||
PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
|
||||
BRANCH_NAME = bot.get("branch_name", bot_id)
|
||||
|
||||
# v2 naming: bot-{bot_id}.json
|
||||
PENDING_V2 = PENDING_DIR / f"bot-{bot_id}.json"
|
||||
PENDING_V2.unlink(missing_ok=True)
|
||||
|
||||
# v1 naming: telegram-{branch_name}.json
|
||||
if BRANCH_NAME:
|
||||
PENDING_V1 = PENDING_DIR / f"telegram-{BRANCH_NAME}.json"
|
||||
PENDING_V1.unlink(missing_ok=True)
|
||||
|
||||
# Step 5: Deregister from registry
|
||||
if not deregister_bot(bot_id):
|
||||
logger.warning("delete_bot: deregistration failed for '%s'", bot_id)
|
||||
return False
|
||||
|
||||
logger.info("Bot deleted: %s", bot_id)
|
||||
return True
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_operations.py - Bot operation handlers for multi-bot module
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - start, stop, status, list operations for multi-bot system
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - No Prax imports (handler tier 3)
|
||||
# - Stdlib only (subprocess for systemd)
|
||||
# - Returns values for caller to log/display - no handler-level logging
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Operation Handlers for Multi-Bot Architecture
|
||||
|
||||
Implementation logic for bot lifecycle operations:
|
||||
- start_bot: load config and run polling loop
|
||||
- stop_bot: stop systemd service
|
||||
- get_status: query registry for bot status
|
||||
- get_all_bots: list all registered bots
|
||||
- format_bot_details: format a bot entry for display
|
||||
|
||||
Called by the telegram_bot module (thin orchestration layer).
|
||||
All functions return values - the module layer handles logging and display.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import subprocess
|
||||
|
||||
# Internal handler imports
|
||||
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
|
||||
from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin
|
||||
from aipass.api.apps.handlers.telegram.bot_registry import list_bots, get_bot
|
||||
from aipass.api.apps.handlers.telegram.config import load_bot_config
|
||||
|
||||
# =============================================
|
||||
# BOT OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def start_bot(bot_id: str) -> int | None:
|
||||
"""
|
||||
Load config and start a bot's polling loop.
|
||||
|
||||
Loads config from ~/.aipass/telegram_bots/{bot_id}.json.
|
||||
If config has "branch_name", creates a BranchPlugin, else a BaseBot.
|
||||
Calls bot.run() which blocks until terminated.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to start
|
||||
|
||||
Returns:
|
||||
Bot exit code, or None if config loading failed.
|
||||
"""
|
||||
config = load_bot_config(bot_id)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
bot_token = config.get("bot_token")
|
||||
if not bot_token:
|
||||
return None
|
||||
|
||||
work_dir = Path(config.get("work_dir", str(Path.home())))
|
||||
bot_name = config.get("bot_name", f"AIPass {bot_id} Bot")
|
||||
allowed_user_ids = config.get("allowed_user_ids", [])
|
||||
branch_name = config.get("branch_name")
|
||||
|
||||
if branch_name:
|
||||
bot = BranchPlugin(
|
||||
branch_name=branch_name,
|
||||
bot_id=bot_id,
|
||||
bot_token=bot_token,
|
||||
work_dir=work_dir,
|
||||
bot_name=bot_name,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
)
|
||||
else:
|
||||
bot = BaseBot(
|
||||
bot_id=bot_id,
|
||||
bot_token=bot_token,
|
||||
work_dir=work_dir,
|
||||
bot_name=bot_name,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
)
|
||||
|
||||
return bot.run()
|
||||
|
||||
|
||||
def stop_bot(bot_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Stop a bot's systemd service.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to stop
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
service_name = f"telegram-bot@{bot_id}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", service_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, f"Stopped {service_name}"
|
||||
|
||||
return False, f"Failed to stop {service_name}: {result.stderr.strip()}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Timeout stopping {service_name}"
|
||||
except OSError as e:
|
||||
return False, f"Error stopping {service_name}: {e}"
|
||||
|
||||
|
||||
def get_status(bot_id: str | None = None) -> list[dict]:
|
||||
"""
|
||||
Get bot status entries. If no bot_id, returns all bots.
|
||||
|
||||
Args:
|
||||
bot_id: Specific bot to check, or None for all bots
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts. Empty list if not found.
|
||||
"""
|
||||
if bot_id:
|
||||
bot = get_bot(bot_id)
|
||||
return [bot] if bot else []
|
||||
|
||||
return list_bots()
|
||||
|
||||
|
||||
def get_all_bots() -> list[dict]:
|
||||
"""
|
||||
Get all registered bots.
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts.
|
||||
"""
|
||||
return list_bots()
|
||||
|
||||
|
||||
def format_bot_details(bot: dict) -> list[str]:
|
||||
"""
|
||||
Format a single bot entry into display lines.
|
||||
|
||||
Args:
|
||||
bot: Bot entry dict from registry.
|
||||
|
||||
Returns:
|
||||
List of formatted strings for display.
|
||||
"""
|
||||
bot_id = bot.get("bot_id", "?")
|
||||
username = bot.get("username", "?")
|
||||
branch = bot.get("branch_name") or "none (base bot)"
|
||||
work_dir = bot.get("work_dir", "?")
|
||||
status = bot.get("status", "?")
|
||||
service = bot.get("service_name", f"telegram-bot@{bot_id}")
|
||||
|
||||
return [
|
||||
f"Bot ID: {bot_id}",
|
||||
f"Username: @{username}",
|
||||
f"Branch: {branch}",
|
||||
f"Work Dir: {work_dir}",
|
||||
f"Status: {status}",
|
||||
f"Service: {service}",
|
||||
]
|
||||
|
||||
|
||||
def format_bot_table(bots: list[dict]) -> list[str]:
|
||||
"""
|
||||
Format a list of bots into table rows.
|
||||
|
||||
Args:
|
||||
bots: List of bot entry dicts.
|
||||
|
||||
Returns:
|
||||
List of formatted strings (header + separator + rows + total).
|
||||
"""
|
||||
lines = []
|
||||
lines.append(f" {'Bot ID':<18} {'Branch':<16} {'Username':<24} {'Status':<10}")
|
||||
lines.append(f" {'---' * 6:<18} {'---' * 5:<16} {'---' * 8:<24} {'---' * 3:<10}")
|
||||
|
||||
for bot in bots:
|
||||
bot_id = bot.get("bot_id", "?")
|
||||
branch = bot.get("branch_name") or "-"
|
||||
username = f"@{bot.get('username', '?')}"
|
||||
status = bot.get("status", "?")
|
||||
lines.append(f" {bot_id:<18} {branch:<16} {username:<24} {status}")
|
||||
|
||||
lines.append(f" Total: {len(bots)} bot(s)")
|
||||
return lines
|
||||
|
||||
|
||||
def parse_create_args(args: list) -> dict | None:
|
||||
"""
|
||||
Parse create command arguments.
|
||||
|
||||
Args:
|
||||
args: Arguments after 'create' (bot_id, token, [--branch name], [--work-dir path])
|
||||
|
||||
Returns:
|
||||
Dict with parsed values, or None if args are insufficient.
|
||||
"""
|
||||
if len(args) < 2:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"bot_id": args[0],
|
||||
"bot_token": args[1],
|
||||
"branch_name": None,
|
||||
"work_dir": None,
|
||||
}
|
||||
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] == "--branch" and i + 1 < len(args):
|
||||
result["branch_name"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--work-dir" and i + 1 < len(args):
|
||||
result["work_dir"] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_registry.py - Bot registry management for multi-bot architecture
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - registry CRUD with fcntl locking for multi-bot management
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Thread-safe via fcntl.flock (shared read, exclusive write)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Registry Management for Multi-Bot Architecture
|
||||
|
||||
Manages a central registry of all Telegram bots in the AIPass ecosystem.
|
||||
Each bot entry tracks its ID, branch association, working directory,
|
||||
config path, systemd service name, and status.
|
||||
|
||||
Registry location: ~/.aipass/telegram_bots/_registry.json
|
||||
|
||||
Thread-safe via fcntl.flock file locking (same pattern as session_store.py).
|
||||
All public functions return None/False on errors - never raise exceptions.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import fcntl
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
REGISTRY_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||
REGISTRY_FILE = REGISTRY_DIR / "_registry.json"
|
||||
|
||||
# =============================================
|
||||
# EMPTY REGISTRY TEMPLATE
|
||||
# =============================================
|
||||
|
||||
|
||||
def _empty_registry() -> dict:
|
||||
"""Return a fresh empty registry structure."""
|
||||
return {
|
||||
"bots": {},
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return current UTC timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# =============================================
|
||||
# REGISTRY LIFECYCLE
|
||||
# =============================================
|
||||
|
||||
|
||||
def ensure_registry() -> None:
|
||||
"""
|
||||
Create registry directory and file if they don't exist.
|
||||
|
||||
Safe to call multiple times - only creates what is missing.
|
||||
"""
|
||||
try:
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not REGISTRY_FILE.exists():
|
||||
data = _empty_registry()
|
||||
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
logger.info("Created new bot registry at %s", REGISTRY_FILE)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to ensure registry: %s", e)
|
||||
|
||||
|
||||
# =============================================
|
||||
# READ / WRITE WITH LOCKING
|
||||
# =============================================
|
||||
|
||||
|
||||
def load_registry() -> dict:
|
||||
"""
|
||||
Load registry with fcntl shared lock.
|
||||
|
||||
Returns:
|
||||
Registry dict. Returns empty structure if file missing or corrupt.
|
||||
"""
|
||||
if not REGISTRY_FILE.exists():
|
||||
return _empty_registry()
|
||||
|
||||
try:
|
||||
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
data = json.load(f)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
if not isinstance(data, dict) or "bots" not in data:
|
||||
logger.warning("Registry file has unexpected structure, returning empty")
|
||||
return _empty_registry()
|
||||
|
||||
return data
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load registry: %s", e)
|
||||
return _empty_registry()
|
||||
|
||||
|
||||
def save_registry(data: dict) -> bool:
|
||||
"""
|
||||
Save registry with fcntl exclusive lock.
|
||||
|
||||
Args:
|
||||
data: Full registry dict to write.
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False on error.
|
||||
"""
|
||||
try:
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update metadata timestamp
|
||||
if "metadata" not in data:
|
||||
data["metadata"] = {}
|
||||
data["metadata"]["last_updated"] = _now_iso()
|
||||
|
||||
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
logger.warning("Failed to save registry: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# CRUD OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def get_bot(bot_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Get a single bot entry by bot_id.
|
||||
|
||||
Args:
|
||||
bot_id: Unique bot identifier.
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
registry = load_registry()
|
||||
return registry.get("bots", {}).get(bot_id)
|
||||
|
||||
|
||||
def list_bots(status: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
List all bots, optionally filtered by status.
|
||||
|
||||
Args:
|
||||
status: Filter by status (e.g., "active", "inactive"). None returns all.
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = list(registry.get("bots", {}).values())
|
||||
|
||||
if status is not None:
|
||||
bots = [b for b in bots if b.get("status") == status]
|
||||
|
||||
return bots
|
||||
|
||||
|
||||
def register_bot(
|
||||
bot_id: str,
|
||||
username: str,
|
||||
branch_name: Optional[str],
|
||||
work_dir: str,
|
||||
config_path: str,
|
||||
bot_token_ref: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Register a new bot in the registry.
|
||||
|
||||
Args:
|
||||
bot_id: Unique bot identifier.
|
||||
username: Telegram bot username (e.g., "aipass_dev_central_bot").
|
||||
branch_name: AIPass branch name, or None for the base bot.
|
||||
work_dir: Working directory for Claude sessions.
|
||||
config_path: Path to the bot's config JSON file.
|
||||
bot_token_ref: Optional env var name or reference for the token.
|
||||
|
||||
Returns:
|
||||
True on success, False if bot_id already exists or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id in bots:
|
||||
logger.warning("Bot '%s' already registered", bot_id)
|
||||
return False
|
||||
|
||||
now = _now_iso()
|
||||
entry = {
|
||||
"bot_id": bot_id,
|
||||
"username": username,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": str(work_dir),
|
||||
"config_path": str(config_path),
|
||||
"service_name": f"telegram-bot@{bot_id}",
|
||||
"status": "active",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if bot_token_ref:
|
||||
entry["bot_token_env"] = bot_token_ref
|
||||
|
||||
bots[bot_id] = entry
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Registered bot '%s' (branch=%s, work_dir=%s)", bot_id, branch_name, work_dir)
|
||||
return True
|
||||
|
||||
|
||||
def update_bot(bot_id: str, **kwargs) -> bool:
|
||||
"""
|
||||
Update specific fields of a bot entry.
|
||||
|
||||
Auto-updates the updated_at timestamp.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to update.
|
||||
**kwargs: Fields to update (e.g., status="inactive", username="new_name").
|
||||
|
||||
Returns:
|
||||
True on success, False if bot not found or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id not in bots:
|
||||
logger.warning("Cannot update bot '%s': not found", bot_id)
|
||||
return False
|
||||
|
||||
for key, value in kwargs.items():
|
||||
bots[bot_id][key] = value
|
||||
|
||||
bots[bot_id]["updated_at"] = _now_iso()
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Updated bot '%s': %s", bot_id, list(kwargs.keys()))
|
||||
return True
|
||||
|
||||
|
||||
def deregister_bot(bot_id: str) -> bool:
|
||||
"""
|
||||
Remove a bot from the registry.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to remove.
|
||||
|
||||
Returns:
|
||||
True on success, False if bot not found or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id not in bots:
|
||||
logger.warning("Cannot deregister bot '%s': not found", bot_id)
|
||||
return False
|
||||
|
||||
del bots[bot_id]
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Deregistered bot '%s'", bot_id)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOOKUP HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def get_bot_by_branch(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Find a bot by its branch_name.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central").
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
registry = load_registry()
|
||||
for bot in registry.get("bots", {}).values():
|
||||
if bot.get("branch_name") == branch_name:
|
||||
return bot
|
||||
return None
|
||||
|
||||
|
||||
def get_bot_by_work_dir(work_dir) -> Optional[dict]:
|
||||
"""
|
||||
Find a bot whose work_dir matches the given path.
|
||||
|
||||
Used by the response router to match CWD to a bot.
|
||||
|
||||
Args:
|
||||
work_dir: Path (str or Path) to match against bot work_dir fields.
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
target = str(Path(work_dir).resolve())
|
||||
registry = load_registry()
|
||||
|
||||
for bot in registry.get("bots", {}).values():
|
||||
bot_dir = bot.get("work_dir", "")
|
||||
if bot_dir:
|
||||
try:
|
||||
if str(Path(bot_dir).resolve()) == target:
|
||||
return bot
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,532 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: botfather_client.py - Telethon-based BotFather automation client
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - automated bot creation via BotFather using Telethon
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Telethon for Telegram user-account interaction with @BotFather
|
||||
# - Graceful error handling (never raise - return None on failure)
|
||||
# - Sync wrapper for async Telethon calls (callable from stdlib code)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
BotFather Automation Client
|
||||
|
||||
Automates Telegram bot creation by driving a conversation with @BotFather
|
||||
using Telethon (user-account MTProto client). This replaces the manual
|
||||
"go to BotFather, create a bot, paste the token" workflow.
|
||||
|
||||
Flow:
|
||||
1. Connect to Telegram as Patrick's user account (pre-authenticated session)
|
||||
2. Send /newbot to @BotFather
|
||||
3. Provide display name and username
|
||||
4. Parse the bot token from BotFather's success response
|
||||
5. Return token + metadata for bot_factory.py to complete registration
|
||||
|
||||
Requirements:
|
||||
- Telethon 1.42.0+ installed in .venv
|
||||
- One-time manual phone auth to create .telethon.session file
|
||||
- API credentials in ~/.aipass/telegram_bots/.telethon_config.json
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Third party (Telethon) — runtime-imported in methods to avoid Pyright issues
|
||||
TELETHON_AVAILABLE = False
|
||||
try:
|
||||
import telethon as _telethon_check # noqa: F401
|
||||
TELETHON_AVAILABLE = True
|
||||
del _telethon_check
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||
TELETHON_CONFIG_PATH = BOT_CONFIG_DIR / ".telethon_config.json"
|
||||
SESSION_PATH = BOT_CONFIG_DIR / ".telethon" # Telethon appends .session automatically
|
||||
|
||||
BOTFATHER_USERNAME = "BotFather"
|
||||
BOT_TOKEN_PATTERN = re.compile(r"\d+:[A-Za-z0-9_-]+")
|
||||
|
||||
# Timeouts
|
||||
MESSAGE_TIMEOUT = 30 # seconds to wait for BotFather response
|
||||
MAX_USERNAME_ATTEMPTS = 3
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONFIG LOADER
|
||||
# =============================================
|
||||
|
||||
|
||||
def _load_telethon_config() -> Optional[dict]:
|
||||
"""
|
||||
Load Telethon API credentials from .telethon_config.json.
|
||||
|
||||
Expected format:
|
||||
{"api_id": 12345, "api_hash": "abc123..."}
|
||||
|
||||
Returns:
|
||||
Dict with "api_id" (int) and "api_hash" (str), or None on failure.
|
||||
"""
|
||||
try:
|
||||
if not TELETHON_CONFIG_PATH.exists():
|
||||
logger.warning("Telethon config not found: %s", TELETHON_CONFIG_PATH)
|
||||
return None
|
||||
|
||||
with open(TELETHON_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
api_id = config.get("api_id")
|
||||
api_hash = config.get("api_hash")
|
||||
|
||||
if not api_id or not api_hash:
|
||||
logger.warning("Telethon config missing api_id or api_hash")
|
||||
return None
|
||||
|
||||
# Ensure api_id is an integer
|
||||
config["api_id"] = int(api_id)
|
||||
config["api_hash"] = str(api_hash)
|
||||
|
||||
logger.info("Telethon config loaded successfully")
|
||||
return config
|
||||
|
||||
except (json.JSONDecodeError, ValueError, OSError) as e:
|
||||
logger.warning("Failed to load Telethon config: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# SETUP CHECK
|
||||
# =============================================
|
||||
|
||||
|
||||
def check_telethon_setup() -> tuple[bool, str]:
|
||||
"""
|
||||
Check whether Telethon is ready for BotFather automation.
|
||||
|
||||
Verifies:
|
||||
1. Telethon library is importable
|
||||
2. .telethon_config.json exists with valid credentials
|
||||
3. .telethon.session exists (phone auth already completed)
|
||||
|
||||
Returns:
|
||||
(True, "ready") if everything is in place.
|
||||
(False, "reason") with a human-readable explanation of what is missing.
|
||||
"""
|
||||
if not TELETHON_AVAILABLE:
|
||||
return (False, "Telethon library not installed. Run: pip install telethon")
|
||||
|
||||
if not TELETHON_CONFIG_PATH.exists():
|
||||
return (False, f"Telethon config not found at {TELETHON_CONFIG_PATH}")
|
||||
|
||||
config = _load_telethon_config()
|
||||
if config is None:
|
||||
return (False, "Telethon config is invalid (missing api_id or api_hash)")
|
||||
|
||||
# Telethon creates session files with .session extension
|
||||
session_file = Path(str(SESSION_PATH) + ".session")
|
||||
if not session_file.exists():
|
||||
return (False, f"Telethon session not found at {session_file}. Run one-time phone auth first.")
|
||||
|
||||
return (True, "ready")
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _format_display_name(branch_name: str) -> str:
|
||||
"""
|
||||
Convert a branch_name to a BotFather display name.
|
||||
|
||||
Examples:
|
||||
"dev_central" -> "AIPass Dev Central"
|
||||
"flow" -> "AIPass Flow"
|
||||
"vera" -> "AIPass Vera"
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (snake_case).
|
||||
|
||||
Returns:
|
||||
Display name string.
|
||||
"""
|
||||
title = branch_name.replace("_", " ").title()
|
||||
return f"AIPass {title}"
|
||||
|
||||
|
||||
def _format_username(branch_name: str, suffix: int = 0) -> str:
|
||||
"""
|
||||
Generate a BotFather username from a branch name.
|
||||
|
||||
Examples:
|
||||
("dev_central", 0) -> "aipass_dev_central_bot"
|
||||
("dev_central", 1) -> "aipass_dev_central_1_bot"
|
||||
("dev_central", 2) -> "aipass_dev_central_2_bot"
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (snake_case).
|
||||
suffix: Numeric suffix for retries (0 = no suffix).
|
||||
|
||||
Returns:
|
||||
Username string ending in _bot.
|
||||
"""
|
||||
if suffix == 0:
|
||||
return f"aipass_{branch_name}_bot"
|
||||
return f"aipass_{branch_name}_{suffix}_bot"
|
||||
|
||||
|
||||
# =============================================
|
||||
# BOTFATHER CLIENT
|
||||
# =============================================
|
||||
|
||||
|
||||
class BotFatherClient:
|
||||
"""
|
||||
Telethon-based client that automates bot creation via @BotFather.
|
||||
|
||||
Uses Patrick's authenticated user session to send commands to BotFather
|
||||
and parse the resulting bot token.
|
||||
|
||||
Usage:
|
||||
client = BotFatherClient(api_id=12345, api_hash="abc...")
|
||||
result = await client.create_bot("dev_central")
|
||||
# result = {"token": "123:ABC", "username": "aipass_dev_central_bot", "display_name": "AIPass Dev Central"}
|
||||
"""
|
||||
|
||||
def __init__(self, api_id: int, api_hash: str) -> None:
|
||||
self._api_id = api_id
|
||||
self._api_hash = api_hash
|
||||
self._client: Any = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""
|
||||
Connect to Telegram using the existing session file.
|
||||
|
||||
The session file must already exist from a prior manual phone auth.
|
||||
This method will NOT prompt for phone/code input.
|
||||
|
||||
Returns:
|
||||
True if connected and authorized, False otherwise.
|
||||
"""
|
||||
try:
|
||||
from telethon import TelegramClient as _TelegramClient
|
||||
|
||||
self._client = _TelegramClient(
|
||||
str(SESSION_PATH),
|
||||
self._api_id,
|
||||
self._api_hash,
|
||||
)
|
||||
await self._client.connect()
|
||||
|
||||
if not await self._client.is_user_authorized():
|
||||
logger.warning("Telethon session exists but is not authorized. Re-run phone auth.")
|
||||
await self._client.disconnect()
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
me = await self._client.get_me()
|
||||
if me:
|
||||
logger.info(
|
||||
"Connected to Telegram as: %s (id=%s)",
|
||||
getattr(me, "first_name", "?"),
|
||||
getattr(me, "id", "?"),
|
||||
)
|
||||
else:
|
||||
logger.info("Connected to Telegram (could not resolve self)")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to Telegram: %s", e)
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Telegram gracefully."""
|
||||
if self._client:
|
||||
try:
|
||||
await self._client.disconnect()
|
||||
logger.info("Disconnected from Telegram")
|
||||
except Exception as e:
|
||||
logger.warning("Error during disconnect: %s", e)
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
async def _send_and_wait(self, entity: Any, message: str) -> Optional[str]:
|
||||
"""
|
||||
Send a message to BotFather and wait for a response.
|
||||
|
||||
Handles FloodWaitError by sleeping for the required duration and retrying once.
|
||||
|
||||
Args:
|
||||
entity: The BotFather entity to send to.
|
||||
message: The text message to send.
|
||||
|
||||
Returns:
|
||||
BotFather's response text, or None on timeout/error.
|
||||
"""
|
||||
if not self._client:
|
||||
logger.warning("_send_and_wait called without active client")
|
||||
return None
|
||||
|
||||
from telethon.errors import FloodWaitError as _FloodWaitError
|
||||
from telethon.errors import RPCError as _RPCError
|
||||
|
||||
try:
|
||||
await self._client.send_message(entity, message)
|
||||
logger.info("Sent to BotFather: %s", message)
|
||||
except _FloodWaitError as e:
|
||||
wait_seconds = e.seconds
|
||||
logger.warning("FloodWaitError: waiting %d seconds before retry", wait_seconds)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
try:
|
||||
await self._client.send_message(entity, message)
|
||||
logger.info("Sent to BotFather (after flood wait): %s", message)
|
||||
except Exception as retry_err:
|
||||
logger.warning("Failed to send after flood wait: %s", retry_err)
|
||||
return None
|
||||
except _RPCError as e:
|
||||
logger.warning("RPC error sending to BotFather: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error sending to BotFather: %s", e)
|
||||
return None
|
||||
|
||||
# Wait for BotFather's response
|
||||
deadline = time.monotonic() + MESSAGE_TIMEOUT
|
||||
# Brief pause to let BotFather process
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
# Get the most recent messages from BotFather
|
||||
messages = await self._client.get_messages(entity, limit=1)
|
||||
if not messages:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
# get_messages returns a list-like object
|
||||
msg_list = list(messages)
|
||||
if msg_list:
|
||||
latest = msg_list[0]
|
||||
# Check that this message is FROM BotFather (not our own)
|
||||
if getattr(latest, "out", True) is False and getattr(latest, "text", None):
|
||||
response_text: str = latest.text
|
||||
logger.info("BotFather response received (%d chars)", len(response_text))
|
||||
return response_text
|
||||
|
||||
# Poll interval
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.warning("Timeout waiting for BotFather response (after %ds)", MESSAGE_TIMEOUT)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error reading BotFather response: %s", e)
|
||||
return None
|
||||
|
||||
async def create_bot(self, branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Create a new Telegram bot via @BotFather conversation.
|
||||
|
||||
Conversation flow:
|
||||
1. /newbot
|
||||
2. Display name (e.g., "AIPass Dev Central")
|
||||
3. Username (e.g., "aipass_dev_central_bot")
|
||||
4. Parse token from success response
|
||||
|
||||
If the username is taken, retries with numeric suffixes up to MAX_USERNAME_ATTEMPTS.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central", "flow").
|
||||
|
||||
Returns:
|
||||
Dict with "token", "username", "display_name" on success.
|
||||
None on any failure.
|
||||
"""
|
||||
if not self._client:
|
||||
logger.warning("create_bot called without active connection")
|
||||
return None
|
||||
|
||||
display_name = _format_display_name(branch_name)
|
||||
|
||||
# Resolve BotFather entity
|
||||
try:
|
||||
botfather = await self._client.get_entity(BOTFATHER_USERNAME)
|
||||
logger.info("Resolved BotFather entity: %s", getattr(botfather, "id", "?"))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve @BotFather entity: %s", e)
|
||||
return None
|
||||
|
||||
# Step 1: Send /newbot
|
||||
response = await self._send_and_wait(botfather, "/newbot")
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to /newbot")
|
||||
return None
|
||||
|
||||
# BotFather should ask for a name
|
||||
if "name" not in response.lower():
|
||||
logger.warning("Unexpected BotFather response to /newbot: %s", response[:200])
|
||||
return None
|
||||
|
||||
logger.info("BotFather asked for bot name")
|
||||
|
||||
# Step 2: Send display name
|
||||
response = await self._send_and_wait(botfather, display_name)
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to display name")
|
||||
return None
|
||||
|
||||
# BotFather should ask for a username
|
||||
if "username" not in response.lower():
|
||||
logger.warning("Unexpected BotFather response to display name: %s", response[:200])
|
||||
return None
|
||||
|
||||
logger.info("BotFather asked for username")
|
||||
|
||||
# Step 3: Try usernames with incrementing suffix
|
||||
for attempt in range(MAX_USERNAME_ATTEMPTS):
|
||||
username = _format_username(branch_name, suffix=attempt)
|
||||
logger.info("Trying username: %s (attempt %d/%d)", username, attempt + 1, MAX_USERNAME_ATTEMPTS)
|
||||
|
||||
response = await self._send_and_wait(botfather, username)
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to username '%s'", username)
|
||||
return None
|
||||
|
||||
# Check if the username was accepted (token in response)
|
||||
token_match = BOT_TOKEN_PATTERN.search(response)
|
||||
if token_match:
|
||||
token = token_match.group()
|
||||
logger.info(
|
||||
"Bot created successfully: @%s (token: %s...%s)",
|
||||
username, token[:8], token[-4:],
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
# Username taken - BotFather says "Sorry" or mentions "already"
|
||||
if "sorry" in response.lower() or "already" in response.lower() or "taken" in response.lower():
|
||||
logger.info("Username '%s' is taken, trying next", username)
|
||||
# If not the last attempt, BotFather is still waiting for a username
|
||||
# so we can send another one directly without restarting /newbot
|
||||
continue
|
||||
|
||||
# Unexpected response
|
||||
logger.warning(
|
||||
"Unexpected BotFather response for username '%s': %s",
|
||||
username, response[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"All %d username attempts exhausted for branch '%s'",
|
||||
MAX_USERNAME_ATTEMPTS, branch_name,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYNCHRONOUS WRAPPER
|
||||
# =============================================
|
||||
|
||||
|
||||
def create_bot_via_botfather(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Synchronous wrapper to create a Telegram bot via BotFather automation.
|
||||
|
||||
This is the main entry point for stdlib-based callers (e.g., base_bot.py).
|
||||
Loads config, connects via Telethon, drives the BotFather conversation,
|
||||
and returns the result.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central").
|
||||
|
||||
Returns:
|
||||
Dict with "token", "username", "display_name" on success.
|
||||
None on any failure (config missing, connection failed, BotFather error, etc.).
|
||||
"""
|
||||
# Pre-flight checks
|
||||
ready, reason = check_telethon_setup()
|
||||
if not ready:
|
||||
logger.warning("Telethon setup check failed: %s", reason)
|
||||
return None
|
||||
|
||||
# Load config
|
||||
config = _load_telethon_config()
|
||||
if config is None:
|
||||
logger.warning("Cannot create bot: Telethon config not loaded")
|
||||
return None
|
||||
|
||||
api_id = config["api_id"]
|
||||
api_hash = config["api_hash"]
|
||||
|
||||
# Run the async flow
|
||||
client = BotFatherClient(api_id, api_hash)
|
||||
result = None
|
||||
|
||||
async def _run() -> Optional[dict]:
|
||||
connected = await client.connect()
|
||||
if not connected:
|
||||
return None
|
||||
try:
|
||||
return await client.create_bot(branch_name)
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
try:
|
||||
# Handle the case where an event loop is already running
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# We're inside an existing event loop (unlikely for our stdlib callers,
|
||||
# but handle gracefully). Create a new loop in a thread.
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, _run())
|
||||
result = future.result(timeout=120)
|
||||
else:
|
||||
result = asyncio.run(_run())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("create_bot_via_botfather failed: %s", e)
|
||||
return None
|
||||
|
||||
if result:
|
||||
logger.info(
|
||||
"Bot created via BotFather: @%s for branch '%s'",
|
||||
result.get("username"), branch_name,
|
||||
)
|
||||
else:
|
||||
logger.warning("Bot creation via BotFather failed for branch '%s'", branch_name)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: branch_plugin.py - BranchPlugin extends BaseBot for per-branch bots
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - BranchPlugin with message prefixing and startup injection
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Inherits Prax get_direct_logger() from BaseBot (FPLAN-0382 migration)
|
||||
# - Extends BaseBot via hook overrides
|
||||
# - No duplicated logic - all core behavior lives in BaseBot
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
BranchPlugin - Per-branch Telegram bot extending BaseBot.
|
||||
|
||||
Each AIPass branch gets its own dedicated Telegram bot. BranchPlugin overrides
|
||||
BaseBot's hooks to:
|
||||
- Prefix incoming messages with "Patrick via Telegram: "
|
||||
- Prefix outgoing responses with "@branch_name"
|
||||
- Inject "hi" on session creation to trigger the branch startup protocol
|
||||
|
||||
Usage:
|
||||
bot = BranchPlugin(
|
||||
branch_name="dev_central",
|
||||
bot_id="dev_central",
|
||||
bot_token="123:ABC",
|
||||
work_dir=Path("/home/aipass/aipass_os/dev_central"),
|
||||
bot_name="AIPass Dev Central Bot",
|
||||
allowed_user_ids=[7235222625],
|
||||
)
|
||||
sys.exit(bot.run())
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
# Sibling import
|
||||
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
|
||||
|
||||
|
||||
# =============================================
|
||||
# BranchPlugin CLASS
|
||||
# =============================================
|
||||
|
||||
class BranchPlugin(BaseBot):
|
||||
"""
|
||||
Per-branch Telegram bot that extends BaseBot with branch-specific behavior.
|
||||
|
||||
Overrides BaseBot hooks to prefix messages, tag responses, and trigger
|
||||
the AIPass startup protocol when a new tmux session is created.
|
||||
"""
|
||||
|
||||
def __init__(self, branch_name: str, **kwargs) -> None:
|
||||
"""
|
||||
Initialize BranchPlugin.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central", "seed")
|
||||
**kwargs: All BaseBot constructor arguments (bot_id, bot_token, etc.)
|
||||
"""
|
||||
self.branch_name = branch_name
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# =============================================
|
||||
# HOOK OVERRIDES
|
||||
# =============================================
|
||||
|
||||
def on_message(self, text: str) -> str:
|
||||
"""
|
||||
Prefix incoming messages with sender attribution.
|
||||
|
||||
Args:
|
||||
text: Raw message text from Telegram
|
||||
|
||||
Returns:
|
||||
Prefixed text for Claude: "Patrick via Telegram: {text}"
|
||||
"""
|
||||
return f"Patrick via Telegram: {text}"
|
||||
|
||||
def on_response(self, text: str) -> str:
|
||||
"""
|
||||
Prefix outgoing responses with branch tag.
|
||||
|
||||
Args:
|
||||
text: Raw response text from Claude
|
||||
|
||||
Returns:
|
||||
Tagged text: "@{branch_name}\n{text}"
|
||||
"""
|
||||
return f"@{self.branch_name}\n{text}"
|
||||
|
||||
def on_session_create(self, session_name: str, work_dir: Path) -> None:
|
||||
"""
|
||||
Inject "hi" after tmux session creation to trigger startup protocol.
|
||||
|
||||
Waits 2 seconds for Claude to fully initialize, then injects "hi"
|
||||
which triggers the AIPass startup sequence (reading memories, etc.).
|
||||
|
||||
Args:
|
||||
session_name: The tmux session name that was created
|
||||
work_dir: The working directory of the session
|
||||
"""
|
||||
self.logger.info(
|
||||
"Branch session created for @%s, injecting startup greeting",
|
||||
self.branch_name,
|
||||
)
|
||||
time.sleep(2)
|
||||
self.inject_message("hi")
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLI ENTRY POINT
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AIPass Telegram Branch Bot")
|
||||
parser.add_argument("--bot-id", required=True, help="Bot identifier")
|
||||
parser.add_argument("--config", help="Path to bot config JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config from ~/.aipass/telegram_bots/{bot_id}.json or --config path
|
||||
config_path = (
|
||||
Path(args.config)
|
||||
if args.config
|
||||
else Path.home() / ".aipass" / "telegram_bots" / f"{args.bot_id}.json"
|
||||
)
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# If config has "branch_name", create BranchPlugin; otherwise BaseBot
|
||||
shared_session = config.get("shared_session")
|
||||
|
||||
if config.get("branch_name"):
|
||||
bot = BranchPlugin(
|
||||
branch_name=config["branch_name"],
|
||||
bot_id=args.bot_id,
|
||||
bot_token=config["bot_token"],
|
||||
work_dir=Path(config["work_dir"]),
|
||||
bot_name=config.get("bot_name", f"AIPass {config['branch_name']} Bot"),
|
||||
allowed_user_ids=config.get("allowed_user_ids", []),
|
||||
shared_session=shared_session,
|
||||
)
|
||||
else:
|
||||
bot = BaseBot(
|
||||
bot_id=args.bot_id,
|
||||
bot_token=config["bot_token"],
|
||||
work_dir=Path(config.get("work_dir", str(Path.home()))),
|
||||
bot_name=config.get("bot_name", "AIPass Bot"),
|
||||
allowed_user_ids=config.get("allowed_user_ids", []),
|
||||
shared_session=shared_session,
|
||||
)
|
||||
|
||||
sys.exit(bot.run())
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: config.py - Telegram Configuration Handler
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-02-24): Add multi-bot config: load_bot_config, list_bot_configs, validate_bot_config
|
||||
# - v1.1.0 (2026-02-03): Add allowed_user_ids loading for user allowlist
|
||||
# - v1.0.0 (2026-02-03): Initial config loader for Telegram bridge
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Configuration Handler
|
||||
|
||||
Manages Telegram bot configuration:
|
||||
- Load bot token from config file (legacy single-bot: ~/.aipass/telegram_config.json)
|
||||
- Load bot username
|
||||
- Load allowed user IDs for access control
|
||||
- Load per-bot configs (multi-bot: ~/.aipass/telegram_bots/{bot_id}.json)
|
||||
- List and validate bot configs
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
CONFIG_PATH = Path.home() / ".aipass" / "telegram_config.json"
|
||||
BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||
|
||||
REQUIRED_BOT_FIELDS = ("bot_id", "bot_token")
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION LOADING
|
||||
# =============================================
|
||||
|
||||
def load_telegram_config() -> Optional[dict]:
|
||||
"""
|
||||
Load Telegram configuration from config file.
|
||||
|
||||
Config file location: ~/.aipass/telegram_config.json
|
||||
Expected structure:
|
||||
{
|
||||
"telegram_bot_token": "...",
|
||||
"telegram_bot_username": "aipass_bridge_bot",
|
||||
"allowed_user_ids": []
|
||||
}
|
||||
|
||||
Returns:
|
||||
Configuration dict or None if load fails
|
||||
"""
|
||||
try:
|
||||
if not CONFIG_PATH.exists():
|
||||
return None
|
||||
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_bot_token() -> Optional[str]:
|
||||
"""
|
||||
Get Telegram bot token from config.
|
||||
|
||||
Returns:
|
||||
Bot token string or None if not found
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
token = config.get("telegram_bot_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_bot_username() -> Optional[str]:
|
||||
"""
|
||||
Get Telegram bot username from config.
|
||||
|
||||
Returns:
|
||||
Bot username string or None if not found
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
username = config.get("telegram_bot_username")
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return username
|
||||
|
||||
|
||||
def get_allowed_user_ids() -> List[int]:
|
||||
"""
|
||||
Get list of allowed Telegram user IDs from config.
|
||||
|
||||
Returns:
|
||||
List of allowed user IDs. Empty list means allow all (for testing).
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return []
|
||||
|
||||
allowed = config.get("allowed_user_ids", [])
|
||||
if not isinstance(allowed, list):
|
||||
return []
|
||||
|
||||
return [int(uid) for uid in allowed if isinstance(uid, (int, str))]
|
||||
|
||||
|
||||
def validate_config() -> bool:
|
||||
"""
|
||||
Validate that Telegram configuration is complete.
|
||||
|
||||
Returns:
|
||||
True if config is valid, False otherwise
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return False
|
||||
|
||||
if not config.get("telegram_bot_token"):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# MULTI-BOT CONFIGURATION (per-bot configs)
|
||||
# =============================================
|
||||
|
||||
|
||||
def load_bot_config(bot_id: str) -> dict | None:
|
||||
"""
|
||||
Load per-bot config from ~/.aipass/telegram_bots/{bot_id}.json.
|
||||
|
||||
Config format:
|
||||
{
|
||||
"bot_id": "dev_central",
|
||||
"bot_token": "123:ABC...",
|
||||
"bot_name": "AIPass Dev Central Bot",
|
||||
"branch_name": "dev_central", // null for base bot
|
||||
"work_dir": "/home/aipass/aipass_os/dev_central",
|
||||
"allowed_user_ids": [7235222625]
|
||||
}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier matching the config filename.
|
||||
|
||||
Returns:
|
||||
Config dict or None if not found/invalid.
|
||||
"""
|
||||
config_path = BOT_CONFIG_DIR / f"{bot_id}.json"
|
||||
|
||||
try:
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def list_bot_configs() -> list[str]:
|
||||
"""
|
||||
List all bot config files (returns list of bot_ids).
|
||||
|
||||
Scans ~/.aipass/telegram_bots/ for .json files, excluding
|
||||
internal files that start with underscore (e.g., _registry.json).
|
||||
|
||||
Returns:
|
||||
List of bot_id strings derived from config filenames.
|
||||
"""
|
||||
if not BOT_CONFIG_DIR.exists():
|
||||
return []
|
||||
|
||||
bot_ids = []
|
||||
try:
|
||||
for path in sorted(BOT_CONFIG_DIR.glob("*.json")):
|
||||
# Skip internal files (e.g., _registry.json)
|
||||
if path.stem.startswith("_"):
|
||||
continue
|
||||
bot_ids.append(path.stem)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
return bot_ids
|
||||
|
||||
|
||||
def validate_bot_config(config: object) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate a bot config dict.
|
||||
|
||||
Checks for required fields and basic type correctness.
|
||||
|
||||
Args:
|
||||
config: Bot config dict to validate.
|
||||
|
||||
Returns:
|
||||
Tuple of (valid, error_message). error_message is empty on success.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return False, "Config must be a dict"
|
||||
|
||||
# Check required fields
|
||||
for field in REQUIRED_BOT_FIELDS:
|
||||
if not config.get(field):
|
||||
return False, f"Missing required field: {field}"
|
||||
|
||||
# Type checks
|
||||
bot_token = config.get("bot_token", "")
|
||||
if not isinstance(bot_token, str) or ":" not in bot_token:
|
||||
return False, "bot_token must be a string in format 'id:hash'"
|
||||
|
||||
if "work_dir" in config and config["work_dir"] is not None:
|
||||
work_dir = Path(config["work_dir"])
|
||||
if not work_dir.is_absolute():
|
||||
return False, "work_dir must be an absolute path"
|
||||
|
||||
if "allowed_user_ids" in config:
|
||||
allowed = config["allowed_user_ids"]
|
||||
if not isinstance(allowed, list):
|
||||
return False, "allowed_user_ids must be a list"
|
||||
|
||||
return True, ""
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: file_handler.py - Telegram File Upload Handler
|
||||
# Date: 2026-02-10
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-10): Initial file handler - photo, document, file upload support (Phase 5 FPLAN-0312)
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram File Upload Handler
|
||||
|
||||
Handles file uploads from Telegram:
|
||||
- Downloads files to temp directory
|
||||
- Detects file type (text, image, pdf, binary)
|
||||
- Builds Claude prompts with file content inline or path reference
|
||||
- Cleans up temp files after processing
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
# Constants
|
||||
TEMP_DIR = Path('/tmp/telegram_uploads')
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
TEXT_CONTENT_LIMIT = 50000
|
||||
|
||||
# Text file extensions that can be read as UTF-8
|
||||
SUPPORTED_TEXT_EXTENSIONS = {
|
||||
'.py', '.js', '.ts', '.java', '.go', '.rs', '.rb', '.php', '.c', '.cpp',
|
||||
'.h', '.hpp', '.cs', '.swift', '.kt',
|
||||
'.sh', '.bash', '.zsh', '.sql', '.html', '.css', '.scss',
|
||||
'.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.cfg',
|
||||
'.md', '.txt', '.rst', '.log', '.csv', '.env', '.gitignore', '.dockerfile',
|
||||
}
|
||||
|
||||
# Image file extensions
|
||||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'}
|
||||
|
||||
# Map file extensions to language names for code blocks
|
||||
LANGUAGE_MAP = {
|
||||
'.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.java': 'java',
|
||||
'.go': 'go', '.rs': 'rust', '.rb': 'ruby', '.php': 'php', '.c': 'c',
|
||||
'.cpp': 'cpp', '.h': 'c', '.cs': 'csharp', '.swift': 'swift', '.kt': 'kotlin',
|
||||
'.sh': 'bash', '.bash': 'bash', '.sql': 'sql', '.html': 'html', '.css': 'css',
|
||||
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml',
|
||||
'.toml': 'toml', '.md': 'markdown',
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_filename(raw_filename: str) -> str:
|
||||
"""
|
||||
Sanitize a filename by removing path separators and dangerous characters.
|
||||
|
||||
Args:
|
||||
raw_filename: The original filename to sanitize
|
||||
|
||||
Returns:
|
||||
A safe filename string
|
||||
"""
|
||||
safe_name = Path(raw_filename).name
|
||||
safe_name = "".join(c if c.isalnum() or c in '.-_' else '_' for c in safe_name)
|
||||
return safe_name or str(uuid.uuid4())
|
||||
|
||||
|
||||
async def download_telegram_file(file_obj, filename: str | None = None) -> Path:
|
||||
"""
|
||||
Download a Telegram file to the temp directory.
|
||||
|
||||
Args:
|
||||
file_obj: Telegram File object (from get_file())
|
||||
filename: Optional original filename
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file
|
||||
|
||||
Raises:
|
||||
ValueError: If file exceeds MAX_FILE_SIZE
|
||||
"""
|
||||
if file_obj.file_size and file_obj.file_size > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"File too large: {file_obj.file_size} bytes "
|
||||
f"(max {MAX_FILE_SIZE // (1024 * 1024)}MB)"
|
||||
)
|
||||
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if filename:
|
||||
safe_name = _sanitize_filename(filename)
|
||||
else:
|
||||
ext = ''
|
||||
if file_obj.file_path:
|
||||
ext = Path(file_obj.file_path).suffix
|
||||
safe_name = f"{uuid.uuid4()}{ext}"
|
||||
|
||||
dest = TEMP_DIR / safe_name
|
||||
await file_obj.download_to_drive(dest)
|
||||
print("[INFO]", "Downloaded file to %s (%s bytes)", dest, file_obj.file_size)
|
||||
return dest
|
||||
|
||||
|
||||
def detect_file_type(file_path: Path) -> str:
|
||||
"""
|
||||
Detect the type of a file based on extension and content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
One of: 'text', 'image', 'pdf', 'binary'
|
||||
"""
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
if suffix in SUPPORTED_TEXT_EXTENSIONS:
|
||||
return 'text'
|
||||
|
||||
if suffix in IMAGE_EXTENSIONS:
|
||||
return 'image'
|
||||
|
||||
if suffix == '.pdf':
|
||||
return 'pdf'
|
||||
|
||||
# Unknown extension - try reading as UTF-8
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
sample = f.read(1024)
|
||||
sample.decode('utf-8')
|
||||
return 'text'
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return 'binary'
|
||||
|
||||
|
||||
def build_file_prompt(
|
||||
file_path: Path,
|
||||
file_type: str,
|
||||
caption: str | None = None,
|
||||
sender_name: str = 'Patrick'
|
||||
) -> str:
|
||||
"""
|
||||
Build a Claude prompt that includes file content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the downloaded file
|
||||
file_type: One of 'text', 'image', 'pdf', 'binary'
|
||||
caption: Optional caption from the Telegram message
|
||||
sender_name: Name of the sender
|
||||
|
||||
Returns:
|
||||
Formatted prompt string for Claude
|
||||
"""
|
||||
FILE_NAME = file_path.name
|
||||
|
||||
if file_type == 'text':
|
||||
try:
|
||||
FILE_CONTENT = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
except OSError:
|
||||
FILE_CONTENT = '[Error reading file]'
|
||||
|
||||
if len(FILE_CONTENT) > TEXT_CONTENT_LIMIT:
|
||||
FILE_CONTENT = FILE_CONTENT[:TEXT_CONTENT_LIMIT] + '\n[...truncated]'
|
||||
|
||||
FILE_SUFFIX = file_path.suffix.lower()
|
||||
FILE_LANGUAGE = LANGUAGE_MAP.get(FILE_SUFFIX, '')
|
||||
|
||||
PROMPT_CAPTION = caption or 'Review this file'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"File: {FILE_NAME}\n\n"
|
||||
f"```{FILE_LANGUAGE}\n{FILE_CONTENT}\n```"
|
||||
)
|
||||
|
||||
elif file_type == 'image':
|
||||
PROMPT_CAPTION = caption or 'What do you see in this image?'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[Image attached at: {file_path}]\n"
|
||||
f"Please use the Read tool to view the image file at the path above."
|
||||
)
|
||||
|
||||
elif file_type == 'pdf':
|
||||
PROMPT_CAPTION = caption or 'Review this document'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[PDF document at: {file_path}]\n"
|
||||
f"Please use the Read tool to view the PDF file at the path above."
|
||||
)
|
||||
|
||||
else: # binary
|
||||
try:
|
||||
FILE_SIZE = file_path.stat().st_size
|
||||
except OSError:
|
||||
FILE_SIZE = 0
|
||||
PROMPT_CAPTION = caption or 'I sent a file'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[File at: {file_path}] (binary, {FILE_SIZE} bytes)\n"
|
||||
f"Note: This is a binary file that may not be directly readable."
|
||||
)
|
||||
|
||||
|
||||
def cleanup_file(file_path: Path) -> None:
|
||||
"""
|
||||
Remove a temporary file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to clean up
|
||||
"""
|
||||
file_path.unlink(missing_ok=True)
|
||||
print("[INFO]", "Cleaned up temp file: %s", file_path)
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: log_streamer.py - Stream system logs to Telegram
|
||||
# Date: 2026-02-26
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-26): Initial - daemon thread log tailing with batched Telegram delivery
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Uses Prax get_direct_logger() — no event pipeline (FPLAN-0382 migration)
|
||||
# - Silent failure on Telegram errors (log warning, never crash)
|
||||
# - Handlers implement logic, modules orchestrate
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
LogStreamer - Stream system log lines to a Telegram chat.
|
||||
|
||||
v1.0.0
|
||||
|
||||
Runs as a background daemon thread, tailing log files for a specific branch
|
||||
and batching new lines to send via the Telegram Bot API. Tracks file positions
|
||||
to only deliver new content, handles file rotation, and discovers new log files
|
||||
each cycle.
|
||||
|
||||
Usage:
|
||||
streamer = LogStreamer(bot_token="...", chat_id=123456, branch_name="api")
|
||||
streamer.start()
|
||||
# ... later ...
|
||||
streamer.stop()
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, List
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Logging (Prax direct logger — FPLAN-0382, no event pipeline)
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
SYSTEM_LOGS_DIR = Path("/home/aipass/system_logs")
|
||||
BATCH_INTERVAL = 5.0
|
||||
TELEGRAM_MAX_LENGTH = 4000
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOG STREAMER
|
||||
# =============================================
|
||||
|
||||
|
||||
class LogStreamer:
|
||||
"""Stream system log lines for a branch to Telegram via batched sends."""
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.branch_name = branch_name
|
||||
|
||||
self._running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self.log_positions: Dict[str, int] = {}
|
||||
|
||||
# Direct logger (no event pipeline — avoids recursion with log tailing)
|
||||
self.logger = get_direct_logger()
|
||||
|
||||
# Initialize positions to end of all existing log files
|
||||
self._init_positions()
|
||||
|
||||
# -----------------------------------------
|
||||
# POSITION TRACKING
|
||||
# -----------------------------------------
|
||||
|
||||
def _get_log_files(self) -> List[Path]:
|
||||
"""Find all log files matching this branch's pattern."""
|
||||
if not SYSTEM_LOGS_DIR.exists():
|
||||
return []
|
||||
return sorted(SYSTEM_LOGS_DIR.glob(f"{self.branch_name}_*.log"))
|
||||
|
||||
def _init_positions(self) -> None:
|
||||
"""Set initial positions to end of file so we only tail new lines."""
|
||||
for log_file in self._get_log_files():
|
||||
file_path = str(log_file)
|
||||
try:
|
||||
self.log_positions[file_path] = log_file.stat().st_size
|
||||
except OSError:
|
||||
self.log_positions[file_path] = 0
|
||||
self.logger.info(
|
||||
"Initialized positions for %d log files (branch: %s)",
|
||||
len(self.log_positions), self.branch_name
|
||||
)
|
||||
|
||||
def _read_new_lines(self) -> List[str]:
|
||||
"""Read new lines from all tracked log files."""
|
||||
all_new_lines: List[str] = []
|
||||
|
||||
for log_file in self._get_log_files():
|
||||
file_path = str(log_file)
|
||||
|
||||
try:
|
||||
current_size = log_file.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
last_pos = self.log_positions.get(file_path, 0)
|
||||
|
||||
try:
|
||||
# New file discovered mid-run: start from beginning
|
||||
if file_path not in self.log_positions:
|
||||
last_pos = 0
|
||||
self.logger.info("New log file discovered: %s", file_path)
|
||||
|
||||
# File rotation: size shrank, reset to beginning
|
||||
if current_size < last_pos:
|
||||
self.logger.info("File rotation detected: %s", file_path)
|
||||
last_pos = 0
|
||||
|
||||
# Read new content
|
||||
if current_size > last_pos:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
f.seek(last_pos)
|
||||
new_content = f.read()
|
||||
self.log_positions[file_path] = f.tell()
|
||||
|
||||
lines = new_content.splitlines()
|
||||
if lines:
|
||||
all_new_lines.extend(lines)
|
||||
else:
|
||||
# Update position even when nothing new (handles new file registration)
|
||||
self.log_positions[file_path] = current_size
|
||||
except OSError as e:
|
||||
self.logger.warning("Failed to process %s: %s", file_path, e)
|
||||
continue
|
||||
|
||||
return all_new_lines
|
||||
|
||||
# -----------------------------------------
|
||||
# TELEGRAM DELIVERY
|
||||
# -----------------------------------------
|
||||
|
||||
def _send_message(self, message: str) -> bool:
|
||||
"""Send a message to Telegram. Returns True on success."""
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = json.dumps({
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"disable_notification": True
|
||||
}).encode("utf-8")
|
||||
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception) as e:
|
||||
self.logger.warning("Telegram send failed: %s", e)
|
||||
return False
|
||||
|
||||
def _send_batched(self, lines: List[str]) -> None:
|
||||
"""Split lines into messages respecting TELEGRAM_MAX_LENGTH, send each."""
|
||||
if not lines:
|
||||
return
|
||||
|
||||
batch: List[str] = []
|
||||
batch_len = 0
|
||||
|
||||
for line in lines:
|
||||
# +1 for the newline separator between lines
|
||||
line_len = len(line) + (1 if batch else 0)
|
||||
|
||||
if batch_len + line_len > TELEGRAM_MAX_LENGTH and batch:
|
||||
# Send current batch
|
||||
message = "\n".join(batch)
|
||||
self._send_message(message)
|
||||
batch = []
|
||||
batch_len = 0
|
||||
|
||||
batch.append(line)
|
||||
batch_len += line_len
|
||||
|
||||
# Send remaining
|
||||
if batch:
|
||||
message = "\n".join(batch)
|
||||
self._send_message(message)
|
||||
|
||||
# -----------------------------------------
|
||||
# DAEMON THREAD
|
||||
# -----------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Main loop: read new lines, batch, send, sleep."""
|
||||
self.logger.info("Log streamer started for branch: %s", self.branch_name)
|
||||
self.logger.info("Watching: %s/%s_*.log (chat_id=%s)", SYSTEM_LOGS_DIR, self.branch_name, self.chat_id)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
new_lines = self._read_new_lines()
|
||||
if new_lines:
|
||||
self.logger.info("Found %d new log lines, sending to Telegram", len(new_lines))
|
||||
self._send_batched(new_lines)
|
||||
except Exception as e:
|
||||
self.logger.warning("Streamer cycle error: %s", e)
|
||||
|
||||
# Interruptible sleep
|
||||
self._stop_event.wait(BATCH_INTERVAL)
|
||||
|
||||
self.logger.info("Log streamer stopped for branch: %s", self.branch_name)
|
||||
|
||||
# -----------------------------------------
|
||||
# PUBLIC API
|
||||
# -----------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the log streamer daemon thread."""
|
||||
if self._running:
|
||||
self.logger.warning("Log streamer already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"log-streamer-{self.branch_name}",
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self.logger.info("Daemon thread started: %s", self._thread.name)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the log streamer and wait for thread to finish."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=BATCH_INTERVAL + 2)
|
||||
if self._thread.is_alive():
|
||||
self.logger.warning("Daemon thread did not exit cleanly")
|
||||
self._thread = None
|
||||
|
||||
self.logger.info("Log streamer stopped")
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: notifier.py - Telegram Push Notifications
|
||||
# Date: 2026-02-17
|
||||
# Version: 1.1.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-18): DPLAN-005 - Add silent mode, CLI interface for cross-branch use, markdown support
|
||||
# - v1.0.0 (2026-02-17): Initial implementation - reusable Telegram notification sender
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handlers implement logic, modules orchestrate
|
||||
# - No cross-branch imports, no Prax logger
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram notification sender for the scheduler bot.
|
||||
|
||||
Can be used two ways:
|
||||
1. Import (within API branch): send_telegram_notification("message")
|
||||
2. CLI (cross-branch, no import guard): python3 notifier.py "message"
|
||||
Flags: --silent (silent push), --markdown (Markdown parse mode)
|
||||
|
||||
Reads bot token and chat_id from ~/.aipass/scheduler_config.json.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
CONFIG_PATH = Path.home() / ".aipass" / "scheduler_config.json"
|
||||
|
||||
# =============================================
|
||||
# PUBLIC API
|
||||
# =============================================
|
||||
|
||||
|
||||
def send_telegram_notification(
|
||||
message: str,
|
||||
silent: bool = False,
|
||||
parse_mode: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send a message to Telegram via the scheduler bot.
|
||||
|
||||
Args:
|
||||
message: Text to send (plain text or Markdown)
|
||||
silent: If True, send as silent notification (no sound on phone)
|
||||
parse_mode: Telegram parse mode ("Markdown" or "HTML"). None for plain text.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
bot_token = config["telegram_bot_token"]
|
||||
chat_id = config["telegram_chat_id"]
|
||||
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload_dict: dict[str, object] = {"chat_id": chat_id, "text": message}
|
||||
if silent:
|
||||
payload_dict["disable_notification"] = True
|
||||
if parse_mode:
|
||||
payload_dict["parse_mode"] = parse_mode
|
||||
|
||||
data = json.dumps(payload_dict).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLI INTERFACE (cross-branch use)
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
if not args or args[0] in ("-h", "--help"):
|
||||
print("Usage: python3 notifier.py [--silent] [--markdown] \"message\"")
|
||||
print(" --silent Send as silent notification (no sound)")
|
||||
print(" --markdown Use Telegram Markdown parse mode")
|
||||
sys.exit(0)
|
||||
|
||||
silent_flag = "--silent" in args
|
||||
markdown_flag = "--markdown" in args
|
||||
msg_args = [a for a in args if not a.startswith("--")]
|
||||
|
||||
if not msg_args:
|
||||
print("Error: no message provided", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
msg = " ".join(msg_args)
|
||||
mode = "Markdown" if markdown_flag else None
|
||||
ok = send_telegram_notification(msg, silent=silent_flag, parse_mode=mode)
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: output_parser.py - Claude Stream JSON Output Parser
|
||||
# Date: 2026-02-10
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-10): Initial - parse stream-json from Claude CLI
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Claude Stream JSON Output Parser
|
||||
|
||||
Parses newline-delimited JSON output from Claude CLI when invoked with
|
||||
--output-format stream-json --verbose.
|
||||
|
||||
Output format (3 line types):
|
||||
1. system/init - session_id, model, tools
|
||||
2. assistant - message content blocks (text, tool_use)
|
||||
3. result - success/error, session_id, cost, duration
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Infrastructure
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
sys.path.insert(0, str(AIPASS_ROOT))
|
||||
|
||||
logger = logging.getLogger("telegram_output_parser")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedResult:
|
||||
"""Structured result from parsing Claude stream-json output."""
|
||||
success: bool
|
||||
text: str
|
||||
session_id: Optional[str] = None
|
||||
cost_usd: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class OutputParser:
|
||||
"""Parse Claude CLI stream-json output into structured results."""
|
||||
|
||||
@staticmethod
|
||||
def parse_stream(raw_output: str) -> ParsedResult:
|
||||
"""
|
||||
Parse raw stream-json output from Claude CLI.
|
||||
|
||||
Splits output by newlines, parses each line as JSON, and extracts:
|
||||
- Text from assistant message content blocks (type='text' only)
|
||||
- session_id from result line
|
||||
- cost from result line total_cost_usd
|
||||
- Error detection from result line is_error / subtype='error'
|
||||
|
||||
Falls back to returning raw text if JSON parsing fails entirely.
|
||||
|
||||
Args:
|
||||
raw_output: Raw stdout from Claude CLI with --output-format stream-json
|
||||
|
||||
Returns:
|
||||
ParsedResult with extracted fields
|
||||
"""
|
||||
if not raw_output or not raw_output.strip():
|
||||
return ParsedResult(success=False, text="", error_message="Empty output")
|
||||
|
||||
text_parts: List[str] = []
|
||||
session_id: Optional[str] = None
|
||||
cost_usd: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
is_error = False
|
||||
error_message: Optional[str] = None
|
||||
parsed_any = False
|
||||
|
||||
for line in raw_output.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Skipping non-JSON line: %s", line[:100])
|
||||
continue
|
||||
|
||||
parsed_any = True
|
||||
line_type = data.get("type")
|
||||
|
||||
if line_type == "assistant":
|
||||
# Extract text from content blocks
|
||||
message = data.get("message", {})
|
||||
content_blocks = message.get("content", [])
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
|
||||
elif line_type == "result":
|
||||
# Extract session_id, cost, duration, error status
|
||||
session_id = data.get("session_id", session_id)
|
||||
cost_usd = data.get("total_cost_usd", cost_usd)
|
||||
duration_ms = data.get("duration_ms", duration_ms)
|
||||
|
||||
if data.get("is_error", False) or data.get("subtype") == "error":
|
||||
is_error = True
|
||||
error_message = data.get("result", "Unknown error")
|
||||
|
||||
elif line_type == "system":
|
||||
# Init line - capture session_id as fallback
|
||||
if not session_id:
|
||||
session_id = data.get("session_id")
|
||||
|
||||
# If we couldn't parse any JSON at all, fall back to raw text
|
||||
if not parsed_any:
|
||||
logger.warning("No valid JSON lines found, returning raw output")
|
||||
return ParsedResult(success=True, text=raw_output.strip())
|
||||
|
||||
combined_text = "\n".join(text_parts) if text_parts else ""
|
||||
|
||||
if is_error:
|
||||
return ParsedResult(
|
||||
success=False,
|
||||
text=combined_text,
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
if not combined_text:
|
||||
return ParsedResult(
|
||||
success=False,
|
||||
text="",
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
error_message="No text content in response",
|
||||
)
|
||||
|
||||
return ParsedResult(
|
||||
success=True,
|
||||
text=combined_text,
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: response_router.py - CWD-safe response routing for multi-bot architecture
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - CWD-safe pending file matching with directory tree resolution
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Stdlib only (no external deps)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CWD-Safe Response Routing for Multi-Bot Architecture
|
||||
|
||||
Fixes the CWD mismatch bug in the Stop hook. When Claude fires the Stop hook,
|
||||
the working directory may be a subdirectory of the branch root (e.g.,
|
||||
/home/aipass/aipass_os/dev_central/git_repo/ instead of
|
||||
/home/aipass/aipass_os/dev_central/). The old logic used Path.cwd().name
|
||||
which fails in subdirectories.
|
||||
|
||||
New logic uses cwd.relative_to(work_dir) which succeeds if CWD is ANYWHERE
|
||||
in the bot's directory tree.
|
||||
|
||||
Pending file naming:
|
||||
- v2 (new): bot-{bot_id}.json
|
||||
- v1 (legacy): telegram-{branch_name}.json
|
||||
Both formats are supported during the transition period.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
|
||||
PENDING_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
# =============================================
|
||||
# DIRECTORY TREE MATCHING
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_cwd_in_tree(cwd: Path, work_dir) -> bool:
|
||||
"""
|
||||
Check if cwd is within work_dir's directory tree using relative_to().
|
||||
|
||||
This is the core fix for the CWD mismatch bug. Instead of comparing
|
||||
directory names (which fails in subdirectories), we check if cwd is
|
||||
a child of work_dir at any depth.
|
||||
|
||||
Args:
|
||||
cwd: Current working directory to check.
|
||||
work_dir: Bot's configured working directory (str or Path).
|
||||
|
||||
Returns:
|
||||
True if cwd is within work_dir's tree, False otherwise.
|
||||
"""
|
||||
try:
|
||||
cwd.relative_to(Path(work_dir))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# TMUX SESSION CHECKING
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_tmux_alive(session_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists.
|
||||
|
||||
Args:
|
||||
session_name: Name of the tmux session to check.
|
||||
|
||||
Returns:
|
||||
True if the session exists, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", session_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# PENDING FILE EXPIRY
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_pending_expired(pending_data: dict) -> bool:
|
||||
"""
|
||||
Check if a pending file is expired.
|
||||
|
||||
A pending file is considered expired only when BOTH conditions are met:
|
||||
1. The timestamp is older than PENDING_TTL seconds
|
||||
2. The associated tmux session is no longer alive
|
||||
|
||||
This prevents premature cleanup of pending files for long-running sessions.
|
||||
|
||||
Args:
|
||||
pending_data: Parsed contents of a pending file.
|
||||
|
||||
Returns:
|
||||
True if the pending file should be cleaned up, False otherwise.
|
||||
"""
|
||||
# Condition 1: Check TTL
|
||||
timestamp = pending_data.get("timestamp", 0)
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
timestamp = 0
|
||||
|
||||
if time.time() - timestamp <= PENDING_TTL:
|
||||
return False # Still within TTL, not expired
|
||||
|
||||
# Condition 2: Check tmux session
|
||||
# Derive session name from bot_id or branch_name
|
||||
bot_id = pending_data.get("bot_id", "")
|
||||
branch_name = pending_data.get("branch_name", "")
|
||||
|
||||
# Try the bot_id-based tmux session name first (v2)
|
||||
if bot_id:
|
||||
if is_tmux_alive(f"telegram-{bot_id}"):
|
||||
return False # Session alive, not expired
|
||||
|
||||
# Try the branch-based tmux session name (v1)
|
||||
if branch_name and branch_name != bot_id:
|
||||
if is_tmux_alive(f"telegram-{branch_name}"):
|
||||
return False # Session alive, not expired
|
||||
|
||||
# Past TTL AND no tmux session alive
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# PENDING FILE LOADING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _load_pending_file(pending_path: Path) -> Optional[dict]:
|
||||
"""
|
||||
Load and parse a pending file from disk.
|
||||
|
||||
Args:
|
||||
pending_path: Path to the pending JSON file.
|
||||
|
||||
Returns:
|
||||
Parsed dict with "pending_path" key added, or None on error.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(pending_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
data["pending_path"] = str(pending_path)
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN ROUTING LOGIC
|
||||
# =============================================
|
||||
|
||||
|
||||
def find_pending_bot(
|
||||
cwd: Optional[Path] = None,
|
||||
session_id: Optional[str] = None,
|
||||
env_bot_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Find which bot's pending file matches the current context.
|
||||
|
||||
Uses a priority-based matching strategy:
|
||||
|
||||
Priority 1: AIPASS_BOT_ID env var (set in tmux session by BaseBot)
|
||||
Direct match: look for bot-{env_bot_id}.json
|
||||
|
||||
Priority 2: cwd.relative_to(work_dir) - CWD anywhere in bot's directory tree
|
||||
Load each pending file, check if cwd is within its work_dir
|
||||
|
||||
Priority 3: session_id match - fallback for legacy compatibility
|
||||
Check session_id field in each pending file
|
||||
|
||||
Args:
|
||||
cwd: Current working directory. Defaults to Path.cwd().
|
||||
session_id: Claude Code session ID for fallback matching.
|
||||
env_bot_id: Bot ID from environment. Defaults to AIPASS_BOT_ID env var.
|
||||
|
||||
Returns:
|
||||
Pending file data dict with "pending_path" key, or None if no match.
|
||||
"""
|
||||
if not PENDING_DIR.exists():
|
||||
return None
|
||||
|
||||
if cwd is None:
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
cwd = Path.home()
|
||||
|
||||
if env_bot_id is None:
|
||||
env_bot_id = os.environ.get("AIPASS_BOT_ID")
|
||||
|
||||
# Priority 1: Direct match via AIPASS_BOT_ID env var
|
||||
if env_bot_id:
|
||||
# v2 naming: bot-{bot_id}.json
|
||||
PENDING_V2 = PENDING_DIR / f"bot-{env_bot_id}.json"
|
||||
if PENDING_V2.exists():
|
||||
data = _load_pending_file(PENDING_V2)
|
||||
if data and not is_pending_expired(data):
|
||||
logger.info("Matched pending by AIPASS_BOT_ID: %s", env_bot_id)
|
||||
return data
|
||||
|
||||
# Also check v1 naming for this bot_id
|
||||
PENDING_V1 = PENDING_DIR / f"telegram-{env_bot_id}.json"
|
||||
if PENDING_V1.exists():
|
||||
data = _load_pending_file(PENDING_V1)
|
||||
if data and not is_pending_expired(data):
|
||||
logger.info("Matched pending by AIPASS_BOT_ID (v1 naming): %s", env_bot_id)
|
||||
return data
|
||||
|
||||
# Priority 2: CWD directory tree matching
|
||||
# Check all pending files and see if CWD is within any bot's work_dir
|
||||
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
|
||||
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
continue
|
||||
|
||||
work_dir = data.get("work_dir", "")
|
||||
if work_dir and is_cwd_in_tree(cwd, work_dir):
|
||||
logger.info("Matched pending by CWD tree: cwd=%s within work_dir=%s", cwd, work_dir)
|
||||
return data
|
||||
|
||||
# Legacy v1 files may not have work_dir - try branch_name directory matching
|
||||
branch_name = data.get("branch_name", "")
|
||||
if branch_name and not work_dir:
|
||||
# CWD's directory name or any parent matches branch_name
|
||||
path_cursor = cwd
|
||||
while path_cursor != path_cursor.parent:
|
||||
if path_cursor.name == branch_name:
|
||||
logger.info("Matched pending by branch name in CWD path: %s", branch_name)
|
||||
return data
|
||||
path_cursor = path_cursor.parent
|
||||
|
||||
# Priority 3: Session ID fallback
|
||||
if session_id:
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
continue
|
||||
|
||||
if data.get("session_id") == session_id:
|
||||
logger.info("Matched pending by session_id: %s", session_id[:8])
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLEANUP
|
||||
# =============================================
|
||||
|
||||
|
||||
def clean_expired_pending() -> int:
|
||||
"""
|
||||
Remove all expired pending files from the pending directory.
|
||||
|
||||
A file is expired when it is past TTL AND its tmux session is dead.
|
||||
|
||||
Returns:
|
||||
Number of expired files removed.
|
||||
"""
|
||||
if not PENDING_DIR.exists():
|
||||
return 0
|
||||
|
||||
REMOVED_COUNT = 0
|
||||
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
|
||||
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
# Corrupt or unreadable file - remove it
|
||||
try:
|
||||
pending_path.unlink(missing_ok=True)
|
||||
REMOVED_COUNT += 1
|
||||
logger.info("Removed corrupt pending file: %s", pending_path.name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove corrupt pending file %s: %s", pending_path.name, e)
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
try:
|
||||
pending_path.unlink(missing_ok=True)
|
||||
REMOVED_COUNT += 1
|
||||
logger.info("Removed expired pending file: %s", pending_path.name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove expired pending file %s: %s", pending_path.name, e)
|
||||
|
||||
if REMOVED_COUNT > 0:
|
||||
logger.info("Cleaned %d expired pending file(s)", REMOVED_COUNT)
|
||||
|
||||
return REMOVED_COUNT
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: spawner.py - Claude Session Spawner
|
||||
# Date: 2026-02-03
|
||||
# Version: 4.3.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v4.3.0 (2026-02-12): Branch targeting - @branch prefix routes Claude to branch CWD
|
||||
# - v4.2.0 (2026-02-10): Add raw_prompt flag to run_claude_capture (Phase 5 FPLAN-0312)
|
||||
# - v4.1.0 (2026-02-10): Session persistence - use stored session_id for resume, deterministic as fallback
|
||||
# - v4.0.0 (2026-02-10): Stream JSON output parsing - structured results with session_id, cost
|
||||
# - v3.2.0 (2026-02-10): Security fix - subprocess_exec for capture mode (no shell injection)
|
||||
# - v3.1.0 (2026-02-09): Fix session ID collision - random UUID for new sessions, deterministic for resume only
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Claude Session Spawner
|
||||
|
||||
Two modes of operation:
|
||||
1. Visual mode: Spawn visible sessions (gnome-terminal/tmux) - fire and forget
|
||||
2. Capture mode: Run Claude directly and capture stdout for response
|
||||
|
||||
Supports branch targeting: messages starting with @branch_name spawn Claude
|
||||
in that branch's directory. Default: /home/aipass/aipass_os/dev_central.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
import shutil
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
# Infrastructure
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
sys.path.insert(0, str(AIPASS_ROOT))
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("telegram_spawner")
|
||||
|
||||
from api.apps.handlers.telegram.output_parser import OutputParser, ParsedResult
|
||||
from api.apps.handlers.telegram.session_store import get_session, save_session
|
||||
|
||||
# Constants
|
||||
DEFAULT_SESSION_PATH = Path.home() / "aipass_os" / "dev_central"
|
||||
BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json"
|
||||
CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude")
|
||||
TMUX_SESSION_NAME = "telegram-claude"
|
||||
TELEGRAM_CHAR_LIMIT = 4096
|
||||
DEFAULT_TIMEOUT = 120 # seconds
|
||||
|
||||
# Backwards compatibility
|
||||
SESSION_PATH = DEFAULT_SESSION_PATH
|
||||
|
||||
|
||||
def resolve_branch_target(message: str) -> Tuple[str, Path]:
|
||||
"""
|
||||
Extract @branch target from message and resolve to a directory path.
|
||||
|
||||
If message starts with @branch_name, look up the branch in BRANCH_REGISTRY.json
|
||||
and return the cleaned message (without the @branch prefix) and the branch path.
|
||||
If no @branch prefix or branch not found, returns original message and DEFAULT_SESSION_PATH.
|
||||
|
||||
Returns:
|
||||
Tuple of (cleaned_message, target_path)
|
||||
"""
|
||||
match = re.match(r'^@(\w+)\s*(.*)', message, re.DOTALL)
|
||||
if not match:
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
branch_name = match.group(1).lower()
|
||||
rest_of_message = match.group(2).strip()
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
branches = registry.get("branches", [])
|
||||
for branch_entry in branches:
|
||||
# Match against email (@branch_name) or name field
|
||||
clean_email = branch_entry.get("email", "").replace("@", "").lower()
|
||||
if clean_email == branch_name:
|
||||
branch_path = Path(branch_entry.get("path", ""))
|
||||
if branch_path.is_dir():
|
||||
logger.info("Branch target resolved: @%s -> %s", branch_name, branch_path)
|
||||
return rest_of_message or "hi", branch_path
|
||||
else:
|
||||
logger.warning("Branch path not found on disk: %s", branch_path)
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning("Failed to resolve branch @%s: %s", branch_name, e)
|
||||
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
|
||||
def has_display() -> bool:
|
||||
"""Check if DISPLAY environment variable is set (GUI available)."""
|
||||
return bool(os.environ.get("DISPLAY"))
|
||||
|
||||
|
||||
def has_gnome_terminal() -> bool:
|
||||
"""Check if gnome-terminal is available."""
|
||||
return shutil.which("gnome-terminal") is not None
|
||||
|
||||
|
||||
def has_tmux() -> bool:
|
||||
"""Check if tmux is available."""
|
||||
return shutil.which("tmux") is not None
|
||||
|
||||
|
||||
def build_prompt(message: str) -> str:
|
||||
"""
|
||||
Build the Claude prompt from Telegram message (shell-escaped).
|
||||
|
||||
Used by visual mode (gnome-terminal, tmux) which still requires shell escaping.
|
||||
|
||||
Args:
|
||||
message: The message text
|
||||
|
||||
Returns:
|
||||
Formatted prompt string with shell-safe escaping
|
||||
"""
|
||||
# Escape single quotes for shell safety
|
||||
safe_message = message.replace("'", "'\"'\"'")
|
||||
return f"Patrick via Telegram: {safe_message}"
|
||||
|
||||
|
||||
def build_prompt_clean(message: str) -> str:
|
||||
"""
|
||||
Build the Claude prompt from Telegram message (no shell escaping).
|
||||
|
||||
Used by capture mode where subprocess_exec passes args directly,
|
||||
bypassing the shell entirely.
|
||||
|
||||
Args:
|
||||
message: The message text
|
||||
|
||||
Returns:
|
||||
Formatted prompt string (raw, no escaping needed)
|
||||
"""
|
||||
return f"Patrick via Telegram: {message}"
|
||||
|
||||
|
||||
def spawn_gnome_terminal(prompt: str) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Spawn Claude in a visible gnome-terminal window.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to pass to Claude
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, pid or None)
|
||||
"""
|
||||
try:
|
||||
# Escape prompt for shell
|
||||
safe_prompt = prompt.replace("'", "'\"'\"'")
|
||||
|
||||
# Build command: gnome-terminal spawns, runs claude, stays open
|
||||
cmd = [
|
||||
"gnome-terminal",
|
||||
"--",
|
||||
"bash", "-c",
|
||||
f"cd {SESSION_PATH} && claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions; exec bash"
|
||||
]
|
||||
|
||||
logger.info("Spawning gnome-terminal at %s", SESSION_PATH)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
return True, f"gnome-terminal spawned (PID: {process.pid})", process.pid
|
||||
|
||||
except Exception as e:
|
||||
logger.error("gnome-terminal spawn failed: %s", e)
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def spawn_tmux(prompt: str) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Spawn Claude in a tmux session (headless fallback).
|
||||
|
||||
Creates or attaches to session named 'telegram-claude'.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to pass to Claude
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, pid or None)
|
||||
"""
|
||||
try:
|
||||
# Escape prompt for shell
|
||||
safe_prompt = prompt.replace("'", "'\"'\"'")
|
||||
|
||||
# Check if session already exists
|
||||
check_cmd = ["tmux", "has-session", "-t", TMUX_SESSION_NAME]
|
||||
session_exists = subprocess.run(
|
||||
check_cmd,
|
||||
capture_output=True
|
||||
).returncode == 0
|
||||
|
||||
if session_exists:
|
||||
# Kill existing session to start fresh
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
|
||||
capture_output=True
|
||||
)
|
||||
logger.info("Killed existing tmux session: %s", TMUX_SESSION_NAME)
|
||||
|
||||
# Create new session with Claude
|
||||
cmd = [
|
||||
"tmux", "new-session",
|
||||
"-d", # Detached
|
||||
"-s", TMUX_SESSION_NAME,
|
||||
"-c", str(SESSION_PATH), # Working directory
|
||||
f"claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions"
|
||||
]
|
||||
|
||||
logger.info("Spawning tmux session '%s' at %s", TMUX_SESSION_NAME, SESSION_PATH)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
msg = f"tmux session '{TMUX_SESSION_NAME}' created. Attach with: tmux attach -t {TMUX_SESSION_NAME}"
|
||||
return True, msg, None # tmux doesn't give us the claude PID directly
|
||||
else:
|
||||
return False, f"tmux exited with code {process.returncode}", None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("tmux spawn failed: %s", e)
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def spawn_claude_session(sender_name: str, message: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Spawn a visible Claude session for a Telegram message.
|
||||
|
||||
Strategy:
|
||||
1. If DISPLAY exists and gnome-terminal available → use gnome-terminal
|
||||
2. Otherwise if tmux available → use tmux
|
||||
3. If neither available → return error
|
||||
|
||||
Args:
|
||||
sender_name: Telegram sender name/username
|
||||
message: The message text from Telegram
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_message)
|
||||
"""
|
||||
_ = sender_name # Reserved for future use (e.g., multi-user filtering)
|
||||
prompt = build_prompt(message)
|
||||
|
||||
# Strategy 1: gnome-terminal (GUI)
|
||||
if has_display() and has_gnome_terminal():
|
||||
logger.info("Using gnome-terminal (DISPLAY available)")
|
||||
success, msg, _ = spawn_gnome_terminal(prompt)
|
||||
return success, msg
|
||||
|
||||
# Strategy 2: tmux (headless)
|
||||
if has_tmux():
|
||||
logger.info("Using tmux (headless fallback)")
|
||||
success, msg, _ = spawn_tmux(prompt)
|
||||
return success, msg
|
||||
|
||||
# No spawner available
|
||||
error_msg = "No spawner available. Install gnome-terminal (GUI) or tmux (headless)."
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
# =============================================
|
||||
# RESPONSE CHUNKING
|
||||
# =============================================
|
||||
|
||||
def chunk_response(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> List[str]:
|
||||
"""
|
||||
Split text into chunks for Telegram's message limit.
|
||||
|
||||
Attempts to split at sentence boundaries when possible.
|
||||
|
||||
Args:
|
||||
text: The full response text
|
||||
limit: Maximum characters per chunk (default 4096)
|
||||
|
||||
Returns:
|
||||
List of text chunks, each within the limit
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
remaining = text
|
||||
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
# Try to find a sentence boundary within limit
|
||||
chunk = remaining[:limit]
|
||||
|
||||
# Look for sentence endings (. ! ?) followed by space or newline
|
||||
best_break = -1
|
||||
for i in range(len(chunk) - 1, max(0, len(chunk) - 500), -1):
|
||||
if chunk[i] in '.!?' and (i + 1 >= len(chunk) or chunk[i + 1] in ' \n'):
|
||||
best_break = i + 1
|
||||
break
|
||||
|
||||
# If no sentence boundary, try paragraph break
|
||||
if best_break == -1:
|
||||
newline_pos = chunk.rfind('\n\n')
|
||||
if newline_pos > limit // 2:
|
||||
best_break = newline_pos + 2
|
||||
|
||||
# If still no good break, try single newline
|
||||
if best_break == -1:
|
||||
newline_pos = chunk.rfind('\n')
|
||||
if newline_pos > limit // 2:
|
||||
best_break = newline_pos + 1
|
||||
|
||||
# Last resort: break at space
|
||||
if best_break == -1:
|
||||
space_pos = chunk.rfind(' ')
|
||||
if space_pos > limit // 2:
|
||||
best_break = space_pos + 1
|
||||
|
||||
# Ultimate fallback: hard break at limit
|
||||
if best_break == -1:
|
||||
best_break = limit
|
||||
|
||||
chunks.append(remaining[:best_break].rstrip())
|
||||
remaining = remaining[best_break:].lstrip()
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
# =============================================
|
||||
# CAPTURE MODE - Run Claude and capture output
|
||||
# =============================================
|
||||
|
||||
def _chat_id_to_uuid(chat_id: int) -> str:
|
||||
"""Generate a deterministic UUID from a Telegram chat ID."""
|
||||
namespace = uuid.UUID("a1a55000-0000-4000-8000-000000000000")
|
||||
return str(uuid.uuid5(namespace, str(chat_id)))
|
||||
|
||||
|
||||
async def _run_claude_cmd(
|
||||
args: List[str],
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Execute a Claude CLI command and capture output.
|
||||
|
||||
Uses subprocess_exec (no shell) to prevent shell injection attacks.
|
||||
Parses stream-json output via OutputParser.
|
||||
|
||||
Args:
|
||||
args: Command arguments as a list (e.g. [CLAUDE_BIN, '-p', prompt, ...])
|
||||
timeout: Maximum seconds to wait for response
|
||||
target_cwd: Working directory for the Claude process (defaults to DEFAULT_SESSION_PATH)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text or error_message, session_id or None)
|
||||
"""
|
||||
session_cwd = target_cwd or DEFAULT_SESSION_PATH
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=session_cwd
|
||||
)
|
||||
|
||||
try:
|
||||
raw_stdout, raw_stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return False, f"Claude timed out after {timeout} seconds", None
|
||||
|
||||
if process.returncode != 0:
|
||||
error_text = raw_stderr.decode('utf-8', errors='replace').strip()
|
||||
return False, f"Claude error: {error_text or 'Unknown error'}", None
|
||||
|
||||
raw_output = raw_stdout.decode('utf-8', errors='replace').strip()
|
||||
|
||||
if not raw_output:
|
||||
return False, "Claude returned empty response", None
|
||||
|
||||
# Parse stream-json output
|
||||
result = OutputParser.parse_stream(raw_output)
|
||||
|
||||
if result.success:
|
||||
logger.info(
|
||||
"Parsed response: %d chars, session=%s, cost=$%.4f",
|
||||
len(result.text),
|
||||
result.session_id or "unknown",
|
||||
result.cost_usd or 0.0,
|
||||
)
|
||||
return True, result.text, result.session_id
|
||||
else:
|
||||
error = result.error_message or "Parse failed"
|
||||
logger.warning("Parse error: %s", error)
|
||||
return False, error, result.session_id
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Capture failed: {str(e)}", None
|
||||
|
||||
|
||||
async def run_claude_capture(
|
||||
message: str,
|
||||
chat_id: int = 0,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
raw_prompt: bool = False,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Run Claude CLI and capture its response with session persistence.
|
||||
|
||||
Strategy (when chat_id provided):
|
||||
1. Look up stored session_id → try --resume with it
|
||||
2. If no stored or resume fails → create new with random UUID
|
||||
3. If random create fails → fall back to deterministic UUID
|
||||
|
||||
Args:
|
||||
message: The message/prompt to send to Claude
|
||||
chat_id: Telegram chat ID for session persistence
|
||||
timeout: Maximum seconds to wait for response
|
||||
raw_prompt: If True, use message as-is (skip build_prompt_clean)
|
||||
target_cwd: Working directory for the Claude process (branch targeting)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text or error_message, session_id or None)
|
||||
"""
|
||||
if raw_prompt:
|
||||
prompt = message
|
||||
else:
|
||||
prompt = build_prompt_clean(message)
|
||||
|
||||
if chat_id:
|
||||
# Strategy 1: Try stored session_id from session_store
|
||||
stored = get_session(chat_id)
|
||||
if stored and stored.get("session_id"):
|
||||
stored_id = stored["session_id"]
|
||||
resume_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--resume', stored_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
logger.info("Attempting resume with stored session %s for chat %s", stored_id[:8], chat_id)
|
||||
|
||||
success, response, session_id = await _run_claude_cmd(resume_args, timeout, target_cwd=target_cwd)
|
||||
if success:
|
||||
logger.info("Stored session resumed - response captured (%d chars)", len(response))
|
||||
return True, response, session_id
|
||||
|
||||
logger.info("Stored session resume failed for chat %s, creating new session", chat_id)
|
||||
|
||||
# Strategy 2: Create new session with random UUID
|
||||
random_id = str(uuid.uuid4())
|
||||
logger.info("Creating new session for chat %s with random ID %s", chat_id, random_id[:8])
|
||||
create_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--session-id', random_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
success, response, session_id = await _run_claude_cmd(create_args, timeout, target_cwd=target_cwd)
|
||||
if success:
|
||||
return True, response, session_id
|
||||
|
||||
# Strategy 3: Deterministic UUID as last resort
|
||||
deterministic_id = _chat_id_to_uuid(chat_id)
|
||||
logger.info("Random create failed, falling back to deterministic ID %s", deterministic_id[:8])
|
||||
fallback_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--resume', deterministic_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
return await _run_claude_cmd(fallback_args, timeout, target_cwd=target_cwd)
|
||||
else:
|
||||
# No chat_id - one-shot with no session ID
|
||||
one_shot_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
logger.info("Running Claude in one-shot mode (timeout: %ds)", timeout)
|
||||
return await _run_claude_cmd(one_shot_args, timeout, target_cwd=target_cwd)
|
||||
|
||||
|
||||
async def spawn_and_capture(
|
||||
sender_name: str,
|
||||
message: str,
|
||||
chat_id: int = 0,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, List[str], Optional[str]]:
|
||||
"""
|
||||
Run Claude and return chunked response for Telegram.
|
||||
|
||||
This is the main entry point for capture mode.
|
||||
|
||||
Args:
|
||||
sender_name: Telegram sender name/username (for logging)
|
||||
message: The message text from Telegram
|
||||
chat_id: Telegram chat ID (for logging)
|
||||
timeout: Maximum seconds to wait for Claude
|
||||
target_cwd: Working directory for the Claude process (branch targeting)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, list_of_response_chunks, session_id or None)
|
||||
"""
|
||||
logger.info("spawn_and_capture called by %s (chat_id: %s)", sender_name, chat_id)
|
||||
|
||||
success, response, session_id = await run_claude_capture(message, chat_id, timeout, target_cwd=target_cwd)
|
||||
|
||||
if not success:
|
||||
return False, [response], session_id
|
||||
|
||||
chunks = chunk_response(response)
|
||||
logger.info("Response split into %d chunk(s)", len(chunks))
|
||||
|
||||
return True, chunks, session_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the spawner
|
||||
print("=" * 60)
|
||||
print("TELEGRAM CLAUDE SPAWNER")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Environment check:")
|
||||
print(f" DISPLAY: {os.environ.get('DISPLAY', 'Not set')}")
|
||||
print(f" gnome-terminal: {'Available' if has_gnome_terminal() else 'Not found'}")
|
||||
print(f" tmux: {'Available' if has_tmux() else 'Not found'}")
|
||||
print()
|
||||
print(f"Session path: {SESSION_PATH}")
|
||||
print(f"tmux session name: {TMUX_SESSION_NAME}")
|
||||
print()
|
||||
print("Would spawn with:")
|
||||
if has_display() and has_gnome_terminal():
|
||||
print(" → gnome-terminal (GUI mode)")
|
||||
elif has_tmux():
|
||||
print(" → tmux (headless mode)")
|
||||
else:
|
||||
print(" → ERROR: No spawner available")
|
||||
print()
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: telegram_standards.py - Shared Telegram Bot Standards
|
||||
# Date: 2026-02-15
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-15): Initial - shared commands, templates, and utilities for all bots
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - stdlib ONLY (importable by all bots)
|
||||
# - Provides text generation, not message delivery
|
||||
# - Single source of truth for standard bot commands
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Shared Telegram Bot Standards for AIPass
|
||||
|
||||
Central definitions for standard commands, response templates, and text
|
||||
builders used by ALL AIPass Telegram bots (bridge, assistant, test, scheduler).
|
||||
|
||||
Two bot types exist in AIPass:
|
||||
- Async bots (python-telegram-bot): Use the text builders directly and
|
||||
deliver via their own async handlers.
|
||||
- Sync bots (stdlib urllib): Use parse_command() and handle_standard_command()
|
||||
to process commands, then deliver via their own send functions.
|
||||
|
||||
This module has ZERO external dependencies (stdlib only). Every bot can import it.
|
||||
|
||||
Usage (async bot):
|
||||
from aipass.api.apps.handlers.telegram.telegram_standards import (
|
||||
build_welcome_text, build_status_text, STANDARD_COMMANDS,
|
||||
)
|
||||
|
||||
Usage (sync bot):
|
||||
from aipass.api.apps.handlers.telegram.telegram_standards import (
|
||||
parse_command, handle_standard_command, STANDARD_COMMANDS,
|
||||
)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# =============================================
|
||||
# STANDARD COMMAND REGISTRY
|
||||
# =============================================
|
||||
|
||||
STANDARD_COMMANDS: dict[str, dict[str, str]] = {
|
||||
"start": {
|
||||
"description": "Welcome message and command list",
|
||||
"menu_text": "Start / welcome message",
|
||||
},
|
||||
"help": {
|
||||
"description": "Show available commands",
|
||||
"menu_text": "Show help",
|
||||
},
|
||||
"new": {
|
||||
"description": "Kill current session and start fresh (clean Claude context)",
|
||||
"menu_text": "Fresh session",
|
||||
},
|
||||
"status": {
|
||||
"description": "Show session info (branch, uptime, session state)",
|
||||
"menu_text": "Session status",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# RESPONSE TEMPLATES
|
||||
# =============================================
|
||||
|
||||
PROCESSING_MSG = "Processing..."
|
||||
|
||||
ERROR_TEMPLATE = "Something went wrong: {error}"
|
||||
|
||||
HELP_FOOTER = "\nSend any message to chat with Claude."
|
||||
|
||||
# Internal templates (used by builder functions)
|
||||
_WELCOME_HEADER = "Hello! I'm {bot_name}."
|
||||
_WELCOME_BRANCH = "Branch: @{branch_name}"
|
||||
|
||||
_STATUS_HEADER = "Session Status"
|
||||
|
||||
|
||||
# =============================================
|
||||
# TEXT BUILDERS
|
||||
# =============================================
|
||||
|
||||
def _format_command_list(
|
||||
standard_commands: dict[str, dict[str, str]],
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format a combined command list as readable text.
|
||||
|
||||
Each command appears as: /command - description
|
||||
|
||||
Args:
|
||||
standard_commands: The STANDARD_COMMANDS dict (or a subset).
|
||||
custom_commands: Optional additional commands in the same format.
|
||||
|
||||
Returns:
|
||||
Multi-line string of formatted commands.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for cmd, info in standard_commands.items():
|
||||
lines.append(f"/{cmd} - {info['description']}")
|
||||
if custom_commands:
|
||||
for cmd, info in custom_commands.items():
|
||||
lines.append(f"/{cmd} - {info['description']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_help_text(
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build a /help message combining standard and custom commands.
|
||||
|
||||
Args:
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
Formatted help text string.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
parts: list[str] = [
|
||||
"Commands:",
|
||||
_format_command_list(standard_commands, custom_commands),
|
||||
HELP_FOOTER,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_welcome_text(
|
||||
bot_name: str,
|
||||
branch_name: str,
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the /start welcome message.
|
||||
|
||||
Args:
|
||||
bot_name: Display name of the bot (e.g., "AIPass Bridge Bot").
|
||||
branch_name: The branch this bot operates on (e.g., "dev_central").
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
Formatted welcome text string.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
parts: list[str] = [
|
||||
_WELCOME_HEADER.format(bot_name=bot_name),
|
||||
_WELCOME_BRANCH.format(branch_name=branch_name),
|
||||
"",
|
||||
"Commands:",
|
||||
_format_command_list(standard_commands, custom_commands),
|
||||
HELP_FOOTER,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_status_text(
|
||||
session_name: str,
|
||||
branch_name: str,
|
||||
uptime: Optional[str] = None,
|
||||
message_count: Optional[int] = None,
|
||||
chat_id: Optional[str | int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the /status response.
|
||||
|
||||
Checks tmux session state via subprocess. Reports branch, session,
|
||||
activity status, and optional metrics.
|
||||
|
||||
Args:
|
||||
session_name: tmux session name (e.g., "telegram-assistant").
|
||||
branch_name: Branch name (e.g., "assistant").
|
||||
uptime: Optional human-readable uptime string.
|
||||
message_count: Optional count of messages processed.
|
||||
chat_id: Optional Telegram chat ID to display.
|
||||
|
||||
Returns:
|
||||
Formatted status text string.
|
||||
"""
|
||||
active = _tmux_session_exists(session_name)
|
||||
|
||||
lines: list[str] = [_STATUS_HEADER]
|
||||
if chat_id is not None:
|
||||
lines.append(f"Chat ID: {chat_id}")
|
||||
lines.append(f"Branch: @{branch_name}")
|
||||
lines.append(f"Session: {session_name}")
|
||||
lines.append(f"State: {'Active' if active else 'Inactive'}")
|
||||
if uptime:
|
||||
lines.append(f"Uptime: {uptime}")
|
||||
if message_count is not None:
|
||||
lines.append(f"Messages: {message_count}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_botfather_commands(
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Build command list for BotFather setMyCommands API.
|
||||
|
||||
Returns the format expected by Telegram's setMyCommands endpoint:
|
||||
[{"command": "start", "description": "Start / welcome message"}, ...]
|
||||
|
||||
Args:
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
List of dicts with "command" and "description" keys.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
result: list[dict[str, str]] = []
|
||||
for cmd, info in standard_commands.items():
|
||||
result.append({"command": cmd, "description": info["menu_text"]})
|
||||
if custom_commands:
|
||||
for cmd, info in custom_commands.items():
|
||||
result.append({"command": cmd, "description": info["menu_text"]})
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYNC BOT UTILITIES (stdlib bots)
|
||||
# =============================================
|
||||
|
||||
def parse_command(text: str) -> Optional[tuple[str, str]]:
|
||||
"""
|
||||
Extract command name and arguments from message text.
|
||||
|
||||
Handles both '/command' and '/command@bot_username' formats.
|
||||
Returns None if the text is not a command.
|
||||
|
||||
Args:
|
||||
text: Raw message text from Telegram.
|
||||
|
||||
Returns:
|
||||
Tuple of (command_name, args_string) or None if not a command.
|
||||
command_name is lowercase without the leading slash.
|
||||
args_string is everything after the command, stripped.
|
||||
|
||||
Examples:
|
||||
parse_command("/status") -> ("status", "")
|
||||
parse_command("/new please") -> ("new", "please")
|
||||
parse_command("/help@mybot") -> ("help", "")
|
||||
parse_command("hello world") -> None
|
||||
"""
|
||||
if not text or not text.startswith("/"):
|
||||
return None
|
||||
|
||||
# Split on whitespace: first part is /command[@botname], rest is args
|
||||
parts = text.split(None, 1)
|
||||
raw_command = parts[0][1:] # Remove leading /
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Strip @bot_username suffix if present
|
||||
if "@" in raw_command:
|
||||
raw_command = raw_command.split("@", 1)[0]
|
||||
|
||||
command = raw_command.lower().strip()
|
||||
if not command:
|
||||
return None
|
||||
|
||||
return (command, args.strip())
|
||||
|
||||
|
||||
def handle_standard_command(
|
||||
command: str,
|
||||
session_name: str,
|
||||
branch_name: str,
|
||||
bot_name: str,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
chat_id: Optional[str | int] = None,
|
||||
message_count: Optional[int] = None,
|
||||
uptime: Optional[str] = None,
|
||||
) -> Optional[str | tuple[str, str]]:
|
||||
"""
|
||||
Handle a standard command and return the response text.
|
||||
|
||||
For most commands, returns a string with the response text.
|
||||
For /new, returns a tuple ("new", instructions_text) to signal
|
||||
the caller that they need to kill and restart their tmux session.
|
||||
The caller is responsible for tmux operations and for sending
|
||||
the response text.
|
||||
|
||||
Returns None if the command is not a standard command.
|
||||
|
||||
Args:
|
||||
command: The command name (lowercase, no slash).
|
||||
session_name: tmux session name (e.g., "telegram-assistant").
|
||||
branch_name: Branch name (e.g., "assistant").
|
||||
bot_name: Display name of the bot.
|
||||
custom_commands: Optional bot-specific commands for help text.
|
||||
chat_id: Optional Telegram chat ID (for /status display).
|
||||
message_count: Optional message count (for /status display).
|
||||
uptime: Optional uptime string (for /status display).
|
||||
|
||||
Returns:
|
||||
- str: Response text for /start, /help, /status
|
||||
- tuple[str, str]: ("new", response_text) for /new command
|
||||
- None: Command is not a standard command
|
||||
"""
|
||||
if command == "start":
|
||||
return build_welcome_text(
|
||||
bot_name=bot_name,
|
||||
branch_name=branch_name,
|
||||
custom_commands=custom_commands,
|
||||
)
|
||||
|
||||
if command == "help":
|
||||
return build_help_text(custom_commands=custom_commands)
|
||||
|
||||
if command == "new":
|
||||
response_text = f"Session cleared for @{branch_name}. Next message starts fresh."
|
||||
return ("new", response_text)
|
||||
|
||||
if command == "status":
|
||||
return build_status_text(
|
||||
session_name=session_name,
|
||||
branch_name=branch_name,
|
||||
uptime=uptime,
|
||||
message_count=message_count,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTERNAL HELPERS
|
||||
# =============================================
|
||||
|
||||
def _tmux_session_exists(session_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists by name.
|
||||
|
||||
Args:
|
||||
session_name: The tmux session name to check.
|
||||
|
||||
Returns:
|
||||
True if the session is running, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", session_name],
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
# tmux not installed
|
||||
return False
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: tmux_manager.py - tmux Session Manager for Telegram Bridge
|
||||
# Date: 2026-02-12
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + auto /rename for Claude session identification
|
||||
# - v1.1.0 (2026-02-24): Add bot_id awareness — AIPASS_BOT_ID env var in tmux sessions
|
||||
# - v1.0.0 (2026-02-12): Initial - tmux session create/kill/send/list for persistent Claude sessions
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
tmux Session Manager for Telegram Bridge
|
||||
|
||||
Manages persistent Claude Code sessions in tmux:
|
||||
- Create named sessions (telegram-{branch_name}) running Claude Code
|
||||
- Inject messages via tmux send-keys -l (literal mode)
|
||||
- Kill/list sessions
|
||||
- Capture pane content for status display
|
||||
|
||||
Each tmux session runs `claude --permission-mode bypassPermissions` continuously.
|
||||
Messages are injected via send-keys, responses captured via Stop hook.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# Constants
|
||||
SESSION_PREFIX = "telegram-"
|
||||
DEFAULT_BRANCH = "dev_central"
|
||||
CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude")
|
||||
SEND_KEYS_DELAY = 0.5 # seconds between text injection and Enter
|
||||
|
||||
|
||||
RENAME_DELAY = 3 # seconds to wait for Claude to initialize before /rename
|
||||
|
||||
|
||||
def _session_name(branch_name: str) -> str:
|
||||
"""Build tmux session name from branch name."""
|
||||
return f"{SESSION_PREFIX}{branch_name}"
|
||||
|
||||
|
||||
def _send_rename(session_name: str, branch_name: str) -> None:
|
||||
"""Send /rename to a tmux session after Claude initializes."""
|
||||
time.sleep(RENAME_DELAY)
|
||||
rename_cmd = f"/rename {branch_name.upper()}-telegram"
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", session_name, rename_cmd, "Enter"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def has_tmux() -> bool:
|
||||
"""Check if tmux is available on the system."""
|
||||
return shutil.which("tmux") is not None
|
||||
|
||||
|
||||
def session_exists(branch_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists for the given branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (e.g. 'dev_central')
|
||||
|
||||
Returns:
|
||||
True if session is alive
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", name],
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def create_session(branch_name: str, branch_path: Path, *, bot_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Create a tmux session and launch Claude Code inside it.
|
||||
|
||||
Session is named telegram-{branch_name} and starts in branch_path.
|
||||
Claude is launched with AIPASS_SESSION_TYPE=telegram and
|
||||
--permission-mode bypassPermissions. After initialization,
|
||||
sends /rename BRANCH-telegram for the /resume picker.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name for session naming
|
||||
branch_path: Working directory for Claude Code
|
||||
bot_id: Optional bot ID — sets AIPASS_BOT_ID env var in tmux session
|
||||
|
||||
Returns:
|
||||
True if session was created successfully
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if session_exists(branch_name):
|
||||
print("[INFO]", "Session %s already exists", name)
|
||||
return True
|
||||
|
||||
if not branch_path.is_dir():
|
||||
print("[ERROR]", "Branch path does not exist: %s", branch_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create detached tmux session
|
||||
result = subprocess.run(
|
||||
[
|
||||
"tmux", "new-session",
|
||||
"-d", # Detached
|
||||
"-s", name, # Session name
|
||||
"-c", str(branch_path), # Working directory
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to create tmux session %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
# Set bot_id environment variable if provided
|
||||
if bot_id:
|
||||
subprocess.run(
|
||||
["tmux", "set-environment", "-t", name, "AIPASS_BOT_ID", bot_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Launch Claude Code inside the session
|
||||
# Explicit cd guarantees CWD even if shell profile drifts it
|
||||
# AIPASS_SESSION_TYPE=telegram lets drone status label this session
|
||||
claude_cmd = (
|
||||
f"cd '{branch_path}' && "
|
||||
f"AIPASS_SESSION_TYPE=telegram {CLAUDE_BIN} --permission-mode bypassPermissions"
|
||||
)
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, claude_cmd, "Enter"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Rename the Claude conversation for the /resume picker
|
||||
# Claude needs a few seconds to initialize before /rename works
|
||||
_send_rename(name, branch_name)
|
||||
|
||||
print("[INFO]", "Created tmux session %s at %s", name, branch_path)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error creating tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
async def send_message(branch_name: str, message: str) -> bool:
|
||||
"""
|
||||
Inject a message into a tmux session via send-keys.
|
||||
|
||||
Uses -l flag for literal mode (no shell interpretation).
|
||||
Sends text first, waits briefly, then sends Enter.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
message: The message text to inject
|
||||
|
||||
Returns:
|
||||
True if message was sent successfully
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
print("[ERROR]", "Session %s does not exist", name)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Send text literally (no shell interpretation)
|
||||
result = subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, "-l", message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to send text to %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
# Wait before sending Enter (prevents rapid keystroke issues)
|
||||
await asyncio.sleep(SEND_KEYS_DELAY)
|
||||
|
||||
# Send Enter to submit the message
|
||||
result = subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, "Enter"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to send Enter to %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
print("[INFO]", "Injected message into %s (%d chars)", name, len(message))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error sending to tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
def kill_session(branch_name: str) -> bool:
|
||||
"""
|
||||
Kill a tmux session for the given branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
|
||||
Returns:
|
||||
True if session was killed (or didn't exist)
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
print("[INFO]", "Session %s does not exist, nothing to kill", name)
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "kill-session", "-t", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("[INFO]", "Killed tmux session %s", name)
|
||||
return True
|
||||
else:
|
||||
print("[ERROR]", "Failed to kill session %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error killing tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
def list_sessions() -> List[str]:
|
||||
"""
|
||||
List all active telegram-* tmux sessions.
|
||||
|
||||
Returns:
|
||||
List of branch names with active sessions
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(SESSION_PREFIX):
|
||||
branch = line[len(SESSION_PREFIX):]
|
||||
if branch:
|
||||
sessions.append(branch)
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_session_pane(branch_name: str) -> Optional[str]:
|
||||
"""
|
||||
Capture current visible pane content from a tmux session.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
|
||||
Returns:
|
||||
Pane content as string, or None if session doesn't exist
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "capture-pane", "-t", name, "-p"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,29 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Telegram Service Handler Package
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial package
|
||||
# =============================================
|
||||
|
||||
"""Telegram Service Handler - systemd control operations"""
|
||||
|
||||
from aipass.api.apps.handlers.telegram_service.service import (
|
||||
start_service,
|
||||
stop_service,
|
||||
get_status,
|
||||
get_logs,
|
||||
SERVICE_NAME,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"start_service",
|
||||
"stop_service",
|
||||
"get_status",
|
||||
"get_logs",
|
||||
"SERVICE_NAME",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: service.py - Telegram Service Handler
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial handler - systemd service operations
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Service Handler
|
||||
|
||||
Low-level systemd operations for telegram-bridge service.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
SERVICE_NAME = "telegram-bridge"
|
||||
LOG_FILE = Path.home() / "system_logs" / "telegram_bridge.log"
|
||||
|
||||
|
||||
def start_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Start the telegram-bridge service
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "start", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, "Service started"
|
||||
return False, result.stderr.strip() if result.stderr else "Unknown error"
|
||||
|
||||
|
||||
def stop_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Stop the telegram-bridge service
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, "Service stopped"
|
||||
return False, result.stderr.strip() if result.stderr else "Unknown error"
|
||||
|
||||
|
||||
def get_status() -> Tuple[str, str]:
|
||||
"""
|
||||
Get telegram-bridge service status
|
||||
|
||||
Returns:
|
||||
Tuple of (status_code, details)
|
||||
status_code: 'running', 'stopped', 'not_found', 'unknown'
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "status", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
output = result.stdout if result.stdout else result.stderr
|
||||
|
||||
if "Active: active (running)" in output:
|
||||
return "running", output
|
||||
elif "Active: inactive (dead)" in output:
|
||||
return "stopped", output
|
||||
elif "could not be found" in output.lower():
|
||||
return "not_found", output
|
||||
return "unknown", output
|
||||
|
||||
|
||||
def get_logs(lines: int = 30) -> Tuple[bool, str]:
|
||||
"""
|
||||
Get recent service logs
|
||||
|
||||
Args:
|
||||
lines: Number of recent lines to return
|
||||
|
||||
Returns:
|
||||
Tuple of (success, log_content or error_message)
|
||||
"""
|
||||
if not LOG_FILE.exists():
|
||||
return False, f"No log file found at {LOG_FILE}"
|
||||
|
||||
try:
|
||||
with open(LOG_FILE, encoding="utf-8") as f:
|
||||
all_lines = f.readlines()
|
||||
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||
|
||||
if not recent:
|
||||
return False, "Log file is empty"
|
||||
|
||||
return True, "".join(recent)
|
||||
except OSError as e:
|
||||
return False, f"Failed to read logs: {e}"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Usage Tracking Domain
|
||||
|
||||
Handlers for API usage monitoring and cost tracking.
|
||||
Query generation metrics, aggregate statistics, and data cleanup.
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: aggregation.py - Usage Aggregation Handler
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/usage
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial handler - usage stats aggregation
|
||||
# - v1.1.0 (2025-11-16): Extracted aggregation logic from archive
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Usage Aggregation Handler
|
||||
|
||||
Business logic for aggregating usage statistics:
|
||||
- Calculate per-caller stats from usage data
|
||||
- Daily/monthly rollups and summaries
|
||||
- Cost, token, and latency aggregation
|
||||
- Model usage tracking and breakdown
|
||||
|
||||
Extracted from: /home/aipass/aipass_core/api/apps/archive.temp/api_usage.py
|
||||
Functions: get_caller_usage(), get_session_summary(), get_daily_usage()
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Standard library for JSON operations
|
||||
import json
|
||||
|
||||
|
||||
# =============================================
|
||||
# MODULE CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "aggregation"
|
||||
DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern
|
||||
# Navigate: aggregation.py -> usage/ -> handlers/ -> apps/ -> api/
|
||||
API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json"
|
||||
|
||||
|
||||
# =============================================
|
||||
# AGGREGATION FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def get_caller_usage(caller: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate usage statistics for specific caller
|
||||
|
||||
Args:
|
||||
caller: Module name that made API calls
|
||||
|
||||
Returns:
|
||||
Dict with requests, total_cost, total_tokens, models_used, last_request
|
||||
Returns empty dict {} if no data found
|
||||
"""
|
||||
try:
|
||||
# Load usage data from JSON
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
if not data_path.exists():
|
||||
# logger.info(f"[{MODULE_NAME}] No usage data file found")
|
||||
return {}
|
||||
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data or "data" not in data:
|
||||
# logger.info(f"[{MODULE_NAME}] No usage data available")
|
||||
return {}
|
||||
|
||||
# Extract caller-specific data
|
||||
usage_by_caller = data["data"].get("usage_by_caller", {})
|
||||
caller_data = usage_by_caller.get(caller, {})
|
||||
|
||||
if not caller_data:
|
||||
# logger.info(f"[{MODULE_NAME}] No usage data found for caller: {caller}")
|
||||
return {}
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Retrieved usage stats for {caller}: {caller_data.get('requests', 0)} requests")
|
||||
return caller_data
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to get caller usage for {caller}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_session_summary(session_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Aggregate current session usage totals
|
||||
|
||||
Args:
|
||||
session_id: Optional session identifier (unused, for future support)
|
||||
|
||||
Returns:
|
||||
Dict with start_time, total_requests, total_cost, total_tokens
|
||||
Returns empty dict {} if no session data found
|
||||
"""
|
||||
try:
|
||||
# Load usage data from JSON
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
if not data_path.exists():
|
||||
# logger.info(f"[{MODULE_NAME}] No session data file found")
|
||||
return {}
|
||||
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data or "data" not in data:
|
||||
# logger.info(f"[{MODULE_NAME}] No session data available")
|
||||
return {}
|
||||
|
||||
# Extract session summary
|
||||
session_data = data["data"].get("current_session", {})
|
||||
|
||||
if not session_data:
|
||||
# logger.info(f"[{MODULE_NAME}] No session summary found")
|
||||
return {}
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Retrieved session summary: {session_data.get('total_requests', 0)} requests")
|
||||
return session_data
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to get session summary: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_daily_usage(date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate daily usage rollup
|
||||
|
||||
Args:
|
||||
date: Date string YYYY-MM-DD format (None = today)
|
||||
|
||||
Returns:
|
||||
Dict with requests, cost, tokens for the date
|
||||
Returns empty dict {} if no data found
|
||||
"""
|
||||
try:
|
||||
# Default to today if no date provided
|
||||
if not date:
|
||||
date = datetime.now().date().isoformat()
|
||||
|
||||
# Load usage data from JSON
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
if not data_path.exists():
|
||||
# logger.info(f"[{MODULE_NAME}] No daily usage data file found")
|
||||
return {}
|
||||
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data or "data" not in data:
|
||||
# logger.info(f"[{MODULE_NAME}] No daily usage data available")
|
||||
return {}
|
||||
|
||||
# Extract daily totals
|
||||
daily_totals = data["data"].get("daily_totals", {})
|
||||
daily_data = daily_totals.get(date, {})
|
||||
|
||||
if not daily_data:
|
||||
# logger.info(f"[{MODULE_NAME}] No usage data found for date: {date}")
|
||||
return {}
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Retrieved daily usage for {date}: {daily_data.get('requests', 0)} requests")
|
||||
return daily_data
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to get daily usage for {date}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def calculate_totals(usage_data: List[Dict]) -> Dict[str, float]:
|
||||
"""
|
||||
Aggregate cost, tokens, latency from usage records
|
||||
|
||||
Args:
|
||||
usage_data: List of dicts with total_cost, tokens_prompt, tokens_completion, latency
|
||||
|
||||
Returns:
|
||||
Dict with total_cost, total_tokens, total_requests, avg_latency, total_latency
|
||||
"""
|
||||
try:
|
||||
if not usage_data:
|
||||
# logger.info(f"[{MODULE_NAME}] No usage data provided for totals calculation")
|
||||
return {
|
||||
"total_cost": 0.0,
|
||||
"total_tokens": 0,
|
||||
"total_requests": 0,
|
||||
"avg_latency": 0.0,
|
||||
"total_latency": 0
|
||||
}
|
||||
|
||||
total_cost = 0.0
|
||||
total_tokens = 0
|
||||
total_latency = 0
|
||||
latency_count = 0
|
||||
|
||||
for record in usage_data:
|
||||
# Aggregate cost
|
||||
total_cost += float(record.get("total_cost", 0))
|
||||
|
||||
# Aggregate tokens (prompt + completion)
|
||||
tokens_prompt = int(record.get("tokens_prompt", 0))
|
||||
tokens_completion = int(record.get("tokens_completion", 0))
|
||||
total_tokens += tokens_prompt + tokens_completion
|
||||
|
||||
# Aggregate latency (optional field)
|
||||
if "latency" in record:
|
||||
total_latency += int(record.get("latency", 0))
|
||||
latency_count += 1
|
||||
|
||||
# Calculate average latency
|
||||
avg_latency = total_latency / latency_count if latency_count > 0 else 0.0
|
||||
|
||||
result = {
|
||||
"total_cost": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
"total_requests": len(usage_data),
|
||||
"avg_latency": avg_latency,
|
||||
"total_latency": total_latency
|
||||
}
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Calculated totals: {result['total_requests']} requests, ${result['total_cost']:.6f}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to calculate totals: {e}")
|
||||
return {
|
||||
"total_cost": 0.0,
|
||||
"total_tokens": 0,
|
||||
"total_requests": 0,
|
||||
"avg_latency": 0.0,
|
||||
"total_latency": 0
|
||||
}
|
||||
|
||||
|
||||
def get_model_breakdown(caller: Optional[str] = None) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Calculate model usage breakdown by caller or globally
|
||||
|
||||
Args:
|
||||
caller: Optional caller name to filter by (None = all callers)
|
||||
|
||||
Returns:
|
||||
Dict of {model_name: {"requests": count}}
|
||||
Returns empty dict {} if no data found
|
||||
"""
|
||||
try:
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
if not data_path.exists():
|
||||
# logger.info(f"[{MODULE_NAME}] No model breakdown data file found")
|
||||
return {}
|
||||
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data or "data" not in data:
|
||||
# logger.info(f"[{MODULE_NAME}] No model breakdown data available")
|
||||
return {}
|
||||
|
||||
model_stats = {}
|
||||
usage_by_caller = data["data"].get("usage_by_caller", {})
|
||||
|
||||
if caller:
|
||||
# Single caller breakdown
|
||||
caller_data = usage_by_caller.get(caller, {})
|
||||
models_used = caller_data.get("models_used", {})
|
||||
for model, count in models_used.items():
|
||||
model_stats[model] = {"requests": count}
|
||||
else:
|
||||
# Global breakdown across all callers
|
||||
for caller_name, caller_data in usage_by_caller.items():
|
||||
models_used = caller_data.get("models_used", {})
|
||||
for model, count in models_used.items():
|
||||
if model not in model_stats:
|
||||
model_stats[model] = {"requests": 0}
|
||||
model_stats[model]["requests"] += count
|
||||
|
||||
# logger.info(f"[{MODULE_NAME}] Retrieved model breakdown: {len(model_stats)} models")
|
||||
return model_stats
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"[{MODULE_NAME}] Failed to get model breakdown: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: cleanup.py - Usage data retention and cleanup
|
||||
# Date: 2025-11-16
|
||||
# Version: 0.1.0
|
||||
# Category: api/handlers
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v0.1.0 (2025-11-16): Extracted from api_usage.py
|
||||
# - v1.0.0 (2025-11-15): Initial handler - old data cleanup
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handler layer (standalone functions)
|
||||
# - Uses CLI service for output
|
||||
# - Under 300 lines
|
||||
# ==============================================
|
||||
|
||||
"""
|
||||
Usage Data Cleanup Handler
|
||||
|
||||
Manages data retention policies and cleanup operations.
|
||||
Removes old generation tracking data based on retention rules.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
# CLI services
|
||||
from aipass.cli.apps.modules import console, success, warning
|
||||
|
||||
|
||||
def _read_json(file_path: Path) -> Optional[Dict]:
|
||||
"""Read JSON file with error handling."""
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return None
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to read JSON from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _write_json(file_path: Path, data: Dict) -> bool:
|
||||
"""Write JSON file with error handling."""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to write JSON to {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_old_data(data_file_path: Path, retention_days: int = 30) -> int:
|
||||
"""
|
||||
Remove usage data older than retention period.
|
||||
|
||||
Args:
|
||||
data_file_path: Path to the usage data JSON file
|
||||
retention_days: Number of days to retain data (default: 30)
|
||||
|
||||
Returns:
|
||||
int: Number of generation entries cleaned up
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now() - timedelta(days=retention_days)
|
||||
data = _read_json(data_file_path)
|
||||
if not data:
|
||||
return 0
|
||||
|
||||
# Extract the actual data content (handle wrapper structure)
|
||||
data_content = data.get("data", data)
|
||||
|
||||
# Identify and remove old generation tracking entries
|
||||
old_generations = _identify_old_generations(
|
||||
data_content.get("generation_tracking", {}),
|
||||
cutoff_date
|
||||
)
|
||||
|
||||
if not old_generations:
|
||||
return 0
|
||||
|
||||
for gen_id in old_generations:
|
||||
del data_content["generation_tracking"][gen_id]
|
||||
|
||||
# Update wrapper if needed
|
||||
if "data" in data:
|
||||
data["data"] = data_content
|
||||
data["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
_write_json(data_file_path, data)
|
||||
# logger.info(f"Cleaned up {len(old_generations)} generation entries")
|
||||
success(f"Cleaned up {len(old_generations)} generation entries older than {retention_days} days")
|
||||
|
||||
return len(old_generations)
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Cleanup failed: {e}")
|
||||
console.print(f"[red]Cleanup failed: {e}[/red]")
|
||||
raise
|
||||
|
||||
|
||||
def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]:
|
||||
"""Identify generation IDs older than cutoff date."""
|
||||
old_generations = []
|
||||
|
||||
for gen_id, gen_data in generation_tracking.items():
|
||||
try:
|
||||
timestamp_str = gen_data.get("timestamp")
|
||||
if not timestamp_str:
|
||||
old_generations.append(gen_id)
|
||||
continue
|
||||
|
||||
gen_date = datetime.fromisoformat(timestamp_str)
|
||||
if gen_date < cutoff_date:
|
||||
old_generations.append(gen_id)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
old_generations.append(gen_id)
|
||||
|
||||
return old_generations
|
||||
|
||||
|
||||
def cleanup_daily_totals(data_file_path: Path, retention_days: int = 90) -> int:
|
||||
"""Remove daily total entries older than retention period."""
|
||||
try:
|
||||
cutoff_date = (datetime.now() - timedelta(days=retention_days)).date()
|
||||
data = _read_json(data_file_path)
|
||||
if not data:
|
||||
return 0
|
||||
|
||||
data_content = data.get("data", data)
|
||||
old_dates = []
|
||||
daily_totals = data_content.get("daily_totals", {})
|
||||
|
||||
for date_str in daily_totals.keys():
|
||||
try:
|
||||
date_obj = datetime.fromisoformat(date_str).date()
|
||||
if date_obj < cutoff_date:
|
||||
old_dates.append(date_str)
|
||||
except (ValueError, TypeError):
|
||||
old_dates.append(date_str)
|
||||
|
||||
if not old_dates:
|
||||
return 0
|
||||
|
||||
for date_str in old_dates:
|
||||
del data_content["daily_totals"][date_str]
|
||||
|
||||
if "data" in data:
|
||||
data["data"] = data_content
|
||||
data["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
_write_json(data_file_path, data)
|
||||
# logger.info(f"Cleaned up {len(old_dates)} daily total entries")
|
||||
success(f"Cleaned up {len(old_dates)} daily total entries older than {retention_days} days")
|
||||
|
||||
return len(old_dates)
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Daily totals cleanup failed: {e}")
|
||||
console.print(f"[red]Daily totals cleanup failed: {e}[/red]")
|
||||
raise
|
||||
|
||||
|
||||
def auto_cleanup(data_file_path: Path, config: Optional[Dict] = None) -> Dict[str, int]:
|
||||
"""Perform automatic cleanup based on configuration."""
|
||||
try:
|
||||
gen_retention = config.get("cleanup_old_data_days", 30) if config else 30
|
||||
daily_retention = config.get("cleanup_daily_totals_days", 90) if config else 90
|
||||
|
||||
generations_removed = cleanup_old_data(data_file_path, gen_retention)
|
||||
daily_totals_removed = cleanup_daily_totals(data_file_path, daily_retention)
|
||||
|
||||
# logger.info(f"Auto cleanup: {generations_removed} generations, {daily_totals_removed} daily totals removed")
|
||||
|
||||
return {
|
||||
"generations_removed": generations_removed,
|
||||
"daily_totals_removed": daily_totals_removed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Auto cleanup failed: {e}")
|
||||
console.print(f"[red]Auto cleanup failed: {e}[/red]")
|
||||
raise
|
||||
|
||||
|
||||
def get_cleanup_stats(data_file_path: Path) -> Dict[str, int]:
|
||||
"""Get statistics about data that could be cleaned up."""
|
||||
empty_stats = {
|
||||
"total_generations": 0,
|
||||
"total_daily_totals": 0,
|
||||
"cleanable_generations": 0,
|
||||
"cleanable_daily_totals": 0
|
||||
}
|
||||
|
||||
try:
|
||||
data = _read_json(data_file_path)
|
||||
if not data:
|
||||
return empty_stats
|
||||
|
||||
data_content = data.get("data", data)
|
||||
generation_tracking = data_content.get("generation_tracking", {})
|
||||
daily_totals = data_content.get("daily_totals", {})
|
||||
|
||||
# Count cleanable generations (older than 30 days)
|
||||
cutoff_30 = datetime.now() - timedelta(days=30)
|
||||
cleanable_gens = len(_identify_old_generations(generation_tracking, cutoff_30))
|
||||
|
||||
# Count cleanable daily totals (older than 90 days)
|
||||
cutoff_90 = (datetime.now() - timedelta(days=90)).date()
|
||||
cleanable_daily = sum(
|
||||
1 for date_str in daily_totals.keys()
|
||||
if _is_old_date(date_str, cutoff_90)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_generations": len(generation_tracking),
|
||||
"total_daily_totals": len(daily_totals),
|
||||
"cleanable_generations": cleanable_gens,
|
||||
"cleanable_daily_totals": cleanable_daily
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# logger.error(f"Failed to get cleanup stats: {e}")
|
||||
return empty_stats
|
||||
|
||||
|
||||
def _is_old_date(date_str: str, cutoff_date) -> bool:
|
||||
"""Check if date string is older than cutoff."""
|
||||
try:
|
||||
date_obj = datetime.fromisoformat(date_str).date()
|
||||
return date_obj < cutoff_date
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: tracking.py - Usage Tracking Handler
|
||||
# Date: 2025-11-16
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/usage
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-16): Extracted tracking logic from archive
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Usage Tracking Handler
|
||||
|
||||
Business logic for tracking API usage from OpenRouter:
|
||||
- Query OpenRouter /generation endpoint for real metrics
|
||||
- Retrieve cost, tokens (prompt + completion), latency data
|
||||
- Store generation tracking data with newest-first ordering
|
||||
- Handle HTTP requests with proper error handling
|
||||
|
||||
Extracted from: /home/aipass/aipass_core/api/apps/archive.temp/api_usage.py
|
||||
Functions: track_usage(), _get_generation_metrics(), _store_usage_data()
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# =============================================
|
||||
# MODULE CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "tracking"
|
||||
DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern
|
||||
# Navigate: tracking.py -> usage/ -> handlers/ -> apps/ -> api/
|
||||
API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json"
|
||||
|
||||
# OpenRouter API configuration
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation"
|
||||
|
||||
# Default configuration values
|
||||
DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics
|
||||
|
||||
|
||||
# =============================================
|
||||
# CORE TRACKING FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Track API usage for generation ID by querying OpenRouter metrics
|
||||
|
||||
This is the main entry point for usage tracking. It:
|
||||
1. Waits for OpenRouter to process the generation
|
||||
2. Queries the /generation endpoint for real metrics
|
||||
3. Stores the usage data with newest-first ordering
|
||||
|
||||
Args:
|
||||
generation_id: OpenRouter generation ID from API response
|
||||
caller: Module name that made the API call (e.g., "flow_mbank")
|
||||
model: Model name used for the request (default: "unknown")
|
||||
api_key: OpenRouter API key (optional, loads from config if not provided)
|
||||
|
||||
Returns:
|
||||
Dict with success status and metrics or error message
|
||||
Example: {"success": True, "metrics": {...}} or {"success": False, "error": "..."}
|
||||
"""
|
||||
try:
|
||||
# Tracking usage for caller - generation
|
||||
|
||||
# Get API key if not provided
|
||||
if not api_key:
|
||||
# Import here to avoid circular dependencies
|
||||
try:
|
||||
from aipass.api.apps.handlers.auth.keys import get_api_key
|
||||
api_key = get_api_key("openrouter")
|
||||
except Exception as e:
|
||||
# Failed to load API key
|
||||
return {"success": False, "error": "No API key available"}
|
||||
|
||||
if not api_key:
|
||||
# No API key available for tracking
|
||||
return {"success": False, "error": "No API key available"}
|
||||
|
||||
# Wait for OpenRouter to process the generation
|
||||
time.sleep(DEFAULT_GENERATION_CHECK_DELAY)
|
||||
|
||||
# Query OpenRouter for real metrics
|
||||
metrics = get_generation_metrics(generation_id, api_key)
|
||||
|
||||
if not metrics:
|
||||
# Failed to retrieve generation metrics
|
||||
return {"success": False, "error": "Failed to retrieve generation metrics"}
|
||||
|
||||
# Store the usage data
|
||||
if store_usage_data(caller, model, generation_id, metrics):
|
||||
# Successfully tracked usage
|
||||
return {"success": True, "metrics": metrics}
|
||||
else:
|
||||
# Failed to store usage data
|
||||
return {"success": False, "error": "Failed to store usage data"}
|
||||
|
||||
except Exception as e:
|
||||
# Usage tracking failed
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query OpenRouter /generation endpoint for real usage metrics
|
||||
|
||||
Makes HTTP GET request to:
|
||||
https://openrouter.ai/api/v1/generation?id={generation_id}
|
||||
|
||||
Args:
|
||||
generation_id: OpenRouter generation ID
|
||||
api_key: OpenRouter API key for authentication
|
||||
|
||||
Returns:
|
||||
Dict with metrics:
|
||||
- total_cost: Total cost in USD
|
||||
- tokens_prompt: Number of prompt tokens
|
||||
- tokens_completion: Number of completion tokens
|
||||
- generation_time: Generation time in milliseconds
|
||||
- latency: Total latency in milliseconds
|
||||
- provider_name: Provider that served the request
|
||||
|
||||
Returns None if request fails or data is invalid
|
||||
"""
|
||||
try:
|
||||
# Set up request headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Query the generation endpoint
|
||||
response = requests.get(
|
||||
f"{GENERATION_ENDPOINT}?id={generation_id}",
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Check response status
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Validate response structure
|
||||
if not data or "data" not in data:
|
||||
# Invalid response structure from OpenRouter
|
||||
return None
|
||||
|
||||
# Extract metrics from response
|
||||
metrics = data["data"]
|
||||
result = {
|
||||
"total_cost": float(metrics.get("total_cost", 0)),
|
||||
"tokens_prompt": int(metrics.get("tokens_prompt", 0)),
|
||||
"tokens_completion": int(metrics.get("tokens_completion", 0)),
|
||||
"generation_time": int(metrics.get("generation_time", 0)),
|
||||
"latency": int(metrics.get("latency", 0)),
|
||||
"provider_name": metrics.get("provider_name", "unknown")
|
||||
}
|
||||
|
||||
# Retrieved metrics for generation_id
|
||||
return result
|
||||
|
||||
else:
|
||||
# OpenRouter API returned non-200 status
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# Request timeout querying generation
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Request error querying generation
|
||||
return None
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
# Error parsing metrics
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Unexpected error getting generation metrics
|
||||
return None
|
||||
|
||||
|
||||
def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Store usage data with aggregation and newest-first ordering
|
||||
|
||||
Updates:
|
||||
- current_session: Total requests, cost, tokens
|
||||
- usage_by_caller: Per-caller statistics and model tracking
|
||||
- daily_totals: Daily aggregated statistics
|
||||
- generation_tracking: Individual generation details (newest first)
|
||||
|
||||
Args:
|
||||
caller: Module name that made the call
|
||||
model: Model name used
|
||||
generation_id: OpenRouter generation ID
|
||||
metrics: Usage metrics from get_generation_metrics()
|
||||
|
||||
Returns:
|
||||
True if successfully stored, False on error
|
||||
"""
|
||||
try:
|
||||
# Ensure API JSON directory exists
|
||||
API_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
|
||||
# Load current data or create initial structure
|
||||
if data_path.exists():
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data_wrapper = json.load(f)
|
||||
current_data = data_wrapper.get("data", {})
|
||||
else:
|
||||
current_data = {
|
||||
"current_session": {
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"total_requests": 0,
|
||||
"total_cost": 0.0,
|
||||
"total_tokens": 0
|
||||
},
|
||||
"usage_by_caller": {},
|
||||
"daily_totals": {},
|
||||
"monthly_totals": {},
|
||||
"generation_tracking": {}
|
||||
}
|
||||
|
||||
# Calculate total tokens
|
||||
total_tokens = metrics["tokens_prompt"] + metrics["tokens_completion"]
|
||||
|
||||
# Update session totals
|
||||
current_data["current_session"]["total_requests"] += 1
|
||||
current_data["current_session"]["total_cost"] += metrics["total_cost"]
|
||||
current_data["current_session"]["total_tokens"] += total_tokens
|
||||
|
||||
# Update per-caller tracking
|
||||
if caller not in current_data["usage_by_caller"]:
|
||||
current_data["usage_by_caller"][caller] = {
|
||||
"requests": 0,
|
||||
"total_cost": 0.0,
|
||||
"total_tokens": 0,
|
||||
"models_used": {},
|
||||
"last_request": None
|
||||
}
|
||||
|
||||
caller_data = current_data["usage_by_caller"][caller]
|
||||
caller_data["requests"] += 1
|
||||
caller_data["total_cost"] += metrics["total_cost"]
|
||||
caller_data["total_tokens"] += total_tokens
|
||||
caller_data["last_request"] = datetime.now().isoformat()
|
||||
|
||||
# Track models used by caller
|
||||
if model not in caller_data["models_used"]:
|
||||
caller_data["models_used"][model] = 0
|
||||
caller_data["models_used"][model] += 1
|
||||
|
||||
# Update daily totals
|
||||
today = datetime.now().date().isoformat()
|
||||
if today not in current_data["daily_totals"]:
|
||||
current_data["daily_totals"][today] = {
|
||||
"requests": 0,
|
||||
"cost": 0.0,
|
||||
"tokens": 0
|
||||
}
|
||||
|
||||
current_data["daily_totals"][today]["requests"] += 1
|
||||
current_data["daily_totals"][today]["cost"] += metrics["total_cost"]
|
||||
current_data["daily_totals"][today]["tokens"] += total_tokens
|
||||
|
||||
# Store generation details with newest-first ordering
|
||||
new_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"caller": caller,
|
||||
"model": model,
|
||||
"usage_data": metrics
|
||||
}
|
||||
|
||||
# Create new dict with new entry first, then existing entries
|
||||
current_tracking = current_data["generation_tracking"]
|
||||
current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking}
|
||||
|
||||
# Save updated data with proper wrapper structure
|
||||
data_wrapper = {
|
||||
"module_name": "api_usage",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data": current_data
|
||||
}
|
||||
|
||||
with open(data_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data_wrapper, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Stored usage data for caller
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Failed to store usage data
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def load_usage_data() -> Dict[str, Any]:
|
||||
"""
|
||||
Load current usage data from JSON file
|
||||
|
||||
Returns:
|
||||
Dict with usage data or empty dict if file doesn't exist
|
||||
"""
|
||||
try:
|
||||
data_path = API_JSON_DIR / DATA_FILE
|
||||
if not data_path.exists():
|
||||
return {}
|
||||
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
data_wrapper = json.load(f)
|
||||
|
||||
return data_wrapper.get("data", {})
|
||||
|
||||
except Exception as e:
|
||||
# Failed to load usage data
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
# JSON Templates package - Default JSON file templates
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "2025-11-13",
|
||||
"config": {
|
||||
"auto_save": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"created": "2025-11-13",
|
||||
"last_updated": "2025-11-13",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: api_key.py - API Key Management Module
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: api/modules
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial module - orchestrates key management
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
API Key Management Module
|
||||
|
||||
Orchestrates API key and credential operations:
|
||||
- Get/validate keys
|
||||
- List providers
|
||||
- Initialize .env template
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning, section
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import keys, env
|
||||
from aipass.api.apps.handlers.config import provider
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Show module introspection - connected handlers and capabilities"""
|
||||
console.print()
|
||||
header("API Key Module Introspection")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Purpose:[/cyan] API key management and validation")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Connected Handlers:[/cyan]")
|
||||
console.print(" • api.apps.handlers.auth.keys")
|
||||
console.print(" • api.apps.handlers.auth.env")
|
||||
console.print(" • api.apps.handlers.config.provider")
|
||||
console.print(" • api.apps.handlers.json.json_handler")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Available Workflows:[/cyan]")
|
||||
console.print(" • get_key() - Retrieve API key")
|
||||
console.print(" • validate_key() - Validate credentials")
|
||||
console.print(" • list_providers() - Show providers")
|
||||
console.print(" • init_env() - Initialize configuration")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle API key management commands
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
try:
|
||||
if command not in ["get-key", "validate", "list-providers", "init"]:
|
||||
return False
|
||||
|
||||
# Log operation
|
||||
json_handler.log_operation(f"api_key_{command}", {"command": command})
|
||||
|
||||
if command == "get-key":
|
||||
get_key(args)
|
||||
elif command == "validate":
|
||||
validate_key(args)
|
||||
elif command == "list-providers":
|
||||
list_providers()
|
||||
elif command == "init":
|
||||
init_env()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in api_key.handle_command: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_key(args: List[str]):
|
||||
"""Orchestrate key retrieval workflow"""
|
||||
provider_name = args[0] if args else "openrouter"
|
||||
|
||||
header(f"Get API Key - {provider_name}")
|
||||
console.print()
|
||||
|
||||
# Call handler to get key
|
||||
api_key = keys.get_api_key(provider_name)
|
||||
|
||||
if api_key:
|
||||
success(f"API key retrieved for {provider_name}")
|
||||
console.print(f" Key (first 20 chars): {api_key[:20]}...")
|
||||
else:
|
||||
error(f"Failed to retrieve API key for {provider_name}")
|
||||
|
||||
|
||||
def validate_key(args: List[str]):
|
||||
"""Orchestrate key validation workflow"""
|
||||
provider_name = args[0] if args else "openrouter"
|
||||
|
||||
header(f"Validate API Key - {provider_name}")
|
||||
console.print()
|
||||
|
||||
# Get key from handler
|
||||
api_key = keys.get_api_key(provider_name)
|
||||
|
||||
if not api_key:
|
||||
error(f"No API key found for {provider_name}")
|
||||
return
|
||||
|
||||
# Validate via handler
|
||||
is_valid = keys.validate_key(api_key, provider_name)
|
||||
|
||||
if is_valid:
|
||||
success(f"API key for {provider_name} is valid")
|
||||
else:
|
||||
error(f"API key for {provider_name} is invalid")
|
||||
|
||||
|
||||
def list_providers():
|
||||
"""Orchestrate list providers workflow"""
|
||||
header("Available Providers")
|
||||
console.print()
|
||||
|
||||
# TODO: Get from handler when implemented
|
||||
console.print(" - openrouter")
|
||||
console.print()
|
||||
|
||||
|
||||
def init_env():
|
||||
"""Orchestrate initialization workflow"""
|
||||
header("Initialize API Configuration")
|
||||
console.print()
|
||||
|
||||
# Create .env template via handler
|
||||
if env.create_env_template():
|
||||
success("Environment template created")
|
||||
else:
|
||||
error("Failed to create environment template")
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help output for API key management"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='API Key Management Module - Manage API keys and credentials',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
get-key - Retrieve API key for a provider
|
||||
validate - Validate API key
|
||||
list-providers - List available providers
|
||||
init - Initialize .env template
|
||||
|
||||
USAGE:
|
||||
python3 api_key.py <command> [args]
|
||||
python3 api_key.py --help
|
||||
|
||||
EXAMPLES:
|
||||
# Get key for provider
|
||||
python3 api_key.py get-key openrouter
|
||||
|
||||
# Validate key
|
||||
python3 api_key.py validate openrouter
|
||||
|
||||
# List providers
|
||||
python3 api_key.py list-providers
|
||||
|
||||
# Initialize environment
|
||||
python3 api_key.py init
|
||||
"""
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Standalone execution mode"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Execute command
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]python3 api_key.py --help[/dim] for available commands")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: openrouter_client.py - OpenRouter Client Module
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: api/modules
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-15): Initial module - orchestrates OpenRouter operations
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
OpenRouter Client Module
|
||||
|
||||
Orchestrates LLM API client operations:
|
||||
- Test connections
|
||||
- Make API calls
|
||||
- List models
|
||||
- Check status
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning, section
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import keys
|
||||
from aipass.api.apps.handlers.openrouter import client, models
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Show module introspection - connected handlers and capabilities"""
|
||||
console.print()
|
||||
header("OpenRouter Client Module Introspection")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Purpose:[/cyan] OpenRouter LLM API client operations")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Connected Handlers:[/cyan]")
|
||||
console.print(" • api.apps.handlers.auth.keys")
|
||||
console.print(" • api.apps.handlers.openrouter.client")
|
||||
console.print(" • api.apps.handlers.openrouter.models")
|
||||
console.print(" • api.apps.handlers.json.json_handler")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Available Workflows:[/cyan]")
|
||||
console.print(" • test_connection() - Test connection")
|
||||
console.print(" • make_call() - Make API call")
|
||||
console.print(" • list_models() - List models")
|
||||
console.print(" • check_status() - Check status")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print module help with argparse"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python3 api.py",
|
||||
description="OpenRouter Client - Manage LLM API connections",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
test - Test OpenRouter connection
|
||||
call - Make API call to model
|
||||
models - List available models
|
||||
status - Check connection status
|
||||
|
||||
USAGE:
|
||||
python3 api.py test
|
||||
python3 api.py call <prompt> [--model MODEL]
|
||||
python3 api.py models
|
||||
python3 api.py status
|
||||
|
||||
ARGUMENTS:
|
||||
prompt - Prompt to send to the model
|
||||
--model - Model to use (optional)
|
||||
|
||||
EXAMPLES:
|
||||
# Test OpenRouter connection
|
||||
python3 api.py test
|
||||
|
||||
# Make an API call
|
||||
python3 api.py call "What is AI?" --model gpt-4
|
||||
|
||||
# List available models
|
||||
python3 api.py models
|
||||
|
||||
# Check connection status
|
||||
python3 api.py status
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# test command
|
||||
subparsers.add_parser("test", help="Test OpenRouter connection")
|
||||
|
||||
# call command
|
||||
call_parser = subparsers.add_parser("call", help="Make API call to model")
|
||||
call_parser.add_argument("prompt", help="Prompt to send")
|
||||
call_parser.add_argument("--model", help="Model to use")
|
||||
|
||||
# models command
|
||||
subparsers.add_parser("models", help="List available models")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Check connection status")
|
||||
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle OpenRouter client commands
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
try:
|
||||
if command not in ["test", "call", "models", "status"]:
|
||||
return False
|
||||
|
||||
# Log operation
|
||||
json_handler.log_operation(f"openrouter_{command}", {"command": command})
|
||||
|
||||
if command == "test":
|
||||
test_connection()
|
||||
elif command == "call":
|
||||
make_call(args)
|
||||
elif command == "models":
|
||||
list_models()
|
||||
elif command == "status":
|
||||
check_status()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in openrouter_client.handle_command: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def test_connection():
|
||||
"""Orchestrate connection test workflow"""
|
||||
header("Test OpenRouter Connection")
|
||||
console.print()
|
||||
|
||||
# Get API key via handler
|
||||
api_key = keys.get_api_key("openrouter")
|
||||
|
||||
if not api_key:
|
||||
error("No API key configured")
|
||||
return
|
||||
|
||||
warning("Testing connection...")
|
||||
|
||||
# TODO: Call handler to test connection when implemented
|
||||
success("Connection test successful")
|
||||
|
||||
|
||||
def make_call(args: List[str]):
|
||||
"""Orchestrate API call workflow"""
|
||||
header("OpenRouter API Call")
|
||||
console.print()
|
||||
|
||||
# TODO: Parse args for model, messages
|
||||
# TODO: Call handler to make request
|
||||
warning("API call workflow - TODO")
|
||||
|
||||
|
||||
def list_models():
|
||||
"""Orchestrate list models workflow"""
|
||||
header("Available Models")
|
||||
console.print()
|
||||
|
||||
# Get API key via handler
|
||||
api_key = keys.get_api_key("openrouter")
|
||||
|
||||
if not api_key:
|
||||
error("No API key configured")
|
||||
return
|
||||
|
||||
warning("Fetching available models...")
|
||||
|
||||
# Call handler to fetch models
|
||||
model_list = models.fetch_models_from_api(api_key)
|
||||
|
||||
if model_list:
|
||||
success(f"Found {len(model_list)} models")
|
||||
for model in model_list[:10]:
|
||||
console.print(f" - {model}")
|
||||
if len(model_list) > 10:
|
||||
console.print(f" ... and {len(model_list) - 10} more")
|
||||
else:
|
||||
error("Failed to fetch models")
|
||||
|
||||
|
||||
def check_status():
|
||||
"""Orchestrate status check workflow"""
|
||||
header("OpenRouter Client Status")
|
||||
console.print()
|
||||
|
||||
# TODO: Call handlers for status when implemented
|
||||
warning("Client status - TODO")
|
||||
|
||||
|
||||
# =============================================
|
||||
# PUBLIC API - Re-export handler functions
|
||||
# =============================================
|
||||
|
||||
def get_response(prompt: str, caller: str | None = None, model: str | None = None, **kwargs):
|
||||
"""
|
||||
Public API: Get response from OpenRouter
|
||||
|
||||
This is a re-export of the handler function for cross-branch access.
|
||||
Flow and other branches should use this module-level function instead
|
||||
of importing directly from handlers.
|
||||
|
||||
Args:
|
||||
prompt: User prompt text
|
||||
caller: Module making the request (auto-detected if not provided)
|
||||
model: Model to use (required - caller must provide from branch config)
|
||||
**kwargs: Additional OpenAI API parameters
|
||||
|
||||
Returns:
|
||||
Dict with 'content', 'id', 'model' or None on failure
|
||||
|
||||
Example:
|
||||
>>> from aipass.api.apps.modules.openrouter_client import get_response
|
||||
>>> response = get_response("Hello", caller="flow", model="anthropic/claude-3.5-sonnet")
|
||||
>>> if response:
|
||||
... print(response['content'])
|
||||
"""
|
||||
return client.get_response(prompt, caller, model, **kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Standalone execution mode"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Execute command
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]python3 openrouter_client.py --help[/dim] for available commands")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user