diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 034eaec32..e6531f638 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -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 @@ -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).""" @@ -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.""" diff --git a/packages/api/tests/unit/test_message_activities.py b/packages/api/tests/unit/test_message_activities.py index e95c210d5..91b2aa6bb 100644 --- a/packages/api/tests/unit/test_message_activities.py +++ b/packages/api/tests/unit/test_message_activities.py @@ -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": ( + '

see file

' + ), + "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'

see file

', + "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": ( + '

bad' + 'bad

' + ), + "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 ")