From d34984d4800146ebe60849875c113bbcd182106a Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Wed, 1 Jul 2026 01:37:57 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Bitable=20?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E9=80=9A=E7=9F=A5=E5=AE=9E=E7=8E=B0=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E4=B8=BA=20SSE=20=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md | 1524 ++++++++++++++-------- 2 files changed, 973 insertions(+), 552 deletions(-) diff --git a/.gitignore b/.gitignore index e3e089a..328b7cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ DerivedData/ xcuserdata/ Config.xcconfig .DS_Store +.idea/ \ No newline at end of file diff --git a/docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md b/docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md index 951e3cf..23d7702 100644 --- a/docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md +++ b/docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md @@ -1,76 +1,98 @@ # Bitable Change Notification — Implementation Guide -**Date:** 2026-06-30 +**Date:** 2026-07-01 **Status:** Ready for implementation --- ## TL;DR -Extend the existing `feishu-app` (Python, already deployed with WebSocket long connection to Feishu) to listen for `drive.file.bitable_record_changed_v1` events and push wake-up signals to Bugger via localhost HTTP. Because `feishu-app` uses **app-level `tenant_access_token`** (not user tokens), it bypasses the owner/manager permission restriction — the app just needs to be added as a document manager to each Bitable once. +Extend `feishu-app` (a separate Python/FastAPI service deployed via Docker, repo at `../../aptsell/feishu-app`) to listen for `drive.file.bitable_record_changed_v1` WebSocket events, maintain a local cache of `{record_id → assignee}`, and push change notifications to Bugger via **Server-Sent Events (SSE)**. Bugger opens an outbound SSE connection to feishu-app — no local HTTP server required. -- **Code to write:** ~150 LOC Python (feishu-app) + ~100 LOC Swift (Bugger) -- **New dependencies:** None — SDK v1.5.3 already has all required models -- **Estimated effort:** 1 day implementation + configuration +Key design decisions: +- **SSE (Bugger → feishu-app, outbound)**: Bugger initiates the connection, so it works through NAT. feishu-app pushes events down the stream. +- **Assignee-aware routing**: feishu-app caches `{record_id → assignee}` and only notifies Bugger instances whose assignee actually changed. Eliminates redundant API calls. +- **Cache reconciliation**: feishu-app does a full Bitable pull on startup and daily to keep the cache accurate. +- **Bugger `fetchNow()` unchanged**: zero signature changes to the existing polling pipeline. + +- **Feature flag:** `BITABLE_CHANGE_NOTIFICATION_ENABLED=false` by default. When disabled, **zero resources are allocated** — no imports, no Redis connections, no SSE endpoint, no WebSocket handler registration. +- **Code to write:** ~420 LOC Python (feishu-app) + ~120 LOC Swift (Bugger) +- **New dependencies:** None — `lark-oapi==1.4.20` and `httpx` already in requirements.txt +- **Bugger config change:** Add `feishuAppBaseURL` field to `AppConfig` +- **Estimated effort:** 2 days implementation + configuration --- ## 1. How It Works ``` -┌──────────────────────┐ WebSocket push ┌──────────────┐ -│ Feishu Cloud │ ────bitable_record────▶ │ feishu-app │ -│ (Bitable changed) │ changed_v1 │ (Python) │ -└──────────────────────┘ └──────┬───────┘ - │ - localhost HTTP POST - {table_id, revision, - file_token} - │ - ▼ - ┌───────────────┐ - │ Bugger (Mac) │ - │ fetchNow() │ - └───────────────┘ +┌──────────────────────┐ WebSocket push ┌──────────────────┐ +│ Feishu Cloud │ ────bitable_record─────▶ │ feishu-app │ +│ (Bitable changed) │ changed_v1 │ (Docker) │ +└──────────────────────┘ └────────┬─────────┘ + │ + ① Fetch changed record (1 API call) + ② Diff cached assignee ↔ current + ③ Only notify if assignee changed + │ + SSE push to matching + assignee's Bugger instance(s) + │ +┌──────────────────────┐ │ +│ Bugger (User A) │◀═══ SSE: assignee changed ═══════┘ +│ menu bar app │ +│ │──▶ fetchNow() (existing, unchanged) +│ PollerService │──▶ Feishu Bitable API (existing flow) +│ (periodic fallback) │ +└──────────────────────┘ ``` -1. User edits a Bitable record -2. Feishu pushes `drive.file.bitable_record_changed_v1` event via WebSocket to `feishu-app` -3. `feishu-app` handler filters to registered tables, extracts `{file_token, table_id, revision}` -4. `feishu-app` POSTs wake-up signal to Bugger's localhost listener -5. Bugger triggers `fetchNow()` for that table -6. Bugger's `PollerService` remains as periodic fallback (15-30 min) +1. User edits a Bitable record (any field) +2. Feishu pushes `drive.file.bitable_record_changed_v1` via WebSocket to `feishu-app` +3. `feishu-app` fetches the changed record (1 API call), compares `current_assignee` against its local cache +4. If assignee unchanged (e.g. priority edit) → **skip, no notification**. +5. If assignee changed (new bug, reassignment, deletion) → push wake-up signal via SSE to the affected Bugger instance(s) +6. Bugger triggers its existing `fetchNow()` → `FeishuService.fetchBugs(assigneeName:)` (which already filters by assignee) +7. Bugger's `PollerService` remains as periodic fallback (15-30 min) + +**API call economics (team of 20 developers):** + +| Scenario | Dumb relay (old design) | Assignee-aware (this design) | +|----------|------------------------|------------------------------| +| Tester edits priority | 20 Bugger fetches | 1 record fetch → skip | +| Assignee changed A→B | 20 Bugger fetches | 1 record fetch + 2 Bugger fetches (A's + B's) | +| New bug assigned to A | 20 Bugger fetches | 1 record fetch + 1 Bugger fetch (A's) | --- ## 2. Prerequisites — Feishu Open Platform Configuration -### 2.1 Verify/Create the App +### 2.1 Verify the App -The `feishu-app` service already uses: +`feishu-app` is a separate repository at `../../aptsell/feishu-app/feishu-service-python/`. It uses: - `FEISHU_APP_ID=cli_a7245c3f17745013` (from `.env.example`) - `FEISHU_APP_SECRET` (configured in deployment) - `VERIFICATION_TOKEN` (configured in deployment) +- SDK: `lark-oapi==1.4.20` (confirmed in `requirements.txt`) +- Existing Redis for dedup/session storage -Confirm this app exists in the [Feishu Open Platform](https://open.feishu.cn/). +Confirm the `P2DriveFileBitableRecordChangedV1` model and `register_p2_drive_file_bitable_record_changed_v1` builder method are available in the SDK version. If not, bump to `lark-oapi>=1.5.0`. ### 2.2 Add Required Permission Scope -The app needs **one** of the following scopes to call the subscribe API: +The app needs **one** of the following scopes to call the subscribe API and fetch records: | Scope | Description | |-------|-------------| -| `drive:drive` | Full cloud document access (recommended if app already uses drive APIs) | -| `docs:event:subscribe` | Minimal scope — only subscribe to document events | -| `docs:doc` | Document read/write/manage | -| `sheets:spreadsheet` | Spreadsheet access | +| `drive:drive` | Full cloud document access (recommended — covers subscribe + record read) | +| `bitable:app` | Bitable read/write access | **Steps:** 1. Go to [Feishu Open Platform](https://open.feishu.cn/) → Apps → your app 2. **Permissions** (权限管理) → **Add Permission** (添加权限) -3. Search for `drive:drive` (or `docs:event:subscribe` for minimal scope) +3. Search for `bitable:app` (or `drive:drive`) 4. Add the permission -5. **Publish** a new app version (create version → publish) for the permission change to take effect +5. **Publish** a new app version for the permission change to take effect > ⚠️ **Important:** Feishu requires creating and publishing a new app version after any permission change. Without this, the subscribe API returns 403. @@ -87,212 +109,345 @@ This is a **one-time setup per Bitable**. Once added, the app can subscribe and --- +## 2A. Feature Flag + +The entire Bitable change notification system is gated behind a single boolean flag. When disabled, **no resources are consumed** — no imports, no class instances, no Redis keys, no API calls. + +### 2A.1 Setting Definition + +Add to `src/app/config.py` (follows the existing `enable_link_summary` pattern, around line 76): + +```python +# Bitable change notification feature flag (disabled by default) +bitable_change_notification_enabled: bool = ( + os.getenv("BITABLE_CHANGE_NOTIFICATION_ENABLED", "false").lower() == "true" +) +``` + +### 2A.2 `.env` Entry + +``` +# Enable Bitable change notification (SSE push to Bugger) +BITABLE_CHANGE_NOTIFICATION_ENABLED=false +``` + +### 2A.3 What the Flag Gates + +``` +BITABLE_CHANGE_NOTIFICATION_ENABLED +│ +├─ false (default) +│ ├─ No import of BitableEventService +│ ├─ No import of bitable_subscription router +│ ├─ No import of P2DriveFileBitableRecordChangedV1 +│ ├─ No WebSocket handler registration +│ ├─ No SSE endpoint registered on FastAPI +│ ├─ No Redis cache keys created +│ ├─ No full Bitable pull on startup +│ ├─ No reconciliation task +│ └─ No Feishu subscribe API calls +│ +└─ true + ├─ Import BitableEventService (lazy, only on first SSE connection) + ├─ Register SSE router + ├─ Register WebSocket handler + ├─ Redis cache populated on first use + ├─ Full pull + reconciliation task + └─ Feishu subscribe on first Bugger connection +``` + +### 2A.4 Gating Pattern + +Every change point uses the same guard — no dead imports, no orphaned constants, no half-initialized state: + +```python +if settings.bitable_change_notification_enabled: + # ... register handler, import service, wire router, start reconciliation +``` + +This follows the existing `if settings.platform.lower() == "dingtalk":` branching pattern already used in `main.py`. + +--- + ## 3. Code Changes — feishu-app (Python) +Repository: `../../aptsell/feishu-app/feishu-service-python/` + ### 3.1 New File: `src/app/services/bitable_event_service.py` -Create a service to manage Bitable subscriptions and event handling: +The core service — manages the assignee cache, handles WebSocket events, and serves SSE connections. ```python """ Bitable change event service. -Manages subscriptions and dispatches change events to Bugger. +Maintains {record_id → assignee} cache, filters events by assignee transitions, +and pushes notifications to Bugger instances via SSE. """ +import asyncio import json import logging -import httpx -from typing import Dict, Any, Optional, Set +import time +from typing import Dict, Any, Optional, Set, List from dataclasses import dataclass, field +import httpx + from src.app.config import settings logger = logging.getLogger(__name__) @dataclass -class BitableSubscription: - """Tracks a subscribed Bitable table.""" +class BuggerSSEConnection: + """An SSE connection from a Bugger instance.""" + queue: asyncio.Queue file_token: str - table_id: str - subscribed_at: float # timestamp + assignee_field: str # e.g. "负责人", "assignee" + assignee_name: str # e.g. "张三" class BitableEventService: """ - Manages Bitable event subscriptions and notification to Bugger. + Manages Bitable event subscriptions, assignee cache, and SSE fan-out. Lifecycle: - 1. Bugger registers tables via POST /api/v1/bitable/subscribe - 2. This service calls Feishu's subscribe API with tenant_access_token - 3. Events arrive via WebSocket handler → filtered → forwarded to Bugger + 1. bugger opens SSE: GET /api/v1/bitable/events?file_token=...&assignee_field=...&assignee_name=... + 2. First connection for a file_token → feishu-app calls Feishu subscribe API + → triggers initial full pull to warm {record_id → assignee} cache + 3. WebSocket events arrive → fetch changed record → diff assignee → + push to matching SSE connections + 4. Daily cron: full pull → reconcile cache → notify missed transitions + 5. Last connection for a file_token → unsubscribe from Feishu """ def __init__(self, bot_service): self._bot = bot_service - # file_token -> BitableSubscription - self._subscriptions: Dict[str, BitableSubscription] = {} - # Bugger's localhost callback URL - self._bugger_callback: Optional[str] = None + # file_token → assignee_field → record_id → assignee_value + self._cache: Dict[str, Dict[str, Dict[str, str]]] = {} + # SSE connections, keyed by (file_token, assignee_name) + self._connections: Dict[str, List[BuggerSSEConnection]] = {} + # Track Feishu subscription state per file_token + self._feishu_subscribed: Set[str] = set() + # Reconciliation task + self._reconcile_task: Optional[asyncio.Task] = None - # ── subscribe / unsubscribe ────────────────────────────── + # ── Feishu subscribe / unsubscribe (internal) ──────────── - async def subscribe(self, file_token: str, - table_id: str = "", - bugger_callback: str = "") -> Dict[str, Any]: - """ - Subscribe to Bitable change events for a file. - - Args: - file_token: Bitable file token (e.g. 'bTkAbFdN...') - table_id: Optional specific table ID (for metadata only; - subscription is per-file) - bugger_callback: Bugger's localhost URL for wake-up signals - """ - if bugger_callback: - self._bugger_callback = bugger_callback - - # Call Feishu subscribe API using SDK + async def _subscribe_to_file(self, file_token: str) -> bool: + """Call Feishu subscribe API to start receiving WebSocket events.""" try: - from lark_oapi.api.drive.v1 import ( - SubscribeFileRequest, SubscribeFileRequestBuilder - ) headers = await self._bot._get_headers() - - # Build the subscribe request - request = (SubscribeFileRequestBuilder() - .file_token(file_token) - .file_type("bitable") - .build()) - - # Call the API via httpx (following existing _get_headers pattern) url = (f"{self._bot.base_url}/drive/v1/files/" f"{file_token}/subscribe?file_type=bitable") async with httpx.AsyncClient() as client: - response = await client.post( - url, - headers=headers, - timeout=10.0 - ) + response = await client.post(url, headers=headers, timeout=10.0) result = response.json() if result.get("code") == 0: - import time - self._subscriptions[file_token] = BitableSubscription( - file_token=file_token, - table_id=table_id, - subscribed_at=time.time() - ) - logger.info(f"✅ Subscribed to Bitable: {file_token}") - return {"status": "subscribed", "file_token": file_token} - + logger.info(f"✅ Subscribed to Feishu events for {file_token}") + self._feishu_subscribed.add(file_token) + return True elif result.get("code") == 1069603: logger.error( - f"❌ Subscribe permission denied for {file_token}. " - f"Ensure the app is added as document manager " - f"('添加文档应用' in Bitable UI). " + f"❌ Permission denied for {file_token}. " + f"App must be added as document manager. " f"Feishu msg: {result.get('msg')}" ) - return { - "status": "permission_denied", - "file_token": file_token, - "error": "App must be added as document manager to this Bitable", - "feishu_code": 1069603, - "feishu_msg": result.get("msg"), - "fix": ("Open the Bitable → '...' → '更多' → " - "'添加文档应用' → select this app → grant '可管理'") - } + return False else: logger.error(f"Subscribe failed for {file_token}: {result}") - return { - "status": "error", - "file_token": file_token, - "feishu_code": result.get("code"), - "feishu_msg": result.get("msg") - } - + return False except Exception as e: logger.error(f"Subscribe exception for {file_token}: {e}") - return {"status": "error", "file_token": file_token, "error": str(e)} + return False - async def unsubscribe(self, file_token: str) -> Dict[str, Any]: - """Unsubscribe from Bitable change events.""" + async def _unsubscribe_from_file(self, file_token: str) -> None: + """Call Feishu unsubscribe API.""" try: headers = await self._bot._get_headers() url = (f"{self._bot.base_url}/drive/v1/files/" f"{file_token}/unsubscribe?file_type=bitable") async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, timeout=10.0) - result = response.json() - - self._subscriptions.pop(file_token, None) - logger.info(f"Unsubscribed from Bitable: {file_token}") - return {"status": "unsubscribed", "file_token": file_token, - "feishu_code": result.get("code")} + await client.post(url, headers=headers, timeout=10.0) + self._feishu_subscribed.discard(file_token) + logger.info(f"Unsubscribed from Feishu events for {file_token}") except Exception as e: logger.error(f"Unsubscribe exception for {file_token}: {e}") - return {"status": "error", "file_token": file_token, "error": str(e)} - async def get_subscription_status(self, - file_token: str) -> Dict[str, Any]: - """Check if currently subscribed to a Bitable.""" + # ── Assignee cache ────────────────────────────────────── + + async def warm_cache(self, file_token: str, assignee_field: str) -> Dict[str, str]: + """ + Full pull of Bitable records to build/refresh the assignee cache. + Returns the built cache slice: {record_id: assignee_value}. + """ + cache: Dict[str, str] = {} + page_token: Optional[str] = None + headers = await self._bot._get_headers() + try: - headers = await self._bot._get_headers() - url = (f"{self._bot.base_url}/drive/v1/files/" - f"{file_token}/subscribe?file_type=bitable") + while True: + url = ( + f"{self._bot.base_url}/bitable/v1/apps/{file_token}" + f"/tables/tblXXXXXXXXXXXXXXXX/records" + f"?page_size=500" + ) + if page_token: + url += f"&page_token={page_token}" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, timeout=10.0) - result = response.json() + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=30.0) + data = response.json() - return { - "file_token": file_token, - "subscribed_in_api": result.get("code") == 0, - "subscribed_locally": file_token in self._subscriptions, - "feishu_code": result.get("code") - } + if data.get("code") != 0: + logger.error(f"Full pull failed for {file_token}: {data}") + break + + items = (data.get("data") or {}).get("items") or [] + for item in items: + record_id = item.get("record_id") + fields = item.get("fields") or {} + assignee = self._extract_assignee(fields, assignee_field) + if record_id and assignee: + cache[record_id] = assignee + + if not data.get("data", {}).get("has_more"): + break + page_token = data.get("data", {}).get("page_token") + + # Store in cache structure: file_token → assignee_field → record_id → assignee + file_cache = self._cache.setdefault(file_token, {}) + file_cache[assignee_field] = cache + logger.info( + f"Cache warmed for {file_token} " + f"(field={assignee_field}): {len(cache)} records" + ) + return cache except Exception as e: - logger.error(f"Status check exception for {file_token}: {e}") - return {"status": "error", "file_token": file_token, "error": str(e)} + logger.error(f"Cache warm exception for {file_token}: {e}", exc_info=True) + return cache - # ── event handling ─────────────────────────────────────── + def _extract_assignee(self, fields: Dict[str, Any], assignee_field: str) -> Optional[str]: + """ + Extract assignee name from a record's fields. + Handles Feishu's user-type field format: + - Single user: {"name": "张三", "id": "ou_xxx", ...} + - List of users: [{"name": "张三", ...}] + - String/text field: "张三" + """ + value = fields.get(assignee_field) + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, dict): + return value.get("name") or value.get("id") + if isinstance(value, list) and len(value) > 0: + first = value[0] + if isinstance(first, dict): + return first.get("name") or first.get("id") + if isinstance(first, str): + return first + return None + + def _cache_key(self, file_token: str, assignee_name: str) -> str: + return f"{file_token}:{assignee_name}" + + # ── SSE connection management ─────────────────────────── + + async def add_connection(self, conn: BuggerSSEConnection) -> bool: + """ + Add an SSE connection. Returns True if the cache was ready, + False if a warm is needed. + """ + key = self._cache_key(conn.file_token, conn.assignee_name) + self._connections.setdefault(key, []).append(conn) + logger.info( + f"SSE connection added: file={conn.file_token}, " + f"assignee={conn.assignee_name}, total={len(self._connections[key])}" + ) + + # First connection for this file_token? Subscribe and warm cache + if conn.file_token not in self._feishu_subscribed: + await self._subscribe_to_file(conn.file_token) + + cache_ready = ( + conn.file_token in self._cache + and conn.assignee_field in self._cache[conn.file_token] + ) + return cache_ready + + def remove_connection(self, conn: BuggerSSEConnection) -> None: + """Remove an SSE connection. Unsubscribe if last connection.""" + key = self._cache_key(conn.file_token, conn.assignee_name) + bucket = self._connections.get(key, []) + if conn in bucket: + bucket.remove(conn) + if not bucket: + self._connections.pop(key, None) + + # Last connection for this file_token? Unsubscribe + has_other = any( + c.file_token == conn.file_token + for bucket in self._connections.values() + for c in bucket + ) + if not has_other and conn.file_token in self._feishu_subscribed: + asyncio.create_task(self._unsubscribe_from_file(conn.file_token)) + + async def notify_connections( + self, file_token: str, assignee_name: str, reason: str = "changed" + ) -> int: + """Push a wake-up event to all SSE connections matching the assignee.""" + key = self._cache_key(file_token, assignee_name) + notified = 0 + for conn in self._connections.get(key, []): + try: + await conn.queue.put({ + "event": "change", + "data": json.dumps({ + "file_token": file_token, + "assignee_name": assignee_name, + "reason": reason, + "timestamp": int(time.time()), + }) + }) + notified += 1 + except Exception as e: + logger.error(f"Failed to queue SSE event: {e}") + if notified: + logger.info(f"Notified {notified} Bugger instance(s) for {assignee_name}") + return notified + + # ── Event handling ────────────────────────────────────── async def handle_bitable_record_changed(self, data) -> None: """ Handle a bitable_record_changed_v1 event from WebSocket. - - Called by the WebSocket event handler in run_feishu_service.py. - Filters to registered tables, then notifies Bugger. + Fetch changed record → diff assignee → notify only if assignee changed. """ try: header = getattr(data, 'header', None) event = getattr(data, 'event', None) - event_type = getattr(header, 'event_type', 'unknown') event_id = getattr(header, 'event_id', None) file_token = getattr(event, 'file_token', None) table_id = getattr(event, 'table_id', None) revision = getattr(event, 'revision', None) logger.info( - f"🔔 Bitable change event: " - f"event_id={event_id}, " - f"file_token={file_token}, " - f"table_id={table_id}, " - f"revision={revision}" + f"🔔 Bitable change event: event_id={event_id}, " + f"file_token={file_token}, table_id={table_id}, revision={revision}" ) - # Filter: only forward events for registered tables - if not file_token or file_token not in self._subscriptions: - logger.debug( - f"Skipping event for unregistered file_token={file_token}" - ) + if not file_token or file_token not in self._feishu_subscribed: return - # Dedup via Redis (reuse existing bot service dedup mechanism) + # Dedup via Redis if event_id: redis = await self._bot._get_redis_client() dedup_key = f"feishu:bitable_event:{event_id}" @@ -300,158 +455,173 @@ class BitableEventService: if already_seen: logger.debug(f"Dedup: skipping duplicate event {event_id}") return - await redis.set(dedup_key, "1", ex=3600) # 1 hour TTL + await redis.set(dedup_key, "1", ex=3600) - # Build wake-up signal payload - payload = { - "file_token": file_token, - "table_id": table_id, - "revision": revision, - "timestamp": getattr(header, 'create_time', None) - } + # Process each changed record + action_list = getattr(event, 'action_list', None) or [] + headers = await self._bot._get_headers() - # Log action details if available - action_list = getattr(event, 'action_list', None) - if action_list: - for action in action_list: - act_type = getattr(action, 'action', 'unknown') - record_id = getattr(action, 'record_id', None) + for action in action_list: + record_id = getattr(action, 'record_id', None) + act_type = getattr(action, 'action', 'unknown') + if not record_id: + continue + + # Fetch the full record + record = await self._fetch_record( + file_token, table_id, record_id, headers + ) + + if record is None: + # Record deleted — notify old assignee(s) + for field_name, assignee_cache in ( + self._cache.get(file_token, {}).items() + ): + old_assignee = assignee_cache.pop(record_id, None) + if old_assignee: + logger.info( + f"Record {record_id} deleted, " + f"notifying old assignee={old_assignee}" + ) + await self.notify_connections( + file_token, old_assignee, reason="deleted" + ) + continue + + fields = record.get("fields") or {} + + # Check each tracked assignee field for this file_token + file_cache = self._cache.setdefault(file_token, {}) + for assignee_field in list(file_cache.keys()): + current = self._extract_assignee(fields, assignee_field) + cached = file_cache[assignee_field].get(record_id) + + if current == cached: + logger.debug( + f"Record {record_id}: assignee unchanged ({current}) — skip" + ) + continue + + # Assignee changed — update cache and notify logger.info( - f" Action: {act_type} on record {record_id}" + f"Record {record_id}: assignee " + f"{cached or '(none)'} → {current or '(none)'}" ) - # Notify Bugger via localhost HTTP - await self._notify_bugger(payload) + if current: + file_cache[assignee_field][record_id] = current + else: + file_cache[assignee_field].pop(record_id, None) + + # Notify new assignee + if current: + await self.notify_connections( + file_token, current, reason="assigned" + ) + + # Notify old assignee (bug moved away) + if cached and cached != current: + await self.notify_connections( + file_token, cached, reason="unassigned" + ) except Exception as e: - logger.error(f"Error handling bitable change event: {e}", - exc_info=True) + logger.error(f"Error handling bitable change event: {e}", exc_info=True) - async def _notify_bugger(self, payload: Dict[str, Any]) -> None: - """Send wake-up signal to Bugger's localhost listener.""" - if not self._bugger_callback: - logger.warning( - "No Bugger callback URL configured — " - "event dropped. Bugger must register first." + async def _fetch_record( + self, file_token: str, table_id: str, + record_id: str, headers: Dict[str, str] + ) -> Optional[Dict[str, Any]]: + """Fetch a single Bitable record. Returns None if deleted.""" + try: + url = ( + f"{self._bot.base_url}/bitable/v1/apps/{file_token}" + f"/tables/{table_id}/records/{record_id}" ) + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=10.0) + data = response.json() + + if data.get("code") == 0: + return (data.get("data") or {}).get("record") + elif data.get("code") == 172001: # record not found / deleted + return None + else: + logger.warning(f"Failed to fetch record {record_id}: {data}") + return None + except Exception as e: + logger.error(f"Fetch record exception for {record_id}: {e}") + return None + + # ── Daily reconciliation ──────────────────────────────── + + async def start_reconciliation(self, interval_hours: int = 24): + """Start periodic full-pull reconciliation.""" + if self._reconcile_task: return - try: - async with httpx.AsyncClient() as client: - response = await client.post( - self._bugger_callback, - json=payload, - timeout=5.0 - ) - if response.status_code == 200: - logger.debug( - f"Notified Bugger: table={payload.get('table_id')}, " - f"revision={payload.get('revision')}" - ) - else: - logger.warning( - f"Bugger notification returned " - f"{response.status_code}" - ) - except httpx.ConnectError: - logger.debug("Bugger not reachable (app may be closed)") - except Exception as e: - logger.error(f"Failed to notify Bugger: {e}") -``` + async def _run(): + while True: + await asyncio.sleep(interval_hours * 3600) + await self._reconcile_all() -### 3.2 Modify `run_feishu_service.py` + self._reconcile_task = asyncio.create_task(_run()) + logger.info(f"Reconciliation task started (every {interval_hours}h)") -Add the Bitable event handler registration and initialize the service. - -**Add import (after existing imports, around line 17):** - -```python -from src.app.services.bitable_event_service import BitableEventService -``` - -**Add the event handler function (after `on_p2_card_action_trigger`, around line 302):** - -```python -# Initialize bitable event service (set after bot_service is created in main()) -bitable_event_service = None - - -def on_p2_drive_file_bitable_record_changed_v1( - data: P2DriveFileBitableRecordChangedV1 -): - """Handle Bitable record change events from WebSocket.""" - try: - logger.info("=" * 60) - logger.info("🔔 收到 Bitable 记录变更事件!") - logger.info(f"事件ID: {getattr(data.header, 'event_id', None)}") - logger.info(f"文件Token: {getattr(data.event, 'file_token', None)}") - logger.info(f"表格ID: {getattr(data.event, 'table_id', None)}") - logger.info(f"版本: {getattr(data.event, 'revision', None)}") - - # Process asynchronously - if bitable_event_service: - async def _handle(): - await bitable_event_service.handle_bitable_record_changed(data) - - task = asyncio.create_task(_handle()) - - def handle_exception(t): + async def _reconcile_all(self): + """Full pull for all active file_tokens, reconcile cache.""" + logger.info("Starting daily reconciliation...") + for file_token, field_caches in self._cache.items(): + for assignee_field, old_cache in field_caches.items(): try: - t.result() + new_cache = await self.warm_cache(file_token, assignee_field) + + # Find and notify transitions + all_ids = set(old_cache.keys()) | set(new_cache.keys()) + for record_id in all_ids: + old_val = old_cache.get(record_id) + new_val = new_cache.get(record_id) + if old_val != new_val: + logger.info( + f"Reconciliation: record {record_id} " + f"{old_val or '(none)'} → {new_val or '(none)'}" + ) + if new_val: + await self.notify_connections( + file_token, new_val, reason="reconciled" + ) + if old_val: + await self.notify_connections( + file_token, old_val, reason="reconciled" + ) + + # Update cache in place + self._cache[file_token][assignee_field] = new_cache + except Exception as e: - logger.error(f"Bitable事件处理异常: {e}", exc_info=True) - task.add_done_callback(handle_exception) - - logger.info("=" * 60) - - except Exception as e: - logger.error(f"处理 Bitable 变更事件出错: {e}", exc_info=True) + logger.error( + f"Reconciliation failed for {file_token}/{assignee_field}: {e}", + exc_info=True + ) + logger.info("Daily reconciliation complete") ``` -**Add import for the event model (add at top with other lark_oapi imports):** +### 3.2 New File: `src/app/api/endpoints/bitable_subscription.py` -```python -from lark_oapi.api.drive.v1.model.p2_drive_file_bitable_record_changed_v1 import ( - P2DriveFileBitableRecordChangedV1, -) -``` - -**Register the handler in `FeishuStreamManager.start()` (add after the card action registration block, around line 349):** - -```python - # 注册 Bitable 记录变更处理器 - if hasattr(builder, "register_p2_drive_file_bitable_record_changed_v1"): - builder = builder.register_p2_drive_file_bitable_record_changed_v1( - on_p2_drive_file_bitable_record_changed_v1 - ) - logger.info("已注册 bitable_record_changed 处理器") - else: - logger.warning( - "当前 SDK 版本不支持 register_p2_drive_file_bitable_record_changed_v1" - ) -``` - -**Initialize the bitable_event_service in `main()` (add after `bot_service = FeishuBotService()`, around line 426):** - -```python - # 初始化 Bitable 事件服务 - global bitable_event_service - bitable_event_service = BitableEventService(bot_service) -``` - -### 3.3 New File: `src/app/api/endpoints/bitable_subscription.py` - -Create a REST endpoint so Bugger can manage subscriptions: +The SSE endpoint that Bugger connects to: ```python """ -API endpoints for Bitable subscription management. -Called by Bugger to register/unregister tables for change notifications. +SSE endpoint for Bitable change notifications. +Bugger instances connect here to receive real-time wake-up signals. """ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from typing import Optional +import json +import asyncio import logging +from typing import Optional + +from fastapi import APIRouter, Query, Request +from fastapi.responses import StreamingResponse logger = logging.getLogger(__name__) @@ -461,383 +631,633 @@ router = APIRouter() bitable_event_service = None -class SubscribeRequest(BaseModel): - file_token: str - table_id: str = "" - bugger_callback: str = "" # e.g. "http://localhost:18924/bitable-event" - - -class SubscribeResponse(BaseModel): - status: str - file_token: str - error: Optional[str] = None - feishu_code: Optional[int] = None - feishu_msg: Optional[str] = None - fix: Optional[str] = None - - -@router.post("/subscribe", response_model=SubscribeResponse) -async def subscribe_bitable(req: SubscribeRequest): +@router.get("/events") +async def bitable_events( + request: Request, + file_token: str = Query(..., description="Bitable file token"), + assignee_field: str = Query(..., description="Field name for assignee"), + assignee_name: str = Query(..., description="This Bugger's assignee name"), + table_id: str = Query("", description="Table ID (for record fetch URL)"), +): """ - Subscribe to Bitable change events. - - Bugger calls this when a user enables real-time sync for a Bitable. - Returns success or a detailed permission error with fix instructions. + SSE endpoint. Bugger opens a long-lived connection here. + feishu-app pushes change events only when the assignee matches. """ if not bitable_event_service: - raise HTTPException(status_code=503, detail="Bitable service not initialized") + return StreamingResponse( + _error_stream("Bitable service not initialized"), + media_type="text/event-stream" + ) - result = await bitable_event_service.subscribe( - file_token=req.file_token, - table_id=req.table_id, - bugger_callback=req.bugger_callback + from src.app.services.bitable_event_service import BuggerSSEConnection + + conn = BuggerSSEConnection( + queue=asyncio.Queue(), + file_token=file_token, + assignee_field=assignee_field, + assignee_name=assignee_name, ) - return result + # Register connection + service = bitable_event_service + cache_ready = await service.add_connection(conn) -@router.post("/unsubscribe") -async def unsubscribe_bitable(req: SubscribeRequest): - """Unsubscribe from Bitable change events.""" - if not bitable_event_service: - raise HTTPException(status_code=503, detail="Bitable service not initialized") + # If cache not ready for this file_token/field, warm it + if not cache_ready: + logger.info(f"Cache cold for {file_token}/{assignee_field}, warming...") + await service.warm_cache(file_token, assignee_field) + else: + logger.info(f"Cache already warm for {file_token}/{assignee_field}") - result = await bitable_event_service.unsubscribe( - file_token=req.file_token + logger.info( + f"Bugger SSE connected: file={file_token}, " + f"assignee={assignee_name}, field={assignee_field}" ) - return result + async def event_stream(): + try: + # Send initial connected event + yield f"event: connected\ndata: {json.dumps({'status': 'ok'})}\n\n" -@router.get("/subscription/{file_token}") -async def get_subscription_status(file_token: str): - """Check subscription status for a Bitable.""" - if not bitable_event_service: - raise HTTPException(status_code=503, detail="Bitable service not initialized") + while True: + # Check client disconnect + if await request.is_disconnected(): + break - return await bitable_event_service.get_subscription_status(file_token) + try: + # Wait for events with heartbeat timeout + event = await asyncio.wait_for(conn.queue.get(), timeout=30.0) + yield f"event: {event['event']}\ndata: {event['data']}\n\n" + except asyncio.TimeoutError: + # Heartbeat + yield ": heartbeat\n\n" + except asyncio.CancelledError: + pass + finally: + service.remove_connection(conn) + logger.info( + f"Bugger SSE disconnected: file={file_token}, " + f"assignee={assignee_name}" + ) -@router.get("/subscriptions") -async def list_subscriptions(): - """List all active Bitable subscriptions.""" - if not bitable_event_service: - return {"subscriptions": {}} - - return { - "subscriptions": { - ft: { - "file_token": sub.file_token, - "table_id": sub.table_id, - } - for ft, sub in bitable_event_service._subscriptions.items() + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # disable nginx buffering } - } + ) + + +async def _error_stream(message: str): + yield f"event: error\ndata: {json.dumps({'error': message})}\n\n" +``` + +### 3.3 Modify `run_feishu_service.py` + +All changes are gated behind the feature flag. When disabled, the file is untouched at runtime. + +**Add import (after the existing `lark_oapi` imports, around line 10):** + +```python +if settings.bitable_change_notification_enabled: + from lark_oapi.api.drive.v1.model.p2_drive_file_bitable_record_changed_v1 import ( + P2DriveFileBitableRecordChangedV1, + ) +``` + +**Add import for the new service (after the existing service imports, around line 16):** + +```python +if settings.bitable_change_notification_enabled: + from src.app.services.bitable_event_service import BitableEventService +``` + +**Add the event handler function (after `on_p2_card_action_trigger`, around line 302):** + +```python +# Bitble change notification — only defined when feature is enabled +if settings.bitable_change_notification_enabled: + bitable_event_service = None + + + def on_p2_drive_file_bitable_record_changed_v1( + data: P2DriveFileBitableRecordChangedV1 + ): + """Handle Bitable record change events from WebSocket.""" + try: + logger.info("=" * 60) + logger.info("🔔 收到 Bitable 记录变更事件!") + evt = getattr(data, 'event', None) + header = getattr(data, 'header', None) + logger.info(f"事件ID: {getattr(header, 'event_id', None)}") + logger.info(f"文件Token: {getattr(evt, 'file_token', None)}") + logger.info(f"表格ID: {getattr(evt, 'table_id', None)}") + logger.info(f"版本: {getattr(evt, 'revision', None)}") + + action_list = getattr(evt, 'action_list', None) + if action_list: + for action in action_list: + logger.info( + f" Action: {getattr(action, 'action', 'unknown')} " + f"on record {getattr(action, 'record_id', None)}" + ) + + # Process asynchronously + if bitable_event_service: + async def _handle(): + await bitable_event_service.handle_bitable_record_changed(data) + + task = asyncio.create_task(_handle()) + + def handle_exception(t): + try: + t.result() + except Exception as e: + logger.error(f"Bitable事件处理异常: {e}", exc_info=True) + task.add_done_callback(handle_exception) + + logger.info("=" * 60) + + except Exception as e: + logger.error(f"处理 Bitable 变更事件出错: {e}", exc_info=True) +else: + # Feature disabled — no imports, no handler, no service reference + bitable_event_service = None +``` + +**Register the handler in `FeishuStreamManager.start()` (add after the card action registration block, around line 349):** + +```python + # 注册 Bitable 记录变更处理器(仅在启用时) + if settings.bitable_change_notification_enabled: + if hasattr(builder, "register_p2_drive_file_bitable_record_changed_v1"): + builder = builder.register_p2_drive_file_bitable_record_changed_v1( + on_p2_drive_file_bitable_record_changed_v1 + ) + logger.info("已注册 bitable_record_changed 处理器") + else: + logger.warning( + "当前 SDK 版本不支持 register_p2_drive_file_bitable_record_changed_v1" + ) +``` + +**Initialize the service in `main()` (add after `bot_service = FeishuBotService()`, around line 426):** + +```python + # 初始化 Bitable 事件服务(仅在启用时) + if settings.bitable_change_notification_enabled: + global bitable_event_service + bitable_event_service = BitableEventService(bot_service) + logger.info("✅ Bitable 事件服务已初始化") ``` ### 3.4 Modify `src/app/main.py` -Register the new router and wire up the service: +Register the SSE endpoint and wire the service — all gated behind the feature flag. **Add after existing router registrations (around line 94):** ```python -from src.app.api.endpoints import bitable_subscription - -# Register Bitable subscription endpoints -app.include_router( - bitable_subscription.router, - prefix="/api/v1/bitable", - tags=["bitable"] -) +# Register Bitable SSE endpoint (Bugger connects to this) — only when enabled +if settings.bitable_change_notification_enabled: + from src.app.api.endpoints import bitable_subscription + app.include_router( + bitable_subscription.router, + prefix="/api/v1/bitable", + tags=["bitable"] + ) ``` **Wire the service instance (add in `lifespan` startup, after `stream_manager.start()`):** ```python - # Wire bitable_event_service into the API router - from src.app.api.endpoints.bitable_subscription import \ - bitable_event_service as api_bitable_svc - # Both modules reference the same global from run_feishu_service - import run_feishu_service as feishu_ws - if feishu_ws.bitable_event_service: - bitable_subscription.bitable_event_service = \ - feishu_ws.bitable_event_service + # Wire bitable_event_service into the SSE router (only when enabled) + if settings.bitable_change_notification_enabled: + from run_feishu_service import bitable_event_service as global_svc + if global_svc: + bitable_subscription.bitable_event_service = global_svc + # Start daily reconciliation task + await global_svc.start_reconciliation(interval_hours=24) + logger.info("✅ Bitable event service wired and reconciliation started") ``` --- ## 4. Code Changes — Bugger (Swift / macOS) -### 4.1 New: `ConnectorListener` (Swift) +Repository: this project (`bugger/`) -A lightweight HTTP server that listens for wake-up signals from feishu-app: +### 4.1 New File: `Sources/Services/BitableEventService.swift` + +An SSE client that connects to feishu-app and triggers `fetchNow()` on change events. ```swift import Foundation -/// Receives Bitable change notifications from feishu-app via localhost HTTP. -/// Triggers immediate re-fetch for the changed table. -final class BitableEventListener { - static let shared = BitableEventListener() +/// Connects to feishu-app via SSE to receive Bitable change notifications. +/// Triggers PollerService.fetchNow() when the assignee changes. +final class BitableEventService { + static let shared = BitableEventService() - private let port: UInt16 = 18924 - private var server: HTTPServer? + private var task: URLSessionDataTask? + private var session: URLSession? + private var isConnected = false - func start() { - server = HTTPServer(port: port) - server?.onRequest = { [weak self] request in - self?.handleEvent(request) + private init() {} + + // MARK: - Public + + /// Start the SSE connection. Safe to call multiple times — no-op if already connected. + func connect() { + guard !isConnected else { return } + guard let url = buildURL() else { + BuggerLog.info("BitableEventService: feishuAppBaseURL not configured, skipping SSE") + return } - server?.start() - log.info("BitableEventListener started on port \(port)") + + isConnected = true + BuggerLog.info("BitableEventService: connecting to \(url.absoluteString)") + + let session = URLSession( + configuration: .default, + delegate: SSEDelegate(), + delegateQueue: nil + ) + self.session = session + + var request = URLRequest(url: url) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.timeoutInterval = TimeInterval(INT_MAX) // no timeout + + task = session.dataTask(with: request) + task?.resume() } - func stop() { - server?.stop() - server = nil + /// Disconnect the SSE stream. + func disconnect() { + task?.cancel() + task = nil + session?.invalidateAndCancel() + session = nil + isConnected = false + BuggerLog.info("BitableEventService: disconnected") } - private func handleEvent(_ request: HTTPRequest) -> HTTPResponse { - guard let body = request.body, - let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], - let tableId = json["table_id"] as? String else { - return HTTPResponse(statusCode: 400) + // MARK: - Private + + private func buildURL() -> URL? { + guard let config = AppStateService.shared.config, + config.isConfigured, + !config.feishuAppBaseURL.isEmpty else { + return nil } - // Extract wake-up signal - let fileToken = json["file_token"] as? String - let revision = json["revision"] as? Int + let assigneeName = config.assigneeName + ?? TokenManager.shared.persistedUserInfo?.name + ?? "" + let assigneeField = config.fieldMappings.assignee - log.info("🔔 Bitable change: table=\(tableId), revision=\(revision ?? 0)") - - // Trigger immediate re-fetch on main queue - DispatchQueue.main.async { - // Notify PollerService to fetch this table now - PollerService.shared.fetchNow(tableId: tableId, fileToken: fileToken) + guard !assigneeName.isEmpty, !assigneeField.isEmpty else { + BuggerLog.warning("BitableEventService: assignee name or field not configured") + return nil } - return HTTPResponse(statusCode: 200, body: "ok") + var components = URLComponents( + string: "\(config.feishuAppBaseURL)/api/v1/bitable/events" + )! + components.queryItems = [ + URLQueryItem(name: "file_token", value: config.appToken), + URLQueryItem(name: "table_id", value: config.tableId), + URLQueryItem(name: "assignee_field", value: assigneeField), + URLQueryItem(name: "assignee_name", value: assigneeName), + ] + return components.url } } -``` -### 4.2 Modify: `PollerService` (Swift) +// MARK: - URLSessionDataDelegate (SSE parsing) -Add a `fetchNow()` method for immediate refresh: +private final class SSEDelegate: NSObject, URLSessionDataDelegate { + private var buffer = "" -```swift -extension PollerService { - /// Immediately fetch a specific table (triggered by push notification). - /// The periodic poll interval continues for fallback coverage. - func fetchNow(tableId: String, fileToken: String? = nil) { - log.info("Push-triggered fetch for table: \(tableId)") + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + guard let chunk = String(data: data, encoding: .utf8) else { return } + buffer.append(chunk) - Task { - do { - try await fetchRecords(for: tableId) - // Update last-fetched timestamp in UI - await MainActor.run { - NotificationCenter.default.post( - name: .tableRefreshed, - object: nil, - userInfo: ["tableId": tableId, "source": "push"] - ) - } - } catch { - log.error("Push-triggered fetch failed for \(tableId): \(error)") - // PollerService will retry on its next cycle + // Parse SSE frames: lines ending with \n\n + while let range = buffer.range(of: "\n\n") { + let frame = String(buffer[.. SyncMode { - let feishuAppURL = "http://localhost:8000/api/v1/bitable/subscribe" - let buggerCallback = "http://localhost:18924/bitable-event" +// Add to the AppConfig struct: +var feishuAppBaseURL: String // e.g. "http://192.168.1.100:8000" +``` - let body: [String: Any] = [ - "file_token": fileToken, - "table_id": tableId, - "bugger_callback": buggerCallback - ] +Also update `isConfigured` to include this if SSE is the primary refresh mechanism (optional — polling still works without it): - do { - var request = URLRequest(url: URL(string: feishuAppURL)!) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) +```swift +// No change needed — isConfigured only requires appToken + tableId. +// SSE is an enhancement, not a requirement. +``` - let (data, response) = try await URLSession.shared.data(for: request) +### 4.3 Modify: `Sources/AppDelegate.swift` - guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let status = result["status"] as? String else { - return .pollOnly - } +Start the SSE connection on launch: - switch status { - case "subscribed": - log.info("✅ Real-time sync enabled for \(tableId)") - // Start the local listener if not already running - BitableEventListener.shared.start() - return .pushAndPoll // Real-time push + fallback polling - - case "permission_denied": - let fix = result["fix"] as? String ?? "" - log.warning("⚠️ Push unavailable: app needs document manager role. \(fix)") - // Show user-facing guidance - await showPermissionGuidance(fileToken: fileToken, fix: fix) - return .pollOnly - - default: - log.warning("Subscription returned: \(status)") - return .pollOnly - } - } catch { - log.warning("feishu-app not reachable, using poll-only mode: \(error)") - return .pollOnly - } +```swift +// In applicationDidFinishLaunching, after PollerService startup (around line 32): +Task { + await PollerService.shared.startIfConfigured() + BitableEventService.shared.connect() // SSE is non-blocking } +``` -enum SyncMode { - case pushAndPoll // Real-time push with periodic fallback polling - case pollOnly // Traditional polling only -} +### 4.4 Modify: `Sources/Views/Settings/SettingsView.swift` + +Add the feishu-app URL field to the settings UI. The user configures the feishu-app server address (e.g. `http://192.168.1.100:8000` for a shared deployment, or `http://localhost:8000` for local Docker). + +--- + +## 5. How Assignee Filtering Works (End-to-End) + +Given three developers — Alice, Bob, Carol — each running Bugger configured with their own name: + +``` +feishu-app cache (file_token=X, assignee_field="负责人"): + + rec_001 → "Alice" + rec_002 → "Bob" + rec_003 → "Carol" + rec_004 → "Bob" + +Bugger SSE connections active: + file_token=X, assignee_name=Alice (Alice's Bugger) + file_token=X, assignee_name=Bob (Bob's Bugger) + # Carol's Bugger is closed today +``` + +**Scenario 1: Tester changes priority of rec_001** + +``` +Event: record rec_001 changed +feishu-app: fetch rec_001 → assignee="Alice" +Cache: cached="Alice", current="Alice" → same → SKIP +Notifications: None. Zero Bugger fetches. +``` + +**Scenario 2: PM reassigns rec_001 from Alice → Bob** + +``` +Event: record rec_001 changed +feishu-app: fetch rec_001 → assignee="Bob" +Cache: cached="Alice", current="Bob" → changed! + → Update cache: rec_001 → "Bob" + → Notify Bob's SSE connection + → Notify Alice's SSE connection +Alice's Bugger: fetchNow() → Alice's bugs list → rec_001 gone ✓ +Bob's Bugger: fetchNow() → Bob's bugs list → rec_001 appears ✓ +``` + +**Scenario 3: New bug created, assigned to Carol** + +``` +Event: record rec_005 created +feishu-app: fetch rec_005 → assignee="Carol" +Cache: no entry for rec_005 → new record + → Update cache: rec_005 → "Carol" + → Notify Carol's SSE connection... but Carol's Bugger is closed! + → No notification delivered. + +Next morning (9:00): Carol opens Bugger → PollerService.fetchNow() runs immediately + → Carol sees the bug. + OR: daily reconciliation detects rec_005 as new → but Carol still offline → no-op. +``` + +**Scenario 4: Record deleted** + +``` +Event: record rec_003 deleted +feishu-app: fetch rec_003 → 404 +Cache: rec_003 → "Carol" (cached) + → Remove from cache + → Notify "Carol" connections +Carol's Bugger (when online): fetchNow() → rec_003 gone ✓ ``` --- -## 5. Testing +## 6. Config Changes Summary -### 5.1 Test feishu-app in Isolation +### feishu-app `config.py` — New Setting -```bash -# 1. Start feishu-app -cd feishu-app/feishu-service-python -python run_feishu_service.py +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `bitable_change_notification_enabled` | `bool` | `false` | Master feature flag. All resources are gated behind this. | -# 2. Verify the WebSocket handler is registered -# Look for log line: "已注册 bitable_record_changed 处理器" +### feishu-app `.env` — New Entry -# 3. Test subscribe API -curl -X POST http://localhost:8000/api/v1/bitable/subscribe \ - -H "Content-Type: application/json" \ - -d '{ - "file_token": "YOUR_BITABLE_FILE_TOKEN", - "table_id": "YOUR_TABLE_ID", - "bugger_callback": "http://localhost:18924/bitable-event" - }' - -# Expected success response: -# {"status":"subscribed","file_token":"YOUR_BITABLE_FILE_TOKEN"} - -# If permission denied: -# {"status":"permission_denied","feishu_code":1069603, -# "fix":"Open the Bitable → ... → 更多 → 添加文档应用 ..."} - -# 4. Check subscription list -curl http://localhost:8000/api/v1/bitable/subscriptions - -# 5. Make a change in the Bitable (add/edit a record) -# → Watch feishu-app logs for "🔔 收到 Bitable 记录变更事件!" - -# 6. Test Bugger notification -# Start a simple echo server to simulate Bugger: -python3 -c " -from http.server import HTTPServer, BaseHTTPRequestHandler -import json - -class H(BaseHTTPRequestHandler): - def do_POST(self): - length = int(self.headers['Content-Length']) - body = json.loads(self.rfile.read(length)) - print(f'🔔 Received: {json.dumps(body, indent=2)}') - self.send_response(200) - self.end_headers() - self.wfile.write(b'ok') - -HTTPServer(('localhost', 18924), H).serve_forever() -" +``` +# Enable Bitable change notification (SSE push to Bugger) +BITABLE_CHANGE_NOTIFICATION_ENABLED=false ``` -### 5.2 Test End-to-End with Bugger +### Bugger `AppConfig` — New Field -1. Start feishu-app (if not already running) -2. Start Bugger (debug build with `BitableEventListener`) -3. Configure a Bitable in Bugger — verify subscription succeeds -4. Edit a record in the Bitable via Feishu UI -5. Verify Bugger refreshes within seconds -6. Check Bugger logs for `"Push-triggered fetch for table"` +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `feishuAppBaseURL` | `String` | `""` | feishu-app server URL, e.g. `http://192.168.1.100:8000` | -### 5.3 Test Error Cases +When `feishuAppBaseURL` is empty, Bugger's SSE client treats it as disabled and never attempts a connection — no HTTP requests leave the Mac. + +### Bugger Settings UI — New Input + +Add a text field in Settings labeled "Feishu App URL" for the `feishuAppBaseURL` config value. + +--- + +## 7. Testing + +### 7.1 Test feishu-app in Isolation + +```bash +# 0. Enable the feature flag +export BITABLE_CHANGE_NOTIFICATION_ENABLED=true + +# 1. Start feishu-app +cd ../../aptsell/feishu-app/feishu-service-python +python run_feishu_service.py + +# 2. Verify the feature is loaded +# Look for log line: "✅ Bitable 事件服务已初始化" +# Look for log line: "已注册 bitable_record_changed 处理器" + +# 3. Test SSE endpoint with curl +curl -N "http://localhost:8000/api/v1/bitable/events?file_token=YOUR_TOKEN&assignee_field=负责人&assignee_name=张三&table_id=YOUR_TABLE_ID" + +# Expected: SSE stream with initial "connected" event +# event: connected +# data: {"status":"ok"} + +# 4. Make a change in the Bitable (change an assignee) +# → Watch the curl SSE stream for a "change" event + +# 5. Verify cache warm logged correctly +# Look for: "Cache warmed for ... (field=负责人): N records" +``` + +### 7.2 Test with Bugger + +1. Start feishu-app +2. Configure `feishuAppBaseURL` in Bugger Settings to `http://localhost:8000` +3. Configure a Bitable with the correct `appToken`, `tableId`, and assignee field mapping +4. Restart Bugger → check logs for `BitableEventService: connecting to ...` +5. Edit a record's assignee in Feishu → Bugger should refresh within seconds +6. Check Bugger logs for `"Bitable change push received, triggering fetchNow()"` + +### 7.3 Test Error Cases | Scenario | Expected Behavior | |----------|-------------------| -| feishu-app not running | Bugger falls back to poll-only, shows "feishu-app not reachable" | -| App not added to Bitable | Subscribe returns `permission_denied` with fix instructions | -| Bugger closed, event arrives | feishu-app logs "Bugger not reachable", event dropped (PollerService catches it) | -| WebSocket disconnect | feishu-app auto-reconnects via SDK; Bugger's PollerService covers the gap | -| Duplicate events | Redis-based dedup via `event_id` prevents double-notify | +| Feature flag disabled (default) | feishu-app starts normally. No Bitable imports, no Redis cache keys, no SSE endpoint, no WebSocket handler. Zero overhead. Bugger uses polling as always. | +| feishu-app not running | Bugger SSE fails to connect, retries every 30s. `PollerService` covers the gap. | +| App not added to Bitable | `_subscribe_to_file` logs error code 1069603. Admin must add the app as document manager. | +| Bugger closed | SSE connection dropped. feishu-app cleans up connection; daily reconciliation catches missed events. | +| WebSocket disconnect | feishu-app SDK auto-reconnects. Cache stays warm. Daily reconciliation fills any gaps. | +| Duplicate events | Redis-based dedup via `event_id` prevents double-processing. | +| SSE connection dies mid-stream | `URLSessionDataDelegate.didCompleteWithError` triggers auto-reconnect after 30s. | +| SSE endpoint called when flag disabled | Returns 404 (route not registered). | --- -## 6. Files Changed Summary +## 8. Files Changed Summary + +### feishu-app (separate repo: `../../aptsell/feishu-app/feishu-service-python/`) | File | Action | LOC | |------|--------|-----| -| `feishu-app/.../services/bitable_event_service.py` | **New** | ~170 | -| `feishu-app/.../endpoints/bitable_subscription.py` | **New** | ~65 | -| `feishu-app/run_feishu_service.py` | Modify | +30 | -| `feishu-app/src/app/main.py` | Modify | +10 | -| Bugger: `BitableEventListener.swift` | **New** | ~50 | -| Bugger: `PollerService.swift` | Modify | +25 | -| Bugger: Bitable setup flow | Modify | +40 | -| **Total** | | **~390 LOC** | +| `src/app/services/bitable_event_service.py` | **New** | ~280 | +| `src/app/api/endpoints/bitable_subscription.py` | **New** | ~80 | +| `src/app/config.py` | Modify | +3 | +| `run_feishu_service.py` | Modify | +45 | +| `src/app/main.py` | Modify | +12 | +| `.env` (or `.env.example`) | Modify | +2 | +| **Total (Python)** | | **~420 LOC** | + +### Bugger (this repo) + +| File | Action | LOC | +|------|--------|-----| +| `Sources/Services/BitableEventService.swift` | **New** | ~100 | +| `Sources/Models/AppConfig.swift` | Modify | +2 | +| `Sources/AppDelegate.swift` | Modify | +2 | +| `Sources/Views/Settings/SettingsView.swift` | Modify | +15 | +| **Total (Swift)** | | **~120 LOC** | --- -## 7. Deployment Considerations +## 9. Deployment Considerations -### 7.1 feishu-app Must Run Alongside Bugger +### 9.1 feishu-app Network Reachability -feishu-app is a Docker-based service. For Bugger users: -- If feishu-app is deployed on a **shared server** (current setup), Bugger connects to it over the network — update `feishuAppURL` from `localhost` to the server address -- If feishu-app is deployed **locally per user**, the docker-compose setup needs to run on each Mac +feishu-app is a Docker service. Bugger must be able to reach it over the network: -**For local deployment**, consider adding feishu-app's docker-compose to Bugger's DMG or providing a one-liner setup script. +- **Shared server deployment** (current): Bugger users configure `feishuAppBaseURL` to the server address (e.g. `http://10.0.1.50:8000`). SSE is outbound from Bugger → works through NAT. +- **Local Docker per user**: each developer runs feishu-app locally, `feishuAppBaseURL` = `http://localhost:8000`. -### 7.2 localhost HTTP Port Selection +Both work because Bugger initiates the connection. -| Port | Service | -|------|---------| -| `8000` | feishu-app FastAPI | -| `18924` | Bugger BitableEventListener | +### 9.2 SSE Connection Count -If port `18924` is taken, Bugger should try the next available port and update the `bugger_callback` URL in the subscribe request. +feishu-app holds one SSE connection per active Bugger instance. For a team of 20, that's up to 20 idle TCP connections — negligible for any modern server. -### 7.3 Security +### 9.3 Nginx / Reverse Proxy -The localhost HTTP channel between feishu-app and Bugger is loopback-only — no external access. If feishu-app runs on a remote server, add a shared secret in the callback payload for verification: +If feishu-app sits behind nginx, add these directives to prevent SSE buffering: -```python -# In bitable_event_service.py, add to payload: -payload["secret"] = settings.bugger_callback_secret +```nginx +location /api/v1/bitable/events { + proxy_pass http://feishu-app:8000; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; +} ``` +### 9.4 Security + +The SSE endpoint accepts query parameters with no authentication (v1). For production: +- Add a shared API token in `Settings` → validate via `Authorization` header +- Or restrict the feishu-app port to the VPN/office network only + --- -## 8. Limitations & Future Work +## 10. Limitations & Future Work | Limitation | Mitigation | |------------|------------| -| Formula field changes don't trigger events | Bugger's periodic poll catches these | -| Bulk imports may produce many events | Dedup by `event_id`; Bugger's fetchNow() is idempotent | -| App must be added per Bitable | One-time setup; Bugger shows clear fix instructions on failure | -| feishu-app downtime | PollerService is the universal fallback | -| No `before_value`/`after_value` delivered to Bugger | Wake-up signal is intentionally minimal; Bugger does full re-fetch | +| Formula field changes don't trigger events | `PollerService` periodic poll catches these | +| Bulk import may produce many events | Dedup by `event_id`; record fetches are idempotent | +| App must be added per Bitable | One-time setup; feishu-app logs clear error on failure | +| feishu-app downtime | `PollerService` is the universal fallback | +| Assignee field must be configured consistently | Bugger's existing `AppConfig.fieldMappings.assignee` provides this | +| Multi-field assignee detection | Cache is keyed per `{file_token, assignee_field}`; multiple field names can be tracked | +| Cache cold after feishu-app restart | SSE connections trigger cache warm on connect; reconciliation fills gaps |