Skip to content

Commit 1da7f9b

Browse files
feat(teams): webhook primitive subpath (chat@4.31 8c71411)
Port the NET-NEW Teams webhook primitives subpackage from packages/adapter-teams/src/webhook (chat@4.31.0, commit 8c71411), mirroring the slack/webhook layout. Parse-only Bot Framework activity classification — no JWT verification, no outbound HTTP. Classifies an inbound activity into typed, discriminated payloads (message / card_action / dialog_open / dialog_submit / message_reaction / conversation_update / installation_update / unsupported), plus continuation / user / attachment extraction and is_teams_mention bot-mention detection. SDK-free: plain dicts + stdlib only, no microsoft_teams imports. Typed results use snake_case dataclasses while the camelCase Bot Framework wire keys survive verbatim on each payload's raw passthrough. Truthiness fallbacks use `is not None` / membership checks; invalid JSON and non-object bodies raise TeamsWebhookParseError. Strict lane scoping: only the new webhook/ subpackage and its test file are added — the Teams package __init__ is left untouched (PEP-562 lazy subpath registration is deferred to the packaging PR). Tests: 45 cases porting the 12 index.test.ts `it` cases plus the source boundary case (13 upstream), with adversarial/divergence coverage for the camelCase raw passthrough, `replyToId ?? id` fallback, mention `:`-suffix matching, and explicit-null content handling.
1 parent 5765b3d commit 1da7f9b

5 files changed

