microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.0.8

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/docs/ApiClient-Design.md

249lines · modecode

1# ApiClient Design Document
2
3## Overview
4
5The `ApiClient` class (`Microsoft.Teams.Apps.Api.Clients`) provides a hierarchical, Libraries-compatible API surface for Teams Bot operations. It organizes Bot Framework v3 REST API calls into sub-clients that delegate to the core SDK infrastructure rather than making raw HTTP calls.
6
7## Architecture
8
9```
10ApiClient (top-level facade)
11├── Bots → BotClient
12│ └── SignIn → BotSignInClient [delegates to core UserTokenClient]
13├── Conversations → ConversationApiClient [delegates to core ConversationClient]
14│ ├── Activities → ActivityClient
15│ ├── Members → MemberClient
16│ └── Reactions → ReactionClient [Experimental]
17├── Users → UserClient
18│ └── Token → UserTokenApiClient [delegates to core UserTokenClient]
19├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/]
20└── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/]
21```
22
23### HTTP strategies
24
25| Sub-client | Strategy | Why |
26|---|---|---|
27| Conversations (Activities, Members, Reactions) | Delegates to core `ConversationClient` | Reuses auth, logging, agents-channel handling, agentic identity support |
28| Bots.SignIn, Users.Token | Delegates to core `UserTokenClient` | Reuses auth, logging, agentic identity; single source of truth for token API calls |
29| Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints |
30
31### Experimental APIs
32
33| Feature | Diagnostic ID | Notes |
34|---|---|---|
35| `ReactionClient` | `ExperimentalTeamsReactions` | Reactions endpoint assumed but not confirmed in Teams Bot Framework API |
36| `ActivityClient.CreateTargetedAsync` / `UpdateTargetedAsync` / `DeleteTargetedAsync` | `ExperimentalTeamsTargeted` | Targeted (recipient-only visible) activities; not supported in team channel conversations |
37
38## Construction & Scoping
39
40### The serviceUrl problem
41
42The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), but `ApiClient` is per-application (DI singleton). The `ApiClient` solves this with a two-step pattern:
43
441. **DI registration** creates a base `ApiClient` without a serviceUrl
452. **Per-request**, `ForServiceUrl(uri)` creates a lightweight scoped copy with all sub-clients bound
46
47### DI-friendly constructor (no serviceUrl)
48
49```csharp
50// Registered automatically by AddTeamsBotApplication()
51// The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor
52[ActivatorUtilitiesConstructor]
53public ApiClient(HttpClient httpClient, ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger? logger = null)
54```
55
56`AddTeamsBotApplication()` calls `AddBotClient<ApiClient>(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` and `UserTokenClient` dependencies are resolved from DI automatically.
57
58**Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null!`. Only `Bots` and `Users` are available on the unscoped instance. Accessing `Conversations`, `Teams`, or `Meetings` directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance.
59
60### Per-request scoping via Context.Api
61
62In activity handlers, use the `Context.Api` property which auto-scopes to the current activity's service URL:
63
64```csharp
65// In a handler — Context.Api is lazy-initialized via ForServiceUrl(Activity.ServiceUrl)
66botApp.OnMessage(async (ctx, ct) =>
67{
68 var members = await ctx.Api.Conversations.Members.GetAsync(conversationId, ct);
69 var team = await ctx.Api.Teams.GetByIdAsync(teamId, ct);
70});
71```
72
73**Do NOT use `ctx.TeamsBotApplication.Api.Conversations`** — that is the unscoped base client and will throw `NullReferenceException`.
74
75### ForServiceUrl (explicit scoping)
76
77For code outside handlers (e.g., proactive messaging, compat layer):
78
79```csharp
80ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl);
81await scoped.Conversations.Activities.CreateAsync(conversationId, activity);
82```
83
84`ForServiceUrl` shares the underlying `BotHttpClient`, `ConversationClient`, and `UserTokenClient` — only the sub-client wrappers are new allocations.
85
86### Constructors
87
88| Constructor | Use case |
89|---|---|
90| `ApiClient(HttpClient, ConversationClient, UserTokenClient, ILogger?)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) |
91| `ApiClient(Uri, HttpClient, ConversationClient, UserTokenClient, ILogger?)` | Fully initialized with known serviceUrl |
92| `ApiClient(ApiClient)` | Copy constructor |
93| Private: `ApiClient(BotHttpClient, ConversationClient, UserTokenClient, Uri)` | Used by `ForServiceUrl` — shares clients |
94
95## Delegation Pattern
96
97The Apps-layer sub-clients delegate to core clients rather than duplicating HTTP logic:
98
99- **Conversation sub-clients** (`ActivityClient`, `MemberClient`, `ReactionClient`) → core `ConversationClient`
100- **Token/SignIn sub-clients** (`UserTokenApiClient`, `BotSignInClient`) → core `UserTokenClient`
101
102This ensures:
103
104- Single source of truth for URL construction, auth, and error handling
105- Agents-channel ID truncation logic is preserved
106- Agentic identity support works transparently for all operations
107- Custom headers and logging from core clients apply
108
109### Parameter bridging
110
111The Libraries-style API takes `(conversationId, activity)` as separate parameters, while the core `ConversationClient` expects context embedded in the activity or passed as method parameters. The sub-clients bridge this:
112
113```
114ActivityClient.CreateAsync(conversationId, activity)
115 → sets activity.ServiceUrl, activity.Conversation
116 → calls ConversationClient.SendActivityAsync(activity)
117
118MemberClient.GetAsync(conversationId)
119 → calls ConversationClient.GetConversationMembersAsync(conversationId, serviceUrl)
120
121ReactionClient.AddAsync(conversationId, activityId, reactionType)
122 → calls ConversationClient.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)
123```
124
125### Method mapping
126
127#### ActivityClient → ConversationClient
128
129| ActivityClient | ConversationClient | Notes |
130|---|---|---|
131| `CreateAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `ServiceUrl` and `Conversation` on activity |
132| `UpdateAsync(conversationId, id, activity)` | `UpdateActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity |
133| `ReplyAsync(conversationId, id, activity)` | `SendActivityAsync(activity)` | Sets `ReplyToId`, `ServiceUrl`, `Conversation` |
134| `DeleteAsync(conversationId, id)` | `DeleteActivityAsync(conversationId, id, serviceUrl)` | |
135| `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` [Experimental] |
136| `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` [Experimental] |
137| `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | [Experimental] |
138
139#### MemberClient → ConversationClient
140
141| MemberClient | ConversationClient |
142|---|---|
143| `GetAsync(conversationId)` | `GetConversationMembersAsync(conversationId, serviceUrl)` |
144| `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync<ConversationAccount>(conversationId, memberId, serviceUrl)` |
145| `GetByIdAsync<T>(conversationId, memberId)` | `GetConversationMemberAsync<T>(conversationId, memberId, serviceUrl)` |
146| `DeleteAsync(conversationId, memberId)` | `DeleteConversationMemberAsync(conversationId, memberId, serviceUrl)` |
147
148#### ReactionClient → ConversationClient [Experimental]
149
150| ReactionClient | ConversationClient |
151|---|---|
152| `AddAsync(conversationId, activityId, reactionType)` | `AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)` |
153| `DeleteAsync(conversationId, activityId, reactionType)` | `DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl)` |
154
155#### ConversationApiClient → ConversationClient
156
157| ConversationApiClient | ConversationClient |
158|---|---|
159| `CreateAsync(parameters)` | `CreateConversationAsync(parameters, serviceUrl)` |
160
161#### TeamClient (direct HTTP)
162
163| TeamClient | Endpoint |
164|---|---|
165| `GetByIdAsync(id)` | `GET {serviceUrl}/v3/teams/{id}` |
166| `GetConversationsAsync(id)` | `GET {serviceUrl}/v3/teams/{id}/conversations` |
167
168#### MeetingClient (direct HTTP)
169
170| MeetingClient | Endpoint |
171|---|---|
172| `GetByIdAsync(id)` | `GET {serviceUrl}/v1/meetings/{id}` |
173| `GetParticipantAsync(meetingId, id, tenantId)` | `GET {serviceUrl}/v1/meetings/{meetingId}/participants/{id}?tenantId={tenantId}` |
174
175#### BotSignInClient → UserTokenClient
176
177| BotSignInClient | UserTokenClient |
178|---|---|
179| `GetUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` |
180| `GetResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` |
181
182#### UserTokenApiClient → UserTokenClient
183
184| UserTokenApiClient | UserTokenClient | Notes |
185|---|---|---|
186| `GetAsync(userId, connectionName, channelId, code?)` | `GetTokenAsync(userId, connectionName, channelId, code?)` | |
187| `GetAadAsync(userId, connectionName, channelId, resourceUrls?)` | `GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?)` | `IList<string>?` → `string[]?` |
188| `GetStatusAsync(userId, channelId, includeFilter?)` | `GetTokenStatusAsync(userId, channelId, include?)` | Returns `GetTokenStatusResult[]` as `IList<>?` |
189| `SignOutAsync(userId, connectionName, channelId)` | `SignOutUserAsync(userId, connectionName?, channelId?)` | |
190| `ExchangeAsync(userId, connectionName, channelId, token)` | `ExchangeTokenAsync(userId, connectionName, channelId, token?)` | |
191
192## File Layout
193
194```
195core/src/Microsoft.Teams.Apps/Api/Clients/
196├── ApiClient.cs Top-level facade, DI entry point, ForServiceUrl factory
197├── ConversationApiClient.cs Conversation facade → delegates to core ConversationClient
198├── ActivityClient.cs Activity CRUD + targeted → delegates to core ConversationClient
199├── MemberClient.cs Member operations → delegates to core ConversationClient
200├── ReactionClient.cs Reaction operations → delegates to core ConversationClient [Experimental]
201├── TeamClient.cs Team info → BotHttpClient (v3/teams/)
202├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models
203├── BotClient.cs Bot facade (groups SignIn)
204├── BotSignInClient.cs Sign-in URLs → delegates to core UserTokenClient
205├── BotTokenClient.cs Static scope constants
206├── UserClient.cs User facade (groups Token)
207└── UserTokenApiClient.cs User token ops → delegates to core UserTokenClient
208```
209
210## Integration with Context and Handlers
211
212The `Context<TActivity>` class exposes a lazy `Api` property:
213
214```csharp
215public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl(Activity.ServiceUrl);
216```
217
218This is the primary way handlers should access the API clients. It ensures the scoped `ApiClient` is created once per request and reused across multiple calls within the same handler.
219
220## Integration with TeamsApiClient
221
222`TeamsApiClient` retrieves clients from `TurnState`:
223
224- **`ApiClient`** (from `TurnState.Get<ApiClient>()`) for Teams/Meetings operations:
225 - `client.Teams.GetByIdAsync(teamId)` — team details
226 - `client.Teams.GetConversationsAsync(teamId)` — channel list
227 - `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant
228
229- **`ConversationClient`** (from `CompatConnectorClient` in `TurnState.Get<IConnectorClient>()`) for member operations:
230 - `GetConversationMemberAsync<TeamsConversationAccount>(...)` — single member
231 - `GetConversationMembersAsync(...)` — all members
232 - `GetConversationPagedMembersAsync(...)` — paged members
233
234**Note on TeamsBotFrameworkHttpAdapter scoping:** The `TeamsBotFrameworkHttpAdapter` currently stores the unscoped `TeamsApiClient` in `TurnState` (line 59). This works because `TeamsApiClient` uses the `ApiClient` sub-clients which are scoped. However, `TeamsBotFrameworkHttpAdapter` should ideally scope the `ApiClient` before storing:
235
236```csharp
237// Current (unscoped — Teams/Meetings sub-clients are null):
238turnContext.TurnState.Add<ApiClient>(_teamsBotApplication.TeamsApiClient);
239
240// Should be (scoped):
241ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl));
242turnContext.TurnState.Add<ApiClient>(scopedClient);
243```
244
245## Future Work
246
247- **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints.
248- **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types.
249- **TeamsBotFrameworkHttpAdapter scoping**: Fix `TeamsBotFrameworkHttpAdapter` to call `ForServiceUrl` before storing `ApiClient` in `TurnState`.
250