hermes-agent-custom/plugins/mika_runtime/tools.py
Felipe Domingues 697825c505
feat: post Mika cron and skill actions back to platform (#5)
* feat: post mika actions back to platform

* fix: start hermes gateway for telegram runtime

* fix: isolate gateway from runtime api port

* fix: enable mika runtime tools in gateway config

* fix: prefer mika platform tools over native runtime tools

* fix: intercept mika platform actions before llm

* fix: route managed cronjobs and skills
2026-05-30 14:05:17 -03:00

1012 lines
32 KiB
Python

"""Stable runtime bridge for integrations synced from Mika into Hermes."""
from __future__ import annotations
import json
import asyncio
import logging
import os
import re
import threading
import unicodedata
from pathlib import Path
from typing import Any, Dict, Iterable, Tuple
from urllib import error, parse, request
from hermes_constants import get_hermes_home
SUPPORTED_SLUGS = ("notion", "todoist", "calcom")
MANAGED_INTEGRATIONS_ROOT = Path(get_hermes_home()) / "mika" / "integrations"
INTEGRATIONS_MANIFEST_PATH = MANAGED_INTEGRATIONS_ROOT / "manifest.json"
DEFAULT_NOTION_VERSION = "2022-06-28"
DEFAULT_CALCOM_VERSION = "2026-02-25"
MAX_RESPONSE_CHARS = 20000
USER_AGENT = "domco-mika-runtime/0.1"
logger = logging.getLogger(__name__)
AGENT_INSTANCE_ENV_NAMES = (
"MIKA_AGENT_INSTANCE_ID",
"HERMES_AGENT_INSTANCE_ID",
"AGENT_INSTANCE_ID",
)
INTERNAL_SECRET_ENV_NAMES = (
"MIKA_INTERNAL_FUNCTION_SECRET",
"HERMES_INTERNAL_FUNCTION_SECRET",
"INTERNAL_FUNCTION_SECRET",
)
GATEWAY_ACTION_INTERCEPT_ENV_NAMES = (
"MIKA_GATEWAY_ACTION_INTERCEPT",
"HERMES_GATEWAY_ACTION_INTERCEPT",
)
INTEGRATIONS_STATUS_SCHEMA = {
"name": "mika_integrations_status",
"description": (
"Lists the Mika integrations currently synced into this Hermes runtime. "
"Use it to confirm whether Notion, Todoist, or Cal.com are connected "
"before making provider-specific API calls."
),
"parameters": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"enum": list(SUPPORTED_SLUGS),
"description": "Optional provider slug to filter by.",
},
},
"additionalProperties": False,
},
}
NOTION_API_SCHEMA = {
"name": "mika_notion_api",
"description": (
"Makes authenticated requests against the connected Notion workspace. "
"Useful for search, retrieving pages, creating pages, updating pages, "
"querying databases, and appending block children. Authorization and "
"Notion-Version headers are injected automatically."
),
"parameters": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PATCH"],
"description": "HTTP method.",
},
"path": {
"type": "string",
"description": (
"Notion API path such as /v1/search, /v1/pages/<page_id>, "
"/v1/pages, or /v1/blocks/<block_id>/children."
),
},
"query": {
"type": "object",
"description": "Optional query parameters appended to the URL.",
"additionalProperties": True,
},
"body": {
"description": "Optional JSON request body for POST or PATCH requests.",
"anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}],
},
},
"required": ["method", "path"],
"additionalProperties": False,
},
}
TODOIST_API_SCHEMA = {
"name": "mika_todoist_api",
"description": (
"Makes authenticated requests against Todoist REST API v2 for the "
"connected account. Useful for tasks, projects, sections, labels, and "
"comments. Authorization is injected automatically."
),
"parameters": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "DELETE"],
"description": "HTTP method.",
},
"path": {
"type": "string",
"description": (
"Todoist REST v2 path such as /tasks, /tasks/<task_id>, "
"/tasks/<task_id>/close, /projects, /sections, or /comments."
),
},
"query": {
"type": "object",
"description": "Optional query parameters appended to the URL.",
"additionalProperties": True,
},
"body": {
"description": "Optional JSON request body for POST requests.",
"anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}],
},
},
"required": ["method", "path"],
"additionalProperties": False,
},
}
CALCOM_API_SCHEMA = {
"name": "mika_calcom_api",
"description": (
"Makes authenticated requests against Cal.com API v2 for the connected "
"account. Useful for /v2/me, /v2/event-types, /v2/bookings, and related "
"resources. Authorization and cal-api-version headers are injected automatically."
),
"parameters": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PATCH"],
"description": "HTTP method.",
},
"path": {
"type": "string",
"description": (
"Cal.com API path such as /v2/me, /v2/event-types, "
"/v2/event-types/<id>, /v2/bookings, or /v2/bookings/<uid>."
),
},
"query": {
"type": "object",
"description": "Optional query parameters appended to the URL.",
"additionalProperties": True,
},
"body": {
"description": "Optional JSON request body for POST or PATCH requests.",
"anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}],
},
},
"required": ["method", "path"],
"additionalProperties": False,
},
}
def _json_response(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
def _is_truthy_env_default_true(*names: str) -> bool:
for name in names:
value = os.environ.get(name)
if value is None:
continue
lowered = str(value).strip().lower()
if lowered in {"0", "false", "no", "off", "disabled"}:
return False
if lowered in {"1", "true", "yes", "on", "enabled"}:
return True
return True
def _normalize_intent_text(value: Any) -> str:
text = str(value or "").strip().lower()
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return re.sub(r"\s+", " ", text)
_TEMPORAL_RE = re.compile(
r"("
r"\bdaqui\s+(?:a\s+)?\d+\s*(?:min(?:uto)?s?|h(?:ora)?s?|dias?|semanas?)\b"
r"|\b(?:hoje|amanha|depois de amanha)\b"
r"|\b(?:todo|toda|todos|todas|diariamente|semanalmente|mensalmente|anualmente)\b"
r"|\b(?:segunda|terca|quarta|quinta|sexta|sabado|domingo)(?:-feira)?s?\b"
r"|\b(?:dia util|dias uteis|fim de semana)\b"
r"|\b(?:as|às)\s*\d{1,2}(?::\d{2}|h\d{0,2})?\b"
r"|\b\d{1,2}(?::\d{2}|h\d{0,2})\b"
r"|\b(?:cron|cronjob|automacao|automatizacao|lembrete|reminder|schedule)\b"
r")",
re.IGNORECASE,
)
_CRON_INTENT_RE = re.compile(
r"("
r"\bme\s+lemb(?:ra|re)\b"
r"|\blemb(?:ra|re)(?:-me)?\b"
r"|\bme\s+avis(?:a|e)\b"
r"|\bavis(?:a|e)(?:-me)?\b"
r"|\bagend(?:e|ar)\b"
r"|\bme\s+agenda\b"
r"|\bagenda\s+(?:um|uma|isso|para|pra)\b"
r"|\bprogram(?:a|e|ar)\b"
r"|\bautomatiz(?:a|e|ar)\b"
r"|\bcria(?:r)?\s+(?:um\s+|uma\s+)?(?:cronjob|lembrete|automacao)\b"
r"|\b(?:todo|toda|todos|todas)\b.*\b(?:manda|envia|me\s+manda|me\s+envia|resum[ao])\b"
r")",
re.IGNORECASE,
)
_SKILL_INTENT_RE = re.compile(
r"("
r"\bcria(?:r)?\s+(?:uma\s+|um\s+)?skill\b"
r"|\bcrie\s+(?:uma\s+|um\s+)?skill\b"
r"|\bnova\s+skill\b"
r"|\badicion(?:a|e|ar)\s+(?:uma\s+|um\s+)?skill\b"
r"|\bensina(?:r)?\b.*\b(?:skill|quando eu mandar|workflow|processo)\b"
r"|\bsalv(?:a|e|ar)\b.*\b(?:skill|workflow|processo)\b"
r")",
re.IGNORECASE,
)
def detect_gateway_platform_action(text: Any) -> str | None:
"""Return a deterministic Mika platform action for explicit user intents."""
normalized = _normalize_intent_text(text)
if not normalized:
return None
# Plain slash commands should continue to Hermes/skills dispatch.
if normalized.startswith("/") and not _SKILL_INTENT_RE.search(normalized):
return None
if _SKILL_INTENT_RE.search(normalized):
return "skill"
if _CRON_INTENT_RE.search(normalized) and _TEMPORAL_RE.search(normalized):
return "cronjob"
return None
def _load_manifest() -> dict[str, Any]:
if not INTEGRATIONS_MANIFEST_PATH.exists():
return {"integrations": []}
try:
return json.loads(INTEGRATIONS_MANIFEST_PATH.read_text(encoding="utf-8"))
except Exception as exc:
return {
"integrations": [],
"error": f"failed to read integrations manifest: {exc}",
}
def _normalize_slug(value: Any) -> str:
return str(value or "").strip().lower()
def _pick_active_integration(slug: str) -> Tuple[dict[str, Any] | None, str | None]:
manifest = _load_manifest()
manifest_integrations = manifest.get("integrations", [])
if not isinstance(manifest_integrations, list):
return None, "integrations manifest is malformed"
matches = [
item
for item in manifest_integrations
if isinstance(item, dict)
and _normalize_slug(item.get("slug")) == slug
]
if not matches:
return None, f"integration '{slug}' is not synced into this runtime"
active = [
item for item in matches
if str(item.get("status") or "").strip().lower() == "active"
]
if not active:
status = sorted({str(item.get("status") or "unknown") for item in matches})
return None, (
f"integration '{slug}' is synced but not active "
f"(current statuses: {', '.join(status)})"
)
chosen = sorted(
active,
key=lambda item: str(item.get("updated_at") or item.get("synced_at") or ""),
reverse=True,
)[0]
managed_file = str(chosen.get("managed_file") or "").strip()
if not managed_file:
return None, f"integration '{slug}' is missing managed_file metadata"
record_path = MANAGED_INTEGRATIONS_ROOT / managed_file
if not record_path.exists():
return None, (
f"integration '{slug}' expected runtime file '{managed_file}', "
"but it does not exist"
)
try:
record = json.loads(record_path.read_text(encoding="utf-8"))
except Exception as exc:
return None, f"failed to read integration runtime file for '{slug}': {exc}"
access_token = str(record.get("access_token") or "").strip()
if not access_token:
return None, f"integration '{slug}' has no access token in runtime storage"
return record, None
def _redact_integration(item: dict[str, Any]) -> dict[str, Any]:
redacted = dict(item)
redacted.pop("access_token", None)
redacted.pop("refresh_token", None)
return redacted
def _coerce_query_pairs(value: Any) -> list[tuple[str, str]]:
if not isinstance(value, dict):
return []
pairs: list[tuple[str, str]] = []
for key, raw_value in value.items():
if raw_value is None:
continue
if isinstance(raw_value, (list, tuple)):
for entry in raw_value:
pairs.append((str(key), _stringify_query_value(entry)))
continue
pairs.append((str(key), _stringify_query_value(raw_value)))
return pairs
def _stringify_query_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
return str(value)
def _normalize_path(path: Any) -> str:
text = str(path or "").strip()
if not text:
raise ValueError("path is required")
if not text.startswith("/"):
text = f"/{text}"
return text
def _prepare_request(
*,
provider: str,
base_url: str,
allowed_methods: Iterable[str],
extra_headers: dict[str, str],
args: dict[str, Any],
) -> tuple[str, str, dict[str, str], bytes | None]:
method = str(args.get("method") or "").strip().upper()
if method not in set(allowed_methods):
raise ValueError(
f"method must be one of: {', '.join(sorted(set(allowed_methods)))}"
)
path = _normalize_path(args.get("path"))
query_pairs = _coerce_query_pairs(args.get("query"))
url = f"{base_url.rstrip('/')}{path}"
if query_pairs:
url = f"{url}?{parse.urlencode(query_pairs, doseq=True)}"
body = args.get("body")
payload = None
headers = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
**extra_headers,
}
if body is not None:
if method == "GET":
raise ValueError(f"{provider} GET requests do not accept a JSON body")
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
return method, url, headers, payload
def _truncate_text(text: str, limit: int = MAX_RESPONSE_CHARS) -> tuple[str, bool]:
if len(text) <= limit:
return text, False
return text[:limit], True
def _decode_response_body(content_type: str, raw_body: bytes) -> tuple[Any, bool]:
if not raw_body:
return None, False
text = raw_body.decode("utf-8", errors="replace")
text, truncated = _truncate_text(text)
if "application/json" in content_type.lower():
try:
return json.loads(text), truncated
except Exception:
return text, truncated
return text, truncated
def _perform_request(
*,
provider: str,
integration: dict[str, Any],
base_url: str,
allowed_methods: Iterable[str],
extra_headers: dict[str, str],
args: dict[str, Any],
) -> str:
try:
method, url, headers, payload = _prepare_request(
provider=provider,
base_url=base_url,
allowed_methods=allowed_methods,
extra_headers=extra_headers,
args=args,
)
except ValueError as exc:
return _json_response({
"ok": False,
"provider": provider,
"error": str(exc),
})
req = request.Request(
url=url,
data=payload,
method=method,
headers=headers,
)
try:
with request.urlopen(req, timeout=45) as response:
raw_body = response.read()
content_type = response.headers.get("Content-Type", "")
decoded_body, truncated = _decode_response_body(content_type, raw_body)
return _json_response({
"ok": True,
"provider": provider,
"integration": {
"slug": integration.get("slug"),
"name": integration.get("name"),
"status": integration.get("status"),
"connected_account_name": integration.get("connected_account_name"),
"connected_account_email": integration.get("connected_account_email"),
},
"request": {
"method": method,
"url": url,
},
"response": {
"status": response.status,
"content_type": content_type,
"truncated": truncated,
"body": decoded_body,
},
})
except error.HTTPError as exc:
raw_body = exc.read()
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
decoded_body, truncated = _decode_response_body(content_type, raw_body)
return _json_response({
"ok": False,
"provider": provider,
"integration": {
"slug": integration.get("slug"),
"name": integration.get("name"),
"status": integration.get("status"),
"connected_account_name": integration.get("connected_account_name"),
"connected_account_email": integration.get("connected_account_email"),
},
"request": {
"method": method,
"url": url,
},
"response": {
"status": exc.code,
"content_type": content_type,
"truncated": truncated,
"body": decoded_body,
},
})
except Exception as exc:
return _json_response({
"ok": False,
"provider": provider,
"request": {
"method": method,
"url": url,
},
"error": str(exc),
})
def handle_integrations_status(args: dict[str, Any], **_: Any) -> str:
slug = _normalize_slug(args.get("slug"))
manifest = _load_manifest()
integrations = manifest.get("integrations", [])
if not isinstance(integrations, list):
return _json_response({
"ok": False,
"error": "integrations manifest is malformed",
})
filtered = [
_redact_integration(item)
for item in integrations
if isinstance(item, dict)
and (not slug or _normalize_slug(item.get("slug")) == slug)
]
return _json_response({
"ok": True,
"agent_instance_id": manifest.get("agent_instance_id"),
"user_id": manifest.get("user_id"),
"synced_at": manifest.get("synced_at"),
"integrations": filtered,
"available_tools": {
"notion": "mika_notion_api",
"todoist": "mika_todoist_api",
"calcom": "mika_calcom_api",
},
})
def handle_notion_api(args: dict[str, Any], **_: Any) -> str:
integration, err = _pick_active_integration("notion")
if err:
return _json_response({"ok": False, "provider": "notion", "error": err})
return _perform_request(
provider="notion",
integration=integration,
base_url="https://api.notion.com",
allowed_methods=("GET", "POST", "PATCH"),
extra_headers={
"Authorization": f"Bearer {integration['access_token']}",
"Notion-Version": str(
integration.get("notion_version")
or DEFAULT_NOTION_VERSION
),
},
args=args,
)
def handle_todoist_api(args: dict[str, Any], **_: Any) -> str:
integration, err = _pick_active_integration("todoist")
if err:
return _json_response({"ok": False, "provider": "todoist", "error": err})
return _perform_request(
provider="todoist",
integration=integration,
base_url="https://api.todoist.com/rest/v2",
allowed_methods=("GET", "POST", "DELETE"),
extra_headers={
"Authorization": f"Bearer {integration['access_token']}",
},
args=args,
)
def handle_calcom_api(args: dict[str, Any], **_: Any) -> str:
integration, err = _pick_active_integration("calcom")
if err:
return _json_response({"ok": False, "provider": "calcom", "error": err})
return _perform_request(
provider="calcom",
integration=integration,
base_url="https://api.cal.com",
allowed_methods=("GET", "POST", "PATCH"),
extra_headers={
"Authorization": f"Bearer {integration['access_token']}",
"cal-api-version": str(
integration.get("cal_api_version")
or DEFAULT_CALCOM_VERSION
),
},
args=args,
)
CRONJOB_CREATE_SCHEMA = {
"name": "cronjob_create",
"description": (
"Creates a recurring automation, reminder, or cronjob via Supabase edge "
"function. Use this whenever the user asks to schedule, remind, or automate "
"something on a recurring basis (e.g. 'remind me every Monday at 9am')."
),
"parameters": {
"type": "object",
"properties": {
"natural_language_input": {
"type": "string",
"description": (
"The user's original request in natural language "
"(e.g., 'remind me every Monday at 9am to check emails')."
),
},
"name": {
"type": "string",
"description": "A short name for this automation (optional).",
},
},
"required": ["natural_language_input"],
"additionalProperties": False,
},
}
SKILL_CREATE_SCHEMA = {
"name": "skill_create",
"description": (
"Creates a new custom Mika/Hermes skill via the Mika platform. Use this "
"when the user asks to teach the assistant a new procedure, add a new "
"skill, save a repeatable workflow, or turn instructions into a reusable "
"capability."
),
"parameters": {
"type": "object",
"properties": {
"natural_language_input": {
"type": "string",
"description": (
"The user's original request describing the skill to create."
),
},
"name": {
"type": "string",
"description": "A short skill name (optional).",
},
"description": {
"type": "string",
"description": "A short description of what the skill does (optional).",
},
"trigger_keywords": {
"type": "string",
"description": (
"Comma-separated phrases that should trigger this skill (optional)."
),
},
"markdown_content": {
"type": "string",
"description": (
"Full SKILL.md content if already drafted. If omitted, Mika "
"will generate a valid skill from natural_language_input."
),
},
},
"required": ["natural_language_input"],
"additionalProperties": False,
},
}
def _first_env(*names: str) -> str:
for name in names:
value = os.environ.get(name, "").strip()
if value:
return value
return ""
def _platform_functions_base_url() -> str:
explicit = _first_env(
"MIKA_PLATFORM_FUNCTIONS_BASE_URL",
"HERMES_PLATFORM_FUNCTIONS_BASE_URL",
).rstrip("/")
if explicit:
return explicit
supabase_url = os.environ.get("SUPABASE_URL", "").strip().rstrip("/")
if not supabase_url:
return ""
return f"{supabase_url}/functions/v1"
def _platform_endpoint(action: str) -> str:
if action == "cronjob":
explicit = _first_env("MIKA_CREATE_CRONJOB_URL", "HERMES_CREATE_CRONJOB_URL")
path = "create-cronjob-from-agent"
elif action == "skill":
explicit = _first_env("MIKA_CREATE_SKILL_URL", "HERMES_CREATE_SKILL_URL")
path = "create-skill-from-agent"
else:
raise ValueError(f"unknown platform action: {action}")
if explicit:
return explicit
base_url = _platform_functions_base_url()
if not base_url:
return ""
return f"{base_url}/{path}"
def _platform_auth_context(action: str) -> tuple[str, str, str] | str:
endpoint = _platform_endpoint(action)
internal_secret = _first_env(*INTERNAL_SECRET_ENV_NAMES)
agent_instance_id = _first_env(*AGENT_INSTANCE_ENV_NAMES)
if not endpoint:
return (
"Erro: endpoint da plataforma não configurado. Defina "
"MIKA_CREATE_CRONJOB_URL/MIKA_CREATE_SKILL_URL ou SUPABASE_URL."
)
if not internal_secret:
return "Erro: segredo interno da plataforma não configurado."
if not agent_instance_id:
return "Erro: agent_instance_id da Mika não configurado no runtime."
return endpoint, internal_secret, agent_instance_id
def _post_platform_action(action: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
ctx = _platform_auth_context(action)
if isinstance(ctx, str):
return 0, {"ok": False, "error": ctx}
url, internal_secret, agent_instance_id = ctx
body_payload = {
"agent_instance_id": agent_instance_id,
**payload,
}
body = json.dumps(body_payload, ensure_ascii=False).encode("utf-8")
headers = {
"Content-Type": "application/json",
"X-Internal-Secret": internal_secret,
"User-Agent": USER_AGENT,
}
req = request.Request(url=url, data=body, method="POST", headers=headers)
try:
with request.urlopen(req, timeout=45) as response:
raw_body = response.read()
try:
data = json.loads(raw_body.decode("utf-8", errors="replace"))
except Exception:
data = {}
return int(response.status), data
except error.HTTPError as exc:
raw_body = exc.read()
try:
data = json.loads(raw_body.decode("utf-8", errors="replace"))
except Exception:
data = {"error": str(exc)}
return int(exc.code), data
except Exception as exc:
return 0, {"error": f"Erro de rede ao chamar plataforma: {exc}"}
def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str:
natural_language_input = str(args.get("natural_language_input") or "").strip()
if not natural_language_input:
return "Erro: natural_language_input é obrigatório."
payload = {
"natural_language_input": natural_language_input,
}
name = args.get("name") or None
if name:
payload["name"] = str(name)
status, data = _post_platform_action("cronjob", payload)
if status < 200 or status >= 300 or data.get("success") is False:
err_msg = data.get("error") or data.get("message") or data.get("runtime_sync_error")
if not err_msg:
err_msg = f"HTTP {status}" if status else "falha desconhecida"
return f"Erro ao criar automação: {err_msg}"
human_readable = data.get("human_readable") or data.get("description") or ""
next_run_at = data.get("next_run_at") or ""
if human_readable:
msg = f"Automação criada e sincronizada: {human_readable}."
if next_run_at:
msg += f" Próxima execução: {next_run_at}."
return msg
return "Automação criada e sincronizada com sucesso."
def handle_skill_create(args: dict[str, Any], **_: Any) -> str:
natural_language_input = str(args.get("natural_language_input") or "").strip()
if not natural_language_input:
return "Erro: natural_language_input é obrigatório."
payload: dict[str, Any] = {
"natural_language_input": natural_language_input,
}
for key in ("name", "description", "trigger_keywords", "markdown_content"):
value = args.get(key)
if isinstance(value, str) and value.strip():
payload[key] = value.strip()
status, data = _post_platform_action("skill", payload)
if status < 200 or status >= 300 or data.get("success") is False:
if data.get("skill_id") and data.get("runtime_sync_ok") is False:
return (
"Skill criada na plataforma, mas ainda não sincronizada no runtime. "
f"Ela ficou em status {data.get('status') or 'testing'}. "
f"Erro: {data.get('runtime_sync_error') or 'sync falhou'}"
)
err_msg = data.get("error") or data.get("message")
if not err_msg:
err_msg = f"HTTP {status}" if status else "falha desconhecida"
return f"Erro ao criar skill: {err_msg}"
name = data.get("name") or "Skill"
synced_count = data.get("synced_count")
msg = f"Skill criada e sincronizada: {name}."
if synced_count is not None:
msg += f" Skills ativas sincronizadas: {synced_count}."
return msg
def _source_is_authorized_for_gateway_intercept(gateway: Any, source: Any) -> bool:
checker = getattr(gateway, "_is_user_authorized", None)
if not callable(checker):
return False
try:
return bool(checker(source))
except Exception:
logger.debug("gateway auth check failed for Mika intercept", exc_info=True)
return False
def _gateway_thread_metadata(gateway: Any, event: Any) -> dict[str, Any] | None:
builder = getattr(gateway, "_thread_metadata_for_source", None)
if not callable(builder):
return None
try:
return builder(event.source, getattr(event, "message_id", None))
except TypeError:
try:
return builder(event.source)
except Exception:
return None
except Exception:
return None
def _gateway_reply_anchor(gateway: Any, event: Any) -> str | None:
resolver = getattr(gateway, "_reply_anchor_for_event", None)
if callable(resolver):
try:
return resolver(event)
except Exception:
pass
message_id = getattr(event, "message_id", None)
return str(message_id) if message_id is not None else None
def _schedule_gateway_reply(
*,
loop: asyncio.AbstractEventLoop,
adapter: Any,
chat_id: str,
content: str,
reply_to: str | None,
metadata: dict[str, Any] | None,
) -> None:
async def _send() -> None:
await adapter.send(
chat_id=chat_id,
content=content,
reply_to=reply_to,
metadata=metadata,
)
future = asyncio.run_coroutine_threadsafe(_send(), loop)
def _log_failure(done: Any) -> None:
try:
done.result()
except Exception:
logger.warning("failed to send Mika platform action reply", exc_info=True)
future.add_done_callback(_log_failure)
def _run_gateway_platform_action(
*,
action: str,
natural_language_input: str,
loop: asyncio.AbstractEventLoop,
adapter: Any,
chat_id: str,
reply_to: str | None,
metadata: dict[str, Any] | None,
) -> None:
try:
if action == "cronjob":
content = handle_cronjob_create({
"natural_language_input": natural_language_input,
})
elif action == "skill":
content = handle_skill_create({
"natural_language_input": natural_language_input,
})
else:
content = "Erro: ação da plataforma não reconhecida."
except Exception as exc:
logger.warning("Mika platform action intercept failed", exc_info=True)
label = "automação" if action == "cronjob" else "skill"
content = f"Erro ao criar {label}: {exc}"
_schedule_gateway_reply(
loop=loop,
adapter=adapter,
chat_id=chat_id,
content=content,
reply_to=reply_to,
metadata=metadata,
)
def handle_gateway_platform_action_intercept(
*,
event: Any,
gateway: Any,
session_store: Any = None,
) -> dict[str, str] | None:
"""Pre-gateway hook that makes Mika platform actions deterministic.
The LLM can still use cronjob_create/skill_create as tools, but explicit
Telegram requests are also routed directly to Supabase before the model
runs. That keeps Supabase as the source of truth and avoids a natural
language "ok, vou lembrar" response that never persisted anything.
"""
del session_store
if not _is_truthy_env_default_true(*GATEWAY_ACTION_INTERCEPT_ENV_NAMES):
return None
if bool(getattr(event, "internal", False)):
return None
source = getattr(event, "source", None)
if source is None or bool(getattr(source, "is_bot", False)):
return None
text = str(getattr(event, "text", "") or "").strip()
action = detect_gateway_platform_action(text)
if not action:
return None
if not _source_is_authorized_for_gateway_intercept(gateway, source):
return None
adapters = getattr(gateway, "adapters", {}) or {}
adapter = adapters.get(getattr(source, "platform", None))
chat_id = str(getattr(source, "chat_id", "") or "").strip()
if adapter is None or not chat_id:
logger.warning("Mika intercept could not find adapter/chat_id for action=%s", action)
return None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.warning("Mika intercept has no running event loop")
return None
thread = threading.Thread(
target=_run_gateway_platform_action,
kwargs={
"action": action,
"natural_language_input": text,
"loop": loop,
"adapter": adapter,
"chat_id": chat_id,
"reply_to": _gateway_reply_anchor(gateway, event),
"metadata": _gateway_thread_metadata(gateway, event),
},
name=f"mika-platform-action-{action}",
daemon=True,
)
thread.start()
return {"action": "skip", "reason": f"mika_{action}_handled"}