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