Lines changed: 1121 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Teams webhook primitives — a lightweight, runtime-free subpath.
2+
3+
Port of ``packages/adapter-teams/src/webhook`` (chat@4.31, commit 8c71411),
4+
exposed upstream as ``@chat-adapter/teams/webhook``. Provides primitives for
5+
classifying an inbound Bot Framework activity into typed, discriminated
6+
payloads, plus continuation / user / attachment extraction and bot-mention
7+
detection — without the full Teams adapter, the ``microsoft_teams`` SDK,
8+
chat state, dedupe, locks, or subscriptions.
9+
10+
Parse-ONLY: these primitives never verify JWTs and never make outbound HTTP
11+
requests. Importing this module never imports ``microsoft_teams``, HTTP
12+
clients, or the high-level :mod:`chat_sdk.adapters.teams.adapter`.
13+
14+
Wire boundary: typed results are ``snake_case`` dataclasses; the original
15+
camelCase Bot Framework activity is preserved verbatim on each payload's
16+
``raw`` field (``channelData.teamsChannelId``, ``replyToId``, ``aadObjectId``,
17+
``tenantId``, ...).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import inspect
23+
from typing import Any
24+
25+
from chat_sdk.adapters.teams.webhook.continuation import (
26+
extract_teams_attachments,
27+
extract_teams_continuation,
28+
extract_teams_user,
29+
is_teams_mention,
30+
)
31+
from chat_sdk.adapters.teams.webhook.parse import parse_teams_webhook_body
32+
from chat_sdk.adapters.teams.webhook.types import (
33+
TeamsActivity,
34+
TeamsCardActionPayload,
35+
TeamsContinuation,
36+
TeamsConversationUpdatePayload,
37+
TeamsDialogOpenPayload,
38+
TeamsDialogSubmitPayload,
39+
TeamsInstallationUpdatePayload,
40+
TeamsMessagePayload,
41+
TeamsMessageReactionPayload,
42+
TeamsParseOptions,
43+
TeamsUnsupportedPayload,
44+
TeamsWebhookAttachment,
45+
TeamsWebhookError,
46+
TeamsWebhookParseError,
47+
TeamsWebhookPayload,
48+
TeamsWebhookUser,
49+
)
50+
51+
52+
async def read_teams_request_body(request: Any) -> str:
53+
"""Read the raw body from a duck-typed request object.
54+
55+
Python stand-in for the Fetch API's ``await request.text()`` used by the
56+
upstream ``readTeamsWebhook`` helper. Supports:
57+
58+
- ``request.text`` as an (async or sync) method or plain attribute
59+
- ``request.body`` as an (async or sync) method, awaitable, bytes, or str
60+
- falling back to ``str(request)``
61+
62+
Bytes are decoded as UTF-8.
63+
"""
64+
text_attr = getattr(request, "text", None)
65+
if text_attr is not None:
66+
if callable(text_attr):
67+
result = text_attr()
68+
text_attr = await result if inspect.isawaitable(result) else result
69+
return text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr)
70+
raw = getattr(request, "body", None)
71+
if raw is not None:
72+
if callable(raw):
73+
raw = raw()
74+
if inspect.isawaitable(raw):
75+
raw = await raw
76+
return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
77+
return str(request)
78+
79+
80+
async def read_teams_webhook(
81+
request: Any,
82+
options: TeamsParseOptions | None = None,
83+
) -> TeamsWebhookPayload:
84+
"""Read a request body and classify it — no JWT verification.
85+
86+
Port of upstream ``readTeamsWebhook``: reads ``await request.text()`` then
87+
delegates to :func:`parse_teams_webhook_body`.
88+
"""
89+
body = await read_teams_request_body(request)
90+
return parse_teams_webhook_body(body, options)
91+
92+
93+
__all__ = [
94+
"TeamsActivity",
95+
"TeamsCardActionPayload",
96+
"TeamsContinuation",
97+
"TeamsConversationUpdatePayload",
98+
"TeamsDialogOpenPayload",
99+
"TeamsDialogSubmitPayload",
100+
"TeamsInstallationUpdatePayload",
101+
"TeamsMessagePayload",
102+
"TeamsMessageReactionPayload",
103+
"TeamsParseOptions",
104+
"TeamsUnsupportedPayload",
105+
"TeamsWebhookAttachment",
106+
"TeamsWebhookError",
107+
"TeamsWebhookParseError",
108+
"TeamsWebhookPayload",
109+
"TeamsWebhookUser",
110+
"extract_teams_attachments",
111+
"extract_teams_continuation",
112+
"extract_teams_user",
113+
"is_teams_mention",
114+
"parse_teams_webhook_body",
115+
"read_teams_request_body",
116+
"read_teams_webhook",
117+
]
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Continuation / extraction helpers for the Teams webhook primitives.
2+
3+
Port of ``packages/adapter-teams/src/webhook/continuation.ts`` (chat@4.31,
4+
commit 8c71411). Pulls provider-native reply data, the acting user, message
5+
attachments, and bot-mention state out of a raw Bot Framework activity.
6+
7+
These helpers are SDK-free: they operate on plain dicts (the camelCase wire
8+
shape) using membership / ``is not None`` checks — never truthiness ``or``
9+
fallbacks that would coerce ``""``/``0``/``False`` into the wrong branch.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any
15+
16+
from chat_sdk.adapters.teams.webhook.types import (
17+
TeamsActivity,
18+
TeamsContinuation,
19+
TeamsWebhookAttachment,
20+
TeamsWebhookUser,
21+
)
22+
23+
24+
def _record(value: Any) -> dict[str, Any] | None:
25+
"""Return ``value`` when it is a dict, else ``None`` (optional chaining)."""
26+
return value if isinstance(value, dict) else None
27+
28+
29+
def _opt_str(value: Any) -> str | None:
30+
"""Return ``value`` only when it is a non-empty string, else ``None``.
31+
32+
Mirrors the upstream ``activity.foo ? { foo } : {}`` spread guards, which
33+
drop empty strings as well as ``undefined`` (an empty string is falsy in
34+
JS). Keeping the same shape avoids emitting blank continuation fields.
35+
"""
36+
return value if isinstance(value, str) and value else None
37+
38+
39+
def extract_teams_continuation(activity: TeamsActivity) -> TeamsContinuation:
40+
"""Build a :class:`TeamsContinuation` from a raw activity.
41+
42+
``conversation_id`` / ``service_url`` default to ``""`` (upstream ``?? ""``)
43+
so the continuation always carries the two fields a reply needs; the rest
44+
are populated only when present, applying the same channelData fallbacks
45+
as upstream (``channel.id`` then ``teamsChannelId``; ``team.id`` then
46+
``teamsTeamId``; ``conversation.tenantId`` then ``tenant.id``).
47+
"""
48+
channel_data = _record(activity.get("channelData")) or {}
49+
conversation = _record(activity.get("conversation")) or {}
50+
channel = _record(channel_data.get("channel")) or {}
51+
team = _record(channel_data.get("team")) or {}
52+
tenant = _record(channel_data.get("tenant")) or {}
53+
54+
channel_id = _opt_str(channel.get("id"))
55+
if channel_id is None:
56+
channel_id = _opt_str(channel_data.get("teamsChannelId"))
57+
58+
team_id = _opt_str(team.get("id"))
59+
if team_id is None:
60+
team_id = _opt_str(channel_data.get("teamsTeamId"))
61+
62+
tenant_id = _opt_str(conversation.get("tenantId"))
63+
if tenant_id is None:
64+
tenant_id = _opt_str(tenant.get("id"))
65+
66+
conversation_id = conversation.get("id")
67+
service_url = activity.get("serviceUrl")
68+
69+
return TeamsContinuation(
70+
activity_id=_opt_str(activity.get("id")),
71+
channel_id=channel_id,
72+
conversation_id=conversation_id if isinstance(conversation_id, str) else "",
73+
reply_to_id=_opt_str(activity.get("replyToId")),
74+
service_url=service_url if isinstance(service_url, str) else "",
75+
team_id=team_id,
76+
tenant_id=tenant_id,
77+
)
78+
79+
80+
def extract_teams_user(activity: TeamsActivity) -> TeamsWebhookUser | None:
81+
"""Return the acting user, or ``None`` when ``from.id`` is missing.
82+
83+
Upstream short-circuits on ``!activity.from?.id`` — a present user object
84+
with no ``id`` yields ``None``. ``aadObjectId`` / ``name`` are included
85+
only when truthy on the wire.
86+
"""
87+
sender = _record(activity.get("from"))
88+
if sender is None:
89+
return None
90+
user_id = sender.get("id")
91+
if not (isinstance(user_id, str) and user_id):
92+
return None
93+
return TeamsWebhookUser(
94+
aad_object_id=_opt_str(sender.get("aadObjectId")),
95+
id=user_id,
96+
name=_opt_str(sender.get("name")),
97+
)
98+
99+
100+
def extract_teams_attachments(activity: TeamsActivity) -> list[TeamsWebhookAttachment]:
101+
"""Normalize ``activity.attachments``, skipping non-object entries.
102+
103+
Non-list ``attachments`` yields ``[]``; ``None`` / non-dict entries are
104+
dropped. The typed ``content`` is the entry's ``content`` value (``None``
105+
when absent); the key-present-vs-absent distinction (upstream's
106+
``typeof attachment.content === "undefined" ? {} : { content }``) is
107+
preserved on ``raw``. ``content_type`` / ``content_url`` / ``name`` are
108+
copied only when they are strings.
109+
"""
110+
raw_attachments = activity.get("attachments")
111+
if not isinstance(raw_attachments, list):
112+
return []
113+
result: list[TeamsWebhookAttachment] = []
114+
for attachment in raw_attachments:
115+
record = _record(attachment)
116+
if record is None:
117+
continue
118+
content_type = record.get("contentType")
119+
content_url = record.get("contentUrl")
120+
name = record.get("name")
121+
result.append(
122+
TeamsWebhookAttachment(
123+
# The typed ``content`` is ``None`` for both a missing key and an
124+
# explicit ``null`` (upstream copies the value only when present,
125+
# but the absent-value is itself ``undefined``/``None``). The
126+
# key-present-vs-absent distinction is preserved in ``raw``.
127+
content=record.get("content"),
128+
content_type=content_type if isinstance(content_type, str) else None,
129+
content_url=content_url if isinstance(content_url, str) else None,
130+
name=name if isinstance(name, str) else None,
131+
raw=record,
132+
)
133+
)
134+
return result
135+
136+
137+
def is_teams_mention(activity: TeamsActivity, bot_app_id: str | None = None) -> bool:
138+
"""Return ``True`` when the activity @-mentions the bot.
139+
140+
Without a ``bot_app_id`` this is always ``False``. A match requires a
141+
``mention`` entity whose ``mentioned.id`` either equals ``bot_app_id`` or
142+
ends with the exact ``":" + bot_app_id`` suffix (Bot Framework prefixes
143+
user ids like ``28:<app-id>``).
144+
"""
145+
if not bot_app_id:
146+
return False
147+
entities = activity.get("entities")
148+
if not isinstance(entities, list):
149+
return False
150+
suffix = f":{bot_app_id}"
151+
for entity in entities:
152+
record = _record(entity)
153+
if record is None or record.get("type") != "mention":
154+
continue
155+
mentioned = _record(record.get("mentioned"))
156+
if mentioned is None:
157+
continue
158+
mentioned_id = mentioned.get("id")
159+
if not isinstance(mentioned_id, str):
160+
continue
161+
if mentioned_id == bot_app_id or mentioned_id.endswith(suffix):
162+
return True
163+
return False

0 commit comments

Comments
 (0)