Skip to content

feat: isolated Telegram Bot API integration with localized key normalization#736

Open
Utkarsh-Sinha0 wants to merge 3 commits into
Spectral-Finance:mainfrom
Utkarsh-Sinha0:feature/bounty-telegram-core-isolated
Open

feat: isolated Telegram Bot API integration with localized key normalization#736
Utkarsh-Sinha0 wants to merge 3 commits into
Spectral-Finance:mainfrom
Utkarsh-Sinha0:feature/bounty-telegram-core-isolated

Conversation

@Utkarsh-Sinha0

@Utkarsh-Sinha0 Utkarsh-Sinha0 commented Jun 16, 2026

Copy link
Copy Markdown

Summary

This PR implements a strictly scoped, production-grade, and secure Telegram Bot API integration for the Lux multi-agent framework, fully adhering to native patterns and avoiding any changes to core framework files (prism.ex, lens.ex).

Core Enhancements

  1. Localized Parameter Key Normalization:

    • LLM tool-calling and webhooks output string-keyed maps (e.g., %{"chat_id" => 123}).
    • Telegram integration lenses and prisms rely on atom keys, which otherwise cause runtime crashes.
    • Added a safe, recursive Lux.Integrations.Telegram.normalize_keys/1 helper using String.to_existing_atom/1 (preventing atom leaks / DoS attacks) and ignoring Elixir structs.
    • This is cleanly integrated directly within the Telegram integration client, webhook, lenses, and prisms, keeping core framework logic completely untouched.
  2. Hardened Bot Token URL Substitution:

    • Replaced naive string matching with a segment-isolated regex ~r/(^|\/)bot(?::token|(?=\/|$))(\/?)/.
    • This ensures path substitution only affects actual bot token placeholders (e.g. /bot:token/ or /bot/), preventing corruption of custom URLs containing somebot.
  3. Resilient HTTP Client:

    • Standardized exponential backoff retries for transient errors (500, 502, 503, 504) and transport errors.
    • Handles rate-limiting (429) automatically by parsing retry_after headers/body fields.
    • Supports serial queued request execution (request_many/2) for ordered message flows.
    • Supports multipart file uploads using File.stream!/3.
  4. Webhooks & Rich Update Parsing:

    • Plug-compatible webhook verification utilizing Plug.Crypto.secure_compare/2 for constant-time comparison of incoming secret tokens.
    • Unified %Lux.Integrations.Telegram.Update{} parser that categorizes incoming updates (messages, callback queries, inline queries, channel posts) and safely normalizes their structure.
  5. Lenses & Prisms (R1 Compliance):

    • Lenses (Read): ListUpdates (getUpdates), GetWebhookInfo (getWebhookInfo), GetFile (getFile). These are purely declarative and use after_focus/1 callbacks without overriding the focus/2 macro.
    • Prisms (Write): SetWebhook (setWebhook) and DeleteWebhook (deleteWebhook) implementing clean handler logic.

Verification & Test Coverage

  • Verified by 70 unit tests (no external network dependencies) achieving complete coverage of all execution branches.
  • Evaluated and verified clean within Erlang/OTP 27 Docker container.

✅ Review fixes — addressing @MyTH-zyxeon's review-assist (commit 979ce21)

The review-assist for bounty #62 gated on three items. All are resolved, kept strictly scoped to the Telegram integration (still no prism.ex / lens.ex changes).

1. Key normalization was only wired into the new webhook prisms

Issue raised: normalize_keys/1 was used by the new webhook prisms/lenses, but the pre-existing messaging/media/interactive prisms still read atom keys directly — e.g. SendMessage.handler/2 did Map.fetch(params, :chat_id) — so the most basic string-keyed LLM/JSON input (%{"chat_id" => 123, "text" => "hi"}) still failed on the send path.

Fix: Applied Lux.Integrations.Telegram.normalize_keys/1 as the first line of every messaging, media, and interactive handler — the exact one-line pattern the webhook prisms already established. No new abstraction, no core change.

2. Missing-token behavior was inconsistent between client and lenses

Issue raised: Client.request/3 returns {:error, :missing_token}, but Telegram.add_auth/1 left the lens URL with the literal bot:token placeholder when no token was configured — so ListUpdates / GetFile / GetWebhookInfo could issue a live request against https://api.telegram.org/bot:token/… instead of failing locally.

