597 lines
26 KiB
Python
597 lines
26 KiB
Python
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
||
|
||
Références ObsiGate (Phase 4, issue ObsiGate #22) : un connecteur personnel
|
||
peut pointer vers ``coffre:<slug>/<nom>`` au lieu d'un jeton chiffré local.
|
||
La référence est **toujours liée à un workspace_id** (voir
|
||
``app/services/coffre_reference.py``) ; à l'appel, la valeur est distribuée
|
||
en mémoire par le serveur puis oubliée, jamais persistée ni mise en cache.
|
||
Les secrets déjà chiffrés localement restent valides (migration
|
||
opportuniste, connecteur par connecteur — voir
|
||
``docs/coffre-reference-flowdeck.md``).
|
||
|
||
|
||
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
|
||
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
||
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
||
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
|
||
d'outils dans ``tools_json``).
|
||
|
||
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
|
||
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
|
||
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
|
||
from app.config import settings
|
||
from app.db import get_conn
|
||
from app.services import coffre_reference as coffre_module
|
||
from app.services import oauth_connectors
|
||
from app.services.coffre_reference import CoffreWorkspaceMismatch
|
||
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
|
||
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak", "obsigate")
|
||
#: Kinds personnels qui parlent MCP (probe = handshake + tools/list,
|
||
#: outils dynamiques ``mcp_<serveur>_*``) — générique + les deux presets
|
||
#: de fédération du programme (TikTak ACC-2.1, ObsiGate ACC-2.2).
|
||
MCP_KINDS = ("mcp", "tiktak", "obsigate")
|
||
AUTH_SCHEMES = ("bearer", "bot", "none")
|
||
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
||
# par l'utilisateur — jamais codé en dur ici).
|
||
def _presets() -> dict:
|
||
from app.services import draco_urls
|
||
|
||
return {
|
||
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
|
||
"hint": "Bot Discord Developers → Token"},
|
||
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
|
||
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
||
"mcp": {"url": "https://", "auth": "bearer",
|
||
"hint": "URL du endpoint MCP (streamable HTTP)"},
|
||
# Fusion ACC-2.1 — première paire de fédération : le serveur MCP en
|
||
# lecture de TikTak (POST /mcp, JSON-RPC) ; la clé `tck_` se crée
|
||
# dans TikTak → Réglages → Intégrations. Comportement MCP standard
|
||
# (probe, cache d'outils, outils dynamiques mcp_tiktak_*).
|
||
# v7.82.0 : URL dérivée de la config (TIKTAK_URL ; défaut officiel).
|
||
"tiktak": {"url": f"{draco_urls.tiktak_url()}/mcp", "auth": "bearer",
|
||
"hint": "TikTak → Réglages → Intégrations → clé API tck_…"},
|
||
# Fusion ACC-2.2 — pont MCP vers ObsiGate (plan « étape 1 :
|
||
# lectures seules », tests T0–T5) : serveur MCP d'ObsiGate sur
|
||
# /mcp (Streamable HTTP à session — le client gère le
|
||
# Mcp-Session-Id). La clé se crée dans ObsiGate →
|
||
# Configurations → Clés API & MCP, dédiée au pont et limitée
|
||
# à UN SEUL vault au départ : c'est le jeton qui fait la
|
||
# portée (garde-fou n° 1 du plan), pas ce preset.
|
||
"obsigate": {"url": f"{draco_urls.obsigate_url()}/mcp", "auth": "bearer",
|
||
"hint": "ObsiGate → Configurations → Clés API & MCP → "
|
||
"clé dédiée au pont, limitée à un seul vault"},
|
||
}
|
||
|
||
|
||
class _Presets(dict):
|
||
"""Vue dict des presets, résolue à chaque accès (config dev/prod).
|
||
|
||
``PRESETS`` reste un dict pour ses lecteurs historiques
|
||
(``PRESETS["tiktak"]["url"]``), mais l'URL TikTak suit la config
|
||
courante au lieu d'être figée à l'import.
|
||
"""
|
||
|
||
def __getitem__(self, key):
|
||
return _presets()[key]
|
||
|
||
def __contains__(self, key):
|
||
return key in _presets()
|
||
|
||
def get(self, key, default=None):
|
||
return _presets().get(key, default)
|
||
|
||
def keys(self):
|
||
return _presets().keys()
|
||
|
||
def items(self):
|
||
return _presets().items()
|
||
|
||
def values(self):
|
||
return _presets().values()
|
||
|
||
|
||
PRESETS = _Presets()
|
||
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
|
||
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
||
|
||
|
||
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
||
|
||
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
|
||
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
|
||
if kind in OAUTH_KINDS:
|
||
try:
|
||
oauth_connectors._client(kind) # lève si client_id/secret absents
|
||
except ValueError as exc:
|
||
return ("missing", str(exc))
|
||
tok = oauth_connectors.tokens(kind, user_id)
|
||
if tok.get("access_token"):
|
||
scope = (tok.get("scope") or "").split()
|
||
return ("ok", f"connecté · {len(scope)} scope(s)")
|
||
return ("missing", "non connecté — lancer la connexion OAuth")
|
||
if kind == "gitea":
|
||
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
||
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
|
||
if kind == "github":
|
||
if settings.github_token:
|
||
return ("ok", "PAT configuré (30 req/min)")
|
||
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
|
||
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
||
|
||
|
||
#: Colonnes lues pour un connecteur personnel (les deux colonnes coffre
|
||
#: sont ajoutées par la migration 52 ; les bases antérieures les ignorent
|
||
#: via les requêtes tolérantes de `_select_cols` — voir ci-dessous).
|
||
_CONNECTOR_COLS = ("id", "name", "url", "secret_encrypted", "enabled", "status",
|
||
"detail", "kind", "auth", "tools_json", "workspace_id",
|
||
"coffre_reference")
|
||
|
||
|
||
def _select_cols(conn) -> str:
|
||
"""Liste SELECT tolérante : les colonnes coffre n'existent qu'après
|
||
la migration 52 (bases créées avant, ou tests à schéma figé)."""
|
||
try:
|
||
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
|
||
except Exception: # noqa: BLE001 — repli : colonnes historiques seules
|
||
cols = set(_CONNECTOR_COLS[:10])
|
||
wanted = [c for c in _CONNECTOR_COLS if c in cols]
|
||
return ", ".join(wanted) if wanted else ", ".join(_CONNECTOR_COLS[:10])
|
||
|
||
|
||
def _row(r) -> dict:
|
||
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).
|
||
|
||
``coffre_reference`` est une métadonnée (pas une valeur) : elle peut
|
||
figurer dans la réponse. ``secret_source`` indique la provenance :
|
||
``coffre`` (distribué à l'appel), ``chiffre_local`` (Fernet historique)
|
||
ou ``aucun``.
|
||
"""
|
||
try:
|
||
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
||
except (ValueError, TypeError):
|
||
tools = []
|
||
try:
|
||
reference = r["coffre_reference"] or ""
|
||
except (KeyError, IndexError):
|
||
reference = ""
|
||
try:
|
||
workspace_id = r["workspace_id"]
|
||
except (KeyError, IndexError):
|
||
workspace_id = None
|
||
try:
|
||
stored = r["secret_encrypted"]
|
||
except (KeyError, IndexError):
|
||
stored = ""
|
||
if reference:
|
||
source = "coffre"
|
||
elif stored:
|
||
source = "chiffre_local"
|
||
else:
|
||
source = "aucun"
|
||
return {
|
||
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
||
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
||
"status": r["status"], "detail": r["detail"] or "",
|
||
"has_secret": bool(stored or reference), "oauth": False,
|
||
"tools_count": len(tools), "workspace_id": workspace_id,
|
||
"coffre_reference": reference, "secret_source": source,
|
||
}
|
||
|
||
|
||
def list_connectors(user_id: int | None = None) -> list[dict]:
|
||
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
|
||
out: list[dict] = []
|
||
for kind in NATIVE_KINDS:
|
||
status, detail = native_state(kind, user_id)
|
||
out.append({
|
||
"id": None, "builtin": True, "kind": kind,
|
||
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
|
||
else kind.capitalize()),
|
||
"url": "", "enabled": True, "status": status, "detail": detail,
|
||
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||
})
|
||
with get_conn() as conn:
|
||
cols = _select_cols(conn)
|
||
rows = conn.execute(
|
||
f"SELECT {cols} FROM agent_connectors ORDER BY id"
|
||
).fetchall()
|
||
return out + [_row(r) for r in rows]
|
||
|
||
|
||
def get(connector_id: int) -> dict | None:
|
||
with get_conn() as conn:
|
||
cols = _select_cols(conn)
|
||
r = conn.execute(
|
||
f"SELECT {cols} FROM agent_connectors WHERE id=?",
|
||
(connector_id,),
|
||
).fetchone()
|
||
return _row(r) if r else None
|
||
|
||
|
||
def _workspace_exists(workspace_id: int) -> bool:
|
||
with get_conn() as conn:
|
||
row = conn.execute(
|
||
"SELECT id FROM workspaces WHERE id=?", (workspace_id,)
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
||
|
||
def create_connector(name: str, url: str, secret: str = "", *,
|
||
kind: str = "custom", auth: str = "bearer",
|
||
workspace_id: int | None = None,
|
||
coffre_reference: str | None = None) -> dict:
|
||
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).
|
||
|
||
Variante coffre (Phase 4) : si ``coffre_reference`` est fourni, le
|
||
champ jeton **devient** ce champ référence — aucun jeton n'est stocké
|
||
(``secret_encrypted`` reste vide) et un ``workspace_id`` existant est
|
||
**obligatoire** (octroi toujours lié à un workspace). ``secret`` et
|
||
``coffre_reference`` sont exclusifs. Un connecteur sans référence peut
|
||
aussi recevoir un ``workspace_id`` (première étape d'une migration
|
||
opportuniste : rattacher d'abord, basculer ensuite).
|
||
"""
|
||
from app.services.importers.url_fetch import _validate_url
|
||
|
||
name = (name or "").strip()
|
||
if not name:
|
||
raise ValueError("name est requis")
|
||
if kind not in CUSTOM_KINDS:
|
||
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
|
||
if auth not in AUTH_SCHEMES:
|
||
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
|
||
reference = ""
|
||
if coffre_reference is not None and str(coffre_reference).strip():
|
||
reference = coffre_module.parse_reference(str(coffre_reference))
|
||
reference = f"coffre:{reference[0]}/{reference[1]}"
|
||
if (secret or "").strip():
|
||
raise ValueError("secret et coffre_reference sont exclusifs "
|
||
"(le jeton devient la référence)")
|
||
if workspace_id is None:
|
||
raise ValueError("coffre_reference exige un workspace_id "
|
||
"(octroi toujours lié à un workspace)")
|
||
if workspace_id is not None:
|
||
workspace_id = int(workspace_id)
|
||
if workspace_id <= 0 or not _workspace_exists(workspace_id):
|
||
raise ValueError(f"workspace introuvable: {workspace_id}")
|
||
url = (url or "").strip()
|
||
if "{secret}" in url and not (secret or "").strip() and not reference:
|
||
raise ValueError("clé requise : l'URL contient {secret}")
|
||
url = _validate_url(url) # lève ValueError si hôte interne
|
||
with get_conn() as conn:
|
||
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
|
||
extra_cols, extra_vals = "", ()
|
||
if "workspace_id" in cols:
|
||
extra_cols += ", workspace_id"
|
||
extra_vals += (workspace_id,)
|
||
if "coffre_reference" in cols:
|
||
extra_cols += ", coffre_reference"
|
||
extra_vals += (reference,)
|
||
cur = conn.execute(
|
||
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
||
f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})",
|
||
(name, url, encrypt_secret(secret or "") if not reference else "",
|
||
"unknown", "", kind, auth, *extra_vals),
|
||
)
|
||
conn.commit()
|
||
cid = cur.lastrowid
|
||
return get(cid)
|
||
|
||
|
||
_UNSET = object()
|
||
|
||
|
||
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None,
|
||
workspace_id=_UNSET, coffre_reference=_UNSET) -> dict | None:
|
||
"""Met à jour un connecteur personnel.
|
||
|
||
Règles coffre (Phase 4) :
|
||
* poser une référence exige un workspace (celui du connecteur, ou
|
||
celui passé dans le même appel) ; la référence et un jeton local
|
||
sont exclusifs dans le même appel ;
|
||
* un ``workspace_id`` déjà posé est **immuable** : on ne transfère pas
|
||
un octroi d'un workspace à l'autre en éditant le connecteur ;
|
||
* effacer la référence (``coffre_reference=""``) réactive l'éventuel
|
||
jeton local dormant, sans jamais le réécrire.
|
||
"""
|
||
row = get(connector_id)
|
||
if row is None:
|
||
return None
|
||
sets, params = [], []
|
||
if name is not None:
|
||
name = str(name).strip()
|
||
if not name:
|
||
raise ValueError("name est requis")
|
||
sets.append("name=?")
|
||
params.append(name)
|
||
if enabled is not None:
|
||
sets.append("enabled=?")
|
||
params.append(1 if enabled else 0)
|
||
effective_workspace = row.get("workspace_id")
|
||
if workspace_id is not _UNSET:
|
||
if workspace_id is None:
|
||
raise ValueError("workspace_id ne peut pas être effacé")
|
||
workspace_id = int(workspace_id)
|
||
if workspace_id <= 0 or not _workspace_exists(workspace_id):
|
||
raise ValueError(f"workspace introuvable: {workspace_id}")
|
||
if row.get("workspace_id") is not None and row["workspace_id"] != workspace_id:
|
||
raise ValueError("workspace_id immuable : un connecteur ne change "
|
||
"pas de workspace (pas de transfert d'octroi)")
|
||
effective_workspace = workspace_id
|
||
sets.append("workspace_id=?")
|
||
params.append(workspace_id)
|
||
reference_cleared = False
|
||
if coffre_reference is not _UNSET:
|
||
if coffre_reference is None or not str(coffre_reference).strip():
|
||
reference_cleared = True
|
||
sets.append("coffre_reference=?")
|
||
params.append("")
|
||
else:
|
||
parsed = coffre_module.parse_reference(str(coffre_reference))
|
||
if effective_workspace is None:
|
||
raise ValueError("coffre_reference exige un workspace_id "
|
||
"(octroi toujours lié à un workspace)")
|
||
if secret is not None and str(secret).strip():
|
||
raise ValueError("secret et coffre_reference sont exclusifs "
|
||
"(le jeton devient la référence)")
|
||
sets.append("coffre_reference=?")
|
||
params.append(f"coffre:{parsed[0]}/{parsed[1]}")
|
||
if secret is not None:
|
||
if row.get("coffre_reference") and not reference_cleared:
|
||
raise ValueError("ce connecteur pointe vers une référence ObsiGate : "
|
||
"effacez d'abord coffre_reference pour revenir "
|
||
"à un jeton local")
|
||
sets.append("secret_encrypted=?")
|
||
params.append(encrypt_secret(str(secret)))
|
||
if sets:
|
||
with get_conn() as conn:
|
||
params.append(connector_id)
|
||
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
|
||
conn.commit()
|
||
return get(connector_id)
|
||
|
||
|
||
def delete_connector(connector_id: int) -> bool:
|
||
with get_conn() as conn:
|
||
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
|
||
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
||
|
||
def resolve_secret(connector_id: int, *, workspace_id: int | None = None,
|
||
distribute=None) -> str:
|
||
"""Résout le jeton d'un connecteur **en mémoire**, jamais persisté.
|
||
|
||
* Connecteur à référence : le ``workspace_id`` demandé doit être
|
||
celui du connecteur, sinon ``CoffreWorkspaceMismatch`` explicite
|
||
**avant tout appel** à ObsiGate (le distributeur n'est pas appelé).
|
||
La valeur distribuée est renvoyée à l'appelant, qui l'utilise puis
|
||
l'oublie — aucune mise en cache ici.
|
||
* Connecteur historique : déchiffrement Fernet local (migration
|
||
opportuniste : ces connecteurs restent valides tels quels).
|
||
* Sans jeton ni référence : chaîne vide.
|
||
"""
|
||
# Lecture directe en base : le dict API de get() n'expose jamais le
|
||
# jeton (chiffré ou non), c'est ici seulement qu'il est résolu.
|
||
with get_conn() as conn:
|
||
cols = _select_cols(conn)
|
||
r = conn.execute(
|
||
f"SELECT {cols} FROM agent_connectors WHERE id=?",
|
||
(connector_id,),
|
||
).fetchone()
|
||
if r is None:
|
||
raise ValueError(f"Connecteur introuvable: {connector_id}")
|
||
try:
|
||
reference = r["coffre_reference"] or ""
|
||
except (KeyError, IndexError):
|
||
reference = ""
|
||
try:
|
||
owner = r["workspace_id"]
|
||
except (KeyError, IndexError):
|
||
owner = None
|
||
if reference:
|
||
if workspace_id is None:
|
||
raise ValueError("workspace_id est requis pour un connecteur "
|
||
"à référence ObsiGate")
|
||
if owner is None or int(owner) != int(workspace_id):
|
||
raise CoffreWorkspaceMismatch(
|
||
f"workspace {workspace_id} ne peut pas utiliser ce connecteur "
|
||
f"(il appartient au workspace {owner}) : octroi d'un autre "
|
||
"workspace refusé"
|
||
)
|
||
return coffre_module.distribuer(
|
||
reference, int(workspace_id), request=distribute)
|
||
try:
|
||
stored = r["secret_encrypted"]
|
||
except (KeyError, IndexError):
|
||
stored = ""
|
||
return decrypt_secret(stored or "")
|
||
|
||
|
||
def _headers(connector_id: int | None = None, *, workspace_id: int | None = None,
|
||
distribute=None) -> dict:
|
||
"""En-têtes d'appel : la clé n'est lue (et déchiffrée/distribuée) qu'ici.
|
||
|
||
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
|
||
Pour un connecteur à référence, ``workspace_id`` est obligatoire et
|
||
l'isolation est vérifiée par ``resolve_secret`` avant distribution.
|
||
"""
|
||
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
||
secret, auth = "", "bearer"
|
||
if connector_id:
|
||
row = get(connector_id)
|
||
if row:
|
||
auth = row["auth"] or "bearer"
|
||
secret = resolve_secret(connector_id, workspace_id=workspace_id,
|
||
distribute=distribute)
|
||
if secret and auth == "bot":
|
||
headers["Authorization"] = f"Bot {secret}"
|
||
elif secret and auth != "none":
|
||
headers["Authorization"] = f"Bearer {secret}"
|
||
headers["X-Api-Key"] = secret
|
||
return headers
|
||
|
||
|
||
def _with_secret(url: str, connector_id: int, *, workspace_id: int | None = None,
|
||
distribute=None) -> str:
|
||
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
|
||
if "{secret}" not in url:
|
||
return url
|
||
secret = resolve_secret(connector_id, workspace_id=workspace_id,
|
||
distribute=distribute)
|
||
if not secret:
|
||
raise ValueError("clé manquante pour une URL contenant {secret}")
|
||
return url.replace("{secret}", secret)
|
||
|
||
|
||
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
|
||
"""GET gardé SSRF (validation + re-vérification après redirection).
|
||
|
||
Point d'injection des tests : on monkeypatche ``connectors._get``.
|
||
"""
|
||
from app.services.http_client import shared_client
|
||
from app.services.importers.url_fetch import _is_public_host, _validate_url
|
||
|
||
safe = _validate_url(url)
|
||
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
|
||
resp = await client.get(safe)
|
||
if resp.url.host and not _is_public_host(resp.url.host):
|
||
raise ValueError("Redirection vers un hôte non autorisé")
|
||
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
|
||
|
||
|
||
def _resolve(connector: str) -> tuple[str, str | int]:
|
||
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
|
||
token = str(connector or "").strip()
|
||
if not token:
|
||
raise ValueError("connector est requis")
|
||
if token.isdigit():
|
||
row = get(int(token))
|
||
if row is None:
|
||
raise ValueError(f"Connecteur inconnu: {token}")
|
||
return (row["kind"], row["id"])
|
||
low = token.lower()
|
||
if low in NATIVE_KINDS:
|
||
return (low, low)
|
||
with get_conn() as conn:
|
||
row = conn.execute(
|
||
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
|
||
).fetchone()
|
||
if row:
|
||
return (get(row["id"])["kind"], row["id"])
|
||
raise ValueError(f"Connecteur inconnu: {token}")
|
||
|
||
|
||
async def probe(connector: str, user_id: int | None = None, *,
|
||
workspace_id: int | None = None, distribute=None) -> dict:
|
||
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).
|
||
|
||
``workspace_id`` est transmis aux chemins à référence ObsiGate ; un
|
||
connecteur à référence testé sans workspace échoue proprement
|
||
(``error``), jamais avec une valeur d'un autre workspace.
|
||
"""
|
||
kind, ref = _resolve(connector)
|
||
try:
|
||
if kind in MCP_KINDS:
|
||
from app.services import mcp_client
|
||
tools = await mcp_client.initialize_and_list(
|
||
int(ref), workspace_id=workspace_id, distribute=distribute)
|
||
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
||
elif kind in OAUTH_KINDS:
|
||
res = await oauth_connectors.api_get(kind, user_id,
|
||
oauth_connectors.PROVIDERS[kind]["probe_path"])
|
||
status, detail = res["status"], res["text"][:200]
|
||
elif kind == "gitea":
|
||
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
||
{"Authorization": f"token {settings.gitea_token}"})
|
||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||
elif kind == "github":
|
||
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
|
||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||
elif kind == "web":
|
||
status, detail = "ok", native_state("web")[1]
|
||
else:
|
||
row = get(int(ref))
|
||
url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
|
||
distribute=distribute)
|
||
code, _ = await _get(url, _headers(int(ref), workspace_id=workspace_id,
|
||
distribute=distribute))
|
||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
||
logger.info("Connector probe failed (%s): %s", connector, exc)
|
||
status, detail = "error", str(exc)[:200]
|
||
if kind not in NATIVE_KINDS:
|
||
with get_conn() as conn:
|
||
conn.execute(
|
||
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
|
||
(status, detail, int(ref)),
|
||
)
|
||
conn.commit()
|
||
return {"connector": connector, "status": status, "detail": detail}
|
||
|
||
|
||
def _gh_headers() -> dict:
|
||
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
|
||
"Accept": "application/vnd.github+json"}
|
||
if settings.github_token:
|
||
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||
return headers
|
||
|
||
|
||
async def connector_fetch(connector: str, path: str = "", query: str = "",
|
||
user_id: int | None = None, *,
|
||
workspace_id: int | None = None,
|
||
distribute=None) -> dict:
|
||
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||
kind, ref = _resolve(connector)
|
||
path = (path or "").strip()
|
||
if kind in OAUTH_KINDS:
|
||
return await oauth_connectors.api_get(kind, user_id, path)
|
||
if kind in MCP_KINDS:
|
||
from app.services import mcp_client
|
||
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
||
if kind == "gitea":
|
||
base = settings.gitea_url.rstrip("/")
|
||
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
||
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
|
||
elif kind == "github":
|
||
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
|
||
code, text = await _get(url, _gh_headers())
|
||
elif kind == "web":
|
||
from app.services.web_search import search_web
|
||
if not (query or "").strip():
|
||
return {"status": "error", "text": "query est requis pour le connecteur web"}
|
||
results, provider = await search_web(query.strip(), 5)
|
||
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
|
||
for r in results) or "Aucun résultat."
|
||
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
|
||
else:
|
||
row = get(int(ref))
|
||
if row is None:
|
||
return {"status": "error", "text": "Connecteur supprimé"}
|
||
if not row["enabled"]:
|
||
return {"status": "error", "text": "Connecteur désactivé"}
|
||
url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
|
||
distribute=distribute).rstrip("/")
|
||
if path:
|
||
url += "/" + path.lstrip("/")
|
||
code, text = await _get(url, _headers(int(ref), workspace_id=workspace_id,
|
||
distribute=distribute))
|
||
if code >= 400:
|
||
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|