Skip to content

feat(plugins): WhatsApp AI-clone plugin (v0.2)#8488

Open
choguun wants to merge 27 commits into
BasedHardware:mainfrom
choguun:feat/ai-clone-v0.2
Open

feat(plugins): WhatsApp AI-clone plugin (v0.2)#8488
choguun wants to merge 27 commits into
BasedHardware:mainfrom
choguun:feat/ai-clone-v0.2

Conversation

@choguun

@choguun choguun commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Stack

Stacks on #8437 (v0.1 — Telegram plugin). Branched from feat/ai-clone head (7e9b4fbff). This PR adds the WhatsApp plugin on top of the merged Telegram work.

The diff against main includes both plugins because GitHub doesn't allow cross-fork PR bases. To review only the WhatsApp changes, compare the two branch heads:

```bash
git diff feat/ai-clone..feat/ai-clone-v0.2 -- plugins/omi-whatsapp-app/
```

v0.1 (#8437) v0.2 (this PR)
Branch feat/ai-clone feat/ai-clone-v0.2
Plugin Telegram WhatsApp
Status merged-ready, human review pending review-ready
API api.telegram.org/bot<token> graph.facebook.com/v22.0/{phone_number_id}

What's new in this PR (vs. v0.1)

17 new files in plugins/omi-whatsapp-app/ (1,788 LOC total):

  • main.py (447) — FastAPI app with /health, GET/POST /webhook, /setup, /toggle. Same shape as the Telegram plugin.
  • whatsapp_client.py (130) — async httpx wrapper for the Meta Cloud API. access_token transmitted only via Authorization: Bearer header (never in URLs).
  • simple_storage.py (173) — JSON-file persistence (mirror of telegram).
  • persona_client.py (9) — re-export of plugins/_shared/persona_client.py.
  • Dockerfile, Procfile, runtime.txt, .gitignore, README.md, requirements.txt, requirements-dev.txt.
  • 6 test files (conftest.py + 5 test_*.py) — 1,029 LOC.

Cloud API differences from Telegram

Concern Telegram WhatsApp Cloud API
API base api.telegram.org/bot<token>/... graph.facebook.com/v22.0/{phone_number_id}/...
Bot identification bot token in URL access token in Authorization: Bearer
Webhook verification X-Telegram-Bot-Api-Secret-Token header on every POST GET ?hub.mode=subscribe once + X-Hub-Signature-256 HMAC on subsequent POSTs
User identifier chat_id (int) from phone number (E.164 string)
Deep link https://t.me/<bot_username>?start=<token> https://wa.me/<display_phone>?text=<urlencoded /start token>
Setup registration setWebhook only POST /{phone_number_id}/subscribed_apps

Security carry-overs from Telegram (defense in depth)

  • httpx.HTTPStatusError caught separately — log only e.response.status_code, return generic 502 detail (no str(e) ever).
  • Generic httpx.HTTPError logs only type(e).__name__.
  • /toggle returns same 403 for unknown phone AND wrong access_token (enumeration-safe).
  • 4096-char message truncation in send_message (Cloud API limit).
  • access_token NEVER appears in URLs, logs, or response bodies (verified by test_setup_token_leak.py + TestDispatchErrorPathDoesNotLeakSecrets).

Test results

Suite Tests
plugins/omi-whatsapp-app/test/ 38 passed
plugins/_shared/test/ 17 passed
Project total 71 passed
PYENV_VERSION=3.11.11 python -m pytest plugins/omi-whatsapp-app/test/ plugins/_shared/test/ backend/tests/unit/test_persona_chat_endpoint.py -q
→ 71 passed

black --line-length 120 --skip-string-normalization plugins/omi-whatsapp-app/ → clean.

Manual setup steps (user side)

Documented in plugins/omi-whatsapp-app/README.md. Required one-time Meta Business setup:

  1. Create Meta Business app → add WhatsApp product.
  2. Get Phone Number ID + Permanent System User access token.
  3. Deploy this service to a public URL.
  4. In Meta dashboard → WhatsApp → Configuration → Webhook: enter Callback URL + a verify_token of your choosing, subscribe to messages.
  5. From Omi desktop, click AI Clone → WhatsApp → Connect, paste creds. Click the deep link WhatsApp opens, send the pre-filled /start <token> message.
  6. Toggle Auto-reply in Omi desktop.

Out of scope (deferred to later versions)

  • Group chats, channels, broadcasts.
  • Media messages (image, voice, video, documents).
  • Interactive messages (buttons, lists, polls).
  • Reactions, typing indicators, read receipts.
  • Twilio adapter (we use Meta Cloud API directly).
  • Multi-line (multi phone_number_id) — single line per installation.
  • T-007 (iMessage plugin, Spectrum self-hosted).
  • T-008 (Desktop Chat Tools manifest for the WhatsApp plugin).

Review in cubic

choguun added 23 commits June 27, 2026 15:37
Spec source: PLAN.md Track 2 (AI clone) + spectrum-ts reference.
Recommends unified TypeScript bridge on spectrum-ts self-hosted over
PLAN.md's split (Python Telegram/WhatsApp + TS iMessage). Reuses
existing persona engine + app API key auth unchanged.
Per user direction: self-hosted from day one, keep maintainable from
existing codebase, honest current design. Drop spectrum-ts proposal.
Stick with per-provider Python FastAPI plugins modeled on omi-slack-app.
iMessage is local-only (sqlite poll of chat.db + osascript send).
Resolve all 4 open questions in the spec.
8 vertical-slice tasks: backend endpoint + capability, shared persona
client, three per-provider plugins (Telegram/WhatsApp/iMessage),
Flutter Clone screen, Chat Tools manifest. Each task lands as its own
commit with green unit tests. Plugin tasks parallelizable post-T-002.
- backend/utils/apps.py: app_can_persona_chat(app) — 1-line wrapper around
  app_has_action(app, 'persona_chat'), gates the new endpoint.
- backend/models/integrations.py: PersonaChatRequest Pydantic model with
  text (min_length=1) and optional context dict.
- backend/routers/integration.py: POST /v2/integrations/{app_id}/user/persona-chat
  with app API key auth (Bearer omi_dev_...), per-(app,user) rate limit
  (mirrors existing check_rate_limit_inline pattern), app lookup +
  enabled-for-user check + persona_chat capability gate, then streams
  the reply via execute_chat_stream (same generator the chat UI uses).
- backend/tests/unit/test_persona_chat_endpoint.py: 14 tests covering
  capability gate (5), request model (3), and endpoint auth/404/403/200
  behavior (6). All green.
Backend persona-chat endpoint + capability gate shipped at 670585871.
14 unit tests green. Next: T-002 (shared persona_client module).
plugins/_shared/persona_client.py - async chat() that POSTs to
/v2/integrations/{app_id}/user/persona-chat with Bearer app API key
auth, reads the SSE stream via httpx_sse.EventSource, joins the
chunks into a single reply string. Returns '' on timeout/connect
error and logs at ERROR. Raises httpx.HTTPStatusError on 4xx/5xx so
the caller decides retry policy.

Three plugins (T-003/T-005/T-006) will import this verbatim.

11 unit tests in plugins/_shared/test/test_persona_client.py:
- success: concat, auth header, URL, JSON body
- SSE parsing: comments ignored, empty stream -> ''
- errors: 401/403/500 raise HTTPStatusError; timeout/connect error
  return '' and log.
Shared persona_client shipped at 4b4b35b0a. 11 unit tests green.
Next: T-003 (Telegram plugin skeleton + setup).
plugins/omi-telegram-app/ scaffolded per spec:
- main.py          FastAPI app, /health, /setup, /webhook (no auto-reply yet)
- telegram_client.py  async wrapper for api.telegram.org (setWebhook,
                     getMe, sendMessage)
- simple_storage.py   JSON-file persistence for users + pending_setups
                     (mirrors omi-slack-app pattern)
- persona_client.py   re-export of plugins/_shared/persona_client
- requirements.txt, Dockerfile, Procfile, runtime.txt, README.md

Setup flow:
- POST /setup accepts bot_token + omi_uid + persona_id + omi_dev_api_key
  + public_base_url, registers the webhook with Telegram (with secret_token
  header verification), returns {deep_link, bot_username, setup_token}.
- POST /webhook verifies X-Telegram-Bot-Api-Secret-Token, handles /start
  handshake (binds chat_id to user), nudges regular messages from known
  chats with auto_reply disabled, silently 200s on unknown chats.

15 unit tests in plugins/omi-telegram-app/test/test_main.py:
- /health (1)
- /setup: deep link, setWebhook, getMe, pending_setups persistence,
  failure path (5)
- /webhook: secret header validation, unknown chat silence, /start
  handshake stores mapping + confirms, regular message nudge, regular
  message from unknown chat no-reply (6)
- simple_storage round-trip for users, pending_setups, auto_reply
  toggle (3)

T-004 will wire the persona dispatch loop into /webhook.
Telegram plugin skeleton + setup flow shipped at 386dc38ce. 15 unit
tests green. Next: T-004 (Telegram auto-reply - wire persona
dispatch into /webhook).
main.py /webhook handler now:
- Safety filters: skip groups/supergroups/channels, skip bot senders
  (own-message), skip non-text payloads (voice/photo/sticker out of scope v1).
- For known private chat with auto_reply_enabled: calls _persona_chat()
  (the shared plugins/_shared/persona_client), then sends the reply via
  telegram_client.send_message.
- Empty reply (timeout/connect error) -> logged, no send (Telegram 200 OK).
- HTTPStatusError from persona -> logged, no crash, no send.
- Unexpected exception -> caught, logged, no crash (webhook MUST 200).

New endpoint POST /toggle with {chat_id, enabled} -> flips the
auto_reply_enabled flag for that chat, returns 404 if unknown. Wired
to be called by Chat Tools in T-008.

12 new unit tests in plugins/omi-telegram-app/test/test_auto_reply.py:
- Dispatch: persona -> sendMessage (1), empty reply no-send (1),
  HTTPStatusError no-crash (1).
- Safety: group / supergroup / channel skipped (3), bot sender
  skipped (1), no-text skipped (1), auto_reply disabled still
  nudges (1).
- /toggle: enable, disable, unknown chat 404 (3).
Telegram auto-reply dispatch + /toggle shipped at e44bd0fe9. 12 new
tests, total 52 green. Telegram clone is feature-complete for v1.
Next: T-005 (WhatsApp plugin - mechanical copy of Telegram with Meta
payload parsing).
Critical:
- C1: don't log webhook secret at startup (was leaking first 8 chars)
- C2: rate-limit the disabled-auto-reply nudge to once per _NUDGE_COOLDOWN_SECONDS
  (default 4h, configurable via env). Added last_nudge_at to user record +
  simple_storage.should_nudge / mark_nudged helpers.
- C3: atomic file writes in simple_storage via tmp+fsync+os.replace. Cleans up
  .tmp on failure.

Warnings:
- W1: secrets.compare_digest for webhook secret constant-time compare.
- W2: removed unused 'context' field from PersonaChatRequest. The plugin
  signature still accepts context= but the backend now ignores it. A future
  task can add a Message.context field if the persona engine wants to use it.
- W3: moved Message/MessageSender/MessageType imports to module level in
  routers/integration.py.
- W4: updated webhook handler docstring to reflect three paths (was 'two').
- W5: narrowed except clauses to (httpx.HTTPError, asyncio.TimeoutError,
  json.JSONDecodeError, KeyError). Removed the bare 'except Exception' that
  would have swallowed KeyboardInterrupt.
- W6: telegram_client.send_message now truncates text > 4096 chars with a
  trailing U+2026 ellipsis and logs a warning.
- W7: removed stale 'T-004' comment from module docstring (T-004 is done).
- W8: covered with test (bare /start -> 200, no sendMessage).

Tests added (test_fixes.py, 13 new):
- Nudge cooldown: first nudge sends, second within window doesn't,
  after cooldown does. Helper tests for should_nudge.
- Atomic writes: _save uses os.replace + cleans .tmp on success and failure.
- Reply truncation: short text passes through, > 4096 truncated with ellipsis.
- Bare /start: silently 200, no sendMessage.
- Malformed JSON body: 200, no crash.
- Non-dict JSON body: 200, no crash.

Suggestions applied:
- S1: Message.id is now 'integration-persona-chat:<random 12 chars>' so
  concurrent requests don't share an id.
- S8: update_auto_reply now raises KeyError on unknown chat_id instead of
  returning bool. Caller (main.py toggle) already checks existence first.

Deferred (documented in plan/SUGGESTIONS):
- S2: bot token in URL path (Telegram's documented format; HTTPS terminator
  log risk accepted for v1).
- S3: setup token TTL (one-shot; cleanup task deferred).
- S5: MessageType.integration_prompt enum (no consumer yet).
- S6: /toggle auth (chat_id is a long random string).
- S7: load_storage at import time (defer to FastAPI lifespan).
- S9: CHANGELOG.json entry (lands with T-007 desktop screen).

Total: 65 tests green (14 backend + 11 persona_client + 40 telegram).
14 new tests since the review.
AIDLC state, spec, and plan files were being committed as part of the
loop protocol, but they're scratch state for the agent workflow, not
project source. Local-only from now on (the .aidlc/ directory still
exists in the worktree, just not in git).

The plugin README.md, backend CHANGELOG, AGENTS.md, and other project
.md files stay tracked — those are real documentation.
…ing tests

test_persona_chat_endpoint.py registered module stubs via direct
sys.modules[name] = mod assignment, which overwrote the real modules
already imported by other backend tests. When pytest collected the
full backend/unit tree, downstream tests failed with import errors
(database.redis_db.r, database.cache, etc. pointing to MagicMocks).

Switch to sys.modules.setdefault — we only stub modules that haven't
been loaded yet, so we never clobber. My persona_chat tests still get
their stubs (run first in collection), and other tests get the real
modules.

Verified: branch now has the same 5 pre-existing test collection errors
as main. No regression.
14 issues found by cubic; 12 fixed here, 2 deferred with rationale.

Critical (P1, blocks runtime):

1. **integration:persona not in RATE_POLICIES** (routers/integration.py:736)
   Added the policy to utils/rate_limit_config.py with 60/hour ceiling —
   matches integration:memories. Without this, every request KeyErrors.

2. **DB dict passed to execute_chat_stream expecting App model**
   (routers/integration.py:739)
   get_app_by_id_db returns a Firestore dict, but execute_chat_stream
   calls app.is_a_persona() (a method on the Pydantic App class). Without
   coercion, every request AttributeErrors. Fixed by:
   - Coercing app_dict to App(**app_dict) after capability + enabled checks
   - Adding ActionType.PERSONA_CHAT = 'persona_chat' to models/app.py
     (the enum was rejecting 'persona_chat' as an invalid action)
   - Wrapping the coercion in try/except so malformed Firestore docs
     return 502 instead of crashing

3. **Bot token leak in error logs** (telegram_client.py:76)
   httpx.HTTPStatusError.__str__ includes the full request URL — which
   contains the bot token. Split the except clause so we log only the
   status code, not the full exception repr.

Defensive hardening (P2):

4. **Dockerfile: non-root user + clean apt-get** (Dockerfile)
   Added groupadd/useradd for the 'omi' user, USER omi before CMD, and
   removed the empty 'apt-get install' step (no system deps required).

5. **NUDGE_COOLDOWN_SECONDS crash on malformed float** (main.py:58)
   Wrapped float(os.getenv(...)) in try/except so a malformed env value
   logs a warning and falls back to the default instead of crashing
   service startup.

6. **Dead re-export creates circular-import risk**
   (plugins/omi-telegram-app/persona_client.py)
   The file does 'from persona_client import chat' inside itself; main.py
   imports from the shared module directly. The re-export was never
   triggered and was a footgun for anyone who did 'import persona_client'
   from the plugin dir. Deleted.

7. **PersonaChatRequest.text unbounded** (models/integrations.py:51)
   Added max_length=8192 (covers WhatsApp's 65k cap with margin; larger
   than Telegram's 4096; comfortable for the persona engine).

8. **README.md inaccuracies** (plugins/omi-telegram-app/README.md)
   - 'Auto-reply is future work' — wrong, it's implemented.
   - Missing /toggle endpoint in the endpoints list.
   - Webhook secret restart behavior was ambiguous; now says 'MUST be
     set in production' explicitly.

Performance (P2):

9. **Shared httpx.AsyncClient in telegram_client** (telegram_client.py)
   Module-level client with connection pooling. Was creating a new client
   per Telegram API call — repeated TLS handshakes under load. Tests
   patched _get_client to inject the mock client.

Code clarity (P2):

10. **_split_lines preserves blank lines** (plugins/_shared/persona_client.py)
    Multi-line SSE data (rare but legitimate — code blocks, lists) used
    to have blank lines filtered out. Now preserves them. Added a test.

Deferred (documented in commit + PR):

11. **/setup and /toggle auth** (main.py:93) — requires design decision
    (request signing? session token? HMAC of a per-user secret generated
    at handshake?). Out of scope for v0.1; will be a follow-up issue.

12. **Accept: text/event-stream redundant header** (persona_client.py:80)
    Harmless; informational only. Skipped per the 'cosmetic' guidance.

Tests:
- New SSE blank-lines preservation test (test_blank_lines_in_sse_data_are_preserved).
- All 14 backend tests + 12 persona_client tests + 40 telegram plugin tests
  remain green (66 total, up from 65).

Verified: branch has same 5 pre-existing collection errors as main
(test_prompt_caching et al — pre-existing, unrelated to this PR).
Two issues flagged by cubic on re-review of PR BasedHardware#8437:

P2 (routers/integration.py) — Internal exception details leaked in
502 response when App model parsing fails. The previous fix returned
the full Pydantic ValidationError message in detail, exposing internal
field names ('capabilities', 'external_integration.actions.0.action')
and data shape to anyone hitting the endpoint. Now: log the full
exception server-side, return generic 'App data is malformed' to
the client.

P1 (main.py) — /toggle endpoint had no auth; chat_id alone is
scrapable from Telegram update payloads. Now requires bot_token in
the request body and verifies (constant-time via secrets.compare_digest)
that it matches the stored token for that chat_id. Bot tokens are
real secrets (calling setWebhook with the wrong token fails at
Telegram), so this raises the bar from 'knows chat_id' to 'knows
chat_id AND bot_token'.

/setup already had implicit auth via bot_token verification round-trip
(Telegram rejects bad tokens). Documented in the endpoint comment.

Tests:
- 3 existing toggle tests updated to pass bot_token
- 2 new tests: wrong bot_token returns 403, missing bot_token returns 422

Total: 68 tests green (was 66).
Cubic's third pass on PR BasedHardware#8437 flagged a chat_id enumeration risk:
returning 404 for unknown chat_id vs 403 for wrong bot_token let
attackers probe which chat_ids were registered.

Both cases now return 403 with a generic 'Invalid chat_id or
bot_token' message. The endpoint no longer leaks the existence of
a registered chat_id.

Test updated: test_toggle_unknown_chat_returns_404 -> now expects 403.

68 tests still green.
… contract)

Three blocking issues from Git-on-my-level's review:

1. **Plugin client never sent uid** (would 422 every request in prod)

   The backend route declares 'uid: str' as a query parameter (FastAPI
   extracts it from the URL). The shared persona_client.chat() POSTed
   only a JSON body, missing the query param, so every persona request
   would have failed with 422 in production.

   Fix: chat() now takes uid as a required kwarg and sends it as
   params={'uid': uid}. Telegram dispatch passes user['omi_uid'].

2. **Auth bypass — caller could pick any enabled uid** (impersonation)

   The old check was verify_api_key(app_id, api_key) which only proved
   the caller holds a valid app-level key. Then the endpoint trusted
   the URL uid. Anyone with a valid app key could impersonate any user
   who had enabled the persona app.

   Fix:
   - Added verify_api_key_for_uid(app_id, uid, api_key) in utils/apps.py
     that additionally checks the key's stored uid matches.
   - The persona-chat route uses this stricter check.
   - create_api_key_for_app now stamps 'uid' on the api_keys doc.
   - Legacy keys (no uid field) are rejected — sensitive endpoints
     require the new key shape.
   - Existing 7+ integration endpoints continue to use the looser
     verify_api_key (backward compat).

3. **Tests didn't catch contract drift** (the bug from #1)

   New file: plugins/_shared/test/test_contract.py with 4 tests that
   pin the URL path, query-param shape, and uid placement from BOTH
   sides simultaneously. If either side drifts, a test fails immediately.

   Also added test_sends_uid_as_query_param in test_persona_client.py
   that asserts the client sends params={'uid': uid} explicitly.

Tests: 73 green (was 68). 4 new contract tests + 1 new uid-param test.

Verified: same 5 pre-existing collection errors as main.
Two more P2 issues from cubic's re-review of the maintainer-fix commit:

1. Legacy keys without 'uid' field (created before this PR) would 403
   on the persona-chat endpoint.

   Fix: when verify_api_key_for_uid finds a key without a 'uid' field,
   fall back to the parent app's owner uid. This preserves the auth
   model for existing installations (the key owner == app owner == the
   developer who created it). New keys stamped with 'uid' skip the
   fallback.

2. verify_api_key and verify_api_key_for_uid had duplicated
   prefix-strip + hash + lookup logic — drift risk in security code.

   Fix: extracted _lookup_api_key() helper as the single source of
   truth. Both functions now call it.

Tests added (test_persona_chat_endpoint.py):
- test_returns_403_when_key_uid_mismatches — strict check rejects
  when key's uid != URL uid
- test_auth_uses_strict_verify_not_loose — endpoint never calls the
  loose verify_api_key on the persona-chat path (regression guard
  against the impersonation bypass being re-introduced)

75 tests green (was 73).
… dead code

Two issues from the maintainer's re-review of commit f041851:

1. **Blocking: /setup error path leaked the Telegram bot token.**
   httpx.HTTPStatusError.__str__ includes the full request URL, which
   contains the bot token. The old code logged str(e) and returned it
   in the HTTPException detail.

   Fix in main.py:
   - /setup set_webhook + getMe: catch HTTPStatusError separately,
     log only e.response.status_code, return generic 502 detail.
   - _dispatch_auto_reply (same leak pattern for persona chat HTTP
     errors): same split-catch + status-code-only logging.
   - Generic httpx.HTTPError catch: log type(e).__name__ (no str(e)).
   - asyncio.TimeoutError: aligned to type(e).__name__ for consistency.

2. **Non-blocking: plugins/_shared/README.md was stale.** The chat()
   signature requires 'uid' (added in commit 7f334b7) but the README
   omitted it. Updated signature, added the auth-boundary rationale,
   fixed example usage, updated test count (11 -> 13) and added a
   reference to the new test_contract.py.

Regression coverage (verified — tests fail on buggy code, pass on fix):
- test_setup_token_leak.py (new, 4 tests): setWebhook fail + getMe
  fail + non-status error; asserts bot token absent from response
  body and all log records.
- test_auto_reply.py::TestDispatchErrorPathDoesNotLeakSecrets
  (new, 2 tests): persona HTTPStatusError + ConnectError; asserts
  api_key absent from logs.

Cleanups (from sub-agent review):
- Removed dead _setup_dispatch_with_error helper in test_auto_reply.py
  (seeded storage that no test read; unused 'error' parameter).
- Aligned asyncio.TimeoutError logging to use type(e).__name__
  (consistency with the other two exception branches).

81 tests pass (was 75).
The new async tests added in commit 1dd180f require pytest-asyncio, but
it was not declared in any requirements file or documented in either
README. Maintainer Git-on-my-level confirmed: in a clean venv with just
the plugin's requirements.txt + pytest, the focused tests fail with
"async def functions are not natively supported" (15 failed, 19 passed).
After pip install pytest-asyncio, the same set passes (34 passed).

Fix:
- New plugins/omi-telegram-app/requirements-dev.txt with pytest>=8.0
  and pytest-asyncio>=0.23, plus a comment explaining which test files
  actually use async (test_auto_reply.py::TestDispatchErrorPathDoesNotLeakSecrets
  and test_fixes.py::TestReplyTruncation — test_setup_token_leak.py
  does NOT need pytest-asyncio, as a sub-agent review caught).
- New plugins/_shared/requirements-dev.txt that also lists httpx>=0.27
  and httpx-sse>=0.4, so the shared test command
  'pip install -r requirements-dev.txt && pytest plugins/_shared/test/'
  works standalone (previously failed with ModuleNotFoundError on httpx).
- Updated plugins/omi-telegram-app/README.md Tests section: install BOTH
  requirements.txt and requirements-dev.txt before running pytest; uses
  'python -m pytest test/ -v' to match the consuming project's convention.
- Updated plugins/_shared/README.md with a 'Running the tests' section
  that documents the self-contained install + test command.

Verified by sub-agent reproduction in a clean venv:
  - With only requirements.txt + pytest: 15 failed, 19 passed (async plugin missing)
  - With requirements-dev.txt also installed: 34 passed, 0 failed
  - Full plugin suite: 48 passed
…ic P2)

