microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
kavin/agents-sdk-interop

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Apps/Routing/Router.cs

241lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Diagnostics;
5using Microsoft.Extensions.Logging;
6using Microsoft.Teams.Apps.Diagnostics;
7using Microsoft.Teams.Apps.Handlers;
8using Microsoft.Teams.Apps.Schema;
9using Microsoft.Teams.Core.Diagnostics;
10
11namespace Microsoft.Teams.Apps.Routing;
12
13/// <summary>
14/// Router for dispatching Teams activities to registered routes
15/// </summary>
16internal sealed class Router
17{
18 private readonly List<RouteBase> _routes = [];
19 private readonly ILogger _logger;
20
21 internal Router(ILogger logger)
22 {
23 _logger = logger;
24 }
25
26 /// <summary>
27 /// Routes registered in the router.
28 /// </summary>
29 public IReadOnlyList<RouteBase> GetRoutes() => _routes.AsReadOnly();
30
31 /// <summary>
32 /// Determines whether any registered route matches the given activity.
33 /// </summary>
34 /// <param name="activity">The activity to check against registered routes.</param>
35 /// <returns><c>true</c> if at least one route matches; otherwise, <c>false</c>.</returns>
36 public bool IsMatch(TeamsActivity activity)
37 {
38 ArgumentNullException.ThrowIfNull(activity);
39 return _routes.Any(r => r.Matches(activity));
40 }
41
42 /// <summary>
43 /// Registers a route. Routes are checked and invoked in registration order.
44 /// For non-invoke activities all matching routes run sequentially.
45 /// For invoke activities — routes must be non-overlapping.
46 /// </summary>
47 /// <exception cref="InvalidOperationException">
48 /// Thrown if a route with the same name is already registered, or if an invoke catch-all
49 /// is mixed with specific invoke handlers.
50 /// </exception>
51 public Router Register<TActivity>(Route<TActivity> route) where TActivity : TeamsActivity
52 {
53 if (_routes.Any(r => r.Name == route.Name))
54 {
55 throw new InvalidOperationException($"A route with name '{route.Name}' is already registered.");
56 }
57
58 string invokePrefix = TeamsActivityType.Invoke + "/";
59
60 if (route.Name == TeamsActivityType.Invoke && _routes.Any(r => r.Name.StartsWith(invokePrefix, StringComparison.Ordinal)))
61 {
62 throw new InvalidOperationException("Cannot register a catch-all invoke handler when specific invoke handlers are already registered. Use specific handlers or handle all invoke types inside OnInvoke.");
63 }
64
65 if (route.Name.StartsWith(invokePrefix, StringComparison.Ordinal) && _routes.Any(r => r.Name == TeamsActivityType.Invoke))
66 {
67 throw new InvalidOperationException($"Cannot register '{route.Name}' when a catch-all invoke handler is already registered. Remove OnInvoke or use specific handlers exclusively.");
68 }
69 _routes.Add(route);
70 _logger.LogDebug("Registered route '{Name}' for activity type '{ActivityType}'.", route.Name, typeof(TActivity).Name);
71 return this;
72 }
73
74 /// <summary>
75 /// Dispatches the activity to all matching routes in registration order.
76 /// </summary>
77 public async Task DispatchAsync(Context<TeamsActivity> ctx, CancellationToken cancellationToken = default)
78 {
79 ArgumentNullException.ThrowIfNull(ctx);
80
81 _logger.LogDebug("Routing activity of type '{Type}' against {RouteCount} registered routes.", ctx.Activity.Type, _routes.Count);
82
83 List<RouteBase> matchingRoutes = [];
84 foreach (RouteBase route in _routes)
85 {
86 bool matched = route.Matches(ctx.Activity);
87 _logger.LogTrace("Route '{Name}' selector returned {Result} for activity of type '{Type}'.", route.Name, matched, ctx.Activity.Type);
88 if (matched)
89 {
90 matchingRoutes.Add(route);
91 }
92 }
93
94 if (matchingRoutes.Count == 0 && _routes.Count > 0)
95 {
96 AppsTelemetry.HandlerUnmatched.Add(1, new KeyValuePair<string, object?>(AppsTelemetry.Tags.ActivityType, ctx.Activity.Type));
97 _logger.LogWarning(
98 "No routes matched activity of type '{Type}'.",
99 ctx.Activity.Type
100 );
101 return;
102 }
103
104 _logger.LogDebug("Matched {MatchCount} route(s) for activity of type '{Type}'.", matchingRoutes.Count, ctx.Activity.Type);
105
106 foreach (RouteBase route in matchingRoutes)
107 {
108 _logger.LogInformation("Dispatching '{Type}' activity to route '{Name}'.", ctx.Activity.Type, route.Name);
109 _logger.LogTrace("Dispatching activity to route '{Name}': {Activity}", route.Name, ctx.Activity.ToJson());
110
111 (string handlerType, string dispatch) = GetHandlerTags(route.Name);
112 TagList handlerTags = new()
113 {
114 { AppsTelemetry.Tags.HandlerType, handlerType },
115 { AppsTelemetry.Tags.HandlerDispatch, dispatch },
116 };
117
118 AppsTelemetry.HandlerDispatched.Add(1, handlerTags);
119
120 using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal);
121 if (span is not null)
122 {
123 span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType);
124 span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch);
125 }
126
127 long startTimestamp = Stopwatch.GetTimestamp();
128 try
129 {
130 await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false);
131 }
132 catch (Exception ex)
133 {
134 AppsTelemetry.HandlerFailures.Add(1, handlerTags);
135 span.RecordException(ex);
136 throw;
137 }
138 finally
139 {
140 double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds;
141 AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags);
142 }
143
144 _logger.LogDebug("Completed route '{Name}' for '{Type}' activity.", route.Name, ctx.Activity.Type);
145 }
146 }
147
148 /// <summary>
149 /// Dispatches the specified activity context to the first matching route and returns the result of the invocation.
150 /// </summary>
151 /// <param name="ctx">The activity context to dispatch. Cannot be null.</param>
152 /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
153 /// <returns>A task that represents the asynchronous operation. The task result contains a response object with the outcome
154 /// of the invocation.</returns>
155 public async Task<InvokeResponse> DispatchWithReturnAsync(Context<TeamsActivity> ctx, CancellationToken cancellationToken = default)
156 {
157 ArgumentNullException.ThrowIfNull(ctx);
158
159 string? name = ctx.Activity is InvokeActivity inv ? inv.Name : null;
160
161 _logger.LogDebug("Routing invoke activity with name '{Name}' against {RouteCount} registered routes.", name, _routes.Count);
162
163 List<RouteBase> matchingRoutes = [];
164 foreach (RouteBase route in _routes)
165 {
166 bool matched = route.Matches(ctx.Activity);
167 _logger.LogTrace("Route '{RouteName}' selector returned {Result} for invoke '{Name}'.", route.Name, matched, name);
168 if (matched)
169 {
170 matchingRoutes.Add(route);
171 }
172 }
173
174 if (matchingRoutes.Count == 0 && _routes.Count > 0)
175 {
176 TagList unmatchedTags = new()
177 {
178 { AppsTelemetry.Tags.ActivityType, ctx.Activity.Type },
179 { AppsTelemetry.Tags.InvokeName, name ?? string.Empty },
180 };
181 AppsTelemetry.HandlerUnmatched.Add(1, unmatchedTags);
182 _logger.LogWarning("No routes matched invoke activity with name '{Name}'; returning 501.", name);
183 return new InvokeResponse(501);
184 }
185
186 _logger.LogInformation("Dispatching invoke activity with name '{Name}' to route '{Route}'.", name, matchingRoutes[0].Name);
187 _logger.LogTrace("Dispatching invoke activity to route '{Route}': {Activity}", matchingRoutes[0].Name, ctx.Activity.ToJson());
188
189 (string handlerType, string dispatch) = GetHandlerTags(matchingRoutes[0].Name);
190 TagList handlerTags = new()
191 {
192 { AppsTelemetry.Tags.HandlerType, handlerType },
193 { AppsTelemetry.Tags.HandlerDispatch, dispatch },
194 };
195
196 AppsTelemetry.HandlerDispatched.Add(1, handlerTags);
197
198 using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal);
199 if (span is not null)
200 {
201 span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType);
202 span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch);
203 }
204
205 long startTimestamp = Stopwatch.GetTimestamp();
206 InvokeResponse response;
207 try
208 {
209 response = await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false);
210 }
211 catch (Exception ex)
212 {
213 AppsTelemetry.HandlerFailures.Add(1, handlerTags);
214 span.RecordException(ex);
215 throw;
216 }
217 finally
218 {
219 double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds;
220 AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags);
221 }
222
223 _logger.LogDebug("Completed invoke route '{Route}' for '{Name}' with status {Status}.", matchingRoutes[0].Name, name, response.Status);
224
225 return response;
226 }
227
228 private static (string handlerType, string dispatch) GetHandlerTags(string routeName)
229 {
230 const string invokePrefix = TeamsActivityType.Invoke + "/";
231 if (string.Equals(routeName, TeamsActivityType.Invoke, StringComparison.Ordinal))
232 {
233 return (routeName, "catchall");
234 }
235 if (routeName.StartsWith(invokePrefix, StringComparison.Ordinal))
236 {
237 return (routeName[invokePrefix.Length..], "invoke");
238 }
239 return (routeName, "type");
240 }
241}
242