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
292 changes: 292 additions & 0 deletions Packs/MicrosoftTeams/Integrations/MicrosoftTeams/MicrosoftTeams.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ class GraphPermissions(str, Enum):
"microsoft-teams-auth-test": [],
"microsoft-teams-auth-reset": [],
"microsoft-teams-token-permissions-list": [],
"microsoft-teams-send-proactive-message": [],
},
CLIENT_CREDENTIALS_FLOW: {
"send-notification": [Perms.GROUPMEMBER_READ_ALL, Perms.CHANNEL_READBASIC_ALL],
Expand Down Expand Up @@ -270,6 +271,7 @@ class GraphPermissions(str, Enum):
"microsoft-teams-auth-test": [],
"microsoft-teams-auth-reset": [],
"microsoft-teams-token-permissions-list": [],
"microsoft-teams-send-proactive-message": [],
},
}
HIGHER_PERMISSIONS: dict[GraphPermissions, list[GraphPermissions]] = {
Expand Down Expand Up @@ -918,6 +920,229 @@ def get_bot_access_token() -> str:
raise ValueError("Failed to get bot access token")


def get_graph_token_for_proactive_messaging() -> str:
"""
Retrieves Microsoft Graph API access token specifically for proactive messaging user resolution.
Uses a separate token with Graph API scope instead of Bot Framework scope.
:return: The Microsoft Graph API access token
"""
integration_context: dict = get_integration_context()
access_token: str = integration_context.get("graph_proactive_token", "")
valid_until: int = integration_context.get("graph_proactive_valid_until", 0)

if access_token and valid_until and epoch_seconds() < valid_until:
demisto.debug("Using cached Graph API token for proactive messaging")
return access_token

tenant_id = integration_context.get("tenant_id")
if not tenant_id:
raise ValueError(MISS_CONFIGURATION_ERROR_MESSAGE)

data: dict = {
"grant_type": "client_credentials",
"client_id": BOT_ID,
"client_secret": BOT_PASSWORD,
"scope": "https://graph.microsoft.com/.default",
}

url: str = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
demisto.debug(f"Requesting Graph API token for proactive messaging from tenant {tenant_id}")
response: requests.Response = requests.post(url, data=data, verify=USE_SSL, proxies=PROXIES)

if not response.ok:
error = error_parser(response)
raise ValueError(f"Failed to get Graph access token for proactive messaging [{response.status_code}] - {error}")

try:
response_json: dict = response.json()
access_token = response_json.get("access_token", "")
expires_in: int = response_json.get("expires_in", 3595)
time_now: int = epoch_seconds()
time_buffer = 5 # seconds by which to shorten the validity period
if expires_in - time_buffer > 0:
expires_in -= time_buffer

integration_context["graph_proactive_token"] = access_token
integration_context["graph_proactive_valid_until"] = time_now + expires_in
set_integration_context(integration_context)
demisto.debug("Successfully obtained Graph API token for proactive messaging")
return access_token
except ValueError:
raise ValueError("Failed to get Graph access token for proactive messaging")


def resolve_user_id_for_proactive_message(user_identifier: str) -> str:
"""
Resolves a user identifier (displayName, mail, or userPrincipalName) to the actual user ID.
Uses Microsoft Graph API to query users.

:param user_identifier: User identifier - can be displayName, mail, userPrincipalName, or user ID
:return: The resolved user ID
"""
demisto.debug(f"Resolving user identifier: {user_identifier}")

# Get Graph API token for user resolution
access_token = get_graph_token_for_proactive_messaging()

headers: dict = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json"
}

# Try direct user ID lookup first (if it looks like a GUID)
if re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', user_identifier):
demisto.debug(f"User identifier appears to be a GUID, using directly: {user_identifier}")
return user_identifier

# Try to get user by exact match on userPrincipalName or mail
url = f"{GRAPH_BASE_URL}/v1.0/users/{urllib.parse.quote(user_identifier)}"
demisto.debug(f"Attempting direct user lookup: {url}")

try:
response: requests.Response = requests.get(url, headers=headers, verify=USE_SSL, proxies=PROXIES)
if response.ok:
user_data = response.json()
user_id = user_data.get("id", "")
if user_id:
demisto.debug(f"Found user by exact match: {user_id}")
return user_id
except Exception as e:
demisto.debug(f"Direct user lookup failed: {str(e)}")

# Try filter query for exact match on displayName, mail, or userPrincipalName
filter_query = (
f"displayName eq '{user_identifier}' or "
f"userPrincipalName eq '{user_identifier}' or "
f"mail eq '{user_identifier}'"
)
url = f"{GRAPH_BASE_URL}/v1.0/users?$filter={urllib.parse.quote(filter_query)}"
demisto.debug(f"Attempting exact match filter query: {filter_query}")

