-
Notifications
You must be signed in to change notification settings - Fork 12
Add support for Targeted Messages #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0a04250
2576dd7
1c7400a
e651790
feb46cb
eab9808
1602754
00e9c90
3c805a7
74648af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 } |
| 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 |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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. | ||
|
|
@@ -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}" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you need to have "isTargeted" for update messages?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]: | ||
| """ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.