Skip to content

Commit 0141936

Browse files
feat(teams): format primitive subpath (chat@4.31 8c71411) (#156)
* feat(teams): format primitive subpath (chat@4.31 8c71411) Port the NET-NEW upstream packages/adapter-teams/src/format/index.ts helpers as a low-level, runtime-free string-primitive module at src/chat_sdk/adapters/teams/format.py — distinct from the existing AST-based format_converter.py. Helpers: escape_teams_text / unescape_teams_text, format_teams_mention, teams_mention_to_plain_text, teams_html_to_markdown, markdown_to_teams_html, convert_teams_emoji_placeholders, safe_link_href. - SDK-free: plain strings + stdlib only (re, urllib.parse); no microsoft_teams.* and no adapter/runtime imports. Mirrors the Slack primitive subpath layout and lazy-import style. - markdown_to_teams_html gates link hrefs through an exact {http, https, mailto} protocol allowlist via urllib.parse.urlparse (port of the upstream URL().protocol check) — rejects javascript:, data:, relative, and other unsafe hrefs (SSRF / injection guard). - escape_teams_text runs before any tag insertion so a user-supplied '<' cannot forge HTML (emit/parse symmetry). - Emoji placeholders reuse chat_sdk.emoji (no duplicated emoji map): each upstream colon placeholder maps to a normalized SDK emoji name and delegates the unicode lookup to convert_emoji_placeholders. Tests (tests/test_teams_format_primitives.py): the 6 upstream index.test.ts cases + a source-level import-boundary test (faithful port of format/boundary.test.ts) + adversarial forged-tag and disallowed-protocol cases. 14 tests, all green. Lane-scoped: only the new module + its test file; no shared files touched (lazy subpath registration in teams/__init__.py deferred to the packaging PR). * test(teams): harden format boundary scan + tighten safe_link_href to upstream parity Boundary test: replace the substring scan over raw import lines with an AST-based one that reconstructs the fully-qualified module path each import statement reaches. The old scan missed the idiomatic split form `from chat_sdk.adapters.teams import adapter` (the dotted token `chat_sdk.adapters.teams.adapter` never appears on one side of ` import `), the most likely circular-import / SDK-pull-in regression. Both `import <forbidden>` and `from <forbidden> import ...` forms are now caught for the adapter module, microsoft_teams, and httpx/aiohttp. safe_link_href: upstream uses `new URL(href).protocol`, which throws (=> rejected => plain label) for malformed bare-scheme hrefs like `http:` or `https://`. `urlparse(...).scheme` is lenient and passed those (emitting a live link). Require a non-empty host for the http/https branch so bare-scheme http(s) hrefs are rejected to parity; mailto: (no netloc) stays allowed. Adds regression tests for both.
1 parent 5765b3d commit 0141936

2 files changed

Lines changed: 408 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Teams format primitives — a lightweight, runtime-free subpath.
2+
3+
Port of ``packages/adapter-teams/src/format/index.ts`` (vercel/chat@4.31,
4+
commit 8c71411), exposed upstream as ``@chat-adapter/teams/format``. Provides
5+
runtime-free primitives for escaping/unescaping Teams text, building and
6+
normalizing ``<at>`` mentions, and converting between Teams' restricted HTML
7+
subset and Markdown-ish text — without the full Teams adapter, the
8+
``microsoft_teams`` SDK, or the chat runtime.
9+
10+
This is intentionally distinct from :mod:`chat_sdk.adapters.teams.format_converter`,
11+
which is a higher-level AST-based converter. The helpers here are low-level
12+
string primitives that operate purely on text.
13+
14+
Importing this module never imports the ``microsoft_teams`` SDK, an HTTP
15+
client, or the high-level :mod:`chat_sdk.adapters.teams.adapter` module. Emoji
16+
placeholder conversion delegates to :mod:`chat_sdk.emoji` so the emoji map is
17+
never duplicated here.
18+
19+
Python-specific hardening (divergence from upstream, see
20+
``docs/UPSTREAM_SYNC.md``): :func:`markdown_to_teams_html` gates link hrefs
21+
through an exact ``{http, https, mailto}`` protocol allowlist using
22+
:func:`urllib.parse.urlparse` (port of the upstream ``URL().protocol`` check),
23+
rejecting ``javascript:``, ``data:``, relative, and other unsafe hrefs so they
24+
render as plain text rather than active links (SSRF / injection guard). Because
25+
``urlparse`` is more lenient than the upstream ``new URL(...)`` (which throws on
26+
a bare-scheme href like ``http:`` or ``https://``), the ``http``/``https``
27+
branch additionally requires a non-empty host so those malformed hrefs are
28+
rejected to parity.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import re
34+
from urllib.parse import urlparse
35+
36+
from chat_sdk.emoji import convert_emoji_placeholders
37+
38+
__all__ = [
39+
"convert_teams_emoji_placeholders",
40+
"escape_teams_text",
41+
"format_teams_mention",
42+
"markdown_to_teams_html",
43+
"safe_link_href",
44+
"teams_html_to_markdown",
45+
"teams_mention_to_plain_text",
46+
"unescape_teams_text",
47+
]
48+
49+
# JS source patterns ported 1:1. The `gis` flags become DOTALL | IGNORECASE in
50+
# Python; JS `g` (replace-all) is the default for `re.sub`/`str.replace`.
51+
_HTML_ESCAPE_PATTERN = re.compile(r"[&<>\"]")
52+
_MARKDOWN_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
53+
_TEAMS_MENTION_PATTERN = re.compile(r"<at\b[^>]*>(.*?)</at>", re.DOTALL | re.IGNORECASE)
54+
_TAG_PATTERN = re.compile(r"<[^>]+>")
55+
56+
# Order matters: `&` is escaped via the single-pass regex below so an already
57+
# present `&` is not double-escaped. Matches upstream `HTML_ESCAPES`.
58+
_HTML_ESCAPES: dict[str, str] = {
59+
'"': "&quot;",
60+
"&": "&amp;",
61+
"<": "&lt;",
62+
">": "&gt;",
63+
}
64+
65+
# Upstream `EMOJI_PLACEHOLDERS` maps Slack-style colon placeholders to unicode.
66+
# Rather than re-declare the unicode (which would duplicate the emoji map), we
67+
# map each upstream placeholder to its normalized name in :mod:`chat_sdk.emoji`
68+
# and delegate the unicode lookup to that single source of truth.
69+
_PLACEHOLDER_TO_NORMALIZED: dict[str, str] = {
70+
":red_circle:": "red_circle",
71+
":warning:": "warning",
72+
":white_check_mark:": "check",
73+
":x:": "x",
74+
}
75+
76+
# Exact protocol allowlist for Markdown link hrefs (port of upstream
77+
# `SAFE_LINK_PROTOCOLS`). `urlparse` lowercases the scheme and yields it
78+
# without the trailing colon, so these are bare scheme names.
79+
_SAFE_LINK_PROTOCOLS: frozenset[str] = frozenset({"http", "https", "mailto"})
80+
81+
82+
def escape_teams_text(text: str) -> str:
83+
"""Escape the Teams HTML control characters (``&``, ``<``, ``>``, ``"``).
84+
85+
Run this before inserting any HTML tags so user-supplied ``<`` cannot
86+
forge markup.
87+
"""
88+
return _HTML_ESCAPE_PATTERN.sub(lambda m: _HTML_ESCAPES.get(m.group(0), m.group(0)), text)
89+
90+
91+
def unescape_teams_text(text: str) -> str:
92+
"""Reverse :func:`escape_teams_text`.
93+
94+
Entities are replaced in reverse order (``&amp;`` last) so a literal
95+
``&amp;lt;`` does not collapse into ``<``.
96+
"""
97+
return text.replace("&quot;", '"').replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
98+
99+
100+
def format_teams_mention(name: str) -> str:
101+
"""Wrap a display name in an escaped Teams ``<at>`` mention tag."""
102+
return f"<at>{escape_teams_text(name)}</at>"
103+
104+
105+
def teams_mention_to_plain_text(text: str) -> str:
106+
"""Replace Teams ``<at>...</at>`` mention tags with ``@name`` plain text."""
107+
108+
def _replace(match: re.Match[str]) -> str:
109+
name = match.group(1)
110+
return f"@{unescape_teams_text(_strip_tags(name).strip())}"
111+
112+
return _TEAMS_MENTION_PATTERN.sub(_replace, text)
113+
114+
115+
def teams_html_to_markdown(html: str) -> str:
116+
"""Convert Teams' restricted HTML subset to Markdown-ish text."""
117+
text = teams_mention_to_plain_text(html)
118+
text = re.sub(r"<strong\b[^>]*>(.*?)</strong>", r"**\1**", text, flags=re.DOTALL | re.IGNORECASE)
119+
text = re.sub(r"<b\b[^>]*>(.*?)</b>", r"**\1**", text, flags=re.DOTALL | re.IGNORECASE)
120+
text = re.sub(r"<em\b[^>]*>(.*?)</em>", r"_\1_", text, flags=re.DOTALL | re.IGNORECASE)
121+
text = re.sub(r"<i\b[^>]*>(.*?)</i>", r"_\1_", text, flags=re.DOTALL | re.IGNORECASE)
122+
text = re.sub(r"<s\b[^>]*>(.*?)</s>", r"~~\1~~", text, flags=re.DOTALL | re.IGNORECASE)
123+
text = re.sub(r"<strike\b[^>]*>(.*?)</strike>", r"~~\1~~", text, flags=re.DOTALL | re.IGNORECASE)
124+
text = re.sub(r"<code\b[^>]*>(.*?)</code>", r"`\1`", text, flags=re.DOTALL | re.IGNORECASE)
125+
text = re.sub(
126+
r"<a\b[^>]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>",
127+
r"[\2](\1)",
128+
text,
129+
flags=re.DOTALL | re.IGNORECASE,
130+
)
131+
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
132+
text = re.sub(r"</p>\s*<p[^>]*>", "\n\n", text, flags=re.IGNORECASE)
133+
text = _TAG_PATTERN.sub("", text)
134+
text = text.replace(" ", " ")
135+
return unescape_teams_text(text).strip()
136+
137+
138+
def markdown_to_teams_html(markdown: str) -> str:
139+
"""Convert Markdown-ish text to Teams' restricted HTML subset.
140+
141+
The input is escaped *before* any tag insertion so user-supplied ``<``
142+
cannot forge HTML. Link hrefs are gated through :func:`safe_link_href`;
143+
unsafe hrefs render as plain label text.
144+
"""
145+
text = convert_teams_emoji_placeholders(escape_teams_text(markdown))
146+
text = re.sub(r"\*\*(.*?)\*\*", r"<strong>\1</strong>", text)
147+
text = re.sub(r"_(.*?)_", r"<em>\1</em>", text)
148+
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
149+
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
150+
151+
def _link(match: re.Match[str]) -> str:
152+
label, href = match.group(1), match.group(2)
153+
return f'<a href="{href}">{label}</a>' if safe_link_href(href) else label
154+
155+
text = _MARKDOWN_LINK_PATTERN.sub(_link, text)
156+
return text.replace("\n", "<br>")
157+
158+
159+
def convert_teams_emoji_placeholders(text: str) -> str:
160+
"""Convert Teams' Slack-style colon emoji placeholders to unicode.
161+
162+
Delegates the unicode lookup to :mod:`chat_sdk.emoji` so the emoji map is
163+
never duplicated. Each upstream placeholder (``:white_check_mark:`` etc.)
164+
is mapped to its normalized SDK emoji name and resolved to the platform
165+
(unicode) form via :func:`chat_sdk.emoji.convert_emoji_placeholders`.
166+
"""
167+
converted = text
168+
for placeholder, normalized in _PLACEHOLDER_TO_NORMALIZED.items():
169+
converted = converted.replace(
170+
placeholder,
171+
convert_emoji_placeholders(f"{{{{emoji:{normalized}}}}}", "teams"),
172+
)
173+
return converted
174+
175+
176+
def _strip_tags(text: str) -> str:
177+
return _TAG_PATTERN.sub("", text)
178+
179+
180+
def safe_link_href(href: str) -> bool:
181+
"""Return ``True`` only for ``http``/``https``/``mailto`` hrefs.
182+
183+
Port of the upstream ``safeLinkHref`` protocol check using
184+
:func:`urllib.parse.urlparse`. Rejects ``javascript:``, ``data:``,
185+
relative, and any other scheme (SSRF / injection guard).
186+
187+
Upstream parses the href with ``new URL(href)``, which *throws* for a
188+
malformed bare-scheme href like ``http:`` or ``https://`` (no authority),
189+
so such hrefs are rejected and render as plain label text.
190+
:func:`urllib.parse.urlparse` is lenient and would yield a matching scheme
191+
with an empty ``netloc`` for those, so the ``http``/``https`` branch
192+
additionally requires a non-empty host to match upstream. ``mailto:`` has
193+
no ``netloc`` by design and stays allowed.
194+
"""
195+
try:
196+
parsed = urlparse(href)
197+
except ValueError:
198+
return False
199+
if parsed.scheme not in _SAFE_LINK_PROTOCOLS:
200+
return False
201+
if parsed.scheme in ("http", "https"):
202+
return bool(parsed.netloc)
203+
return True

0 commit comments

Comments
 (0)