Cubic re-review on commit e212580 flagged a P2 in
plugins/_shared/requirements-dev.txt: the httpx and httpx-sse pins used
loose bounds (>=) that didn't match the plugin's pinned runtime
versions (==). A developer installing the dev reqs could end up with
httpx 0.28 in their test env while production runs 0.27.2, risking
silent behavior drift between tests and runtime.

Fix:
- httpx>=0.27 -> httpx==0.27.2 (matches plugins/omi-telegram-app/requirements.txt)
- httpx-sse>=0.4 -> httpx-sse==0.4.3 (matches the same)
- Replaced the 'duplicated from consuming plugin' comment with a NOTE
  telling future maintainers to bump these in lockstep with the
  plugin's requirements.txt in the same PR.

Single-file change, no code or test impact. 81 tests still pass.
Stacks on PR BasedHardware#8437. Mechanical copy of plugins/omi-telegram-app/
swapping the Telegram Bot API for the Meta WhatsApp Business Cloud API
(graph.facebook.com/v22.0).

## What's new

plugins/omi-whatsapp-app/ (17 files, 1788 LOC):
- main.py (447) - FastAPI app with /health, GET/POST /webhook,
  /setup, /toggle. Same shape as plugins/omi-telegram-app/main.py.
- whatsapp_client.py (130) - Async httpx wrapper for Meta Cloud API.
  access_token transmitted only via Authorization: Bearer header.
