microsoft/teams.net

Public

mirrored from https://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

Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs

289lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4using System.Text.Json;
5using System.Text.Json.Serialization;
6
7using Microsoft.AspNetCore.Builder;
8using Microsoft.AspNetCore.Http;
9using Microsoft.Teams.Api.Activities;
10using Microsoft.Teams.Api.Auth;
11using Microsoft.Teams.Api.Clients;
12using Microsoft.Teams.Apps;
13using Microsoft.Teams.Apps.Events;
14using Microsoft.Teams.Apps.Plugins;
15using Microsoft.Teams.Common.Http;
16using Microsoft.Teams.Common.Logging;
17
18using HttpRequest = Microsoft.AspNetCore.Http.HttpRequest;
19
20namespace Microsoft.Teams.Plugins.AspNetCore;
21
22[Plugin]
23public partial class AspNetCorePlugin : ISenderPlugin, IAspNetCorePlugin
24{
25 [Dependency]
26 public ILogger Logger { get; set; }
27
28 [Dependency("Token", optional: true)]
29 public IToken? Token { get; set; }
30
31 [Dependency]
32 public IHttpClient Client { get; set; }
33
34 public event EventFunction Events;
35
36 private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
37 {
38 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
39 };
40
41 public IApplicationBuilder Configure(IApplicationBuilder builder)
42 {
43 return builder;
44 }
45
46 public Task OnInit(App app, CancellationToken cancellationToken = default)
47 {
48 return Task.CompletedTask;
49 }
50
51 public Task OnStart(App app, CancellationToken cancellationToken = default)
52 {
53 Logger.Debug("OnStart");
54 return Task.CompletedTask;
55 }
56
57 public Task OnError(App app, IPlugin plugin, ErrorEvent @event, CancellationToken cancellationToken = default)
58 {
59 Logger.Debug("OnError");
60 return Task.CompletedTask;
61 }
62
63 public Task OnActivity(App app, ISenderPlugin sender, ActivityEvent @event, CancellationToken cancellationToken = default)
64 {
65 Logger.Debug("OnActivity");
66 return Task.CompletedTask;
67 }
68
69 public Task OnActivitySent(App app, ISenderPlugin sender, ActivitySentEvent @event, CancellationToken cancellationToken = default)
70 {
71 Logger.Debug("OnActivitySent");
72 return Task.CompletedTask;
73 }
74
75 public Task OnActivityResponse(App app, ISenderPlugin sender, ActivityResponseEvent @event, CancellationToken cancellationToken = default)
76 {
77 Logger.Debug("OnActivityResponse");
78 return Task.CompletedTask;
79 }
80
81 public Task<IActivity> Send(IActivity activity, Api.ConversationReference reference, CancellationToken cancellationToken = default)
82 {
83 return Send<IActivity>(activity, reference, cancellationToken);
84 }
85
86 public async Task<TActivity> Send<TActivity>(TActivity activity, Api.ConversationReference reference, CancellationToken cancellationToken = default) where TActivity : IActivity
87 {
88 var client = new ApiClient(reference.ServiceUrl, Client, cancellationToken);
89
90 activity.Conversation = reference.Conversation;
91 activity.From = reference.Bot;
92 activity.ChannelId = reference.ChannelId;
93
94 // For targeted messages with an explicit Recipient (proactive sends), preserve it.
95 // Otherwise, use the reference User from the conversation context.
96 #pragma warning disable ExperimentalTeamsTargeted
97 var isTargeted = activity.Recipient?.IsTargeted == true;
98
99 if (isTargeted && reference.Conversation.Type?.IsPersonal == true)
100 {
101 throw new InvalidOperationException(
102 "Targeted messages are not supported in personal (1:1) chats.");
103 }
104
105 if (!isTargeted)
106 {
107 activity.Recipient = reference.User;
108 }
109
110 if (activity.Id is not null && !activity.IsStreaming)
111 {
112 if (isTargeted)
113 {
114 await client
115 .Conversations
116 .Activities
117 .UpdateTargetedAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false);
118 }
119 else
120 {
121 await client
122 .Conversations
123 .Activities
124 .UpdateAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false);
125 }
126
127 return activity;
128 }
129
130 var res = isTargeted
131 ? await client.Conversations.Activities.CreateTargetedAsync(reference.Conversation.Id, activity).ConfigureAwait(false)
132 : await client.Conversations.Activities.CreateAsync(reference.Conversation.Id, activity).ConfigureAwait(false);
133 #pragma warning restore ExperimentalTeamsTargeted
134
135 activity.Id = res?.Id;
136 return activity;
137 }
138
139 public IStreamer CreateStream(Api.ConversationReference reference, CancellationToken cancellationToken = default)
140 {
141 return new Stream()
142 {
143 Send = async activity =>
144 {
145 var res = await Send(activity, reference, cancellationToken).ConfigureAwait(false);
146 return res;
147 },
148 Logger = Logger.Child("stream")
149 };
150 }
151
152 public async Task<Response> Do(ActivityEvent @event, CancellationToken cancellationToken = default)
153 {
154 try
155 {
156 var @out = await Events(
157 this,
158 "activity",
159 @event,
160 cancellationToken
161 ).ConfigureAwait(false);
162
163 var res = (Response?)@out ?? throw new Exception("expected activity response");
164 Logger.Debug(res);
165 return res;
166 }
167 catch (Exception ex)
168 {
169 Logger.Error(ex);
170 await Events(
171 this,
172 "error",
173 new ErrorEvent() { Exception = ex },
174 cancellationToken
175 ).ConfigureAwait(false);
176
177 return new Response(System.Net.HttpStatusCode.InternalServerError, ex.ToString());
178 }
179 }
180
181 public async Task<IResult> Do(HttpContext httpContext, CancellationToken cancellationToken = default)
182 {
183 try
184 {
185 var request = httpContext.Request;
186 var token = ExtractToken(request);
187 var activity = await ParseActivity(request).ConfigureAwait(false);
188
189 if (activity is null)
190 {
191 return Results.BadRequest("Missing activity");
192 }
193
194 // Require the token's serviceurl claim to match the activity's serviceUrl
195 // (normalized, case-insensitive) when the activity specifies one. Mismatches
196 // are logged server-side.
197 if (!string.IsNullOrEmpty(activity.ServiceUrl))
198 {
199 var claimServiceUrl = token.Token.Payload.TryGetValue("serviceurl", out var serviceUrlClaim)
200 ? serviceUrlClaim as string
201 : null;
202
203 if (!NormalizeServiceUrl(claimServiceUrl).Equals(NormalizeServiceUrl(activity.ServiceUrl), StringComparison.OrdinalIgnoreCase))
204 {
205 Logger.Warn($"Rejecting activity {activity.Id}: serviceUrl '{activity.ServiceUrl}' does not match the token serviceurl claim '{claimServiceUrl}'.");
206 return Results.Unauthorized();
207 }
208 }
209
210 var data = new Dictionary<string, object?>
211 {
212 ["Request.TraceId"] = httpContext.TraceIdentifier
213 };
214
215 foreach (var pair in httpContext.Items)
216 {
217 var key = pair.Key.ToString();
218
219 if (key is null) continue;
220
221 data[key] = pair.Value;
222 }
223
224 var res = await Do(new ActivityEvent()
225 {
226 Token = token,
227 Activity = activity,
228 Extra = data,
229 Services = httpContext.RequestServices
230 }, cancellationToken).ConfigureAwait(false);
231
232 // convert response metadata to headers
233 foreach (var (key, value) in res.Meta)
234 {
235 var str = value?.ToString();
236 if (string.IsNullOrEmpty(str)) continue;
237 httpContext.Response.Headers.Append($"X-Teams-{char.ToUpper(key[0]) + key[1..]}", str);
238 }
239
240 return Results.Json(
241 res.Body,
242 _jsonSerializerOptions,
243 contentType: null,
244 statusCode: (int)res.Status
245 );
246 }
247 catch (Exception ex)
248 {
249 Logger.Error(ex);
250 await Events(
251 this,
252 "error",
253 new ErrorEvent() { Exception = ex },
254 cancellationToken
255 ).ConfigureAwait(false);
256
257 return Results.Problem(detail: ex.Message, statusCode: 500);
258 }
259 }
260
261 // Normalize a serviceUrl for comparison: null/empty becomes empty, and a single
262 // trailing slash is trimmed so "https://host/teams" and "https://host/teams/" match.
263 private static string NormalizeServiceUrl(string? serviceUrl) =>
264 string.IsNullOrEmpty(serviceUrl) ? string.Empty :
265 serviceUrl.EndsWith('/') ? serviceUrl[..^1] : serviceUrl;
266
267 public JsonWebToken ExtractToken(HttpRequest httpRequest)
268 {
269 var authHeader = httpRequest.Headers.Authorization.FirstOrDefault() ?? throw new UnauthorizedAccessException();
270 return new JsonWebToken(authHeader.Replace("Bearer ", ""));
271 }
272
273 public async Task<Activity?> ParseActivity(HttpRequest httpRequest)
274 {
275 httpRequest.EnableBuffering();
276
277 if (httpRequest.Body.CanSeek)
278 {
279 // reset the stream position to the beginning in case it was read before
280 httpRequest.Body.Position = 0;
281 }
282
283 using StreamReader sr = new(httpRequest.Body);
284 var body = await sr.ReadToEndAsync().ConfigureAwait(false);
285 Activity? activity = JsonSerializer.Deserialize<Activity>(body);
286
287 return activity;
288 }
289}