microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/docs/Activity-Design.md
362lines · modecode
| 1 | # CoreActivity / TeamsActivity Design |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | The 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 | ``` |
| 11 | CoreActivity (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 | |
| 18 | ConversationAccount (Core) |
| 19 | ├── Declared properties: id, name, isTargeted, |
| 20 | │ agenticAppId, agenticUserId, agenticAppBlueprintId |
| 21 | └── [JsonExtensionData] Properties bag for channel-specific fields |
| 22 | |
| 23 | AgenticIdentity (Core) |
| 24 | ├── AgenticAppId, AgenticUserId, AgenticAppBlueprintId |
| 25 | └── FromAccount(ConversationAccount?) — factory from typed fields |
| 26 | |
| 27 | TeamsActivity (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 | |
| 38 | MessageActivity (Apps) : TeamsActivity |
| 39 | ├── Attachments, Text, TextFormat, AttachmentLayout |
| 40 | └── SuggestedActions extracted from Properties |
| 41 | |
| 42 | InvokeActivity (Apps) : TeamsActivity |
| 43 | ├── Name, Value (JsonNode?) |
| 44 | └── InvokeActivity<TValue> shadows Value with strongly-typed access |
| 45 | |
| 46 | EventActivity (Apps) : TeamsActivity |
| 47 | ├── Name, Value (JsonNode?) |
| 48 | └── EventActivity<TValue> shadows Value with strongly-typed access |
| 49 | ``` |
| 50 | |
| 51 | ## Activity Lifecycle |
| 52 | |
| 53 | ``` |
| 54 | Incoming: |
| 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 | |
| 63 | Outgoing: |
| 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 | |
| 77 | CoreActivity 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 | |
| 90 | Everything 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 | |
| 102 | The `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 | |
| 106 | TeamsActivity 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")] |
| 115 | public new TeamsConversationAccount? From |
| 116 | { |
| 117 | get => base.From as TeamsConversationAccount; |
| 118 | set => base.From = value; |
| 119 | } |
| 120 | |
| 121 | [JsonPropertyName("conversation")] |
| 122 | public new TeamsConversation? Conversation |
| 123 | { |
| 124 | get => base.Conversation as TeamsConversation; |
| 125 | set => base.Conversation = value!; |
| 126 | } |
| 127 | ``` |
| 128 | |
| 129 | Since `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 | |
| 131 | The `TeamsActivity(CoreActivity)` constructor stores converted types in the base slots: |
| 132 | ```csharp |
| 133 | base.From = TeamsConversationAccount.FromConversationAccount(activity.From) ?? new TeamsConversationAccount(); |
| 134 | base.Recipient = TeamsConversationAccount.FromConversationAccount(activity.Recipient) ?? new TeamsConversationAccount(); |
| 135 | base.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 |
| 145 | public 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 | |
| 156 | This 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 | |
| 168 | Both 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 | ``` |
| 180 | Declared properties (deserialized into typed fields): |
| 181 | type, channelId, id, serviceUrl, replyToId, conversation, from, recipient |
| 182 | |
| 183 | Extension 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 | ``` |
| 191 | Declared properties: |
| 192 | id, name, isTargeted, agenticAppId, agenticUserId, agenticAppBlueprintId |
| 193 | |
| 194 | Extension properties: |
| 195 | aadObjectId, userRole, userPrincipalName, givenName, surname, email, |
| 196 | tenantId, ... (anything not declared above) |
| 197 | ``` |
| 198 | |
| 199 | ### TeamsActivity JSON Fields |
| 200 | |
| 201 | ``` |
| 202 | Inherited declared (shadowed with Teams types): |
| 203 | from (TeamsConversationAccount), recipient (TeamsConversationAccount), |
| 204 | conversation (TeamsConversation) |
| 205 | |
| 206 | Inherited declared (used as-is): |
| 207 | type, channelId, id, serviceUrl, replyToId |
| 208 | |
| 209 | Promoted from Properties during construction: |
| 210 | channelData, entities, timestamp, localTimestamp, |
| 211 | locale, localTimezone, suggestedActions |
| 212 | |
| 213 | Promoted 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 | |
| 221 | Remaining 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 | ``` |
| 235 | CoreActivity |
| 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 | |
| 249 | Conversation |
| 250 | └── TeamsConversation |
| 251 | |
| 252 | ConversationAccount |
| 253 | └── TeamsConversationAccount |
| 254 | |
| 255 | CoreActivityBuilder<TActivity, TBuilder> |
| 256 | ├── CoreActivityBuilder |
| 257 | └── TeamsActivityBuilder |
| 258 | ``` |
| 259 | |
| 260 | ## Property Flow (Incoming Activity) |
| 261 | |
| 262 | ``` |
| 263 | JSON → 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 | ``` |
| 304 | Incoming activity JSON: |
| 305 | { "from": { "id": "bot1", "agenticAppId": "app-123", "agenticUserId": "user-456" } } |
| 306 | |
| 307 | CoreActivity.FromJsonStreamAsync() |
| 308 | → activity.From = ConversationAccount { Id="bot1", AgenticAppId="app-123", AgenticUserId="user-456" } |
| 309 | |
| 310 | ConversationClient.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 | |
| 328 | The copy constructor shares the Properties reference: |
| 329 | |
| 330 | ```csharp |
| 331 | Properties = activity.Properties; // Reference copy, not deep copy |
| 332 | ``` |
| 333 | |
| 334 | When `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 | |
| 338 | When `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 | |
| 346 | CoreActivity 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 | |