microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.1.0-preview-0007

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/docs/Activity-Design.md

362lines · modecode

1# CoreActivity / TeamsActivity Design
2
3## Overview
4
5The activity model is the central abstraction for all bot communication. It follows a two-layer architecture:
6
7- **CoreActivity** (`Microsoft.Teams.Core`): Channel-agnostic activity model with typed core properties and dynamic extension properties via `[JsonExtensionData]`. AOT-compatible via source-generated JSON.
8- **TeamsActivity** (`Microsoft.Teams.Apps`): Teams-specific extension that shadows base properties with Teams-specific types and promotes additional extension properties into typed fields.
9
10```
11CoreActivity (Core) — internal constructors, created via CreateBuilder() or deserialization
12 ├── Declared properties: type, channelId, id, serviceUrl,
13 │ replyToId, conversation (non-nullable), from, recipient
14 ├── [JsonExtensionData] Properties bag for everything else (incl. value)
15 ├── AOT serialization via CoreActivityJsonContext
16 └── CoreActivityBuilder (fluent builder)
17
18ConversationAccount (Core)
19 ├── Declared properties: id, name, isTargeted,
20 │ agenticAppId, agenticUserId, agenticAppBlueprintId
21 └── [JsonExtensionData] Properties bag for channel-specific fields
22
23AgenticIdentity (Core)
24 ├── AgenticAppId, AgenticUserId, AgenticAppBlueprintId
25 └── FromAccount(ConversationAccount?) — factory from typed fields
26
27TeamsActivity (Apps) : CoreActivity
28 ├── Shadows: From, Recipient (as TeamsConversationAccount)
29 │ Conversation (as TeamsConversation)
30 │ — via getter/setter delegates to base slot (single storage)
31 ├── Additional typed properties: ChannelData, Entities,
32 │ Timestamp, LocalTimestamp, Locale, LocalTimezone, SuggestedActions
33 ├── Polymorphic deserialization via ActivityDeserializerMap
34 ├── Type-specific serialization via ActivitySerializerMap
35 ├── TeamsActivityBuilder (fluent builder)
36 └── Derived types: MessageActivity, InvokeActivity, ConversationUpdateActivity, ...
37
38MessageActivity (Apps) : TeamsActivity
39 ├── Attachments, Text, TextFormat, AttachmentLayout
40 └── SuggestedActions extracted from Properties
41
42InvokeActivity (Apps) : TeamsActivity
43 ├── Name, Value (JsonNode?)
44 └── InvokeActivity<TValue> shadows Value with strongly-typed access
45
46EventActivity (Apps) : TeamsActivity
47 ├── Name, Value (JsonNode?)
48 └── EventActivity<TValue> shadows Value with strongly-typed access
49```
50
51## Activity Lifecycle
52
53```
54Incoming:
55 HTTP body (JSON)
56 → CoreActivity.FromJsonStreamAsync() // from, recipient → typed properties
57 → TurnMiddleware pipeline // channelData, entities, text → Properties bag
58 → TeamsActivity.FromActivity(coreActivity) // Converts typed + Extract remaining
59 → ActivityDeserializerMap dispatches to // MessageActivity, InvokeActivity, etc.
60 concrete type
61 → Router dispatches to handler
62
63Outgoing:
64 Handler builds reply
65 → TeamsActivityBuilder.WithConversationReference(incoming)
66 └── WithFrom(incoming.Recipient) // Swaps from/recipient for reply
67 → ConversationClient.SendActivityAsync(activity)
68 ├── Reads activity.Recipient.IsTargeted and AgenticIdentity.FromAccount(activity.From)
69 └── activity.ToJson() serializes for HTTP POST
70 → POST {serviceUrl}/v3/conversations/{id}/activities/
71```
72
73## Core Design Decisions
74
75### 1. Typed Properties for Protocol-Level Fields
76
77CoreActivity declares typed `[JsonPropertyName]` properties for fields that are part of the Activity Protocol Specification and needed by the Core layer:
78
79| Property | Type | Why typed |
80|----------|------|-----------|
81| `Type` | `string` | Routing decisions |
82| `ChannelId` | `string?` | URL construction |
83| `Id` | `string?` | Reply targeting |
84| `ServiceUrl` | `Uri?` | HTTP endpoint |
85| `ReplyToId` | `string?` | Reply threading |
86| `Conversation` | `Conversation` (non-nullable) | URL construction, always initialized |
87| `From` | `ConversationAccount?` | AgenticIdentity extraction |
88| `Recipient` | `ConversationAccount?` | IsTargeted flag for targeted messaging |
89
90Everything else (`text`, `attachments`, `entities`, `channelData`, `value`, `timestamp`, etc.) remains in the `[JsonExtensionData] Properties` dictionary, promoted to typed properties at the TeamsActivity layer or its derived types (e.g., `value` is promoted by `InvokeActivity` and `EventActivity`).
91
92### 2. ConversationAccount with Typed Agentic Identity Fields
93
94`ConversationAccount` declares the agentic identity fields as typed properties rather than relying on the extension data dictionary:
95
96```csharp
97[JsonPropertyName("agenticAppId")] public string? AgenticAppId { get; set; }
98[JsonPropertyName("agenticUserId")] public string? AgenticUserId { get; set; }
99[JsonPropertyName("agenticAppBlueprintId")] public string? AgenticAppBlueprintId { get; set; }
100```
101
102The `AgenticIdentity` class is a separate DTO used by `BotRequestOptions` and `BotAuthenticationHandler` for token acquisition. It is constructed from a `ConversationAccount`'s typed fields via `AgenticIdentity.FromAccount(account)` at the point of use — there is no computed property or duplication on `ConversationAccount` itself.
103
104### 3. Property Shadowing with Getter/Setter Delegates
105
106TeamsActivity shadows base properties with more specific types using the `new` keyword, but delegates storage to the base slot via getter/setter properties:
107
108```csharp
109// CoreActivity
110[JsonPropertyName("from")] public ConversationAccount? From { get; set; }
111[JsonPropertyName("conversation")] public Conversation Conversation { get; set; }
112
113// TeamsActivity — single storage, delegates to base
114[JsonPropertyName("from")]
115public new TeamsConversationAccount? From
116{
117 get => base.From as TeamsConversationAccount;
118 set => base.From = value;
119}
120
121[JsonPropertyName("conversation")]
122public new TeamsConversation? Conversation
123{
124 get => base.Conversation as TeamsConversation;
125 set => base.Conversation = value!;
126}
127```
128
129Since `TeamsConversationAccount` extends `ConversationAccount` and `TeamsConversation` extends `Conversation`, the derived type is stored directly in the base slot. The getter casts back. This eliminates dual storage and the need for manual sync — code accessing through either a `CoreActivity` or `TeamsActivity` reference sees the same value.
130
131The `TeamsActivity(CoreActivity)` constructor stores converted types in the base slots:
132```csharp
133base.From = TeamsConversationAccount.FromConversationAccount(activity.From) ?? new TeamsConversationAccount();
134base.Recipient = TeamsConversationAccount.FromConversationAccount(activity.Recipient) ?? new TeamsConversationAccount();
135base.Conversation = TeamsConversation.FromConversation(activity.Conversation) ?? new TeamsConversation();
136```
137
138**Serialization:** The `[JsonPropertyName]` attribute on the `new` property (not `[JsonIgnore]`) ensures the source-generated serializer for TeamsActivity uses the correctly-typed property (e.g., `TeamsConversation?` instead of `Conversation`), preserving fields like `TenantId` and `ConversationType`.
139
140### 4. Extension Data for Remaining Properties
141
142`ExtendedPropertiesDictionary.Extract<T>(key)` is used by TeamsActivity subtypes to promote remaining properties from the untyped bag to typed fields:
143
144```csharp
145public T? Extract<T>(string key)
146{
147 if (!TryGetValue(key, out object? raw)) return default;
148 Remove(key); // Remove to avoid duplicate serialization
149 if (raw is T typed) return typed; // Already the right type
150 if (raw is JsonElement element) // Deserialized from JSON
151 return JsonSerializer.Deserialize<T>(element.GetRawText());
152 return default; // Unknown type — data is lost
153}
154```
155
156This pattern is used for: `channelData`, `entities`, `value`, `attachments`, `text`, `textFormat`, `attachmentLayout`, `suggestedActions`, `name`, `action`, `membersAdded`, `membersRemoved`, `reactionsAdded`, `reactionsRemoved`.
157
158### 5. Dual Serialization Strategy
159
160| Path | When | Mechanism |
161|------|------|-----------|
162| AOT (source-gen) | `CoreActivity.ToJson()` | `CoreActivityJsonContext.Default.CoreActivity` |
163| Reflection | `CoreActivity.ToJson<T>(instance)` | `ReflectionJsonOptions` with camelCase |
164| Type-specific | `TeamsActivity.ToJson()` | `ActivitySerializerMap` dispatch by runtime type |
165
166### 6. Builder Pattern
167
168Both layers provide fluent builders with `With*` (replace) and `Add*` (append) methods:
169
170- `CoreActivityBuilder` — core-level activities with `WithFrom()`, `WithRecipient()`, `WithConversation()`, `WithProperty()`. Builder parameters accept nullable types where appropriate (`Uri?`, `string?`, `ConversationAccount?`).
171- `TeamsActivityBuilder` — Teams-specific, shadows `WithFrom`/`WithRecipient`/`WithConversation` (via `new`) to convert to `TeamsConversationAccount`/`TeamsConversation`. Attachment methods (`WithAttachments`, `AddAttachment`, etc.) set the typed property when the underlying activity is a `MessageActivity`, otherwise store in Properties as fallback.
172
173`TeamsActivityBuilder.WithConversationReference(activity)` is the canonical way to build a reply — it copies `ServiceUrl`, `ChannelId`, `Conversation` from the incoming activity and swaps `From`/`Recipient`.
174
175## Serialization Architecture
176
177### CoreActivity JSON Fields
178
179```
180Declared properties (deserialized into typed fields):
181 type, channelId, id, serviceUrl, replyToId, conversation, from, recipient
182
183Extension properties (deserialized into [JsonExtensionData] Properties):
184 value, text, textFormat, attachments, entities, channelData, timestamp,
185 locale, ... (anything not declared above)
186```
187
188### ConversationAccount JSON Fields
189
190```
191Declared properties:
192 id, name, isTargeted, agenticAppId, agenticUserId, agenticAppBlueprintId
193
194Extension properties:
195 aadObjectId, userRole, userPrincipalName, givenName, surname, email,
196 tenantId, ... (anything not declared above)
197```
198
199### TeamsActivity JSON Fields
200
201```
202Inherited declared (shadowed with Teams types):
203 from (TeamsConversationAccount), recipient (TeamsConversationAccount),
204 conversation (TeamsConversation)
205
206Inherited declared (used as-is):
207 type, channelId, id, serviceUrl, replyToId
208
209Promoted from Properties during construction:
210 channelData, entities, timestamp, localTimestamp,
211 locale, localTimezone, suggestedActions
212
213Promoted by derived types:
214 MessageActivity: text, textFormat, attachmentLayout, attachments
215 InvokeActivity: name, value
216 EventActivity: name, value
217 ConversationUpdateActivity: membersAdded, membersRemoved
218 InstallUpdateActivity: action
219 MessageReactionActivity: reactionsAdded, reactionsRemoved, replyToId
220
221Remaining extension properties (via [JsonExtensionData]):
222 Any fields not declared or promoted above
223```
224
225### Source-Generated JSON Contexts
226
227| Context | Project | Types |
228|---------|---------|-------|
229| `CoreActivityJsonContext` | Core | CoreActivity, ChannelData, Conversation, ConversationAccount, ExtendedPropertiesDictionary, primitives |
230| `TeamsActivityJsonContext` | Apps | TeamsActivity, MessageActivity, StreamingActivity, all Entity types, SuggestedActions, TeamsAttachment, TeamsConversation, TeamsConversationAccount, TeamsChannelData |
231
232## Class Hierarchy
233
234```
235CoreActivity
236└── TeamsActivity
237 ├── MessageActivity
238 ├── StreamingActivity
239 ├── InvokeActivity
240 │ └── InvokeActivity<TValue>
241 ├── ConversationUpdateActivity
242 ├── EventActivity
243 │ └── EventActivity<TValue>
244 ├── InstallUpdateActivity
245 ├── MessageReactionActivity
246 ├── MessageUpdateActivity
247 └── MessageDeleteActivity
248
249Conversation
250└── TeamsConversation
251
252ConversationAccount
253└── TeamsConversationAccount
254
255CoreActivityBuilder<TActivity, TBuilder>
256├── CoreActivityBuilder
257└── TeamsActivityBuilder
258```
259
260## Property Flow (Incoming Activity)
261
262```
263JSON → CoreActivity deserialization
264
265 │ Typed properties populated directly by JSON deserializer:
266 │ type, channelId, id, serviceUrl, replyToId, conversation, from, recipient
267
268 │ Remaining fields go to [JsonExtensionData] Properties:
269 │ value, channelData, entities, attachments, text, textFormat, timestamp, ...
270
271 ├── TeamsActivity(CoreActivity) constructor:
272 │ From base typed properties (converted, stored in base slot):
273 │ base.From ← TeamsConversationAccount.FromConversationAccount(activity.From)
274 │ base.Recipient ← TeamsConversationAccount.FromConversationAccount(activity.Recipient)
275 │ base.Conversation ← TeamsConversation.FromConversation(activity.Conversation)
276 │ From Properties via Extract<T>:
277 │ ChannelData ← Extract<TeamsChannelData>("channelData")
278 │ Entities ← Extract<EntityList>("entities")
279
280 ├── MessageActivity(CoreActivity) constructor:
281 │ Attachments ← Extract<IList<TeamsAttachment>>("attachments")
282 │ Text ← Extract<string>("text")
283 │ TextFormat ← Extract<string>("textFormat")
284 │ AttachmentLayout ← Extract<string>("attachmentLayout")
285 │ SuggestedActions ← Extract<SuggestedActions>("suggestedActions")
286
287 ├── InvokeActivity: Name ← Extract<string>("name")
288 │ Value ← Extract<JsonNode>("value")
289 ├── EventActivity: Name ← Extract<string>("name")
290 │ Value ← Extract<JsonNode>("value")
291 ├── InstallUpdateActivity: Action ← Extract<string>("action")
292 ├── ConversationUpdateActivity:
293 │ MembersAdded ← Extract<IList<TeamsConversationAccount>>("membersAdded")
294 │ MembersRemoved ← Extract<IList<TeamsConversationAccount>>("membersRemoved")
295 └── MessageReactionActivity:
296 ReactionsAdded ← Extract<IList<MessageReaction>>("reactionsAdded")
297 ReactionsRemoved ← Extract<IList<MessageReaction>>("reactionsRemoved")
298 ReplyToId ← Extract<string>("replyToId")
299```
300
301## Agentic Identity Flow
302
303```
304Incoming activity JSON:
305 { "from": { "id": "bot1", "agenticAppId": "app-123", "agenticUserId": "user-456" } }
306
307CoreActivity.FromJsonStreamAsync()
308 → activity.From = ConversationAccount { Id="bot1", AgenticAppId="app-123", AgenticUserId="user-456" }
309
310ConversationClient.SendActivityAsync(activity):
311 1. AgenticIdentity.FromAccount(activity.From)
312 → Reads AgenticAppId, AgenticUserId, AgenticAppBlueprintId from typed fields
313 → Returns AgenticIdentity { AgenticAppId="app-123", AgenticUserId="user-456" }
314 2. CreateRequestOptions(agenticIdentity, ...)
315 → BotRequestOptions.AgenticIdentity = agenticIdentity
316 3. BotHttpClient.SendAsync(...)
317 → request.Options.Set(AgenticIdentityKey, agenticIdentity)
318 4. BotAuthenticationHandler middleware
319 → Uses AgenticIdentity for user-delegated token acquisition
320```
321
322## Remaining Considerations
323
324### Shared Mutable Properties Dictionary (Shallow Copy)
325
326**Files: CoreActivity.cs, TeamsConversationAccount.cs**
327
328The copy constructor shares the Properties reference:
329
330```csharp
331Properties = activity.Properties; // Reference copy, not deep copy
332```
333
334When `TeamsActivity(CoreActivity)` calls `Extract<>()`, it removes keys from the shared dictionary, mutating the source activity. This is currently safe because the source isn't used after conversion, but it's fragile. Consider a shallow clone or document the contract that the source is consumed.
335
336### Extract<T> Silently Loses Data for Unknown Types
337
338When `raw` is neither `T` nor `JsonElement`, `Extract<T>` removes the key and returns `default`. This only affects Properties-based fields (channelData, attachments, entities, etc.) since `from`/`recipient`/`conversation` are now typed properties and never go through Extract.
339
340### Context.SendActivityAsync Overwrites Conversation Reference
341
342`Context.SendActivityAsync(TeamsActivity)` always applies `WithConversationReference(Activity)`, which overwrites `ServiceUrl`, `ChannelId`, `Conversation`, and `From`. For cross-conversation or proactive messaging, use `TeamsBotApplication.SendActivityAsync` directly.
343
344### CoreActivity Constructors are Internal
345
346CoreActivity constructors are `internal` — external consumers create instances via `CoreActivity.CreateBuilder()` or JSON deserialization (`FromJsonString`, `FromJsonStreamAsync`). The single `[JsonConstructor]` parameterized constructor handles both direct construction and deserialization, defaulting to `ActivityType.Message` and initializing `Conversation` to a non-null empty instance.
347
348## Test Coverage
349
350| Area | Coverage |
351|------|----------|
352| ConversationClient URL construction | Good |
353| ConversationClient isTargeted from Recipient property | Good |
354| ConversationClient AgenticIdentity from From property | Good |
355| CoreActivity JSON round-trip (from/recipient as typed props) | Good |
356| TeamsActivity.FromActivity() conversion | Good |
357| TeamsActivity.ToJson() single from/recipient in output | Good |
358| AgenticIdentity.FromAccount factory | Good |
359| Extract<T> with JsonElement (for channelData, entities, etc.) | Good |
360| TeamsActivityBuilder getter/setter property access (From/Recipient) | Good |
361| TeamsActivityBuilder.WithConversationReference | Partial |
362| Context.SendActivityAsync conversation ref application | Missing |
363