Fix: Each read lens now guards focus/2: with no token it returns {:error, :missing_token} (the same shape the client returns) with no network call, and delegates to super/2 unchanged when a token is present. Implemented with defoverridable focus: 2, so the guard lives entirely inside the Telegram lenses (the Lux.Lens macro only exposes after_focus/before_focus as overridable).

3. Acceptance should cover the existing send/edit/delete/media/interactive surface

Fix: Added regression tests for the string-key path across all three families — SendMessage (messaging), SendPhoto (media), AnswerCallbackQuery (interactive) — plus a missing_token_test covering all three lenses.

Before / after

Metric Before After
Telegram prisms applying key normalization 2 / 30 (webhook only) 30 / 30
SendMessage.handler(%{"chat_id" => 1, "text" => "hi"}) {:error, "Missing or invalid chat_id"} {:ok, %{sent: true, …}}
Read lens with no token configured live GET to …/bot:token/getUpdates {:error, :missing_token} (local, no network)
Client ↔ lens missing-token parity inconsistent identical {:error, :missing_token}
Telegram regression tests added +5 cases (3 string-key handlers, 1 lens missing-token × 3 lenses)
Telegram unit suite (OTP 27.2 / Elixir 1.18.1) 296 tests, 0 failures
Core framework files touched (prism.ex/lens.ex) 0 0

Design — read-lens missing-token guard

flowchart LR
    A["Lens.focus/2"] --> B{"get_token/0"}
    B -- "token present" --> C["super/2: authenticate + HTTP request"]
    C --> D["after_focus → {:ok, result}"]
    B -- "no token" --> E["{:error, :missing_token} (local, no request)"]
Loading
  • Item 1 reuses the existing recursive, struct-safe normalize_keys/1 (String.to_existing_atom/1, so the atom table cannot grow from untrusted input) as a per-handler choke point — identical to the webhook-prism pattern, so the read/write surfaces stay consistent.
  • Item 2 keeps reads (lenses) and writes (client) returning the same {:error, :missing_token} contract, localized via defoverridable so lens.ex stays untouched.

@MyTH-zyxeon

Copy link
Copy Markdown

Maintainer review-assist for bounty #62:

This looks like a useful Telegram-core hardening path because it keeps the key-normalization work localized to the Telegram integration instead of changing the general Prism/Lens runtime, and it adds concrete read/webhook surfaces (ListUpdates, GetFile, GetWebhookInfo, setWebhook, deleteWebhook) plus retry/rate-limit tests.

Before treating it as a complete #62 closure, I would gate on three items:

  1. The string-key normalization is not yet applied to the existing Telegram prism surface. Lux.Integrations.Telegram.normalize_keys/1 is used in the new webhook prisms/lenses, but pre-existing modules under lux/lib/lux/prisms/telegram/{messaging,media,interactive} still fetch atom keys directly. For example, SendMessage.handler/2 still does Map.fetch(params, :chat_id) / Map.fetch(params, :text), so LLM/JSON input like %{"chat_id" => 123, "text" => "hi"} can still fail on the most basic send-message path. Either add a shared Telegram-prism normalization path or normalize at every existing Telegram handler, with a regression test for a string-keyed SendMessage.handler/2 call.

  2. Missing-token behavior is inconsistent between the client and lenses. Client.request/3 now returns {:error, :missing_token}, but Telegram.add_auth/1 leaves a %Lux.Lens{url: "...bot:token/..."} unmodified when no token is configured. That means ListUpdates / GetFile / GetWebhookInfo can still attempt a live request with the literal placeholder instead of failing locally with the same deterministic missing-token shape.

  3. The Telegram Core API Integration ($2,500) #62 acceptance criteria cover the full Telegram Bot API lens/integration surface, including message/media/keyboard flows. This PR strengthens the client, update parsing, read lenses, and webhook management, but acceptance should also verify that the existing send/edit/delete/media/interactive prism set still works through the new client semantics and the string-key input path.

No local tests run here; this is based on the visible PR diff at 415656f06b069f67bebba6339f0119d4231edb53.

…l-Finance#62)

1. Apply Lux.Integrations.Telegram.normalize_keys/1 to all messaging, media,
   and interactive Telegram prism handlers so string-keyed LLM/JSON input
   (e.g. %{"chat_id" => 1, "text" => "hi"}) works on every prism, matching the
   pattern already used by the webhook prisms.
