Skip to content

release: 0.4.29 completion train — stranded-PR recovery + remaining 4.29 ports + cut#134

Merged
patrick-chinchill merged 14 commits into
mainfrom
claude/upbeat-goodall-4djz9p
Jun 18, 2026
Merged

release: 0.4.29 completion train — stranded-PR recovery + remaining 4.29 ports + cut#134
patrick-chinchill merged 14 commits into
mainfrom
claude/upbeat-goodall-4djz9p

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What this is

The 0.4.29 completion train: stranded-PR recovery + the remaining 4.29 ports, finishing with the release cut. One logical item per commit.

Updated 2026-06-16 — head 44c44fd. Three post-review fixes were added on top of the original train after an independent local review:

  • be28808 — fix(chat): await the modal-context state write before opening the modal (was fire-and-forget; aligns to upstream, closes a remote-state race)
  • 19a8848 — fix(messenger): is not None for config credential fallbacks (truthiness port-rule)
  • 44c44fd — fix(types): widen ChatInstance.process_message return annotation to match the impl

Merge order is TRAIN-FIRST (the integration the reviews validated): this train merges first, then the companions rebase/merge onto the train-inclusive main. (The original "train last" note is superseded.) #133 is superseded by #137.
Sequence: #134#130#131#107#112#127#125#137#136.
Three new tests here are skipif-gated on BaseAdapter.fetch_subject and auto-activate when #131 lands.

