Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/apps/src/microsoft_teams/apps/activity_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ async def send_or_update_activity(
if activity.id:
activity_id = activity.id
if is_targeted:
res = await scoped_api.conversations.update_targeted_activity(ref.conversation.id, activity_id, activity)
else:
res = await scoped_api.conversations.update_activity(ref.conversation.id, activity_id, activity)
# The recipient of an existing targeted message cannot be edited, so it only selects the
# targeted endpoint and is dropped from the outbound payload. The caller still gets the
# recipient back on the returned activity.
payload = activity.model_copy(update={"recipient": None})
res = await scoped_api.conversations.update_targeted_activity(ref.conversation.id, activity_id, payload)
return SentActivity.merge(activity, res.model_copy(update={"activity_params": activity}))

res = await scoped_api.conversations.update_activity(ref.conversation.id, activity_id, activity)
return SentActivity.merge(activity, res)

if is_targeted:
Expand Down
26 changes: 17 additions & 9 deletions packages/apps/tests/test_activity_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,10 @@ async def test_targeted_message_with_explicit_recipient(self) -> None:
assert sent_activity.recipient.is_targeted is True

@pytest.mark.asyncio
async def test_targeted_update_preserves_recipient(self) -> None:
async def test_targeted_update_drops_recipient_from_payload(self) -> None:
"""
When updating a targeted message, the recipient should be preserved.
Teams rejects targeted updates that carry a recipient, so the recipient only selects the
targeted endpoint and is stripped from the outbound payload.
"""
incoming_sender = Account(id="user-123", name="Test User")
ctx, mock_sender = self._create_activity_context(from_account=incoming_sender)
Expand All @@ -280,16 +281,23 @@ async def test_targeted_update_preserves_recipient(self) -> None:
activity = MessageActivityInput(text="Updated text").with_recipient(incoming_sender, is_targeted=True)
activity.id = "existing-activity-id" # This makes it an update

await ctx.send(activity)
result = await ctx.send(activity)

ctx.api.conversations.activities.return_value.update_targeted.assert_called_once()
sent_activity = ctx.api.conversations.activities.return_value.update_targeted.call_args.args[1]
ctx.api.conversations.activities.return_value.create_targeted.assert_not_called()
activity_id, sent_activity = ctx.api.conversations.activities.return_value.update_targeted.call_args.args

# Verify recipient was preserved
assert sent_activity.recipient is not None
assert sent_activity.recipient.id == incoming_sender.id
assert sent_activity.recipient.name == incoming_sender.name
assert sent_activity.recipient.is_targeted is True
# The targeted endpoint is still chosen, but the wire payload carries no recipient
assert activity_id == "existing-activity-id"
assert sent_activity.text == "Updated text"
assert sent_activity.recipient is None
assert "recipient" not in sent_activity.model_dump(by_alias=True, exclude_none=True)

# The caller still gets the recipient back
assert result.activity_params.recipient is not None
assert result.activity_params.recipient.id == incoming_sender.id
assert result.activity_params.recipient.is_targeted is True
mock_sender.send.assert_not_called()

@pytest.mark.asyncio
async def test_send_existing_activity_updates(self) -> None:
Expand Down