Bot & Automation

AI Assistants

/root/hermes-projects/AI Assistants

apps/whatsapp_control_api/app/services/env_manager.py text
from __future__ import annotations

import json
import os
import re
from pathlib import Path
from typing import Mapping

from ..core.config import get_settings

ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
SENSITIVE_KEY_PATTERN = re.compile(
    r"(?i)(?:^|_)(?:SECRET|TOKEN|KEY|PASSWORD)$|ACCESS_TOKEN|APP_SECRET"
)


def is_valid_env_key(key: str) -> bool:
    return bool(ENV_KEY_PATTERN.fullmatch(key.strip()))


def is_sensitive_env_key(key: str) -> bool:
    return bool(SENSITIVE_KEY_PATTERN.search(key.strip()))


def format_env_value(value: str) -> str:
    cleaned = value.replace("\r", "").replace("\n", "")
    if cleaned == "":
        return '""'
    if re.search(r"\s|#|^['\"]|['\"]$", cleaned):
        escaped = cleaned.replace("'", "'\"'\"'")
        return f"'{escaped}'"
    return cleaned


def parse_env_file(env_path: Path) -> dict[str, str]:
    if not env_path.exists():
        return {}

    result: dict[str, str] = {}
    for raw_line in env_path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = raw_line.split("=", 1)
        key = key.strip()
        if not is_valid_env_key(key):
            continue
        result[key] = _unquote_env_value(value.strip())
    return result


def update_env_file(env_path: Path, updates: Mapping[str, str]) -> None:
    env_path.parent.mkdir(parents=True, exist_ok=True)
    existing_lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
    remaining = dict(updates)
    rewritten_lines: list[str] = []

    assignment_pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*?)(\s*)$")
    for raw_line in existing_lines:
        match = assignment_pattern.match(raw_line)
        if not match:
            rewritten_lines.append(raw_line)
            continue

        prefix, key, separator, _, suffix = match.groups()
        if key in remaining:
            rewritten_lines.append(f"{prefix}{key}{separator}{format_env_value(str(remaining.pop(key)))}{suffix}")
        else:
            rewritten_lines.append(raw_line)

    for key, value in remaining.items():
        rewritten_lines.append(f"{key}={format_env_value(str(value))}")

    env_path.write_text("\n".join(rewritten_lines).rstrip("\n") + "\n", encoding="utf-8")


def resolve_runtime_env_path() -> Path:
    return Path.cwd() / ".env"


def resolve_project_env_path(project_name: str) -> Path:
    settings = get_settings()
    project_name = project_name.strip()
    if not project_name:
        raise ValueError("Project name is required.")

    root = settings.hermes_projects_root.resolve()
    candidate = (root / project_name).resolve()
    try:
        candidate.relative_to(root)
    except ValueError as exc:
        raise ValueError("Project path is outside the Hermes projects root.") from exc

    return candidate / ".env"


def redact_sensitive_assignments(text: str) -> str:
    redacted = text
    assignment_pattern = re.compile(
        r"(?i)\b([A-Za-z_][A-Za-z0-9_]*?(?:SECRET|TOKEN|KEY|PASSWORD|APP_SECRET|ACCESS_TOKEN))\b(\s*=\s*)([^\s]+)"
    )
    redacted = assignment_pattern.sub(r"\1\2[redacted]", redacted)
    return redacted


def sanitize_whatsapp_payload(payload: dict) -> dict:
    sanitized = json.loads(json.dumps(payload))
    for entry in sanitized.get("entry", []):
        for change in entry.get("changes", []):
            value = change.get("value", {})
            for message in value.get("messages", []):
                text = message.get("text")
                if isinstance(text, dict) and "body" in text:
                    text["body"] = redact_sensitive_assignments(str(text["body"]))
    return sanitized


def sanitize_whatsapp_message(message: dict) -> dict:
    sanitized = json.loads(json.dumps(message))
    text = sanitized.get("text")
    if isinstance(text, dict) and "body" in text:
        text["body"] = redact_sensitive_assignments(str(text["body"]))
    return sanitized


def render_env_preview(env_map: Mapping[str, str], *, limit: int = 20) -> str:
    items: list[str] = []
    for key in sorted(env_map):
        value = env_map[key]
        display = "[redacted]" if is_sensitive_env_key(key) else value
        items.append(f"{key}={display}")
        if len(items) >= limit:
            break
    if not items:
        return "(empty)"
    return "\n".join(items)


def reload_runtime_settings() -> None:
    get_settings.cache_clear()


def _unquote_env_value(value: str) -> str:
    if len(value) >= 2 and ((value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'"))):
        inner = value[1:-1]
        if value.startswith("'"):
            return inner.replace("'\"'\"'", "'")
        return inner
    return value