Commits

  1. feat(messenger): adapter (PR 2 of 2) — recovery of feat(messenger): adapter — webhook, Graph API, send/stream (PR 2 of 2) #124 (GitHub showed it "merged" but its base was the already-merged scaffolding branch, not main; cherry-picked verbatim, cf. previously-reviewed head 1405271).
  2. feat(ai): create_chat_tools (PR 2 of 3) — recovery of feat(ai): add create_chat_tools tool factory (PR 2 of 3) #122 (same stranding; cf. fb8c50a).
  3. feat(chat): process_message returns the handler taskfeat: add @chat-adapter/web — browser chat UI for chat-sdk bots vercel/chat#444 + fidelity alignment for the 4.29 pin.
  4. release: 0.4.29 — version 0.4.29, consolidated CHANGELOG (+ backfilled 0.4.27.1), README, lint.yml re-pinned to chat@4.29.0, deferral rows (Teams Migrate Teams adapter to microsoft-teams-apps (official MS Python SDK) #93 → 0.4.30, tests-kit #470, GitHub/Linear client getters), examples/ai_tools_example.py.
  5. feat(slack): native markdown_text outgoingfeat(slack): use native markdown_text for outgoing messages vercel/chat#440.
  6. feat(slack): external installation provider[slack] add support for external installation providers for bot token management vercel/chat#467 (composes with the ContextVar token resolver; deliberately avoids feat(slack): expose web_client property on SlackAdapter (#98) #127's surface).
  7. fix(discord): gateway-only interactionsfix(discord): handle interactions in gateway-only mode vercel/chat#490.
  8. feat(chat): Transcripts API + thread_history renamefeat(chat): add Transcripts API + rename per-thread cache to threadHistory vercel/chat#448 (back-compat shims; msg-history: storage prefix preserved).
  9. feat(chat): callback_url on buttons and modalsfeat(chat): add callbackUrl to buttons and modals vercel/chat#454.

Plus a final commit: fidelity MAPPING extended to the new 4.29 core test files, baseline regenerated at chat@4.29.0, and Known Non-Parity rows for the wave's divergences/deferrals.

Validation (full gauntlet at 44c44fd)

ruff check / ruff format --check          clean
audit_test_quality.py                     0 hard failures (39 pre-existing warnings)
verify_test_fidelity.py --strict @4.29.0  731/731 (100%)
pytest tests/ -q                          4450 passed, 6 skipped
pyrefly check src/                        0 errors

(6 skips = 3 pre-existing documented + 3 skipif-gated on #131.) GitHub CI green on 44c44fd.

What this completes

Every item in the 0.4.29 sync scope (#98 / PR #108) is ported or documented as deferred, the version is cut, and CI is strict-green at the chat@4.29.0 pin. The 4.30 wave is scoped in #135.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64

#124)

* feat(messenger): adapter — webhook, Graph API, send/stream (vercel/chat#461)

Port of `packages/adapter-messenger/src/index.ts` (PR 2 of 2). Builds on
the scaffolding (types/format converter/cards) added in PR #118.

Includes:
- `MessengerAdapter`: webhook routing (GET verification + POST events),
  X-Hub-Signature-256 HMAC-SHA256 verification, Graph API client backed by
  aiohttp, send paths (text / Generic template / Button template / text
  fallback), buffered streaming, postback / reaction / echo / delivery /
  read handling, attachment extraction with lazy download, message cache
  (Messenger has no history API), user-profile cache, thread / channel
  helpers, typed Graph-API error mapping.
- `create_messenger_adapter` factory with FACEBOOK_* env fallbacks.
- Exports wired through `chat_sdk.adapters.messenger`.

Q1 (init-failure behavior, see #110): match `WhatsAppAdapter` — raise
`ValidationError` from both the factory and the constructor when any
required credential is missing, so config errors surface loudly at
startup.

Q3 (signature verification, see #110): pin upstream's X-Hub-Signature-256
+ App Secret HMAC contract. A swappable verifier (Slack-style) would
diverge from Meta's protocol with no offsetting benefit for a
single-secret integration; flagged as a possible future divergence but
not introduced here.

Tests: `tests/test_messenger_webhook.py` (57) and
`tests/test_messenger_api.py` (64) — mirrors the Telegram/WhatsApp file
split. Covers signature valid/invalid/missing/wrong-algo/replay,
webhook routing for all event types, postback decoding (raw + chat:
prefix), card → template paths, stream buffering, truncation, error
mapping, and the Q1 constructor failure path. 121 new tests, full
suite: 4220 passed, 3 skipped.

* fix(messenger): verify HMAC on raw bytes + URL/profile defenses (gemini review)

Four follow-ups to PR #124 from Gemini's review of the Messenger adapter:

- `_handle_verification` casts `request.url` to `str` before `urlparse`.
  Starlette/yarl `URL` objects are non-`str` and would raise.

- `_get_request_body` now returns `bytes` instead of `str`. Decoding to UTF-8
  and re-encoding for the HMAC step risks replacement characters (U+FFFD)
  for any non-UTF-8 byte sequence — that breaks signature parity with
  Meta's reference implementation and silently rejects legitimate webhooks.
  Body sources are also reordered: `body` attribute first (canonical raw
  bytes on Starlette/Django), `text` attribute fallback (aiohttp's str path).
  `is not None` everywhere so a legitimately empty `b""` body isn't skipped.

- `_verify_signature(body: bytes, ...)` matches the new contract; the
  `body.encode("utf-8")` step is dropped — HMAC operates on the exact wire
  bytes Meta signed. JSON parsing at the call site still works (`json.loads`
  accepts both str and bytes).

- `_fetch_user_profile` now `isinstance(profile, dict)` checks the Graph
  API response before caching. A non-dict response (None, list, unexpected
  shape) used to poison `_user_profile_cache` and raise `AttributeError`
  on the next `.get` call in `_profile_display_name`; we now fall back to
  the minimal `{"id": user_id}` profile and leave the cache untouched.

Regression coverage (+7 tests):

- `TestVerifySignature::test_verifies_raw_bytes_without_encoding_roundtrip`
  feeds a body with lone continuation bytes (0x80 0xff) that don't survive
  UTF-8 round-trip. Fails on the old decode+re-encode path.
- `TestGetRequestBody` (new class, 5 cases) pins the bytes return type
  across all four framework-shaped inputs: bytes body attribute, str body
  attribute, async-callable body (Starlette/FastAPI), async-callable text
  (aiohttp), and missing-body → `b""`.
- `test_non_dict_profile_response_falls_back` exercises both `None` and
  list responses, asserting the fallback display name and that the cache
  stays empty.

Existing `TestVerifySignature` cases updated to pass `bytes` bodies,
matching the new contract. `_sign` test helper accepts `bytes | str` for
convenience.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

* fix(messenger): accept uppercase-hex signatures + clarify init-failure docstring (review)

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

* fix(messenger): implement rehydrate_attachment for queue/debounce safety (codex)

Codex P2: Messenger attachments processed under queue/debounce/burst
concurrency lose their fetch_data closure during JSON serialization
in the state backend.  Without a rehydrate_attachment hook, dequeued
handlers see attachment.fetch_data is None and cannot download the
file.

Mirrors WhatsAppAdapter.rehydrate_attachment (same Meta family, same
queue-mode failure shape):
- _extract_attachments now persists the download URL on
  attachment.fetch_metadata={"url": url}
- New rehydrate_attachment reads that URL and rebuilds the lazy
  downloader via _make_attachment_downloader, reusing the shared
  aiohttp session.  Returns the attachment unchanged when metadata
  is missing/incomplete (degraded mode, matches the documented
  "leave unchanged when no hook" behavior).

No auth headers are attached by the rebuilt closure — Messenger
payload URLs are signature-gated by Meta and the original
_download_attachment already operated without Bearer tokens.

Upstream parity: vercel/chat's TS adapter-messenger does not
implement this hook because queue mode is Python-only.

Tests (4 new, load-bearing):
- fetch_metadata carries the URL after extraction
- queue/serialize roundtrip + rehydrate restores a working
  downloader that hits the original URL (would fail without the
  hook — verified by temporary revert)
- two degraded-mode pins (no metadata, metadata without url key)

* fix(messenger): use is-not-None for limit/user_name/cache checks + regression tests (audit)

Targeted audit follow-up — three real truthiness bugs in
src/chat_sdk/adapters/messenger/adapter.py, same Port Rule #1 root pattern:

1. _paginate_messages: ``options.limit or 50`` silently swallowed an
   explicit ``limit=0`` and substituted 50. Switched to ``is not None``.
   Regression: tests/test_messenger_api.py::test_explicit_zero_limit_is_not_swallowed_to_default.

2. __init__: ``config.user_name or "bot"`` paired with
   ``bool(config.user_name)`` silently replaced an explicit ``user_name=""``
   with the ``"bot"`` fallback AND left ``_has_explicit_user_name`` False,
   so ``initialize()`` would then overwrite it from ``chat.get_user_name()``
   / ``/me``. Switched both sites to ``is not None`` to match upstream's
   ``hasExplicitUserName`` semantics.
   Regression: tests/test_messenger_webhook.py::test_explicit_empty_user_name_is_respected.

3. _fetch_user_profile: ``if cached:`` treated a cached ``{}`` (empty dict,
   falsy) as a miss and re-fetched every call. Switched to
   ``if cached is not None``. Theoretical (Graph API rarely returns ``{}``)
   but cheap to make robust.
   Regression: tests/test_messenger_api.py::test_empty_dict_cache_entry_is_a_hit_not_a_miss.

All three regression tests are load-bearing: each asserts an outcome the
old code would have produced differently. Other Meta-family adapters
(Teams, Discord, Google Chat) already use the ``is not None`` pattern for
the same code site.

Validation: ruff check / format clean, audit_test_quality.py 0 hard
failures, 4235 passed / 3 skipped (was 4232 + 3 new tests = 4235).

* fix(messenger): thread echo events by recipient PSID in parse_message (codex review)

For a Messenger echo event (a bot-sent message echoed back), sender.id is
the Page ID and recipient.id is the user's PSID — the reverse of a normal
inbound message. parse_message previously keyed the thread ID off sender.id
unconditionally, so a replayed echo was threaded under messenger:<page id>
instead of messenger:<user PSID>, causing fetch_messages('messenger:<PSID>')
to miss the bot's own echoed message.

Branch on message.is_echo so the thread ID keys off recipient.id for echoes
(matching _handle_echo) and sender.id otherwise. Non-echo behavior unchanged.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

* build(messenger): add messenger extra declaring aiohttp (codex review)

The Messenger adapter lazy-imports aiohttp on its runtime paths (initialize,
send, profile lookup, attachment downloads), but the package ships
`dependencies = []` and had no `messenger` optional-dependencies extra. A base
install enabling only Messenger would raise `ModuleNotFoundError: aiohttp` the
moment the adapter talks to Meta. Add `messenger = ["aiohttp>=3.9"]`, matching
the sibling aiohttp adapters (telegram/whatsapp/teams/linear). The `all` extra
already includes aiohttp>=3.9, so no change there.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

---------

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 3ad373a)

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
* feat(ai): add create_chat_tools tool factory (PR 2 of 3) (vercel/chat#492)

Port of `packages/chat/src/ai/tools.ts` (plus the supporting
`tools/{channels,messages,reactions,threads,users}.ts` and `types.ts`)
introduced by vercel/chat#492. Builds on top of #116, which moved
`chat_sdk.ai` from a single module to a package so `tools.py` has a home.

`create_chat_tools(chat, preset=, require_approval=, overrides=)` returns
a mapping of tool-name -> `ChatTool` dataclass holding a description,
JSON-Schema-shaped `input_schema`, an async `execute` callable, and the
`needs_approval` flag. Three presets (`reader` / `messenger` /
`moderator`) and per-tool overrides match the upstream API surface
verbatim. Individual factories (`post_message`, `add_reaction`, ...) are
also exported so callers can cherry-pick.

PR 3 will wire these tools into the existing handler paths; this PR
adds the surface only.

Validation:
  uv run ruff check src/ tests/                  -> clean
  uv run ruff format --check src/ tests/         -> clean
  uv run python scripts/audit_test_quality.py    -> 0 hard failures
  uv run pytest tests/ -q                        -> 4081 passed, 3 skipped

Refs #98, #109.

* fix(ai/tools): wrap ChatNotImplementedError + tighten preset check (gemini review)

Four small follow-ups to PR 2 of the chat-ai port from Gemini's review:

- Import `ChatNotImplementedError` alongside `ChatError`.
- Wrap `fetch_channel_messages` invocation in `try/except
  ChatNotImplementedError` and re-raise as `ChatError` (preserves cause).
  The early `getattr(adapter, "fetch_channel_messages", None) is None`
  branch only catches missing attributes; `BaseAdapter` exposes the
  attribute as a stub that itself raises `ChatNotImplementedError`, so
  without this wrap the tool surfaces a different exception type than
  callers expect. Matches the `Chat.get_user` pattern.
- Same wrap for `list_threads`.
- `_resolve_preset_tools` now distinguishes a single `str` preset from any
  iterable of presets via `isinstance(preset, str)` rather than
  `isinstance(preset, list)`, so tuples/sets/Sequences work correctly.

Two new regression tests (`test_fetch_channel_messages_wraps_not_implemented`,
`test_list_threads_wraps_not_implemented`) drive an `AsyncMock` whose
`side_effect` is `ChatNotImplementedError`, assert the surfaced exception is
`ChatError` with the expected message, and pin the cause chain
(`__cause__ is ChatNotImplementedError`).

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

* fix(ai/tools): isolate per-tool input schemas to prevent mutation bleed (review)

* fix(ai/tools): pass list_threads options as keyword (codex P2)

`MockAdapter.list_threads` is declared as
`list_threads(self, channel_id, **kwargs)`, so the tool's positional
`list_method(channel_id, ListThreadsOptions(...))` raised `TypeError` for
any consumer wiring `create_chat_tools` into MockAdapter (the SDK's own
mock). Production adapters accept the kwarg form just as readily as the
positional form, so the change is universally safe.

The existing `test_list_threads_projects_summaries` used
`AsyncMock(return_value=...)` which masked the TypeError by replacing the
real `MockAdapter.list_threads` entirely. New
`test_list_threads_uses_keyword_options` exercises the real MockAdapter
and asserts the default empty result — fails on the old positional call
with `TypeError`, passes after.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj

* style(ai/tools): apply is-not-None idiom + hoist core import (audit nits)

---------

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit bf66edb)

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Messenger (Meta) adapter with webhook HMAC verification and Graph API send support; implements an AI tools layer exposing chat operations as ChatTool factories with approval/preset controls; introduces persistent ThreadHistoryCache and TranscriptsApiImpl; adds callback_url encoding/resolve/posting; updates Slack/Discord/other adapters and format converters; includes broad test coverage, docs, and a release/version bump to 0.4.29.

Changes

Core features and integrations

Layer / File(s) Summary
Packaging & docs
pyproject.toml, README.md, CHANGELOG.md, CLAUDE.md, docs/UPSTREAM_SYNC.md, .github/workflows/lint.yml, examples/ai_tools_example.py, scripts/*
Bumps project version to 0.4.29, adds messenger optional dep (aiohttp>=3.9), updates README/changelog/docs to upstream v4.29.0, updates CI clone pin, and adds an AI tools example.
Messenger module exports & factory
src/chat_sdk/adapters/messenger/__init__.py, src/chat_sdk/adapters/messenger/adapter.py
Exports Messenger adapter constants/classes and adds create_messenger_adapter factory.
Messenger adapter core
src/chat_sdk/adapters/messenger/adapter.py
Implements MessengerAdapter lifecycle, webhook GET verification, POST signature verification (HMAC-SHA256), and event dispatcher.
Messenger events & IO
src/chat_sdk/adapters/messenger/adapter.py
Parses incoming messages, attachments (lazy download/rehydration), echoes, postbacks, reactions, user-profile caching, per-thread message cache, sorting/pagination, truncation, and send/typing behaviors.
Graph API & helpers
src/chat_sdk/adapters/messenger/adapter.py
Graph API request wrapper, error translation into typed adapter errors, framework-agnostic request body/header helpers, and adapter factory validation.
AI tools framework
src/chat_sdk/ai/tools.py, src/chat_sdk/ai/__init__.py
Adds ChatTool dataclass, per-operation tool factories, projection/normalization helpers, and create_chat_tools orchestrator with presets, approval resolution, and safe overrides; re-exports in ai package.
Thread history & message_history shim
src/chat_sdk/thread_history.py, src/chat_sdk/message_history.py
Adds ThreadHistoryCache and config; message_history becomes a compatibility shim re-exporting thread-history types.
Transcripts API
src/chat_sdk/transcripts.py, src/chat_sdk/types.py
Adds TranscriptsApiImpl, transcript types, append/list/count/delete semantics, retention parsing, tombstones, and Chat.transcripts wiring.
Callback URL handling
src/chat_sdk/callback_url.py, src/chat_sdk/cards.py, src/chat_sdk/modals.py
Adds callback_url support for buttons/modals: token encode/decode, process_card_callback_urls, resolve_callback_url, and post_to_callback_url; Card/Modal/Button builders updated.
Channel/Thread integration
src/chat_sdk/channel.py, src/chat_sdk/thread.py, src/chat_sdk/chat.py
Wires thread_history into Channel/Thread/Chat, applies callback_url processing on outbound posts/ephemeral/schedule/edit, updates process_message/process_action/modal flows to post callback URLs and await where appropriate, and populates message.user_key via identity resolver for transcripts.
Slack/Discord/other adapter updates
src/chat_sdk/adapters/slack/*, src/chat_sdk/adapters/discord/*, src/chat_sdk/adapters/telegram/*, src/chat_sdk/adapters/whatsapp/*
Slack: enterprise installation_provider, enterprise_id context, attachment metadata/rehydration, refactored payload rendering and format converter changes; Discord: custom_id encode/decode and forwarded gateway interaction handling; Telegram/WhatsApp: rename persist_message_history → persist_thread_history properties.
Tests
tests/*
Adds comprehensive tests: Messenger API/webhook, AI tools, callback-url, thread_history, transcripts and wiring, Slack installation provider, format converter updates, Discord custom_id tests, forwarded gateway interactions, rebind/serialization adjustments, and many faithful/integration test updates.
Estimated code review effort 🎯 4 (Complex)
  • Possibly related issues:
    • #78 — verify_test_fidelity.py MAPPING and parity tag updated for chat@4.29.0.

🐰 A rabbit hops through Meta's door,
With webhooks signed and caches pure,
While tools for AI stand ready, poised,
To fetch and post where agents voice.
Now messages and reactions flow,
Let the Messenger garden grow! 🌱

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 12, 2026 19:24

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Meta Messenger adapter for the Chat SDK, implementing webhook routing, signature verification, and message/card sending via the Meta Graph API. It also adds a framework-agnostic tool factory to expose Chat SDK operations to AI agents, along with comprehensive test suites. The feedback highlights a potential RuntimeError in _get_request_body when handling aiohttp requests, a possible AttributeError in _project_message when message.metadata is None, and an opportunity to cache fallback user profiles to prevent redundant Graph API calls on failure.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1095 to +1120
async def _get_request_body(request: Any) -> bytes:
"""Extract the raw request body as bytes (sync or async).

Returned as raw bytes so signature verification operates on the exact
wire bytes Meta signed. Any encode/decode round-trip risks replacement
characters or altered code points and breaks HMAC parity.
"""
body = getattr(request, "body", None)
if body is not None:
if callable(body):
result = body()
body = await result if inspect.isawaitable(result) else result
if isinstance(body, (bytes, bytearray)):
return bytes(body)
if isinstance(body, str):
return body.encode("utf-8")
text_attr = getattr(request, "text", None)
if text_attr is not None:
if callable(text_attr):
result = text_attr()
text_attr = await result if inspect.isawaitable(result) else result
if isinstance(text_attr, (bytes, bytearray)):
return bytes(text_attr)
if isinstance(text_attr, str):
return text_attr.encode("utf-8")
return b""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In frameworks like aiohttp, accessing request.body directly as a property before calling await request.read() raises a RuntimeError: Read first. Since getattr(request, "body", None) does not catch RuntimeError, this can crash the webhook handler. Additionally, falling back to request.text performs an encode/decode round-trip which can alter the raw wire bytes and break HMAC signature verification.

To prevent this, we should try calling request.read() first (which is standard in aiohttp to get raw bytes), and wrap the attribute accesses in try...except blocks to ensure robustness. Since this involves handling exceptions for asynchronous operations, ensure that the await statement is placed inside the try block so that exceptions are caught during execution, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.

    @staticmethod
    async def _get_request_body(request: Any) -> bytes:
        """Extract the raw request body as bytes (sync or async).

        Returned as raw bytes so signature verification operates on the exact
        wire bytes Meta signed. Any encode/decode round-trip risks replacement
        characters or altered code points and breaks HMAC parity.
        """
        # Try reading via a callable 'read' method first (e.g. aiohttp) to get raw bytes directly
        read_attr = getattr(request, "read", None)
        if read_attr is not None and callable(read_attr):
            try:
                result = read_attr()
                body_bytes = await result if inspect.isawaitable(result) else result
                if isinstance(body_bytes, (bytes, bytearray)):
                    return bytes(body_bytes)
            except Exception:
                pass

        # Fallback to 'body' attribute, guarding against properties that raise exceptions (e.g. aiohttp's BaseRequest.body)
        try:
            body = getattr(request, "body", None)
            if body is not None:
                if callable(body):
                    result = body()
                    body = await result if inspect.isawaitable(result) else result
                if isinstance(body, (bytes, bytearray)):
                    return bytes(body)
                if isinstance(body, str):
                    return body.encode("utf-8")
        except Exception:
            pass

        # Fallback to 'text' attribute/method
        try:
            text_attr = getattr(request, "text", None)
            if text_attr is not None:
                if callable(text_attr):
                    result = text_attr()
                    text_attr = await result if inspect.isawaitable(result) else result
                if isinstance(text_attr, (bytes, bytearray)):
                    return bytes(text_attr)
                if isinstance(text_attr, str):
                    return text_attr.encode("utf-8")
        except Exception:
            pass

        return b""
References
  1. When handling exceptions for asynchronous operations, ensure that the await statement is placed inside the try block so that exceptions are caught during execution, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.

Comment thread src/chat_sdk/ai/tools.py
Comment on lines +258 to +259
"dateSent": (message.metadata.date_sent.isoformat() if message.metadata.date_sent else None),
"edited": message.metadata.edited,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If message.metadata is None, accessing message.metadata.date_sent and message.metadata.edited directly will raise an AttributeError. Since message.metadata is optional, we should add appropriate is not None checks before accessing its properties to ensure defensive programming and prevent runtime crashes. Avoid using truthiness checks (like if message.metadata) to check for optional values that can be falsy but valid, to ensure we do not silently ignore them.

Suggested change
"dateSent": (message.metadata.date_sent.isoformat() if message.metadata.date_sent else None),
"edited": message.metadata.edited,
"dateSent": (message.metadata.date_sent.isoformat() if message.metadata is not None and message.metadata.date_sent is not None else None),
"edited": message.metadata.edited if message.metadata is not None else False,
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

Comment on lines +918 to +921
except Exception:
# On any error, fall back to a minimal profile carrying just the
# user ID. Matches upstream's silent fallback.
return {"id": user_id}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When fetching the user profile fails, the adapter falls back to a minimal profile carrying just the user ID. However, this fallback profile is not cached. As a result, every subsequent message from this user will trigger another failing Graph API call, leading to unnecessary network overhead and potential performance bottlenecks.

We should cache the fallback profile in self._user_profile_cache to prevent repeated failing API calls.

Suggested change
except Exception:
# On any error, fall back to a minimal profile carrying just the
# user ID. Matches upstream's silent fallback.
return {"id": user_id}
except Exception:
# On any error, fall back to a minimal profile carrying just the
# user ID. Matches upstream's silent fallback. Cache the fallback
# to avoid spamming the Graph API on subsequent messages.
fallback: MessengerUserProfile = {"id": user_id}
self._user_profile_cache[user_id] = fallback
return fallback

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b3b48734e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +703 to +705
async def open_dm(self, user_id: str) -> str:
"""Open a DM with a user. Returns the encoded thread ID."""
return self.encode_thread_id(MessengerThreadId(recipient_id=user_id))

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 Badge Wire Messenger users into open_dm inference

When callers use the generic Chat.open_dm path (including the new AI sendDirectMessage tool), this Messenger open_dm implementation is not reachable: Chat._infer_adapter_from_user_id has cases for Slack, Teams, Google Chat, Linear, and numeric Discord/Telegram/GitHub IDs, but no Messenger case. As a result, a Messenger-only bot calling sendDirectMessage with a PSID such as the adapter’s own USER_123 examples raises Cannot infer adapter... instead of returning messenger:USER_123; add Messenger inference/qualified user-id handling or avoid advertising DM support through the generic path.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chat_sdk/adapters/messenger/adapter.py`:
- Around line 799-807: Inbound attachment URLs are used directly to create
Attachment objects and downloader closures (via _make_attachment_downloader) and
then passed to session.get, which risks SSRF; add strict URL validation before
adding to result and inside the downloader: parse the URL in the
attachment-processing loop (where Attachment(...) is constructed) and reject or
skip if scheme is not http/https, host is empty, or host resolves to a
private/local IP, and likewise perform the same validation inside
_make_attachment_downloader (and any code paths that call session.get) so
rehydrated closures re-check the URL before issuing the request; reference the
Attachment construction, _make_attachment_downloader, _map_attachment_type, and
any session.get call sites and implement host whitelisting/blacklisting + IP
address checks (block RFC1918/localhost/169.254/IPv6 link-local) to satisfy the
SSRF protection requirement.
- Line 128: The current assignment self._api_version = config.api_version or
DEFAULT_API_VERSION (and similar "x or default" patterns for logger, app_secret,
page_access_token, verify_token) can override explicit falsy values like empty
strings; update these to use explicit None checks: if config.api_version is not
None then use config.api_version else DEFAULT_API_VERSION; similarly for logger,
app_secret, page_access_token, and verify_token, prefer the provided config
value when it is not None and only fall back to the environment (e.g.,
os.environ.get("...")) or a default when the config field is None to avoid
silently masking config bugs.
- Around line 914-917: After the isinstance(profile, dict) guard in the method
that retrieves and caches user profiles, cast profile to the declared type
MessengerUserProfile before assigning it into self._user_profile_cache and
before returning; specifically replace storing/returning the raw dict with a
casted value (e.g., profile_cast = cast(MessengerUserProfile, profile)) and then
set self._user_profile_cache[user_id] = profile_cast and return profile_cast so
the cache matches its declared dict[str, MessengerUserProfile] type.
- Around line 867-875: The function _map_attachment_type currently returns a
plain str but should be narrowed to the Attachment.type literal union
(Literal["image","file","video","audio"]); change the return type annotation of
_map_attachment_type to that literal union and ensure its returned values remain
one of "image", "video", "audio", or "file" so callers (e.g., where
type=self._map_attachment_type(...)) no longer need casts and type-checkers
accept the result.
- Around line 89-176: Add an async get_user method to MessengerAdapter with
signature async def get_user(self, user_id: str) -> UserInfo | None that calls
the existing self._fetch_user_profile(user_id), returns None if that call
returns None, and otherwise converts the returned MessengerUserProfile into a
UserInfo: set user_id, user_name and full_name from the MessengerUserProfile
helper _profile_display_name (or equivalent), avatar_url from profile_pic,
is_bot=False and email=None; ensure the method name and signature exactly match
the Adapter protocol so callers expecting chat_sdk.types.Adapter accept
MessengerAdapter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e9f6c501-1da0-4cf4-9f61-72afa7b4c0af

📥 Commits

Reviewing files that changed from the base of the PR and between 3bd86e7 and 5b3b487.

📒 Files selected for processing (8)
  • pyproject.toml
  • src/chat_sdk/adapters/messenger/__init__.py
  • src/chat_sdk/adapters/messenger/adapter.py
  • src/chat_sdk/ai/__init__.py
  • src/chat_sdk/ai/tools.py
  • tests/test_ai_tools.py
  • tests/test_messenger_api.py
  • tests/test_messenger_webhook.py

Comment on lines +89 to +176
class MessengerAdapter:
"""Messenger (Meta) adapter for chat SDK.

Implements the chat-sdk ``Adapter`` interface for the Messenger Platform.
"""

def __init__(self, config: MessengerAdapterConfig) -> None:
# Upstream's constructor takes the resolved credentials directly. Our
# ``MessengerAdapterConfig`` exposes them via ``resolved_*`` helpers so
# the constructor and the factory both go through the same fallback
# chain. Constructing the adapter without resolved credentials is a
# programmer error — the factory enforces presence, and direct callers
# opt in to env-var fallbacks by leaving fields as ``None``.
app_secret = config.resolved_app_secret()
page_access_token = config.resolved_page_access_token()
verify_token = config.resolved_verify_token()
if not app_secret:
raise ValidationError(
"messenger",
f"appSecret is required. Set {ENV_APP_SECRET} or provide it in config.",
)
if not page_access_token:
raise ValidationError(
"messenger",
f"pageAccessToken is required. Set {ENV_PAGE_ACCESS_TOKEN} or provide it in config.",
)
if not verify_token:
raise ValidationError(
"messenger",
f"verifyToken is required. Set {ENV_VERIFY_TOKEN} or provide it in config.",
)

self._name = "messenger"
self._lock_scope: LockScope = "channel"
self._persist_message_history = True

self._app_secret = app_secret
self._page_access_token = page_access_token
self._verify_token = verify_token
self._api_version = config.api_version or DEFAULT_API_VERSION
self._graph_api_url = f"{GRAPH_API_BASE}/{self._api_version}"
self._logger: Logger = config.logger
self._format_converter = MessengerFormatConverter()

# If a user_name is provided we treat it as explicit and never
# overwrite it from ``/me`` / ``chat.get_user_name()`` — matches the
# upstream ``hasExplicitUserName`` gate. ``is not None`` (not truthy)
# so an explicit ``user_name=""`` is respected rather than silently
# replaced by the ``"bot"`` fallback.
self._has_explicit_user_name = config.user_name is not None
self._user_name = config.user_name if config.user_name is not None else "bot"

self._chat: ChatInstance | None = None
self._bot_user_id: str | None = None

# Local caches. Messenger has no message-history API, so the adapter
# holds every parsed message in memory to back ``fetch_messages``.
self._message_cache: dict[str, list[Message]] = {}
self._user_profile_cache: dict[str, MessengerUserProfile] = {}

# Shared aiohttp session for connection pooling; created lazily.
self._http_session: Any | None = None

# =========================================================================
# Adapter interface properties
# =========================================================================

@property
def name(self) -> str:
return self._name

@property
def lock_scope(self) -> LockScope:
return self._lock_scope

@property
def persist_message_history(self) -> bool:
return self._persist_message_history

@property
def user_name(self) -> str:
return self._user_name

@property
def bot_user_id(self) -> str | None:
return self._bot_user_id

# =========================================================================

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the adapter file and print relevant ranges around the reported line numbers
FILE="src/chat_sdk/adapters/messenger/adapter.py"
echo "=== File: $FILE ==="
test -f "$FILE" && true
python3 - <<'PY'
import itertools,sys
path="src/chat_sdk/adapters/messenger/adapter.py"
start_lines=[330,350,370,380,420,440]
for s in start_lines:
    print(f"\n--- {path}:{s}-{s+120} ---")
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f, start=1):
            if s <= i <= s+120:
                sys.stdout.write(f"{i:5d}:{line}")
PY

# Search for get_user in the messenger adapter
echo "=== rg: get_user in messenger adapter ==="
rg -n "def get_user|get_user\(" src/chat_sdk/adapters/messenger/adapter.py || true

# Locate Adapter protocol definition
echo "=== Find Adapter protocol/interface ==="
rg -n "class Adapter\b|Protocol\W*Adapter|get_user\(" src/chat_sdk -S || true

# Print the Adapter definition block(s)
python3 - <<'PY'
import re,sys,os,glob
paths=[]
for root,dirs,files in os.walk("src/chat_sdk"):
    for fn in files:
        if fn.endswith(".py"):
            paths.append(os.path.join(root,fn))
needle=re.compile(r"(class\s+Adapter\b|Protocol\s*\(?\s*Adapter\b|class\s+Adapter\s*\(|get_user\s*\()")
for p in paths:
    try:
        with open(p,'r',encoding='utf-8') as f:
            txt=f.read()
    except Exception:
        continue
    if "Adapter" in p or "adapter" in p:
        pass
    if "get_user" in txt and ("Adapter" in txt or "Protocol" in txt):
        if needle.search(txt):
            print("\n### Candidate file:",p)
            # Print a small window around get_user occurrences
            lines=txt.splitlines()
            for i,l in enumerate(lines, start=1):
                if "get_user" in l and ("def " in l or "get_user(" in l):
                    a=max(1,i-20); b=min(len(lines),i+40)
                    print(f"--- {p}:{a}-{b} ---")
                    for j in range(a,b+1):
                        print(f"{j:5d}:{lines[j-1]}")
PY

# Find attachment download URL usage (for the URL-validation part of the original comment context)
echo "=== Search attachment download URL fetch path ==="
rg -n "_extract_attachments|_download_attachment|attachment.*url|download.*attachment" src/chat_sdk/adapters/messenger/adapter.py || true

# Check credential/env fallback usage for guideline issues ('x or default')
echo "=== Search for 'or DEFAULT' / 'x or ' patterns around credential fallback ==="
rg -n "or\s+DEFAULT|resolved_.*\(|config\..*or\s+DEFAULT|ENV_.*or" src/chat_sdk/adapters/messenger/adapter.py || true

Repository: Chinchill-AI/chat-sdk-python

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/chat_sdk/adapters/messenger/adapter.py"

echo "=== class MessengerAdapter definition (bases) ==="
rg -n "class MessengerAdapter\\b" "$FILE" || true
python3 - <<'PY'
import re
path="src/chat_sdk/adapters/messenger/adapter.py"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
for i,l in enumerate(lines,1):
    if re.search(r'class\s+MessengerAdapter\b', l):
        # print a small window of the class header
        start=max(1,i-5); end=min(len(lines), i+5)
        for j in range(start,end+1):
            print(f"{j:5d}:{lines[j-1].rstrip()}")
        break
PY

echo "=== rg: def get_user in messenger adapter ==="
rg -n "def get_user\\b|async def get_user\\b" "$FILE" || true

echo "=== types.py: where Adapter protocol / BaseAdapter defines get_user ==="
rg -n "class Adapter\\(Protocol\\)|class BaseAdapter|async def get_user\\(" src/chat_sdk/types.py | head -n 50

Repository: Chinchill-AI/chat-sdk-python

Length of output: 864


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/chat_sdk/adapters/messenger/adapter.py"

echo "=== rg: _fetch_user_profile (or similar) ==="
rg -n "_fetch_user_profile|fetch_user_profile|get_user_profile|user_profile" "$FILE" || true

echo "=== Show nearby definitions for any match ==="
python3 - <<'PY'
import re
path="src/chat_sdk/adapters/messenger/adapter.py"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
for i,l in enumerate(lines,1):
    if re.search(r"def\s+_fetch_user_profile|def\s+fetch_user_profile|def\s+_get_user_profile|get_user_profile", l):
        a=max(1,i-10); b=min(len(lines),i+60)
        print(f"\n--- {path}:{a}-{b} ---")
        for j in range(a,b+1):
            print(f"{j:5d}:{lines[j-1].rstrip()}")
PY

Repository: Chinchill-AI/chat-sdk-python

Length of output: 4008


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== types.py: locate UserInfo definition ==="
rg -n "class UserInfo\\b|UserInfo\\s*=\\s*TypedDict|UserInfo\\b.*TypedDict" src/chat_sdk/types.py
# print nearby block(s)
python3 - <<'PY'
import re
path="src/chat_sdk/types.py"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
targets=[]
for i,l in enumerate(lines,1):
    if re.search(r"class\s+UserInfo\b", l) or re.search(r"\bUserInfo\s*[:=]\s*TypedDict\b", l) or "UserInfo" in l and "TypedDict" in l:
        targets.append(i)
for i in targets[:5]:
    a=max(1,i-5); b=min(len(lines),i+60)
    print(f"\n--- {path}:{a}-{b} ---")
    for j in range(a,b+1):
        print(f"{j:5d}:{lines[j-1].rstrip()}")
PY

echo "=== Inspect ActionEvent/ReactionEvent typing for adapter field ==="
rg -n "class ActionEvent\\b|class ReactionEvent\\b|ActionEvent\\(|ReactionEvent\\(" src/chat_sdk | head -n 50

# Show their definitions (small windows)
python3 - <<'PY'
import os,re
def show(p, start_re):
    with open(p,'r',encoding='utf-8') as f: lines=f.readlines()
    for i,l in enumerate(lines,1):
        if re.search(start_re, l):
            a=max(1,i-5); b=min(len(lines),i+80)
            print(f"\n--- {p}:{a}-{b} ---")
            for j in range(a,b+1):
                print(f"{j:5d}:{lines[j-1].rstrip()}")
            return
    print("not found",p)

# find likely file(s) containing ActionEvent/ReactionEvent
files=[]
for root,dirs,fs in os.walk("src/chat_sdk"):
    for fn in fs:
        if fn.endswith(".py"):
            p=os.path.join(root,fn)
            files.append(p)
for p in files:
    try:
        txt=open(p,'r',encoding='utf-8').read()
    except: 
        continue
    if "class ActionEvent" in txt and "adapter:" in txt:
        show(p, r"class\s+ActionEvent\b")
    if "class ReactionEvent" in txt and "adapter:" in txt:
        show(p, r"class\s+ReactionEvent\b")
PY

echo "=== Other adapters get_user implementations (signature) ==="
rg -n "async def get_user\\(self, user_id: str\\) -> UserInfo \\| None" src/chat_sdk/adapters -S | head -n 50
for f in $(rg -l "async def get_user\\(self, user_id: str\\)" src/chat_sdk/adapters | head -n 5); do
  echo "---- $f (get_user block) ----"
  python3 - <<PY
import re
p="$f"
with open(p,'r',encoding='utf-8') as f2: lines=f2.readlines()
for i,l in enumerate(lines,1):
    if re.search(r"async def get_user\\(", l):
        a=max(1,i-5); b=min(len(lines),i+80)
        print(f"--- {p}:{a}-{b} ---")
        for j in range(a,b+1):
            print(f"{j:5d}:{lines[j-1].rstrip()}")
        break
PY
done

Repository: Chinchill-AI/chat-sdk-python

Length of output: 32350


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/chat_sdk/adapters/messenger/adapter.py"

echo "=== rg: MessengerUserProfile definition ==="
rg -n "MessengerUserProfile" "$FILE" | head -n 50

python3 - <<'PY'
import re
path="src/chat_sdk/adapters/messenger/adapter.py"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
for i,l in enumerate(lines,1):
    if re.search(r"\bMessengerUserProfile\b", l) and ("TypedDict" in l or "class MessengerUserProfile" in l or "MessengerUserProfile =" in l):
        a=max(1,i-5); b=min(len(lines),i+80)
        print(f"\n--- {path}:{a}-{b} ---")
        for j in range(a,b+1):
            print(f"{j:5d}:{lines[j-1].rstrip()}")
        break
PY

echo "=== rg: where _profile_display_name is used ==="
rg -n "_profile_display_name" "$FILE" || true

Repository: Chinchill-AI/chat-sdk-python

Length of output: 682


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/chat_sdk/adapters/messenger/adapter.py"

python3 - <<'PY'
path="src/chat_sdk/adapters/messenger/adapter.py"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
for target in [630,640,650,660,670,675,680,690,700]:
    end=target+80
    print(f"\n--- {path}:{target}-{end} ---")
    for i in range(target, min(end,len(lines))+1):
        print(f"{i:5d}:{lines[i-1].rstrip()}")
PY

Repository: Chinchill-AI/chat-sdk-python

Length of output: 36902


Add missing get_user to MessengerAdapter to satisfy the Adapter protocol (fix type-check CI).

Type errors at lines 359/393/432 come from passing self where chat_sdk.types.Adapter is expected; MessengerAdapter has no get_user. Implement:

  • async def get_user(self, user_id: str) -> UserInfo | None (matching chat_sdk/types.py)
  • Delegate to the existing self._fetch_user_profile(user_id) and map MessengerUserProfileUserInfo (e.g., user_id, user_name/full_name from _profile_display_name, avatar_url from profile_pic, is_bot=False, email=None), returning None only if you intentionally want “not found” semantics.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/messenger/adapter.py` around lines 89 - 176, Add an
async get_user method to MessengerAdapter with signature async def
get_user(self, user_id: str) -> UserInfo | None that calls the existing
self._fetch_user_profile(user_id), returns None if that call returns None, and
otherwise converts the returned MessengerUserProfile into a UserInfo: set
user_id, user_name and full_name from the MessengerUserProfile helper
_profile_display_name (or equivalent), avatar_url from profile_pic, is_bot=False
and email=None; ensure the method name and signature exactly match the Adapter
protocol so callers expecting chat_sdk.types.Adapter accept MessengerAdapter.

Source: Pipeline failures

self._app_secret = app_secret
self._page_access_token = page_access_token
self._verify_token = verify_token
self._api_version = config.api_version or DEFAULT_API_VERSION

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/chat_sdk/adapters/messenger/adapter.py"

echo "== Target file =="
ls -l "$FILE" || true

echo
echo "== Snippet around line 128 =="
nl -ba "$FILE" | sed -n '110,150p'

echo
echo "== Search for common truthy fallbacks in this file =="
rg -n "config\.api_version\s+or\s+DEFAULT_API_VERSION|logger\s+or\s+ConsoleLogger|app_secret\s+or\s+os\.environ|page_access_token\s+or\s+os\.environ|verify_token\s+or\s+os\.environ" "$FILE" || true

echo
echo "== Snippet around lines 1165-1182 (as referenced) =="
nl -ba "$FILE" | sed -n '1145,1210p'

Repository: Chinchill-AI/chat-sdk-python

Length of output: 276


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/chat_sdk/adapters/messenger/adapter.py"

echo "== Line count =="
wc -l "$FILE" || true
echo

echo "== Snippet around ~line 128 =="
cat -n "$FILE" | sed -n '110,150p'
echo

echo "== Search for truthy fallbacks in this file =="
rg -n "config\.api_version\s+or\s+DEFAULT_API_VERSION|logger\s+or\s+ConsoleLogger|_logger\s*=.*\s+logger\s+or\s+ConsoleLogger|app_secret\s+or\s+os\.environ|page_access_token\s+or\s+os\.environ|verify_token\s+or\s+os\.environ" "$FILE" || true

echo

echo "== Snippet around ~lines 1165-1182 (if present) =="
if [ "$(wc -l < "$FILE")" -ge 1182 ]; then
  cat -n "$FILE" | sed -n '1145,1210p'
else
  echo "File has fewer than 1182 lines; skipping range."
fi

echo

echo "== Search for 'or os.environ' occurrences =="
rg -n "or\s+os\.environ\.get\(" "$FILE" || true

echo
echo "== Search for 'logger or' occurrences =="
rg -n "logger\s+or\s+" "$FILE" || true

echo
echo "== Search for 'api_version or' occurrences =="
rg -n "api_version\s+or\s+" "$FILE" || true

Repository: Chinchill-AI/chat-sdk-python

Length of output: 5747


Avoid truthy or fallbacks in Messenger config resolution
x or default can silently replace explicitly provided falsy values (e.g., "") with env values for credentials/tokens, masking configuration bugs. Use explicit is not None checks in src/chat_sdk/adapters/messenger/adapter.py for config.api_version and the env fallback wiring for logger, app_secret, page_access_token, and verify_token.

Suggested patch
-        self._api_version = config.api_version or DEFAULT_API_VERSION
+        self._api_version = config.api_version if config.api_version is not None else DEFAULT_API_VERSION
...
-    _logger = logger or ConsoleLogger("info").child("messenger")
+    _logger = logger if logger is not None else ConsoleLogger("info").child("messenger")
...
-    _app_secret = app_secret or os.environ.get(ENV_APP_SECRET)
+    _app_secret = app_secret if app_secret is not None else os.environ.get(ENV_APP_SECRET)
...
-    _page_access_token = page_access_token or os.environ.get(ENV_PAGE_ACCESS_TOKEN)
+    _page_access_token = (
+        page_access_token if page_access_token is not None else os.environ.get(ENV_PAGE_ACCESS_TOKEN)
+    )
...
-    _verify_token = verify_token or os.environ.get(ENV_VERIFY_TOKEN)
+    _verify_token = verify_token if verify_token is not None else os.environ.get(ENV_VERIFY_TOKEN)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/messenger/adapter.py` at line 128, The current
assignment self._api_version = config.api_version or DEFAULT_API_VERSION (and
similar "x or default" patterns for logger, app_secret, page_access_token,
verify_token) can override explicit falsy values like empty strings; update
these to use explicit None checks: if config.api_version is not None then use
config.api_version else DEFAULT_API_VERSION; similarly for logger, app_secret,
page_access_token, and verify_token, prefer the provided config value when it is
not None and only fall back to the environment (e.g., os.environ.get("...")) or
a default when the config field is None to avoid silently masking config bugs.

Source: Coding guidelines

Comment on lines +799 to +807
url = payload.get("url")
if not url:
continue
result.append(
Attachment(
type=self._map_attachment_type(attachment.get("type", "")),
url=url,
fetch_data=self._make_attachment_downloader(url),
# Persist the download URL so the closure can be rebuilt

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Validate attachment URLs before outbound fetches to prevent SSRF paths.

Attachment URLs from inbound payload/metadata are passed straight to session.get(...) without validation. Add URL validation (scheme/host constraints and private-network blocking) before building/rehydrating download closures and before issuing requests.
As per coding guidelines, validate external URLs before making requests to prevent SSRF attacks.

Also applies to: 831-864, 877-882

🧰 Tools
🪛 GitHub Actions: Lint & Type Check / 0_Lint & Type Check.txt

[error] 804-804: pyrefly/mypy: Argument str is not assignable to parameter type with type Literal['audio', 'file', 'image', 'video'] in chat_sdk.types.Attachment.__init__ [bad-argument-type].

🪛 GitHub Actions: Lint & Type Check / Lint & Type Check

[error] 804-804: pyrefly type check failed: Argument str is not assignable to parameter type with type Literal['audio', 'file', 'image', 'video'] in chat_sdk.types.Attachment.__init__ [bad-argument-type].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/messenger/adapter.py` around lines 799 - 807, Inbound
attachment URLs are used directly to create Attachment objects and downloader
closures (via _make_attachment_downloader) and then passed to session.get, which
risks SSRF; add strict URL validation before adding to result and inside the
downloader: parse the URL in the attachment-processing loop (where
Attachment(...) is constructed) and reject or skip if scheme is not http/https,
host is empty, or host resolves to a private/local IP, and likewise perform the
same validation inside _make_attachment_downloader (and any code paths that call
session.get) so rehydrated closures re-check the URL before issuing the request;
reference the Attachment construction, _make_attachment_downloader,
_map_attachment_type, and any session.get call sites and implement host
whitelisting/blacklisting + IP address checks (block
RFC1918/localhost/169.254/IPv6 link-local) to satisfy the SSRF protection
requirement.

Source: Coding guidelines

Comment thread src/chat_sdk/adapters/messenger/adapter.py Outdated
Comment thread src/chat_sdk/adapters/messenger/adapter.py Outdated
claude added 8 commits June 12, 2026 20:52
…alignment

Ports the core slice of vercel/chat#444 (processMessage returns the inner
task as Promise<void>): process_message now returns the eagerly-started
asyncio.Task so streaming callers can await full handler completion and
observe handler exceptions; wait_until keeps swallowed-error semantics.

Fidelity alignment for the chat@4.29.0 pin:
- MAPPING follows upstream's ai.test.ts split into ai/messages.test.ts +
  ai/index.test.ts (vercel/chat#492)
- tests/test_ai_tools.py: 14 tests renamed to converter-exact names so the
  strict checker exact-matches the upstream it() titles
- new faithful ports in tests/test_chat_faithful.py: the #444 awaitable
  test, plus the #459/#495 queue/burst subject-rehydration tests, skipif-
  gated on BaseAdapter.fetch_subject so they activate when PR #131 lands

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
…in, ai example

- pyproject 0.4.29a2 -> 0.4.29; README status + 9-platform claims
- CHANGELOG: consolidated 0.4.29 release entry (0.4.29a3 notes folded in)
  + backfilled 0.4.27.1 section
- lint.yml upstream clone re-pinned chat@4.26.0 -> chat@4.29.0
- docs/UPSTREAM_SYNC.md: version-table rows for 0.4.27.1/0.4.29; Known
  Non-Parity rows for GitHub octokit / Linear linear_client getters (no
  Python SDK object to expose), @chat-adapter/tests kit, and the Teams
  microsoft-teams-apps migration deferral to 0.4.30 (issue #93)
- CLAUDE.md: pin references moved to chat@4.29.0
- examples/ai_tools_example.py + README AI section (chat/ai PR 3 of 3,
  design #109)
- 0.4.30 wave tracking issue: #135

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
)

Port of upstream 3546b3f. Slack natively renders markdown via the
markdown_text parameter on chat.postMessage / postEphemeral / update /
scheduleMessage, so the adapter passes markdown through directly
instead of converting to mrkdwn.

- str / PostableRaw messages still go to text (preserves literal *).
- PostableMarkdown / PostableAst go to markdown_text (12k char limit).
- to_blocks_with_table, _mdast_table_to_slack_block, and the
  _render_with_table_blocks call sites are removed (tables now ride
  along in markdown_text).
- SlackMarkdownConverter alias removed; use SlackFormatConverter.
- render_formatted(ast) / from_ast now return standard markdown
  (was mrkdwn) via stringify_markdown.
- response_url payloads reject markdown_text (no_text), so those
  render through the retained mrkdwn node renderer
  (to_response_url_text).
- Incoming message events still arrive as mrkdwn and parse unchanged.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
(cherry picked from commit f07815e327ef1f2ba8ff688b2eb593af818c4ab6)
…ercel/chat#467)

Port of upstream c46fdb6. Adds SlackAdapterConfig.installation_provider
(SlackInstallationProvider protocol) for multi-workspace apps using
external token management (e.g. Vercel Connect). When set, the adapter
bypasses internal StateAdapter storage for token lookups on incoming
webhooks — the provider is authoritative (no state fallback) and
read-only (set_installation / delete_installation / OAuth callback
still write to internal state).

Enterprise Grid support rides along: org-wide installs
(is_enterprise_install) resolve by enterprise_id instead of team_id
across event_callback, slash command, and interactive payload entry
paths; RequestContext carries enterprise_id/is_enterprise_install;
attachment fetch_metadata captures enterpriseId/isEnterpriseInstall
(omitted when absent) and rehydrate_attachment routes through
_resolve_token_for_team so the provider is honored after a JSON
roundtrip.

Composes with the existing bot_token resolver design (see
docs/UPSTREAM_SYNC.md non-parity rows): a configured default bot_token
(static or resolver) still selects single-workspace mode and bypasses
per-installation resolution entirely; _get_token / client caches are
untouched.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
(cherry picked from commit ee1e30703514d8a871d7f64c0ccc17c0774a7d6d)
Port of upstream b9b17cd. Discord sends interactions through either
the Gateway or an Interactions Endpoint URL, not both — deployments
without an endpoint URL receive interactions over the Gateway, and the
adapter previously dropped them ("Forwarded Gateway event (no
handler)").

The Python adapter is HTTP-interactions-only with a gateway-forwarder
receiver, so the port lands on that surface: a forwarded
GATEWAY_INTERACTION_CREATE event (raw wire-format INTERACTION_CREATE
dispatch payload) is now acknowledged via the interaction callback
REST endpoint — POST /interactions/{id}/{token}/callback with type 5
for slash commands / type 6 for components, the same wire calls
upstream's resident discord.js handler makes via deferReply() /
deferUpdate() — then routed through the existing slash-command and
action handler paths. Deferred slash responses resolve exactly like
HTTP ones (post_message PATCHes the @original webhook message).
Callback path segments are URL-quoted (hazard #12) and defer failures
are logged without dispatching the handler, matching upstream's
listener-level catch.

Also aligns slash-command boolean option flattening with TS
String(true) — "status true", not Python str(True) "status True" —
pinned by the ported upstream test.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
(cherry picked from commit 458428afc383b57704e6fe7c6d8abde23235afda)
…history (vercel/chat#448)

Port of upstream 46d183b (chat@4.29.0).

Rename (with back-compat, mirroring upstream):
- message_history.py -> thread_history.py; MessageHistoryCache ->
  ThreadHistoryCache. Old module path kept as a deprecated re-export shim.
- ChatConfig.thread_history added; deprecated ChatConfig.message_history
  still read, thread_history wins when both are set.
- Adapter.persist_thread_history added; deprecated persist_message_history
  still honored (either flag enables persistence). Telegram and WhatsApp
  adapters switch to the new flag, matching upstream.
- State storage key prefix "msg-history:" is deliberately unchanged so
  existing persisted data is not orphaned.

New Transcripts API:
- transcripts.py: TranscriptsApiImpl (append/list/count/delete) keyed by a
  cross-platform user key, backed by StateAdapter.append_to_list. delete()
  writes a tombstone via append_to_list(max_length=1) because state.delete
  only addresses the k/v namespace on non-memory adapters.
- ChatConfig.transcripts + ChatConfig.identity (IdentityResolver); the
  constructor raises when transcripts is set without identity. Inbound
  dispatch resolves message.user_key once per message via the resolver.
- chat.transcripts accessor raises when not configured (fail loudly).
- New types: TranscriptEntry, TranscriptsConfig, TranscriptRole,
  AppendInput, AppendOptions, ListQuery, CountQuery, DeleteTarget,
  DeleteResult, IdentityContext, IdentityResolver, DurationString.

Tests: test_message_history.py renamed to test_thread_history.py (names
aligned to thread-history.test.ts titles, deprecated-alias test added);
chat.test.ts persistThreadHistory block ported into test_chat_faithful.py;
new test_transcripts.py and test_transcripts_wiring.py port
transcripts.test.ts and transcripts-wiring.test.ts 1:1.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
(cherry picked from commit 765420dc4d21804b19f1d8eccddebc3a392b0e61)
- MAPPING: four new chat@4.29.0 core test files mapped (callback-url,
  thread-history, transcripts, transcripts-wiring) — 12 of 19 files now
  in scope; stale 4.26-era references in the script/template updated
- fidelity_baseline.json regenerated at chat@4.29.0 (731 TS tests in
  scope, 0 missing; strict mode green)
- docs/UPSTREAM_SYNC.md: Known Non-Parity rows from the port wave —
  GitHub octokit / Linear linear_client getters, @chat-adapter/tests
  kit, Teams modal-submit webhook options slice, jsx-runtime callbackUrl
  props, Transcripts API Python adaptations, Slack legacy mrkdwn
  renderer scope note, Discord gateway-only interactions surface, and
  the Teams microsoft-teams-apps deferral to 0.4.30
- tests/test_chat_faithful.py: the [Slash Commands] duplicate-title
  openModal-unsupported port (matcher counts names as a multiset) and
  the #459 subject-wiring port (skipif-gated on BaseAdapter.fetch_subject,
  activates when PR #131 lands)

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
@patrick-chinchill patrick-chinchill changed the title recover: re-land stranded #124 (Messenger adapter) + #122 (chat/ai tools) onto main release: 0.4.29 completion train — stranded-PR recovery + remaining 4.29 ports + cut Jun 12, 2026
Comment thread src/chat_sdk/chat.py Fixed
"slack:C123:1234.5678",
create_test_message("msg-burst-json-2", "Hey @slack-bot two"),
)
await task

def get_installation(
self, installation_id: str, is_enterprise_install: bool
) -> Awaitable[SlackInstallation | None]: ...
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed

@coderabbitai coderabbitai 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.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/chat_sdk/adapters/discord/adapter.py (1)

170-172: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Add persist_thread_history to satisfy the Adapter protocol.

DiscordAdapter exposes persist_message_history but is missing persist_thread_history, which is now required by the protocol. This is the root cause of the type-check breakages reported downstream (Line 404, Line 491, Line 763, Line 857) when self is passed where Adapter is expected.

Proposed fix
     `@property`
     def persist_message_history(self) -> bool | None:
         return None
+
+    `@property`
+    def persist_thread_history(self) -> bool | None:
+        # Backward-compatible alias for newer Adapter protocol surface.
+        return self.persist_message_history
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/discord/adapter.py` around lines 170 - 172, The
DiscordAdapter class currently implements persist_message_history but is missing
the persist_thread_history property required by the Adapter protocol; add a
persist_thread_history property on DiscordAdapter (matching the type signature
bool | None) that returns the same default/behavior as persist_message_history
so instances satisfy the Adapter protocol (refer to the persist_message_history
property on DiscordAdapter and the Adapter protocol's persist_thread_history
requirement when implementing).

Source: Pipeline failures

src/chat_sdk/adapters/slack/adapter.py (1)

542-548: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expose persist_thread_history on the adapter.

The class still only advertises persist_message_history, so it no longer satisfies the Adapter protocol and this file keeps failing pyright/pyrefly at every event-construction site.

💡 Proposed fix
     `@property`
     def persist_message_history(self) -> bool:
         return self._persist_message_history
+
+    `@property`
+    def persist_thread_history(self) -> bool:
+        return self._persist_message_history
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/slack/adapter.py` around lines 542 - 548, The adapter
class currently exposes lock_scope and persist_message_history but is missing
the persist_thread_history property required by the Adapter protocol; add a
`@property` named persist_thread_history that returns self._persist_thread_history
(mirroring persist_message_history) so code sites expecting
Adapter.persist_thread_history type-check correctly and reference the same
internal flag used by this adapter.

Source: Pipeline failures

src/chat_sdk/chat.py (2)

952-988: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update the protocol contract to match the new return value.

Chat.process_message() now returns the created task, but ChatInstance.process_message() in src/chat_sdk/types.py still returns None. That leaves the concrete class out of sync with the interface and blocks adapters typed against ChatInstance from legally using the new task handle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/chat.py` around lines 952 - 988, The interface for
ChatInstance.process_message is out of sync with Chat.process_message:
Chat.process_message now returns asyncio.Task[None] | None but
ChatInstance.process_message in types still returns None; update the
ChatInstance.process_message signature to return the same type
(asyncio.Task[None] | None) so implementations and adapters can use the returned
task. Locate the ChatInstance.process_message declaration in
src/chat_sdk/types.py and change its return type to match the concrete method
(use the same union type or an appropriate alias), and update any related type
imports (e.g., import asyncio.Task or typing annotations) to keep type checking
passing.

1336-1355: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist modal context before calling open_modal().

_store_modal_context() schedules the state write and returns immediately. If that write lags or gets dropped at request teardown, the modal submit path loses related_* and callback_url state for the same context_id. Making this helper async and awaiting it at both modal-opening call sites removes that race.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/chat.py` around lines 1336 - 1355, Make _store_modal_context an
async function that awaits the state write instead of fire-and-forget: change
def _store_modal_context(...) to async def _store_modal_context(...), and inside
either await self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS)
directly or await the task returned by _create_task so the write completes
before returning. Then update both modal-opening call sites that invoke
_store_modal_context (the places that call open_modal / the modal-opening logic)
to await _store_modal_context(...) so the modal submit path reliably sees
related_* and callback_url state for the same context_id.
src/chat_sdk/channel.py (1)

363-377: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don't mint 30-day callback tokens at schedule time.

schedule() now tokenizes card callbacks immediately, but the stored state entry expires 30 days after scheduling, not 30 days after delivery. Any message scheduled beyond that window will arrive with callback buttons that are already unresolvable. This needs either delivery-time tokenization or a TTL derived from post_at.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/channel.py` around lines 363 - 377, The schedule() method
currently calls _process_callback_urls(message) at schedule time which mints
callback tokens that expire 30 days from now; change schedule() to avoid minting
tokens immediately—remove the _process_callback_urls call and pass the raw
message to adapter.schedule_message(self._id, message, {"post_at": post_at}) so
tokenization is deferred to delivery-time (e.g., in send()/deliver() path where
_process_callback_urls can be called), or if you must mint now compute a TTL
based on post_at (expires_at = post_at) and pass that TTL into
_process_callback_urls or adapter.schedule_message so minted tokens expire at
delivery time; update schedule(), _process_callback_urls, and the
adapter.schedule_message usage accordingly to support deferred or TTL-based
tokenization.
🧹 Nitpick comments (1)
tests/integration/test_replay_callback_url.py (1)

57-60: 🎯 Functional Correctness | ⚡ Quick win

Root cause: truthiness-based defaulting (x or default) in Python test helpers.
tests/integration/test_replay_callback_url.py and tests/test_callback_url.py both use or fallbacks where explicit None checks are required. This is the same portability/correctness trap and should be fixed consistently in both files.

As per coding guidelines: Use x if x is not None else default instead of x or default to avoid truthiness trap when porting from TypeScript.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_replay_callback_url.py` around lines 57 - 60, The
constructor __init__ currently uses truthiness fallback "headers or {}" which
incorrectly replaces valid falsy values; change it to an explicit None check by
setting self.headers = headers if headers is not None else {} in
tests/integration/test_replay_callback_url.py and apply the same fix in the
corresponding constructor or helper in tests/test_callback_url.py so both use "x
if x is not None else default" instead of "x or default".

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chat_sdk/adapters/discord/cards.py`:
- Around line 54-69: The current encode_discord_custom_id only validates overall
length, allowing an empty or delimiter-containing action_id which leads to
broken decoding/dispatch; update encode_discord_custom_id to explicitly reject
an empty action_id and any action_id containing DISCORD_CUSTOM_ID_DELIMITER or
newline characters by raising ValidationError, and still validate the final
encoded string length via _validate_discord_custom_id; reference
_validate_discord_custom_id, encode_discord_custom_id, decode_discord_custom_id
and DISCORD_CUSTOM_ID_DELIMITER so the change is clearly tied to the
encoding/decoding flow.

In `@src/chat_sdk/adapters/slack/adapter.py`:
- Around line 994-1039: The current try/except in _resolve_token_for_team
swallows all exceptions (including from
self._installation_provider.get_installation) and returns None, which callers
interpret as "not found"; change the error handling so provider failures are not
collapsed into cache misses: call
self._installation_provider.get_installation(...) inside a small try/except that
logs the exception (use self._logger.error with the
installationId/isEnterpriseInstall and the exception) and then re-raise the
exception (or return a distinct failure signal upstream) instead of returning
None; keep the original fallback to await self.get_installation(installation_id)
only when the provider call succeeds or explicitly returns no installation, and
ensure RequestContext construction remains unchanged.
- Around line 505-507: The factory and adapter must reject using an external
installation provider in Socket Mode: update create_slack_adapter() to validate
that if config.mode == "socket" (or mode parameter equals "socket") then
config.installation_provider (and any passed SlackInstallationProvider) is not
accepted—raise a clear error (or ValueError) when installation_provider is set
for socket mode; also ensure the adapter's attribute self._installation_provider
(and any logic that assigns config.installation_provider) is only set when mode
is not "socket" so Socket Mode remains single-workspace-only.

In `@src/chat_sdk/adapters/slack/format_converter.py`:
- Around line 29-32: BARE_MENTION_REGEX is too permissive and rewrites
unresolved literals like "`@alice`" or "`@channel`" into `<`@alice`>`; change it to
only match Slack user-ID-shaped tokens and/or exclude special commands: update
BARE_MENTION_REGEX to something like r"(?<![<\w])@([UW][A-Z0-9]+)" (match Slack
IDs that start with U or W and contain caps/numbers) and ensure the
transformation code in SlackFormatConverter._finalize() and
SlackFormatConverter._node_to_mrkdwn() only applies this regex; separately
handle or explicitly exclude the special words "channel", "here", "everyone",
and "group" (or convert them to `<!channel>`/`<!here>`/`<!everyone>`/`<!group>`
where intended) and do not apply the rewrite when
adapter.py::_resolve_outgoing_mentions() left a literal unresolved.

In `@src/chat_sdk/adapters/telegram/adapter.py`:
- Line 597: The Adapter protocol still expects persist_message_history but this
adapter only defines _persist_thread_history/persist_thread_history; add a
temporary alias property named persist_message_history on the Telegram adapter
class that delegates to the existing self._persist_thread_history (getter and
setter) so the class satisfies the old protocol; do the same change in the
WhatsApp adapter (add a persist_message_history property that reads/writes the
existing _persist_thread_history/persist_thread_history) or alternatively
migrate the protocol and all call sites together—ensure the alias uses the same
backing field (self._persist_thread_history) to maintain identical behavior.

In `@src/chat_sdk/callback_url.py`:
- Around line 184-197: The _fetch function currently forwards any string URL to
aiohttp, creating an SSRF risk; before opening
aiohttp.ClientSession/session.request validate the URL: parse the URL in _fetch,
ensure the scheme is http or https, reject URLs containing credentials
(username/password in netloc), resolve the host (via socket.getaddrinfo) and
verify none of the resolved IPs are loopback, private, link-local, multicast, or
otherwise non-public using the ipaddress module; if validation fails raise an
exception (e.g., ValueError) and do not call
aiohttp.ClientSession/session.request. Ensure these checks live at the top of
_fetch so unsafe URLs are rejected before any network call.

In `@src/chat_sdk/channel.py`:
- Around line 470-475: When rebinding chat context in ChannelImpl.from_json the
code clears channel._thread_history but only restores _state_adapter_instance
when chat is provided, which loses the messages() cache/fallback; change the
logic so that when chat is passed in you also rebind the history cache instead
of unconditionally clearing it — e.g. if chat is not None set
channel._thread_history from chat.thread_history or recreate it according to
ChatConfig.thread_history (and mirror the same change in the other identical
block affecting lines around the second rebind) so messages() behavior and
append fallback are preserved.

In `@src/chat_sdk/chat.py`:
- Around line 1108-1115: The log statements calling self._logger.error that
include the full callback_url should be changed to log a redacted version of
callback_url instead of the raw value; implement or call a small sanitizer
(e.g., redact_callback_url or similar) that strips or masks query params and
sensitive path segments and use that sanitized value in both the Modal callback
POST error branches shown (the block with str(post_result.error) and the except
Exception as error branch), and make the same replacement for the analogous
logger calls around lines 1462-1468 so no raw callback_url is emitted in logs.

In `@src/chat_sdk/transcripts.py`:
- Around line 70-83: The helper _parse_duration should be narrowed to return
only int | None to match StateAdapter.append_to_list's contract: change the type
hint to int | None and ensure any numeric or computed duration is normalized to
an integer (e.g., after computing n * MS_PER_UNIT[...] or if a numeric input is
float cast/convert to int) before returning; keep the current bool exclusion and
string parsing logic but explicitly cast the final result to int to avoid
returning floats.

In `@tests/test_chat_faithful.py`:
- Around line 4362-4367: Replace the hardcoded asyncio.sleep calls used to wait
for background persistence of modal context with a deterministic polling wait
with a timeout: repeatedly check state.cache for keys starting with
"modal-context:" (as done in the current assertion) until the expected count
appears or a short timeout elapses, and use asyncio.wait_for or an equivalent
awaitable to fail deterministically on timeout; apply the same polling pattern
for both occurrences (the block around _store_modal_context and the later block
around the modal callback at the other location) so tests wait reliably for the
background task to complete.

---

Outside diff comments:
In `@src/chat_sdk/adapters/discord/adapter.py`:
- Around line 170-172: The DiscordAdapter class currently implements
persist_message_history but is missing the persist_thread_history property
required by the Adapter protocol; add a persist_thread_history property on
DiscordAdapter (matching the type signature bool | None) that returns the same
default/behavior as persist_message_history so instances satisfy the Adapter
protocol (refer to the persist_message_history property on DiscordAdapter and
the Adapter protocol's persist_thread_history requirement when implementing).

In `@src/chat_sdk/adapters/slack/adapter.py`:
- Around line 542-548: The adapter class currently exposes lock_scope and
persist_message_history but is missing the persist_thread_history property
required by the Adapter protocol; add a `@property` named persist_thread_history
that returns self._persist_thread_history (mirroring persist_message_history) so
code sites expecting Adapter.persist_thread_history type-check correctly and
reference the same internal flag used by this adapter.

In `@src/chat_sdk/channel.py`:
- Around line 363-377: The schedule() method currently calls
_process_callback_urls(message) at schedule time which mints callback tokens
that expire 30 days from now; change schedule() to avoid minting tokens
immediately—remove the _process_callback_urls call and pass the raw message to
adapter.schedule_message(self._id, message, {"post_at": post_at}) so
tokenization is deferred to delivery-time (e.g., in send()/deliver() path where
_process_callback_urls can be called), or if you must mint now compute a TTL
based on post_at (expires_at = post_at) and pass that TTL into
_process_callback_urls or adapter.schedule_message so minted tokens expire at
delivery time; update schedule(), _process_callback_urls, and the
adapter.schedule_message usage accordingly to support deferred or TTL-based
tokenization.

In `@src/chat_sdk/chat.py`:
- Around line 952-988: The interface for ChatInstance.process_message is out of
sync with Chat.process_message: Chat.process_message now returns
asyncio.Task[None] | None but ChatInstance.process_message in types still
returns None; update the ChatInstance.process_message signature to return the
same type (asyncio.Task[None] | None) so implementations and adapters can use
the returned task. Locate the ChatInstance.process_message declaration in
src/chat_sdk/types.py and change its return type to match the concrete method
(use the same union type or an appropriate alias), and update any related type
imports (e.g., import asyncio.Task or typing annotations) to keep type checking
passing.
- Around line 1336-1355: Make _store_modal_context an async function that awaits
the state write instead of fire-and-forget: change def _store_modal_context(...)
to async def _store_modal_context(...), and inside either await
self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS) directly or await
the task returned by _create_task so the write completes before returning. Then
update both modal-opening call sites that invoke _store_modal_context (the
places that call open_modal / the modal-opening logic) to await
_store_modal_context(...) so the modal submit path reliably sees related_* and
callback_url state for the same context_id.

---

Nitpick comments:
In `@tests/integration/test_replay_callback_url.py`:
- Around line 57-60: The constructor __init__ currently uses truthiness fallback
"headers or {}" which incorrectly replaces valid falsy values; change it to an
explicit None check by setting self.headers = headers if headers is not None
else {} in tests/integration/test_replay_callback_url.py and apply the same fix
in the corresponding constructor or helper in tests/test_callback_url.py so both
use "x if x is not None else default" instead of "x or default".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 84ed5d76-b34e-493f-8997-6eb409415c7b

📥 Commits

Reviewing files that changed from the base of the PR and between 5b3b487 and 3835d74.

📒 Files selected for processing (51)
  • .github/workflows/lint.yml
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • docs/UPSTREAM_SYNC.md
  • examples/ai_tools_example.py
  • pyproject.toml
  • scripts/fidelity_baseline.json
  • scripts/verify_test_fidelity.py
  • src/chat_sdk/__init__.py
  • src/chat_sdk/adapters/discord/__init__.py
  • src/chat_sdk/adapters/discord/adapter.py
  • src/chat_sdk/adapters/discord/cards.py
  • src/chat_sdk/adapters/discord/types.py
  • src/chat_sdk/adapters/slack/adapter.py
  • src/chat_sdk/adapters/slack/format_converter.py
  • src/chat_sdk/adapters/slack/types.py
  • src/chat_sdk/adapters/teams/adapter.py
  • src/chat_sdk/adapters/telegram/adapter.py
  • src/chat_sdk/adapters/whatsapp/adapter.py
  • src/chat_sdk/callback_url.py
  • src/chat_sdk/cards.py
  • src/chat_sdk/channel.py
  • src/chat_sdk/chat.py
  • src/chat_sdk/message_history.py
  • src/chat_sdk/modals.py
  • src/chat_sdk/shared/mock_adapter.py
  • src/chat_sdk/thread.py
  • src/chat_sdk/thread_history.py
  • src/chat_sdk/transcripts.py
  • src/chat_sdk/types.py
  • tests/integration/test_replay_callback_url.py
  • tests/test_ai_tools.py
  • tests/test_callback_url.py
  • tests/test_channel_faithful.py
  • tests/test_chat_faithful.py
  • tests/test_chat_resolver.py
  • tests/test_discord_adapter.py
  • tests/test_discord_cards.py
  • tests/test_discord_extended.py
  • tests/test_serialization.py
  • tests/test_slack_api.py
  • tests/test_slack_format.py
  • tests/test_slack_installation_provider.py
  • tests/test_teams_native_streaming.py
  • tests/test_telegram_adapter.py
  • tests/test_thread_faithful.py
  • tests/test_thread_history.py
  • tests/test_transcripts.py
  • tests/test_transcripts_wiring.py
  • tests/test_whatsapp_adapter.py
✅ Files skipped from review due to trivial changes (6)
  • .github/workflows/lint.yml
  • scripts/fidelity_baseline.json
  • src/chat_sdk/adapters/discord/types.py
  • tests/test_teams_native_streaming.py
  • CHANGELOG.md
  • src/chat_sdk/adapters/teams/adapter.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_ai_tools.py

Comment on lines +54 to +69
def _validate_discord_custom_id(custom_id: str) -> None:
if len(custom_id) == 0 or len(custom_id) > DISCORD_CUSTOM_ID_MAX_LENGTH:
raise ValidationError(
"discord",
f"Discord custom_id must be 1-{DISCORD_CUSTOM_ID_MAX_LENGTH} characters. Shorten the button id or value.",
)


def encode_discord_custom_id(action_id: str, value: str | None = None) -> str:
"""Encode a button's action ID and optional value into a custom_id."""
if value is None or value == "":
_validate_discord_custom_id(action_id)
return action_id
encoded = f"{action_id}{DISCORD_CUSTOM_ID_DELIMITER}{value}"
_validate_discord_custom_id(encoded)
return encoded

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject empty or delimiter-containing action_id in custom_id encoding.

Current validation only checks overall length. If action_id is empty (or contains \n), decode_discord_custom_id can produce an empty or truncated action_id, which then breaks action dispatch when consumed in the adapter (Line 392 in src/chat_sdk/adapters/discord/adapter.py).

Proposed fix
 def encode_discord_custom_id(action_id: str, value: str | None = None) -> str:
     """Encode a button's action ID and optional value into a custom_id."""
+    if not action_id or DISCORD_CUSTOM_ID_DELIMITER in action_id:
+        raise ValidationError(
+            "discord",
+            "Button id must be non-empty and must not contain a newline.",
+        )
     if value is None or value == "":
         _validate_discord_custom_id(action_id)
         return action_id
     encoded = f"{action_id}{DISCORD_CUSTOM_ID_DELIMITER}{value}"
     _validate_discord_custom_id(encoded)
     return encoded
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/discord/cards.py` around lines 54 - 69, The current
encode_discord_custom_id only validates overall length, allowing an empty or
delimiter-containing action_id which leads to broken decoding/dispatch; update
encode_discord_custom_id to explicitly reject an empty action_id and any
action_id containing DISCORD_CUSTOM_ID_DELIMITER or newline characters by
raising ValidationError, and still validate the final encoded string length via
_validate_discord_custom_id; reference _validate_discord_custom_id,
encode_discord_custom_id, decode_discord_custom_id and
DISCORD_CUSTOM_ID_DELIMITER so the change is clearly tied to the
encoding/decoding flow.

Comment on lines +505 to +507
# External installation provider (e.g. Vercel Connect). When set,
# per-installation token lookups bypass internal StateAdapter storage.
self._installation_provider: SlackInstallationProvider | None = config.installation_provider

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject installation_provider in Socket Mode too.

This new option can put the adapter into multi-workspace resolution without client_id/client_secret, but create_slack_adapter() still only blocks the OAuth fields. A mode="socket" + installation_provider config now slips through even though the factory/docstring say Socket Mode is single-workspace-only.

💡 Proposed fix (outside this hunk)
 def create_slack_adapter(config: SlackAdapterConfig | None = None) -> SlackAdapter:
     """Create a new SlackAdapter instance.
@@
-    if config is not None and (config.mode or "webhook") == "socket" and (config.client_id or config.client_secret):
+    if config is not None and (config.mode or "webhook") == "socket" and (
+        config.client_id is not None
+        or config.client_secret is not None
+        or config.installation_provider is not None
+    ):
         raise ValidationError(
             "slack",
             "Multi-workspace (clientId/clientSecret) is not supported in socket mode.",
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# External installation provider (e.g. Vercel Connect). When set,
# per-installation token lookups bypass internal StateAdapter storage.
self._installation_provider: SlackInstallationProvider | None = config.installation_provider
def create_slack_adapter(config: SlackAdapterConfig | None = None) -> SlackAdapter:
"""Create a new SlackAdapter instance.
Args:
config: Optional configuration for the Slack adapter.
Returns:
A configured SlackAdapter instance.
Raises:
ValidationError: If Socket Mode is configured with multi-workspace options.
"""
if config is not None and (config.mode or "webhook") == "socket" and (
config.client_id is not None
or config.client_secret is not None
or config.installation_provider is not None
):
raise ValidationError(
"slack",
"Multi-workspace (clientId/clientSecret) is not supported in socket mode.",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/slack/adapter.py` around lines 505 - 507, The factory
and adapter must reject using an external installation provider in Socket Mode:
update create_slack_adapter() to validate that if config.mode == "socket" (or
mode parameter equals "socket") then config.installation_provider (and any
passed SlackInstallationProvider) is not accepted—raise a clear error (or
ValueError) when installation_provider is set for socket mode; also ensure the
adapter's attribute self._installation_provider (and any logic that assigns
config.installation_provider) is only set when mode is not "socket" so Socket
Mode remains single-workspace-only.

Comment on lines +994 to 1039
async def _resolve_token_for_team(
self, installation_id: str, is_enterprise_install: bool = False
) -> RequestContext | None:
"""Resolve the bot token for an installation.

Checks the external installation provider first (e.g. Vercel
Connect); when no provider is configured, falls back to the
internal state adapter.

``installation_id`` is the ``team_id`` -- or the ``enterprise_id``
for Enterprise Grid org-wide installs (``is_enterprise_install``).
"""
try:
installation = await self.get_installation(team_id)
# Check external installation provider first (e.g. Vercel Connect)
if self._installation_provider is not None:
installation = await self._installation_provider.get_installation(
installation_id, is_enterprise_install
)
if installation:
return RequestContext(
token=installation.bot_token,
bot_user_id=installation.bot_user_id,
)
self._logger.warn(
"No installation found from provider",
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
)
return None
# Fall back to internal state adapter
installation = await self.get_installation(installation_id)
if installation:
return RequestContext(
token=installation.bot_token,
bot_user_id=installation.bot_user_id,
)
self._logger.warn("No installation found for team", {"teamId": team_id})
self._logger.warn(
"No installation found for team",
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
)
return None
except Exception as exc:
self._logger.error("Failed to resolve token for team", {"teamId": team_id, "error": exc})
self._logger.error(
"Failed to resolve token for team",
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install, "error": exc},
)
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't collapse installation-provider failures into normal cache misses.

The new installation_provider path catches every exception and returns None. Callers treat None as “installation not found” and often ack/continue, so a transient provider outage can silently drop event callbacks or interactive requests with no retry signal.

💡 Proposed fix
         except Exception as exc:
             self._logger.error(
                 "Failed to resolve token for team",
                 {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install, "error": exc},
             )
-            return None
+            raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/slack/adapter.py` around lines 994 - 1039, The current
try/except in _resolve_token_for_team swallows all exceptions (including from
self._installation_provider.get_installation) and returns None, which callers
interpret as "not found"; change the error handling so provider failures are not
collapsed into cache misses: call
self._installation_provider.get_installation(...) inside a small try/except that
logs the exception (use self._logger.error with the
installationId/isEnterpriseInstall and the exception) and then re-raise the
exception (or return a distinct failure signal upstream) instead of returning
None; keep the original fallback to await self.get_installation(installation_id)
only when the provider call succeeds or explicitly returns no installation, and
ensure RequestContext construction remains unchanged.

Comment on lines +29 to +32
# Match bare @mentions (e.g. "@george") to rewrite as Slack's `<@george>`.
# The lookbehind excludes `<` (already-formatted mentions like `<@U123>`) and
# any word character, so email addresses like `user@example.com` are left alone.
BARE_MENTION_REGEX = re.compile(r"(?<![<\w])@(\w+)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Slack message payloads, does <@...>require a Slack user ID, and are@channel/@herespecial cases that should not be rewritten as<@name>?

💡 Result:

In Slack message payloads, the <@...> syntax specifically requires a Slack user ID (e.g., <@u12345678>) to correctly trigger a mention [1][2]. When using this syntax, the ID is essential for the Slack client to resolve the user and notify them properly [2]. Special mentions like @channel, @here, @everyone, and @group are not user mentions and should not be formatted as <@name>. Instead, they must be represented using the special command syntax <!foo>, where foo is the command [3]. Specifically: - @channel: <!channel> [3] - @here: <!here> [3] - @everyone: <!everyone> [3] - @group: <!group> [3] These <!...> sequences are distinct from user mentions and tell the Slack client to trigger the appropriate notification behavior [3]. For developers using Block Kit, you should use the designated API objects rather than raw strings whenever possible, but when using mrkdwn text objects, these specific formatting syntaxes are required [1][2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant slack adapter mention resolution + formatting converter code
files=(
  "src/chat_sdk/adapters/slack/format_converter.py"
  "src/chat_sdk/adapters/slack/adapter.py"
)

for f in "${files[@]}"; do
  echo "==== $f ===="
  if [ -f "$f" ]; then
    wc -l "$f"
    sed -n '1,260p' "$f" | nl -ba | sed -n '1,260p'
  else
    echo "MISSING: $f"
  fi
done

# Also search for mention/regex usage to ensure we capture all call sites
echo "==== rg mention related ===="
rg -n "BARE_MENTION_REGEX|to_slack_payload|to_response_url_text|_finalize|_node_to_mrkdwn|_resolve_outgoing_mentions|mention" src/chat_sdk/adapters/slack -S

Repository: Chinchill-AI/chat-sdk-python

Length of output: 244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "src/chat_sdk/adapters/slack/format_converter.py"
  "src/chat_sdk/adapters/slack/adapter.py"
)

for f in "${files[@]}"; do
  echo "==== $f ===="
  if [ -f "$f" ]; then
    wc -l "$f"
    # Print lines 1-260 with line numbers
    cat -n "$f" | sed -n '1,260p'
  else
    echo "MISSING: $f"
  fi
done

echo "==== rg mention related ===="
rg -n "BARE_MENTION_REGEX|_finalize|to_slack_payload|to_response_url_text|_node_to_mrkdwn|_resolve_outgoing_mentions|<!@|<!channel|<!here|<!everyone|<!group|`@channel`|`@here`|`@everyone`|`@group`" src/chat_sdk/adapters/slack -S

Repository: Chinchill-AI/chat-sdk-python

Length of output: 25844


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print targeted sections with context
sed -n '2820,3010p' src/chat_sdk/adapters/slack/adapter.py
echo "----"
sed -n '3460,3700p' src/chat_sdk/adapters/slack/adapter.py
echo "----"
sed -n '3880,4080p' src/chat_sdk/adapters/slack/adapter.py
echo "----"
sed -n '4520,4688p' src/chat_sdk/adapters/slack/adapter.py

Repository: Chinchill-AI/chat-sdk-python

Length of output: 34066


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "<!channel|<!here|<!everyone|<!group" src/chat_sdk/adapters/slack -S || true
rg -n "@(channel|here|everyone|group)\b" src/chat_sdk/adapters/slack -S || true
rg -n "slack:user-by-name:|user-by-name" src/chat_sdk/adapters/slack/adapter.py -S

Repository: Chinchill-AI/chat-sdk-python

Length of output: 226


🌐 Web query:

Slack does plain text "@channel" trigger mention or must it use "<!channel>"? Slack mrkdwn documentation

💡 Result:

When sending messages via the Slack API, you must use the <!channel> syntax to trigger an @channel mention [1][2][3]. While users typing in the standard Slack client UI can simply type "@channel" to trigger a notification, the Slack platform's mrkdwn formatting requires specific escape sequences for programmatic mentions [4][3]. In the mrkdwn syntax used by the Slack API, special system mentions are represented using the <!command> format [3]. The specific special commands for notifications are: - <!channel>: Notifies all members of a channel [4][1][2]. - <!here>: Notifies only the active members of a channel [4][1][2]. - <!everyone>: Notifies every non-guest member of a workspace (typically restricted to the #general channel) [4][1][2]. If you send the plain text string "@channel" via the API, it will generally render as literal text rather than triggering a mention, unless your integration or the destination channel has specific auto-parsing (link_names) enabled, which is not recommended for reliable programmatic behavior [5][3]. To ensure a mention is triggered, always use the bracketed <!channel> syntax [3].

Citations:


Fix Slack mention formatting: don’t rewrite unresolved @name (or @channel/@here) into <@...>

  • SlackFormatConverter._finalize() and _node_to_mrkdwn() apply BARE_MENTION_REGEX to convert any leftover @(\w+) into <@\1> (29-32, 172-178, 191-194), even when adapter.py::_resolve_outgoing_mentions() couldn’t resolve the name and already left it literal.
  • Slack expects <@U...> for user mentions, and @channel/@here/@everyone/@group must use <!channel> / <!here> / <!everyone> / <!group>—not <@name>.
  • Restrict the bare-mention rewrite to Slack user-id-shaped tokens only (e.g. @U...), and handle the special commands separately (or exclude them entirely).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/slack/format_converter.py` around lines 29 - 32,
BARE_MENTION_REGEX is too permissive and rewrites unresolved literals like
"`@alice`" or "`@channel`" into `<`@alice`>`; change it to only match Slack
user-ID-shaped tokens and/or exclude special commands: update BARE_MENTION_REGEX
to something like r"(?<![<\w])@([UW][A-Z0-9]+)" (match Slack IDs that start with
U or W and contain caps/numbers) and ensure the transformation code in
SlackFormatConverter._finalize() and SlackFormatConverter._node_to_mrkdwn() only
applies this regex; separately handle or explicitly exclude the special words
"channel", "here", "everyone", and "group" (or convert them to
`<!channel>`/`<!here>`/`<!everyone>`/`<!group>` where intended) and do not apply
the rewrite when adapter.py::_resolve_outgoing_mentions() left a literal
unresolved.

self._name: str = "telegram"
self._lock_scope: LockScope = "channel"
self._persist_message_history: bool = True
self._persist_thread_history: bool = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Root cause: protocol migration is incomplete across src/chat_sdk/adapters/telegram/adapter.py and src/chat_sdk/adapters/whatsapp/adapter.py.

Both adapters switched to persist_thread_history, but the active Adapter protocol still requires persist_message_history. Add a temporary alias property in both files (or atomically migrate the protocol and all call sites in the same PR) to restore type and contract compatibility.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/telegram/adapter.py` at line 597, The Adapter protocol
still expects persist_message_history but this adapter only defines
_persist_thread_history/persist_thread_history; add a temporary alias property
named persist_message_history on the Telegram adapter class that delegates to
the existing self._persist_thread_history (getter and setter) so the class
satisfies the old protocol; do the same change in the WhatsApp adapter (add a
persist_message_history property that reads/writes the existing
_persist_thread_history/persist_thread_history) or alternatively migrate the
protocol and all call sites together—ensure the alias uses the same backing
field (self._persist_thread_history) to maintain identical behavior.

Source: Pipeline failures

Comment on lines +184 to +197
async def _fetch(url: str, *, method: str, headers: dict[str, str], body: str) -> tuple[int, str]:
"""POST ``body`` to ``url`` and return ``(status, text)``.

Thin seam over aiohttp so tests can stub the network the way upstream
stubs global ``fetch``. aiohttp is an optional dependency, so it is
imported lazily (hazard #10); only http(s) URLs are supported, matching
the WHATWG ``fetch`` upstream relies on.
"""
import aiohttp

async with (
aiohttp.ClientSession() as session,
session.request(method, url, data=body.encode("utf-8"), headers=headers) as response,
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate callback URLs before issuing the request.

_fetch() sends whatever string was stored in callback_url; the only enforcement today is a docstring comment. That leaves this path open to SSRF against loopback/private targets or credential-bearing URLs whenever a callback destination is misconfigured or user-influenced. Reject unsafe URLs before calling aiohttp rather than relying on downstream failures.

As per coding guidelines, "Validate external URLs before making requests to prevent SSRF attacks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/callback_url.py` around lines 184 - 197, The _fetch function
currently forwards any string URL to aiohttp, creating an SSRF risk; before
opening aiohttp.ClientSession/session.request validate the URL: parse the URL in
_fetch, ensure the scheme is http or https, reject URLs containing credentials
(username/password in netloc), resolve the host (via socket.getaddrinfo) and
verify none of the resolved IPs are loopback, private, link-local, multicast, or
otherwise non-public using the ipaddress module; if validation fails raise an
exception (e.g., ValueError) and do not call
aiohttp.ClientSession/session.request. Ensure these checks live at the top of
_fetch so unsafe URLs are rejected before any network call.

Source: Coding guidelines

Comment thread src/chat_sdk/channel.py
Comment on lines 470 to +475
# Both `_state_adapter_instance` (old chat's state backend)
# and `_message_history` (old chat's cache) would route to
# and `_thread_history` (old chat's cache) would route to
# the previous context otherwise.
if adapter is not None or chat is not None:
channel._state_adapter_instance = None
channel._message_history = None
channel._thread_history = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Rebind the history cache when rebinding the chat context.

This path clears _thread_history, but the later chat rebind only restores _state_adapter_instance. A ChannelImpl.from_json(..., chat=...) therefore loses cached messages() fallback and append behavior even when ChatConfig.thread_history is enabled.

Also applies to: 503-514

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/channel.py` around lines 470 - 475, When rebinding chat context
in ChannelImpl.from_json the code clears channel._thread_history but only
restores _state_adapter_instance when chat is provided, which loses the
messages() cache/fallback; change the logic so that when chat is passed in you
also rebind the history cache instead of unconditionally clearing it — e.g. if
chat is not None set channel._thread_history from chat.thread_history or
recreate it according to ChatConfig.thread_history (and mirror the same change
in the other identical block affecting lines around the second rebind) so
messages() behavior and append fallback are preserved.

Comment thread src/chat_sdk/chat.py
Comment on lines +1108 to +1115
self._logger.error(
"Modal callbackUrl POST failed",
{"callback_url": callback_url, "error": str(post_result.error)},
)
except Exception as error: # mirrors upstream's trailing .catch
self._logger.error(
"Modal callbackUrl POST failed",
{"callback_url": callback_url, "error": str(error)},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact callback URLs in failure logs.

These log lines emit the full callback_url on transient POST failures. Callback URLs often carry tenant identifiers, shared secrets, or signed query params, so this turns a network blip into a log-based secret leak. Log a redacted form instead.

Also applies to: 1462-1468

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/chat.py` around lines 1108 - 1115, The log statements calling
self._logger.error that include the full callback_url should be changed to log a
redacted version of callback_url instead of the raw value; implement or call a
small sanitizer (e.g., redact_callback_url or similar) that strips or masks
query params and sensitive path segments and use that sanitized value in both
the Modal callback POST error branches shown (the block with
str(post_result.error) and the except Exception as error branch), and make the
same replacement for the analogous logger calls around lines 1462-1468 so no raw
callback_url is emitted in logs.

Comment thread src/chat_sdk/transcripts.py
Comment thread tests/test_chat_faithful.py Outdated
Comment on lines +4362 to +4367
# _store_modal_context persists via a background task
await asyncio.sleep(0.02)

modal_context_keys = [k for k in state.cache if k.startswith("modal-context:")]
assert len(modal_context_keys) == 1
context_id = modal_context_keys[0].split(":")[-1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Replace fixed sleeps with deterministic async waits in new modal callback tests.

Line 4363 and Line 4496 rely on hardcoded sleeps to synchronize background work. These are prone to CI flakiness; prefer polling for the expected state/task completion with a timeout.

Also applies to: 4495-4497

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_chat_faithful.py` around lines 4362 - 4367, Replace the hardcoded
asyncio.sleep calls used to wait for background persistence of modal context
with a deterministic polling wait with a timeout: repeatedly check state.cache
for keys starting with "modal-context:" (as done in the current assertion) until
the expected count appears or a short timeout elapses, and use asyncio.wait_for
or an equivalent awaitable to fail deterministically on timeout; apply the same
polling pattern for both occurrences (the block around _store_modal_context and
the later block around the modal callback at the other location) so tests wait
reliably for the background task to complete.

…er Protocol

CI's Lint & Type Check job failed on pyrefly (43 errors) while every other
step was green:

- persist_thread_history / persist_message_history were declared on the
  structural Adapter Protocol, making them REQUIRED members and breaking
  conformance for every adapter that satisfies the Protocol without
  extending BaseAdapter (the exact trap documented in PR #131). Moved to
  BaseAdapter only, with a NOTE on the Protocol; the SDK reads both flags
  via getattr.
- MessengerAdapter (recovered #124 — the original cause of the failure on
  the recovery push) now defines get_user, mirroring BaseAdapter's
  ChatNotImplementedError contract; the real Graph-backed lookup remains
  issue #132. Also: _map_attachment_type returns the Attachment.type
  Literal, and the user-profile cache write is typed.
- transcripts: retention_ms coerced to int (append_to_list ttl_ms contract;
  fractional milliseconds are meaningless).

uv run pyrefly check: 0 errors (matches the origin/main baseline).

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread src/chat_sdk/types.py Fixed
Comment thread tests/test_chat_faithful.py Fixed

Copy link
Copy Markdown
Collaborator Author

@codex review


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e1cb88286

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/chat_sdk/ai/tools.py
Comment on lines +639 to +642
result = await thread.adapter.fetch_messages(
args["threadId"],
FetchOptions(limit=limit, cursor=cursor, direction=direction),
)

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 Badge Read through the thread history fallback

For adapters that intentionally have no server-side history, this bypasses the SDK fallback that ThreadImpl.messages() provides. For example, WhatsApp's adapter returns an empty FetchResult and relies on persisted thread history; calling thread.adapter.fetch_messages() directly means an agent's fetchMessages tool sees an empty conversation even after the SDK has stored messages. Please route through the thread iterator/fallback or duplicate that fallback here.

Useful? React with 👍 / 👎.

Comment on lines +561 to +564
"message": {
"mid": result["message_id"],
"is_echo": True,
},

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 Badge Preserve card text in Messenger cache

When sending a Messenger card/template, this synthetic echo only contains mid and is_echo, so _parse_messenger_message() caches it as a Message with empty text. Because fetch_messages() reads from this adapter cache first, a card the bot just sent is later read back as a blank message instead of its fallback/title text. Include the card fallback text or attachment payload in the cached event before parsing it.

Useful? React with 👍 / 👎.

Comment on lines +109 to +110
if getattr(message, "markdown", None) is not None:
return {"markdown_text": self._finalize(message.markdown)}

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 Badge Guard long Slack markdown payloads

This sends every PostableMarkdown through markdown_text unconditionally, but Slack documents markdown_text as limited to 12,000 characters and mutually exclusive with text (Slack chat.postMessage docs). A markdown response between 12k and the normal text limit now fails where the previous text/mrkdwn path would post, so please validate/split or fall back before returning markdown_text.

Useful? React with 👍 / 👎.

claude added 3 commits June 14, 2026 10:05
`_store_modal_context` was synchronous and scheduled the state write as a
fire-and-forget task before `open_modal` was awaited. With a remote
(Redis/Postgres) state backend a fast modal submit could race ahead of the
write and miss the stored callbackUrl/channel, silently breaking modal
callback POSTs and context restoration.

Make `_store_modal_context` `async` and `await` it at both modal-opening
call sites (slash + action paths) before `adapter.open_modal`, matching
upstream where `openModal` awaits `storeModalContext` (chat.ts
:1280/:1342/:1554). Remove the now-unnecessary `asyncio.sleep(0.02)`
workaround in the modal-callback test; the context is persisted
deterministically by the time `open_modal` returns.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
Convert the `config.X or <default/env>` truthiness patterns to
`config.X if config.X is not None else <default/env>` for api_version
(adapter) and the app_secret / page_access_token / verify_token env
fallbacks (config helpers), per the TS->Python truthiness port rule.
Mirrors upstream's `??` null-coalescing (index.ts:92/931). Behaviour is
unchanged for real configs; an explicit value now wins over the fallback
even if it is falsy.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
The `ChatInstance` Protocol annotated `process_message` as `-> None`, but
the concrete `Chat.process_message` returns `asyncio.Task[None] | None`
(it hands back the handler task so streaming callers can await it). Widen
the Protocol annotation to match the implementation and upstream's
interface-equals-impl typing. No runtime impact.

https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
Comment thread src/chat_sdk/chat.py
await self._invoke_handler(pat.handler, full_event)

if callback_url_task is not None:
await callback_url_task
Comment thread src/chat_sdk/types.py
message: Message | Callable[[], Awaitable[Message]],
options: WebhookOptions | None = None,
) -> None: ...
) -> asyncio.Task[None] | None: ...
Comment thread src/chat_sdk/types.py
# ``transcripts`` is not configured on the Chat instance — callers should
# check ``ChatConfig.transcripts`` if they need a no-raise guard.
@property
def transcripts(self) -> TranscriptsApi: ...
Comment thread src/chat_sdk/types.py
No-op if the Message has no ``user_key`` (resolver returned None).
- For AppendInput: ``options.user_key`` is required.
"""
...
Comment thread src/chat_sdk/types.py

async def count(self, query: CountQuery) -> int:
"""Total stored count for a user key."""
...
Comment thread src/chat_sdk/types.py

async def delete(self, target: DeleteTarget) -> DeleteResult:
"""GDPR / DSR delete — wipes every stored message under the user key."""
...
Comment thread src/chat_sdk/types.py
raise ``max_per_user``; to fetch a different slice, narrow with
``thread_id`` / ``platforms`` / ``roles``.
"""
...
@patrick-chinchill patrick-chinchill merged commit 367d1d2 into main Jun 18, 2026
8 checks passed
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.

2 participants