From f8df0e13b5029551bb1579ebd0fdf6240d72f524 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 7 Sep 2026 23:12:51 -0400 Subject: [PATCH] feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0) Moteur d'automatisation complet : declencheurs (evenement / cron / bouton), conditions combinables (eq, neq, contains, is_empty, is_not_empty, changed) et actions (webhook, set_property, create_page, notify). - Migration v5 : tables `automations` + `automation_runs` (avec index). - Service `app/services/automations.py` : fire_event, cron_due (`*/N`, minute fixe, @hourly/@daily), scheduler de fond dans le lifespan. - Router `app/routers/automations.py` : CRUD `/workspace/automations`, historique des executions, run manuel + run bouton `/api/automations/{id}/run` (exempt CSRF, comme `/api/agent`). - Hooks d'evenements dans collections.py / board.py / workspace.py (collection.* et page.* : created/updated/deleted/moved). - Bloc `button` dans l'editeur (menu slash) : declenche une regle au clic, picker d'automation inline, serialisation automations_id/name. - Panneau Automations dans le Settings (creer/editer/activer/desactiver/ lancer/supprimer + historique), collection scope + JSON conditions/actions. - 11 tests `tests/test_automations.py` ; suite complete pytest 289 verte. - Version bump 5.2.0 (VERSION, app/main.py, CHANGELOG). --- CHANGELOG.md | 22 +- VERSION | 2 +- app/main.py | 15 +- app/middleware/csrf.py | 2 +- app/migrations.py | 52 +++ app/routers/automations.py | 174 +++++++++++ app/routers/board.py | 12 +- app/routers/collections.py | 34 +- app/routers/workspace.py | 7 + app/services/automations.py | 400 ++++++++++++++++++++++++ app/templates/_page_editor_scripts.html | 71 ++++- app/templates/settings.html | 218 ++++++++++++- tests/test_automations.py | 351 +++++++++++++++++++++ 13 files changed, 1345 insertions(+), 15 deletions(-) create mode 100644 app/routers/automations.py create mode 100644 app/services/automations.py create mode 100644 tests/test_automations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93258c4..d66f59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,24 @@ -# Changelog — FlowDeck +# Changelog - FlowDeck + +## v5.2.0 (2026-09-07) - Automations : un moteur de règles (if-this-then-that) + +> Nouveau moteur de règles : brancher des actions sur des événements de pages +> (création, mise à jour, suppression, déplacement), sur un planning cron ou +> sur un bouton cliquable directement dans l'éditeur. + +- **Moteur de règles** : trigger (événement / cron / bouton), conditions + combinables (eq, neq, contains, is_empty, is_not_empty, changed) et actions + (webhook, changer une propriété, créer une page, notification). +- **Événements couverts** : collection créée/modifiée/supprimée, page + créée/modifiée/supprimée/déplacée (API et Canvas). +- **Déclencheur cron** : `*/N * * * *`, minute fixe, `@hourly`, `@daily`, avec + un planificateur en arrière-plan qui exécute les règles dues chaque minute. +- **Bouton d'automation** : nouveau bloc de type `button` dans l'éditeur + (menu slash) qui déclenche une règle au clic. +- **Interface** : panneau Automations dans les paramètres (créer, modifier, + activer/désactiver, lancer, supprimer, historique des exécutions). +- **Historique des exécutions** : chaque déclenchement est tracé (statut, + détail, page/collection concernée). ## v5.1.11 (2026-09-07) — Éditeur : collage intelligent (découpe en blocs) diff --git a/VERSION b/VERSION index 831c6cd..7cbea07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.11 \ No newline at end of file +5.2.0 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 0b42f5c..352cb05 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search from app.routers.notifications import router as notifications_router +from app.routers.automations import router as automations_router from app.routers.collaboration import router as collaboration_router from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router @@ -47,20 +48,29 @@ async def lifespan(_app: FastAPI): from app.routers.agent import agent_scheduler scheduler_task = asyncio.create_task(agent_scheduler()) - logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port) + # ── Automations (v5.1.0): cron trigger scheduler ── + from app.services.automations import automation_scheduler + automation_task = asyncio.create_task(automation_scheduler()) + + logger.info("FlowDeck v5.2.0 started on port %d", settings.app_port) try: yield finally: scheduler_task.cancel() + automation_task.cancel() try: await scheduler_task except asyncio.CancelledError: pass + try: + await automation_task + except asyncio.CancelledError: + pass app = FastAPI( title="FlowDeck", - version="5.1.11", + version="5.2.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -90,6 +100,7 @@ app.include_router(sharing.router) app.include_router(sidebar_config.router) app.include_router(export.router) app.include_router(notifications_router) +app.include_router(automations_router) app.include_router(collaboration_router) app.include_router(agent.router) app.include_router(search.router) diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 78910ca..4ce46a6 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/migrations.py b/app/migrations.py index 956c6e7..808d152 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -172,6 +172,58 @@ def _migration_fts5(conn: sqlite3.Connection) -> None: ) +@register(5, "automations (v5.1.0 rules engine)") +def _migration_automations(conn: sqlite3.Connection) -> None: + """v5.1.0: database automations — if-this-then-that rule engine (trigger + + condition + action) and clickable buttons that trigger actions. + + ``automations`` — the rules (event/cron/button trigger, optional + condition JSON, actions JSON, run counters). + ``automation_runs`` — execution history for auditing and the Settings UI. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS automations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button + event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event' + cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron' + collection_id INTEGER, -- optional scope (event triggers) + condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses + actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors + enabled BOOLEAN NOT NULL DEFAULT 1, + created_by INTEGER, + last_run_at TIMESTAMP, + run_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS automation_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, + trigger_source TEXT NOT NULL DEFAULT 'event', + status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error + detail TEXT NOT NULL DEFAULT '', + collection_id INTEGER, + page_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)" + ) + + @register(4, "database templates (icon) + property validation") def _migration_db_templates_validation(conn: sqlite3.Connection) -> None: """v5.3.0: database templates get an icon, properties a validation config, diff --git a/app/routers/automations.py b/app/routers/automations.py new file mode 100644 index 0000000..dbb9be3 --- /dev/null +++ b/app/routers/automations.py @@ -0,0 +1,174 @@ +"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history.""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Request, HTTPException + +from app.db import get_conn +from app.auth.session import SessionManager +from app.services.automations import run_automation, get_page_context + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["automations"]) + +TRIGGER_TYPES = ("event", "cron", "button") + + +def _json_or_dumps(val, default="[]"): + """Store JSON string columns without double-encoding.""" + if val is None: + return default + if isinstance(val, str): + try: + json.loads(val) + return val + except (TypeError, json.JSONDecodeError): + return json.dumps(val) + return json.dumps(val) + + +def _current_user(request: Request) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + return user if user and user.get("id") else {} + + +def _validate_payload(body: dict) -> None: + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name required") + trigger_type = body.get("trigger_type", "event") + if trigger_type not in TRIGGER_TYPES: + raise HTTPException(status_code=400, detail="invalid trigger_type") + if trigger_type == "event" and not body.get("event"): + raise HTTPException(status_code=400, detail="event required for event trigger") + if trigger_type == "cron" and not (body.get("cron_expression") or "").strip(): + raise HTTPException(status_code=400, detail="cron_expression required for cron trigger") + for key in ("condition_json", "actions_json"): + val = body.get(key, "[]") + try: + if isinstance(val, str): + json.loads(val) + else: + json.dumps(val) + except (TypeError, json.JSONDecodeError): + raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") + + +@router.get("/workspace/automations") +async def list_automations(request: Request): + with get_conn() as conn: + rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall() + items = [dict(r) for r in rows] + return {"automations": items} + + +@router.post("/workspace/automations") +async def create_automation(request: Request): + body = await request.json() if request.headers.get("content-type") else {} + _validate_payload(body) + user = _current_user(request) + by = user.get("id") or 1 + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO automations + (workspace, name, trigger_type, event, cron_expression, collection_id, + condition_json, actions_json, enabled, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + body.get("workspace", "") or "", + (body.get("name") or "").strip(), + body.get("trigger_type", "event"), + body.get("event", "page.created"), + body.get("cron_expression", "") or "", + body.get("collection_id") or None, + _json_or_dumps(body.get("condition", body.get("condition_json", []))), + _json_or_dumps(body.get("actions", body.get("actions_json", []))), + int(body.get("enabled", True)), + by, + ), + ) + conn.commit() + new_id = cur.lastrowid + return {"id": new_id, "status": "created"} + + +@router.get("/workspace/automations/{auto_id}") +async def get_automation(request: Request, auto_id: int): + with get_conn() as conn: + row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Automation not found") + return dict(row) + + +@router.put("/workspace/automations/{auto_id}") +async def update_automation(request: Request, auto_id: int): + body = await request.json() if request.headers.get("content-type") else {} + _validate_payload(body) + with get_conn() as conn: + row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Automation not found") + conn.execute( + """UPDATE automations SET + name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?, + condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + ( + (body.get("name") or "").strip(), + body.get("trigger_type", "event"), + body.get("event", "page.created"), + body.get("cron_expression", "") or "", + body.get("collection_id") or None, + _json_or_dumps(body.get("condition", body.get("condition_json", []))), + _json_or_dumps(body.get("actions", body.get("actions_json", []))), + int(body.get("enabled", True)), + auto_id, + ), + ) + conn.commit() + return {"id": auto_id, "status": "updated"} + + +@router.delete("/workspace/automations/{auto_id}") +async def delete_automation(request: Request, auto_id: int): + with get_conn() as conn: + conn.execute("DELETE FROM automations WHERE id=?", (auto_id,)) + conn.commit() + return {"id": auto_id, "status": "deleted"} + + +async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict: + page_id = body.get("page_id") if isinstance(body, dict) else None + collection_id = body.get("collection_id") if isinstance(body, dict) else None + context = {"collection_id": collection_id, "page_id": page_id} + if page_id: + context.update(get_page_context(int(page_id), collection_id or 0)) + result = await run_automation(automation_id, trigger_source, context) + result["automation_id"] = automation_id + return result + + +@router.post("/workspace/automations/{auto_id}/run") +async def run_automation_endpoint(request: Request, auto_id: int): + body = await request.json() if request.headers.get("content-type") else {} + return await _execute(auto_id, "manual", body) + + +@router.post("/api/automations/{auto_id}/run") +async def run_automation_button(request: Request, auto_id: int): + body = await request.json() if request.headers.get("content-type") else {} + return await _execute(auto_id, "button", body) + + +@router.get("/workspace/automations/{auto_id}/runs") +async def automation_runs_history(request: Request, auto_id: int, limit: int = 50): + with get_conn() as conn: + rows = conn.execute( + """SELECT * FROM automation_runs WHERE automation_id=? + ORDER BY created_at DESC, id DESC LIMIT ?""", + (auto_id, limit), + ).fetchall() + return {"runs": [dict(r) for r in rows]} \ No newline at end of file diff --git a/app/routers/board.py b/app/routers/board.py index 0815e8c..1c11313 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -10,8 +10,9 @@ from fastapi.responses import HTMLResponse, JSONResponse from app.db import get_conn from app.services.gitea_client import gitea from app.auth.session import SessionManager -from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.dashboard import _get_app_version +from app.routers.sidebar_config import get_sidebar_config_sync +from app.services.automations import fire_event logger = logging.getLogger(__name__) router = APIRouter(tags=["board"], prefix="/board") @@ -836,6 +837,8 @@ async def create_page(request: Request, title: str = Query(default=""), ) conn.commit() page_id = cur.lastrowid + await fire_event("page.created", {"page_id": page_id, "title": page_title, + "workspace": ws_key, "parent_id": parent_id}) return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} except Exception as e: logger.error("create_page failed: %s", e) @@ -866,6 +869,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default if content_format: conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.commit() + await fire_event("page.updated", {"page_id": page_id, "title": title, + "content_format": content_format or "markdown"}) return {"status": "ok"} @@ -886,6 +891,8 @@ async def save_page_blocks(request: Request, page_id: int): (blocks_json, page_id), ) conn.commit() + await fire_event("page.updated", {"page_id": page_id, "title": title or "", + "content_format": "blocks"}) return {"status": "ok", "id": page_id} @@ -932,6 +939,8 @@ async def move_page(request: Request, page_id: int): ) conn.commit() + await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, + "parent_id": new_parent_id}) return {"status": "ok", "id": page_id} @@ -945,6 +954,7 @@ async def delete_page(request: Request, page_id: int): import datetime conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,)) conn.commit() + await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}) return {"status": "ok", "deleted": page_id, "title": row["title"]} diff --git a/app/routers/collections.py b/app/routers/collections.py index b659ba4..26c9bd5 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -12,6 +12,7 @@ from app.db import get_conn from app.auth.session import SessionManager from app.services.property_types import validate_property_rule from app.services.db_templates import materialize_properties +from app.services.automations import fire_event logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") @@ -162,6 +163,7 @@ async def create_collection_api(request: Request): ) conn.commit() + await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}) return {"id": collection_id, "name": name, "status": "created"} @@ -200,6 +202,7 @@ async def update_collection_api(request: Request, collection_id: int): ) conn.commit() + await fire_event("collection.updated", {"collection_id": collection_id, "name": name}) return {"id": collection_id, "status": "updated"} @@ -216,6 +219,8 @@ async def delete_collection_api(request: Request, collection_id: int): conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) conn.commit() + await fire_event("collection.deleted", {"collection_id": collection_id, + "name": existing["name"] if existing else ""}) return {"id": collection_id, "status": "deleted"} @@ -276,6 +281,13 @@ async def update_page_api(request: Request, page_id: int): ) conn.commit() + await fire_event("page.updated", { + "page_id": page_id, + "collection_id": existing["collection_id"], + "title": title, + "icon": icon, + "properties": props, + }) return {"id": page_id, "status": "updated"} @@ -292,6 +304,11 @@ async def delete_page_api(request: Request, page_id: int): conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() + await fire_event("page.deleted", { + "page_id": page_id, + "collection_id": existing["collection_id"], + "title": existing["title"], + }) return {"id": page_id, "status": "deleted"} @@ -754,8 +771,16 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int): (collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))), ) conn.commit() + new_id = cur.lastrowid - return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"} + await fire_event("page.created", { + "page_id": new_id, + "collection_id": collection_id, + "parent_id": page_id, + "title": title, + "properties": body.get("properties", {}), + }) + return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"} @router.get("/{collection_id}/pages/{page_id}/status-aggregate") @@ -1897,4 +1922,11 @@ async def create_page_api(request: Request, collection_id: int): conn.commit() page_id = cur.lastrowid + await fire_event("page.created", { + "page_id": page_id, + "collection_id": collection_id, + "title": title, + "icon": icon, + "properties": property_values, + }) return {"id": page_id, "title": title, "status": "created"} diff --git a/app/routers/workspace.py b/app/routers/workspace.py index 1d6cb01..a9de9f4 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -12,6 +12,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse from app.db import get_conn from app.auth.session import SessionManager +from app.services.automations import fire_event logger = logging.getLogger(__name__) router = APIRouter(tags=["workspace"], prefix="/workspace") @@ -289,6 +290,12 @@ async def apply_page_template(request: Request, collection_id: int, tid: int): (collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]), ) conn.commit() + await fire_event("page.created", { + "page_id": cur.lastrowid, + "collection_id": collection_id, + "title": body.get("title", "New Page"), + "properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {}, + }) return {"id": cur.lastrowid, "status": "created"} diff --git a/app/services/automations.py b/app/services/automations.py new file mode 100644 index 0000000..400fbac --- /dev/null +++ b/app/services/automations.py @@ -0,0 +1,400 @@ +"""FlowDeck — Automations engine (v5.1.0). + +Implements the "if-this-then-that" rule engine: automations match an event (or a +cron schedule, or a clickable button), optionally guard on a condition, then run +a list of actions. + +Condition clauses (``condition_json``), all combined with AND: + {"property": "Status", "op": "eq", "value": "Done"} + {"property": "Priority", "op": "not_contains", "value": "Low"} + {"property": "Assignee", "op": "is_empty"} + {"property": "Estimate", "op": "changed"} (only event triggers) +Flags: + op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed} + +Actions (``actions_json``), executed sequentially: + {"type": "webhook", "url": "...", "secret": "..."} + {"type": "set_property", "property": "Status", "value": "Done"} + {"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}} + {"type": "notify", "message": "Automation fired"} +""" +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timedelta + +import httpx + +from app.db import get_conn +from app.services import notifications + +logger = logging.getLogger(__name__) + +COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"} +# Events that fire on collection pages (payload carries a `properties` dict). +PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"} + + +def _prop_value(props: dict, key) -> tuple[bool, object]: + """Resolve a property value by id or name. Returns ``(found, value)``. + + ``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API). + """ + if props is None: + return False, None + if key is None: + return True, None + skey = str(key) + if skey in props: + return True, props[skey] + if isinstance(key, int) and str(key) in props: + return True, props[str(key)] + return False, None + + +def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool: + """Evaluate a single condition clause against page property values.""" + op = clause.get("op", "eq") + if op not in COND_OPS: + return False + if op == "changed": + key = clause.get("property") + if before_props is None: + return False + found_before, before_val = _prop_value(before_props, key) + found_after, after_val = _prop_value(props, key) + return found_before and found_after and before_val != after_val + + found, val = _prop_value(props, clause.get("property")) + + if op == "is_empty": + if not found: + return True + return val is None or str(val).strip() == "" + if op == "is_not_empty": + return found and val is not None and str(val).strip() != "" + + if not found: + return False + want = clause.get("value") + if op == "eq": + return _norm(val) == _norm(want) + if op == "neq": + return _norm(val) != _norm(want) + if op == "contains": + return _norm(want) in _norm(val) if _norm(val) else False + if op == "not_contains": + return _norm(want) not in _norm(val) if _norm(val) else True + return False + + +def _norm(v) -> str: + if v is None: + return "" + if isinstance(v, (list, dict)): + return json.dumps(v) + return str(v) + + +def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool: + """Evaluate the stored condition list (AND of all clauses). Empty list → True.""" + try: + clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or []) + except (TypeError, json.JSONDecodeError): + clauses = [] + for clause in clauses or []: + if not match_condition_props(props, before_props, clause): + return False + return True + + +def get_page_context(page_id: int, collection_id: int) -> dict: + """Load a collection page's property values for condition evaluation.""" + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?", + (page_id,), + ).fetchone() + if not row: + return {"page_id": page_id, "collection_id": collection_id, + "title": "", "properties": {}, "icon": "file"} + try: + props = json.loads(row["property_values_json"]) + except (TypeError, json.JSONDecodeError): + props = {} + return {"page_id": page_id, "collection_id": collection_id, + "title": row["title"], "icon": row["icon"], "properties": props} + + +def _save_run(automation_id: int, trigger_source: str, status: str, detail: str, + collection_id: int | None = None, page_id: int | None = None) -> None: + with get_conn() as conn: + conn.execute( + """INSERT INTO automation_runs + (automation_id, trigger_source, status, detail, collection_id, page_id) + VALUES (?,?,?,?,?,?)""", + (automation_id, trigger_source, status, detail, collection_id, page_id), + ) + conn.execute( + "UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?", + (automation_id,), + ) + conn.commit() + + +def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]: + """Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}.""" + if not isinstance(value, str): + return True, value + replaced = value + for key in props: + if f"[[" + str(key) + "]]" in replaced: + replaced = replaced.replace(f"[[" + str(key) + "]]", str(props[key])) + if "{{title}}" in replaced: + replaced = replaced.replace("{{title}}", str(props.get("title", ""))) + if "{{id}}" in replaced: + replaced = replaced.replace("{{id}}", str(props.get("page_id", ""))) + return True, replaced + + +async def _run_action(action: dict, context: dict, trigger_source: str) -> str: + """Execute a single action. Returns a human summary. Raises on failure.""" + atype = action.get("type") + + if atype == "webhook": + url = action.get("url", "").strip() + if not url: + raise ValueError("webhook action requires a url") + secret = action.get("secret", "") + headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")} + if secret: + headers["X-FlowDeck-Secret"] = secret + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json=context, headers=headers) + if resp.status_code >= 400: + raise RuntimeError(f"webhook returned HTTP {resp.status_code}") + return f"webhook → {url} ({resp.status_code})" + + if atype == "set_property": + prop = action.get("property") + value = action.get("value") + page_id = context.get("page_id") + if not prop or not page_id: + raise ValueError("set_property requires property + page context") + _, resolved = _maybe_convert_prediction(value, context) + with get_conn() as conn: + row = conn.execute( + "SELECT property_values_json, collection_id FROM collection_pages WHERE id=?", + (page_id,), + ).fetchone() + if not row: + raise ValueError(f"page {page_id} not found") + try: + props = json.loads(row["property_values_json"]) + except (TypeError, json.JSONDecodeError): + props = {} + props[prop] = resolved + from app.routers.collections import _validate_page_properties + _validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id) + conn.execute( + "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (json.dumps(props), page_id), + ) + conn.commit() + return f"set property {prop} = {resolved}" + + if atype == "create_page": + coll_id = action.get("collection_id") + title = action.get("title", "Automation page") + properties = action.get("properties", {}) or {} + if not coll_id: + raise ValueError("create_page requires a collection_id") + _, resolved_title = _maybe_convert_prediction(title, context) + resolved_props = {} + for k, v in properties.items(): + _, pv = _maybe_convert_prediction(v, context) + resolved_props[k] = pv + with get_conn() as conn: + max_pos = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", + (coll_id,), + ).fetchone()[0] + cur = conn.execute( + "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", + (coll_id, resolved_title, max_pos, json.dumps(resolved_props)), + ) + conn.commit() + return f"created page {cur.lastrowid} in collection {coll_id}" + + if atype == "notify": + message = action.get("message", "Automation fired") + user_id = action.get("user_id") + if not user_id: + user_id = context.get("created_by") or 1 + _, resolved = _maybe_convert_prediction(message, context) + notifications.create_notification( + user_id=user_id, + actor_id=context.get("created_by") or 1, + ntype="page", + title=context.get("automation_name", "Automation"), + message=resolved, + resource_type="collection_page" if context.get("page_id") else "page", + resource_id=context.get("page_id") or context.get("collection_id") or 0, + url=context.get("url", ""), + ) + return f"notified user {user_id}" + + raise ValueError(f"unknown action type: {atype!r}") + + +async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict: + """Load, condition-check and execute an automation. Records a run row.""" + with get_conn() as conn: + row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone() + if not row: + return {"status": "skipped", "detail": "automation not found"} + auto = dict(row) + + if not auto["enabled"]: + return {"status": "skipped", "detail": "automation disabled"} + + props = context.get("properties") + before = context.get("before_properties") + if not evaluate_conditions(auto["condition_json"], props, before): + _save_run(automation_id, trigger_source, "skipped", "condition not met", + context.get("collection_id"), context.get("page_id")) + return {"status": "skipped", "detail": "condition not met"} + + try: + actions = json.loads(auto["actions_json"]) if auto["actions_json"] else [] + except (TypeError, json.JSONDecodeError): + actions = [] + + ctx = dict(context) + ctx["automation_name"] = auto["name"] + ctx["created_by"] = auto["created_by"] or ctx.get("created_by") + + results = [] + try: + for action in actions or []: + results.append(await _run_action(action, ctx, trigger_source)) + detail = "; ".join(results) + _save_run(automation_id, trigger_source, "fired", detail, + ctx.get("collection_id"), ctx.get("page_id")) + return {"status": "fired", "detail": detail} + except Exception as exc: # noqa: BLE001 — record every failure in history + logger.warning("Automation %s failed: %s", automation_id, exc) + _save_run(automation_id, trigger_source, "error", str(exc), + ctx.get("collection_id"), ctx.get("page_id")) + return {"status": "error", "detail": str(exc)} + + +async def fire_event(event: str, payload: dict): + """Dispatch an event to outbound webhooks and matching automations.""" + # Outbound webhooks (v2.1.0 machinery, previously called nowhere). + try: + from app.services.webhook_outbound import fire_event as fire_webhooks + await fire_webhooks(event, payload) + except Exception: # noqa: BLE001 + logger.debug("Webhook dispatch failed for %s", event) + + with get_conn() as conn: + rows = conn.execute( + """SELECT * FROM automations + WHERE trigger_type='event' AND event=? AND enabled=1""", + (event,), + ).fetchall() + for row in rows: + auto = dict(row) + if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]: + continue + context = dict(payload) + context["event"] = event + await run_automation(auto["id"], "event", context) + + +# ═══════════ Cron scheduling (trigger_type='cron') ═══════════ + +_SUPPORTED_CRON = { + "*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30, + "*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45, +} + + +def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool: + """True when a ``*/N`-style or fixed-minute cron expression is due. + + Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact + ``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work. + """ + expr = (expression or "").strip().lower() + if not expr: + return False + now = now or datetime.utcnow() + minute = now.minute + fields = expr.split() + + if expr in ("@hourly", "hourly"): + if last_run_at is None: + return True + try: + last = datetime.fromisoformat(str(last_run_at).replace("Z", "")) + except Exception: + return True + return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60) + + if expr in ("@daily", "daily"): + if last_run_at is None: + return True + try: + last = datetime.fromisoformat(str(last_run_at).replace("Z", "")) + except Exception: + return True + return (now - last.replace(tzinfo=None)) >= timedelta(hours=24) + + # "*/N * * * *" → every N minutes + if fields and fields[0].startswith("*/"): + val = fields[0][2:] + if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values(): + return False + n = int(val) + if last_run_at is None: + return True + try: + last = datetime.fromisoformat(str(last_run_at).replace("Z", "")) + except Exception: + return True + return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n) + + # "H * * * *" → at a fixed minute of each hour + if len(fields) == 5 and fields[0].isdigit(): + return int(fields[0]) == minute + + return False + + +async def automation_scheduler(): + """Background loop: fire due cron automations (checked every 60s).""" + while True: + try: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1" + ).fetchall() + for row in rows: + auto = dict(row) + try: + if cron_due(auto["cron_expression"], auto["last_run_at"]): + context = { + "collection_id": auto["collection_id"] or 0, + "page_id": None, + "properties": None, + } + await run_automation(auto["id"], "cron", context) + except Exception: # noqa: BLE001 + logger.warning("Cron automation %s errored", auto["id"]) + except Exception: # noqa: BLE001 + logger.warning("automation_scheduler iteration failed") + await asyncio.sleep(60) \ No newline at end of file diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 54f8fb2..3697a0c 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -9,7 +9,7 @@ {id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'}, {id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'}, {id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, - {id:'table',name:'Table',icon:'⊞'}, + {id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'}, ]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]}, {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}]; @@ -358,6 +358,7 @@ if(b.type==='meeting')return renderMeetingBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx); if(b.type==='divider')return `

