844 lines
30 KiB
Markdown
844 lines
30 KiB
Markdown
# Bitable Change Notification — Implementation Guide
|
|
|
|
**Date:** 2026-06-30
|
|
**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.
|
|
|
|
- **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
|
|
|
|
---
|
|
|
|
## 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() │
|
|
└───────────────┘
|
|
```
|
|
|
|
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)
|
|
|
|
---
|
|
|
|
## 2. Prerequisites — Feishu Open Platform Configuration
|
|
|
|
### 2.1 Verify/Create the App
|
|
|
|
The `feishu-app` service already uses:
|
|
- `FEISHU_APP_ID=cli_a7245c3f17745013` (from `.env.example`)
|
|
- `FEISHU_APP_SECRET` (configured in deployment)
|
|
- `VERIFICATION_TOKEN` (configured in deployment)
|
|
|
|
Confirm this app exists in the [Feishu Open Platform](https://open.feishu.cn/).
|
|
|
|
### 2.2 Add Required Permission Scope
|
|
|
|
The app needs **one** of the following scopes to call the subscribe API:
|
|
|
|
| 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 |
|
|
|
|
**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)
|
|
4. Add the permission
|
|
5. **Publish** a new app version (create version → publish) 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.
|
|
|
|
---
|
|
|
|
## 3. Code Changes — feishu-app (Python)
|
|
|
|
### 3.1 New File: `src/app/services/bitable_event_service.py`
|
|
|
|
Create a service to manage Bitable subscriptions and event handling:
|
|
|
|
```python
|
|
"""
|
|
Bitable change event service.
|
|
Manages subscriptions and dispatches change events to Bugger.
|
|
"""
|
|
import json
|
|
import logging
|
|
import httpx
|
|
from typing import Dict, Any, Optional, Set
|
|
from dataclasses import dataclass, field
|
|
|
|
from src.app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class BitableSubscription:
|
|
"""Tracks a subscribed Bitable table."""
|
|
file_token: str
|
|
table_id: str
|
|
subscribed_at: float # timestamp
|
|
|
|
|
|
class BitableEventService:
|
|
"""
|
|
Manages Bitable event subscriptions and notification to Bugger.
|
|
|
|
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
|
|
"""
|
|
|
|
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
|
|
|
|
# ── subscribe / unsubscribe ──────────────────────────────
|
|
|
|
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
|
|
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
|
|
)
|
|
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}
|
|
|
|
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"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 '可管理'")
|
|
}
|
|
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")
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Subscribe exception for {file_token}: {e}")
|
|
return {"status": "error", "file_token": file_token, "error": str(e)}
|
|
|
|
async def unsubscribe(self, file_token: str) -> Dict[str, Any]:
|
|
"""Unsubscribe from Bitable change events."""
|
|
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")}
|
|
|
|
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."""
|
|
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.get(url, headers=headers, timeout=10.0)
|
|
result = 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")
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Status check exception for {file_token}: {e}")
|
|
return {"status": "error", "file_token": file_token, "error": str(e)}
|
|
|
|
# ── 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.
|
|
"""
|
|
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}"
|
|
)
|
|
|
|
# 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}"
|
|
)
|
|
return
|
|
|
|
# Dedup via Redis (reuse existing bot service dedup mechanism)
|
|
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) # 1 hour TTL
|
|
|
|
# Build wake-up signal payload
|
|
payload = {
|
|
"file_token": file_token,
|
|
"table_id": table_id,
|
|
"revision": revision,
|
|
"timestamp": getattr(header, 'create_time', None)
|
|
}
|
|
|
|
# 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)
|
|
logger.info(
|
|
f" Action: {act_type} on record {record_id}"
|
|
)
|
|
|
|
# Notify Bugger via localhost HTTP
|
|
await self._notify_bugger(payload)
|
|
|
|
except Exception as e:
|
|
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."
|
|
)
|
|
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}")
|
|
```
|
|
|
|
### 3.2 Modify `run_feishu_service.py`
|
|
|
|
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):
|
|
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)
|
|
```
|
|
|
|
**Add import for the event model (add at top with other lark_oapi imports):**
|
|
|
|
```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:
|
|
|
|
```python
|
|
"""
|
|
API endpoints for Bitable subscription management.
|
|
Called by Bugger to register/unregister tables for change notifications.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Will be set by main.py on startup
|
|
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):
|
|
"""
|
|
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.
|
|
"""
|
|
if not bitable_event_service:
|
|
raise HTTPException(status_code=503, detail="Bitable service not initialized")
|
|
|
|
result = await bitable_event_service.subscribe(
|
|
file_token=req.file_token,
|
|
table_id=req.table_id,
|
|
bugger_callback=req.bugger_callback
|
|
)
|
|
return result
|
|
|
|
|
|
@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")
|
|
|
|
result = await bitable_event_service.unsubscribe(
|
|
file_token=req.file_token
|
|
)
|
|
return result
|
|
|
|
|
|
@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")
|
|
|
|
return await bitable_event_service.get_subscription_status(file_token)
|
|
|
|
|
|
@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()
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.4 Modify `src/app/main.py`
|
|
|
|
Register the new router and wire up the service:
|
|
|
|
**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"]
|
|
)
|
|
```
|
|
|
|
**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
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Code Changes — Bugger (Swift / macOS)
|
|
|
|
### 4.1 New: `ConnectorListener` (Swift)
|
|
|
|
A lightweight HTTP server that listens for wake-up signals from feishu-app:
|
|
|
|
```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()
|
|
|
|
private let port: UInt16 = 18924
|
|
private var server: HTTPServer?
|
|
|
|
func start() {
|
|
server = HTTPServer(port: port)
|
|
server?.onRequest = { [weak self] request in
|
|
self?.handleEvent(request)
|
|
}
|
|
server?.start()
|
|
log.info("BitableEventListener started on port \(port)")
|
|
}
|
|
|
|
func stop() {
|
|
server?.stop()
|
|
server = nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Extract wake-up signal
|
|
let fileToken = json["file_token"] as? String
|
|
let revision = json["revision"] as? Int
|
|
|
|
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)
|
|
}
|
|
|
|
return HTTPResponse(statusCode: 200, body: "ok")
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 Modify: `PollerService` (Swift)
|
|
|
|
Add a `fetchNow()` method for immediate refresh:
|
|
|
|
```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)")
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.3 Modify: Bitable Setup Flow (Swift)
|
|
|
|
When Bugger configures a Bitable, attempt to subscribe via feishu-app:
|
|
|
|
```swift
|
|
/// Called when user adds/configures a Bitable in Bugger.
|
|
/// Returns the sync mode for this Bitable.
|
|
func setupBitableNotification(fileToken: String, tableId: String) async -> SyncMode {
|
|
let feishuAppURL = "http://localhost:8000/api/v1/bitable/subscribe"
|
|
let buggerCallback = "http://localhost:18924/bitable-event"
|
|
|
|
let body: [String: Any] = [
|
|
"file_token": fileToken,
|
|
"table_id": tableId,
|
|
"bugger_callback": buggerCallback
|
|
]
|
|
|
|
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)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let status = result["status"] as? String else {
|
|
return .pollOnly
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
enum SyncMode {
|
|
case pushAndPoll // Real-time push with periodic fallback polling
|
|
case pollOnly // Traditional polling only
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Testing
|
|
|
|
### 5.1 Test feishu-app in Isolation
|
|
|
|
```bash
|
|
# 1. Start feishu-app
|
|
cd feishu-app/feishu-service-python
|
|
python run_feishu_service.py
|
|
|
|
# 2. Verify the WebSocket handler is registered
|
|
# Look for log line: "已注册 bitable_record_changed 处理器"
|
|
|
|
# 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()
|
|
"
|
|
```
|
|
|
|
### 5.2 Test End-to-End with Bugger
|
|
|
|
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"`
|
|
|
|
### 5.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 |
|
|
|
|
---
|
|
|
|
## 6. Files Changed Summary
|
|
|
|
| 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** |
|
|
|
|
---
|
|
|
|
## 7. Deployment Considerations
|
|
|
|
### 7.1 feishu-app Must Run Alongside Bugger
|
|
|
|
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
|
|
|
|
**For local deployment**, consider adding feishu-app's docker-compose to Bugger's DMG or providing a one-liner setup script.
|
|
|
|
### 7.2 localhost HTTP Port Selection
|
|
|
|
| Port | Service |
|
|
|------|---------|
|
|
| `8000` | feishu-app FastAPI |
|
|
| `18924` | Bugger BitableEventListener |
|
|
|
|
If port `18924` is taken, Bugger should try the next available port and update the `bugger_callback` URL in the subscribe request.
|
|
|
|
### 7.3 Security
|
|
|
|
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:
|
|
|
|
```python
|
|
# In bitable_event_service.py, add to payload:
|
|
payload["secret"] = settings.bugger_callback_secret
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 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 |
|