You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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-97TeamsThreadId has no type; adapter.py:1858-1873 decode requires 3 parts; :1875-1878is_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 lastTeamsThreadId 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.
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.
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.
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
@wordto<at>word</at>, which mangles emails and URLs and never notified anyone (Teams needs mention entities); (2) carry an explicitconversationTypein thread IDs soa:-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 explicitapi_urlpinning every call.Upstream changes
7062c395fix(teams): preserve outgoing mention text (#898) — chat@4.40.0 —renderPostablereturns str/raw unchanged and AST text nodes pass through; incoming<at>decoding kept. Supersedesd4c52cad(#652, chat@4.33.0, shared bare-mention scanner): do not port that for Teams.257a32d0fix(teams): classify group chats by conversation type (#746) — chat@4.36.0 — optionalconversationTypeonTeamsThreadId; a 4th segment only when it disagrees with the19:heuristic; decode accepts 3 or 4 parts;isDM, DM Graph-context cache,openDM(personal) and Graph readers honour it.a8de95bcfix(teams): infer missing conversation types (#879) — chat@4.40.0 — explicitconversationType, elseisGroup === false→personal,isGroup === true→channelifchannelData.team.idelsegroupChat, else undefined.2e2426d1feat(teams): add installation lifecycle events (#914) — chat@4.41.0 —TeamsApp.apiFor/sendTohalf only: the thread's service URL via a per-URL connector client sharing the bot token; explicitapiUrlpins all calls; trailing slashes stripped.4cc3445cfix(teams,slack): follow-up hardening for html and url parsing (#779) — chat@4.37.0 — Teams part only:stripHtmlTagsloops<[^>]{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,:102and:112, and to AST text nodes at:133. Result:user@example.combecomesuser<at>example</at>.com.teams/types.py:84-97TeamsThreadIdhas no type;adapter.py:1858-1873decode requires 3 parts;:1875-1878is_dm=not id.startswith("19:")(same heuristic at:720,:2282); IDs are built untyped at:761,:991,:1039,:1142,:1884,:1893,:2137,:2160,:2384. Ana:group chat therefore fireson_direct_messageand gets a nativeIStreamer(1:1-only in Teams).adapter.py:1406-1437_point_app_api_atmutates the sharedself._app.apiURL before every call (:1492,:1537,:1609,:1641,:1687,:2214,:2245), so concurrent sends to different URLs race and a configuredapi_url/TEAMS_API_URL(:152-164) is overridden.<[^>]+>atteams/format.py:54(used at:133,:176-177) andteams/graph/__init__.py:428;format_converter.py:71-74loops but is unbounded.Scope
format_converter.py: delete_convert_mentions_to_teams;render_postablereturns str/raw unchanged and_node_to_teamsreturns text values verbatim. Keep inbound<at>decoding (:49).format.py: exportstrip_html_tags(text)(bounded pattern, loop until stable); use it inteams_mention_to_plain_text,teams_html_to_markdown,format_converter.to_ast,graph/__init__.py:428.format.pystays SDK-free (tests/test_teams_primitives_packaging.py).types.py:conversation_type: Literal["channel","groupChat","personal"] | None = Noneas the lastTeamsThreadIdfield.adapter.py:_conversation_type_from_activity+_thread_id_from_activityat every inbound builder above and inparse_message.encode_thread_idappends:{type}only when set and(type == "personal") != (not id.startswith("19:"));decode_thread_idaccepts 3 or 4 parts (unknown segment →ValidationError);is_dmprefers the explicit type;channel_id_from_thread_idkeeps it;open_dmencodespersonal._cache_user_contextwrites 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 forgroupChat;list_threadschild IDs getchannel, else inherit the parent type (personalfor DM context)._point_app_api_atwith_api_for(service_url)(self._app.apiwhen the URL is empty, equals the default, orapi_url/TEAMS_API_URLis set; else a cached per-normalized-URLApiClientsharing the app's token-bearing HTTP client) and_send_to(target, activity)(ConversationReferencewith the thread URL unless pinned,conversation_typeonly when known, viaself._app.activity_sender.send). Routepost_message,edit_message,delete_message,start_typing,post_channel_messagethrough them;_validate_service_urlruns first.Out of scope
bot_user_idformat: [4.41/T2] Teams installation lifecycle + bot join events #217. Reactions, targeted ephemerals, placeholder streaming (reuse_api_for): [4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219.is_mentiontri-state: [4.41/C2a] Core mentions & message model: tri-state is_mention, mention regex, Author.email/is_system, Message.reply_to #192. Shared bare-mention scanner (d4c52cad, used by Slack/Discord): [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193.teams/webhook/(unchanged upstream).Porting notes
a:group chats and19:personal chats gain a 4th segment.isGroup is False/is Trueexplicitly, never by truthiness. Activity dict keys stay camelCase (model_dump(by_alias=True)); snake_case only onTeamsThreadId.microsoft_teams.api.ApiClient(service_url, options=<http client>)shares the token;ActivitySender.sendalready builds a per-ref client fromref.service_url(SDK 2.0.13.4apps/activity_sender.py:39-80). Cache byurl.rstrip("/").await; removing that race is the point.Tests
Adapter tests are not fidelity-mapped (MAPPING covers only
packages/chat/src/*). Port frompackages/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 attests/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".post_messagecalls on different service URLs viaasyncio.gather(AsyncMocksender) 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 tostrip_html_tagscompletes with an asserted output.Acceptance criteria
docs/UPSTREAM_SYNC.mdupdated for any divergence or skip (e.g. client caching).<at>markup (plain@namedoes not notify);a:group-chat and19:personal-chat thread IDs gain a:groupChat/:personalsuffix and such group chats getis_dm == False(subscription/history keys change for those chats only);api_url/TEAMS_API_URLnow pins all outbound calls.Dependencies
Blocked by #192 (it edits the same
_handle_message_activitymention block). Blocks #217.Verify first
uv.lockpinsmicrosoft-teams-apps2.0.13.4; CI resolved 2.0.16 (test(teams): make the skip-auth fixture survive the SDK's flag rename #180). ConfirmApiClient(service_url, options=...),ApiClient.httpandActivitySender.send(activity, ref)behave the same on both.Metadata
a:group chats. Downstream consumers (e.g. chinchill) should run a Teams live loop before adopting.Part of #184.