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
47 changes: 47 additions & 0 deletions packages/api/src/microsoft_teams/api/activities/message/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
Licensed under the MIT License.
"""

import re
from html import unescape
from typing import Any, List, Literal, Optional, Self
from urllib.parse import urlparse

from microsoft_teams.cards import AdaptiveCard
from microsoft_teams.common.experimental import experimental
Expand Down Expand Up @@ -40,6 +43,24 @@
from ..utils import StripMentionsTextOptions, strip_mentions_text


def _is_sharepoint_url(url: str) -> bool:
hostname = urlparse(url).hostname
if hostname is None:
return False

host = hostname.lower()
return host == "sharepoint.com" or host.endswith(".sharepoint.com")


def _sharepoint_urls_from_candidates(candidates: list[str]) -> list[str]:
urls: list[str] = []
for raw in candidates:
url: str = unescape(raw)
if _is_sharepoint_url(url):
urls.append(url)
return urls


class _MessageBase(CustomBaseModel):
"""Base class containing shared message activity fields (all Optional except type)."""

Expand Down Expand Up @@ -137,6 +158,32 @@ def strip_mentions_text(self, options: Optional[StripMentionsTextOptions] = None
self.text = stripped_text
return self

@property
def shared_file_urls(self) -> list[str]:
"""
Extract SharePoint-backed file URLs from HTML attachments.

When a user shares a file via the Teams compose box, Teams embeds
the file link inside a text/html attachment rather than sending
structured file metadata. This property parses those URLs out,
including OneDrive for Business links that use SharePoint hosts.
"""
urls: list[str] = []
for attachment in self.attachments or []:
if attachment.content_type != "text/html":
continue
if not attachment.content:
continue

html = str(attachment.content)
candidates: list[str] = re.findall(r"href=['\"](https?://[^'\"]+)['\"]", html, flags=re.IGNORECASE)
if not candidates:
candidates = re.findall(r"https?://[^\s\"<>]+", html)

urls.extend(_sharepoint_urls_from_candidates(candidates))

return urls


class MessageActivityInput(_MessageBase, ActivityInputBase):
"""Input model for creating message activities with builder methods."""
Expand Down
139 changes: 139 additions & 0 deletions packages/api/tests/unit/test_message_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,145 @@ def test_add_attachments_to_existing(self):
assert activity.attachments[0] == existing
assert activity.attachments[1] == new_attachment

def test_message_activity_shared_file_urls_extracts_sharepoint_links(self):
expected_url = "https://company.sharepoint.com/sites/Team/file.pdf?web=1&download=1"
payload = {
"type": "message",
"id": "msg-123",
"text": "see attached",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [
{
"contentType": "text/html",
"content": (
'<p>see <a href="https://company.sharepoint.com/sites/Team/file.pdf'
'?web=1&amp;download=1">file</a></p>'
),
"contentUrl": None,
"name": None,
}
],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == [expected_url]

def test_message_activity_shared_file_urls_extracts_one_drive_for_business_sharepoint_host(self):
expected_url = "https://company-my.sharepoint.com/personal/user_company_com/Documents/file.docx"
payload = {
"type": "message",
"id": "msg-234",
"text": "see attached",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [
{
"contentType": "text/html",
"content": f'<p>see <a href="{expected_url}">file</a></p>',
"contentUrl": None,
"name": None,
}
],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == [expected_url]

def test_message_activity_shared_file_urls_ignores_non_html_attachments(self):
payload = {
"type": "message",
"id": "msg-345",
"text": "plain message",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [
{
"contentType": "text/plain",
"content": "https://company.sharepoint.com/sites/Team/file.pdf",
"contentUrl": None,
"name": None,
},
],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == []

def test_message_activity_shared_file_urls_ignores_html_attachments_without_content(self):
payload = {
"type": "message",
"id": "msg-346",
"text": "plain message",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [
{
"contentType": "text/html",
"content": "",
"contentUrl": None,
"name": None,
},
],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == []

def test_message_activity_shared_file_urls_rejects_non_sharepoint_hosts(self):
payload = {
"type": "message",
"id": "msg-567",
"text": "see attached",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [
{
"contentType": "text/html",
"content": (
'<p><a href="https://notsharepoint.com/file.pdf">bad</a>'
'<a href="https://attacker.example/?next=https://company.sharepoint.com/file.pdf">bad</a></p>'
),
"contentUrl": None,
"name": None,
}
],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == []

def test_message_activity_shared_file_urls_empty_when_no_html(self):
payload = {
"type": "message",
"id": "msg-456",
"text": "plain message",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"attachments": [],
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageActivity)
assert activity.shared_file_urls == []

def test_add_mention_basic(self):
"""Test adding a basic mention"""
activity = self.create_message_activity("Hello ")
Expand Down