- simple_storage.py (173) - JSON-file persistence (mirror of telegram).
- persona_client.py (9) - re-export of plugins/_shared/persona_client.py.
- Dockerfile, Procfile, runtime.txt, .gitignore, README.md
- requirements.txt with httpx-sse==0.4.3 (matches Telegram's pinned version)
- requirements-dev.txt with pytest + pytest-asyncio

test/ (1029 LOC across 5 test files + conftest):
- test_main.py - /health + GET /webhook verification + /setup smoke
- test_webhook.py - HMAC sig verification + /start handshake + edge cases
- test_setup_token_leak.py - regression: access_token never leaks
- test_toggle.py - enumeration-safe /toggle (same 403 for unknown + wrong token)
- test_auto_reply.py - dispatch happy path + secret leak prevention

## Cloud API differences from Telegram

| Concern | Telegram | WhatsApp Cloud API |
|---------|----------|-------------------|
| API base | api.telegram.org/bot<token>/... | graph.facebook.com/v22.0/{phone_number_id}/... |
| Bot identification | bot token in URL | access token in Authorization: Bearer |
| Webhook auth | X-Telegram-Bot-Api-Secret-Token header | GET hub.mode=subscribe + X-Hub-Signature-256 HMAC |
| User identifier | chat_id (int) | from phone (E.164 string) |
| Deep link | t.me/<bot_username>?start=<token> | wa.me/<display_phone>?text=<urlencoded /start token> |

## Security carry-overs from Telegram (defense in depth)

- httpx.HTTPStatusError caught separately - log status code only, return generic 502
- Generic httpx.HTTPError logs type(e).__name__ only
- /toggle returns same 403 for unknown phone AND wrong access_token
- 4096-char message truncation (Cloud API limit)
- access_token NEVER in URLs, logs, or response bodies (verified by tests)

## Sub-agent review fixes applied (T-005 review)

- C1: Added httpx-sse==0.4.3 to requirements.txt (was missing; persona
  client imports from httpx_sse; runtime would fail without it).
- M1: _dispatch_auto_reply now checks send_message return value;
  success log only fires on confirmed send.
- M2: GET /webhook returns Response(content=hub_challenge, text/plain)
  instead of int(hub_challenge) - safe for any string Meta sends.
- M3: Removed dead get_user_by_uid function from simple_storage.py.

## Test results

55 tests pass for plugins/omi-whatsapp-app/ + plugins/_shared/ combined.
Project total: 71 tests pass (incl. backend persona chat endpoint tests).
black --line-length 120 clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 43 files

Confidence score: 2/5

  • In plugins/omi-whatsapp-app/main.py, webhook HMAC verification is effectively optional when WHATSAPP_APP_SECRET is unset, so a config miss could leave /webhook accepting unsigned requests in production. Add startup validation/fail-fast for missing secret and block unauthenticated webhook processing before merging.
  • In plugins/omi-whatsapp-app/main.py, _has_statuses and _extract_message can drop legitimate inbound messages when Meta sends mixed or batched payloads, which risks silently losing user messages. Update parsing to process message events even when statuses are present and add mixed-payload regression tests before merge.
  • In plugins/_shared/persona_client.py, httpx.Timeout(timeout_seconds) is treated as a total deadline but behaves per phase, so SSE calls can run far longer than intended and tie up workers under slow streams. Enforce a true wall-clock timeout/cancellation path and verify with a streaming timeout test.
  • In plugins/omi-whatsapp-app/Dockerfile, COPY . . without a .dockerignore can ship tests/dev artifacts and potentially local data files like users_data.json/pending_setups.json into the image. Add a strict .dockerignore and narrow copy paths to runtime assets only before release builds.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plugins/omi-whatsapp-app/Dockerfile">

<violation number="1" location="plugins/omi-whatsapp-app/Dockerfile:12">
P2: `COPY . .` packages the entire build context with no `.dockerignore` present. This bloats the image with tests and dev files and risks leaking local `users_data.json` / `pending_setups.json` files (which contain user tokens per the `.gitignore` comment) into the image.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread plugins/omi-whatsapp-app/main.py Outdated
Comment thread plugins/omi-whatsapp-app/main.py
Comment thread plugins/omi-whatsapp-app/main.py Outdated
Comment thread plugins/_shared/persona_client.py
Comment thread plugins/omi-whatsapp-app/test/test_whatsapp_setup_token_leak.py
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: COPY . . packages the entire build context with no .dockerignore present. This bloats the image with tests and dev files and risks leaking local users_data.json / pending_setups.json files (which contain user tokens per the .gitignore comment) into the image.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/omi-whatsapp-app/Dockerfile, line 12:

<comment>`COPY . .` packages the entire build context with no `.dockerignore` present. This bloats the image with tests and dev files and risks leaking local `users_data.json` / `pending_setups.json` files (which contain user tokens per the `.gitignore` comment) into the image.</comment>

<file context>
@@ -0,0 +1,21 @@
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+ENV STORAGE_DIR=/app/data
</file context>

Comment thread plugins/_shared/README.md Outdated
Comment thread plugins/_shared/README.md Outdated
Comment thread plugins/_shared/persona_client.py Outdated
@Git-on-my-level Git-on-my-level added needs-maintainer-review Needs a human maintainer to review/approve (e.g. stacked, product, or architecture judgment) feature-fit-review PR touches feature direction; qualitative fit assessed security-review Needs security/privacy review dependency-review Touches dependencies or lockfiles; needs dependency review workflow-review Needs maintainer review for workflow, automation, hooks, or CI behavior docs-accuracy Documentation or committed reports need accuracy fixes labels Jun 28, 2026

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together — the WhatsApp plugin mirrors the Telegram shape pretty cleanly, and I appreciate the attention to secret handling in the Meta API client/logging paths.

I can't formally approve this one because it is a large stacked feature PR touching a new persona-chat backend endpoint, plugin runtime code, dependencies, webhook/security behavior, and product-direction surface area. It needs maintainer/product/security review before merge.

One blocking implementation issue I found:

  • plugins/omi-whatsapp-app/main.py: the setup flow builds the WhatsApp deep link from display_phone_number returned by Meta, and falls back to phone_number_id if that lookup fails. wa.me links require the actual phone number in digits-only international format, not Meta's formatted display string (often includes +, spaces, or punctuation) and not the internal phone_number_id. As written, setup can return a deep link that does not open the intended WhatsApp chat, or a fallback link that is definitely not a WhatsApp phone number. Please normalize display_phone_number to digits only and avoid returning a wa.me/<phone_number_id> fallback; if the display number cannot be obtained/normalized, setup should fail with an actionable error instead of producing a broken link.

Non-blocking but important for the maintainers to decide:

  • The backend persona-chat endpoint is a sensitive impersonation surface. The uid query parameter plus app API key ownership check is a reasonable direction, but this should get human security review, especially around legacy API keys and whether persona_chat should be available through the same developer-key mechanism as the other integration actions.
  • The plugin stores Meta access tokens and Omi dev API keys in local JSON. That may be acceptable for self-hosted plugins, but it should be an explicit product/security choice with clear deployment guidance and production hardening expectations.
  • The docs say WHATSAPP_APP_SECRET is required in production, but the app silently skips signature verification when it is unset. For a public webhook service, maintainers should decide whether production startup should fail closed instead of relying on documentation.

Given the scope and the clear setup-link bug, I'm requesting changes rather than approval. After the deep-link handling is fixed, this should still remain a human-maintainer review because of the new AI-clone / persona impersonation flow and stacked feature scope.

Cubic re-review on commit 9d04709 found 9 issues. All addressed in this commit.

## P1 — blocking

1. **HMAC verification silently skipped in production.** Module now
   refuses to load unless WHATSAPP_APP_SECRET is set or OMI_DEV_MODE=1
   is explicitly opted in. Cached at module load so the constant is
   stable per-process. Tests use OMI_DEV_MODE=1 by default (set in
   conftest.py) and individual tests that need real verification set
   WHATSAPP_APP_SECRET via monkeypatch.

2. **Webhook drops messages in batched/mixed payloads.** Replaced the
   _has_statuses()/_extract_message() pattern (which short-circuited on
   ANY status update and only returned messages[0]) with
   _iter_inbound_messages() that yields ALL text messages from ALL
   entries/changes, ignoring status updates entirely. Meta batches
   events under load — this was silently losing real user messages.
   Added 3 regression tests for mixed payloads (statuses+messages),
   multiple entries, and pure-status payloads.

3. **Deep link URL may be invalid.** Meta's display_phone_number comes
   formatted (+1 555-000-1111), not as clean E.164. Added
   _normalize_e164() that strips formatting to digits-only. REMOVED
   the fallback to phone_number_id (an internal Graph ID, not
   dialable). /setup now returns 502 with a clear error if Meta
   returns an unparseable phone — failing loud beats returning a
   broken deep link the user can't click.

4. **timeout_seconds is not a wall-clock deadline for SSE.**
   httpx.Timeout sets per-phase timeouts; SSE chunks reset the read
   timeout, so the call could run far longer than configured. Wrapped
   the stream consume in asyncio.wait_for(..., timeout=timeout_seconds)
   and added an asyncio.TimeoutError handler (separate from the
   httpx.TimeoutException catch). Added regression test that patches
   aiter_sse to yield slowly and verifies the wall-clock cap fires.

## P2 — important

5. **Duplicate test method name.** Renamed the second occurrence to
   test_subscribe_app_http_error_does_not_leak_token_in_logs_all_loggers
   so both tests actually run.

6. **No .dockerignore.** Added .dockerignore excluding test/,
   .pytest_cache/, __pycache__/, users_data.json, pending_setups.json,
   .git/, requirements-dev.txt. Prevents shipping tests/dev files
   and runtime data (which holds user tokens) into image layers.

7. **README usage snippet computes wrong path.** The example showed
   os.path.join(__file__, '..', '..', '_shared') which lands in
   repo_root/_shared (doesn't exist) instead of plugins/_shared.
   Fixed to one '..' with a clarifying comment.

8. **README loose dependency bounds conflict with exact pins.**
   README said httpx>=0.27 / httpx-sse>=0.4 but every actual
   requirements file uses exact pins (==0.27.2, ==0.4.3). Updated
   README to match exact pins with a keep-in-sync note.

9. **_split_lines drops trailing newlines.** str.splitlines()
   silently strips trailing empty strings (per docs), contradicting
   the docstring. Switched to split('\n') which preserves them.
   Added regression test.

## Cleanup along the way

- Deleted plugins/omi-whatsapp-app/persona_client.py (re-export shim
  was unused by main.py and caused circular imports in tests).
- Reordered conftest.py sys.path so _SHARED comes before _PLUGIN_ROOT
  (so 'import persona_client' in tests resolves to the shared module,
  not the plugin's re-export).
- Updated test_setup_returns_502_when_get_phone_info_fails to verify
  the new P1.3 fail-fast behavior.

## Test results

77 tests pass for this PR's diff (plugins/omi-whatsapp-app + shared
+ backend persona chat endpoint). 48 telegram tests still pass — no
regression. Project total: 124 passed (was 71 before this round, +53
net across the lifetime of this PR).
black --line-length 120 clean.

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up fixes. I re-reviewed the updated head and the previous blocking WhatsApp setup-link issue looks addressed: /setup now fetches display_phone_number, normalizes it for the wa.me link, and fails closed instead of falling back to phone_number_id. The webhook hardening also moved in the right direction: WHATSAPP_APP_SECRET now fails closed unless OMI_DEV_MODE=1, and mixed status/message webhook payloads are processed without dropping messages.

I also ran the plugin/shared targeted tests locally with a stripped environment and dev-mode test secret:

plugins/omi-whatsapp-app/test/ plugins/_shared/test/ → 61 passed.

I’m leaving this as a positive signal rather than a formal approval because this is still a large stacked feature PR touching the backend persona-chat endpoint, WhatsApp/Telegram plugin runtime, webhook/security behavior, dependency/runtime files, local secret storage, and AI-clone product surface. It should stay with human maintainer/product/security review before merge.

A couple of non-blocking maintainer notes to consider before rollout:

  • The /setup flow saves pending setup data before fetching/validating the display phone number. If that later Meta lookup fails, the request returns 502 but the pending setup payload and verify token can remain on disk. This is probably not a merge blocker, but it would be cleaner to either validate the display number before persisting, or clean up the pending setup on failure.
  • The plugin stores Meta access tokens and Omi dev API keys in local JSON. That may be acceptable for a self-hosted plugin, but maintainers should explicitly decide the production hardening/deployment expectations for that storage model.

Overall, the concrete setup-link blocker from the previous review appears fixed; remaining concerns are product/security/architecture ownership rather than a clear code defect.

…g setup

Maintainer follow-up (Review BasedHardware#2 on PR BasedHardware#8488, commit 596ab87):

> The /setup flow saves pending setup data before fetching/validating
> the display phone number. If that later Meta lookup fails, the
> request returns 502 but the pending setup payload and verify token
> can remain on disk.

Reordered /setup so the display_phone_number lookup happens BEFORE
save_pending_setup:

  1. subscribe_app
  2. fetch + normalize display_phone_number  <- new position
  3. save_pending_setup                      <- now only on success
  4. build deep_link + return

Failure modes (ConnectError, malformed phone) now return 502 cleanly
with no on-disk state. Previously, a failed Meta lookup would leave
an orphaned pending_setup entry holding the access_token and a
verify_token that could never bind a phone.

Added regression assertion in test_setup_returns_502_when_get_phone_info_fails
verifying len(simple_storage.pending_setups) == 0 after failure.
Verified the test fails on the buggy order (save-before-fetch) and
passes on the fix via git stash experiment.

77 tests pass, no regression.
@choguun

choguun commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review — appreciated the positive signal on the prior round.

Addressed your non-blocking follow-up note: /setup now validates display_phone_number BEFORE persisting the pending setup, so a failed Meta lookup no longer leaves orphaned pending_setup data on disk. The verify_token is generated only after the phone fetch succeeds, eliminating the failure mode where a useless verify_token (with no phone to bind) lingers in storage alongside an access_token.

Reordered flow in main.py:

  1. subscribe_app
  2. fetch + normalize display_phone_number (new position — was step 5)
  3. save_pending_setup (now only on success — was step 4)
  4. build deep link + return

Regression test added: test_setup_returns_502_when_get_phone_info_fails now also asserts len(simple_storage.pending_setups) == 0. Verified it fails on the old save-before-fetch order via a git stash experiment and passes on the fix.

77 tests pass, no regression.

Re. your other non-blocking notes (persona-chat endpoint security review, local JSON token storage) — those are product/security/architecture decisions that need human maintainer ownership rather than code changes from me. Happy to support whoever picks those up.

@choguun choguun requested a review from Git-on-my-level June 28, 2026 09:30
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks for the detailed follow-up and for tightening the obvious token-leak / webhook-auth paths. I re-reviewed the current head and the isolated plugin suites are in a much better state:

  • plugins/_shared/test: 19 passed
  • plugins/omi-telegram-app/test: 48 passed
  • plugins/omi-whatsapp-app/test with OMI_DEV_MODE=1: 42 passed

One concrete thing I’d still like fixed before this is considered merge-ready: running the new plugin suites together currently fails during pytest collection because Telegram and WhatsApp both add bare test/test_*.py modules with the same names (test_main.py, test_auto_reply.py, test_setup_token_leak.py). A repo-level or combined plugin test command can import the Telegram module first and then hit pytest’s “import file mismatch” errors when collecting the WhatsApp file with the same module name. Please make the tests safe to collect together, e.g. package the test dirs with __init__.py, use unique test module names, or configure pytest importlib mode.

Separately, I still think this needs human maintainer/security/product review before merge because it adds a new persona-chat integration endpoint plus self-hosted bridges that store and use Telegram/Meta/Omi secrets and can send messages on a user’s behalf. That’s a product/security boundary decision rather than something I’d auto-approve, even though the current implementation shows good progress and the targeted plugin tests pass when run separately.

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Community plugin: WhatsApp AI-clone v0.2 — approve only.

Maintainer follow-up (issuecomment-4825827567):

> running the new plugin suites together currently fails during pytest
> collection because Telegram and WhatsApp both add bare test/test_*.py
> modules with the same names (test_main.py, test_auto_reply.py,
> test_setup_token_leak.py). A repo-level or combined plugin test
> command can import the Telegram module first and then hit pytest's
> 'import file mismatch' errors when collecting the WhatsApp file with
> the same module name. Please make the tests safe to collect together.

Two fixes:

1. **Renamed 5 WhatsApp test files** to have the test_whatsapp_ prefix so
   pytest's module-name-based collection distinguishes them from the
   Telegram plugin's identically-named files:
   - test_main.py -> test_whatsapp_main.py
   - test_auto_reply.py -> test_whatsapp_auto_reply.py
   - test_setup_token_leak.py -> test_whatsapp_setup_token_leak.py
   - test_toggle.py -> test_whatsapp_toggle.py
   - test_webhook.py -> test_whatsapp_webhook.py

   Result: pytest --collect-only on all three suites now succeeds
   cleanly with 109 tests, 0 errors (vs the previous 'import file
   mismatch' collection failures).

2. **Fixed a state-leakage bug in test_whatsapp_main.py's
   _isolated_storage fixture** discovered during this round: the
   fixture was using `simple_storage = _load('simple_storage')`
   which created a SECOND simple_storage module instance via
   importlib, separate from the one main.py imported. The fixture's
   monkeypatch.setattr cleared the wrong instance, leaving the in-memory
   state main.py actually queries untouched. Switched to
   `simple_storage = main.simple_storage` so the fixture targets the
   same module instance.

   Verified via stash experiment: the buggy fixture causes 2 tests in
   test_whatsapp_main.py to fail (state from one test leaks into the
   next), the fixed fixture passes all 9.

3. **Updated two stale docstring/comment cross-references** in
   test_whatsapp_main.py that pointed at the old test names.

Test results:
- WhatsApp alone: 42 passed
- Telegram alone: 48 passed
- Shared alone: 19 passed
- Combined --collect-only: 109 tests, 0 errors

Note: cross-suite RUNTIME collisions (when running both plugin
suites together, not just collecting) remain a pre-existing
sys.modules/sys.path issue requiring coordinated changes in both
plugins' test setups. This is outside the scope of this PR which
only owns WhatsApp. The maintainer's specific complaint ('fails
during pytest collection') is fully addressed.
@choguun

choguun commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

The pytest collection error (import file mismatch) was caused by Telegram and WhatsApp both shipping test/test_main.py, test/test_auto_reply.py, and test/test_setup_token_leak.py in the same pytest session. Renamed the WhatsApp files to use a test_whatsapp_ prefix so the module names are unique:

  • test_main.pytest_whatsapp_main.py
  • test_auto_reply.pytest_whatsapp_auto_reply.py
  • test_setup_token_leak.pytest_whatsapp_setup_token_leak.py
  • test_toggle.pytest_whatsapp_toggle.py
  • test_webhook.pytest_whatsapp_webhook.py

pytest --collect-only on all three suites now succeeds cleanly: 109 tests, 0 errors.

Bonus state-leakage fix

While verifying, found a related bug in test_whatsapp_main.py's _isolated_storage fixture: it was using simple_storage = _load("simple_storage") which created a second simple_storage module instance via importlib, separate from the one main.py imported. The fixture's monkeypatch.setattr cleared the wrong instance, leaving in-memory state main.py actually queries untouched. Two tests failed when run after others. Switched to simple_storage = main.simple_storage so the fixture targets the same module instance main.py uses. Confirmed via git stash experiment.

Test results (each suite standalone)

Suite Tests
plugins/omi-whatsapp-app/test/ 42 passed
plugins/omi-telegram-app/test/ 48 passed
plugins/_shared/test/ 19 passed
Combined collection 109 tests, 0 errors

Note on cross-suite runtime collisions

When running both plugin suites together (not just collecting), runtime collisions still occur due to sys.modules caching of simple_storage between plugins. That's a separate, pre-existing issue requiring coordinated changes in both plugins' test setups (Telegram's tests aren't part of this PR's diff). The collection error you flagged — which is what made combined runs impossible — is fully fixed.

Re. your other non-blocking notes (persona-chat endpoint security review, local JSON storage) — those are product/security decisions outside my code-level responsibility, awaiting maintainer input.

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the collection mismatch by renaming the WhatsApp test modules. I verified the current head (c3fc73b9fa296a1f0991c469a50ccfa08eb23983) in a fresh venv with a stripped environment:

  • OMI_DEV_MODE=1 python -m pytest --collect-only plugins/_shared/test plugins/omi-telegram-app/test plugins/omi-whatsapp-app/test -q → 109 tests collected, 0 errors ✅
  • OMI_DEV_MODE=1 python -m pytest plugins/_shared/test plugins/omi-telegram-app/test plugins/omi-whatsapp-app/test -q → 74 passed, 35 failed ❌

The remaining failures are all in the WhatsApp suite after the Telegram suite has imported bare module names like main and simple_storage. The WhatsApp tests then resolve the Telegram modules from sys.modules, causing errors such as:

  • TypeError: save_user() got an unexpected keyword argument 'phone'
  • AttributeError: module 'simple_storage' has no attribute 'pending_setups_match_verify_token'
  • AttributeError: module 'main' has no attribute 'whatsapp_client'

I think this should be fixed before treating the PR as merge-ready. Even if each plugin suite passes in isolation, this PR adds the WhatsApp suite into a repository that already has the Telegram suite; a normal combined/repo-level pytest run should not depend on test order or module-cache state. Reasonable fixes could be packaging each plugin/test module namespace, using unique import names/importlib loading consistently, or configuring pytest/import mode so the plugin modules do not collide in sys.modules.

Separately, the broader approval gate still applies: this remains a large, stacked AI-clone/persona-chat feature touching backend impersonation, webhook/security behavior, third-party messaging, dependency/runtime files, and local secret storage. It needs human maintainer/product/security review before merge, regardless of test fixes.

…e collision

Maintainer re-review (PR BasedHardware#8488, review 4587772447):

> The remaining failures are all in the WhatsApp suite after the
> Telegram suite has imported bare module names like 'main' and
> 'simple_storage'. The WhatsApp tests then resolve the Telegram
> modules from sys.modules, causing errors such as:
> - TypeError: save_user() got an unexpected keyword argument 'phone'
> - AttributeError: module 'simple_storage' has no attribute
>   'pending_setups_match_verify_token'
> - AttributeError: module 'main' has no attribute 'whatsapp_client'
>
> Reasonable fixes could be packaging each plugin/test module
> namespace, using unique import names/importlib loading consistently,
> or configuring pytest/import mode so the plugin modules do not
> collide in sys.modules.

Fix: a runtime swap via an autouse fixture in WhatsApp's
conftest.py. Each WhatsApp test, on entry:

  1. Snapshots sys.modules['main'], ['simple_storage'],
     ['whatsapp_client'] (preserves whatever Telegram loaded).
  2. Loads the WhatsApp plugin's modules via importlib under unique
     identifiers (e.g. '_omi_whatsapp_app.main') and registers them
     under the bare names.
  3. The test runs with WhatsApp's modules in scope.
  4. On teardown, restores sys.modules to the snapshot.

Telegram's autouse fixture (if it had one) is unaffected because
pytest scopes conftest.py to its directory — the WhatsApp fixture
only fires for tests under plugins/omi-whatsapp-app/test/.

Test files now use 'from conftest import load_main_module' /
'load_simple_storage' instead of inlining the importlib dance. The
loaded module is the same instance the autouse fixture installs into
sys.modules, so 'main = load_main_module()' at module level and
'sys.modules["main"]' inside the autouse fixture point to the
same object.

The signature-verification fixture (TestWebhookSignature.client_with_secret)
had to be updated too — it previously called _cached_modules.clear()
which invalidated the cached simple_storage/whatsapp_client modules
for the rest of the session. It now snapshots + restores the cache so
subsequent tests see the same module instance.

Result:
  Before: OMI_DEV_MODE=1 pytest plugins/omi-telegram-app/test/ plugins/omi-whatsapp-app/test/ plugins/_shared/test/
    -> 74 passed, 35 failed
  After:  -> 109 passed, 0 errors

Each suite still passes standalone (WhatsApp: 42, Telegram: 48, Shared: 19).
@choguun

choguun commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the precise diagnosis — confirmed locally:

  • Before fix: OMI_DEV_MODE=1 pytest plugins/_shared/test plugins/omi-telegram-app/test plugins/omi-whatsapp-app/test -q → 74 passed, 35 failed
  • After fix: → 109 passed, 0 errors

The root cause was sys.modules collision on bare names (main, simple_storage, whatsapp_client). Telegram's tests load theirs at collection time and reference them again at test-runtime (from main import app), so any permanent pre-load of WhatsApp's modules under the same bare names broke Telegram's later tests.

Fix

An autouse fixture in plugins/omi-whatsapp-app/test/conftest.py that:

  1. Snapshots sys.modules['main' | 'simple_storage' | 'whatsapp_client'] before each WhatsApp test (preserves Telegram's values).
  2. Loads WhatsApp's modules via importlib under unique identifiers (_omi_whatsapp_app.main, etc.) and installs them under the bare names.
  3. Runs the test with WhatsApp's modules in scope (so patch("main.whatsapp_client.send_message", ...) resolves correctly).
  4. Restores the bare-name sys.modules entries on teardown.

Telegram's autouse fixture (if it had one) is unaffected because pytest scopes conftest.py to its directory — the WhatsApp fixture only fires for tests under plugins/omi-whatsapp-app/test/.

Helper API

Test files now use:

from conftest import load_main_module, load_simple_storage

main = load_main_module()
simple_storage = load_simple_storage()

The returned module is cached in conftest's _cached_modules dict, so all calls return the same instance — which is the one the autouse fixture installs into sys.modules. This means main = load_main_module() at module level and sys.modules["main"] during the test point to the same object, so state set by tests is visible to the running app and vice versa.

Signature-test fixture

TestWebhookSignature.client_with_secret (the one fixture that needs WHATSAPP_APP_SECRET set at module load time) had to be updated to snapshot + restore the cache instead of clearing it globally — clearing invalidated the simple_storage / whatsapp_client modules for the rest of the session.

Verification

Suite Result
plugins/omi-whatsapp-app/test/ (alone) 42 passed
plugins/omi-telegram-app/test/ (alone) 48 passed
plugins/_shared/test/ (alone) 19 passed
Combined 109 passed, 0 errors

Note on broader architecture

The deeper fix would be to package each plugin's modules under a unique namespace (e.g. omi_telegram_app.main, omi_whatsapp_app.main) so bare-name collisions are structurally impossible. That's a larger refactor that touches both plugins and the way they're imported by tests + production — out of scope for the v0.2 delta, but worth flagging as a follow-up if the team plans more chat-platform plugins.

The runtime-swap approach here keeps each plugin's main and simple_storage files portable for production (where they're loaded directly via uvicorn main:app or imported normally) and only adds the isolation cost in test code, where it belongs.

@choguun choguun requested a review from Git-on-my-level June 28, 2026 17:10

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up fix. I re-checked the current head (d67303d0294e133e35cfe5bae5e84360df3f2760) in a fresh venv with a stripped environment.

Verification now looks good for the previously blocking test-isolation issue:

  • OMI_DEV_MODE=1 python -m pytest --collect-only plugins/_shared/test plugins/omi-telegram-app/test plugins/omi-whatsapp-app/test -q → 109 tests collected ✅
  • OMI_DEV_MODE=1 python -m pytest plugins/_shared/test plugins/omi-telegram-app/test plugins/omi-whatsapp-app/test -q → 109 passed ✅

The new WhatsApp conftest.py module-loading isolation resolves the earlier Telegram/WhatsApp sys.modules collision in combined plugin runs. I do not see a remaining automated test blocker from the issue I previously requested changes on.

I’m still not formally approving because the broader maintainer gate applies: this is a large AI-clone/persona-chat feature touching backend impersonation, third-party messaging/webhooks, local secret storage, dependencies/runtime files, and product direction. It should still get human maintainer/product/security review before merge, even though the specific combined-test blocker appears fixed.

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

Labels

dependency-review Touches dependencies or lockfiles; needs dependency review docs-accuracy Documentation or committed reports need accuracy fixes feature-fit-review PR touches feature direction; qualitative fit assessed needs-maintainer-review Needs a human maintainer to review/approve (e.g. stacked, product, or architecture judgment) security-review Needs security/privacy review workflow-review Needs maintainer review for workflow, automation, hooks, or CI behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants