Skip to content

Commit baf4d4d

Browse files
authored
Merge pull request #809 from cbcoutinho/feat/deck-webhook-presets
feat(webhooks): add Deck card sync preset with vector indexing
2 parents 303d6a5 + 7da72a3 commit baf4d4d

5 files changed

Lines changed: 309 additions & 11 deletions

File tree

nextcloud_mcp_server/app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,6 +2511,29 @@ async def log_auth_headers(request, call_next):
25112511
response = await call_next(request)
25122512
return response
25132513

2514+
# Log the inbound User-Agent on management API and webhook receiver routes
2515+
# so we can tell which Astrolabe (or other PHP-side client) build is
2516+
# talking to the backend. Astrolabe sends ``Nextcloud-Astrolabe/<version>``.
2517+
_UA_LOGGED_PATH_PREFIXES = ("/api/v1/", "/webhooks/nextcloud")
2518+
2519+
@app.middleware("http")
2520+
async def log_client_user_agent(request, call_next):
2521+
path = request.url.path
2522+
if path.startswith(_UA_LOGGED_PATH_PREFIXES):
2523+
ua = request.headers.get("user-agent") or "(none)"
2524+
logger.info(
2525+
"%s %s from %s",
2526+
request.method,
2527+
path,
2528+
ua,
2529+
extra={
2530+
"user_agent": ua,
2531+
"http_method": request.method,
2532+
"http_path": path,
2533+
},
2534+
)
2535+
return await call_next(request)
2536+
25142537
# Add CORS middleware to allow browser-based clients like MCP Inspector
25152538
app.add_middleware(
25162539
CORSMiddleware, # type: ignore[invalid-argument-type]

nextcloud_mcp_server/server/webhook_presets.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,18 @@ class WebhookPreset(TypedDict):
4343
# Forms webhook events (Nextcloud 30+)
4444
FORMS_EVENT_FORM_SUBMITTED = "OCA\\Forms\\Events\\FormSubmittedEvent"
4545

46-
# NOTE: Deck and Contacts do NOT support webhooks
47-
# Their event classes do not implement IWebhookCompatibleEvent interface.
48-
# Alternative sync strategies:
49-
# - Deck: Use polling with ETag-based change detection
50-
# - Contacts: Use CardDAV sync-token mechanism for efficient syncing
46+
# Deck webhook events (require nextcloud/deck PR #7910, which adds
47+
# IWebhookCompatibleEvent to these event classes). BoardUpdatedEvent only
48+
# carries a board ID and is used as a fan-out signal; the polling scanner
49+
# reconciles affected cards.
50+
DECK_EVENT_CARD_CREATED = "OCA\\Deck\\Event\\CardCreatedEvent"
51+
DECK_EVENT_CARD_UPDATED = "OCA\\Deck\\Event\\CardUpdatedEvent"
52+
DECK_EVENT_CARD_DELETED = "OCA\\Deck\\Event\\CardDeletedEvent"
53+
DECK_EVENT_BOARD_UPDATED = "OCA\\Deck\\Event\\BoardUpdatedEvent"
54+
55+
# NOTE: Contacts does NOT support webhooks — its event classes do not
56+
# implement IWebhookCompatibleEvent. Use CardDAV sync-token mechanism for
57+
# efficient syncing.
5158

5259

5360
WEBHOOK_PRESETS: Dict[str, WebhookPreset] = {
@@ -138,6 +145,29 @@ class WebhookPreset(TypedDict):
138145
},
139146
],
140147
},
148+
"deck_sync": {
149+
"name": "Deck Sync",
150+
"description": "Real-time synchronization for Deck cards (create, update, delete) and board updates",
151+
"app": "deck",
152+
"events": [
153+
{
154+
"event": DECK_EVENT_CARD_CREATED,
155+
"filter": {},
156+
},
157+
{
158+
"event": DECK_EVENT_CARD_UPDATED,
159+
"filter": {},
160+
},
161+
{
162+
"event": DECK_EVENT_CARD_DELETED,
163+
"filter": {},
164+
},
165+
{
166+
"event": DECK_EVENT_BOARD_UPDATED,
167+
"filter": {},
168+
},
169+
],
170+
},
141171
}
142172

143173

nextcloud_mcp_server/vector/webhook_parser.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
``/webhooks/nextcloud`` calls :func:`extract_document_task` and forwards any
55
non-None result to the same processor send-stream the scanner uses.
66
7-
Currently scoped to file (note) events. Calendar / Tables events fall through
8-
to ``None`` for now; those parsers can be added in follow-up changes.
7+
Currently scoped to file (note) events and Deck card events. Calendar /
8+
Tables events fall through to ``None`` for now; those parsers can be added
9+
in follow-up changes.
910
1011
See ADR-010 for the design and ``webhook-testing-findings.md`` for real
1112
captured payloads.
@@ -22,6 +23,19 @@
2223
_FILE_EVENT_WRITTEN = "OCP\\Files\\Events\\Node\\NodeWrittenEvent"
2324
_FILE_EVENT_BEFORE_DELETED = "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent"
2425

