docs: add Bitable change notification implementation guide and technical investigation
- TECH_INVESTIGATION.md: survey of Feishu event subscription options, WebSocket protocol details, architecture proposal with permission showstopper analysis for user-level tokens (~711 lines) - BITABLE_CHANGE_NOTIFICATION_IMPL.md: concrete implementation plan reusing existing feishu-app's app-level tenant_access_token to bypass the owner/manager subscribe restriction. Covers Feishu Open Platform config, feishu-app Python changes (~265 LOC), Bugger Swift changes (~115 LOC), and testing procedures. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3f1367790e
commit
0adfaf1d7e
|
|
@ -0,0 +1,843 @@
|
|||
# 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 |
|
||||
|
|
@ -0,0 +1,710 @@
|
|||
# Feishu Connector — Technical Investigation Report
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Goal:** Replace polling-based Bitable sync with event-driven (push) notifications from Feishu to Bugger.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
⚠️ **Conditionally feasible.** The best approach is a **WebSocket Long Connection** to Feishu's event platform, which does NOT require a public-facing server. A `feishu-connector` service can maintain this connection, listen for `drive.file.bitable_record_changed_v1` events, and notify the Bugger macOS app to refresh.
|
||||
|
||||
**Critical caveat:** Feishu only allows the Bitable **owner or manager** to subscribe to events (§7.1). For editor/viewer users, the connector cannot subscribe — Bugger falls back to polling for those Bitables. This is a **progressive enhancement** on top of polling, not a replacement.
|
||||
|
||||
**Phase 0 (before any code):** Verify the subscribe permission boundary with Bugger's target user roles. If all target users are editors only, pivot to improved polling (§8.2).
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State (Bugger)
|
||||
|
||||
Bugger currently polls Feishu's Bitable REST API:
|
||||
|
||||
```
|
||||
Bugger (macOS) ──[Timer: every N min]──▶ GET /bitable/v1/apps/{appToken}/tables/{tableId}/records
|
||||
│
|
||||
▼
|
||||
Feishu Cloud
|
||||
```
|
||||
|
||||
**Pain points:**
|
||||
- Latency up to the poll interval (default 5 min)
|
||||
- Wasted API calls when nothing changed
|
||||
- Timer-based, no real-time awareness
|
||||
|
||||
**Existing Bugger Feishu integration:**
|
||||
- OAuth 2.0 with `user_access_token` + `refresh_token`
|
||||
- `tenant_access_token` obtained via `app_id` + `app_secret`
|
||||
- `FeishuAuthService` already handles both token types
|
||||
- `FeishuService` already handles Bitable record fetching
|
||||
|
||||
---
|
||||
|
||||
## 2. Feishu Event Subscription Options
|
||||
|
||||
Feishu provides **three** mechanisms to receive Bitable change events:
|
||||
|
||||
### Option A: WebSocket Long Connection (⭐ Recommended)
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **URL** | `wss://open.feishu.cn/open-apis/ws/v1/events` |
|
||||
| **Public server required?** | ❌ No — client initiates outbound WebSocket |
|
||||
| **Auth** | One-time `tenant_access_token` at connection time |
|
||||
| **Protocol** | Custom Protobuf frame (`pbbp2.proto`) over binary WebSocket |
|
||||
| **SDKs available** | Go, Python, Java, Node.js (no official Swift SDK) |
|
||||
| **Connection limit** | 50 concurrent connections per app |
|
||||
| **Heartbeat** | Ping/Pong every ~120 seconds |
|
||||
| **Timeout** | 300s without any frame → reconnect |
|
||||
| **Reconnection** | Must implement exponential backoff manually |
|
||||
| **Deduplication** | Use `event_id` field; platform delivers at-least-once |
|
||||
|
||||
**How it works:**
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ feishu-connector │ │ Feishu Cloud │
|
||||
│ (macOS service) │ │ │
|
||||
│ │ 1. GET tenant_access_token │
|
||||
│ │──── HTTP POST ─────────▶│ /auth/v3/... │
|
||||
│ │◀─── {token} ───────────│ │
|
||||
│ │ │ │
|
||||
│ │ 2. WebSocket connect │ │
|
||||
│ │──── wss://open.feishu.cn/open-apis/ws/v1/events ──▶│
|
||||
│ │ │ │
|
||||
│ │ 3. Auth frame (PbFrame) │ │
|
||||
│ │──── {type:"auth", token}──▶│ │
|
||||
│ │◀─── ACK ───────────────│ │
|
||||
│ │ │ │
|
||||
│ │ 4. Subscribe to Bitable │ │
|
||||
│ │──── POST /drive/v1/files/{token}/subscribe ─▶│
|
||||
│ │ │ │
|
||||
│ │ 5. Events stream in │ │
|
||||
│ │◀── PbFrame {bitable_record_changed} ───────│
|
||||
│ │◀── PbFrame {bitable_record_changed} ───────│
|
||||
│ │ │ │
|
||||
│ │ 6. Ping/Pong keepalive │ │
|
||||
│ │◀──────▶─────────────────│ │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### Option B: Webhook (HTTP Callback)
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Public server required?** | ✅ Yes — must have a public HTTPS URL |
|
||||
| **Auth** | Manual signature verification + decryption on every request |
|
||||
| **Response** | HTTP 200 within 3 seconds |
|
||||
| **Retry** | 15s, 5min, 1hr, 6hr (max 4 retries) |
|
||||
|
||||
❌ **Not suitable** — Bugger is a personal macOS tool with no public server.
|
||||
|
||||
### Option C: Bitable Built-in Automation (No-Code)
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Setup** | Configure in Bitable UI: Automation → "When record changes" → "Send HTTP Request" |
|
||||
| **Public URL required?** | ✅ Yes — HTTP request target must be publicly reachable |
|
||||
| **Payload** | Customizable JSON with field value interpolation |
|
||||
| **Granularity** | Per-table, per-field conditions supported |
|
||||
|
||||
❌ **Not suitable** — still needs a public URL, and has limited payload control.
|
||||
|
||||
---
|
||||
|
||||
## 3. The Bitable Record Changed Event
|
||||
|
||||
**Event type:** `drive.file.bitable_record_changed_v1`
|
||||
|
||||
### Event Payload (key fields)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "f7984f25108f8137722bb63cee927e66",
|
||||
"event_type": "drive.file.bitable_record_changed_v1",
|
||||
"create_time": "1603977298000000",
|
||||
"token": "066zT6pS4QCbgj5Do145GfDbbagCHGgF",
|
||||
"app_id": "cli_xxxxxxxx",
|
||||
"tenant_key": "xxxxxxx"
|
||||
},
|
||||
"event": {
|
||||
"file_type": "bitable",
|
||||
"file_token": "bTkAbFdN...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"revision": 42,
|
||||
"operator_id": { "union_id": "...", "open_id": "..." },
|
||||
"action_list": [
|
||||
{
|
||||
"record_id": "recXXXXXXXX",
|
||||
"action": "record_edited",
|
||||
"before_value": { "field_id": "...", "field_value": "..." },
|
||||
"after_value": { "field_id": "...", "field_value": "..." }
|
||||
}
|
||||
],
|
||||
"subscriber_id_list": ["..."],
|
||||
"update_time": 1603977298
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Formula field changes do NOT trigger events**
|
||||
- **Must call subscribe API first:** `POST /open-apis/drive/v1/files/{file_token}/subscribe?file_type=bitable`
|
||||
- **Only document owner/manager can subscribe** — ⚠️ **SHOWSTOPPER** (see §7.1)
|
||||
- **App needs both app identity AND user identity permissions** for `bitable:app` or `drive:drive`
|
||||
- Events include `before_value` and `after_value` — useful for detecting what changed
|
||||
|
||||
### 3.1 Client-Side Event Filtering (Payload Reduction)
|
||||
|
||||
The raw `bitable_record_changed_v1` event includes full `before_value` / `after_value` for every changed field. For a large Bitable record, this can be several KB — enough to exceed `DistributedNotificationCenter`'s ~2 KB payload limit.
|
||||
|
||||
**Feishu does NOT support server-side field filtering** on the event subscription — the entire changed record is always delivered. However, the connector can filter client-side before notifying Bugger:
|
||||
|
||||
```
|
||||
Feishu ──[full event]──▶ Connector ──[filtered: only changed fields]──▶ Bugger
|
||||
```
|
||||
|
||||
**Filtering strategies (client-side, in `BitableEventHandler`):**
|
||||
|
||||
| Strategy | What Bugger Receives | Payload Reduction |
|
||||
|----------|---------------------|-------------------|
|
||||
| **Assignee-only** | Only `action_list` entries where the changed field is the assignee/person column | ~90%+ for typical records |
|
||||
| **Field whitelist** | Only `action_list` entries matching a configured set of field names/IDs | Configurable |
|
||||
| **Change summary** | `{table_id, record_id, revision, changed_fields: ["Assignee"]}` — no before/after values | ~95%+, fits easily in DNC |
|
||||
| **Wake-up signal only** | `{table_id, revision}` — Bugger does a full re-fetch | Minimal (~100 bytes), simplest |
|
||||
|
||||
**Recommendation:** Start with the **wake-up signal only** approach. It's the simplest, safest for DNC payload limits, and Bugger already knows how to fetch records. The connector just needs to say "table X changed, go fetch." This avoids the entire payload-size concern and keeps the notification channel trivial.
|
||||
|
||||
---
|
||||
|
||||
## 4. WebSocket Long Connection Protocol Details
|
||||
|
||||
### 4.1 Protobuf Frame Format (`pbbp2.proto`)
|
||||
|
||||
```protobuf
|
||||
message PbFrame {
|
||||
int32 method = 1; // 0 = CONTROL, 1 = DATA
|
||||
bytes payload = 2; // JSON string for DATA frames
|
||||
map<string, string> headers = 3; // message_id, seq, sum, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Connection Lifecycle
|
||||
|
||||
```
|
||||
1. Obtain tenant_access_token
|
||||
POST /open-apis/auth/v3/tenant_access_token/internal
|
||||
Body: { "app_id": "...", "app_secret": "..." }
|
||||
|
||||
2. Open WebSocket
|
||||
wss://open.feishu.cn/open-apis/ws/v1/events
|
||||
|
||||
3. Send Authentication Frame
|
||||
PbFrame { method=1, payload='{"type":"authentication","data":{"tenant_access_token":"..."}}' }
|
||||
|
||||
4. Send Subscribe API (over HTTP, NOT websocket)
|
||||
POST /open-apis/drive/v1/files/{file_token}/subscribe?file_type=bitable
|
||||
|
||||
5. Receive Events
|
||||
Binary frames arrive. Parse PbFrame → extract JSON payload → process event.
|
||||
|
||||
6. Heartbeat
|
||||
Server sends ping_interval. Client must send CONTROL frames at that interval.
|
||||
```
|
||||
|
||||
### 4.3 Fragment Reassembly
|
||||
|
||||
Large events may be split across multiple PbFrames. Headers provide:
|
||||
- `message_id` — groups fragments of the same logical message
|
||||
- `sum` — total fragment count
|
||||
- `seq` — 0-indexed fragment number
|
||||
|
||||
Client must buffer and reassemble by `message_id`.
|
||||
|
||||
### 4.4 Deduplication
|
||||
|
||||
Platform uses at-least-once delivery. Use `header.event_id` for idempotency.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture Proposal: feishu-connector
|
||||
|
||||
### 5.1 High-Level Design
|
||||
|
||||
**Key architectural principle: Polling is the primary path; push is a progressive enhancement.** The connector provides real-time notifications only for Bitables where the user has owner/manager permissions (§7.1). For all others, the existing `PollerService` remains the sole refresh mechanism. Bugger operates in a per-Bitable mixed mode.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ User's Mac │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ feishu-connector (new) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ WSClient │ │ EventDispatcher │ │ │
|
||||
│ │ │ (URLSession │──▶│ - dedup │ │ │
|
||||
│ │ │ WebSocket) │ │ - fragment merge │ │ │
|
||||
│ │ │ │ │ - filter: only │ │ │
|
||||
│ │ │ │ │ eligible tables │ │ │
|
||||
│ │ └──────────────┘ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌────────▼─────────┐ │ │
|
||||
│ │ │ LocalNotifier │ │ │
|
||||
│ │ │ (DNC wake-up │ │ │
|
||||
│ │ │ signal: ~100 B) │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ └──────────────────────────────┼──────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────┼──────────────┐ │
|
||||
│ │ Bugger (existing) │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌────────────────┐ ┌──────▼──────┐ │ │
|
||||
│ │ │ PollerService │ │ Connector │ │ │
|
||||
│ │ │ (PRIMARY path │ │ Listener │ │ │
|
||||
│ │ │ for ALL tables)│ │ (new) │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ Interval: │ │ On signal: │ │ │
|
||||
│ │ │ 5 min (no WS) │ │ fetchNow() │ │ │
|
||||
│ │ │ 15-30 min (WS │ │ │ │ │
|
||||
│ │ │ active) │ │ │ │ │
|
||||
│ │ └────────────────┘ └─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Per-Bitable mode: │ │
|
||||
│ │ ✅ owner/manager → WS push + polling │ │
|
||||
│ │ ❌ editor/viewer → polling only │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└──────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
│ wss://open.feishu.cn
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Feishu Cloud │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Component Breakdown
|
||||
|
||||
| Component | Language | Responsibility |
|
||||
|-----------|----------|----------------|
|
||||
| `feishu-connector` | Swift (macOS CLI/daemon) | WebSocket connection, event processing, local notification |
|
||||
| `WSClient` | Swift | Raw WebSocket + Protobuf frame encode/decode |
|
||||
| `EventDispatcher` | Swift | Dedup, merge fragments, route events to handlers |
|
||||
| `BitableEventHandler` | Swift | Parse `bitable_record_changed_v1`, filter to eligible tables only, emit wake-up signal |
|
||||
| `SubscriptionManager` | Swift | Call subscribe API, detect permission errors, maintain eligible-table whitelist |
|
||||
| `LocalNotifier` | Swift | Notify Bugger via DistributedNotificationCenter (wake-up signal: ~100 B) |
|
||||
| `Bugger Connector Listener` | Swift (in Bugger) | Receive wake-up signals, trigger `PollerService.fetchNow()` for the signaled table |
|
||||
| `Bugger Eligibility Check` | Swift (in Bugger) | On Bitable setup, test if subscribe succeeds; set per-table mode (push+poll vs. poll-only) |
|
||||
|
||||
### 5.3 Notification Channel Options (connector → Bugger)
|
||||
|
||||
| Method | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **DistributedNotificationCenter** | Native macOS, no socket, both Swift | No guaranteed delivery, ~2 KB payload limit |
|
||||
| **XPC Service** | Native, secure, lifecycle management | More complex setup, tightly coupled |
|
||||
| **localhost HTTP (e.g., :18924)** | Simple, debuggable, any language | Port management, local firewall issues |
|
||||
| **Unix Domain Socket** | Fast, secure, no port conflicts | Slightly more code |
|
||||
| **File watch / shared memory** | Simplest | Polling defeats the purpose |
|
||||
|
||||
**Recommendation: DistributedNotificationCenter with wake-up-signal pattern.**
|
||||
|
||||
The connector sends a minimal payload — just `{table_id, revision}` (~100 bytes) — and Bugger does a full re-fetch. This eliminates the payload-size concern entirely (§3.1). The signal is idempotent and loss-tolerant since Bugger's `PollerService` runs as a periodic fallback.
|
||||
|
||||
**Fallback:** If DNC proves unreliable in practice, switch to **localhost HTTP** on a fixed port. The wake-up-signal approach keeps the payload tiny regardless of transport.
|
||||
|
||||
### 5.4 Integration with Bugger's Existing Auth
|
||||
|
||||
Bugger already has:
|
||||
- `FeishuAuthService` — obtains `tenant_access_token`, handles OAuth
|
||||
- `TokenManager` — Keychain-backed token storage
|
||||
- `FeishuService` — Bitable API client
|
||||
|
||||
The connector can **reuse the same credentials** (app_id, app_secret from Info.plist) and obtain its own `tenant_access_token`. Alternatively, Bugger can share its token via Keychain (same access group).
|
||||
|
||||
### 5.5 Fallback Strategy
|
||||
|
||||
The connector handles **event loss gracefully**:
|
||||
|
||||
```
|
||||
Event received ──▶ Notify Bugger ──▶ Bugger.fetchNow()
|
||||
│
|
||||
If connector disconnects: │
|
||||
→ Bugger's PollerService continues │
|
||||
as periodic fallback │
|
||||
│
|
||||
When connector reconnects: │
|
||||
→ Full re-fetch to catch missed │
|
||||
events │
|
||||
```
|
||||
|
||||
Bugger's existing `PollerService` should remain as a fallback (e.g., every 15–30 min) to catch any events missed during connector downtime.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Complexity Assessment
|
||||
|
||||
### 6.1 Swift Protobuf Implementation
|
||||
|
||||
Feishu's WebSocket uses a custom binary protocol (`pbbp2.proto`). In Swift:
|
||||
|
||||
| Option | Effort | Risk |
|
||||
|--------|--------|------|
|
||||
| **SwiftProtobuf (Apple)** | Medium | Official, well-maintained. Need `.proto` → Swift codegen |
|
||||
| **Manual binary encode/decode** | High | Error-prone, but avoids dependency |
|
||||
| **Use `Codable` + manual frame parsing** | Medium | Protobuf wire format is straightforward for this simple schema |
|
||||
|
||||
The `PbFrame` schema is simple enough (3 fields: int32, bytes, map) that a manual binary encoder/decoder (~200 lines) may be viable and avoids a Protobuf dependency.
|
||||
|
||||
### 6.2 WebSocket in Swift
|
||||
|
||||
`URLSessionWebSocketTask` (iOS 13+ / macOS 10.15+) provides native WebSocket support:
|
||||
|
||||
```swift
|
||||
let session = URLSession(configuration: .default)
|
||||
let wsTask = session.webSocketTask(with: URL(string: "wss://open.feishu.cn/open-apis/ws/v1/events")!)
|
||||
wsTask.resume()
|
||||
|
||||
// Send binary
|
||||
let frame = try PbFrameEncoder.encode(authFrame)
|
||||
wsTask.send(.data(frame)) { error in ... }
|
||||
|
||||
// Receive
|
||||
func receiveNext() {
|
||||
wsTask.receive { result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .data(let data): handleBinaryFrame(data)
|
||||
case .string(let text): handleTextFrame(text)
|
||||
@unknown default: break
|
||||
}
|
||||
receiveNext() // Loop
|
||||
case .failure(let error): handleDisconnect(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Line Count Estimates
|
||||
|
||||
| Component | Estimated LOC | Complexity |
|
||||
|-----------|---------------|------------|
|
||||
| `PbFrame` encoder/decoder | ~150 | Medium |
|
||||
| `WSClient` (connect, auth, heartbeat, reconnect) | ~300 | Medium |
|
||||
| `EventDispatcher` (dedup, merge, route) | ~150 | Low |
|
||||
| `BitableEventHandler` (filter eligible tables, emit wake-up signal) | ~120 | Low |
|
||||
| `SubscriptionManager` (subscribe API, permission check, whitelist) | ~100 | Low |
|
||||
| `LocalNotifier` (DistributedNotificationCenter) | ~50 | Low |
|
||||
| `ConnectorDaemon` (main loop, signal handling) | ~100 | Low |
|
||||
| **feishu-connector total** | **~970** | |
|
||||
| Bugger: `ConnectorListener` | ~80 | Low |
|
||||
| Bugger: `EligibilityCheck` (test subscribe, set per-table mode) | ~80 | Low |
|
||||
| Bugger: Integration changes | ~100 | Low |
|
||||
| **Bugger changes total** | **~260** | |
|
||||
|
||||
### 6.4 Dependencies
|
||||
|
||||
| Dependency | Need |
|
||||
|------------|------|
|
||||
| `SwiftProtobuf` | Optional (manual encoder possible) |
|
||||
| `Foundation` (URLSession, DistributedNotificationCenter) | Built-in |
|
||||
| Third-party | None required |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & Mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| **Subscribe requires doc owner/manager** | 🔴 Critical | **Showstopper.** The subscribing user must be the Bitable owner or have manager permissions. If Bugger's user is a collaborator/editor only, the WebSocket approach is non-viable for that Bitable. See §7.1 for deep-dive and alternatives. |
|
||||
| **No official Swift SDK** | Medium | Protocol is documented; implement manually. PbFrame schema is simple. |
|
||||
| **Protobuf complexity** | Low | Use Apple's SwiftProtobuf, or hand-roll ~150 lines for the simple schema. |
|
||||
| **WebSocket disconnects** | Medium | Auto-reconnect with exponential backoff. Bugger's PollerService is fallback. |
|
||||
| **Event loss during disconnect** | Medium | On reconnect, trigger full re-fetch. PollerService runs as periodic safety net. |
|
||||
| **Feishu API changes** | Low | Protocol is stable. Event schema versioned (`schema: "2.0"`). |
|
||||
| **Connection limit (50/app)** | Low | Single-user tool — 1 connection. |
|
||||
| **Token expiry (2h)** | Low | Bugger already handles token refresh. Connector re-auths on WS reconnect. |
|
||||
| **App must be "published"** | Medium | Feishu requires app version publication before it can subscribe to events. Acceptable for internal tool, but adds setup friction. |
|
||||
| **macOS sandboxing** | Medium | If the connector ships inside Bugger's app bundle, the sandbox may block outbound WebSocket connections. Requires `com.apple.security.network.client` entitlement. If distributed outside the App Store (direct download), sandboxing may not apply. See §7.2. |
|
||||
|
||||
### 7.1 Deep-Dive: Subscribe Permission (Showstopper)
|
||||
|
||||
Feishu's event subscription API (`POST /drive/v1/files/{file_token}/subscribe`) enforces a hard permission check:
|
||||
|
||||
> **Only the document owner or users with manager-level permissions can subscribe to events on a Bitable.**
|
||||
|
||||
This is a **platform-level restriction** — there is no workaround, no admin override, and no scope escalation that bypasses it.
|
||||
|
||||
**Impact on Bugger users:**
|
||||
|
||||
| User's Bitable Role | Can Subscribe? | WebSocket Viable? |
|
||||
|---------------------|---------------|-------------------|
|
||||
| Owner | ✅ Yes | ✅ Full push notifications |
|
||||
| Manager | ✅ Yes | ✅ Full push notifications |
|
||||
| Editor | ❌ No | ❌ Must fall back to polling |
|
||||
| Viewer / Commenter | ❌ No | ❌ Must fall back to polling |
|
||||
|
||||
**What Bugger can do about it:**
|
||||
|
||||
1. **Detect at setup time.** When the user configures a Bitable in Bugger, call the subscribe API. If it returns a permission error, log it clearly and fall back to polling for that Bitable. Don't silently fail.
|
||||
|
||||
2. **Per-Bitable mode.** Bugger can support a mixed mode — WebSocket push for Bitables where the user is owner/manager, polling for others. The connector subscribes only to eligible Bitables.
|
||||
|
||||
3. **User-facing guidance.** If subscription fails, Bugger should tell the user exactly what's needed: *"To enable real-time sync, ask the Bitable owner to grant you manager permissions, or ask them to install Bugger and set up the connector."*
|
||||
|
||||
4. **Polling remains the universal fallback.** Even with the connector, Bugger's `PollerService` is the baseline that works for all permission levels. The connector is a progressive enhancement, not a replacement.
|
||||
|
||||
**Verdict:** This doesn't kill the project — it scopes it. The connector is a **best-effort push layer** on top of polling, not a replacement for it. The architecture must treat polling as the primary path and push as an optimization available to owner/manager users.
|
||||
|
||||
### 7.2 macOS Sandboxing Considerations
|
||||
|
||||
If `feishu-connector` is distributed as part of Bugger's app bundle (vs. a standalone CLI):
|
||||
|
||||
| Scenario | Sandbox Applied? | WebSocket Blocked? |
|
||||
|----------|------------------|---------------------|
|
||||
| **App Store distribution** | ✅ Mandatory | Needs `com.apple.security.network.client` entitlement (outbound only — granted by default in most templates) |
|
||||
| **Direct download + notarized** | ❌ Optional | No issue |
|
||||
| **Standalone CLI binary** | ❌ N/A | No issue |
|
||||
| **LaunchAgent daemon** | ❌ N/A | No issue |
|
||||
|
||||
**Required entitlements** (if sandboxed):
|
||||
```xml
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
Outbound WebSocket (client-initiated) is the least restricted network operation — this entitlement is included in Xcode's default sandbox template. It should not be a blocker.
|
||||
|
||||
**If the connector also listens on localhost** (for the localhost HTTP fallback), it additionally needs:
|
||||
```xml
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
**Recommendation:** Distribute the connector as a LaunchAgent daemon (via `.plist` in `~/Library/LaunchAgents/`), not embedded in the app bundle. This avoids sandboxing entirely and is the standard pattern for macOS background services. If it must ship inside the bundle, add the network client entitlement explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives Considered
|
||||
|
||||
### 8.1 Go/Python Sidecar
|
||||
|
||||
Run the Feishu WebSocket client in Go or Python (using official SDK), notifying Bugger via localhost HTTP.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Official SDK support | Two runtimes to manage |
|
||||
| Less protocol risk | Distribution complexity |
|
||||
| Faster to prototype | User must install Go/Python runtime |
|
||||
|
||||
Verdict: Viable fallback if pure Swift proves too difficult.
|
||||
|
||||
### 8.2 Improve Polling Instead
|
||||
|
||||
Shorten poll interval + use conditional requests (ETags / If-Modified-Since).
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Zero new infrastructure | Still polling |
|
||||
| Immediate improvement | Feishu Bitable API may not support conditional requests |
|
||||
| No Feishu platform config needed | Higher API call volume |
|
||||
|
||||
Verdict: Quick win, but doesn't solve the fundamental problem. Feishu Bitable List Records API does not support ETags.
|
||||
|
||||
### 8.3 Bitable Automation + ngrok
|
||||
|
||||
Use Bitable's built-in Automation to send HTTP requests to an ngrok tunnel → local server.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Zero code for Feishu integration | ngrok dependency |
|
||||
| Simple to configure | ngrok URL changes on restart |
|
||||
| | Fragile for a long-running tool |
|
||||
|
||||
Verdict: Good for prototyping, not for production.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendation
|
||||
|
||||
### Build `feishu-connector` as a Swift CLI/daemon with per-Bitable eligibility
|
||||
|
||||
**Guiding principle:** The connector is a **best-effort push layer** on top of polling — not a replacement. Polling remains the universal fallback for all Bitables. The connector accelerates refresh for Bitables where the user has owner/manager permissions.
|
||||
|
||||
**Phase 0 — Permission Feasibility Check (1 hour)** ⚠️ **Do this first.**
|
||||
1. Using Bugger's existing auth, call `POST /drive/v1/files/{file_token}/subscribe` for a test Bitable
|
||||
2. Verify the user role required (owner vs. manager vs. editor)
|
||||
3. If the test user (editor role) gets 403, confirm the permission boundary
|
||||
4. **Go/no-go:** If none of Bugger's target users are Bitable owners/managers, the WebSocket approach is non-viable — pivot to improved polling (§8.2) or Bitable Automation + ngrok (§8.3)
|
||||
|
||||
**Phase 1 — Prototype (1-2 days)**
|
||||
1. Implement `PbFrame` encoder/decoder in Swift
|
||||
2. Implement `WSClient` with auth + heartbeat + exponential backoff reconnect
|
||||
3. Implement `SubscriptionManager` — call subscribe API, detect permission errors, build eligible-table whitelist
|
||||
4. Implement `BitableEventHandler` — filter events to eligible tables, emit wake-up signal only
|
||||
5. Print received events to stdout for validation
|
||||
|
||||
**Phase 2 — Integration (1 day)**
|
||||
6. Implement `LocalNotifier` → Bugger via `DistributedNotificationCenter` (wake-up signal: `{table_id, revision}`)
|
||||
7. Add `ConnectorListener` to Bugger that triggers `fetchNow()` for the signaled table
|
||||
8. Add per-Bitable eligibility check in Bugger — test subscribe on setup, set mixed mode
|
||||
9. Keep `PollerService` as primary/fallback (5 min when no WS, 15–30 min when WS active)
|
||||
|
||||
**Phase 3 — Hardening (1-2 days)**
|
||||
10. Fragment reassembly
|
||||
11. Deduplication via `event_id`
|
||||
12. Graceful shutdown
|
||||
13. LaunchAgent `.plist` for auto-start (avoids sandboxing — see §7.2)
|
||||
14. Re-fetch on reconnect to catch missed events
|
||||
|
||||
**Total estimated effort: 3–5 days** (after Phase 0 passes)
|
||||
|
||||
### Quick Alternative
|
||||
|
||||
If the full Swift implementation is too heavy upfront, prototype with Python (`lark-oapi` SDK, ~50 lines) + localhost HTTP notification → Bugger. **Note:** The Python SDK handles Protobuf frames internally; use it for validation, then port to Swift once the approach is confirmed viable. See Appendix A for caveats about the spike code.
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- [Feishu Event Subscription Overview](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
|
||||
- [Bitable Record Changed Event](https://open.feishu.cn/document/docs/bitable-v1/events/bitable_record_changed)
|
||||
- [Subscribe Cloud Document Events API](https://open.feishu.cn/document/server-docs/docs/drive-v1/event/subscribe)
|
||||
- [Feishu WebSocket Long Connection Guide](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
|
||||
- [Bitable Automation: Send HTTP Request](https://feishu.feishu.cn/wiki/FmlgwagDKiVcTWkCjgxckMj9nTe)
|
||||
- [Apple SwiftProtobuf](https://github.com/apple/swift-protobuf)
|
||||
- [URLSessionWebSocketTask](https://developer.apple.com/documentation/foundation/urlsessionwebsockettask)
|
||||
- [Feishu Python SDK (lark-oapi)](https://pypi.org/project/lark-oapi/)
|
||||
- [Feishu Node.js SDK](https://www.npmjs.com/package/@larksuiteoapi/node-sdk)
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Spike Code — Python Prototype (~50 lines)
|
||||
|
||||
> ⚠️ **Caveat:** This is a quick validation spike only. It uses text-mode WebSocket (sends/receives JSON strings) for rapid prototyping. The **production Feishu WebSocket uses binary Protobuf frames** (`pbbp2.proto`), not JSON text frames. The real Swift implementation must handle binary frame encoding/decoding, fragment reassembly, and CONTROL vs DATA frame routing. Do not use this prototype as the basis for production code.
|
||||
|
||||
For quick validation before full Swift implementation:
|
||||
|
||||
```python
|
||||
# DISCLAIMER: Spike only — uses text WebSocket for fast prototyping.
|
||||
# Production must use binary Protobuf frames per §4.1.
|
||||
# Do not ship this.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import requests
|
||||
import sys
|
||||
|
||||
APP_ID = "cli_xxx"
|
||||
APP_SECRET = "xxx"
|
||||
FILE_TOKEN = "bTkAbFdN..." # Bitable token
|
||||
|
||||
def get_tenant_token():
|
||||
r = requests.post(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
json={"app_id": APP_ID, "app_secret": APP_SECRET}
|
||||
)
|
||||
return r.json()["tenant_access_token"]
|
||||
|
||||
def subscribe_bitable(token, file_token):
|
||||
r = requests.post(
|
||||
f"https://open.feishu.cn/open-apis/drive/v1/files/{file_token}/subscribe",
|
||||
params={"file_type": "bitable"},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
print(f"Subscribe: {r.status_code} {r.json()}")
|
||||
# Check for permission error — if 403, user is not owner/manager (§7.1)
|
||||
|
||||
async def main():
|
||||
token = get_tenant_token()
|
||||
subscribe_bitable(token, FILE_TOKEN)
|
||||
|
||||
async with websockets.connect(
|
||||
"wss://open.feishu.cn/open-apis/ws/v1/events"
|
||||
) as ws:
|
||||
# NOTE: Production uses binary PbFrame auth, not JSON text.
|
||||
auth = json.dumps({
|
||||
"type": "authentication",
|
||||
"data": {"tenant_access_token": token}
|
||||
})
|
||||
await ws.send(auth)
|
||||
print("Connected & authenticated")
|
||||
|
||||
async for raw in ws:
|
||||
# NOTE: Production receives binary PbFrame, not JSON text.
|
||||
event = json.loads(raw)
|
||||
event_type = event.get("header", {}).get("event_type", "")
|
||||
if "bitable_record_changed" in event_type:
|
||||
print(f"🔔 Bitable changed: {json.dumps(event, indent=2)}")
|
||||
# TODO: notify Bugger via localhost HTTP (wake-up signal only)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Appendix B: PbFrame Swift Encoder Sketch
|
||||
|
||||
```swift
|
||||
// Minimal Protobuf wire-format encoder for PbFrame
|
||||
// See: https://protobuf.dev/programming-guides/encoding/
|
||||
|
||||
struct PbFrame {
|
||||
enum Method: Int { case control = 0, data = 1 }
|
||||
let method: Method
|
||||
let payload: Data
|
||||
let headers: [String: String]
|
||||
}
|
||||
|
||||
enum PbFrameEncoder {
|
||||
static func encode(_ frame: PbFrame) -> Data {
|
||||
var data = Data()
|
||||
// Field 1: method (varint, wire type 0)
|
||||
data.append(encodeVarint(field: 1, wireType: 0))
|
||||
data.append(encodeVarint(UInt64(frame.method.rawValue)))
|
||||
// Field 2: payload (bytes, wire type 2)
|
||||
data.append(encodeVarint(field: 2, wireType: 2))
|
||||
data.append(encodeVarint(UInt64(frame.payload.count)))
|
||||
data.append(frame.payload)
|
||||
// Field 3: headers (map entries as repeated messages)
|
||||
for (key, value) in frame.headers {
|
||||
let entry = encodeMapEntry(key: key, value: value)
|
||||
data.append(encodeVarint(field: 3, wireType: 2))
|
||||
data.append(encodeVarint(UInt64(entry.count)))
|
||||
data.append(entry)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> PbFrame {
|
||||
var method = Method.control, payload = Data(), headers = [String: String]()
|
||||
var pos = 0
|
||||
while pos < data.count {
|
||||
let (fieldNum, wireType, adv) = try decodeVarint(data, pos: pos)
|
||||
pos += adv
|
||||
switch (fieldNum, wireType) {
|
||||
case (1, 0): // method
|
||||
let (v, adv) = try decodeVarint(data, pos: pos); pos += adv
|
||||
method = Method(rawValue: Int(v)) ?? .control
|
||||
case (2, 2): // payload
|
||||
let (len, adv) = try decodeVarint(data, pos: pos); pos += adv
|
||||
payload = data.subdata(in: pos..<pos+Int(len)); pos += Int(len)
|
||||
case (3, 2): // headers entry
|
||||
let (len, adv) = try decodeVarint(data, pos: pos); pos += adv
|
||||
let (k, v) = try decodeMapEntry(data.subdata(in: pos..<pos+Int(len)))
|
||||
headers[k] = v; pos += Int(len)
|
||||
default: throw DecodingError.dataCorrupted(...)
|
||||
}
|
||||
}
|
||||
return PbFrame(method: method, payload: payload, headers: headers)
|
||||
}
|
||||
|
||||
// ... encodeVarint, decodeVarint, encodeMapEntry, decodeMapEntry helpers
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Conclusion:** Building a Swift-native `feishu-connector` daemon using WebSocket long connection is feasible with ~970 lines of Swift code for the connector and ~260 lines of changes in Bugger, with no third-party dependencies. The approach provides real-time push notifications for Bitables where the user is owner/manager, while polling remains the universal fallback. **Prerequisite:** Verify the subscribe permission boundary (Phase 0) before committing to implementation.
|
||||
Loading…
Reference in New Issue