cmoss3 commited on
Commit
780bb45
·
1 Parent(s): 7da71bc

Verbose Graph errors, Fix Graph endpoints, Log webhook activity

Browse files
.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
- resp.raise_for_status()
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
- resp.raise_for_status()
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
- resp.raise_for_status()
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
- resp.raise_for_status()
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
- resp.raise_for_status()
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 = int(self._settings.subscription_expiry_minutes * 60 * 0.80)
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 = int(self._settings.subscription_expiry_minutes * 60 * 0.80)
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"me/mailFolders/{folder_id}/messages",
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
- for notification in body.get("value", []):
 
 
 
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)