Skip to content
Open
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
40 changes: 40 additions & 0 deletions examples/targeted-messages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Targeted Messages Example

This example demonstrates how to send targeted (private) messages to specific users in Microsoft Teams group chats and channels.

## Features

- Send private messages visible only to a specific user
- Reply privately to messages
- Update and delete targeted messages
- Works in group chats and channels

## Running the Example

```bash
cd examples/targeted-messages
uv run python src/main.py
```

## Usage

In a group chat or channel, mention the bot with one of these commands:

- `@bot targeted` - Sends a private message only visible to you
- `@bot targeted-reply` - Replies privately to your message
- `@bot targeted-update` - Sends a private message, then updates it
- `@bot targeted-delete` - Sends a private message, then deletes it

## How It Works

Targeted messages use the `targeted_recipient_id` parameter to specify which user should see the message:

```python
# Send a targeted message to the user who sent the activity
await ctx.send("This is private!", targeted_recipient_id=ctx.activity.from_.id)

# Reply privately
await ctx.reply("Private reply!", targeted_recipient_id=ctx.activity.from_.id)
```

The message will appear in the conversation but only the targeted recipient can see it.
13 changes: 13 additions & 0 deletions examples/targeted-messages/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[project]
name = "targeted-messages"
version = "0.1.0"
description = "Targeted messages example - demonstrates sending private messages to specific users"
readme = "README.md"
requires-python = ">=3.12,<3.14"
dependencies = [
"microsoft-teams-apps",
"microsoft-teams-api",
]

[tool.uv.sources]
microsoft-teams-apps = { workspace = true }
99 changes: 99 additions & 0 deletions examples/targeted-messages/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.

Targeted Messages Example

This example demonstrates how to send targeted (private) messages
to specific users in Microsoft Teams group chats and channels.
"""

import asyncio
import re

from microsoft_teams.api import MessageActivity, MessageActivityInput
from microsoft_teams.apps import ActivityContext, App

app = App()


@app.on_message_pattern(re.compile(r"^targeted-update$", re.IGNORECASE))
async def on_targeted_update(ctx: ActivityContext[MessageActivity]) -> None:
"""Send a targeted message and then update it."""
# Send initial targeted message
sent = await ctx.send(
"⏳ This private message will be updated in 3 seconds...",
targeted_recipient_id=ctx.activity.from_.id,
)

# Wait 3 seconds
await asyncio.sleep(3)

# Update the targeted message
update_activity = MessageActivityInput(
id=sent.id,
text="✅ Private message has been updated! Only you can see this.",
)
update_activity.recipient = ctx.activity.from_

conversation_id = ctx.activity.conversation.id
await ctx.api.conversations.activities(conversation_id).update(sent.id, update_activity, is_targeted=True)


@app.on_message_pattern(re.compile(r"^targeted-delete$", re.IGNORECASE))
async def on_targeted_delete(ctx: ActivityContext[MessageActivity]) -> None:
"""Send a targeted message and then delete it."""
# Send initial targeted message
sent = await ctx.send(
"⏳ This private message will be deleted in 3 seconds...",
targeted_recipient_id=ctx.activity.from_.id,
)

# Wait 3 seconds
await asyncio.sleep(3)

# Delete the targeted message
conversation_id = ctx.activity.conversation.id
await ctx.api.conversations.activities(conversation_id).delete(sent.id, is_targeted=True)

# Send confirmation (also targeted)
await ctx.send(
"🗑️ The previous private message has been deleted!",
targeted_recipient_id=ctx.activity.from_.id,
)


@app.on_message_pattern(re.compile(r"^targeted-reply$", re.IGNORECASE))
async def on_targeted_reply(ctx: ActivityContext[MessageActivity]) -> None:
"""Reply with a targeted message that only the sender can see."""
await ctx.reply(
"🔒 This private reply is only visible to you!",
targeted_recipient_id=ctx.activity.from_.id,
)


@app.on_message_pattern(re.compile(r"^targeted$", re.IGNORECASE))
async def on_targeted_message(ctx: ActivityContext[MessageActivity]) -> None:
"""Send a targeted message that only the sender can see."""
await ctx.send(
"👋 This is a private message only you can see!",
targeted_recipient_id=ctx.activity.from_.id,
)


@app.on_message
async def on_message(ctx: ActivityContext[MessageActivity]) -> None:
"""Default handler - show available commands."""
await ctx.send(
"**Targeted Messages Example**\n\n"
"Available commands:\n"
"- `targeted` - Send a private message only you can see\n"
"- `targeted-reply` - Reply privately to your message\n"
"- `targeted-update` - Send a private message, then update it\n"
"- `targeted-delete` - Send a private message, then delete it\n\n"
"Try one of these commands in a group chat or channel!"
)


if __name__ == "__main__":
asyncio.run(app.start())
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Licensed under the MIT License.
"""