26+
_DECK_EVENT_CARD_CREATED = "OCA\\Deck\\Event\\CardCreatedEvent"
27+
_DECK_EVENT_CARD_UPDATED = "OCA\\Deck\\Event\\CardUpdatedEvent"
28+
_DECK_EVENT_CARD_DELETED = "OCA\\Deck\\Event\\CardDeletedEvent"
29+
_DECK_EVENT_BOARD_UPDATED = "OCA\\Deck\\Event\\BoardUpdatedEvent"
30+
31+
_DECK_CARD_EVENTS = frozenset(
32+
{
33+
_DECK_EVENT_CARD_CREATED,
34+
_DECK_EVENT_CARD_UPDATED,
35+
_DECK_EVENT_CARD_DELETED,
36+
}
37+
)
38+
2539
# Matches paths inside any user's Notes folder ending in .md, e.g.
2640
# "/admin/files/Notes/Sub/Note.md" or "/alice/files/Notes/foo.md".
2741
_NOTES_PATH_RE = re.compile(r"^/[^/]+/files/Notes/.+\.md$")
@@ -50,6 +64,9 @@ def extract_document_task(payload: dict) -> DocumentTask | None:
5064
):
5165
return _parse_file_event(event_class, event, user_id, time)
5266

67+
if event_class in _DECK_CARD_EVENTS or event_class == _DECK_EVENT_BOARD_UPDATED:
68+
return _parse_deck_event(event_class, event, user_id, time)
69+
5370
logger.debug("Ignoring webhook for unsupported event: %s", event_class)
5471
return None
5572

@@ -85,3 +102,46 @@ def _parse_file_event(
85102
operation=operation,
86103
modified_at=time,
87104
)
105+
106+
107+
def _parse_deck_event(
108+
event_class: str, event: dict, user_id: str, time: int
109+
) -> DocumentTask | None:
110+
# BoardUpdatedEvent carries only ``boardId`` — there's no card identifier to
111+
# index. Log delivery so we can confirm webhook arrival in the receiver
112+
# logs, and let the polling scanner reconcile the affected cards.
113+
if event_class == _DECK_EVENT_BOARD_UPDATED:
114+
board_id = event.get("boardId")
115+
logger.info(
116+
"Deck board %s updated; polling scanner will reconcile cards",
117+
board_id,
118+
)
119+
return None
120+
121+
card = event.get("card") or {}
122+
card_id = card.get("id")
123+
stack_id = card.get("stackId")
124+
125+
if card_id is None:
126+
# Without an id we can't address Qdrant points; the polling scanner
127+
# will pick the change up on its next pass.
128+
logger.warning(
129+
"Webhook %s missing card.id; falling back to scanner",
130+
event_class,
131+
)
132+
return None
133+
134+
operation = "delete" if event_class == _DECK_EVENT_CARD_DELETED else "index"
135+
136+
# board_id is not part of the Card payload — processor.py falls back to
137+
# iterating boards/stacks if it's missing, so we pass stack_id only.
138+
metadata = {"stack_id": stack_id} if stack_id is not None else None
139+
140+
return DocumentTask(
141+
user_id=user_id,
142+
doc_id=str(card_id),
143+
doc_type="deck_card",
144+
operation=operation,
145+
modified_at=time,
146+
metadata=metadata,
147+
)

tests/unit/test_webhook_endpoint.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,68 @@ def _make_app(send_stream=None) -> Starlette:
6969
}
7070

7171

72+
# Deck PR #7910 (CardCreatedEvent etc.) emits ``{"card": Card::jsonSerialize()}``
73+
# — see ~/Software/deck/lib/Event/ACardEvent.php. Card::jsonSerialize() includes
74+
# id and stackId but not boardId (the processor falls back to iteration for
75+
# that). BoardUpdatedEvent emits only ``{"boardId": int}``.
76+
_DECK_CARD_CREATED = {
77+
"user": {"uid": "admin"},
78+
"time": 1762900000,
79+
"event": {
80+
"class": "OCA\\Deck\\Event\\CardCreatedEvent",
81+
"card": {
82+
"id": 4242,
83+
"title": "Webhook smoke test",
84+
"stackId": 16,
85+
},
86+
},
87+
}
88+
89+
90+
_DECK_CARD_DELETED = {
91+
"user": {"uid": "alice"},
92+
"time": 1762900100,
93+
"event": {
94+
"class": "OCA\\Deck\\Event\\CardDeletedEvent",
95+
"card": {
96+
"id": 4242,
97+
"title": "Webhook smoke test",
98+
"stackId": 16,
99+
},
100+
},
101+
}
102+
103+
104+
_DECK_BOARD_UPDATED = {
105+
"user": {"uid": "admin"},
106+
"time": 1762900200,
107+
"event": {
108+
"class": "OCA\\Deck\\Event\\BoardUpdatedEvent",
109+
"boardId": 5,
110+
},
111+
}
112+
113+
114+
_NOTE_CREATED_MISSING_ID = {
115+
"user": {"uid": "admin"},
116+
"time": 1762850300,
117+
"event": {
118+
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
119+
"node": {"path": "/admin/files/Notes/no-id.md"},
120+
},
121+
}
122+
123+
124+
_DECK_CARD_CREATED_MISSING_ID = {
125+
"user": {"uid": "admin"},
126+
"time": 1762900300,
127+
"event": {
128+
"class": "OCA\\Deck\\Event\\CardCreatedEvent",
129+
"card": {"title": "Card without id", "stackId": 16},
130+
},
131+
}
132+
133+
72134
def test_index_event_queues_task_and_returns_200():
73135
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
74136
app = _make_app(send_stream=send_stream)
@@ -127,6 +189,93 @@ def test_unsupported_event_is_ignored():
127189
receive_stream.receive_nowait()
128190

129191

192+
def test_deck_card_created_queues_index_task():
193+
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
194+
app = _make_app(send_stream=send_stream)
195+
196+
with TestClient(app) as client:
197+
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_CREATED)
198+
199+
assert response.status_code == 200
200+
assert response.json()["status"] == "queued"
201+
assert response.json()["operation"] == "index"
202+
assert response.json()["doc_id"] == "4242"
203+
204+
task = receive_stream.receive_nowait()
205+
assert task.user_id == "admin"
206+
assert task.doc_id == "4242"
207+
assert task.doc_type == "deck_card"
208+
assert task.operation == "index"
209+
assert task.metadata == {"stack_id": 16}
210+
211+
212+
def test_deck_card_deleted_queues_delete_task():
213+
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
214+
app = _make_app(send_stream=send_stream)
215+
216+
with TestClient(app) as client:
217+
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_DELETED)
218+
219+
assert response.status_code == 200
220+
assert response.json()["operation"] == "delete"
221+
222+
task = receive_stream.receive_nowait()
223+
assert task.doc_type == "deck_card"
224+
assert task.operation == "delete"
225+
assert task.doc_id == "4242"
226+
assert task.user_id == "alice"
227+
228+
229+
def test_deck_board_updated_is_ignored():
230+
"""BoardUpdatedEvent carries no card id, so the parser logs delivery and
231+
returns None — the polling scanner picks up the actual card changes."""
232+
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
233+
app = _make_app(send_stream=send_stream)
234+
235+
with TestClient(app) as client:
236+
response = client.post("/webhooks/nextcloud", json=_DECK_BOARD_UPDATED)
237+
238+
assert response.status_code == 200
239+
assert response.json()["status"] == "ignored"
240+
241+
with pytest.raises(anyio.WouldBlock):
242+
receive_stream.receive_nowait()
243+
244+
245+
def test_deck_card_missing_id_is_ignored():
246+
"""A card event without ``card.id`` can't address Qdrant points, so the
247+
parser logs a warning and returns None — the polling scanner reconciles."""
248+
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
249+
app = _make_app(send_stream=send_stream)
250+
251+
with TestClient(app) as client:
252+
response = client.post(
253+
"/webhooks/nextcloud", json=_DECK_CARD_CREATED_MISSING_ID
254+
)
255+
256+
assert response.status_code == 200
257+
assert response.json()["status"] == "ignored"
258+
259+
with pytest.raises(anyio.WouldBlock):
260+
receive_stream.receive_nowait()
261+
262+
263+
def test_note_missing_node_id_is_ignored():
264+
"""Symmetric coverage for the file-event fail-open branch: a notes path
265+
without ``node.id`` falls back to the polling scanner."""
266+
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
267+
app = _make_app(send_stream=send_stream)
268+
269+
with TestClient(app) as client:
270+
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED_MISSING_ID)
271+
272+
assert response.status_code == 200
273+
assert response.json()["status"] == "ignored"
274+
275+
with pytest.raises(anyio.WouldBlock):
276+
receive_stream.receive_nowait()
277+
278+
130279
def test_invalid_json_returns_400():
131280
app = _make_app(send_stream=None)
132281

0 commit comments

Comments
 (0)