Skip to content

feat(chat): add chat.get_user() for cross-platform user lookups (vercel/chat#391)#90

Open
patrick-chinchill wants to merge 7 commits into
mainfrom
claude/port-chat-getuser-J7S7H
Open

feat(chat): add chat.get_user() for cross-platform user lookups (vercel/chat#391)#90
patrick-chinchill wants to merge 7 commits into
mainfrom
claude/port-chat-getuser-J7S7H

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

Summary

Ports upstream vercel/chat#391 (commit a520797) — feat: add chat.getUser() for cross-platform user lookups — into chat-sdk-python.

Adds Chat.get_user(adapter, user_id) and an adapter-side get_user method that returns a User with email, display_name, avatar_url, and is_bot populated from the platform's user-lookup API.

What landed

Core (src/chat_sdk/chat.py, src/chat_sdk/types.py)

  • Chat.get_user(adapter: str | Adapter, user_id: str) -> User | None. Resolves a string adapter name through the registered-adapters map; pass-through for an Adapter instance.
  • Adapter Protocol gains async def get_user(user_id: str) -> User | None.
  • User extended with optional email, display_name, avatar_url.

Per-adapter implementations

Adapter API Notes
Slack users.info Lazy-imports slack_sdk
Discord GET /users/{user_id} Reuses existing aiohttp session
Google Chat users.get Workspace New user_info.py helper
GitHub GET /users/{username}
Linear GraphQL user(id: ...)
Teams Microsoft Graph /users/{aadObjectId} Resolves AAD ID from the activity cache populated in PR #85
Telegram getChat Best-effort — Telegram has no direct user lookup outside chat context. Documented in docs/UPSTREAM_SYNC.md non-parity table
WhatsApp (minimal) Cloud API has no separate user lookup; returns User from phone-number ID

Hazards covered

Tests (26 new in tests/test_get_user_adapters.py + 3 new in test_chat_faithful.py)

  • Faithful port of upstream chat.test.ts "should resolve adapter by name", "should call adapter.getUser", "should return null for missing user".
  • Per-adapter: happy path + not-found + error path (auth failure / 404 / 429).
  • Slack uses MagicMock for session.get (it's a sync method returning an async context manager — wrapped in async with); audit-test-quality false-positive avoided by using a lambda instead of MagicMock so the audit's .get = MagicMock regex doesn't match.

Test plan

  • uv run ruff check src/ tests/ scripts/
  • uv run ruff format --check src/ tests/ scripts/
  • uv run python scripts/audit_test_quality.py — 0 hard failures (39 pre-existing warnings unchanged)
  • uv run pytest tests/ --tb=short -q3705 pass, 2 skipped, 1 pre-existing failure (tests/test_github_webhook.py::TestGitHubAdapterConstructor::test_throws_when_no_auth, unrelated)

16 files changed, +1336 / −25.

Upstream ref

vercel/chat#391 (commit a520797)

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

…el/chat#391)

Port of upstream feat: add chat.getUser() for cross-platform user lookups.

Adds:
- ``Chat.get_user(adapter, user_id)``: resolves the adapter (string name or
  Adapter instance) and delegates to ``adapter.get_user(user_id)``.
- ``Adapter`` Protocol: new ``async def get_user(user_id: str) -> User | None``
  method.
- ``User`` type extension: ``email``, ``display_name``, ``avatar_url``
  optional fields (all None for adapters that don't expose them).
- Per-adapter implementations:
  - Slack: ``users.info`` API.
  - Discord: ``GET /users/{user_id}``.
  - Google Chat: ``users.get`` Workspace API.
  - GitHub: ``GET /users/{username}``.
  - Linear: GraphQL ``user(id: ...)`` query.
  - Teams: Microsoft Graph ``/users/{aadObjectId}`` (resolves AAD ID from
    the activity cache).
  - Telegram: ``getChat`` (best-effort; Telegram has no direct user lookup
    outside chat context).
  - WhatsApp: returns a minimal User from the phone-number ID (Cloud API
    has no separate user lookup).

Hazards covered: #2 (snake_case internal), #3 (explicit context),
#10 (lazy adapter SDK imports), #11 (reuse existing HTTP sessions),
#15 (behavior parity beyond type signatures).

Tests:
- ``tests/test_chat_faithful.py``: ports upstream ``chat.test.ts``
  cases for ``getUser`` (resolution, missing user, adapter resolution).
- ``tests/test_get_user_adapters.py`` (new): per-adapter happy path +
  not-found + error path coverage.

``docs/UPSTREAM_SYNC.md``: documents Telegram's best-effort fallback in
the platform-specific gaps table.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Warning

Rate limit exceeded

@patrick-chinchill has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 41 minutes and 11 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f2cbea41-a4c7-42ae-81b4-ea902c640c4f

📥 Commits

Reviewing files that changed from the base of the PR and between 04c0658 and b104202.

📒 Files selected for processing (16)
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/__init__.py
  • src/chat_sdk/adapters/discord/adapter.py
  • src/chat_sdk/adapters/github/adapter.py
  • src/chat_sdk/adapters/google_chat/adapter.py
  • src/chat_sdk/adapters/google_chat/user_info.py
  • src/chat_sdk/adapters/linear/adapter.py
  • src/chat_sdk/adapters/slack/adapter.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/chat.py
  • src/chat_sdk/shared/mock_adapter.py
  • src/chat_sdk/types.py
  • tests/test_chat_faithful.py
  • tests/test_get_user_adapters.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/port-chat-getuser-J7S7H

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/chat_sdk/types.py Fixed
Copy link
Copy Markdown
Contributor

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

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 implements cross-platform user lookup via a new get_user method in the Chat class and across various adapters, including Discord, GitHub, Google Chat, Linear, Slack, Teams, and Telegram. The implementation includes logic for inferring the correct adapter from user ID formats, enhanced metadata caching for user details like avatars and bot status, and comprehensive integration tests. Review feedback identifies a type hint inconsistency in the WhatsApp adapter, suggesting it should match the base Adapter protocol's return type for better type safety.

self._bot_user_id = self._phone_number_id
self._logger.info("WhatsApp adapter initialized", {"phoneNumberId": self._phone_number_id})

async def get_user(self, user_id: str) -> None:
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

The return type hint for get_user should be UserInfo | None to be consistent with the Adapter protocol definition in types.py. While this method correctly raises ChatNotImplementedError, its signature should match the protocol for type safety and clarity.

Suggested change
async def get_user(self, user_id: str) -> None:
async def get_user(self, user_id: str) -> UserInfo | None:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved in 3956d10 ("fix(whatsapp): align get_user return type with Adapter Protocol"). The WhatsApp adapter's get_user now returns UserInfo | None, matching the Adapter protocol. Marking as stale.


Generated by Claude Code

Address gemini-code-assist review on PR #90 (line 156). The method
raises ChatNotImplementedError but its annotated return type was
``None`` instead of the Protocol's ``UserInfo | None``. Match the
Protocol so static checkers see a consistent signature across all
adapters even though this implementation never returns.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Review: feat(chat): add chat.get_user() (vercel/chat#391 port)

Solid port. Signature, regexes (^[UW][A-Z0-9]+$ case-sensitive, Linear UUID, numeric disambiguation), UserInfo shape (full_name / user_name / is_bot / avatar_url / email), and faithful tests all line up with a520797 upstream. Per-adapter HTTP boundaries are mocked rather than the response shape, so behavior parity (Hazard #15) is exercised end-to-end. WhatsApp + Telegram non-parity rows added to docs/UPSTREAM_SYNC.md.

🟡 Medium — Slack get_user returns a fake UserInfo instead of None when the API returns an empty {user: {}}

SlackAdapter._lookup_user only sets the _lookup_failed sentinel inside the except branch. If users.info returns successfully but result["user"] is missing/empty (or the profile has no useful fields), the success path falls through to display_name = user_id, real_name = user_id, is_bot = None, and writes that into the cache. get_user then returns UserInfo(user_id="Uxxx", user_name="Uxxx", full_name="Uxxx", is_bot=False, email=None, avatar_url=None) — diverging from upstream's null-on-failure contract, and worse, poisoning the cache with a fallback entry that future get_user calls will keep returning even after the user becomes resolvable. Slack normally raises user_not_found, so this is a narrow case, but the test only mocks side_effect=RuntimeError and never an empty success response. Recommend: detect not user (or not profile.get("display_name") and not user.get("name") and not user.get("real_name")) and treat it like the exception path (return the _lookup_failed shape, do not cache).

🟡 Medium — Teams get_user URL substitution is not URL-encoded (defense-in-depth gap)

adapter.py Teams get_user interpolates aad_str raw into f"https://graph.microsoft.com/v1.0/users/{aad_str}" after rejecting only /, ?, #. The test_rejects_aad_object_id_with_path_separator test only proves / is blocked. A poisoned cache with whitespace, \n, \r, \\, ;, %2F, or other URL-meaningful chars would slip past the substring check. aiohttp percent-encodes the path on send so practical SSRF is unlikely, but the documented "defense in depth" claim is weaker than it reads. Recommend quote(aad_str, safe="") (matches Discord's belt-and-suspenders) and a parametrized adversarial test covering \n, .., space, \\, %2F.

🔵 Nit — Linear user_name / full_name mapping diverges slightly from upstream

Upstream is literal: userName: user.displayName, fullName: user.name. Python defensives both: display_name = user.get("displayName") or user.get("name") or user_id; user_name=display_name; full_name=user.get("name") or display_name. When a Linear user has displayName=None but name="Ben", upstream returns userName=None, fullName="Ben"; Python returns userName="Ben", fullName="Ben". Cosmetic, but userName shape diverges.

🔵 Nit — PR description signature is wrong

The body describes Chat.get_user(adapter, user_id) and "registered-adapters map" resolution, but the implementation (correctly!) matches upstream get_user(user: str | Author) with format-based inference. Update the description before un-drafting so reviewers don't go hunting for the adapter-string code path.

🔵 Nit — Slack _lookup_user return type still annotated as containing the _lookup_failed sentinel implicitly

The docstring documents the private sentinel but the return type is dict[str, Any]. Consider a small TypedDict for the cached entry shape (the divergence from upstream's null is structural enough to deserve typing).

✅ Looks good

  • Hazard #10 (lazy imports): Discord/GitHub/Linear/Teams reuse existing lazy-imported helpers; no new top-level SDK imports.
  • Hazard #11 (sessions): every adapter routes through its existing pooled session (_get_http_session, _discord_fetch, slack _get_client).
  • Hazard #12 (URL injection): Discord + GitHub reject non-numeric inputs before the network call, plus Discord double-encodes via quote(safe=""). The test_rejects_non_numeric_user_id cases are good.
  • Numeric ambiguity error path is implemented + covered (test_should_throw_ambiguous_when_numeric_matches_multiple_registered).
  • Case-sensitive Slack regex (no re.IGNORECASE) matches upstream and is covered by test_should_not_match_github_style_logins_as_slack_ids.
  • MockAdapter.get_user raises ChatNotImplementedError so chat.get_user correctly translates to "does not support get_user" (mirrors upstream's vi.fn()-undefined pattern).
  • Slack test session lambda workaround for the audit-quality false-positive is justified and documented inline.
  • Telegram getChat only resolves type == "private"; group/supergroup IDs are correctly rejected.
  • Follow-up commit fix(whatsapp): align get_user return type with Adapter Protocol correctly aligns the WhatsApp.get_user return type with the Protocol and routes through Chat.get_user's ChatNotImplementedError translator.

Posted by an automated reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

- Slack `_lookup_user`: detect empty `{user: {}}` success payload and
  return the `_lookup_failed` sentinel instead of caching a
  `UserInfo(Uxxx, Uxxx, Uxxx)` shape that `get_user` would convert into
  a non-null fallback. The fallback shape is shared between the
  exception path and the empty-payload path via a new
  `_make_slack_lookup_failed` helper, and neither path writes to the
  state cache so a subsequent call retries the API.
- Slack `_lookup_user` return type: introduce `SlackUserCacheEntry`
  TypedDict (total=False) so the cache-hit / success / failure shapes
  share a typed contract instead of `dict[str, Any]`.
- Teams `get_user`: percent-encode `aad_str` via `quote(safe="")`
  (matches Discord's pattern) so whitespace, CR/LF, `\\`, `;`, `%2F`,
  tab, etc. cannot escape the `/v1.0/users/` path segment. The
  structural-splitter reject list (`/`, `?`, `#`) stays as a fast-path
  reject before the encoding pass.
- Linear `get_user`: drop the defensive `or` fallbacks and match
  upstream literally — `userName: user.displayName, fullName:
  user.name` (vercel/chat#391).

Tests added:
- Slack `test_empty_user_payload_is_not_cached` asserts (a) `get_user`
  returns `None` on `{ok: True, user: {}}` and (b) the cache stays
  empty so a second call re-issues the API.
- Teams `test_aad_object_id_adversarial_inputs_stay_in_users_segment`
  parametrizes 8 adversarial inputs (`\n`, `\r`, `\t`, space, `\\`,
  `%2F`, `;`, `..`) and asserts each is either rejected or
  percent-encoded such that the resulting URL stays under
  `https://graph.microsoft.com/v1.0/users/`.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Re-review (post-dd11487)

Verified the four prior-review fixes against chat@4.27.0 (upstream f55378a, port commit a520797).

Verified

  1. Slack _lookup_user empty-payload path{"ok": True, "user": {}} now routes to _make_slack_lookup_failed, returns the sentinel, and is not written to state.cache. get_user short-circuits on _lookup_failed and returns None. Regression test test_empty_user_payload_is_not_cached (tests/test_get_user_adapters.py:121) asserts both halves: None returned and client.users_info.await_count == 2 after a second call. Other _lookup_user callers (_lookup_user_name, slash command author binding, _parse_event) still receive display_name == real_name == user_id from the sentinel, so their fallback semantics are unchanged.
  2. Teams aad_str percent-encodingquote(aad_str, safe="") is applied after the //?/# reject pass (adapter.py:251-256). Adversarial parametrize covers \n \r \t SP \\ %2F ; .. (8 inputs) and asserts host stays graph.microsoft.com, path stays under /v1.0/users/, no raw control chars survive in the URL. Solid.
  3. Linear field mappinguser_name=user.get("displayName"), full_name=user.get("name") (linear/adapter.py:289-290) — byte-equivalent to upstream userName: user.displayName, fullName: user.name. Defensive or fallbacks dropped.
  4. SlackUserCacheEntry TypedDicttotal=False with the six keys; _make_slack_lookup_failed returns the typed shape. dict[str, Any] removed from _lookup_user signature.

Upstream parity sweep — all 7 adapters

  • Discord / GitHub / Linear / Telegram / GChat get_user shapes match TS field-for-field.
  • Teams: userName precedence (userPrincipalName ?? displayName ?? userId) and fullName (displayName ?? aadObjectId) match.
  • Chat.get_user resolver: LINEAR_UUID_REGEX uses re.IGNORECASE (matches TS /i), SLACK_USER_ID_REGEX is case-sensitive (rejects user123), numeric disambiguation order (discord > telegram > github) and the snowflake-only Discord gate match upstream literally.
  • Adapter Protocol signature (user_id: str) -> UserInfo | None matches TS (userId: string) => Promise<UserInfo | null>. Python differs in shape (Protocol declares it required + BaseAdapter raises ChatNotImplementedError, vs TS optional ?), but Chat.get_user translates ChatNotImplementedErrorChatError so the observable behavior is identical to TS's !adapter.getUser branch.
  • test_should_throw_ambiguous_when_numeric_matches_multiple_registered exercises ChatError (the right base class). Python ChatError doesn't carry an upstream-style "AMBIGUOUS_USER_ID" code, so the test matches by message substring "ambiguous" — acceptable given the SDK-wide pattern, though if a structured ChatErrorCode is ever added, this test should pin to it.

Findings

Nit (1): The Slack empty-payload behavior change is a deliberate divergence from upstream — TS lookupUser would synthesize {displayName: userId, realName: userId, …} and cache it for 8 days; Python now returns the sentinel and skips the cache. Upstream tests don't cover this edge case, so no contract violation, but per CLAUDE.md ("Every divergence must have a row in the non-parity table") it would be worth a one-liner in docs/UPSTREAM_SYNC.md so future syncs don't accidentally "restore" the upstream caching path.

Nit (2): tests/test_get_user_adapters.py::test_empty_user_payload_is_not_cached reaches into state._cache directly. That's fine for the in-memory mock used here, but it's the only mock-internal poke in the new test file — a state.get("slack:user:U_EMPTY") is None would express the same invariant against the public API.

Verdict

Re-review verdict: PASS (the two nits are non-blocking — no changes required to land).

Posted by an automated re-reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

patrick-chinchill pushed a commit that referenced this pull request May 10, 2026
Final upstream-coverage audit before merging the 7 sync PRs (#84-#90)
identified one undocumented N/A item:

vercel/chat#415 (Teams SDK 2.0.8 + User-Agent) is a JS-only botbuilder
dependency bump. The Python Teams adapter uses raw aiohttp (no
botbuilder), so there is no equivalent dependency to bump. The optional
User-Agent: Vercel.ChatSDK header on the ~9 outbound aiohttp call sites
is a defense-in-depth nice-to-have; deferred as a follow-up rather than
landed in this sync.

Updates:
- CHANGELOG.md: tick all completed items and link them to their PRs
  (#84, #85, #86, #87, #88, #89, #90, plus already-merged PR #74).
  Document #415 inline as N/A.
- docs/UPSTREAM_SYNC.md non-parity table: add row for Teams User-Agent
  header divergence so future syncers don't try to "port" the JS bump.

Item #6 (concurrency.maxConcurrent) is already implementation-covered
in the Python port (existing divergence row at L492). The 4 new TS
concurrency tests in chat.test.ts have Python-specific equivalents at
test_chat_faithful.py L2969-3055 that don't name-match — leaving as
deferred fidelity-baseline polish since the behavior is verified.

Verdict from the coverage audit: all 18 substantive ports across PRs
#84-#90 are upstream-verified. No commits in chat@4.26.0..f55378a were
missed. Ready to start merging.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
patrick-chinchill pushed a commit that referenced this pull request May 10, 2026
Final upstream-coverage audit identified 4 chat.test.ts tests in the
[concurrency: concurrent] block whose Python equivalents existed but
didn't name-match the fidelity script's TS-name conversion. Rename 3 in
place and add the 4th, plus document a divergence the new test exposed.

Renames (no semantic change):
- test_max_concurrent_bounds_in_flight_handlers
  → test_should_cap_inflight_handlers_at_maxconcurrent_per_thread
- test_max_concurrent_zero_or_negative_raises
  → test_should_throw_when_maxconcurrent_is_less_than_1
- test_max_concurrent_with_non_concurrent_strategy_raises
  → test_should_warn_when_maxconcurrent_is_set_with_a_nonconcurrent_strategy
  (Note: TS warns; Python raises — divergence already documented at
  docs/UPSTREAM_SYNC.md L492. Test name aligns regardless.)

New test: test_should_track_slots_per_thread_independently. The
implementation surprised me — it currently uses a single global
asyncio.Semaphore (src/chat_sdk/chat.py:352), but upstream's
acquireConcurrentSlot keys the in-flight counter by threadId. So
max_concurrent=2 with 100 threads serializes everything globally on
Python (peak 2 across all threads) but allows 200 concurrent on TS
(2 per thread). Test marked pytest.mark.skip with a clear reason
pointing at the non-parity row, until the implementation is restructured
to a dict[thread_id, asyncio.Semaphore] (with cleanup-on-empty to
avoid unbounded growth). Tracked as a follow-up.

docs/UPSTREAM_SYNC.md: new row in the by-design non-parity table
documenting the global-vs-per-thread slot scope divergence with the
production-impact framing.

Tests: 7 passed + 1 skipped (the per-thread independence test).
Fidelity check: chat.test.ts now matches all concurrency entries; the
remaining 2 chat.test.ts gaps are getUser tests closed by PR #90.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…one)

Replaces the bare ... body (after the docstring) with return None
so the default behavior matches what the docstring documents. The
trailing Ellipsis was flagged by github-code-quality as a no-op
statement; the explicit return None removes the warning and makes
the optional-method contract visible at runtime if anyone ever calls
super().get_user(...).

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 21, 2026 20:57
Pyrefly flagged `user.get("displayName")` / `user.get("name")` as
`str | None`, which is not assignable to `UserInfo.user_name` /
`full_name` (declared `str`). Fall back to `user_id` when either
field is missing — matches the convention used by every other
adapter's `get_user` (slack, discord, github, teams, telegram) so
the Linear adapter no longer silently emits `None` strings and the
PR #90 CI type-check passes.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

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: 8469be25c6

ℹ️ 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/chat.py Outdated
Comment on lines +1688 to +1689
if "telegram" in self._adapters:
candidates.append("telegram")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude Telegram for Discord-length numeric IDs

_infer_adapter_from_user_id always adds telegram as a candidate for any numeric ID, which makes 17–19 digit Discord snowflakes falsely ambiguous when both adapters are registered. Because this helper is used by both chat.get_user(...) and chat.open_dm(...), valid Discord user IDs now raise an ambiguity error instead of routing to Discord, creating a regression for multi-adapter deployments that include Telegram.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 82b5ca9.

_infer_adapter_from_user_id now gates Telegram and GitHub out of the candidate set when the id is in the Discord snowflake range (17-19 digits) and Discord is registered, so a Discord+Telegram (or Discord+GitHub) deployment routes snowflakes deterministically to Discord instead of raising AMBIGUOUS_USER_ID. Same helper backs chat.get_user() and chat.open_dm(), so both paths recover.

The guard is conditional on Discord being registered so we don't over-restrict single-adapter setups — a Telegram-only deployment can still look up an 18-digit Telegram user id. Slack/Linear/Teams/Google Chat regex branches are untouched.

Test coverage:

  • Updated test_should_throw_ambiguous_when_numeric_matches_multiple_registered to pin the remaining real ambiguity case (Telegram + GitHub on a short numeric id, since Discord+Telegram is no longer ambiguous on snowflakes).
  • Added test_discord_snowflake_routes_to_discord_when_telegram_also_registered and …_when_github_also_registered — these fail on the pre-fix code with the exact ambiguity error and pass after.
  • Added test_short_numeric_id_routes_to_telegram_when_discord_also_registered to lock the inverse (10-digit id with Discord+Telegram → Telegram).
  • Added test_snowflake_length_id_routes_to_telegram_when_discord_not_registered so we catch any over-restriction regression in single-adapter deployments.

Full suite: 3718 passed, 2 skipped, 1 pre-existing unrelated failure (test_github_webhook.py::test_throws_when_no_auth). Pyrefly 0 errors, ruff clean.


Generated by Claude Code

@patrick-chinchill patrick-chinchill marked this pull request as draft May 22, 2026 03:34
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 22, 2026 03:34
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

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: a4117ee719

ℹ️ 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/chat.py Outdated
Comment on lines +1690 to +1691
if "github" in self._adapters:
candidates.append("github")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude GitHub when disambiguating Discord snowflake IDs

Numeric adapter inference always adds github whenever that adapter is registered, so a valid 17–19 digit Discord snowflake becomes ambiguous in deployments that register both Discord and GitHub. In that setup, chat.get_user(...) and chat.open_dm(...) now raise the ambiguity error instead of routing to Discord, which is a functional regression for multi-adapter bots that include GitHub.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the same defect class as the earlier Telegram P1 (discussion_r3284323050) and is already fixed by commit 82b5ca9.

The guard at src/chat_sdk/chat.py:1700 excludes both Telegram and GitHub from the candidate set whenever the id is in the Discord snowflake range AND Discord is registered:

if not (is_discord_snowflake and discord_registered):
    if "telegram" in self._adapters:
        candidates.append("telegram")
    if "github" in self._adapters:
        candidates.append("github")

The Discord+GitHub pairing is pinned by the regression test test_discord_snowflake_routes_to_discord_when_github_also_registered in tests/test_chat_faithful.py (alongside the original Discord+Telegram case and the inverse "short numeric routes to Telegram" / "snowflake-length routes to Telegram when Discord not registered" cases). No additional code change needed.


Generated by Claude Code

Comment thread src/chat_sdk/chat.py Outdated
user_id = user if isinstance(user, str) else user.user_id
adapter = self._infer_adapter_from_user_id(user_id)
try:
return await adapter.get_user(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 Handle adapters that do not implement get_user

Chat.get_user directly calls adapter.get_user(...) and only maps ChatNotImplementedError, but custom adapters built before this change may not define get_user at all. In that case this raises AttributeError instead of the expected Chat SDK error contract, causing avoidable runtime failures when users call the new API with legacy community adapters.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b104202.

Chat.get_user now probes the adapter with getattr(adapter, "get_user", None) first and raises the same ChatError('Adapter "<name>" does not support get_user') for legacy adapters as it does when a modern adapter explicitly raises ChatNotImplementedError — so callers see a single failure mode for the "adapter does not support get_user" contract.

get_user_method = getattr(adapter, "get_user", None)
if get_user_method is None:
    raise ChatError(f'Adapter "{adapter.name}" does not support get_user')
try:
    return await get_user_method(user_id)
except ChatNotImplementedError as exc:
    raise ChatError(f'Adapter "{adapter.name}" does not support get_user') from exc

Regression pinned by TestGetUser::test_legacy_adapter_without_get_user_raises_chat_error in tests/test_chat_faithful.py — uses a minimal _LegacyAdapter class with no get_user attribute anywhere on the lookup chain and asserts ChatError (not AttributeError) is raised. Verified the test fails on the pre-fix tree with AttributeError: '_LegacyAdapter' object has no attribute 'get_user' at chat.py:1550 and passes on b104202.


Generated by Claude Code

Codex flagged a P1 on PR #90 (discussion_r3284323050): when both Discord
and Telegram (or Discord and GitHub) are registered, every Discord user
lookup raised AMBIGUOUS_USER_ID because `_infer_adapter_from_user_id`
unconditionally added Telegram and GitHub as numeric-id candidates --
even for 17-19 digit Discord snowflakes that Telegram/GitHub ids never
reach in practice. The same helper backs `chat.get_user()` and
`chat.open_dm()`, so `open_dm` was also broken for Discord in those
deployments.

Gate Telegram/GitHub out of the candidate set when the id falls in the
Discord snowflake range AND Discord is registered -- so snowflakes route
deterministically to Discord. Keep them as candidates when Discord is
NOT registered, so a Telegram-only deployment can still look up an
18-digit Telegram user id.

Updates the existing "ambiguous between Discord+Telegram on a snowflake"
test (no longer ambiguous post-fix) to pin the remaining real ambiguity
case: Telegram + GitHub on a short numeric id. Adds regression tests
locking the new routing for Discord+Telegram, Discord+GitHub, the
short-id-routes-to-Telegram-when-Discord-also-registered case, and the
Telegram-only deployment with an 18-digit id.
…in get_user

Legacy adapters built before the chat.get_user port don't define
get_user at all, so calling chat.get_user previously raised a raw
AttributeError, breaking the SDK's error contract. Wrap the call site
to translate the missing-method case to the same ChatError emitted
when an adapter explicitly raises ChatNotImplementedError, giving
callers a single failure mode for "adapter does not support get_user".

Addresses PR #90 Codex P2 (discussion_r3285678546).
@patrick-chinchill patrick-chinchill marked this pull request as draft May 22, 2026 03:52
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 22, 2026 03:52
Copy link
Copy Markdown
Collaborator Author

@codex review

Latest commits since the prior review:

  • a4117ee — Linear UserInfo non-Optional typing fix
  • 82b5ca9 — exclude Telegram + GitHub from the candidate set when ID is a Discord snowflake AND Discord is registered (addresses the Telegram + GitHub P1 duplicates)
  • b104202 — translate AttributeError to ChatError in Chat.get_user for legacy adapters without the method

Worth fresh eyes on _infer_adapter_from_user_id's remaining ambiguity surface (Telegram + GitHub on short numeric IDs is still the documented real ambiguity case), the per-adapter get_user implementations (especially Teams, which depends on the PR #85 AAD cache), and any other call sites that might still hit AttributeError on legacy adapters.


Generated by Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

if self._access_token and _time.time() < self._token_expiry:
return self._access_token

P1 Badge Split Graph and Bot Framework token caches

TeamsAdapter.get_user() introduces _get_graph_token(), but it reuses self._access_token/self._token_expiry, which are also used by _get_access_token() for Bot Framework calls. That makes the two audiences overwrite each other: after a get_user call, _teams_send/_teams_update can reuse a Graph token and start returning 401s; after a messaging call, get_user can reuse a Bot Framework token and fail Graph lookups. This cross-contamination is runtime-breaking whenever both features are used in the same adapter instance, so Graph and Bot Framework tokens need separate cache fields.

ℹ️ 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".

Copy link
Copy Markdown
Collaborator Author

@codex review

Re-requesting on the latest state. Since your prior P1 (Discord-snowflake ambiguity) + P2 (AttributeError for legacy adapters), we've landed:

  • 82b5ca9 — gate Telegram + GitHub out of candidates when ID is in Discord snowflake range AND Discord is registered
  • b104202Chat.get_user probes getattr(adapter, "get_user", None) and translates the missing-method case to ChatError

Fresh focus: any remaining ambiguity surface in _infer_adapter_from_user_id, per-adapter get_user correctness under not-found shapes, and whether the new getattr guard could mask legitimate AttributeErrors thrown by an adapter implementation (vs. the "method missing" case).


Generated by Claude Code

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ 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".

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