Skip to content

fix(adapters): Slack streaming team_id + Teams DM Graph conversation IDs (vercel/chat#330, #403)#85

Draft
patrick-chinchill wants to merge 5 commits intomainfrom
claude/port-slack-team-id-teams-dm-graph-J7S7H
Draft

fix(adapters): Slack streaming team_id + Teams DM Graph conversation IDs (vercel/chat#330, #403)#85
patrick-chinchill wants to merge 5 commits intomainfrom
claude/port-slack-team-id-teams-dm-graph-J7S7H

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

Summary

Two small upstream bug-fix ports bundled into one PR. Both touch adapter dispatch code and have new regression tests that fail before the fix.

fix(slack): interactive-payload team_id through streaming context — vercel/chat#330

Slack carries the workspace ID in different shapes depending on the webhook envelope:

  • Message events (message, app_mention): top-level team_id / team (string).
  • Interactive payloads (block_actions, view_submission, …): nested team.id (object), with user.team_id as a final fallback.

The old extraction (raw.get("team_id") or raw.get("team")) returned the entire team dict for block_actions, which then traveled to the Slack adapter as recipient_team_id and either crashed Slack streaming API calls or routed them to the wrong workspace.

Moved the extraction into a dedicated _extract_slack_recipient_team_id helper in src/chat_sdk/thread.py that walks each shape in order and returns None when no string ID is found.

fix(teams): canonical DM conversation ID for Microsoft Graph API — vercel/chat#403

Bot Framework hands out opaque DM conversation IDs (e.g. a:1xWhatever) which Graph's /chats/{chat-id}/messages endpoint rejects with 404. The canonical Graph chat ID for a 1:1 DM is 19:{userAadId}_{botId}@unq.gbl.spaces.

  • Cache the user's AAD object ID from incoming activities (from.aadObjectId) into a new TeamsDmContext keyed by base conversation ID.
  • Add discriminated union TeamsGraphContext = TeamsChannelContext | TeamsDmContext.
  • Rename _get_channel_context_get_graph_context and add _chat_id_from_context() dispatch helper.
  • Update fetch_messages, fetch_channel_messages, and fetch_channel_info to dispatch on context type.
  • Backwards-compatible: cached entries written before #403 lack a type discriminator and are treated as channel.

Tests

  • tests/test_thread_faithful.py:
    • Parametrized test_should_pass_stream_options_from_current_message_context over all four Slack payload shapes (team_id, team string, team.id object, user.team_id fallback).
    • New test_concurrent_block_actions_team_ids_do_not_cross_contaminate covering hazard chore: bump to 0.0.1a3 #6 (no team_id leak across concurrent requests).
    • New test_should_forward_structured_stream_chunks_to_adapter_stream_from_an_action_created_thread (port of upstream's #330 test; restores 0-missing on thread.test.ts fidelity).
  • tests/test_teams_coverage.py: new TestGraphDmConversationIdResolution class with 8 cases covering _chat_id_from_context (DM / channel / no-context branches), _cache_user_context (DM cached / channel skipped / no-aad skipped / DM-like channel adversarial), and end-to-end fetch_messages (DM resolves to 19:{aadId}_{botId}@unq.gbl.spaces, group chat falls back to raw ID).
  • Each new test docstring carries a "What to fix if this fails:" line.

Adversarial checks ran per docs/SELF_REVIEW.md: pass-interaction (concurrent team_ids) and the "DM-like channel" misclassification both have explicit tests.

Test plan

  • uv run ruff check src/ tests/ scripts/ — clean
  • uv run ruff format --check src/ tests/ scripts/ — clean
  • uv run python scripts/audit_test_quality.py — 0 hard failures (39 pre-existing warnings unchanged)
  • TS_ROOT=/tmp/vercel-chat uv run python scripts/verify_test_fidelity.pythread.test.ts now reports 0 missing
  • uv run pytest tests/ --tb=short -q3681 passed, 2 skipped, 1 failed
    • Only failure: pre-existing tests/test_github_webhook.py::TestGitHubAdapterConstructor::test_throws_when_no_auth (called out as ignorable in the task brief)

Upstream refs

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

claude added 3 commits May 8, 2026 02:52
vercel/chat#330)

Slack carries the workspace ID in different shapes depending on the
webhook envelope. Block_actions / view_submission payloads use a nested
``team.id`` (object) with ``user.team_id`` as a fallback, while message
events use the top-level ``team_id`` / ``team`` (string). The previous
``raw.get("team_id") or raw.get("team")`` extraction returned the entire
``team`` dict for block_actions, causing Slack streaming API calls to
fail or hit the wrong workspace.

Move the extraction into a dedicated helper that walks each shape in
order and returns ``None`` when no string ID is found.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…est (vercel/chat#330)

Upstream's chat@4.27.0 added a thread.test.ts case verifying that a
block_actions-created thread can stream structured chunks (text +
TaskUpdateChunk) through ``adapter.stream`` with the resolved
``recipient_team_id``. Port it to keep test_fidelity at 0 missing for
the thread.test.ts mapping.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…l/chat#403)

Bot Framework hands out opaque DM conversation IDs (e.g.
``a:1xWhatever``) which Microsoft Graph's
``/chats/{chat-id}/messages`` endpoint rejects with 404. The canonical
Graph chat ID for a 1:1 DM is ``19:{userAadId}_{botId}@unq.gbl.spaces``.

Cache the user's AAD object ID from incoming activities and resolve
the Graph chat ID before issuing Graph calls. Add a discriminated
union ``TeamsGraphContext`` (channel | DM) and dispatch on context
type from ``fetch_messages``, ``fetch_channel_messages``, and
``fetch_channel_info``. Group chats (no cached context) keep falling
back to the raw conversation ID, which works as-is with Graph.

Backwards-compatible with the pre-#403 cache shape: entries without a
``type`` discriminator are treated as ``channel``.

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

coderabbitai Bot commented May 8, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33af5465-6fe0-4eeb-8b57-f64c4154ee46

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/port-slack-team-id-teams-dm-graph-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.

Copy link
Copy Markdown

@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 canonical Microsoft Graph chat ID resolution for Teams DMs to prevent 404 errors and improves Slack workspace ID extraction from interactive payloads. The Teams adapter now caches and utilizes a broader Graph context for both channels and direct messages, ensuring the correct IDs are used for Graph API calls. Additionally, a new helper function in thread.py ensures correct Slack team ID parsing across different webhook shapes. Feedback was provided regarding duplicated pagination logic in the Teams adapter, suggesting a refactor to improve maintainability.

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
Comment on lines +1212 to +1225
chat_id = self._chat_id_from_context(graph_context, base_conversation_id)
if direction == "forward":
params = {"$top": limit, "$orderby": "createdDateTime asc"}
if options.cursor:
params["$filter"] = f"createdDateTime gt {options.cursor}"
graph_messages = await self._graph_list_chat_messages(chat_id, params)
has_more = len(graph_messages) >= limit
else:
params = {"$top": limit, "$orderby": "createdDateTime desc"}
if options.cursor:
params["$filter"] = f"createdDateTime lt {options.cursor}"
graph_messages = await self._graph_list_chat_messages(chat_id, params)
graph_messages.reverse()
has_more = len(graph_messages) >= limit
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The logic for handling forward and backward pagination is duplicated here and in fetch_messages (lines 1092-1110). To improve maintainability and reduce code duplication, you could refactor this block and apply a similar change in fetch_messages.

Here's a suggested refactoring for this block:

Suggested change
chat_id = self._chat_id_from_context(graph_context, base_conversation_id)
if direction == "forward":
params = {"$top": limit, "$orderby": "createdDateTime asc"}
if options.cursor:
params["$filter"] = f"createdDateTime gt {options.cursor}"
graph_messages = await self._graph_list_chat_messages(chat_id, params)
has_more = len(graph_messages) >= limit
else:
params = {"$top": limit, "$orderby": "createdDateTime desc"}
if options.cursor:
params["$filter"] = f"createdDateTime lt {options.cursor}"
graph_messages = await self._graph_list_chat_messages(chat_id, params)
graph_messages.reverse()
has_more = len(graph_messages) >= limit
chat_id = self._chat_id_from_context(graph_context, base_conversation_id)
order_by = "createdDateTime asc"
filter_op = "gt"
if direction != "forward":
order_by = "createdDateTime desc"
filter_op = "lt"
params = {"$top": limit, "$orderby": order_by}
if options.cursor:
params["$filter"] = f"createdDateTime {filter_op} {options.cursor}"
graph_messages = await self._graph_list_chat_messages(chat_id, params)
has_more = len(graph_messages) >= limit
if direction != "forward":
graph_messages.reverse()

Address gemini-code-assist review on PR #85 (line 1225). The forward/
backward pagination block was duplicated between fetch_messages and
fetch_channel_messages; extract a single helper that takes
(chat_id, limit, direction, cursor) and returns (messages, has_more).

Both call sites now share one code path. Same behavior — reverses on
backward, ``gt``/``lt`` filter ops, ``$top`` page size — covered by the
existing TestGraphDmConversationIdResolution + fetch_messages /
fetch_channel_messages tests (13 pass).

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.

Code review — vercel/chat#330 + #403 ports

Both ports are tight 1:1 transliterations of upstream 8a0c7b3 / 4c24c94. Slack helper walk order matches upstream exactly (team_idteam:strteam.id:objectuser.team_id); Teams discriminated-union dispatch matches the TS version including the legacy-cache fallthrough (type absent → treated as channel). The follow-up _paginate_graph_chat_messages extraction is behavior-preserving (forward = gt + asc + no reverse, backward = lt + desc + reverse, has_more = len >= limit for both empty and exact-page edges). The concurrency test genuinely interleaves with asyncio.gather + asyncio.sleep(0) — Hazard #6 is exercised, not just claimed.

🟡 Medium

  1. Adversarial gap in _extract_slack_recipient_team_id test sweep (src/chat_sdk/thread.py L144‑183, tests/test_thread_faithful.py L797‑818). Per docs/SELF_REVIEW.md principle #1 (adversarial input sweep), the parametrized test only covers the four happy paths. The helper has explicit guards for shapes that aren't exercised:

    • raw.team is a dict but id is missing or non-string (e.g. {"team": {"domain": "x"}} → must fall through to user.team_id)
    • team_id / team is an empty string "" (the truthiness guard and team_id is the only thing keeping that from being returned — Hazard #1)
    • raw is None / not a dict at all
    • user.team_id is non-string

    Each is a one-line pytest.mark.parametrize extension; without them the next refactor of this helper has no signal it broke a fallback.

🔵 Nit

  1. aadObjectId is interpolated into a Graph URL path with no shape validation (src/chat_sdk/adapters/teams/adapter.py L330). The cached graph_chat_id becomes chat_idhttps://graph.microsoft.com/v1.0/chats/{chat_id}/messages. Bot Framework JWT verification authenticates the activity envelope but does not constrain the contents of from.aadObjectId. Real Azure AD object IDs are GUIDs, but a malicious / non-conforming activity could inject /, ?, #, :. The host is hardcoded so this can't be SSRF, and the bearer token is Graph-scoped so worst case is a 404 against a different Graph endpoint — but a one-line re.fullmatch(r"[0-9a-fA-F-]{36}", aad_object_id) guard before formatting would make this robust and matches the same defense-in-depth pattern as _validate_service_url two functions up.

  2. Backwards-compat claim has no end-to-end regression test for legacy cache shape (PR body & _chat_id_from_context docstring claim "cached entries written before #403 are treated as channel"). TestGetGraphContext.test_valid_context_from_cache confirms the legacy {"team_id": "t1", "channel_id": "c1"} shape loads, but no test asserts it routes through fetch_messages to the channel branch (analogous to the new test_fetch_messages_uses_graph_chat_id_for_dm). One added test covering legacy cache → fetch_channel_thread_messages would lock the claim.

  3. Channel-context cache writes a "type": "channel" field that upstream omits (src/chat_sdk/adapters/teams/adapter.py L316). Upstream's TS only sets type on the DM context; channel entries are written without the discriminator. Functionally equivalent (both code paths treat type != "dm" as channel), but it's a wire-shape divergence that future cross-language deployments could trip on. Either match upstream and drop the key, or add a row to the non-parity table.

✅ Looks good

  • _extract_slack_recipient_team_id walk order is identical to upstream and the parametrized + structured-stream tests faithfully port the upstream it.each block.
  • Concurrent block_actions test is a real concurrency test (asyncio.gather + await asyncio.sleep(0) interleave point inside mock_stream), not the typical sequential-disguised-as-concurrent.
  • _paginate_graph_chat_messages refactor is semantically identical to the duplicated blocks it replaces; both edge cases (len == 0False, len == limitTrue) match upstream and are covered transitively by the existing fetch tests.
  • The is_dm-prefix guard for DM caching (not base_channel_id.startswith("19:")) is tested both positively (DM cached) and adversarially (channel-with-aadObjectId not mis-cached as DM).
  • All 14 new tests pass locally.

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


Generated by Claude Code

Three review-driven fixes:

1. Adversarial input sweep on _extract_slack_recipient_team_id (Medium):
   extend the parametrized test from 4 happy paths to 11 cases, covering
   the helper's fallthrough guards (team dict missing id, empty
   team_id/team strings, non-dict raw, non-string user.team_id, team.id
   non-string). Per docs/SELF_REVIEW.md principle #1.

2. Defense-in-depth GUID guard on aadObjectId before formatting it into
   the Graph chat ID (Nit). Bot Framework JWT verification authenticates
   the activity envelope but does not constrain from.aadObjectId; a
   malformed value containing /, ?, # could otherwise inject into the
   Graph URL path. New _AAD_OBJECT_ID_PATTERN reject malformed shapes;
   adversarial test covers 6 attack patterns.

3. End-to-end legacy-cache-shape test for #403 backwards-compat (Nit).
   Cached entries written before the discriminator landed lack a "type"
   key. The test asserts _get_graph_context loads them and
   _chat_id_from_context returns the raw conversation ID (channel
   semantics), not a misclassified Graph DM URL.

4. Wire-shape parity (Nit): the channel-context cache now omits the
   "type": "channel" key to match upstream TS, which only sets the
   discriminator on the DM branch. Functionally equivalent (absent type
   is treated as channel by the dispatcher), but the cross-language wire
   shape is now identical.

All TestGraphDmConversationIdResolution tests pass (10 cases).

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 of fixes (round 2)

Walked the four claimed fixes against f55378a (chat@4.27.0) upstream commits 8a0c7b3 (#330) and 4c24c94 (#403), then hunted for residual gaps. 299 tests pass in test_teams_coverage.py + test_thread_faithful.py + test_coverage_gaps.py.

Verified fixes

  1. Adversarial parametrize (test_should_pass_stream_options_from_current_message_context) — 11 cases now: 4 happy paths + 4 fall-through cascades + 3 None-returning rejections (non-dict, empty dict, non-string team.id). Order matches the helper's team_idteam (str) → team.id (obj) → user.team_id cascade. Solid.
  2. AAD GUID guard (_AAD_OBJECT_ID_PATTERN.fullmatch at adapter.py:348) — fullmatch confirmed to reject path-injection (.../messages), missing hyphens, non-hex chars, short strings. Test sweeps 6 attack patterns including ../etc/passwd. Defense-in-depth is real.
  3. Legacy cache E2E (test_fetch_messages_uses_legacy_cache_shape_for_channel) — asserts that pre-#403 entries (no type key) load via _get_graph_context and _chat_id_from_context returns the raw conversation ID rather than a misclassified DM URL. Good backwards-compat coverage.
  4. Wire-shape parity — channel cache write at adapter.py:322 omits "type": "channel", exactly matching upstream TS index.ts:220. Cross-language wire shape is now identical.

Hunt for residual upstream parity gaps

  • Race conditions / ContextVar: recipient_team_id flows through per-thread self._current_message, no module-global caches. Concurrency test (test_concurrent_block_actions_team_ids_do_not_cross_contaminate) verifies two interleaved block_actions posts don't cross-pollute. Clean.
  • StreamOptions wire-shape: walked upstream chat/src/types.ts — only recipientUserId + recipientTeamId are derived from currentMessage. No new fields in #330. No gap.
  • Other Graph endpoints consuming chat_id: TS upstream applies chatIdFromContext in 3 sites — fetchMessages, fetchChannelMessages, AND listThreads. Python ports the first two correctly. list_threads is not implemented in the Python Teams adapter at all (pre-existing gap on main, not introduced here, but worth tracking — when ported, must thread _chat_id_from_context through). fetch_channel_info is correctly DM-aware (channel branch only fires when type != "dm").
  • graph-api.test.ts chatIdFromContext describe block: all 3 upstream it() cases (DM context → graphChatId, no context → raw ID, channel context → raw ID) are mirrored as test_chat_id_from_context_* in TestGraphDmConversationIdResolution. Full fidelity.
  • TS DM-context simplification commit 2 ("DMs never have threadMessageId so the channel guard doesn't need an explicit DM exclusion"): Python's if graph_context and context_type != "dm" and thread_message_id: keeps the explicit DM exclusion. Functionally equivalent (more defensive than upstream); no parity bug.

Nits (non-blocking)

  • TeamsChannelContext(TypedDict, total=False) makes team_id and channel_id optional at the type level. Upstream TS only marks type optional. Consider class TeamsChannelContext(TypedDict): team_id: str; channel_id: str; type: NotRequired[str] for stricter typing. Runtime is unaffected.
  • self._app_id is interpolated into the Graph chat ID without GUID validation. It's config-sourced (TEAMS_APP_ID env), so not user input — but a misconfigured /-containing value would silently produce a malformed chat ID. Optional symmetric guard.

Re-review verdict: PASS

All 4 review-driven fixes land cleanly. Wire-shape, behavior, and test fidelity match upstream. The two nits above are advisory-only.

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