feat(sync): live gateway WS tap captures inbound channel messages (Telegram, Signal, Slack, ...)#1213
Merged
Merged
Conversation
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]>
This was referenced May 14, 2026
Owner
Author
|
E2E verified end-to-end against the Brain tab in headless chromium — passes. Setup: isolated venv at The gateway WS tap in this PR connected successfully: It surfaced the OpenClaw scope gap as designed: API & UI both render the events that are reachable today: 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.
4 tasks
Owner
Author
UX review
|
Owner
Author
Product review
|
Closed
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Status: PAUSED on upstream — NOT a
[RELEASE]PRPer 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 callsessions.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.readto 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.pyis a single background thread the sync daemon spawns at startup. It:OPENCLAW_GATEWAY_TOKENenv or~/.openclaw/openclaw.json(mirrorsdashboard._detect_gateway_tokenso the daemon doesn't have to import Flask).ws://127.0.0.1:18789/, runs the OpenClaw protocol-3connecthandshake, then attemptssessions.messages.subscribe.eventframe,_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.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 existingCOALESCE(excluded.body, channel_messages.body)upsert — so when both transports observe the same(provider, chat_id, message_id), the body sticks.Wired into
clawmetry/sync.py:run_daemon()right after thelocal_queryserver starts. Default-ON (per MOAT mandatefeedback_local_store_default_off_killed_moat); opt out withCLAWMETRY_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
connectsucceeds but returnsauth.scopes = []. Every method that delivers message bodies (sessions.messages.subscribe,sessions.subscribe,logs.tail,channels.status) then 401s withmissing 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 (catcheshealthsnapshots only).healthevents from the gateway also stream over the same socket — we drop them in_normalize_frame()since the dedicatedgateway.metricingest path already covers them. No double-write.websocket-clientis 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 PASSEDtest_full_loop_against_stubbed_websocketis the headline E2E: it injects a fakewebsocketmodule intosys.modules, drivesGatewayTap.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 inchannel_messageswith the body intact.Real Telegram message → DuckDB (post-upstream-fix)
Once openclaw/openclaw#80836 lands and
~/.openclaw/openclaw.jsoncarriesgateway.auth.tokenScopes: ["operator.read"]:clawmetry syncand check~/.clawmetry/sync.logfor:connected (degraded — no message subscribe; granted scopes=[]))./api/brain-historyand via direct query:Files touched
clawmetry/gateway_tap.py(new, ~430 LOC) — the WS tap moduleclawmetry/sync.py(+13 LOC) — startup wiring insiderun_daemontests/test_gateway_tap.py(new, ~340 LOC) — 11 unit + integration testsConnection status (in this worktree)
auth.scopes = []rejects every message subscribe — verified by/tmp/probe_gateway_ws.py. Mock-only verification.🤖 Generated with Claude Code