Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 90 additions & 22 deletions platform/inneros_core_runtime/notifications/ops_task_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def _notify_ops_auth_email_fallback(
body: str,
provider: str,
tid: str,
alert_kind: str = "external_spend",
) -> dict[str, Any]:
"""When Evolution/WhatsApp is down, reach owner via email (same CONFIRMAR/CANCELAR semantics)."""
import os
Expand All @@ -120,15 +121,18 @@ def _notify_ops_auth_email_fallback(
if not to_addr:
return {"ok": False, "skipped": "no_owner_email_configured"}
short = tid.replace("ops_", "")[:6]
subject = f"[RalfIA OPS] Autorización {provider.upper()} · {short}"
email_body = (
f"{body}\n\n"
f"---\n"
f"WhatsApp no disponible; responde a este correo o usa MCP:\n"
f"• CONFIRMAR {short}\n"
f"• CANCELAR {short}\n"
f'• cursor_authorize_ops_task(task_id="{tid}")\n'
needs_approval = alert_kind == "external_spend"
subject = (
f"[RalfIA OPS] Autorización {provider.upper()} · {short}"
if needs_approval else f"[RalfIA OPS] Alerta interna {provider.upper()} · {short}"
)
instructions = (
f"Autorización pendiente para tarea EXACTA: {tid}. Usa MCP/WhatsApp autenticado cuando esté disponible.\n"
f"NO se procesa automáticamente una respuesta a este correo.\n"
if needs_approval else
f"Alerta informativa, tarea {tid}. No se requiere autorización de gasto externo.\n"
)
email_body = f"{body}\n\n---\nWhatsApp no disponible.\n{instructions}"
from inneros_core_runtime.notifications.email_client import send_email

sent = send_email(
Expand Down Expand Up @@ -181,17 +185,14 @@ def notify_ops_owner_authorization_request(
if not NOTIFY_OPS_TASKS or not NOTIFY_OPS_CREATE_AUTH:
return {"ok": False, "skipped": "notifications_disabled"}

# WhatsApp unavailable must NOT return before the SMTP fallback.
# A connection API failure is also treated as unavailable, not healthy.
try:
from raphiia_openai.notifications.evolution_client import any_whatsapp_connected

if not any_whatsapp_connected():
return {
"ok": False,
"skipped": "evolution_whatsapp_down",
"task_id": str(task.get("task_id") or ""),
}
whatsapp_connected = bool(any_whatsapp_connected())
except Exception:
pass
whatsapp_connected = False

skip = _synthetic_ops_task_skip(task)
if skip:
Expand Down Expand Up @@ -286,17 +287,31 @@ def notify_ops_owner_authorization_request(
from raphiia_openai.notifications.evolution_client import send_alert_whatsapp, send_whatsapp_interactive

if alert_kind != "external_spend":
result = send_alert_whatsapp(body, prefix_node=True)
result = (send_alert_whatsapp(body, prefix_node=True) if whatsapp_connected
else {"ok": False, "reason": "evolution_whatsapp_down"})
email_fallback = None
# Email for blocked/internal-fix or explicitly urgent internal tasks,
# not a flood for every ordinary internal-ready notification.
urgent = str(task.get("priority") or "").lower() in {"p0", "urgent", "critical", "high"}
if not result.get("ok") and (alert_kind == "internal_fix" or urgent):
email_fallback = _notify_ops_auth_email_fallback(
task, body=body, provider=provider, tid=tid,
alert_kind=alert_kind,
)
if email_fallback.get("ok"):
result = {"ok": True, "channel": "email_fallback", "email": email_fallback}
if result.get("ok"):
_mark_sent("owner_auth", tid)
_mark_owner_auth_sent_mongo(tid)
return {
"ok": bool(result.get("ok")),
"result": result,
"task_id": tid,
"summary": summary,
"delivery_mode": "text_internal_fix" if alert_kind == "internal_fix" else "text_internal_brief",
"delivery_mode": ("email_fallback" if email_fallback and email_fallback.get("ok")
else "text_internal_fix" if alert_kind == "internal_fix" else "text_internal_brief"),
"poll_message_id": "",
"email_fallback": None,
"email_fallback": email_fallback,
}

from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending
Expand All @@ -310,7 +325,10 @@ def notify_ops_owner_authorization_request(
{"id": f"ops.auth.yes.{tid}", "label": "Confirmar"},
{"id": f"ops.auth.no.{tid}", "label": "Cancelar"},
]
if auth_ui == "poll":
if not whatsapp_connected:
result = {"ok": False, "reason": "evolution_whatsapp_down"}
delivery_mode = "email_fallback"
elif auth_ui == "poll":
from raphiia_openai.notifications.evolution_client import (
OPS_AUTH_POLL_NO,
OPS_AUTH_POLL_YES,
Expand Down Expand Up @@ -341,7 +359,7 @@ def notify_ops_owner_authorization_request(
provider=provider,
poll_message_id=poll_message_id or None,
)
if not result.get("ok"):
if not result.get("ok") and whatsapp_connected:
result = send_alert_whatsapp(body, prefix_node=True)
delivery_mode = "text"
email_fallback = None
Expand All @@ -365,6 +383,27 @@ def notify_ops_owner_authorization_request(
}



def resend_ops_owner_authorization(task_id: str) -> dict[str, Any]:
"""Resend one existing, nonterminal OPS challenge; never create a new task."""
import re

tid = str(task_id or "").strip().lower()
if not re.fullmatch(r"ops_[a-f0-9]{12}", tid):
return {"ok": False, "skipped": "invalid_exact_task_id", "task_id": tid}
try:
from raphiia_openai import mongo_store
from inneros_core_runtime.coordination_live import OPS_TASKS_COL

task = mongo_store.get_db()[OPS_TASKS_COL].find_one({"task_id": tid}, {"_id": 0})
except Exception as exc:
return {"ok": False, "skipped": "task_lookup_failed", "error": type(exc).__name__, "task_id": tid}
if not task:
return {"ok": False, "skipped": "task_not_found", "task_id": tid}
if str(task.get("status") or "").lower() in TERMINAL_OPS:
return {"ok": False, "skipped": "task_terminal", "task_id": tid}
return notify_ops_owner_authorization_request(task, force=True)

_AWAITING_CLAIM_STATUSES = frozenset(
{
"awaiting_cursor_claim",
Expand Down Expand Up @@ -397,6 +436,15 @@ def notify_cursor_awaiting_claim(task: dict[str, Any], *, previous_status: str |
return {"ok": False, "skipped": "no_status_change"}

tid = str(task.get("task_id") or "")
# A persistent pending WhatsApp challenge outranks the volatile cooldown
# ledger. Do not nag the owner while Confirmar/Cancelar is unanswered.
try:
from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending
pending = ops_pending.get_by_task_id(tid)
if pending and pending.get("status") in {"pending", "cancel_requested"}:
return {"ok": False, "skipped": "already_notified_on_create", "task_id": tid}
except Exception:
pass
if owner_auth_notified(tid):
return {"ok": False, "skipped": "already_notified_on_create", "task_id": tid}

Expand Down Expand Up @@ -433,10 +481,30 @@ def notify_cursor_awaiting_claim(task: dict[str, Any], *, previous_status: str |
if not _can_send("cursor_claim", key):
return {"ok": False, "skipped": "cooldown_or_dedupe"}

result = send_alert_whatsapp(body, prefix_node=True)
try:
from raphiia_openai.notifications.evolution_client import any_whatsapp_connected

whatsapp_connected = bool(any_whatsapp_connected())
except Exception:
whatsapp_connected = False
result = (send_alert_whatsapp(body, prefix_node=True) if whatsapp_connected
else {"ok": False, "reason": "evolution_whatsapp_down"})
email_fallback = None
if not result.get("ok"):
# Task already exists, so this is notification only, never a new claim.
email_fallback = _notify_ops_auth_email_fallback(
task, body=body, provider=provider, tid=tid,
alert_kind="external_spend",
)
if email_fallback.get("ok"):
result = {"ok": True, "channel": "email_fallback", "email": email_fallback}
if result.get("ok"):
_mark_sent("cursor_claim", key)
return {"ok": bool(result.get("ok")), "result": result, "task_id": task.get("task_id"), "status": status}
return {
"ok": bool(result.get("ok")), "result": result,
"task_id": task.get("task_id"), "status": status,
"email_fallback": email_fallback,
}


def notify_ops_transition(
Expand Down
58 changes: 47 additions & 11 deletions platform/inneros_core_runtime/ops_whatsapp_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,57 @@ def handle_owner_reply(
if NUMERIC_AUTH_RE.match(text) and phone:
from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending

pending = ops_pending.latest_pending(phone)
if pending:
tid = str(pending.get("task_id") or "")
text = f"{'SI' if text == '1' else 'NO'} {tid}"
# Numeric 1/2 has no task identity. Refuse it when multiple pending
# requests exist rather than authorize whichever happened to be newest.
pending_tasks = ops_pending.pending_for_phone(phone, limit=2)
if len(pending_tasks) != 1:
return {
"ok": False, "action": "ambiguous_numeric_auth",
"text": "No puedo identificar la aprobación. Usa el botón de la tarea exacta o responde CONFIRMAR ops_<id>.",
}
tid = str(pending_tasks[0].get("task_id") or "")
if not re.fullmatch(r"ops_[a-f0-9]{12}", tid):
return {"ok": False, "action": "ambiguous_numeric_auth",
"text": "Usa el botón de la tarea exacta con un ID válido."}
text = f"{'SI' if text == '1' else 'NO'} {tid}"
deny = DENY_RE.match(text)
if deny:
tid = _resolve_task_id(deny.group(1)) or deny.group(1)
from datetime import datetime, timezone
from raphiia_openai import mongo_store
from inneros_core_runtime.coordination_live import OPS_TASKS_COL
from inneros_core_runtime import durable_coordination_spine
from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending

col = mongo_store.get_db()[OPS_TASKS_COL]
before = col.find_one({"task_id": tid}, {"_id": 0})
if not before:
return {"ok": False, "text": f"Tarea {tid} no encontrada.",
"task_id": tid, "action": "deny_not_found"}
if str(before.get("status") or "").lower() in {"completed", "cancelled", "failed", "superseded"}:
return {"ok": False, "text": f"Tarea {tid} ya está cerrada.",
"task_id": tid, "action": "deny_closed"}
out = durable_coordination_spine.signal_task_workflow(tid, "cancel", "owner_rejected_whatsapp")
ops_pending.clear_pending(tid)
return {
"ok": bool(out.get("ok")),
"text": f"❌ Acción cancelada · {tid}",
"task_id": tid,
"action": "deny",
}
if not out.get("ok"):
return {"ok": False, "text": f"No se pudo solicitar la cancelación de {tid}.",
"task_id": tid, "action": "deny_failed", "details": out}
# A Temporal signal is not durable confirmation of a terminal state.
col.update_one(
{"task_id": tid},
{"$set": {
"cancellation_requested_at": datetime.now(timezone.utc).isoformat(),
"cancellation_requested_by": owner_actor,
"cancellation_channel": "whatsapp",
}},
)
after = col.find_one({"task_id": tid}, {"_id": 0})
if str((after or {}).get("status") or "").lower() == "cancelled":
ops_pending.clear_pending(tid)
return {"ok": True, "text": f"❌ Acción cancelada · {tid}",
"task_id": tid, "action": "deny"}
ops_pending.mark_cancel_requested(tid)
return {"ok": True, "text": f"⏳ Cancelación solicitada · {tid}; pendiente de confirmación de Temporal.",
"task_id": tid, "action": "deny_pending"}

if re.fullmatch(r"^(?:s[ií]|dale|ok|autorizo|autoriza)\.?$", text, re.I) and phone:
from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending
Expand Down Expand Up @@ -197,6 +230,9 @@ def handle_owner_reply(

terminal = {"completed", "failed", "blocked", "partial", "cancelled", "superseded"}
status_now = str(task.get("status") or "").lower()
if task.get("cancellation_requested_at") or status_now == "cancel_requested":
return {"ok": False, "action": "auth_cancel_pending", "task_id": tid,
"text": "Esta tarea tiene una cancelación pendiente; no se puede confirmar."}
if status_now in terminal:
return {
"ok": False,
Expand Down
9 changes: 7 additions & 2 deletions platform/inneros_core_runtime/temporal_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@


def _mongo_projection_client():
"""Use the same Mongo settings as MCP/coordination (not localhost defaults)."""
"""Honor the isolated Temporal canary DB, without changing production defaults.

The client comes from the canonical Mongo connector. An explicit
INNEROS_MONGO_DB selects the event-projection database for isolated tests.
"""
from raphiia_openai import mongo_store

db = mongo_store.get_db()
return db.client, db.name
selected_name = (os.getenv("INNEROS_MONGO_DB") or "").strip()
return db.client, selected_name or db.name
WORKTREE_BASE = Path(
os.environ.get(
"INNEROS_WORKTREE_BASE",
Expand Down
20 changes: 20 additions & 0 deletions platform/tests/probe_ops_email_fallback_config_readonly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Explicit-only read-only check of OPS email fallback settings, no secrets."""
from __future__ import annotations

import json
import os


def test_ops_email_fallback_config_report_only():
fields = ["RALFIA_OPS_AUTH_EMAIL_TO", "INNEROS_ADMIN_EMAILS",
"NOTIFY_EMAIL_TO", "RALFIA_ALERTS_EMAIL_TO",
"NOTIFY_OPS_AUTH_EMAIL_FALLBACK",
"RALFIA_ALERTS_TO", "NOTIFY_WHATSAPP_TO"]
present = {name: bool(os.getenv(name, "").strip()) for name in fields}
active = os.getenv("NOTIFY_OPS_AUTH_EMAIL_FALLBACK", "1").strip().lower() not in {"0", "false", "off", "no"}
configured = any(present[k] for k in ["RALFIA_OPS_AUTH_EMAIL_TO", "INNEROS_ADMIN_EMAILS", "NOTIFY_EMAIL_TO"])
print("OPS_EMAIL_FALLBACK_CONFIG=" + json.dumps({
"configured_delivery_recipient": configured,
"fallback_enabled": active,
"env_presence_only": present,
}, sort_keys=True))
24 changes: 24 additions & 0 deletions platform/tests/probe_owner_whatsapp_readonly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Explicit-only owner WhatsApp connectivity preflight: GET requests, no send.

Does not print phone numbers, API keys, instance IDs or server URLs.
"""
import json
import os


def test_owner_whatsapp_readonly_connectivity():
from raphiia_openai.notifications import evolution_client as evo

status = evo.dual_whatsapp_status()
safe = {
node: {"api_up": bool(info.get("api_up")),
"connected": bool(info.get("connected"))}
for node, info in status.items()
}
safe["owner_alert_destination_configured"] = bool(
(os.getenv("RALFIA_ALERTS_TO") or os.getenv("NOTIFY_WHATSAPP_TO") or "").strip()
)
safe["any_connected"] = bool(evo.any_whatsapp_connected())
safe["read_only"] = True
print("INNEROS_OWNER_WHATSAPP_PREFLIGHT=" + json.dumps(safe, sort_keys=True))
assert safe["any_connected"], "No active WhatsApp Evolution session visible from test runner"
Loading
Loading