2. Guard ListUpdates/GetFile/GetWebhookInfo focus so a missing bot token fails
   locally with {:error, :missing_token} instead of issuing a live request to
   the unsubstituted ":token" URL, consistent with Client.request/3.
3. Add regression tests: string-keyed handler tests for SendMessage, SendPhoto,
   and AnswerCallbackQuery, plus a missing-token lens test for all three lenses.

Scoped to the Telegram integration; no core framework (prism.ex/lens.ex) changes.
@Utkarsh-Sinha0

Utkarsh-Sinha0 commented Jun 18, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review-assist @MyTH-zyxeon — all three gating items are now addressed in 979ce21. Summary below.

✅ Review fixes — addressing @MyTH-zyxeon's review-assist (commit 979ce21)

The review-assist for bounty #62 gated on three items. All are resolved, kept strictly scoped to the Telegram integration (still no prism.ex / lens.ex changes).

1. Key normalization was only wired into the new webhook prisms

Issue raised: normalize_keys/1 was used by the new webhook prisms/lenses, but the pre-existing messaging/media/interactive prisms still read atom keys directly — e.g. SendMessage.handler/2 did Map.fetch(params, :chat_id) — so the most basic string-keyed LLM/JSON input (%{"chat_id" => 123, "text" => "hi"}) still failed on the send path.

Fix: Applied Lux.Integrations.Telegram.normalize_keys/1 as the first line of every messaging, media, and interactive handler — the exact one-line pattern the webhook prisms already established. No new abstraction, no core change.

2. Missing-token behavior was inconsistent between client and lenses

Issue raised: Client.request/3 returns {:error, :missing_token}, but Telegram.add_auth/1 left the lens URL with the literal bot:token placeholder when no token was configured — so ListUpdates / GetFile / GetWebhookInfo could issue a live request against https://api.telegram.org/bot:token/… instead of failing locally.

Fix: Each read lens now guards focus/2: with no token it returns {:error, :missing_token} (the same shape the client returns) with no network call, and delegates to super/2 unchanged when a token is present. Implemented with defoverridable focus: 2, so the guard lives entirely inside the Telegram lenses (the Lux.Lens macro only exposes after_focus/before_focus as overridable).

3. Acceptance should cover the existing send/edit/delete/media/interactive surface

Fix: Added regression tests for the string-key path across all three families — SendMessage (messaging), SendPhoto (media), AnswerCallbackQuery (interactive) — plus a missing_token_test covering all three lenses.

Before / after

Metric Before After
Telegram prisms applying key normalization 2 / 30 (webhook only) 30 / 30
SendMessage.handler(%{"chat_id" => 1, "text" => "hi"}) {:error, "Missing or invalid chat_id"} {:ok, %{sent: true, …}}
Read lens with no token configured live GET to …/bot:token/getUpdates {:error, :missing_token} (local, no network)
Client ↔ lens missing-token parity inconsistent identical {:error, :missing_token}
Telegram regression tests added +5 cases (3 string-key handlers, 1 lens missing-token × 3 lenses)
Telegram unit suite (OTP 27.2 / Elixir 1.18.1) 296 tests, 0 failures
Core framework files touched (prism.ex/lens.ex) 0 0

Design — read-lens missing-token guard

flowchart LR
    A["Lens.focus/2"] --> B{"get_token/0"}
    B -- "token present" --> C["super/2: authenticate + HTTP request"]
    C --> D["after_focus → {:ok, result}"]
    B -- "no token" --> E["{:error, :missing_token} (local, no request)"]
Loading
  • Item 1 reuses the existing recursive, struct-safe normalize_keys/1 (String.to_existing_atom/1, so the atom table cannot grow from untrusted input) as a per-handler choke point — identical to the webhook-prism pattern, so the read/write surfaces stay consistent.
  • Item 2 keeps reads (lenses) and writes (client) returning the same {:error, :missing_token} contract, localized via defoverridable so lens.ex stays untouched.

@Utkarsh-Sinha0

Copy link
Copy Markdown
Author

/claim #62

1 similar comment
@Utkarsh-Sinha0

Copy link
Copy Markdown
Author

/claim #62

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.

3 participants