Spaces:
Sleeping
Sleeping
Verbose Graph errors, Fix Graph endpoints, Log webhook activity
Browse files- .claude/settings.local.json +3 -1
- app/classifier.py +4 -1
- app/graph_client.py +14 -5
- app/processor.py +1 -1
- app/scheduler.py +8 -2
- app/subscription.py +1 -1
- app/webhook.py +4 -1
.claude/settings.local.json
CHANGED
|
@@ -15,7 +15,9 @@
|
|
| 15 |
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
| 16 |
"Bash(xargs grep *)",
|
| 17 |
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"prompt\":\"What is your role?\",\"page\":\"sa-orchestration\"}' http://wordpress.localhost/wp-json/sa-orch/v1/sara/invoke)",
|
| 18 |
-
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"board_id\":1,\"topic\":\"jno-verification\"}' http://wordpress.localhost/wp-json/sa-orch/v1/seed/generate)"
|
|
|
|
|
|
|
| 19 |
]
|
| 20 |
}
|
| 21 |
}
|
|
|
|
| 15 |
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
| 16 |
"Bash(xargs grep *)",
|
| 17 |
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"prompt\":\"What is your role?\",\"page\":\"sa-orchestration\"}' http://wordpress.localhost/wp-json/sa-orch/v1/sara/invoke)",
|
| 18 |
+
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"board_id\":1,\"topic\":\"jno-verification\"}' http://wordpress.localhost/wp-json/sa-orch/v1/seed/generate)",
|
| 19 |
+
"Bash(git config *)",
|
| 20 |
+
"Bash(gh repo *)"
|
| 21 |
]
|
| 22 |
}
|
| 23 |
}
|
app/classifier.py
CHANGED
|
@@ -15,7 +15,7 @@ _KEYWORD_MAP = (
|
|
| 15 |
DocType = str # 'po' | 'invoice' | 'mtr' | 'quote' | 'unknown'
|
| 16 |
|
| 17 |
|
| 18 |
-
def classify(subject: str, filename: str, file_bytes: bytes) -> DocType:
|
| 19 |
subject_lower = subject.lower() if subject else ""
|
| 20 |
filename_lower = filename.lower() if filename else ""
|
| 21 |
|
|
@@ -24,6 +24,9 @@ def classify(subject: str, filename: str, file_bytes: bytes) -> DocType:
|
|
| 24 |
if "mtr" in filename_lower:
|
| 25 |
return "mtr"
|
| 26 |
|
|
|
|
|
|
|
|
|
|
| 27 |
text = _ocr_pdf(file_bytes)
|
| 28 |
if not text:
|
| 29 |
return "unknown"
|
|
|
|
| 15 |
DocType = str # 'po' | 'invoice' | 'mtr' | 'quote' | 'unknown'
|
| 16 |
|
| 17 |
|
| 18 |
+
def classify(subject: str, filename: str, file_bytes: bytes, content_type: str = "") -> DocType:
|
| 19 |
subject_lower = subject.lower() if subject else ""
|
| 20 |
filename_lower = filename.lower() if filename else ""
|
| 21 |
|
|
|
|
| 24 |
if "mtr" in filename_lower:
|
| 25 |
return "mtr"
|
| 26 |
|
| 27 |
+
if content_type and not content_type.startswith("application/pdf"):
|
| 28 |
+
return "unknown"
|
| 29 |
+
|
| 30 |
text = _ocr_pdf(file_bytes)
|
| 31 |
if not text:
|
| 32 |
return "unknown"
|
app/graph_client.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
|
| 3 |
import httpx
|
| 4 |
|
|
@@ -6,6 +7,14 @@ from app.auth import GraphAuthProvider
|
|
| 6 |
|
| 7 |
_BASE_URL = "https://graph.microsoft.com/v1.0"
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class GraphClient:
|
| 11 |
def __init__(self, auth: GraphAuthProvider) -> None:
|
|
@@ -20,35 +29,35 @@ class GraphClient:
|
|
| 20 |
resp = await self._client.get(
|
| 21 |
f"{_BASE_URL}{path}", headers=await self._headers(), params=params
|
| 22 |
)
|
| 23 |
-
|
| 24 |
return resp.json()
|
| 25 |
|
| 26 |
async def get_raw(self, path: str) -> bytes:
|
| 27 |
resp = await self._client.get(
|
| 28 |
f"{_BASE_URL}{path}", headers=await self._headers()
|
| 29 |
)
|
| 30 |
-
|
| 31 |
return resp.content
|
| 32 |
|
| 33 |
async def post(self, path: str, body: dict) -> dict:
|
| 34 |
resp = await self._client.post(
|
| 35 |
f"{_BASE_URL}{path}", headers=await self._headers(), json=body
|
| 36 |
)
|
| 37 |
-
|
| 38 |
return resp.json()
|
| 39 |
|
| 40 |
async def patch(self, path: str, body: dict) -> dict:
|
| 41 |
resp = await self._client.patch(
|
| 42 |
f"{_BASE_URL}{path}", headers=await self._headers(), json=body
|
| 43 |
)
|
| 44 |
-
|
| 45 |
return resp.json()
|
| 46 |
|
| 47 |
async def delete(self, path: str) -> None:
|
| 48 |
resp = await self._client.delete(
|
| 49 |
f"{_BASE_URL}{path}", headers=await self._headers()
|
| 50 |
)
|
| 51 |
-
|
| 52 |
|
| 53 |
async def aclose(self) -> None:
|
| 54 |
await self._client.aclose()
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import logging
|
| 3 |
|
| 4 |
import httpx
|
| 5 |
|
|
|
|
| 7 |
|
| 8 |
_BASE_URL = "https://graph.microsoft.com/v1.0"
|
| 9 |
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _raise_with_detail(resp: httpx.Response) -> None:
|
| 14 |
+
if resp.is_error:
|
| 15 |
+
logger.error("Graph %s %s → %s: %s", resp.request.method, resp.request.url, resp.status_code, resp.text)
|
| 16 |
+
resp.raise_for_status()
|
| 17 |
+
|
| 18 |
|
| 19 |
class GraphClient:
|
| 20 |
def __init__(self, auth: GraphAuthProvider) -> None:
|
|
|
|
| 29 |
resp = await self._client.get(
|
| 30 |
f"{_BASE_URL}{path}", headers=await self._headers(), params=params
|
| 31 |
)
|
| 32 |
+
_raise_with_detail(resp)
|
| 33 |
return resp.json()
|
| 34 |
|
| 35 |
async def get_raw(self, path: str) -> bytes:
|
| 36 |
resp = await self._client.get(
|
| 37 |
f"{_BASE_URL}{path}", headers=await self._headers()
|
| 38 |
)
|
| 39 |
+
_raise_with_detail(resp)
|
| 40 |
return resp.content
|
| 41 |
|
| 42 |
async def post(self, path: str, body: dict) -> dict:
|
| 43 |
resp = await self._client.post(
|
| 44 |
f"{_BASE_URL}{path}", headers=await self._headers(), json=body
|
| 45 |
)
|
| 46 |
+
_raise_with_detail(resp)
|
| 47 |
return resp.json()
|
| 48 |
|
| 49 |
async def patch(self, path: str, body: dict) -> dict:
|
| 50 |
resp = await self._client.patch(
|
| 51 |
f"{_BASE_URL}{path}", headers=await self._headers(), json=body
|
| 52 |
)
|
| 53 |
+
_raise_with_detail(resp)
|
| 54 |
return resp.json()
|
| 55 |
|
| 56 |
async def delete(self, path: str) -> None:
|
| 57 |
resp = await self._client.delete(
|
| 58 |
f"{_BASE_URL}{path}", headers=await self._headers()
|
| 59 |
)
|
| 60 |
+
_raise_with_detail(resp)
|
| 61 |
|
| 62 |
async def aclose(self) -> None:
|
| 63 |
await self._client.aclose()
|
app/processor.py
CHANGED
|
@@ -100,7 +100,7 @@ async def _process_notification(
|
|
| 100 |
final_status = "routing_error"
|
| 101 |
continue
|
| 102 |
|
| 103 |
-
doc_type = classify(subject, filename, file_bytes)
|
| 104 |
logger.info("Notification %d / %s → %s", notif_id, filename, doc_type)
|
| 105 |
|
| 106 |
if doc_type in _ROUTABLE:
|
|
|
|
| 100 |
final_status = "routing_error"
|
| 101 |
continue
|
| 102 |
|
| 103 |
+
doc_type = classify(subject, filename, file_bytes, att.get("content_type", ""))
|
| 104 |
logger.info("Notification %d / %s → %s", notif_id, filename, doc_type)
|
| 105 |
|
| 106 |
if doc_type in _ROUTABLE:
|
app/scheduler.py
CHANGED
|
@@ -13,8 +13,14 @@ class RenewalScheduler:
|
|
| 13 |
self._manager = subscription_manager
|
| 14 |
self._settings = settings
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def start(self, subscription_id: str) -> None:
|
| 17 |
-
interval_seconds =
|
| 18 |
self._scheduler.add_job(
|
| 19 |
self._renew_job,
|
| 20 |
trigger="interval",
|
|
@@ -53,7 +59,7 @@ class RenewalScheduler:
|
|
| 53 |
raise RuntimeError("No active subscription found to determine folder_id for recreation")
|
| 54 |
|
| 55 |
def _reschedule(self, new_subscription_id: str) -> None:
|
| 56 |
-
interval_seconds =
|
| 57 |
self._scheduler.reschedule_job(
|
| 58 |
"subscription_renewal",
|
| 59 |
trigger="interval",
|
|
|
|
| 13 |
self._manager = subscription_manager
|
| 14 |
self._settings = settings
|
| 15 |
|
| 16 |
+
def _renewal_interval_seconds(self) -> int:
|
| 17 |
+
# Renew at 80% of the subscription lifetime minus 15 minutes.
|
| 18 |
+
# With the default 4230-minute expiry that fires roughly every 53.5 hours,
|
| 19 |
+
# well before Graph's expiry and the 60-minute cutoff in ensure_subscription.
|
| 20 |
+
return int(self._settings.subscription_expiry_minutes * 60 * 0.80) - 900
|
| 21 |
+
|
| 22 |
def start(self, subscription_id: str) -> None:
|
| 23 |
+
interval_seconds = self._renewal_interval_seconds()
|
| 24 |
self._scheduler.add_job(
|
| 25 |
self._renew_job,
|
| 26 |
trigger="interval",
|
|
|
|
| 59 |
raise RuntimeError("No active subscription found to determine folder_id for recreation")
|
| 60 |
|
| 61 |
def _reschedule(self, new_subscription_id: str) -> None:
|
| 62 |
+
interval_seconds = self._renewal_interval_seconds()
|
| 63 |
self._scheduler.reschedule_job(
|
| 64 |
"subscription_renewal",
|
| 65 |
trigger="interval",
|
app/subscription.py
CHANGED
|
@@ -50,7 +50,7 @@ class SubscriptionManager:
|
|
| 50 |
body = {
|
| 51 |
"changeType": "created",
|
| 52 |
"notificationUrl": str(self._settings.notification_url),
|
| 53 |
-
"resource": f"
|
| 54 |
"expirationDateTime": expiry_str,
|
| 55 |
"clientState": client_state,
|
| 56 |
}
|
|
|
|
| 50 |
body = {
|
| 51 |
"changeType": "created",
|
| 52 |
"notificationUrl": str(self._settings.notification_url),
|
| 53 |
+
"resource": f"users/{self._settings.mailbox_user}/mailFolders/{folder_id}/messages",
|
| 54 |
"expirationDateTime": expiry_str,
|
| 55 |
"clientState": client_state,
|
| 56 |
}
|
app/webhook.py
CHANGED
|
@@ -37,7 +37,10 @@ async def _process_notifications(request: Request, body: dict) -> None:
|
|
| 37 |
db_path = settings.database_path
|
| 38 |
storage_path = settings.email_storage_path
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
change_type = notification.get("changeType", "")
|
| 42 |
if change_type != "created":
|
| 43 |
logger.debug("Skipping notification with changeType=%s", change_type)
|
|
|
|
| 37 |
db_path = settings.database_path
|
| 38 |
storage_path = settings.email_storage_path
|
| 39 |
|
| 40 |
+
notifications = body.get("value", [])
|
| 41 |
+
logger.info("Webhook received %d notification(s)", len(notifications))
|
| 42 |
+
|
| 43 |
+
for notification in notifications:
|
| 44 |
change_type = notification.get("changeType", "")
|
| 45 |
if change_type != "created":
|
| 46 |
logger.debug("Skipping notification with changeType=%s", change_type)
|