from typing import List, Optional
from typing import Any, List, Optional

from microsoft_teams.common.http import Client

Expand Down Expand Up @@ -35,21 +35,29 @@ def __init__(
super().__init__(http_client, api_client_settings)
self.service_url = service_url

async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity:
async def create(
self, conversation_id: str, activity: ActivityParams, *, is_targeted: bool = False
) -> SentActivity:
"""
Create a new activity in a conversation.

Args:
conversation_id: The ID of the conversation
activity: The activity to create
is_targeted: When True, sends the message privately to the recipient specified in activity.recipient

Returns:
The created activity
"""
url = f"{self.service_url}/v3/conversations/{conversation_id}/activities"
params: dict[str, Any] = {}
if is_targeted:
params["isTargetedActivity"] = "true"

response = await self.http.post(
f"{self.service_url}/v3/conversations/{conversation_id}/activities",
url,
json=activity.model_dump(by_alias=True, exclude_none=True),
params=params,
)

# Note: Typing activities (non-streaming) always produce empty responses.
Expand All @@ -58,55 +66,80 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc
id = response.json().get("id", "DO_NOT_USE_PLACEHOLDER_ID")
return SentActivity(id=id, activity_params=activity)

async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
async def update(
self, conversation_id: str, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False
) -> SentActivity:
"""
Update an existing activity in a conversation.

Args:
conversation_id: The ID of the conversation
activity_id: The ID of the activity to update
activity: The updated activity data
is_targeted: When True, sends the message privately to the recipient specified in activity.recipient

Returns:
The updated activity
"""
url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need to have "isTargeted" for update messages?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes based on service design, we need to pass isTargetedActivity for update and delete.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you revisit that? That doesn't really make sense though right? After an activity is created, the fact that it's targeted/not targeted is irrelevant

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are currently required for correct routing in APX and IC3 so they invoke the targeted handlers and apply the right validations. If dropped, the call goes through the normal message route and the activity cannot be resolved.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So a user needs to record the fact that an activity is targeted or not if they want to update it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is being discussed

params: dict[str, Any] = {}
if is_targeted:
params["isTargetedActivity"] = "true"

response = await self.http.put(
f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
url,
json=activity.model_dump(by_alias=True),
params=params,
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)

async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
async def reply(
self, conversation_id: str, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False
) -> SentActivity:
"""
Reply to an activity in a conversation.

Args:
conversation_id: The ID of the conversation
activity_id: The ID of the activity to reply to
activity: The reply activity
is_targeted: When True, sends the message privately to the recipient specified in activity.recipient

Returns:
The created reply activity
"""
activity_json = activity.model_dump(by_alias=True)
activity_json["replyToId"] = activity_id

url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
params: dict[str, Any] = {}
if is_targeted:
params["isTargetedActivity"] = "true"

response = await self.http.post(
f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
url,
json=activity_json,
params=params,
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)

async def delete(self, conversation_id: str, activity_id: str) -> None:
async def delete(self, conversation_id: str, activity_id: str, *, is_targeted: bool = False) -> None:
"""
Delete an activity from a conversation.

Args:
conversation_id: The ID of the conversation
activity_id: The ID of the activity to delete
is_targeted: When True, deletes a targeted message privately
"""
await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}")
url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
params: dict[str, Any] = {}
if is_targeted:
params["isTargetedActivity"] = "true"

await self.http.delete(url, params=params)

async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,21 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None:
class ActivityOperations(ConversationOperations):
"""Operations for managing activities in a conversation."""

async def create(self, activity: ActivityParams):
return await self._client.activities_client.create(self._conversation_id, activity)
async def create(self, activity: ActivityParams, *, is_targeted: bool = False):
return await self._client.activities_client.create(self._conversation_id, activity, is_targeted=is_targeted)

async def update(self, activity_id: str, activity: ActivityParams):
return await self._client.activities_client.update(self._conversation_id, activity_id, activity)
async def update(self, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False):
return await self._client.activities_client.update(
self._conversation_id, activity_id, activity, is_targeted=is_targeted
)

async def reply(self, activity_id: str, activity: ActivityParams):
return await self._client.activities_client.reply(self._conversation_id, activity_id, activity)
async def reply(self, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False):
return await self._client.activities_client.reply(
self._conversation_id, activity_id, activity, is_targeted=is_targeted
)

async def delete(self, activity_id: str):
await self._client.activities_client.delete(self._conversation_id, activity_id)
async def delete(self, activity_id: str, *, is_targeted: bool = False):
await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted=is_targeted)

async def get_members(self, activity_id: str):
return await self._client.activities_client.get_members(self._conversation_id, activity_id)
Expand Down
Loading