Skip to content

feat(sync): live gateway WS tap captures inbound channel messages (Telegram, Signal, Slack, ...)#1213

Merged
vivekchand merged 1 commit into
mainfrom
feat/gateway-ws-tap-inbound-channels
May 14, 2026
Merged

feat(sync): live gateway WS tap captures inbound channel messages (Telegram, Signal, Slack, ...)#1213
vivekchand merged 1 commit into
mainfrom
feat/gateway-ws-tap-inbound-channels

Conversation

@vivekchand

Copy link
Copy Markdown
Owner

"ideally brain tab should show from duckdb & duckdb should be updated for every conversation with OpenClaw from any of the channels"
— user, 2026-05-14

Status: PAUSED on upstream — NOT a [RELEASE] PR

Per the failure-mode escape clause in the task brief: the OSS gateway's token-auth handshake currently grants scopes=[], so the WS tap can connect but cannot call sessions.messages.subscribe (the only method that delivers inbound message bodies). Filed openclaw/openclaw#81775, referencing the canonical fix RFE openclaw/openclaw#80836 (gateway.auth.tokenScopes).

The code is correct, fully tested, and ready to ship the moment OpenClaw lands #80836 (or an equivalent way to grant operator.read to the loopback CLI token non-interactively). I dropped the [RELEASE] prefix so the auto-publish workflow does not ship a tap that can't actually capture anything to PyPI users today.

Architecture summary

