From 4dbe411f3124e7a7a642ff09863e713a9d1b077b Mon Sep 17 00:00:00 2001 From: Puya Daravi Date: Wed, 18 Feb 2026 22:07:37 -0800 Subject: [PATCH 1/2] feat: Support new shorter Teams meeting URLs - Add detection for new URL format with error messages directing users to use Graph API. - Add tests and update test infrastructure (net6.0, latest packages). --- .../Sample.Common.Beta/Meetings/JoinInfo.cs | 24 ++- .../HeartbeatHandlerTests.cs | 6 +- .../Meetings/JoinInfoTests.cs | 187 ++++++++++++++++++ .../Sample.Common.Tests.csproj | 12 +- .../Sample.Common.V1/Meetings/JoinInfo.cs | 24 ++- .../Common/Sample.Common/Meetings/JoinInfo.cs | 25 ++- .../Sample.Common/Meetings/JoinInfoHelper.cs | 141 +++++++++++++ .../EchoBot/src/EchoBot/Models/JoinInfo.cs | 21 ++ .../RecordingBot.Services/Util/JoinInfo.cs | 20 +- .../HueBot/HueBot/HueBot.csproj | 4 +- 10 files changed, 449 insertions(+), 15 deletions(-) create mode 100644 Samples/Common/Sample.Common.Tests/Meetings/JoinInfoTests.cs create mode 100644 Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs diff --git a/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs index ad7ff546b..f333522f5 100644 --- a/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs @@ -21,20 +21,42 @@ public class JoinInfo { /// /// Parse Join URL into its components. + /// NOTE: This method only works with the OLD Teams meeting URL format that includes a context parameter. + /// For NEW shorter Teams meeting URLs (introduced with MCnumber rollout), you should: + /// 1. Use the Graph API to query the OnlineMeeting by JoinWebUrl to get meeting details + /// 2. Use JoinMeetingIdMeetingInfo with the meeting ID and passcode from the API response + /// Example: var meeting = await graphClient.Communications.OnlineMeetings.Request().Filter($"JoinWebUrl eq '{encodedUrl}'").GetAsync(); /// /// Join URL from Team's meeting body. /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { + if (string.IsNullOrEmpty(joinURL)) + { + throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + } + var decodedURL = WebUtility.UrlDecode(joinURL); - //// URL being needs to be in this format. + //// Old URL format with context parameter: //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + //// New shorter URL format (not supported by this parser): + //// https://teams.microsoft.com/l/meetup-join/... var regex = new Regex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})"); var match = regex.Match(decodedURL); if (!match.Success) { + // Check if this is a new shorter URL format + if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + { + throw new NotSupportedException( + $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + + $"To join meetings with this URL format, please use the Graph API to resolve the meeting details:\n" + + $"1. Query: GET /communications/onlineMeetings?$filter=JoinWebUrl eq '{Uri.EscapeDataString(joinURL)}'\n" + + $"2. Use JoinMeetingIdMeetingInfo with the meeting.JoinMeetingIdSettings from the response.\n" + + $"See: https://learn.microsoft.com/graph/api/resources/joinmeetingidmeetinginfo"); + } throw new ArgumentException($"Join URL cannot be parsed: {joinURL}.", nameof(joinURL)); } diff --git a/Samples/Common/Sample.Common.Tests/HeartbeatHandlerTests.cs b/Samples/Common/Sample.Common.Tests/HeartbeatHandlerTests.cs index cebdb5eb6..126d4dd85 100644 --- a/Samples/Common/Sample.Common.Tests/HeartbeatHandlerTests.cs +++ b/Samples/Common/Sample.Common.Tests/HeartbeatHandlerTests.cs @@ -25,7 +25,7 @@ public class HeartbeatHandlerTests /// /// Gets or sets the test context. /// - public TestContext TestContext + public TestContext? TestContext { get; set; } @@ -73,7 +73,7 @@ public void HeartbeatShouldLogSuccess() onNext: @event => { Interlocked.Increment(ref loggerCount); - this.TestContext.WriteLine(formatter.Format(@event)); + this.TestContext!.WriteLine(formatter.Format(@event)); }, onError: @exception => { @@ -107,7 +107,7 @@ public void HeartbeatShouldLogFailure() onNext: @event => { Interlocked.Increment(ref errorCount); - this.TestContext.WriteLine(formatter.Format(@event)); + this.TestContext!.WriteLine(formatter.Format(@event)); }, onError: @exception => { diff --git a/Samples/Common/Sample.Common.Tests/Meetings/JoinInfoTests.cs b/Samples/Common/Sample.Common.Tests/Meetings/JoinInfoTests.cs new file mode 100644 index 000000000..378487b73 --- /dev/null +++ b/Samples/Common/Sample.Common.Tests/Meetings/JoinInfoTests.cs @@ -0,0 +1,187 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// + +namespace Samples.Common.Tests.Meetings +{ + using System; + using Microsoft.Graph; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Sample.Common.Meetings; + + /// + /// Unit tests for JoinInfo URL parsing. + /// + [TestClass] + public class JoinInfoTests + { + private const string OldFormatUrl = "https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={\"Tid\":\"72f988bf-86f1-41af-91ab-2d7cd011db47\",\"Oid\":\"550fae72-d251-43ec-868c-373732c2704f\",\"MessageId\":\"1536978844957\"}"; + private const string OldFormatUrlEncoded = "https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context=%7B%22Tid%22%3A%2272f988bf-86f1-41af-91ab-2d7cd011db47%22%2C%22Oid%22%3A%22550fae72-d251-43ec-868c-373732c2704f%22%2C%22MessageId%22%3A%221536978844957%22%7D"; + private const string NewFormatUrlShort = "https://teams.microsoft.com/l/meetup-join/19:meeting_abc123def456@thread.v2/0"; + private const string NewFormatUrlWithoutContext = "https://teams.microsoft.com/l/meetup-join/19:meeting_YzY4NDFjZDUtZTdlOC00MDg3LWI3M2QtZTgzYzdiYzM0Yjk5@thread.skype/0"; + + /// + /// Gets or sets the test context. + /// + public TestContext? TestContext { get; set; } + + /// + /// Test that old format URL with context parameter parses successfully. + /// + [TestMethod] + public void ParseJoinURL_OldFormatWithContext_ParsesSuccessfully() + { + // Act + var (chatInfo, meetingInfo) = JoinInfo.ParseJoinURL(OldFormatUrl); + + // Assert + Assert.IsNotNull(chatInfo); + Assert.IsNotNull(meetingInfo); + Assert.AreEqual("19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype", chatInfo.ThreadId); + Assert.AreEqual("1509579179399", chatInfo.MessageId); + Assert.AreEqual("1536978844957", chatInfo.ReplyChainMessageId); + + Assert.IsInstanceOfType(meetingInfo, typeof(OrganizerMeetingInfo)); + var organizerInfo = (OrganizerMeetingInfo)meetingInfo; + Assert.IsNotNull(organizerInfo.Organizer); + Assert.IsNotNull(organizerInfo.Organizer.User); + Assert.AreEqual("550fae72-d251-43ec-868c-373732c2704f", organizerInfo.Organizer.User.Id); + } + + /// + /// Test that old format URL with encoded context parameter parses successfully. + /// + [TestMethod] + public void ParseJoinURL_OldFormatUrlEncoded_ParsesSuccessfully() + { + // Act + var (chatInfo, meetingInfo) = JoinInfo.ParseJoinURL(OldFormatUrlEncoded); + + // Assert + Assert.IsNotNull(chatInfo); + Assert.IsNotNull(meetingInfo); + Assert.AreEqual("19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype", chatInfo.ThreadId); + Assert.IsInstanceOfType(meetingInfo, typeof(OrganizerMeetingInfo)); + } + + /// + /// Test that new shorter URL format throws NotSupportedException. + /// + [TestMethod] + [ExpectedException(typeof(NotSupportedException))] + public void ParseJoinURL_NewShorterFormat_ThrowsNotSupportedException() + { + // Act + JoinInfo.ParseJoinURL(NewFormatUrlShort); + } + + /// + /// Test that new URL format without context throws NotSupportedException. + /// + [TestMethod] + [ExpectedException(typeof(NotSupportedException))] + public void ParseJoinURL_NewFormatWithoutContext_ThrowsNotSupportedException() + { + // Act + JoinInfo.ParseJoinURL(NewFormatUrlWithoutContext); + } + + /// + /// Test that new format URL exception message contains helpful guidance. + /// + [TestMethod] + public void ParseJoinURL_NewFormat_ExceptionContainsGuidance() + { + // Act & Assert + var exception = Assert.ThrowsException(() => + { + JoinInfo.ParseJoinURL(NewFormatUrlShort); + }); + + this.TestContext!.WriteLine($"Exception message: {exception.Message}"); + + // Verify the exception message contains helpful guidance + Assert.IsTrue(exception.Message.Contains("new shorter Teams meeting URL format")); + Assert.IsTrue(exception.Message.Contains("Graph API")); + Assert.IsTrue(exception.Message.Contains("JoinMeetingIdMeetingInfo")); + Assert.IsTrue(exception.Message.Contains("/communications/onlineMeetings")); + } + + /// + /// Test that null URL throws ArgumentException. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ParseJoinURL_NullUrl_ThrowsArgumentException() + { + // Act + JoinInfo.ParseJoinURL(null); + } + + /// + /// Test that empty URL throws ArgumentException. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ParseJoinURL_EmptyUrl_ThrowsArgumentException() + { + // Act + JoinInfo.ParseJoinURL(string.Empty); + } + + /// + /// Test that whitespace URL throws ArgumentException. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ParseJoinURL_WhitespaceUrl_ThrowsArgumentException() + { + // Act + JoinInfo.ParseJoinURL(" "); + } + + /// + /// Test that completely invalid URL throws ArgumentException. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ParseJoinURL_InvalidUrl_ThrowsArgumentException() + { + // Act + JoinInfo.ParseJoinURL("https://example.com/not-a-teams-url"); + } + + /// + /// Test that malformed Teams URL throws ArgumentException. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ParseJoinURL_MalformedTeamsUrl_ThrowsArgumentException() + { + // Act + JoinInfo.ParseJoinURL("https://teams.microsoft.com/malformed?context=invalid"); + } + + /// + /// Test that different thread formats are parsed correctly. + /// + [TestMethod] + public void ParseJoinURL_DifferentThreadFormats_ParsesCorrectly() + { + // Arrange + var threadSkypeUrl = "https://teams.microsoft.com/l/meetup-join/19:meeting_abc@thread.skype/0?context={\"Tid\":\"72f988bf-86f1-41af-91ab-2d7cd011db47\",\"Oid\":\"550fae72-d251-43ec-868c-373732c2704f\"}"; + var threadV2Url = "https://teams.microsoft.com/l/meetup-join/19:meeting_abc@thread.v2/0?context={\"Tid\":\"72f988bf-86f1-41af-91ab-2d7cd011db47\",\"Oid\":\"550fae72-d251-43ec-868c-373732c2704f\"}"; + + // Act + var (chatInfo1, meetingInfo1) = JoinInfo.ParseJoinURL(threadSkypeUrl); + var (chatInfo2, meetingInfo2) = JoinInfo.ParseJoinURL(threadV2Url); + + // Assert + Assert.AreEqual("19:meeting_abc@thread.skype", chatInfo1.ThreadId); + Assert.AreEqual("19:meeting_abc@thread.v2", chatInfo2.ThreadId); + Assert.IsNotNull(meetingInfo1); + Assert.IsNotNull(meetingInfo2); + } + } +} diff --git a/Samples/Common/Sample.Common.Tests/Sample.Common.Tests.csproj b/Samples/Common/Sample.Common.Tests/Sample.Common.Tests.csproj index 98c310c5b..ddf18a942 100644 --- a/Samples/Common/Sample.Common.Tests/Sample.Common.Tests.csproj +++ b/Samples/Common/Sample.Common.Tests/Sample.Common.Tests.csproj @@ -1,18 +1,18 @@ - netcoreapp2.2 - + net6.0 false + enable - - - - + + + + diff --git a/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs index da5d39316..2cb6e8171 100644 --- a/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs @@ -21,20 +21,42 @@ public class JoinInfo { /// /// Parse Join URL into its components. + /// NOTE: This method only works with the OLD Teams meeting URL format that includes a context parameter. + /// For NEW shorter Teams meeting URLs (introduced with MCnumber rollout), you should: + /// 1. Use the Graph API to query the OnlineMeeting by JoinWebUrl to get meeting details + /// 2. Use JoinMeetingIdMeetingInfo with the meeting ID and passcode from the API response + /// Example: var meeting = await graphClient.Communications.OnlineMeetings.Request().Filter($"JoinWebUrl eq '{encodedUrl}'").GetAsync(); /// /// Join URL from Team's meeting body. /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { + if (string.IsNullOrEmpty(joinURL)) + { + throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + } + var decodedURL = WebUtility.UrlDecode(joinURL); - //// URL being needs to be in this format. + //// Old URL format with context parameter: //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + //// New shorter URL format (not supported by this parser): + //// https://teams.microsoft.com/l/meetup-join/... var regex = new Regex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})"); var match = regex.Match(decodedURL); if (!match.Success) { + // Check if this is a new shorter URL format + if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + { + throw new NotSupportedException( + $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + + $"To join meetings with this URL format, please use the Graph API to resolve the meeting details:\n" + + $"1. Query: GET /communications/onlineMeetings?$filter=JoinWebUrl eq '{Uri.EscapeDataString(joinURL)}'\n" + + $"2. Use JoinMeetingIdMeetingInfo with the meeting.JoinMeetingIdSettings from the response.\n" + + $"See: https://learn.microsoft.com/graph/api/resources/joinmeetingidmeetinginfo"); + } throw new ArgumentException($"Join URL cannot be parsed: {joinURL}.", nameof(joinURL)); } diff --git a/Samples/Common/Sample.Common/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common/Meetings/JoinInfo.cs index c43df2d95..c363a136c 100644 --- a/Samples/Common/Sample.Common/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common/Meetings/JoinInfo.cs @@ -21,20 +21,43 @@ public class JoinInfo { /// /// Parse Join URL into its components. + /// NOTE: This method only works with the OLD Teams meeting URL format that includes a context parameter. + /// For NEW shorter Teams meeting URLs (introduced with MCnumber rollout), you should: + /// 1. Use the Graph API to query the OnlineMeeting by JoinWebUrl to get meeting details. + /// 2. Use JoinMeetingIdMeetingInfo with the meeting ID and passcode from the API response. + /// Example: var meeting = await graphClient.Communications.OnlineMeetings.Request().Filter($"JoinWebUrl eq '{encodedUrl}'").GetAsync(). /// /// Join URL from Team's meeting body. /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { + if (string.IsNullOrEmpty(joinURL)) + { + throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + } + var decodedURL = WebUtility.UrlDecode(joinURL); - //// URL being needs to be in this format. + //// Old URL format with context parameter: //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + //// New shorter URL format (not supported by this parser): + //// https://teams.microsoft.com/l/meetup-join/... var regex = new Regex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})"); var match = regex.Match(decodedURL); if (!match.Success) { + // Check if this is a new shorter URL format + if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + { + throw new NotSupportedException( + $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + + $"To join meetings with this URL format, please use the Graph API to resolve the meeting details:\n" + + $"1. Query: GET /communications/onlineMeetings?$filter=JoinWebUrl eq '{Uri.EscapeDataString(joinURL)}'\n" + + $"2. Use JoinMeetingIdMeetingInfo with the meeting.JoinMeetingIdSettings from the response.\n" + + $"See: https://learn.microsoft.com/graph/api/resources/joinmeetingidmeetinginfo"); + } + throw new ArgumentException($"Join URL cannot be parsed: {joinURL}.", nameof(joinURL)); } diff --git a/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs b/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs new file mode 100644 index 000000000..68167bee4 --- /dev/null +++ b/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs @@ -0,0 +1,141 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// + +namespace Sample.Common.Meetings +{ + using System; + using System.Net.Http; + using System.Threading.Tasks; + using System.Web; + using Microsoft.Graph; + + /// + /// Helper class for handling both old and new Teams meeting URL formats. + /// + public class JoinInfoHelper + { + private readonly GraphServiceClient graphClient; + + /// + /// Initializes a new instance of the class. + /// + /// The Graph service client. + public JoinInfoHelper(GraphServiceClient graphClient) + { + this.graphClient = graphClient ?? throw new ArgumentNullException(nameof(graphClient)); + } + + /// + /// Gets meeting information from a join URL, supporting both old and new URL formats. + /// For new shorter URLs, this method queries the Graph API to resolve meeting details. + /// + /// The Teams meeting join URL. + /// A tuple containing ChatInfo and MeetingInfo. + public async Task<(ChatInfo ChatInfo, MeetingInfo MeetingInfo)> GetMeetingInfoAsync(string joinUrl) + { + if (string.IsNullOrWhiteSpace(joinUrl)) + { + throw new ArgumentException("Join URL cannot be null or empty.", nameof(joinUrl)); + } + + // Try parsing the old format first + try + { + return JoinInfo.ParseJoinURL(joinUrl); + } + catch (NotSupportedException) + { + // This is a new shorter URL format - use Graph API to resolve it + return await this.ResolveNewFormatUrlAsync(joinUrl).ConfigureAwait(false); + } + } + + /// + /// Resolves a new-format Teams meeting URL using the Graph API. + /// + /// The Teams meeting join URL. + /// A tuple containing ChatInfo and MeetingInfo. + private async Task<(ChatInfo ChatInfo, MeetingInfo MeetingInfo)> ResolveNewFormatUrlAsync(string joinUrl) + { + // Query the Graph API to get the meeting details + var encodedUrl = HttpUtility.UrlEncode(joinUrl); + var filter = $"JoinWebUrl eq '{encodedUrl}'"; + + var meetings = await this.graphClient.Communications.OnlineMeetings + .Request() + .Filter(filter) + .GetAsync() + .ConfigureAwait(false); + + if (meetings == null || meetings.Count == 0) + { + throw new InvalidOperationException($"No meeting found for URL: {joinUrl}"); + } + + var meeting = meetings[0]; + + // Build ChatInfo from the meeting + var chatInfo = new ChatInfo + { + ThreadId = meeting.ChatInfo?.ThreadId, + MessageId = meeting.ChatInfo?.MessageId, + ReplyChainMessageId = meeting.ChatInfo?.ReplyChainMessageId, + }; + + // Build MeetingInfo using JoinMeetingIdSettings if available + MeetingInfo meetingInfo; + if (meeting.JoinMeetingIdSettings != null && !string.IsNullOrEmpty(meeting.JoinMeetingIdSettings.JoinMeetingId)) + { + meetingInfo = new JoinMeetingIdMeetingInfo + { + JoinMeetingId = meeting.JoinMeetingIdSettings.JoinMeetingId, + Passcode = meeting.JoinMeetingIdSettings.Passcode, + }; + } + else if (meeting.Participants?.Organizer?.Identity?.User != null) + { + // Fall back to OrganizerMeetingInfo if available + meetingInfo = new OrganizerMeetingInfo + { + Organizer = new IdentitySet + { + User = meeting.Participants.Organizer.Identity.User, + }, + }; + } + else + { + throw new InvalidOperationException($"Unable to construct MeetingInfo from meeting response for URL: {joinUrl}"); + } + + return (chatInfo, meetingInfo); + } + + /// + /// Example: Creating a JoinMeetingParameters object for joining a meeting. + /// + /// The Teams meeting join URL. + /// The local media session. + /// Optional tenant ID (required for OrganizerMeetingInfo). + /// A JoinMeetingParameters object ready to use with client.Calls().AddAsync(). + public async Task CreateJoinParametersAsync( + string joinUrl, + ILocalMediaSession mediaSession, + string tenantId = null) + { + var (chatInfo, meetingInfo) = await this.GetMeetingInfoAsync(joinUrl).ConfigureAwait(false); + + var joinParams = new JoinMeetingParameters(chatInfo, meetingInfo, mediaSession); + + // Set tenant ID if using OrganizerMeetingInfo + if (meetingInfo is OrganizerMeetingInfo && !string.IsNullOrEmpty(tenantId)) + { + joinParams.TenantId = tenantId; + } + + return joinParams; + } + } +} diff --git a/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs b/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs index c4e6da7d2..c5eda0dac 100644 --- a/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs +++ b/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs @@ -28,11 +28,17 @@ public class JoinInfo { /// /// Parse Join URL into its components. + /// NOTE: This method only works with the OLD Teams meeting URL format that includes a context parameter. + /// For NEW shorter Teams meeting URLs (introduced with MCnumber rollout), you should: + /// 1. Use the Graph API to query the OnlineMeeting by JoinWebUrl to get meeting details + /// 2. Use JoinMeetingIdMeetingInfo with the meeting ID and passcode from the API response + /// Example: var meeting = await graphClient.Communications.OnlineMeetings.Request().Filter($"JoinWebUrl eq '{encodedUrl}'").GetAsync(); /// /// Join URL from Team's meeting body. /// Parsed data. /// Join URL cannot be null or empty: {joinURL} - joinURL /// Join URL cannot be parsed: {joinURL} - joinURL + /// Join URL is in new shorter format - use Graph API /// Join URL is invalid: missing Tid - joinURL public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { @@ -43,10 +49,25 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) var decodedURL = WebUtility.UrlDecode(joinURL); + //// Old URL format with context parameter: + //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + //// New shorter URL format (not supported by this parser): + //// https://teams.microsoft.com/l/meetup-join/... + var regex = new Regex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})"); var match = regex.Match(decodedURL); if (!match.Success) { + // Check if this is a new shorter URL format + if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + { + throw new NotSupportedException( + $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + + $"To join meetings with this URL format, please use the Graph API to resolve the meeting details:\n" + + $"1. Query: GET /communications/onlineMeetings?$filter=JoinWebUrl eq '{Uri.EscapeDataString(joinURL)}'\n" + + $"2. Use JoinMeetingIdMeetingInfo with the meeting.JoinMeetingIdSettings from the response.\n" + + $"See: https://learn.microsoft.com/graph/api/resources/joinmeetingidmeetinginfo"); + } throw new ArgumentException($"Join URL cannot be parsed: {joinURL}", nameof(joinURL)); } diff --git a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs index 37e776f80..0f75ea1ac 100644 --- a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs +++ b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs @@ -29,11 +29,17 @@ public class JoinInfo { /// /// Parse Join URL into its components. + /// NOTE: This method only works with the OLD Teams meeting URL format that includes a context parameter. + /// For NEW shorter Teams meeting URLs (introduced with MCnumber rollout), you should: + /// 1. Use the Graph API to query the OnlineMeeting by JoinWebUrl to get meeting details + /// 2. Use JoinMeetingIdMeetingInfo with the meeting ID and passcode from the API response + /// Example: var meeting = await graphClient.Communications.OnlineMeetings.Request().Filter($"JoinWebUrl eq '{encodedUrl}'").GetAsync(); /// /// Join URL from Team's meeting body. /// Parsed data. /// Join URL cannot be null or empty: {joinURL} - joinURL /// Join URL cannot be parsed: {joinURL} - joinURL + /// Join URL is in new shorter format - use Graph API /// Join URL is invalid: missing Tid - joinURL public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { @@ -44,13 +50,25 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) var decodedURL = WebUtility.UrlDecode(joinURL); - //// URL being needs to be in this format. + //// Old URL format with context parameter: //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + //// New shorter URL format (not supported by this parser): + //// https://teams.microsoft.com/l/meetup-join/... var regex = new Regex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})"); var match = regex.Match(decodedURL); if (!match.Success) { + // Check if this is a new shorter URL format + if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + { + throw new NotSupportedException( + $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + + $"To join meetings with this URL format, please use the Graph API to resolve the meeting details:\n" + + $"1. Query: GET /communications/onlineMeetings?$filter=JoinWebUrl eq '{Uri.EscapeDataString(joinURL)}'\n" + + $"2. Use JoinMeetingIdMeetingInfo with the meeting.JoinMeetingIdSettings from the response.\n" + + $"See: https://learn.microsoft.com/graph/api/resources/joinmeetingidmeetinginfo"); + } throw new ArgumentException($"Join URL cannot be parsed: {joinURL}", nameof(joinURL)); } diff --git a/Samples/V1.0Samples/LocalMediaSamples/HueBot/HueBot/HueBot.csproj b/Samples/V1.0Samples/LocalMediaSamples/HueBot/HueBot/HueBot.csproj index e5afb0cfc..a08cd5953 100644 --- a/Samples/V1.0Samples/LocalMediaSamples/HueBot/HueBot/HueBot.csproj +++ b/Samples/V1.0Samples/LocalMediaSamples/HueBot/HueBot/HueBot.csproj @@ -13,9 +13,9 @@ - + - + From 41e2ee7d369a7022789ab8c6b94dfdd54478422e Mon Sep 17 00:00:00 2001 From: Puya Daravi Date: Thu, 19 Feb 2026 12:09:34 -0800 Subject: [PATCH 2/2] PR feedback --- .../Sample.Common.Beta/Meetings/JoinInfo.cs | 6 +- .../Sample.Common.V1/Meetings/JoinInfo.cs | 6 +- .../Common/Sample.Common/Meetings/JoinInfo.cs | 6 +- .../Sample.Common/Meetings/JoinInfoHelper.cs | 141 ------------------ .../EchoBot/src/EchoBot/Models/JoinInfo.cs | 6 +- .../RecordingBot.Services/Util/JoinInfo.cs | 6 +- 6 files changed, 15 insertions(+), 156 deletions(-) delete mode 100644 Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs diff --git a/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs index f333522f5..be6402806 100644 --- a/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common.Beta/Meetings/JoinInfo.cs @@ -31,9 +31,9 @@ public class JoinInfo /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { - if (string.IsNullOrEmpty(joinURL)) + if (string.IsNullOrWhiteSpace(joinURL)) { - throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + throw new ArgumentException($"Join URL cannot be null, empty, or whitespace: {joinURL}", nameof(joinURL)); } var decodedURL = WebUtility.UrlDecode(joinURL); @@ -48,7 +48,7 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) if (!match.Success) { // Check if this is a new shorter URL format - if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + if (decodedURL.Contains("teams.microsoft.com") && decodedURL.Contains("/meetup-join/") && !decodedURL.Contains("?context=")) { throw new NotSupportedException( $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + diff --git a/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs index 2cb6e8171..29947aff8 100644 --- a/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common.V1/Meetings/JoinInfo.cs @@ -31,9 +31,9 @@ public class JoinInfo /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { - if (string.IsNullOrEmpty(joinURL)) + if (string.IsNullOrWhiteSpace(joinURL)) { - throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + throw new ArgumentException($"Join URL cannot be null, empty, or whitespace: {joinURL}", nameof(joinURL)); } var decodedURL = WebUtility.UrlDecode(joinURL); @@ -48,7 +48,7 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) if (!match.Success) { // Check if this is a new shorter URL format - if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + if (decodedURL.Contains("teams.microsoft.com") && decodedURL.Contains("/meetup-join/") && !decodedURL.Contains("?context=")) { throw new NotSupportedException( $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + diff --git a/Samples/Common/Sample.Common/Meetings/JoinInfo.cs b/Samples/Common/Sample.Common/Meetings/JoinInfo.cs index c363a136c..aeaf60e49 100644 --- a/Samples/Common/Sample.Common/Meetings/JoinInfo.cs +++ b/Samples/Common/Sample.Common/Meetings/JoinInfo.cs @@ -31,9 +31,9 @@ public class JoinInfo /// Parsed data. public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { - if (string.IsNullOrEmpty(joinURL)) + if (string.IsNullOrWhiteSpace(joinURL)) { - throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + throw new ArgumentException($"Join URL cannot be null, empty, or whitespace: {joinURL}", nameof(joinURL)); } var decodedURL = WebUtility.UrlDecode(joinURL); @@ -48,7 +48,7 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) if (!match.Success) { // Check if this is a new shorter URL format - if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + if (decodedURL.Contains("teams.microsoft.com") && decodedURL.Contains("/meetup-join/") && !decodedURL.Contains("?context=")) { throw new NotSupportedException( $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + diff --git a/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs b/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs deleted file mode 100644 index 68167bee4..000000000 --- a/Samples/Common/Sample.Common/Meetings/JoinInfoHelper.cs +++ /dev/null @@ -1,141 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -// - -namespace Sample.Common.Meetings -{ - using System; - using System.Net.Http; - using System.Threading.Tasks; - using System.Web; - using Microsoft.Graph; - - /// - /// Helper class for handling both old and new Teams meeting URL formats. - /// - public class JoinInfoHelper - { - private readonly GraphServiceClient graphClient; - - /// - /// Initializes a new instance of the class. - /// - /// The Graph service client. - public JoinInfoHelper(GraphServiceClient graphClient) - { - this.graphClient = graphClient ?? throw new ArgumentNullException(nameof(graphClient)); - } - - /// - /// Gets meeting information from a join URL, supporting both old and new URL formats. - /// For new shorter URLs, this method queries the Graph API to resolve meeting details. - /// - /// The Teams meeting join URL. - /// A tuple containing ChatInfo and MeetingInfo. - public async Task<(ChatInfo ChatInfo, MeetingInfo MeetingInfo)> GetMeetingInfoAsync(string joinUrl) - { - if (string.IsNullOrWhiteSpace(joinUrl)) - { - throw new ArgumentException("Join URL cannot be null or empty.", nameof(joinUrl)); - } - - // Try parsing the old format first - try - { - return JoinInfo.ParseJoinURL(joinUrl); - } - catch (NotSupportedException) - { - // This is a new shorter URL format - use Graph API to resolve it - return await this.ResolveNewFormatUrlAsync(joinUrl).ConfigureAwait(false); - } - } - - /// - /// Resolves a new-format Teams meeting URL using the Graph API. - /// - /// The Teams meeting join URL. - /// A tuple containing ChatInfo and MeetingInfo. - private async Task<(ChatInfo ChatInfo, MeetingInfo MeetingInfo)> ResolveNewFormatUrlAsync(string joinUrl) - { - // Query the Graph API to get the meeting details - var encodedUrl = HttpUtility.UrlEncode(joinUrl); - var filter = $"JoinWebUrl eq '{encodedUrl}'"; - - var meetings = await this.graphClient.Communications.OnlineMeetings - .Request() - .Filter(filter) - .GetAsync() - .ConfigureAwait(false); - - if (meetings == null || meetings.Count == 0) - { - throw new InvalidOperationException($"No meeting found for URL: {joinUrl}"); - } - - var meeting = meetings[0]; - - // Build ChatInfo from the meeting - var chatInfo = new ChatInfo - { - ThreadId = meeting.ChatInfo?.ThreadId, - MessageId = meeting.ChatInfo?.MessageId, - ReplyChainMessageId = meeting.ChatInfo?.ReplyChainMessageId, - }; - - // Build MeetingInfo using JoinMeetingIdSettings if available - MeetingInfo meetingInfo; - if (meeting.JoinMeetingIdSettings != null && !string.IsNullOrEmpty(meeting.JoinMeetingIdSettings.JoinMeetingId)) - { - meetingInfo = new JoinMeetingIdMeetingInfo - { - JoinMeetingId = meeting.JoinMeetingIdSettings.JoinMeetingId, - Passcode = meeting.JoinMeetingIdSettings.Passcode, - }; - } - else if (meeting.Participants?.Organizer?.Identity?.User != null) - { - // Fall back to OrganizerMeetingInfo if available - meetingInfo = new OrganizerMeetingInfo - { - Organizer = new IdentitySet - { - User = meeting.Participants.Organizer.Identity.User, - }, - }; - } - else - { - throw new InvalidOperationException($"Unable to construct MeetingInfo from meeting response for URL: {joinUrl}"); - } - - return (chatInfo, meetingInfo); - } - - /// - /// Example: Creating a JoinMeetingParameters object for joining a meeting. - /// - /// The Teams meeting join URL. - /// The local media session. - /// Optional tenant ID (required for OrganizerMeetingInfo). - /// A JoinMeetingParameters object ready to use with client.Calls().AddAsync(). - public async Task CreateJoinParametersAsync( - string joinUrl, - ILocalMediaSession mediaSession, - string tenantId = null) - { - var (chatInfo, meetingInfo) = await this.GetMeetingInfoAsync(joinUrl).ConfigureAwait(false); - - var joinParams = new JoinMeetingParameters(chatInfo, meetingInfo, mediaSession); - - // Set tenant ID if using OrganizerMeetingInfo - if (meetingInfo is OrganizerMeetingInfo && !string.IsNullOrEmpty(tenantId)) - { - joinParams.TenantId = tenantId; - } - - return joinParams; - } - } -} diff --git a/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs b/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs index c5eda0dac..02a56b42c 100644 --- a/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs +++ b/Samples/PublicSamples/EchoBot/src/EchoBot/Models/JoinInfo.cs @@ -42,9 +42,9 @@ public class JoinInfo /// Join URL is invalid: missing Tid - joinURL public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { - if (string.IsNullOrEmpty(joinURL)) + if (string.IsNullOrWhiteSpace(joinURL)) { - throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + throw new ArgumentException($"Join URL cannot be null, empty, or whitespace: {joinURL}", nameof(joinURL)); } var decodedURL = WebUtility.UrlDecode(joinURL); @@ -59,7 +59,7 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) if (!match.Success) { // Check if this is a new shorter URL format - if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + if (decodedURL.Contains("teams.microsoft.com") && decodedURL.Contains("/meetup-join/") && !decodedURL.Contains("?context=")) { throw new NotSupportedException( $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " + diff --git a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs index 0f75ea1ac..a1042b3ee 100644 --- a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs +++ b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Util/JoinInfo.cs @@ -43,9 +43,9 @@ public class JoinInfo /// Join URL is invalid: missing Tid - joinURL public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) { - if (string.IsNullOrEmpty(joinURL)) + if (string.IsNullOrWhiteSpace(joinURL)) { - throw new ArgumentException($"Join URL cannot be null or empty: {joinURL}", nameof(joinURL)); + throw new ArgumentException($"Join URL cannot be null, empty, or whitespace: {joinURL}", nameof(joinURL)); } var decodedURL = WebUtility.UrlDecode(joinURL); @@ -60,7 +60,7 @@ public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) if (!match.Success) { // Check if this is a new shorter URL format - if (decodedURL.Contains("teams.microsoft.com") && !decodedURL.Contains("?context=")) + if (decodedURL.Contains("teams.microsoft.com") && decodedURL.Contains("/meetup-join/") && !decodedURL.Contains("?context=")) { throw new NotSupportedException( $"This appears to be a new shorter Teams meeting URL format which is not supported by this parser. " +