Python: feat(python): cross-channel hosting improvements (endpoint paths, Activity push, Telegram/Teams fixes) (#6307)

* Update hosting channel endpoint paths

Treat channel paths as concrete endpoint paths so built-in channels can be mounted at their defaults or at the app root without sample-specific subclasses. Update docs, tests, and the Foundry Telegram Invocations sample accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add push support to ActivityProtocolChannel

Implement the ChannelPush protocol so the Activity Protocol channel can
receive cross-channel fan-out (ResponseTarget.all_linked) and echo_input
replay as a non-originating destination:

- Add push() that reconstructs a proactive Bot Framework activity (bot/user
  swap) from the stored conversation reference and POSTs it to
  /v3/conversations/{id}/activities.
- Record a ChannelIdentity (service_url, conversation, bot, user, channel_id,
  locale) on ChannelRequest.identity so the host registers the channel under
  its isolation key for fan-out resolution.
- Route the streaming path through deliver_response so Activity-originated
  turns broadcast like Telegram/Discord.
- Add tests for push delivery, service_url validation, ChannelPush instance
  check, and inbound identity recording.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Don't delete Telegram webhook on shutdown by default

The TelegramChannel deleted its webhook on shutdown in webhook mode. During
a rolling redeploy the new revision registers the webhook on startup, then
the old revision's shutdown deletes it, silently breaking inbound delivery
until the next boot. setWebhook is overwriting/idempotent, so startup
re-asserts the webhook every boot and no teardown is needed.

Add a delete_webhook_on_shutdown flag (default False) so teardown is opt-in
for ephemeral deployments, and leave the webhook in place otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Activity channel streaming on non-Teams channels (405 on updateActivity)

The Activity Protocol channel streamed replies the Teams way: POST a
placeholder, then PUT-edit it as tokens arrive. Only Teams supports the
updateActivity REST op; Web Chat, Direct Line and the Emulator return
405 Method Not Allowed on the PUT, so the user saw only the placeholder.

Gate the placeholder+edit flow on edit-capable channels (msteams). Other
channels now buffer the stream and POST a single final message, mirroring
the non-streaming path's fan-out and response-hook semantics. Also add a
defensive 405 fallback inside the Teams edit loop so an unexpected 405
can never strand the user on the placeholder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): don't parse Teams inline attachment content as a URI

Teams message activities include a text/html attachment whose inline
`content` is raw HTML (not a URL). _parse_activity fell back to
`attachment["content"]` and passed it to Content.from_uri, raising
ContentError ("URI must contain a scheme") and failing the whole turn,
so Teams users got no response.

Only treat `contentUrl` as a URI, require an absolute scheme, and skip
unparseable attachments defensively instead of failing the message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting-activity-protocol): native slash-command dispatch for Teams/Activity

Add a commands= parameter to ActivityProtocolChannel that intercepts a
leading /command (after stripping the bot's own @mention) and dispatches
to ChannelCommand handlers, mirroring the Telegram channel. Unknown
commands fall through to the agent. The channel run_hook is applied to
command requests so handlers observe the same resolved isolation key as
ordinary messages, and handler errors are swallowed (200, no Bot Service
retry of non-idempotent commands).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): silent attributed Telegram echoes + Teams markdown rendering

- hosting-telegram: send cross-channel input echoes with disable_notification
  (silent) and detect echo payloads so they aren't re-broadcast.
- hosting-activity-protocol: render outbound + push activities as textFormat
  'markdown' so Teams shows formatted replies (enables per-channel variants).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): address PR #6307 review feedback

Consult the host delivery pipeline even for empty streamed replies so
ResponseTarget.none is honoured and non-originating fan-out is consulted
instead of always emitting an originating "(no response)" message. Applies
to both the progressive-edit (Teams) and buffered (Web Chat/Direct Line)
streaming paths.

Re-validate service_url against the allow-list in push(): the identity is
read from a persisted store and push runs out-of-band, so the captured
service_url must be re-checked before a bearer token is sent.

Adds tests for empty-stream host consultation/suppression on both streaming
paths and for push rejecting a disallowed service_url.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-06-03 16:37:03 +02:00
committed by GitHub
co-authored by Copilot
parent e8c22caaeb
commit e5a6e35843
33 changed files with 1449 additions and 133 deletions
@@ -92,7 +92,7 @@ class DiscordChannel:
public_key: str,
bot_token: str | None = None,
guild_id: str | None = None,
path: str = "/discord",
path: str = "/discord/interactions",
agent_command: str = "ask",
agent_command_description: str = "Ask the agent",
agent_command_option: str = "prompt",
@@ -120,8 +120,8 @@ class DiscordChannel:
guild_id: Optional guild id for guild-scoped slash command
registration. Recommended for development because global
command registration can take a long time to propagate.
path: Host mount path. The interaction route is contributed as
``/interactions`` below this path.
path: Interaction endpoint path on the host. Use ``""`` to expose
the interaction route at the app root.
agent_command: Slash command name that invokes the hosted agent.
agent_command_description: Description for the agent slash command.
agent_command_option: String option name that carries the prompt.
@@ -159,7 +159,7 @@ class DiscordChannel:
self.agent_command_description = agent_command_description
self.agent_command_option = agent_command_option
self.register_commands = register_commands
self._commands: set[ChannelCommand] = set(commands) or {} # type: ignore
self._commands = tuple(commands or ())
self._command_by_name = {command.name: command for command in self._commands}
self._run_hook = run_hook
self.response_hook = response_hook
@@ -184,7 +184,7 @@ class DiscordChannel:
"""Register the Discord interaction route and lifecycle hooks."""
self._ctx = context
return ChannelContribution(
routes=[Route("/interactions", self._handle, methods=["POST"])],
routes=[Route("/", self._handle, methods=["POST"])],
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
@@ -126,9 +126,9 @@ def test_ping_requires_valid_signature_and_returns_pong() -> None:
body = json.dumps({"type": 1}).encode("utf-8")
with TestClient(app) as client:
ok = client.post("/interactions", content=body, headers=_headers(signing_key, body))
ok = client.post("/", content=body, headers=_headers(signing_key, body))
bad = client.post(
"/interactions",
"/",
content=body,
headers={
**_headers(signing_key, body),
@@ -159,11 +159,11 @@ def test_request_validation_errors() -> None:
unsupported_app = Starlette(routes=list(unsupported_channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type]
with TestClient(app) as client:
too_large = client.post("/interactions", content=b"{}x")
invalid_json = client.post("/interactions", content=b"{")
too_large = client.post("/", content=b"{}x")
invalid_json = client.post("/", content=b"{")
with TestClient(unsupported_app) as client:
non_object = client.post("/interactions", json=[])
unsupported = client.post("/interactions", json={"type": 99})
non_object = client.post("/", json=[])
unsupported = client.post("/", json={"type": 99})
assert too_large.status_code == 413
assert invalid_json.status_code == 400