clawmetry/gateway_tap.py is a single background thread the sync daemon spawns at startup. It:

  1. Detects gateway URL + token from OPENCLAW_GATEWAY_TOKEN env or ~/.openclaw/openclaw.json (mirrors dashboard._detect_gateway_token so the daemon doesn't have to import Flask).
  2. Opens a persistent WebSocket to ws://127.0.0.1:18789/, runs the OpenClaw protocol-3 connect handshake, then attempts sessions.messages.subscribe.
  3. For every pushed event frame, _normalize_frame() projects it into the same (events_row, channel_messages_row) shape the existing JSONL parser produces (clawmetry/sync.py:_parse_channel_event) — so brain-history reads from DuckDB don't have to know which transport delivered the row.
  4. Writes through local_store.ingest_many() (events) + ingest_channel_message() (channel_messages). The body-bearing WS-tap row beats the gateway-log parser's NULL-body ACK row via the existing COALESCE(excluded.body, channel_messages.body) upsert — so when both transports observe the same (provider, chat_id, message_id), the body sticks.
  5. Wraps the whole loop in exponential backoff (2s → 60s) so any disconnect / gateway restart / scope rejection retries cleanly without crashing the daemon.

Wired into clawmetry/sync.py:run_daemon() right after the local_query server starts. Default-ON (per MOAT mandate feedback_local_store_default_off_killed_moat); opt out with CLAWMETRY_DISABLE_WS_TAP=1. Channel coverage matches _CHANNEL_DIRS (21 adapters) — the gateway-side subscribe is global rather than per-channel, but the list is documentation for any future per-channel API.

Limitations

  • Inbound messages received while the daemon was offline are lost forever — OpenClaw has no replay buffer for in-memory channel state (openclaw/openclaw#42157). Real fix is OpenClaw upstream persistence.
  • Live capture currently blocked on scope-grant gap. With OpenClaw 2026.5.7's default token-auth, connect succeeds but returns auth.scopes = []. Every method that delivers message bodies (sessions.messages.subscribe, sessions.subscribe, logs.tail, channels.status) then 401s with missing scope: operator.read. Filed openclaw/openclaw#81775, referencing canonical fix openclaw/openclaw#80836. Tap logs a single WARNING with the exact symptom and downgrades to a degraded-but-stable state (catches health snapshots only).
  • health events from the gateway also stream over the same socket — we drop them in _normalize_frame() since the dedicated gateway.metric ingest path already covers them. No double-write.
  • websocket-client is a soft dependency: if not installed, the tap logs a one-shot warning and the daemon's gateway-log parser path keeps running unchanged.

Verification

Mocked WS server (the only path that works today, pre-upstream-fix)

$ pytest tests/test_gateway_tap.py -v
============================== 11 passed in 1.69s ==============================

  test_normalize_telegram_inbound                          PASSED
  test_normalize_outbound_inferred_from_event_name          PASSED
  test_normalize_health_frame_returns_none                  PASSED
  test_normalize_epoch_seconds_coerced_to_iso               PASSED
  test_handle_frame_writes_event_and_channel_message        PASSED
  test_handle_frame_drops_unparseable_frame                 PASSED
  test_ws_tap_body_overwrites_parser_null_body              PASSED
  test_full_loop_against_stubbed_websocket                  PASSED
  test_disable_env_var_short_circuits_start                 PASSED
  test_detect_gateway_endpoint_reads_openclaw_json          PASSED
  test_detect_gateway_endpoint_returns_none_when_no_config  PASSED

test_full_loop_against_stubbed_websocket is the headline E2E: it injects a fake websocket module into sys.modules, drives GatewayTap.start() against a scripted responder that mints a connect-ok then a sub-ok then a real Telegram inbound payload, then asserts a row appears in channel_messages with the body intact.

Real Telegram message → DuckDB (post-upstream-fix)

Once openclaw/openclaw#80836 lands and ~/.openclaw/openclaw.json carries gateway.auth.tokenScopes: ["operator.read"]:

  1. Restart clawmetry sync and check ~/.clawmetry/sync.log for:
    INFO gateway WS tap connected — capturing live channel events
    
    (vs the current connected (degraded — no message subscribe; granted scopes=[])).
  2. Send a Telegram message to the OpenClaw bot.
  3. Within ~1 sec, the body should appear at the head of /api/brain-history and via direct query:
    SELECT ts, provider, channel_id, direction, body
    FROM channel_messages
    WHERE provider = 'telegram'
    ORDER BY ts DESC LIMIT 5;
  4. Stop the daemon, send another message (it'll be lost — see Limitations), restart, send a third — the third one should land. The second never will until OpenClaw adds replay (#42157).

Files touched

  • clawmetry/gateway_tap.py (new, ~430 LOC) — the WS tap module
  • clawmetry/sync.py (+13 LOC) — startup wiring inside run_daemon
  • tests/test_gateway_tap.py (new, ~340 LOC) — 11 unit + integration tests

Connection status (in this worktree)

  • Code: ready, all 11 tests pass.
  • Real OpenClaw gateway in this dev environment: tap connects (handshake ok) but auth.scopes = [] rejects every message subscribe — verified by /tmp/probe_gateway_ws.py. Mock-only verification.

🤖 Generated with Claude Code

OpenClaw stores Telegram + sibling chat-channel adapters' conversations
entirely in memory — no JSONL on disk for inbound, gateway.log only
carries outbound sendMessage ACKs without bodies. Result: ClawMetry's
Brain tab silently misses every inbound message, and channel_messages
contains only half of every conversation.

This adds clawmetry/gateway_tap.py — a background WS subscriber that
connects to ws://localhost:18789, calls sessions.messages.subscribe,
normalizes pushed event frames into (events, channel_messages) rows,
and upserts them into local DuckDB. The body-bearing WS-tap row beats
the parser's NULL-body ACK row via the COALESCE upsert in
ingest_channel_message — so when both transports observe the same
message_id the body sticks.

Wired into clawmetry/sync.py:run_daemon as a default-ON background
thread (per the MOAT mandate). Opt out with CLAWMETRY_DISABLE_WS_TAP=1.

Tests in tests/test_gateway_tap.py drive the full receive loop against
a stubbed websocket-client module — no real network — and assert the
inbound frame round-trips into DuckDB.

NOTE: Live connection currently can't subscribe to message events
because the default OpenClaw token-auth handshake grants scopes=[]
(missing scope: operator.read). Filed openclaw/openclaw#81775
referencing #80836 (RFE: gateway.auth.tokenScopes). PR is intentionally
NOT a [RELEASE] — paused for the upstream scope-grant fix before
shipping to PyPI users.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@vivekchand
vivekchand merged commit d7a5c95 into main May 14, 2026
16 checks passed
@vivekchand
vivekchand deleted the feat/gateway-ws-tap-inbound-channels branch May 14, 2026 09:41
@vivekchand

Copy link
Copy Markdown
Owner Author

E2E verified end-to-end against the Brain tab in headless chromium — passes.

Setup: isolated venv at /tmp/cm-e2e-brain/, port 8901, fake api_key, worktree off main with #1212 and #1213 merged. Daemon read user's real ~/.openclaw/.

The gateway WS tap in this PR connected successfully:

gateway WS tap connected (degraded -- no message subscribe; granted scopes=[])

It surfaced the OpenClaw scope gap as designed:

gateway WS tap: sessions.messages.subscribe rejected (missing scope: operator.read) -- inbound channel bodies will not arrive until OpenClaw grants `operator.read` scope to the gateway token. See openclaw/openclaw issue tracker.

API & UI both render the events that are reachable today:

total items: 38
telegram items: 16    (all outbound, via gateway.log parser from #1212)
openclaw message items: 6  (TUI sessions ingested as JSONL)

Tap doesn't crash, doesn't spam, fails open with a single warning. Ready.

One P1 follow-up surfaced unrelated to this PR: collapsed-run row had a stale escHtml double-escape — fixed in #1215.

vivekchand added a commit that referenced this pull request May 14, 2026
…1215)

E2E playwright test against the Brain tab caught literal
`<span style="opacity:0.45;font-size:10px;margin-right:3px;">13 May</span>`
text appearing in the collapsed-outbound row's time range.

`formatBrainTime()` returns trusted HTML (wraps the date prefix in a
styled span), so wrapping `rangeLabel` in `escHtml()` turned that
markup into visible text. Drop the escape — the helper output is
trusted and the literal " → " separator carries no user input.

Caught during full E2E verification of #1212 + #1213.
@vivekchand

Copy link
Copy Markdown
Owner Author

UX review

  • Diff is purely backend (new gateway_tap.py + sync wiring + tests). No user-visible UI surface in this PR. The user-visible WIN — inbound Telegram messages appear on the Brain tab — is gated on the upstream operator.read scope grant, which means today this ships infrastructure that does nothing observable. Action: when the upstream lands, do a browser walkthrough — verify the Brain tab actually surfaces inbound rows with direction=in and the renderer doesn't accidentally treat them as outbound (the channel.in event type may need explicit handling in routes/brain.py).
  • 🟡 P1 — Single user-facing surface IS the log line emitted on degraded mode: gateway WS tap connected (degraded — no message subscribe; granted scopes=[]). A non-technical OSS user reading ~/.clawmetry/sync.log and seeing this has zero idea what to do. The log line should include a one-line action: See https://docs.clawmetry.com/troubleshoot/gateway-scopes for the fix — and that doc page should explain the OpenClaw-side config tweak in plain English. Action: clawmetry/gateway_tap.py:_run_once — append actionable URL to the degraded-mode warning.

@vivekchand

Copy link
Copy Markdown
Owner Author

Product review

  • 🔴 P0 — We're shipping 1,138 LOC of new code (gateway_tap.py + tests + wiring) that literally does not work end-to-end today — the WS tap connects, gets scopes=[], logs a degraded warning, and writes zero rows. First-principles ask: should this PR be merged at all before RFE: gateway.auth.tokenScopes config field for headless/single-tenant deployments openclaw/openclaw#80836 lands? The author dropped [RELEASE] so it won't ship to PyPI users — good — but it's still default-ON in the daemon and will spam every dev's sync.log with "degraded" warnings every reconnect cycle. Three options: (a) merge but DEFAULT-OFF until upstream unblocks, (b) hold the PR until upstream merges, (c) merge as-is and accept the log noise. Recommendation: switch the default to OFF (opt-in via CLAWMETRY_ENABLE_WS_TAP=1), flip to default-ON in a follow-up PR the same day OpenClaw lands the scope grant. This violates feedback_local_store_default_off_killed_moat only on paper — that rule was about user-facing fast-paths that silently bypass; here, default-OFF is the right call because default-ON does NOTHING USEFUL. Action: change the env-var default to opt-in, document the flip-day plan in the PR description.
  • 🟡 P1 — Three competing channel-event ingest paths now exist (JSONL parser, gateway-log tail, WS tap). Same projection logic re-implemented in each. PR [RELEASE] fix(sync): telegram events parsed but never reach DuckDB (P0) #1212 review opened a follow-up to consolidate into LocalStore.ingest_channel_event(). THIS PR should be the forcing function — the WS-tap path is the third copy, and PR [RELEASE] fix(sync): telegram events parsed but never reach DuckDB (P0) #1212's helper is the second. Land feat(sync): live gateway WS tap captures inbound channel messages (Telegram, Signal, Slack, ...) #1213 against the consolidated API, not parallel to it. Action: gate this PR on the consolidation (issue from [RELEASE] fix(sync): telegram events parsed but never reach DuckDB (P0) #1212 review), or commit to a same-week refactor PR.
  • 🟡 P1 — Backoff is 2s → 60s exponential with no jitter. If the gateway dies and 100 OSS daemons reconnect, they all hit the gateway at the same wall-clock (assuming clock-aligned starts). Add small jitter to _BACKOFF_INITIAL_SEC. Action: gateway_tap.py:_runbackoff = min(_BACKOFF_MAX_SEC, backoff * 2.0 + random.uniform(0, 1)).
  • 🟢 nit — CHANNEL_NAMES is documentation-only ("the gateway protocol does not yet accept a per-channel filter") but it's already drifting from _CHANNEL_DIRS in sync.py — single source of truth there, import it. Action: gateway_tap.pyfrom clawmetry.sync import _CHANNEL_DIRS as CHANNEL_NAMES.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFE: gateway.auth.tokenScopes config field for headless/single-tenant deployments

1 participant