response = requests.get(url, headers=headers, verify=USE_SSL, proxies=PROXIES)

if not response.ok:
error = error_parser(response)
raise ValueError(f"Failed to resolve user ID [{response.status_code}] - {error}")

users = response.json().get("value", [])

if not users:
raise ValueError(
f"User not found: {user_identifier}. "
f"Please provide an exact match for email, User Principal Name, or user ID (GUID)."
)

# Security check: If multiple users match, raise an error to prevent sending to wrong user
if len(users) > 1:
user_list = '\n'.join([
f"- {u.get('displayName', 'N/A')} ({u.get('mail', 'N/A')}) - ID: {u.get('id', 'N/A')}"
for u in users
])
raise ValueError(
f"Multiple users found matching '{user_identifier}':\n{user_list}\n\n"
f"To avoid sending sensitive messages to the wrong user, please provide the exact user ID (GUID) "
f"or a unique email address."
)

user_id = users[0].get("id", "")
if not user_id:
raise ValueError(f"User ID not found in response for: {user_identifier}")

demisto.debug(f"Resolved user '{user_identifier}' to ID: {user_id}")
return user_id


def create_proactive_conversation(user_id: str) -> str:
"""
Creates a proactive conversation with a user using Bot Framework API.
Checks if conversation already exists to avoid unnecessary API calls.

:param user_id: The Microsoft Teams user ID
:return: The conversation ID
"""
integration_context: dict = get_integration_context()
bot_id: str = BOT_ID
bot_name: str = integration_context.get("bot_name", "")
tenant_id: str = integration_context.get("tenant_id", "")
service_url: str = integration_context.get("service_url", "")

if not service_url:
raise ValueError("Service URL not found. Please ensure the bot has been added to a team and has received at least one message.")

if not tenant_id:
raise ValueError(MISS_CONFIGURATION_ERROR_MESSAGE)

# Check if we have a cached conversation for this user
cached_conversations: dict = integration_context.get("proactive_conversations", {})
if isinstance(cached_conversations, str):
cached_conversations = json.loads(cached_conversations)

if user_id in cached_conversations:
conversation_id = cached_conversations[user_id]
demisto.debug(f"Using cached conversation ID for user {user_id}: {conversation_id}")
return conversation_id

# Create new conversation
conversation: dict = {
"bot": {"id": f"28:{bot_id}", "name": bot_name},
"members": [{"id": user_id}],
"channelData": {"tenant": {"id": tenant_id}},
}

url: str = f"{service_url}/v3/conversations"
demisto.debug(f"Creating proactive conversation with user {user_id}")

response: dict = cast(dict[Any, Any], http_request("POST", url, json_=conversation, api="bot"))
conversation_id: str = response.get("id", "")

if not conversation_id:
raise ValueError("Failed to create conversation: No conversation ID returned")

# Cache the conversation ID
cached_conversations[user_id] = conversation_id
integration_context["proactive_conversations"] = json.dumps(cached_conversations)
set_integration_context(integration_context)

demisto.debug(f"Created and cached conversation ID: {conversation_id}")
return conversation_id


def send_proactive_message_to_conversation(conversation_id: str, message: str = "", adaptive_card: dict = None) -> dict:
"""
Sends a proactive message to a conversation using Bot Framework API.

:param conversation_id: The conversation ID
:param message: The text message to send (optional if adaptive_card is provided)
:param adaptive_card: The adaptive card to send (optional if message is provided)
:return: The API response
"""
integration_context: dict = get_integration_context()
service_url: str = integration_context.get("service_url", "")

if not service_url:
raise ValueError("Service URL not found. Please ensure the bot has been added to a team.")

# Build the conversation payload
conversation_payload: dict = {"type": "message"}

if adaptive_card:
# Handle raw adaptive card format
adaptive_card = handle_raw_adaptive_card(adaptive_card)
conversation_payload["attachments"] = [adaptive_card]
elif message:
conversation_payload["text"] = message
conversation_payload["entities"] = []
else:
raise ValueError("Either message or adaptive_card must be provided")

url: str = f"{service_url}/v3/conversations/{conversation_id}/activities"
demisto.debug(f"Sending proactive message to conversation {conversation_id}")

response: dict = cast(dict[Any, Any], http_request("POST", url, json_=conversation_payload, api="bot"))
return response


