34 KiB
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_tokenobtained viaapp_id+app_secretFeishuAuthServicealready handles both token typesFeishuServicealready 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)
{
"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:appordrive:drive - Events include
before_valueandafter_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)
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 messagesum— total fragment countseq— 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— obtainstenant_access_token, handles OAuthTokenManager— Keychain-backed token storageFeishuService— 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:
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:
-
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.
-
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.
-
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."
-
Polling remains the universal fallback. Even with the connector, Bugger's
PollerServiceis 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):
<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:
<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.
- Using Bugger's existing auth, call
POST /drive/v1/files/{file_token}/subscribefor a test Bitable - Verify the user role required (owner vs. manager vs. editor)
- If the test user (editor role) gets 403, confirm the permission boundary
- 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)
- Implement
PbFrameencoder/decoder in Swift - Implement
WSClientwith auth + heartbeat + exponential backoff reconnect - Implement
SubscriptionManager— call subscribe API, detect permission errors, build eligible-table whitelist - Implement
BitableEventHandler— filter events to eligible tables, emit wake-up signal only - 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
- Bitable Record Changed Event
- Subscribe Cloud Document Events API
- Feishu WebSocket Long Connection Guide
- Bitable Automation: Send HTTP Request
- Apple SwiftProtobuf
- URLSessionWebSocketTask
- Feishu Python SDK (lark-oapi)
- Feishu Node.js 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:
# 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
// 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.