`; + if(b.type==='button')return renderAutomationButton(b,idx); if(b.type==='image')return `
${esc(b.alt||'')}
`; if(b.type==='table_of_contents')return renderTOC(); if(b.type==='math')return `
${renderMathBlock(b)}
`; @@ -414,6 +415,18 @@ return html; } + function renderAutomationButton(b,idx){ + var label=(b.content&&b.content.trim())?b.content:((b.automation_name||'Run automation')); + var bound=b.automation_id>0; + var badge=bound?'⚡ '+esc(b.automation_name||'')+'':'Choose…'; + var actions=idx>=0?`
`:''; + return `
+
+ ${actions} +
+
`; + } + function renderColumnsBlock(b){ var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}]; var cells=cols.map(function(c){ @@ -742,14 +755,14 @@ } this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render(); }, - mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];return b;}, + mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;}, getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;}, getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);}, getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};}, focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}}, - onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}}, + onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}}, sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;} - if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue; + if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue; if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;} const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||''; if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';}); @@ -772,7 +785,7 @@ if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='Invalid LaTeX';}} else{el.innerHTML=''+esc(tex)+'';} }, - replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, + replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;}, addAfter(idx,type){return this.addAt(idx+1,type);}, removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, @@ -1164,7 +1177,7 @@ applyAIBlocks(text){ if(id==='database'){ this.openDbTemplatePicker(idx); return; } const block=this.blocks[idx];this.sync();block.content=''; if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');} - else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}} + else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}} this.render();this.dirty=true;this.autoSave(); if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);} else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);} @@ -1379,12 +1392,56 @@ applyAIBlocks(text){ const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||''; this.saving=true; const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';}); if(cb)cb(); }).catch(()=>{this.saving=false;}); }, + runAutomationButton(bid){ + const idx=this.getIdx(bid);if(idx<0)return; + const b=this.blocks[idx]; + if(!b.automation_id){this.pickAutomation(bid);return;} + const csrf=document.cookie.match(/csrf_token=([^;]+)/); + const self=this; + this.showToast('⚡ '+ (b.automation_name||'Automation') + '…'); + fetch('/api/automations/'+b.automation_id+'/run',{method:'POST', + headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''}, + body:JSON.stringify({page_id:this.pid})}) + .then(r=>r.json()) + .then(d=>{ + if(d.status==='fired'){self.showToast('✅ Automation exécutée');} + else if(d.status==='skipped'){self.showToast('⏭️ Condition non remplie (skippée)');} + else{self.showToast((d.detail||'Erreur')+'','error');} + }) + .catch(()=>{self.showToast('Échec de l\u2019exécution','error');}); + }, + pickAutomation(bid){ + const self=this; + fetch('/workspace/automations',{credentials:'same-origin'}) + .then(r=>r.json()) + .then(d=>{ + const list=d.automations||[]; + if(!list.length){self.showToast('Aucune automation — créez-en dans Paramètres → Automations','error');return;} + const ov=document.createElement('div');ov.id='fdAutomationOverlay'; + ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;'; + const box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:20px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;'; + box.innerHTML='
Choisir une automation
'+ + list.filter(a=>a.enabled).map(function(a){ + return ``; + }).join('')+ + ''; + ov.appendChild(box); + document.body.appendChild(ov); + box.addEventListener('click',function(ev){ + const btn=ev.target.closest('[data-id]');if(btn){ + const idx=self.getIdx(bid);if(idx>=0){self.blocks[idx].automation_id=parseInt(btn.getAttribute('data-id'),10);const a=list.find(function(x){return x.id===parseInt(btn.getAttribute('data-id'),10);});self.blocks[idx].automation_name=a?a.name:'';self.dirty=true;self.autoSave();self.render();} + ov.remove(); + }else if(ev.target.closest('[data-cancel]')){ov.remove();} + }); + }) + .catch(()=>{self.showToast('Erreur de chargement','error');}); + }, md2b(md){const bl=[];const lines=String(md).split('\n');let i=0;for(;iw)w=r.length;});if(w<1)w=1;const rows2=rows.map(function(r){const a=r.slice();while(a.length{{ fd_icon("tag",14) }} Tags
Features
{{ fd_icon("link",14) }} Integrations
+
{{ fd_icon("zap",14) }} Automations
{{ fd_icon("bot",14) }} Agent & IA