30 KiB
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() │
└───────────────┘
- User edits a Bitable record
- Feishu pushes
drive.file.bitable_record_changed_v1event via WebSocket tofeishu-app feishu-apphandler filters to registered tables, extracts{file_token, table_id, revision}feishu-appPOSTs wake-up signal to Bugger's localhost listener- Bugger triggers
fetchNow()for that table - Bugger's
PollerServiceremains 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.
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:
- Go to Feishu Open Platform → Apps → your app
- Permissions (权限管理) → Add Permission (添加权限)
- Search for
drive:drive(ordocs:event:subscribefor minimal scope) - Add the permission
- 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:
- Open the Bitable in Feishu
- Click "..." (top-right menu) → "更多" → "添加文档应用"
- Select the feishu-app from the list
- 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:
"""
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):
from src.app.services.bitable_event_service import BitableEventService
Add the event handler function (after on_p2_card_action_trigger, around line 302):
# 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):
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):
# 注册 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):
# 初始化 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:
"""
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):
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()):
# 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:
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:
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:
/// 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
# 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
- Start feishu-app (if not already running)
- Start Bugger (debug build with
BitableEventListener) - Configure a Bitable in Bugger — verify subscription succeeds
- Edit a record in the Bitable via Feishu UI
- Verify Bugger refreshes within seconds
- 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
feishuAppURLfromlocalhostto 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:
# 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 |