def get_refresh_token_from_auth_code_param() -> str:
"""
The function is based on MicrosoftClient._get_refresh_token_from_auth_code_param() from 'MicrosoftApiModule'
Expand Down Expand Up @@ -2627,6 +2852,72 @@ def message_update_command():
return_results(results)


def send_proactive_message_command():
"""
Sends a proactive message to any Microsoft Teams user.
This command enables sending messages to users across the entire organization without requiring
the user to be in a specific team or channel.

The command:
1. Resolves the user identifier (displayName, mail, userPrincipalName, or user ID) to the actual user ID
2. Creates or retrieves a proactive conversation with the user
3. Sends the message or adaptive card to the conversation
"""
args = demisto.args()
user_id_arg: str = args.get('user_id', '')
message: str = args.get('message', '')
adaptive_card_arg: str = args.get('adaptive_card', '')

# Validate inputs - message and adaptive_card are both optional, but at least one must be provided
if not message and not adaptive_card_arg:
raise ValueError("Either message or adaptive_card must be provided")

if message and adaptive_card_arg:
raise ValueError("Provide either message or adaptive_card, not both")

# Step 1: Resolve user identifier to user ID
demisto.debug(f"Resolving user identifier: {user_id_arg}")
user_id = resolve_user_id_for_proactive_message(user_id_arg)

# Step 2: Create or retrieve proactive conversation
demisto.debug(f"Creating/retrieving proactive conversation for user: {user_id}")
conversation_id = create_proactive_conversation(user_id)

# Step 3: Prepare message or adaptive card
adaptive_card_obj = None
if adaptive_card_arg:
try:
adaptive_card_obj = json.loads(adaptive_card_arg)
except json.JSONDecodeError:
raise ValueError("Invalid adaptive card JSON format")

# Step 4: Send the proactive message
demisto.debug(f"Sending proactive message to conversation: {conversation_id}")
response = send_proactive_message_to_conversation(
conversation_id=conversation_id,
message=message,
adaptive_card=adaptive_card_obj
)

# Prepare outputs
outputs = {
"conversationId": conversation_id,
"userId": user_id,
"activityId": response.get("id", ""),
"userIdentifier": user_id_arg
}

result = CommandResults(
outputs_prefix="MicrosoftTeams.ProactiveMessage",
outputs_key_field="conversationId",
outputs=outputs,
readable_output="Message was sent successfully.",
raw_response=response
)

return_results(result)


def get_channel_id_for_send_notification(team_aad_id: str, channel_name: str, message_type: str):
"""
Returns the channel ID to send the message to
Expand Down Expand Up @@ -3741,6 +4032,7 @@ def main(): # pragma: no cover
"microsoft-teams-create-messaging-endpoint": create_messaging_endpoint_command,
"microsoft-teams-message-update": message_update_command,
"microsoft-teams-list-messages": list_messages_command,
"microsoft-teams-send-proactive-message": send_proactive_message_command,
}

commands_auth_code: dict = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,43 @@ script:
- contextPath: MicrosoftTeams.Message.ID
description: ID of the message sent.
type: String
- arguments:
- description: |-
The user identifier to send the message to. Can be one of the following:
- User ID (GUID): e.g., "3fa9f28b-eb0e-463a-ba7b-8089fe9991e2"
- Email address: e.g., "user@example.com"
- User Principal Name: e.g., "user@domain.onmicrosoft.com"
- Display Name: e.g., "John Doe"
name: user_id
required: true
- description: The text message to send to the user.
name: message
- description: The Microsoft Teams adaptive card to send (in JSON format).
name: adaptive_card
description: |-
Sends a proactive message to any Microsoft Teams user across the organization.
This command enables direct communication with users without requiring them to be in a specific team or channel.

The command will:
1. Resolve the user identifier to the actual user ID using Microsoft Graph API
2. Create or retrieve an existing conversation with the user
3. Send the message or adaptive card via the Bot Framework API

Note: Either 'message' or 'adaptive_card' must be provided, but not both.
name: microsoft-teams-send-proactive-message
outputs:
- contextPath: MicrosoftTeams.ProactiveMessage.conversationId
description: The ID of the conversation created or used.
type: String
- contextPath: MicrosoftTeams.ProactiveMessage.userId
description: The resolved user ID.
type: String
- contextPath: MicrosoftTeams.ProactiveMessage.activityId
description: The ID of the sent message activity.
type: String
- contextPath: MicrosoftTeams.ProactiveMessage.userIdentifier
description: The original user identifier provided.
type: String
dockerimage: demisto/teams:1.0.0.6091526
longRunning: true
longRunningPort: true
Expand Down
Loading
Loading