mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 05:16:41 +00:00
Add Mika runtime bridge to Hermes image (#2)
* Add Mika runtime bridge to Hermes image * Build canary image tags on branch pushes
This commit is contained in:
parent
e87c943ebd
commit
1740e05917
7 changed files with 1192 additions and 28 deletions
28
.github/workflows/docker.yml
vendored
28
.github/workflows/docker.yml
vendored
|
|
@ -2,7 +2,13 @@ name: Build and Push Docker Image
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- "codex/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
@ -15,7 +21,22 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/domfelipe/hermes-agent-custom
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
|
@ -26,5 +47,6 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/domfelipe/hermes-agent-custom:latest
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
|||
17
Dockerfile
17
Dockerfile
|
|
@ -36,20 +36,35 @@ RUN pip install --no-cache-dir aiohttp
|
|||
# Copia customizações
|
||||
COPY patches/skills_api.py /opt/hermes-custom/skills_api.py
|
||||
COPY entrypoint.sh /opt/hermes-custom/entrypoint.sh
|
||||
COPY plugins/mika_runtime /opt/hermes/plugins/mika_runtime
|
||||
RUN chmod +x /opt/hermes-custom/entrypoint.sh
|
||||
|
||||
# Garante que o Python encontre o módulo customizado
|
||||
ENV PYTHONPATH=/opt/hermes-custom:/opt/hermes
|
||||
ENV HERMES_MODEL_PROVIDER=ollama-cloud
|
||||
ENV HERMES_MODEL_DEFAULT=gemma4:31b-cloud
|
||||
ENV HERMES_STT_PROVIDER=local
|
||||
ENV HERMES_STT_LOCAL_MODEL=base
|
||||
ENV HERMES_TTS_PROVIDER=disabled
|
||||
|
||||
# Diretório de dados/config do Hermes
|
||||
RUN mkdir -p /opt/data/.hermes /opt/hermes-custom
|
||||
|
||||
# Config padrão (caso HERMES_CONFIG_OVERRIDE não seja definido)
|
||||
# Config padrão. O entrypoint reescreve este arquivo em runtime a partir das
|
||||
# env vars por tenant, mas deixamos um fallback válido já embutido na imagem.
|
||||
RUN printf '%s\n' \
|
||||
'name: hermes-custom' \
|
||||
'host: 127.0.0.1' \
|
||||
'port: 8000' \
|
||||
'data_dir: /opt/data' \
|
||||
'model:' \
|
||||
' provider: "ollama-cloud"' \
|
||||
' default: "gemma4:31b-cloud"' \
|
||||
'stt:' \
|
||||
' enabled: true' \
|
||||
' provider: "local"' \
|
||||
' local:' \
|
||||
' model: "base"' \
|
||||
> /opt/data/.hermes/config.yaml
|
||||
|
||||
# SOUL.md padrão (sobrescrito em runtime via HERMES_SOUL_OVERRIDE)
|
||||
|
|
|
|||
|
|
@ -7,15 +7,78 @@ PUBLIC_HOST="${PUBLIC_HOST:-0.0.0.0}"
|
|||
PUBLIC_PORT="${PORT:-${PUBLIC_PORT:-8642}}"
|
||||
|
||||
HERMES_HOME="${HERMES_HOME:-/opt/data/.hermes}"
|
||||
CONFIG_PATH="${CONFIG_PATH:-$HERMES_HOME/config.yaml}"
|
||||
SOUL_PATH="${SOUL_PATH:-$HERMES_HOME/SOUL.md}"
|
||||
MODEL_PROVIDER="${HERMES_MODEL_PROVIDER:-ollama-cloud}"
|
||||
MODEL_DEFAULT="${HERMES_MODEL_DEFAULT:-gemma4:31b-cloud}"
|
||||
STT_PROVIDER="${HERMES_STT_PROVIDER:-local}"
|
||||
STT_LOCAL_MODEL="${HERMES_STT_LOCAL_MODEL:-base}"
|
||||
STT_OPENAI_MODEL="${HERMES_STT_OPENAI_MODEL:-whisper-1}"
|
||||
TTS_PROVIDER="${HERMES_TTS_PROVIDER:-disabled}"
|
||||
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
write_config() {
|
||||
cat > "$CONFIG_PATH" <<EOF
|
||||
name: hermes-custom
|
||||
host: ${INTERNAL_HOST}
|
||||
port: ${INTERNAL_PORT}
|
||||
data_dir: /opt/data
|
||||
model:
|
||||
provider: "${MODEL_PROVIDER}"
|
||||
default: "${MODEL_DEFAULT}"
|
||||
EOF
|
||||
|
||||
case "${STT_PROVIDER}" in
|
||||
""|disabled|none)
|
||||
cat >> "$CONFIG_PATH" <<EOF
|
||||
stt:
|
||||
enabled: false
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
cat >> "$CONFIG_PATH" <<EOF
|
||||
stt:
|
||||
enabled: true
|
||||
provider: "${STT_PROVIDER}"
|
||||
local:
|
||||
model: "${STT_LOCAL_MODEL}"
|
||||
openai:
|
||||
model: "${STT_OPENAI_MODEL}"
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${TTS_PROVIDER}" in
|
||||
""|disabled|none)
|
||||
;;
|
||||
*)
|
||||
cat >> "$CONFIG_PATH" <<EOF
|
||||
tts:
|
||||
provider: "${TTS_PROVIDER}"
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
write_config
|
||||
|
||||
echo "[entrypoint] Config: $CONFIG_PATH"
|
||||
echo "[entrypoint] Model provider: $MODEL_PROVIDER"
|
||||
echo "[entrypoint] Model default: $MODEL_DEFAULT"
|
||||
echo "[entrypoint] STT provider: $STT_PROVIDER"
|
||||
echo "[entrypoint] TTS provider: $TTS_PROVIDER"
|
||||
|
||||
if [ -n "${HERMES_SOUL_OVERRIDE:-}" ]; then
|
||||
echo "[entrypoint] Applying HERMES_SOUL_OVERRIDE to $SOUL_PATH"
|
||||
printf '%s\n' "$HERMES_SOUL_OVERRIDE" > "$SOUL_PATH"
|
||||
fi
|
||||
|
||||
if [ "${HERMES_SUSPENDED:-}" = "true" ]; then
|
||||
echo "[entrypoint] Agent suspended via HERMES_SUSPENDED=true"
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Hermes interno: $INTERNAL_HOST:$INTERNAL_PORT"
|
||||
echo "[entrypoint] Proxy público: $PUBLIC_HOST:$PUBLIC_PORT"
|
||||
echo "[entrypoint] Iniciando Hermes original..."
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
skills_api.py — Reverse proxy + Skills API para Hermes Agent.
|
||||
skills_api.py — Reverse proxy + Runtime Sync API para Hermes Agent.
|
||||
|
||||
Roda na porta pública ($PORT) e:
|
||||
- Intercepta /api/skills/* → handlers locais
|
||||
- Proxy todo o resto → Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT)
|
||||
- Intercepta /api/skills/*, /api/cronjobs/*, /api/integrations/*
|
||||
- Proxy todo o resto para o Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from aiohttp import web, ClientSession, ClientTimeout
|
||||
|
||||
|
|
@ -19,6 +22,15 @@ HERMES_INTERNAL_PORT = int(os.environ.get("HERMES_INTERNAL_PORT", "8000"))
|
|||
HERMES_BASE = f"http://127.0.0.1:{HERMES_INTERNAL_PORT}"
|
||||
API_SERVER_KEY = os.environ.get("API_SERVER_KEY", "")
|
||||
SKILLS_SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "")
|
||||
HERMES_HOME = os.environ.get("HERMES_HOME", "/opt/data/.hermes")
|
||||
SKILLS_ROOT = os.path.join(HERMES_HOME, "skills")
|
||||
MANAGED_SKILLS_ROOT = os.path.join(SKILLS_ROOT, "mika-managed")
|
||||
SKILLS_MANIFEST_PATH = os.path.join(MANAGED_SKILLS_ROOT, "manifest.json")
|
||||
RUNTIME_ROOT = os.path.join(HERMES_HOME, "mika")
|
||||
MANAGED_CRON_ROOT = os.path.join(RUNTIME_ROOT, "cronjobs")
|
||||
CRON_MANIFEST_PATH = os.path.join(MANAGED_CRON_ROOT, "manifest.json")
|
||||
MANAGED_INTEGRATIONS_ROOT = os.path.join(RUNTIME_ROOT, "integrations")
|
||||
INTEGRATIONS_MANIFEST_PATH = os.path.join(MANAGED_INTEGRATIONS_ROOT, "manifest.json")
|
||||
|
||||
# Hop-by-hop headers que NÃO devem ser repassados no proxy
|
||||
HOP_BY_HOP = {
|
||||
|
|
@ -26,44 +38,533 @@ HOP_BY_HOP = {
|
|||
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
def _check_auth(request: web.Request, expected_token: str = "") -> bool:
|
||||
"""Valida Authorization: Bearer <TOKEN>."""
|
||||
token = (expected_token or API_SERVER_KEY).strip()
|
||||
if not token:
|
||||
return True # sem chave configurada = aberto
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return False
|
||||
return auth[7:].strip() == token
|
||||
|
||||
|
||||
def _load_json(path: str, default: dict | list | None = None):
|
||||
if not os.path.exists(path):
|
||||
return default if default is not None else {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return default if default is not None else {}
|
||||
|
||||
|
||||
def _secure_file(path: str, mode: int = 0o600) -> None:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_json(path: str, payload: dict, mode: int = 0o600) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
_secure_file(path, mode)
|
||||
|
||||
|
||||
def _write_text(path: str, content: str, mode: int = 0o644) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
os.replace(tmp, path)
|
||||
_secure_file(path, mode)
|
||||
|
||||
|
||||
def _managed_skill_dirname(skill: dict) -> str:
|
||||
raw_name = str(skill.get("name") or "skill").strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", raw_name).strip("-") or "skill"
|
||||
skill_id = str(skill.get("skill_id") or "manual").strip()[:8] or "manual"
|
||||
return f"{slug}--{skill_id}"
|
||||
|
||||
|
||||
def _managed_entry_name(slug_source: str, entry_id: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", str(slug_source or "entry").strip().lower()).strip("-") or "entry"
|
||||
suffix = re.sub(r"[^a-z0-9]+", "", str(entry_id or "manual").lower())[:8] or "manual"
|
||||
return f"{slug}--{suffix}"
|
||||
|
||||
|
||||
def _load_skills_manifest() -> dict:
|
||||
data = _load_json(SKILLS_MANIFEST_PATH, {"skills": []})
|
||||
return data if isinstance(data, dict) else {"skills": []}
|
||||
|
||||
|
||||
def _load_runtime_manifest(path: str, key: str) -> dict:
|
||||
data = _load_json(path, {key: []})
|
||||
return data if isinstance(data, dict) else {key: []}
|
||||
|
||||
|
||||
def _load_cron_helpers():
|
||||
try:
|
||||
from cron.jobs import ensure_dirs, load_jobs, save_jobs, compute_next_run
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"cron helpers unavailable: {exc}") from exc
|
||||
return ensure_dirs, load_jobs, save_jobs, compute_next_run
|
||||
|
||||
|
||||
def _normalize_string_list(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
items = []
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
text = item.strip()
|
||||
if text:
|
||||
items.append(text)
|
||||
return items
|
||||
|
||||
|
||||
def _sanitize_cron_runtime_job(raw_job: dict, existing_job: dict | None, synced_at: str, compute_next_run):
|
||||
job_id = str(raw_job.get("job_id") or "").strip()
|
||||
name = str(raw_job.get("name") or "").strip()
|
||||
prompt = str(raw_job.get("action_prompt") or "").strip()
|
||||
cron_expression = str(raw_job.get("cron_expression") or "").strip()
|
||||
|
||||
if not job_id or not name or not prompt or not cron_expression:
|
||||
return None
|
||||
|
||||
status = str(raw_job.get("status") or "paused").strip().lower()
|
||||
enabled = status == "active"
|
||||
human_readable = str(raw_job.get("human_readable") or cron_expression).strip() or cron_expression
|
||||
schedule = {
|
||||
"kind": "cron",
|
||||
"expr": cron_expression,
|
||||
"display": human_readable,
|
||||
}
|
||||
|
||||
last_run_at = (
|
||||
existing_job.get("last_run_at")
|
||||
if existing_job and existing_job.get("last_run_at")
|
||||
else raw_job.get("last_run_at")
|
||||
)
|
||||
|
||||
schedule_changed = (
|
||||
not existing_job or
|
||||
existing_job.get("schedule", {}).get("expr") != cron_expression
|
||||
)
|
||||
|
||||
if existing_job and not schedule_changed and existing_job.get("next_run_at"):
|
||||
next_run_at = existing_job.get("next_run_at")
|
||||
else:
|
||||
next_run_at = raw_job.get("next_run_at") or compute_next_run(schedule, last_run_at)
|
||||
|
||||
repeat = existing_job.get("repeat") if isinstance(existing_job, dict) else None
|
||||
if not isinstance(repeat, dict):
|
||||
repeat = {"times": None, "completed": 0}
|
||||
repeat_completed = repeat.get("completed")
|
||||
if not isinstance(repeat_completed, int):
|
||||
repeat_completed = 0
|
||||
repeat_times = repeat.get("times")
|
||||
if not isinstance(repeat_times, int):
|
||||
repeat_times = None
|
||||
|
||||
paused_reason = None if enabled else (raw_job.get("auto_paused_reason") or "Sincronizado como pausado pelo Mika")
|
||||
paused_at = None if enabled else (
|
||||
existing_job.get("paused_at")
|
||||
if existing_job and existing_job.get("paused_at")
|
||||
else synced_at
|
||||
)
|
||||
|
||||
return {
|
||||
"id": job_id,
|
||||
"name": name,
|
||||
"prompt": prompt,
|
||||
"skills": [],
|
||||
"skill": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"script": None,
|
||||
"context_from": None,
|
||||
"schedule": schedule,
|
||||
"schedule_display": human_readable,
|
||||
"repeat": {
|
||||
"times": repeat_times,
|
||||
"completed": repeat_completed,
|
||||
},
|
||||
"enabled": enabled,
|
||||
"state": "scheduled" if enabled else "paused",
|
||||
"paused_at": paused_at,
|
||||
"paused_reason": paused_reason,
|
||||
"created_at": (
|
||||
existing_job.get("created_at")
|
||||
if existing_job and existing_job.get("created_at")
|
||||
else str(raw_job.get("created_at") or synced_at)
|
||||
),
|
||||
"next_run_at": next_run_at,
|
||||
"last_run_at": last_run_at,
|
||||
"last_status": existing_job.get("last_status") if existing_job else None,
|
||||
"last_error": existing_job.get("last_error") if existing_job else None,
|
||||
"last_delivery_error": existing_job.get("last_delivery_error") if existing_job else None,
|
||||
"deliver": "local",
|
||||
"origin": None,
|
||||
"enabled_toolsets": None,
|
||||
"workdir": None,
|
||||
"managed_by": "mika",
|
||||
"mika": {
|
||||
"job_id": job_id,
|
||||
"description": raw_job.get("description"),
|
||||
"natural_language_input": raw_job.get("natural_language_input"),
|
||||
"required_mcp_slugs": _normalize_string_list(raw_job.get("required_mcp_slugs")),
|
||||
"status": status,
|
||||
"auto_paused_reason": raw_job.get("auto_paused_reason"),
|
||||
"timezone": raw_job.get("timezone"),
|
||||
"source_updated_at": raw_job.get("updated_at"),
|
||||
"synced_at": synced_at,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Skills API handlers
|
||||
# ============================================================
|
||||
async def skills_health(request: web.Request) -> web.Response:
|
||||
"""Health check — não requer auth."""
|
||||
manifest = _load_skills_manifest()
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "skills_api",
|
||||
"hermes_backend": HERMES_BASE,
|
||||
"managed_skills_root": MANAGED_SKILLS_ROOT,
|
||||
"managed_skills_count": len(manifest.get("skills", [])),
|
||||
"last_sync_at": manifest.get("synced_at"),
|
||||
})
|
||||
|
||||
|
||||
def _check_auth(request: web.Request) -> bool:
|
||||
"""Valida Authorization: Bearer <API_SERVER_KEY>."""
|
||||
if not API_SERVER_KEY:
|
||||
return True # sem chave configurada = aberto
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return False
|
||||
return auth[7:].strip() == API_SERVER_KEY
|
||||
|
||||
|
||||
async def skills_list(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
# TODO: integrar com sistema real de skills do Hermes
|
||||
return web.json_response({"skills": []})
|
||||
manifest = _load_skills_manifest()
|
||||
return web.json_response({
|
||||
"skills": manifest.get("skills", []),
|
||||
"managed_skills_root": MANAGED_SKILLS_ROOT,
|
||||
"synced_at": manifest.get("synced_at"),
|
||||
"agent_instance_id": manifest.get("agent_instance_id"),
|
||||
})
|
||||
|
||||
|
||||
async def skills_sync(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request):
|
||||
if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid json"}, status=400)
|
||||
# TODO: persistir skills recebidas
|
||||
return web.json_response({"ok": True, "received": len(payload.get("skills", []))})
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response({"error": "payload must be a json object"}, status=400)
|
||||
|
||||
incoming_skills = payload.get("skills", [])
|
||||
if not isinstance(incoming_skills, list):
|
||||
return web.json_response({"error": "skills must be a list"}, status=400)
|
||||
|
||||
os.makedirs(MANAGED_SKILLS_ROOT, exist_ok=True)
|
||||
|
||||
expected_dirs = set()
|
||||
written_skills = []
|
||||
skipped = 0
|
||||
|
||||
for raw_skill in incoming_skills:
|
||||
if not isinstance(raw_skill, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
skill_id = str(raw_skill.get("skill_id") or "").strip()
|
||||
name = str(raw_skill.get("name") or "").strip()
|
||||
markdown_content = str(raw_skill.get("markdown_content") or "").strip()
|
||||
|
||||
if not skill_id or not name or not markdown_content:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
dirname = _managed_skill_dirname(raw_skill)
|
||||
skill_dir = os.path.join(MANAGED_SKILLS_ROOT, dirname)
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
|
||||
skill_markdown = markdown_content if markdown_content.endswith("\n") else f"{markdown_content}\n"
|
||||
_write_text(os.path.join(skill_dir, "SKILL.md"), skill_markdown, mode=0o644)
|
||||
|
||||
metadata = dict(raw_skill)
|
||||
metadata.pop("markdown_content", None)
|
||||
metadata["managed_dir"] = dirname
|
||||
metadata["synced_at"] = payload.get("synced_at")
|
||||
_write_json(os.path.join(skill_dir, "mika.json"), metadata, mode=0o644)
|
||||
|
||||
expected_dirs.add(dirname)
|
||||
written_skills.append(metadata)
|
||||
|
||||
removed = []
|
||||
if os.path.isdir(MANAGED_SKILLS_ROOT):
|
||||
for entry in os.listdir(MANAGED_SKILLS_ROOT):
|
||||
if entry == "manifest.json":
|
||||
continue
|
||||
full_path = os.path.join(MANAGED_SKILLS_ROOT, entry)
|
||||
if os.path.isdir(full_path) and entry not in expected_dirs:
|
||||
shutil.rmtree(full_path, ignore_errors=True)
|
||||
removed.append(entry)
|
||||
|
||||
manifest = {
|
||||
"agent_instance_id": payload.get("agent_instance_id"),
|
||||
"synced_at": payload.get("synced_at"),
|
||||
"skills": written_skills,
|
||||
}
|
||||
_write_json(SKILLS_MANIFEST_PATH, manifest, mode=0o644)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"received": len(incoming_skills),
|
||||
"written": len(written_skills),
|
||||
"skipped": skipped,
|
||||
"removed": removed,
|
||||
"managed_skills_root": MANAGED_SKILLS_ROOT,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cronjobs API handlers
|
||||
# ============================================================
|
||||
async def cronjobs_health(request: web.Request) -> web.Response:
|
||||
manifest = _load_runtime_manifest(CRON_MANIFEST_PATH, "cronjobs")
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "cronjobs_api",
|
||||
"managed_cron_root": MANAGED_CRON_ROOT,
|
||||
"managed_cronjobs_count": len(manifest.get("cronjobs", [])),
|
||||
"last_sync_at": manifest.get("synced_at"),
|
||||
})
|
||||
|
||||
|
||||
async def cronjobs_list(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
ensure_dirs, load_jobs, _, _ = _load_cron_helpers()
|
||||
ensure_dirs()
|
||||
jobs = load_jobs()
|
||||
managed_jobs = [job for job in jobs if job.get("managed_by") == "mika"]
|
||||
manifest = _load_runtime_manifest(CRON_MANIFEST_PATH, "cronjobs")
|
||||
|
||||
return web.json_response({
|
||||
"cronjobs": managed_jobs,
|
||||
"manifest": manifest,
|
||||
"managed_cron_root": MANAGED_CRON_ROOT,
|
||||
"synced_at": manifest.get("synced_at"),
|
||||
"agent_instance_id": manifest.get("agent_instance_id"),
|
||||
})
|
||||
|
||||
|
||||
async def cronjobs_sync(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid json"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response({"error": "payload must be a json object"}, status=400)
|
||||
|
||||
incoming_jobs = payload.get("cronjobs", [])
|
||||
if not isinstance(incoming_jobs, list):
|
||||
return web.json_response({"error": "cronjobs must be a list"}, status=400)
|
||||
|
||||
ensure_dirs, load_jobs, save_jobs, compute_next_run = _load_cron_helpers()
|
||||
ensure_dirs()
|
||||
os.makedirs(MANAGED_CRON_ROOT, exist_ok=True)
|
||||
|
||||
existing_jobs = load_jobs()
|
||||
existing_managed = {
|
||||
str(job.get("id")): job
|
||||
for job in existing_jobs
|
||||
if job.get("managed_by") == "mika"
|
||||
}
|
||||
unmanaged_jobs = [job for job in existing_jobs if job.get("managed_by") != "mika"]
|
||||
|
||||
synced_at = str(payload.get("synced_at") or "")
|
||||
|
||||
runtime_jobs = []
|
||||
manifest_jobs = []
|
||||
skipped = 0
|
||||
|
||||
for raw_job in incoming_jobs:
|
||||
if not isinstance(raw_job, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
job_id = str(raw_job.get("job_id") or "").strip()
|
||||
existing_job = existing_managed.get(job_id)
|
||||
runtime_job = _sanitize_cron_runtime_job(raw_job, existing_job, synced_at, compute_next_run)
|
||||
if not runtime_job:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
runtime_jobs.append(runtime_job)
|
||||
manifest_jobs.append({
|
||||
"job_id": runtime_job["id"],
|
||||
"name": runtime_job["name"],
|
||||
"status": runtime_job["mika"]["status"],
|
||||
"cron_expression": runtime_job["schedule"]["expr"],
|
||||
"human_readable": runtime_job["schedule_display"],
|
||||
"required_mcp_slugs": runtime_job["mika"]["required_mcp_slugs"],
|
||||
"timezone": runtime_job["mika"]["timezone"],
|
||||
"last_run_at": runtime_job.get("last_run_at"),
|
||||
"next_run_at": runtime_job.get("next_run_at"),
|
||||
"last_status": runtime_job.get("last_status"),
|
||||
"last_error": runtime_job.get("last_error"),
|
||||
"last_delivery_error": runtime_job.get("last_delivery_error"),
|
||||
"source_updated_at": runtime_job["mika"].get("source_updated_at"),
|
||||
"synced_at": runtime_job["mika"].get("synced_at"),
|
||||
})
|
||||
|
||||
save_jobs(unmanaged_jobs + runtime_jobs)
|
||||
|
||||
incoming_ids = {str(job.get("id")) for job in runtime_jobs}
|
||||
removed = [
|
||||
job_id
|
||||
for job_id in existing_managed.keys()
|
||||
if job_id not in incoming_ids
|
||||
]
|
||||
|
||||
manifest = {
|
||||
"agent_instance_id": payload.get("agent_instance_id"),
|
||||
"synced_at": payload.get("synced_at"),
|
||||
"cronjobs": manifest_jobs,
|
||||
}
|
||||
_write_json(CRON_MANIFEST_PATH, manifest, mode=0o644)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"received": len(incoming_jobs),
|
||||
"written": len(runtime_jobs),
|
||||
"skipped": skipped,
|
||||
"removed": removed,
|
||||
"managed_cron_root": MANAGED_CRON_ROOT,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Integrations API handlers
|
||||
# ============================================================
|
||||
async def integrations_health(request: web.Request) -> web.Response:
|
||||
manifest = _load_runtime_manifest(INTEGRATIONS_MANIFEST_PATH, "integrations")
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "integrations_api",
|
||||
"managed_integrations_root": MANAGED_INTEGRATIONS_ROOT,
|
||||
"managed_integrations_count": len(manifest.get("integrations", [])),
|
||||
"last_sync_at": manifest.get("synced_at"),
|
||||
})
|
||||
|
||||
|
||||
async def integrations_list(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
manifest = _load_runtime_manifest(INTEGRATIONS_MANIFEST_PATH, "integrations")
|
||||
return web.json_response({
|
||||
"integrations": manifest.get("integrations", []),
|
||||
"managed_integrations_root": MANAGED_INTEGRATIONS_ROOT,
|
||||
"synced_at": manifest.get("synced_at"),
|
||||
"agent_instance_id": manifest.get("agent_instance_id"),
|
||||
"user_id": manifest.get("user_id"),
|
||||
})
|
||||
|
||||
|
||||
async def integrations_sync(request: web.Request) -> web.Response:
|
||||
if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY):
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid json"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response({"error": "payload must be a json object"}, status=400)
|
||||
|
||||
incoming_integrations = payload.get("integrations", [])
|
||||
if not isinstance(incoming_integrations, list):
|
||||
return web.json_response({"error": "integrations must be a list"}, status=400)
|
||||
|
||||
os.makedirs(MANAGED_INTEGRATIONS_ROOT, exist_ok=True)
|
||||
|
||||
expected_files = set()
|
||||
written_integrations = []
|
||||
skipped = 0
|
||||
|
||||
for raw_integration in incoming_integrations:
|
||||
if not isinstance(raw_integration, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
integration_id = str(raw_integration.get("integration_id") or "").strip()
|
||||
slug = str(raw_integration.get("slug") or "").strip()
|
||||
name = str(raw_integration.get("name") or "").strip()
|
||||
|
||||
if not integration_id or not slug or not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
entry_name = _managed_entry_name(slug, integration_id)
|
||||
full_path = os.path.join(MANAGED_INTEGRATIONS_ROOT, f"{entry_name}.json")
|
||||
|
||||
full_record = dict(raw_integration)
|
||||
full_record["managed_file"] = f"{entry_name}.json"
|
||||
full_record["synced_at"] = payload.get("synced_at")
|
||||
_write_json(full_path, full_record, mode=0o600)
|
||||
|
||||
manifest_record = dict(full_record)
|
||||
manifest_record.pop("access_token", None)
|
||||
manifest_record.pop("refresh_token", None)
|
||||
written_integrations.append(manifest_record)
|
||||
expected_files.add(f"{entry_name}.json")
|
||||
|
||||
removed = []
|
||||
if os.path.isdir(MANAGED_INTEGRATIONS_ROOT):
|
||||
for entry in os.listdir(MANAGED_INTEGRATIONS_ROOT):
|
||||
if entry == "manifest.json":
|
||||
continue
|
||||
full_path = os.path.join(MANAGED_INTEGRATIONS_ROOT, entry)
|
||||
if os.path.isfile(full_path) and entry not in expected_files:
|
||||
os.remove(full_path)
|
||||
removed.append(entry)
|
||||
|
||||
manifest = {
|
||||
"agent_instance_id": payload.get("agent_instance_id"),
|
||||
"user_id": payload.get("user_id"),
|
||||
"synced_at": payload.get("synced_at"),
|
||||
"integrations": written_integrations,
|
||||
}
|
||||
_write_json(INTEGRATIONS_MANIFEST_PATH, manifest, mode=0o600)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"received": len(incoming_integrations),
|
||||
"written": len(written_integrations),
|
||||
"skipped": skipped,
|
||||
"removed": removed,
|
||||
"managed_integrations_root": MANAGED_INTEGRATIONS_ROOT,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -73,7 +574,6 @@ async def proxy(request: web.Request) -> web.StreamResponse:
|
|||
"""Encaminha qualquer request para o Hermes interno."""
|
||||
target_url = f"{HERMES_BASE}{request.rel_url}"
|
||||
|
||||
# Filtra headers hop-by-hop
|
||||
headers = {
|
||||
k: v for k, v in request.headers.items()
|
||||
if k.lower() not in HOP_BY_HOP
|
||||
|
|
@ -90,7 +590,6 @@ async def proxy(request: web.Request) -> web.StreamResponse:
|
|||
data=body,
|
||||
allow_redirects=False,
|
||||
) as upstream:
|
||||
# Stream da resposta de volta
|
||||
resp_headers = {
|
||||
k: v for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_BY_HOP
|
||||
|
|
@ -115,16 +614,21 @@ async def proxy(request: web.Request) -> web.StreamResponse:
|
|||
# App setup
|
||||
# ============================================================
|
||||
def make_app() -> web.Application:
|
||||
app = web.Application(client_max_size=50 * 1024 * 1024) # 50MB
|
||||
app = web.Application(client_max_size=50 * 1024 * 1024)
|
||||
|
||||
# Skills API (precedência sobre o proxy)
|
||||
app.router.add_get("/api/skills/health", skills_health)
|
||||
app.router.add_get("/api/skills", skills_list)
|
||||
app.router.add_post("/api/skills/sync", skills_sync)
|
||||
|
||||
# Proxy catch-all (qualquer outro path)
|
||||
app.router.add_route("*", "/{tail:.*}", proxy)
|
||||
app.router.add_get("/api/cronjobs/health", cronjobs_health)
|
||||
app.router.add_get("/api/cronjobs", cronjobs_list)
|
||||
app.router.add_post("/api/cronjobs/sync", cronjobs_sync)
|
||||
|
||||
app.router.add_get("/api/integrations/health", integrations_health)
|
||||
app.router.add_get("/api/integrations", integrations_list)
|
||||
app.router.add_post("/api/integrations/sync", integrations_sync)
|
||||
|
||||
app.router.add_route("*", "/{tail:.*}", proxy)
|
||||
return app
|
||||
|
||||
|
||||
|
|
|
|||
44
plugins/mika_runtime/__init__.py
Normal file
44
plugins/mika_runtime/__init__.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Bundled Hermes plugin that exposes Mika-managed integrations as stable tools."""
|
||||
|
||||
from plugins.mika_runtime.tools import (
|
||||
CALCOM_API_SCHEMA,
|
||||
INTEGRATIONS_STATUS_SCHEMA,
|
||||
NOTION_API_SCHEMA,
|
||||
TODOIST_API_SCHEMA,
|
||||
handle_calcom_api,
|
||||
handle_integrations_status,
|
||||
handle_notion_api,
|
||||
handle_todoist_api,
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register Mika integration bridge tools."""
|
||||
ctx.register_tool(
|
||||
name="mika_integrations_status",
|
||||
toolset="mika_integrations",
|
||||
schema=INTEGRATIONS_STATUS_SCHEMA,
|
||||
handler=handle_integrations_status,
|
||||
emoji="🔌",
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="mika_notion_api",
|
||||
toolset="mika_integrations",
|
||||
schema=NOTION_API_SCHEMA,
|
||||
handler=handle_notion_api,
|
||||
emoji="🧠",
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="mika_todoist_api",
|
||||
toolset="mika_integrations",
|
||||
schema=TODOIST_API_SCHEMA,
|
||||
handler=handle_todoist_api,
|
||||
emoji="✅",
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="mika_calcom_api",
|
||||
toolset="mika_integrations",
|
||||
schema=CALCOM_API_SCHEMA,
|
||||
handler=handle_calcom_api,
|
||||
emoji="📅",
|
||||
)
|
||||
10
plugins/mika_runtime/plugin.yaml
Normal file
10
plugins/mika_runtime/plugin.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name: mika_runtime
|
||||
version: 0.1.0
|
||||
description: "DOMCO Mika runtime integrations bridge for Notion, Todoist, and Cal.com using synced OAuth tokens."
|
||||
author: DOMCO
|
||||
kind: backend
|
||||
provides_tools:
|
||||
- mika_integrations_status
|
||||
- mika_notion_api
|
||||
- mika_todoist_api
|
||||
- mika_calcom_api
|
||||
506
plugins/mika_runtime/tools.py
Normal file
506
plugins/mika_runtime/tools.py
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
"""Stable runtime bridge for integrations synced from Mika into Hermes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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"
|
||||
|
||||
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 _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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue