1264 lines
48 KiB
Markdown
1264 lines
48 KiB
Markdown
# Bitable Change Notification — Implementation Guide
|
|
|
|
**Date:** 2026-07-01
|
|
**Status:** Ready for implementation
|
|
|
|
---
|
|
|
|
## TL;DR
|
|
|
|
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.
|
|
|
|
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 │ (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 (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 the App
|
|
|
|
`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 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 and fetch records:
|
|
|
|
| Scope | Description |
|
|
|-------|-------------|
|
|
| `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 `bitable:app` (or `drive:drive`)
|
|
4. Add the permission
|
|
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.
|
|
|
|
### 2.3 Add App to Each Bitable
|
|
|
|
For each Bitable that Bugger should receive real-time events for:
|
|
|
|
1. Open the Bitable in Feishu
|
|
2. Click **"..."** (top-right menu) → **"更多"** → **"添加文档应用"**
|
|
3. Select the feishu-app from the list
|
|
4. Grant it **"可管理"** (manage) permission
|
|
|
|
This is a **one-time setup per Bitable**. Once added, the app can subscribe and receive events regardless of individual users' roles on that Bitable.
|
|
|
|
---
|
|
|
|
## 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`
|
|
|
|
The core service — manages the assignee cache, handles WebSocket events, and serves SSE connections.
|
|
|
|
```python
|
|
"""
|
|
Bitable change event service.
|
|
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 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 BuggerSSEConnection:
|
|
"""An SSE connection from a Bugger instance."""
|
|
queue: asyncio.Queue
|
|
file_token: str
|
|
assignee_field: str # e.g. "负责人", "assignee"
|
|
assignee_name: str # e.g. "张三"
|
|
|
|
|
|
class BitableEventService:
|
|
"""
|
|
Manages Bitable event subscriptions, assignee cache, and SSE fan-out.
|
|
|
|
Lifecycle:
|
|
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 → 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
|
|
|
|
# ── Feishu subscribe / unsubscribe (internal) ────────────
|
|
|
|
async def _subscribe_to_file(self, file_token: str) -> bool:
|
|
"""Call Feishu subscribe API to start receiving WebSocket events."""
|
|
try:
|
|
headers = await self._bot._get_headers()
|
|
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)
|
|
result = response.json()
|
|
|
|
if result.get("code") == 0:
|
|
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"❌ Permission denied for {file_token}. "
|
|
f"App must be added as document manager. "
|
|
f"Feishu msg: {result.get('msg')}"
|
|
)
|
|
return False
|
|
else:
|
|
logger.error(f"Subscribe failed for {file_token}: {result}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Subscribe exception for {file_token}: {e}")
|
|
return False
|
|
|
|
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:
|
|
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}")
|
|
|
|
# ── 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:
|
|
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=30.0)
|
|
data = response.json()
|
|
|
|
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"Cache warm exception for {file_token}: {e}", exc_info=True)
|
|
return cache
|
|
|
|
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.
|
|
Fetch changed record → diff assignee → notify only if assignee changed.
|
|
"""
|
|
try:
|
|
header = getattr(data, 'header', None)
|
|
event = getattr(data, 'event', None)
|
|
|
|
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: event_id={event_id}, "
|
|
f"file_token={file_token}, table_id={table_id}, revision={revision}"
|
|
)
|
|
|
|
if not file_token or file_token not in self._feishu_subscribed:
|
|
return
|
|
|
|
# Dedup via Redis
|
|
if event_id:
|
|
redis = await self._bot._get_redis_client()
|
|
dedup_key = f"feishu:bitable_event:{event_id}"
|
|
already_seen = await redis.get(dedup_key)
|
|
if already_seen:
|
|
logger.debug(f"Dedup: skipping duplicate event {event_id}")
|
|
return
|
|
await redis.set(dedup_key, "1", ex=3600)
|
|
|
|
# Process each changed record
|
|
action_list = getattr(event, 'action_list', None) or []
|
|
headers = await self._bot._get_headers()
|
|
|
|
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"Record {record_id}: assignee "
|
|
f"{cached or '(none)'} → {current or '(none)'}"
|
|
)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
async def _run():
|
|
while True:
|
|
await asyncio.sleep(interval_hours * 3600)
|
|
await self._reconcile_all()
|
|
|
|
self._reconcile_task = asyncio.create_task(_run())
|
|
logger.info(f"Reconciliation task started (every {interval_hours}h)")
|
|
|
|
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:
|
|
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"Reconciliation failed for {file_token}/{assignee_field}: {e}",
|
|
exc_info=True
|
|
)
|
|
logger.info("Daily reconciliation complete")
|
|
```
|
|
|
|
### 3.2 New File: `src/app/api/endpoints/bitable_subscription.py`
|
|
|
|
The SSE endpoint that Bugger connects to:
|
|
|
|
```python
|
|
"""
|
|
SSE endpoint for Bitable change notifications.
|
|
Bugger instances connect here to receive real-time wake-up signals.
|
|
"""
|
|
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__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Will be set by main.py on startup
|
|
bitable_event_service = None
|
|
|
|
|
|
@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)"),
|
|
):
|
|
"""
|
|
SSE endpoint. Bugger opens a long-lived connection here.
|
|
feishu-app pushes change events only when the assignee matches.
|
|
"""
|
|
if not bitable_event_service:
|
|
return StreamingResponse(
|
|
_error_stream("Bitable service not initialized"),
|
|
media_type="text/event-stream"
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
# Register connection
|
|
service = bitable_event_service
|
|
cache_ready = await service.add_connection(conn)
|
|
|
|
# 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}")
|
|
|
|
logger.info(
|
|
f"Bugger SSE connected: file={file_token}, "
|
|
f"assignee={assignee_name}, field={assignee_field}"
|
|
)
|
|
|
|
async def event_stream():
|
|
try:
|
|
# Send initial connected event
|
|
yield f"event: connected\ndata: {json.dumps({'status': 'ok'})}\n\n"
|
|
|
|
while True:
|
|
# Check client disconnect
|
|
if await request.is_disconnected():
|
|
break
|
|
|
|
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}"
|
|
)
|
|
|
|
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 SSE endpoint and wire the service — all gated behind the feature flag.
|
|
|
|
**Add after existing router registrations (around line 94):**
|
|
|
|
```python
|
|
# 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 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)
|
|
|
|
Repository: this project (`bugger/`)
|
|
|
|
### 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
|
|
|
|
/// 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 var task: URLSessionDataTask?
|
|
private var session: URLSession?
|
|
private var isConnected = false
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// Disconnect the SSE stream.
|
|
func disconnect() {
|
|
task?.cancel()
|
|
task = nil
|
|
session?.invalidateAndCancel()
|
|
session = nil
|
|
isConnected = false
|
|
BuggerLog.info("BitableEventService: disconnected")
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func buildURL() -> URL? {
|
|
guard let config = AppStateService.shared.config,
|
|
config.isConfigured,
|
|
!config.feishuAppBaseURL.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let assigneeName = config.assigneeName
|
|
?? TokenManager.shared.persistedUserInfo?.name
|
|
?? ""
|
|
let assigneeField = config.fieldMappings.assignee
|
|
|
|
guard !assigneeName.isEmpty, !assigneeField.isEmpty else {
|
|
BuggerLog.warning("BitableEventService: assignee name or field not configured")
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - URLSessionDataDelegate (SSE parsing)
|
|
|
|
private final class SSEDelegate: NSObject, URLSessionDataDelegate {
|
|
private var buffer = ""
|
|
|
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
|
|
didReceive data: Data) {
|
|
guard let chunk = String(data: data, encoding: .utf8) else { return }
|
|
buffer.append(chunk)
|
|
|
|
// Parse SSE frames: lines ending with \n\n
|
|
while let range = buffer.range(of: "\n\n") {
|
|
let frame = String(buffer[..<range.lowerBound])
|
|
buffer.removeSubrange(..<range.upperBound)
|
|
processFrame(frame)
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
|
didCompleteWithError error: Error?) {
|
|
if let error = error {
|
|
BuggerLog.warning("BitableEventService SSE error: \(error.localizedDescription)")
|
|
}
|
|
BitableEventService.shared.disconnect()
|
|
|
|
// Auto-reconnect after delay
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
|
|
BuggerLog.info("BitableEventService: attempting reconnect...")
|
|
BitableEventService.shared.connect()
|
|
}
|
|
}
|
|
|
|
private func processFrame(_ frame: String) {
|
|
var eventType = ""
|
|
var data = ""
|
|
|
|
for line in frame.components(separatedBy: "\n") {
|
|
if line.hasPrefix("event: ") {
|
|
eventType = String(line.dropFirst(7))
|
|
} else if line.hasPrefix("data: ") {
|
|
data = String(line.dropFirst(6))
|
|
}
|
|
}
|
|
|
|
switch eventType {
|
|
case "change":
|
|
BuggerLog.info("🔔 Bitable change push received, triggering fetchNow()")
|
|
Task { await PollerService.shared.fetchNow() }
|
|
|
|
case "error":
|
|
BuggerLog.warning("BitableEventService server error: \(data)")
|
|
|
|
case "connected", "heartbeat":
|
|
break // no-op
|
|
|
|
default:
|
|
break // unknown event, ignore
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 Modify: `Sources/Models/AppConfig.swift`
|
|
|
|
Add the `feishuAppBaseURL` field:
|
|
|
|
```swift
|
|
// Add to the AppConfig struct:
|
|
var feishuAppBaseURL: String // e.g. "http://192.168.1.100:8000"
|
|
```
|
|
|
|
Also update `isConfigured` to include this if SSE is the primary refresh mechanism (optional — polling still works without it):
|
|
|
|
```swift
|
|
// No change needed — isConfigured only requires appToken + tableId.
|
|
// SSE is an enhancement, not a requirement.
|
|
```
|
|
|
|
### 4.3 Modify: `Sources/AppDelegate.swift`
|
|
|
|
Start the SSE connection on launch:
|
|
|
|
```swift
|
|
// In applicationDidFinishLaunching, after PollerService startup (around line 32):
|
|
Task {
|
|
await PollerService.shared.startIfConfigured()
|
|
BitableEventService.shared.connect() // SSE is non-blocking
|
|
}
|
|
```
|
|
|
|
### 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 ✓
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Config Changes Summary
|
|
|
|
### feishu-app `config.py` — New Setting
|
|
|
|
| Field | Type | Default | Description |
|
|
|-------|------|---------|-------------|
|
|
| `bitable_change_notification_enabled` | `bool` | `false` | Master feature flag. All resources are gated behind this. |
|
|
|
|
### feishu-app `.env` — New Entry
|
|
|
|
```
|
|
# Enable Bitable change notification (SSE push to Bugger)
|
|
BITABLE_CHANGE_NOTIFICATION_ENABLED=false
|
|
```
|
|
|
|
### Bugger `AppConfig` — New Field
|
|
|
|
| Field | Type | Default | Description |
|
|
|-------|------|---------|-------------|
|
|
| `feishuAppBaseURL` | `String` | `""` | feishu-app server URL, e.g. `http://192.168.1.100:8000` |
|
|
|
|
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 |
|
|
|----------|-------------------|
|
|
| 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). |
|
|
|
|
---
|
|
|
|
## 8. Files Changed Summary
|
|
|
|
### feishu-app (separate repo: `../../aptsell/feishu-app/feishu-service-python/`)
|
|
|
|
| File | Action | 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** |
|
|
|
|
---
|
|
|
|
## 9. Deployment Considerations
|
|
|
|
### 9.1 feishu-app Network Reachability
|
|
|
|
feishu-app is a Docker service. Bugger must be able to reach it over the network:
|
|
|
|
- **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`.
|
|
|
|
Both work because Bugger initiates the connection.
|
|
|
|
### 9.2 SSE Connection Count
|
|
|
|
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.
|
|
|
|
### 9.3 Nginx / Reverse Proxy
|
|
|
|
If feishu-app sits behind nginx, add these directives to prevent SSE buffering:
|
|
|
|
```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
|
|
|
|
---
|
|
|
|
## 10. Limitations & Future Work
|
|
|
|
| Limitation | Mitigation |
|
|
|------------|------------|
|
|
| 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 |
|