Skip to content

[4.41/T1] Teams routing & outbound text: stop <at> rewriting, conversationType in thread ids, per-service-URL clients #216

Description

@patrick-chinchill

Summary

Ports three Teams routing/outbound-text fixes from upstream 4.36–4.41, plus the Teams half of the bounded-regex hardening: (1) stop rewriting outgoing @word to <at>word</at>, which mangles emails and URLs and never notified anyone (Teams needs mention entities); (2) carry an explicit conversationType in thread IDs so a:-prefixed group chats stop routing as DMs; (3) send/edit/delete/type against the thread's encoded service URL via a per-URL client instead of mutating the shared SDK client, with an explicit api_url pinning every call.

Upstream changes

  • 7062c395 fix(teams): preserve outgoing mention text (#898) — chat@4.40.0 — renderPostable returns str/raw unchanged and AST text nodes pass through; incoming <at> decoding kept. Supersedes d4c52cad (#652, chat@4.33.0, shared bare-mention scanner): do not port that for Teams.
  • 257a32d0 fix(teams): classify group chats by conversation type (#746) — chat@4.36.0 — optional conversationType on TeamsThreadId; a 4th segment only when it disagrees with the 19: heuristic; decode accepts 3 or 4 parts; isDM, DM Graph-context cache, openDM (personal) and Graph readers honour it.
  • a8de95bc fix(teams): infer missing conversation types (#879) — chat@4.40.0 — explicit conversationType, else isGroup === false → personal, isGroup === true → channel if channelData.team.id else groupChat, else undefined.
  • 2e2426d1 feat(teams): add installation lifecycle events (#914) — chat@4.41.0 — TeamsApp.apiFor / sendTo half only: the thread's service URL via a per-URL connector client sharing the bot token; explicit apiUrl pins all calls; trailing slashes stripped.
  • 4cc3445c fix(teams,slack): follow-up hardening for html and url parsing (#779) — chat@4.37.0 — Teams part only: stripHtmlTags loops <[^>]{1,2048}> until stable; used by mention-to-text, HTML-to-markdown and Graph message text.

Current Python behavior

  • src/chat_sdk/adapters/teams/format_converter.py:120-122 _convert_mentions_to_teams = re.sub(r"@(\w+)", r"<at>\1</at>", text). It is applied at :99, :102 and :112, and to AST text nodes at :133. Result: user@example.com becomes user<at>example</at>.com.
  • Thread IDs: teams/types.py:84-97 TeamsThreadId has no type; adapter.py:1858-1873 decode requires 3 parts; :1875-1878 is_dm = not id.startswith("19:") (same heuristic at :720, :2282); IDs are built untyped at :761, :991, :1039, :1142, :1884, :1893, :2137, :2160, :2384. An a: group chat therefore fires on_direct_message and gets a native IStreamer (1:1-only in Teams).
  • Service URL: adapter.py:1406-1437 _point_app_api_at mutates the shared self._app.api URL before every call (:1492, :1537, :1609, :1641, :1687, :2214, :2245), so concurrent sends to different URLs race and a configured api_url / TEAMS_API_URL (:152-164) is overridden.
  • Tag stripping: unbounded single-pass <[^>]+> at teams/format.py:54 (used at :133, :176-177) and teams/graph/__init__.py:428; format_converter.py:71-74 loops but is unbounded.

Scope

  • format_converter.py: delete _convert_mentions_to_teams; render_postable returns str/raw unchanged and _node_to_teams returns text values verbatim. Keep inbound <at> decoding (:49).
  • format.py: export strip_html_tags(text) (bounded pattern, loop until stable); use it in teams_mention_to_plain_text, teams_html_to_markdown, format_converter.to_ast, graph/__init__.py:428. format.py stays SDK-free (tests/test_teams_primitives_packaging.py).
  • types.py: conversation_type: Literal["channel","groupChat","personal"] | None = None as the last TeamsThreadId field.
  • adapter.py: _conversation_type_from_activity + _thread_id_from_activity at every inbound builder above and in parse_message. encode_thread_id appends :{type} only when set and (type == "personal") != (not id.startswith("19:")); decode_thread_id accepts 3 or 4 parts (unknown segment → ValidationError); is_dm prefers the explicit type; channel_id_from_thread_id keeps it; open_dm encodes personal.
  • Graph context: _cache_user_context writes DM context only for personal chats (heuristic when type unknown); Graph readers (fetch_messages, fetch_channel_messages, fetch_channel_info, list_threads) skip stored DM context for groupChat; list_threads child IDs get channel, else inherit the parent type (personal for DM context).
  • Replace _point_app_api_at with _api_for(service_url) (self._app.api when the URL is empty, equals the default, or api_url / TEAMS_API_URL is set; else a cached per-normalized-URL ApiClient sharing the app's token-bearing HTTP client) and _send_to(target, activity) (ConversationReference with the thread URL unless pinned, conversation_type only when known, via self._app.activity_sender.send). Route post_message, edit_message, delete_message, start_typing, post_channel_message through them; _validate_service_url runs first.

Out of scope

Porting notes

  • Stability invariant. IDs, subscription and history keys stay byte-identical whenever the explicit type agrees with the heuristic; only a: group chats and 19: personal chats gain a 4th segment.
  • Check isGroup is False / is True explicitly, never by truthiness. Activity dict keys stay camelCase (model_dump(by_alias=True)); snake_case only on TeamsThreadId.
  • Client construction. microsoft_teams.api.ApiClient(service_url, options=<http client>) shares the token; ActivitySender.send already builds a per-ref client from ref.service_url (SDK 2.0.13.4 apps/activity_sender.py:39-80). Cache by url.rstrip("/").
  • Concurrency. Hold no shared mutable client state across await; removing that race is the point.

Tests

Adapter tests are not fidelity-mapped (MAPPING covers only packages/chat/src/*). Port from packages/adapter-teams/src/:

  • markdown.test.ts: "should preserve @mentions as text", "should not turn email addresses into mentions", "should not mangle an @handle inside a url", "should preserve @mentions in plain strings", "should preserve @mentions in raw messages", it.each "preserves names and formatting in %j", "preserves explicit mention markup in raw text", "decodes incoming full-name mentions without recreating markup". Rewrite the old <at> assertions at tests/test_teams_format.py:104,183,187.
  • index.test.ts › "Teams conversation type routing": "keeps the legacy ID when the conversation type agrees with its prefix", "falls back to isGroup when conversationType is missing", "prefers an explicit conversationType over isGroup"; › "thread ID decode errors": "should throw ValidationError for invalid thread IDs" (add an unknown-segment case).
  • graph-api.test.ts › "TeamsAdapter.fetchMessages Graph routing" (it.each: "resolves an opaque DM conversation through stored Graph context", "uses a group conversation ID without stored context"); › "listThreads": "preserves an explicit group-chat conversation type".
  • app.test.ts: "reuses the app client for the default service URL", "targets other service URLs with a dedicated client", "keeps every client on the configured endpoint", "sends through the configured endpoint instead of the thread URL", "sends through the SDK with the thread's service URL and conversation", "falls back to the default service URL when the thread has none", "rejects sends without credentials".
  • format/index.test.ts: "strips tags and leaves no complete tag on nested input".
  • Python-specific: two post_message calls on different service URLs via asyncio.gather (AsyncMock sender) each hit their own URL; rewrite (not duplicate) tests/test_teams_adapter.py::TestOutboundServiceUrlRouting (:1001), which asserts _point_app_api_at; a 40k-char <a<a… input to strip_html_tags completes with an asserted output.

Acceptance criteria

  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated for any divergence or skip (e.g. client caching).
  • CHANGELOG entry under "Unreleased (4.41 wave)".
  • Consumer-visible changes called out: outgoing Teams text has no <at> markup (plain @name does not notify); a: group-chat and 19: personal-chat thread IDs gain a :groupChat / :personal suffix and such group chats get is_dm == False (subscription/history keys change for those chats only); api_url / TEAMS_API_URL now pins all outbound calls.
  • Teams live loop (DM, group chat, channel): native DM streaming unchanged; group chats buffer.

Dependencies

Blocked by #192 (it edits the same _handle_message_activity mention block). Blocks #217.

Verify first

Metadata

  • Effort: L
  • Consumer impact: high. Every Teams post changes, and routing/streaming mode change for a: group chats. Downstream consumers (e.g. chinchill) should run a Teams live loop before adopting.
  • Suggested branch: sync/4.41-t1

Part of #184.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions