Skip to content

Commit 01d00d0

Browse files
feat(teams): cards primitive — input helpers (chat@4.31 8c71411)
Port the net-new Teams cards-primitive input surface from upstream packages/adapter-teams/src/cards-primitives/input.ts (NEW in chat@4.31.0, commit 8c71411): input_request_to_teams_adaptive_card, parse_teams_input_response, and the TEAMS_INPUT_ACTION_PREFIX / TEAMS_FREEFORM_ACTION_ID constants, plus the TeamsInput* TypedDicts. SDK-free (plain dicts; no microsoft_teams import). Snake_case input shapes, camelCase on the wire (actionId, isMultiSelect, isMultiline, freeform). Parse uses is-not-None / non-empty-string guards matching upstream's typeof reads. cards-primitives/index.ts (cardToAdaptiveCard / cardToTeamsFallbackText) is already covered SDK-free by adapters/teams/cards.py, so the card-emit it() blocks stay in test_teams_cards.py; this file ports the input-specific it() blocks plus the boundary import-isolation check and adversarial parse cases. No lane-forbidden files touched (teams/__init__.py, adapter.py, cards.py, CHANGELOG, UPSTREAM_SYNC all unchanged); divergence notes deferred to T7.
1 parent 19acaef commit 01d00d0

2 files changed

Lines changed: 487 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Generic input-request Adaptive Card helpers for the Teams cards primitive.
2+
3+
Port of ``packages/adapter-teams/src/cards-primitives/input.ts`` (NEW in
4+
chat@4.31.0, commit ``8c71411``), the net-new input surface that ships
5+
alongside the cards primitive (``cards-primitives/index.ts`` is already
6+
covered SDK-free by :mod:`chat_sdk.adapters.teams.cards`).
7+
8+
Builds Adaptive Card JSON for prompt/option input requests (buttons, radio,
9+
select, and freeform text) and parses the corresponding ``Action.Submit``
10+
payloads back into structured responses. Input shapes are TypedDicts with
11+
snake_case keys (``request_id``, ``allow_freeform``, ``option_id``,
12+
``action_id``) — upstream uses camelCase. The emitted Adaptive Card dicts and
13+
the inbound ``data`` keys keep Teams' on-the-wire camelCase field names
14+
(``actionId``, ``isMultiSelect``, ``isMultiline`` …) so the bytes match
15+
upstream exactly.
16+
17+
Importing this module never imports the ``microsoft_teams`` SDK, an HTTP
18+
client, or the high-level :mod:`chat_sdk.adapters.teams.adapter` module. Like
19+
upstream's ``input.ts``, the input helpers perform no escaping (they emit raw
20+
prompt / label / option strings into ``TextBlock`` / ``Input.ChoiceSet``
21+
fields); the :mod:`chat_sdk.adapters.teams.format` primitive is the place to
22+
reach for when a caller needs escaping, but the faithful port does not apply it
23+
here.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import Any, Literal, NotRequired, TypedDict
29+
30+
__all__ = [
31+
"TEAMS_FREEFORM_ACTION_ID",
32+
"TEAMS_INPUT_ACTION_PREFIX",
33+
"TeamsInputAction",
34+
"TeamsInputOption",
35+
"TeamsInputRequest",
36+
"TeamsInputResponse",
37+
"input_request_to_teams_adaptive_card",
38+
"parse_teams_input_response",
39+
]
40+
41+
# Upstream constants (cards-primitives/input.ts). The option ChoiceSet id and
42+
# every Action.Submit ``actionId`` are prefixed with this; the freeform
43+
# Input.Text element uses the fixed freeform id below. Both are exact — the
44+
# adapter's action router matches on them verbatim.
45+
TEAMS_INPUT_ACTION_PREFIX = "input:"
46+
TEAMS_FREEFORM_ACTION_ID = "input-freeform"
47+
48+
_ADAPTIVE_CARD_SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json"
49+
_ADAPTIVE_CARD_VERSION = "1.4"
50+
51+
52+
class TeamsInputOption(TypedDict):
53+
"""One selectable option in an input request."""
54+
55+
id: str
56+
label: str
57+
description: NotRequired[str]
58+
style: NotRequired[Literal["danger", "default", "primary"]]
59+
60+
61+
class TeamsInputRequest(TypedDict):
62+
"""A prompt with optional options rendered as an Adaptive Card."""
63+
64+
prompt: str
65+
request_id: str
66+
allow_freeform: NotRequired[bool]
67+
display: NotRequired[Literal["buttons", "radio", "select"]]
68+
options: NotRequired[list[TeamsInputOption]]
69+
70+
71+
class TeamsInputAction(TypedDict):
72+
"""An inbound ``Action.Submit`` payload to parse into a response."""
73+
74+
action_id: NotRequired[str]
75+
value: NotRequired[Any]
76+
77+
78+
class TeamsInputResponse(TypedDict):
79+
"""The parsed result of an input interaction."""
80+
81+
request_id: str
82+
option_id: NotRequired[str]
83+
value: NotRequired[str]
84+
85+
86+
def input_request_to_teams_adaptive_card(request: TeamsInputRequest) -> dict[str, Any]:
87+
"""Render an input request as a Teams Adaptive Card dict.
88+
89+
Port of ``inputRequestToTeamsAdaptiveCard``. ``select`` / ``radio`` render
90+
a single ``Input.ChoiceSet`` plus one ``Submit`` action; otherwise each
91+
option becomes its own ``Action.Submit`` button. ``allow_freeform`` adds an
92+
``Input.Text`` element and a dedicated "Submit answer" action.
93+
"""
94+
request_id = request["request_id"]
95+
input_action_id = f"{TEAMS_INPUT_ACTION_PREFIX}{request_id}"
96+
body: list[dict[str, Any]] = [
97+
{
98+
"text": request["prompt"],
99+
"type": "TextBlock",
100+
"wrap": True,
101+
}
102+
]
103+
actions: list[dict[str, Any]] = []
104+
options = request.get("options") or []
105+
106+
display = request.get("display")
107+
if display in ("select", "radio"):
108+
body.append(
109+
{
110+
"choices": [{"title": option["label"], "value": option["id"]} for option in options],
111+
"id": input_action_id,
112+
"isMultiSelect": False,
113+
"style": "expanded" if display == "radio" else "compact",
114+
"type": "Input.ChoiceSet",
115+
}
116+
)
117+
actions.append(
118+
{
119+
"data": {"actionId": input_action_id},
120+
"title": "Submit",
121+
"type": "Action.Submit",
122+
}
123+
)
124+
else:
125+
for option in options:
126+
actions.append(
127+
{
128+
"data": {
129+
"actionId": input_action_id,
130+
"value": option["id"],
131+
},
132+
"title": option["label"],
133+
"type": "Action.Submit",
134+
}
135+
)
136+
137+
if request.get("allow_freeform"):
138+
body.append(
139+
{
140+
"id": TEAMS_FREEFORM_ACTION_ID,
141+
"isMultiline": True,
142+
"placeholder": "Type your answer",
143+
"type": "Input.Text",
144+
}
145+
)
146+
actions.append(
147+
{
148+
"data": {
149+
"actionId": input_action_id,
150+
"freeform": True,
151+
},
152+
"title": "Submit answer",
153+
"type": "Action.Submit",
154+
}
155+
)
156+
157+
return {
158+
"$schema": _ADAPTIVE_CARD_SCHEMA,
159+
"actions": actions,
160+
"body": body,
161+
"type": "AdaptiveCard",
162+
"version": _ADAPTIVE_CARD_VERSION,
163+
}
164+
165+
166+
def parse_teams_input_response(action: TeamsInputAction) -> TeamsInputResponse | None:
167+
"""Parse an inbound ``Action.Submit`` payload, or ``None``.
168+
169+
Port of ``parseTeamsInputResponse``. Returns ``None`` unless ``action_id``
170+
starts with :data:`TEAMS_INPUT_ACTION_PREFIX`. A top-level string ``value``
171+
is treated as the chosen ``option_id`` (button submit); otherwise the
172+
option id is read from the request-scoped ChoiceSet key and the freeform
173+
answer from the freeform key. Only string-typed, non-empty values pass —
174+
matching upstream's ``typeof === "string"`` reads and truthiness guards.
175+
"""
176+
action_id = action.get("action_id")
177+
if action_id is None or not action_id.startswith(TEAMS_INPUT_ACTION_PREFIX):
178+
return None
179+
180+
request_id = action_id[len(TEAMS_INPUT_ACTION_PREFIX) :]
181+
input_action_id = f"{TEAMS_INPUT_ACTION_PREFIX}{request_id}"
182+
value = action.get("value")
183+
184+
if isinstance(value, str):
185+
option_id: str | None = value
186+
freeform_value: str | None = None
187+
else:
188+
option_id = _read_string_value(value, input_action_id)
189+
freeform_value = _read_string_value(value, TEAMS_FREEFORM_ACTION_ID)
190+
191+
response: TeamsInputResponse = {"request_id": request_id}
192+
if option_id:
193+
response["option_id"] = option_id
194+
response["value"] = option_id
195+
if freeform_value:
196+
response["value"] = freeform_value
197+
return response
198+
199+
200+
def _read_string_value(value: Any, key: str) -> str | None:
201+
"""Read ``value[key]`` only when it is a string in a dict-like ``value``."""
202+
if not (isinstance(value, dict) and key in value):
203+
return None
204+
field = value[key]
205+
return field if isinstance(field, str) else None

0 commit comments

Comments
 (0)