feat(capture): capture éclair — API à jeton dédié + écoute ntfy (issue #12)
POST /api/capture (X-Capture-Token ou Bearer; 503 si non configuré; exempté Basic Auth/CSRF car machine-à-machine), JSON ou texte libre (1re ligne = titre), dépôt par défaut KANBANTE_CAPTURE_REPO. Module app/capture.py: parsing partagé + abonné ntfy en tâche de fond (reconnexion, curseur kv_store anti-rejeu, tag 'kanban'... 'kanbante' ignoré). _do_capture commun: issue créée, carte placée, événement 'created' source 'capture'.
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
|||||||
|
"""Quick capture (roadmap #12): turn an external message into an issue.
|
||||||
|
|
||||||
|
Two entry points share the same parsing:
|
||||||
|
- POST /api/capture (token-authenticated, in app.main) — curl, a
|
||||||
|
shortcut, any automation;
|
||||||
|
- an ntfy *listener*: messages published to a dedicated topic become
|
||||||
|
issues in the default capture repo. Capture from a phone is then
|
||||||
|
just "share to the ntfy app".
|
||||||
|
|
||||||
|
The listener is a background task with reconnect + dedupe: the last
|
||||||
|
processed ntfy message id is kept (by the caller, in kv_store) and
|
||||||
|
passed back as `since`, so a restart never re-ingests old messages.
|
||||||
|
Messages tagged "kanbante" are skipped — Kanbante never ingests its
|
||||||
|
own posts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kanbante.capture")
|
||||||
|
|
||||||
|
MAX_TITLE_CHARS = 255
|
||||||
|
|
||||||
|
|
||||||
|
def parse_capture_text(text: str) -> tuple[str, str]:
|
||||||
|
"""First non-empty line = title, the rest = body."""
|
||||||
|
lines = (text or "").strip().splitlines()
|
||||||
|
lines = [line.rstrip() for line in lines]
|
||||||
|
while lines and not lines[0].strip():
|
||||||
|
lines.pop(0)
|
||||||
|
if not lines:
|
||||||
|
return "", ""
|
||||||
|
title = lines[0].strip()[:MAX_TITLE_CHARS]
|
||||||
|
body = "\n".join(lines[1:]).strip()
|
||||||
|
return title, body
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ntfy_message(msg: dict) -> tuple[str, str] | None:
|
||||||
|
"""Extract (title, body) from an ntfy message, or None to skip it."""
|
||||||
|
if msg.get("event") not in (None, "message"):
|
||||||
|
return None
|
||||||
|
if "kanbante" in (msg.get("tags") or []):
|
||||||
|
return None
|
||||||
|
title = str(msg.get("title") or "").strip()
|
||||||
|
message = str(msg.get("message") or "").strip()
|
||||||
|
if title and message:
|
||||||
|
return title[:MAX_TITLE_CHARS], message
|
||||||
|
if message:
|
||||||
|
return parse_capture_text(message)
|
||||||
|
if title:
|
||||||
|
return title[:MAX_TITLE_CHARS], ""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ntfy_capture_loop(create_issue, get_last_id, set_last_id) -> None:
|
||||||
|
"""Subscribe to the capture topic forever (reconnect with backoff).
|
||||||
|
|
||||||
|
create_issue(title, body) is an async callback; get_last_id /
|
||||||
|
set_last_id persist the ntfy cursor. Returns quietly when the
|
||||||
|
listener is no longer configured.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
while True:
|
||||||
|
settings = get_settings()
|
||||||
|
base = settings.ntfy_url.rstrip("/")
|
||||||
|
topic = settings.capture_ntfy_topic
|
||||||
|
if not base or not topic:
|
||||||
|
return
|
||||||
|
since = get_last_id() or "latest"
|
||||||
|
headers = {}
|
||||||
|
if settings.ntfy_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"GET", f"{base}/{topic}/json?since={since}", headers=headers
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(
|
||||||
|
"ntfy capture listening on %s/%s (since=%s)",
|
||||||
|
base, topic, since,
|
||||||
|
)
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
msg_id = str(msg.get("id") or "")
|
||||||
|
if msg_id:
|
||||||
|
if msg_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(msg_id)
|
||||||
|
if len(seen) > 500:
|
||||||
|
seen = set(list(seen)[-250:])
|
||||||
|
set_last_id(msg_id)
|
||||||
|
parsed = parse_ntfy_message(msg)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
title, body = parsed
|
||||||
|
try:
|
||||||
|
await create_issue(title, body)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("capture failed for %r", title)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"ntfy capture stream interrupted (%s); retrying in 10s", exc
|
||||||
|
)
|
||||||
|
await asyncio.sleep(10)
|
||||||
+22
-1
@@ -36,6 +36,21 @@ Environment variables:
|
|||||||
the daily briefing is sent to ntfy.
|
the daily briefing is sent to ntfy.
|
||||||
Empty (default) = no automatic send,
|
Empty (default) = no automatic send,
|
||||||
the /briefing page stays available.
|
the /briefing page stays available.
|
||||||
|
KANBANTE_CAPTURE_REPO Default repo for quick capture
|
||||||
|
(POST /api/capture, the /capture page
|
||||||
|
and the ntfy capture listener), e.g.
|
||||||
|
"Projets/kanbante". Empty = captures
|
||||||
|
must name their repo explicitly.
|
||||||
|
KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture
|
||||||
|
(header X-Capture-Token or Bearer).
|
||||||
|
Empty (default) = the endpoint is
|
||||||
|
disabled (503). Distinct from the
|
||||||
|
Gitea token; revocable on its own.
|
||||||
|
KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures:
|
||||||
|
each message published there becomes
|
||||||
|
an issue in KANBANTE_CAPTURE_REPO.
|
||||||
|
Uses KANBANTE_NTFY_URL/_TOKEN as the
|
||||||
|
server. Empty (default) = no listener.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -44,7 +59,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.6.0"
|
APP_VERSION = "0.7.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -100,6 +115,9 @@ class Settings:
|
|||||||
ntfy_topic: str = ""
|
ntfy_topic: str = ""
|
||||||
ntfy_token: str = ""
|
ntfy_token: str = ""
|
||||||
briefing_time: str = ""
|
briefing_time: str = ""
|
||||||
|
capture_repo: str = ""
|
||||||
|
capture_token: str = ""
|
||||||
|
capture_ntfy_topic: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -129,4 +147,7 @@ def get_settings() -> Settings:
|
|||||||
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
||||||
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
||||||
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
||||||
|
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
|
||||||
|
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
|
||||||
|
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
+174
-1
@@ -35,6 +35,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||||
|
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||||
from app.models import (
|
from app.models import (
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
column_entry_times,
|
column_entry_times,
|
||||||
@@ -139,8 +140,30 @@ async def lifespan(app: FastAPI):
|
|||||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||||
)
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
|
capture_task = None
|
||||||
|
if settings.capture_ntfy_topic and settings.ntfy_url:
|
||||||
|
async def _capture_create(title: str, body: str) -> None:
|
||||||
|
repo = get_settings().capture_repo
|
||||||
|
if not repo:
|
||||||
|
logger.warning("ntfy capture: no KANBANTE_CAPTURE_REPO, skipped")
|
||||||
|
return
|
||||||
|
await _do_capture(title, body, repo, "backlog")
|
||||||
|
|
||||||
|
capture_task = asyncio.create_task(
|
||||||
|
ntfy_capture_loop(
|
||||||
|
_capture_create,
|
||||||
|
lambda: get_kv("capture_ntfy_last_id"),
|
||||||
|
lambda value: set_kv("capture_ntfy_last_id", value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"ntfy capture listener started (topic %s)",
|
||||||
|
settings.capture_ntfy_topic,
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
|
if capture_task is not None:
|
||||||
|
capture_task.cancel()
|
||||||
await gitea.aclose()
|
await gitea.aclose()
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +185,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
||||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
||||||
if request.url.path in ("/api/health", "/webhooks/gitea"):
|
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.auth_enabled:
|
if not settings.auth_enabled:
|
||||||
@@ -1003,6 +1026,156 @@ async def sse_event_stream(request: Request):
|
|||||||
hub.unsubscribe(queue)
|
hub.unsubscribe(queue)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_capture(
|
||||||
|
title: str, body: str, repo_full_name: str, column: str = "backlog"
|
||||||
|
) -> dict:
|
||||||
|
"""Create a Gitea issue from a capture and place its card.
|
||||||
|
|
||||||
|
Shared by POST /api/capture, the /capture page and the ntfy
|
||||||
|
listener. Raises ValueError for bad input (callers map to 400).
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
if not title or len(title) > 255:
|
||||||
|
raise ValueError("Title required (max 255 chars)")
|
||||||
|
if column not in KANBAN_COLUMNS:
|
||||||
|
raise ValueError("Invalid column")
|
||||||
|
parts = _split_full_name(repo_full_name)
|
||||||
|
if parts is None:
|
||||||
|
raise ValueError("Invalid repo (expected owner/name)")
|
||||||
|
owner, repo = parts
|
||||||
|
issue = await gitea.create_issue(owner, repo, title, body or "")
|
||||||
|
move_issue(repo_full_name, issue["number"], column)
|
||||||
|
log_event(
|
||||||
|
repo_full_name, issue["number"], "created",
|
||||||
|
to_column=column, source="capture",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"number": issue["number"],
|
||||||
|
"title": issue.get("title", title),
|
||||||
|
"html_url": issue.get("html_url", ""),
|
||||||
|
"repo_full_name": repo_full_name,
|
||||||
|
"column": column,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/capture")
|
||||||
|
async def api_capture(request: Request):
|
||||||
|
"""Quick capture endpoint (roadmap #12).
|
||||||
|
|
||||||
|
Auth: dedicated capture token (X-Capture-Token header or
|
||||||
|
Authorization: Bearer), distinct from the Gitea token. Exempt
|
||||||
|
from Basic Auth and CSRF — it is a machine-to-machine endpoint.
|
||||||
|
Disabled (503) until KANBANTE_CAPTURE_TOKEN is set.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.capture_token:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Capture is not configured "
|
||||||
|
"(set KANBANTE_CAPTURE_TOKEN)"},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
provided = request.headers.get("x-capture-token", "")
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if not provided and auth.lower().startswith("bearer "):
|
||||||
|
provided = auth[7:].strip()
|
||||||
|
if not provided or not secrets.compare_digest(provided, settings.capture_token):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Invalid capture token"}, status_code=401
|
||||||
|
)
|
||||||
|
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
title = body = text = repo = ""
|
||||||
|
column = "backlog"
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
title = str(payload.get("title") or "")
|
||||||
|
body = str(payload.get("body") or "")
|
||||||
|
text = str(payload.get("text") or "")
|
||||||
|
repo = str(payload.get("repo") or "")
|
||||||
|
column = str(payload.get("column") or "backlog")
|
||||||
|
elif "application/x-www-form-urlencoded" in content_type or (
|
||||||
|
"multipart/form-data" in content_type
|
||||||
|
):
|
||||||
|
form = await request.form()
|
||||||
|
title = str(form.get("title") or "")
|
||||||
|
body = str(form.get("body") or "")
|
||||||
|
text = str(form.get("text") or "")
|
||||||
|
repo = str(form.get("repo") or "")
|
||||||
|
column = str(form.get("column") or "backlog")
|
||||||
|
else: # text/plain and friends: the whole body is the capture
|
||||||
|
text = (await request.body()).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if not title and text:
|
||||||
|
title, rest = parse_capture_text(text)
|
||||||
|
body = body or rest
|
||||||
|
repo = repo or settings.capture_repo
|
||||||
|
if not repo:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "No repo given and KANBANTE_CAPTURE_REPO "
|
||||||
|
"is not set"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
created = await _do_capture(title, body, repo, column)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||||
|
return JSONResponse({"ok": True, "issue": created}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/capture", response_class=HTMLResponse)
|
||||||
|
async def capture_page(request: Request):
|
||||||
|
"""Quick-capture page: form, status and usage instructions."""
|
||||||
|
settings = get_settings()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"capture.html",
|
||||||
|
capture_repo=settings.capture_repo,
|
||||||
|
api_enabled=bool(settings.capture_token),
|
||||||
|
ntfy_topic=settings.capture_ntfy_topic,
|
||||||
|
ntfy_url=settings.ntfy_url,
|
||||||
|
created=None,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/capture", response_class=HTMLResponse)
|
||||||
|
async def capture_submit(
|
||||||
|
request: Request,
|
||||||
|
repo_full_name: str = Form(""),
|
||||||
|
title: str = Form(""),
|
||||||
|
body: str = Form(""),
|
||||||
|
column: str = Form("backlog"),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Web form capture (session + CSRF, like every other form)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
settings = get_settings()
|
||||||
|
repo_full_name = repo_full_name.strip() or settings.capture_repo
|
||||||
|
created, error = None, None
|
||||||
|
if not repo_full_name:
|
||||||
|
error = "Aucun dépôt : renseigne le dépôt ou définis KANBANTE_CAPTURE_REPO."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
created = await _do_capture(title, body, repo_full_name, column)
|
||||||
|
except ValueError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"capture.html",
|
||||||
|
capture_repo=settings.capture_repo,
|
||||||
|
api_enabled=bool(settings.capture_token),
|
||||||
|
ntfy_topic=settings.capture_ntfy_topic,
|
||||||
|
ntfy_url=settings.ntfy_url,
|
||||||
|
created=created,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stream")
|
@app.get("/api/stream")
|
||||||
async def live_stream(request: Request):
|
async def live_stream(request: Request):
|
||||||
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
||||||
|
|||||||
Reference in New Issue
Block a user