diff --git a/specs/activity/schema/activity-protocol.schema.json b/specs/activity/schema/activity-protocol.schema.json new file mode 100644 index 00000000..a542cd7d --- /dev/null +++ b/specs/activity/schema/activity-protocol.schema.json @@ -0,0 +1,826 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/microsoft/botframework-sdk/main/specs/activity/protocol-activity.schema.json", + "title": "Activity", + "description": "An Activity is the basic communication type for the Bot Framework 3.0 protocol.", + "type": "object", + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/definitions/message" + }, + { + "$ref": "#/definitions/contactRelationUpdate" + }, + { + "$ref": "#/definitions/conversationUpdate" + }, + { + "$ref": "#/definitions/typing" + }, + { + "$ref": "#/definitions/endOfConversation" + }, + { + "$ref": "#/definitions/event" + }, + { + "$ref": "#/definitions/invoke" + }, + { + "$ref": "#/definitions/messageUpdate" + }, + { + "$ref": "#/definitions/messageDelete" + }, + { + "$ref": "#/definitions/installationUpdate" + }, + { + "$ref": "#/definitions/messageReaction" + }, + { + "$ref": "#/definitions/suggestion" + }, + { + "$ref": "#/definitions/trace" + }, + { + "$ref": "#/definitions/handoff" + } + ], + "definitions": { + "activity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of the activity." + }, + "id": { + "type": "string", + "description": "The unique identifier for the activity." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "The UTC timestamp of the activity." + }, + "localTimestamp": { + "type": "string", + "format": "date-time", + "description": "The local timestamp of the activity." + }, + "localTimezone": { + "type": "string", + "description": "The timezone of the local timestamp." + }, + "callerId": { + "type": "string", + "description": "The caller ID." + }, + "serviceUrl": { + "type": "string", + "description": "The URL of the service." + }, + "channelId": { + "type": "string", + "description": "The ID of the channel." + }, + "from": { + "$ref": "#/definitions/channelAccount", + "description": "The sender of the activity." + }, + "conversation": { + "$ref": "#/definitions/conversationAccount", + "description": "The conversation the activity is part of." + }, + "recipient": { + "$ref": "#/definitions/channelAccount", + "description": "The recipient of the activity." + }, + "entities": { + "type": "array", + "items": { + "$ref": "#/definitions/entity" + }, + "description": "The entities of the activity." + }, + "channelData": { + "type": "object", + "description": "The channel-specific data." + }, + "replyToId": { + "type": "string", + "description": "The ID of the activity to reply to." + } + }, + "required": [ + "type", + "channelId", + "from", + "conversation", + "recipient", + "serviceUrl" + ] + }, + "messageBase": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "locale": { + "type": "string", + "description": "The locale of the activity." + }, + "text": { + "type": "string", + "description": "The text of the activity." + }, + "textFormat": { + "type": "string", + "description": "The format of the text.", + "enum": [ + "plain", + "markdown", + "xml" + ] + }, + "speak": { + "type": "string", + "description": "The text to be spoken." + }, + "inputHint": { + "type": "string", + "description": "A hint for the input.", + "enum": [ + "accepting", + "expecting", + "ignoring" + ] + }, + "summary": { + "type": "string", + "description": "A summary of the activity." + }, + "suggestedActions": { + "$ref": "#/definitions/suggestedActions", + "description": "The suggested actions for the activity." + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/attachment" + }, + "description": "The attachments of the activity." + }, + "attachmentLayout": { + "type": "string", + "description": "The layout of the attachments.", + "enum": [ + "list", + "carousel" + ] + }, + "value": { + "type": "object", + "description": "The value of the activity." + }, + "expiration": { + "type": "string", + "format": "date-time", + "description": "The expiration time of the activity." + }, + "importance": { + "type": "string", + "description": "The importance of the activity.", + "enum": [ + "low", + "normal", + "high" + ] + }, + "deliveryMode": { + "type": "string", + "description": "The delivery mode of the activity.", + "enum": [ + "normal", + "notification", + "expectReplies" + ] + }, + "listenFor": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of phrases to listen for." + }, + "semanticAction": { + "$ref": "#/definitions/semanticAction", + "description": "The semantic action of the activity." + } + } + }, + "message": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/messageBase" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "message" + ] + } + }, + "required": [ "type" ] + }, + "contactRelationUpdate": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "contactRelationUpdate" + ] + }, + "action": { + "type": "string", + "enum": [ + "add", + "remove" + ] + } + }, + "required": [ "type" ] + }, + "conversationUpdate": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "conversationUpdate" + ] + }, + "membersAdded": { + "type": "array", + "items": { + "$ref": "#/definitions/channelAccount" + }, + "description": "The members added to the conversation." + }, + "membersRemoved": { + "type": "array", + "items": { + "$ref": "#/definitions/channelAccount" + }, + "description": "The members removed from the conversation." + }, + "topicName": { + "type": "string", + "description": "The name of the topic." + }, + "historyDisclosed": { + "type": "boolean", + "description": "Indicates if the history is disclosed." + } + }, + "required": [ "type" ] + }, + "typing": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "typing" + ] + } + }, + "required": [ "type" ] + }, + "endOfConversation": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "endOfConversation" + ] + }, + "code": { + "type": "string", + "description": "The code of the activity." + }, + "text": { + "type": "string", + "description": "The text of the activity." + } + }, + "required": [ "type" ] + }, + "event": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ] + }, + "name": { + "type": "string", + "description": "The name of the activity." + }, + "value": { + "type": "object", + "description": "The value of the activity." + }, + "relatesTo": { + "$ref": "#/definitions/conversationReference", + "description": "A reference to another conversation." + } + }, + "required": [ + "type", + "name" + ] + }, + "invoke": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "invoke" + ] + }, + "name": { + "type": "string", + "description": "The name of the activity." + }, + "value": { + "type": "object", + "description": "The value of the activity." + }, + "relatesTo": { + "$ref": "#/definitions/conversationReference", + "description": "A reference to another conversation." + } + }, + "required": [ + "type", + "name" + ] + }, + "messageUpdate": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/messageBase" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "messageUpdate" + ] + } + }, + "required": [ "type" ] + }, + "messageDelete": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "messageDelete" + ] + } + }, + "required": [ "type" ] + }, + "installationUpdate": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "installationUpdate" + ] + }, + "action": { + "type": "string", + "enum": [ + "add", + "remove" + ] + } + }, + "required": [ "type" ] + }, + "messageReaction": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "messageReaction" + ] + }, + "reactionsAdded": { + "type": "array", + "items": { + "$ref": "#/definitions/messageReactionObject" + }, + "description": "The reactions added to the activity." + }, + "reactionsRemoved": { + "type": "array", + "items": { + "$ref": "#/definitions/messageReactionObject" + }, + "description": "The reactions removed from the activity." + } + }, + "required": [ "type" ] + }, + "suggestion": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/messageBase" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "suggestion" + ] + }, + "textHighlights": { + "type": "array", + "items": { + "$ref": "#/definitions/textHighlight" + }, + "description": "The text highlights of the activity." + } + }, + "required": [ "type" ] + }, + "trace": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "trace" + ] + }, + "name": { + "type": "string", + "description": "The name of the activity." + }, + "label": { + "type": "string", + "description": "The label of the activity." + }, + "valueType": { + "type": "string", + "description": "The type of the value." + }, + "value": { + "type": "object", + "description": "The value of the activity." + }, + "relatesTo": { + "$ref": "#/definitions/conversationReference", + "description": "A reference to another conversation." + } + }, + "required": [ "type" ] + }, + "handoff": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/activity" + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "handoff" + ] + } + }, + "required": [ "type" ] + }, + "channelAccount": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the channel account." + }, + "name": { + "type": "string", + "description": "The name of the channel account." + }, + "aadObjectId": { + "type": "string", + "description": "The Azure Active Directory object ID of the channel account." + }, + "role": { + "type": "string", + "description": "The role of the channel account.", + "enum": [ + "user", + "bot" + ] + } + }, + "required": [ + "id" + ] + }, + "conversationAccount": { + "type": "object", + "properties": { + "isGroup": { + "type": "boolean", + "description": "Indicates if the conversation is a group conversation." + }, + "conversationType": { + "type": "string", + "description": "The type of the conversation." + }, + "id": { + "type": "string", + "description": "The ID of the conversation." + }, + "name": { + "type": "string", + "description": "The name of the conversation." + }, + "aadObjectId": { + "type": "string", + "description": "The Azure Active Directory object ID of the conversation." + }, + "role": { + "type": "string", + "description": "The role of the conversation account.", + "enum": [ + "user", + "bot" + ] + }, + "tenantId": { + "type": "string", + "description": "The tenant ID of the conversation." + } + }, + "required": [ + "id" + ] + }, + "messageReactionObject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of the message reaction." + } + } + }, + "suggestedActions": { + "type": "object", + "properties": { + "to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The recipients of the suggested actions." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/cardAction" + }, + "description": "The suggested actions." + } + } + }, + "attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string", + "description": "The content type of the attachment." + }, + "contentUrl": { + "type": "string", + "description": "The URL of the attachment content." + }, + "content": { + "type": "object", + "description": "The content of the attachment." + }, + "name": { + "type": "string", + "description": "The name of the attachment." + }, + "thumbnailUrl": { + "type": "string", + "description": "The URL of the attachment thumbnail." + } + } + }, + "entity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of the entity." + } + } + }, + "conversationReference": { + "type": "object", + "properties": { + "activityId": { + "type": "string", + "description": "The ID of the activity." + }, + "user": { + "$ref": "#/definitions/channelAccount", + "description": "The user." + }, + "bot": { + "$ref": "#/definitions/channelAccount", + "description": "The bot." + }, + "conversation": { + "$ref": "#/definitions/conversationAccount", + "description": "The conversation." + }, + "channelId": { + "type": "string", + "description": "The ID of the channel." + }, + "serviceUrl": { + "type": "string", + "description": "The URL of the service." + }, + "locale": { + "type": "string", + "description": "The locale." + } + } + }, + "textHighlight": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to highlight." + }, + "occurrence": { + "type": "integer", + "description": "The occurrence of the text to highlight." + } + } + }, + "cardAction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of the card action.", + "enum": [ + "openUrl", + "imBack", + "postBack", + "playAudio", + "playVideo", + "showImage", + "downloadFile", + "signin", + "call", + "messageBack" + ] + }, + "title": { + "type": "string", + "description": "The title of the card action." + }, + "image": { + "type": "string", + "description": "The image of the card action." + }, + "text": { + "type": "string", + "description": "The text of the card action." + }, + "displayText": { + "type": "string", + "description": "The display text of the card action." + }, + "value": { + "type": "string", + "description": "The value of the card action." + }, + "channelData": { + "type": "object", + "description": "The channel-specific data." + } + } + }, + "semanticAction": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the semantic action." + }, + "entities": { + "type": "object", + "description": "The entities of the semantic action." + }, + "state": { + "type": "string", + "enum": [ + "start", + "continue", + "done" + ] + } + } + } + } +} \ No newline at end of file diff --git a/specs/activity/schema/validator/csharp/Program.cs b/specs/activity/schema/validator/csharp/Program.cs new file mode 100644 index 00000000..ec0e1188 --- /dev/null +++ b/specs/activity/schema/validator/csharp/Program.cs @@ -0,0 +1,130 @@ +using NJsonSchema; +using NJsonSchema.Validation; +using Newtonsoft.Json.Linq; + +static async Task LoadSchemaAsync(string schemaPath) => await JsonSchema.FromFileAsync(schemaPath); + +string schemaFile = Path.Combine(AppContext.BaseDirectory, "activity.schema.json"); +if (!File.Exists(schemaFile)) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Schema file not found: {schemaFile}"); + Console.ResetColor(); + return; +} + +// Discover example files relative to project root (navigate up to validator folder structure) +// Expect examples under ../examples/json +string? baseDir = AppContext.BaseDirectory; +// Try to locate examples directory by walking up a few levels +string? examplesDir = null; +var current = new DirectoryInfo(baseDir); +for (int i = 0; i < 6 && current != null; i++) +{ + var probe = Path.Combine(current.FullName, "examples", "json"); + if (Directory.Exists(probe)) { examplesDir = probe; break; } + current = current.Parent; +} + +if (examplesDir == null) +{ + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("Examples directory not found (expected ../examples/json). Will fall back to input.json if present."); + Console.ResetColor(); +} + +var schema = await LoadSchemaAsync(schemaFile); + +List<(string name, JToken instance)> instances = new(); + +if (examplesDir != null) +{ + foreach (var file in Directory.GetFiles(examplesDir, "*.json", SearchOption.TopDirectoryOnly).OrderBy(f => f)) + { + try + { + var text = File.ReadAllText(file); + instances.Add((Path.GetFileName(file), JToken.Parse(text))); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Failed to parse {file}: {ex.Message}"); + Console.ResetColor(); + } + } +} + +// Backward compatibility: single input.json +string singleInput = Path.Combine(AppContext.BaseDirectory, "input.json"); +if (File.Exists(singleInput)) +{ + try + { + instances.Add(("input.json", JToken.Parse(File.ReadAllText(singleInput)))); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("input.json parse error: " + ex.Message); + Console.ResetColor(); + } +} + +if (instances.Count == 0) +{ + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("No JSON instances found to validate."); + Console.ResetColor(); + return; +} + +int total = 0; +int failures = 0; + +foreach (var (name, token) in instances) +{ + total++; + var errors = schema.Validate(token); + if (errors.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[OK] {name} ({token["type"] ?? "unknown-type"})"); + Console.ResetColor(); + } + else + { + failures++; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[FAIL] {name} ({token["type"] ?? "unknown-type"}) - {errors.Count} error(s)"); + Console.ResetColor(); + int i = 1; + foreach (var e in errors) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" [{i}] {e.Kind}"); + Console.ResetColor(); + Console.WriteLine($" Path : {(string.IsNullOrEmpty(e.Path) ? "$" : "$" + e.Path)}"); + Console.WriteLine($" Detail : {e}"); + if (!string.IsNullOrEmpty(e.Property)) + Console.WriteLine($" Property: {e.Property}"); + i++; + } + } +} + +Console.WriteLine(); +Console.WriteLine($"Summary: {total - failures} valid / {failures} invalid / {total} total"); +if (failures == 0) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("All examples are valid."); + Console.ResetColor(); +} +else +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Some examples failed validation."); + Console.ResetColor(); + Environment.ExitCode = 1; +} \ No newline at end of file diff --git a/specs/activity/schema/validator/csharp/SchemaValidator.csproj b/specs/activity/schema/validator/csharp/SchemaValidator.csproj new file mode 100644 index 00000000..0f7062ce --- /dev/null +++ b/specs/activity/schema/validator/csharp/SchemaValidator.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/specs/activity/schema/validator/csharp/SchemaValidator.sln b/specs/activity/schema/validator/csharp/SchemaValidator.sln new file mode 100644 index 00000000..101d0469 --- /dev/null +++ b/specs/activity/schema/validator/csharp/SchemaValidator.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36414.22 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchemaValidator", "SchemaValidator.csproj", "{2F44ACFB-7A40-4433-8D58-476B682C7AF7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2F44ACFB-7A40-4433-8D58-476B682C7AF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F44ACFB-7A40-4433-8D58-476B682C7AF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F44ACFB-7A40-4433-8D58-476B682C7AF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F44ACFB-7A40-4433-8D58-476B682C7AF7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {833ED1E0-B359-4969-936C-1C5ED1B6803F} + EndGlobalSection +EndGlobal diff --git a/specs/activity/schema/validator/examples/json/01-message.json b/specs/activity/schema/validator/examples/json/01-message.json new file mode 100644 index 00000000..fab119d7 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/01-message.json @@ -0,0 +1,38 @@ +{ + "type": "message", + "id": "message-activity-1", + "timestamp": "2025-09-15T10:00:01.000Z", + "localTimestamp": "2025-09-15T03:00:01.000-07:00", + "localTimezone": "America/Los_Angeles", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "isGroup": true, "id": "conv-1", "name": "Project Discussion" }, + "recipient": { "id": "bot-id-1", "name": "HelpBot" }, + "text": "Hello, I need help with my order.", + "textFormat": "plain", + "locale": "en-US", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + "title": "Order #12345", + "text": "Status: Shipped" + } + } + ], + "suggestedActions": { + "actions": [ + { + "type": "imBack", + "title": "Track Package", + "value": "track order 12345" + }, + { + "type": "imBack", + "title": "Cancel Order", + "value": "cancel order 12345" + } + ] + } +} diff --git a/specs/activity/schema/validator/examples/json/02-contactRelationUpdate.json b/specs/activity/schema/validator/examples/json/02-contactRelationUpdate.json new file mode 100644 index 00000000..4385c25a --- /dev/null +++ b/specs/activity/schema/validator/examples/json/02-contactRelationUpdate.json @@ -0,0 +1,11 @@ +{ + "type": "contactRelationUpdate", + "id": "contact-relation-update-1", + "timestamp": "2025-09-15T10:00:02.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "skype", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-2" }, + "recipient": { "id": "bot-id-1", "name": "WelcomeBot" }, + "action": "add" +} diff --git a/specs/activity/schema/validator/examples/json/03-conversationUpdate.json b/specs/activity/schema/validator/examples/json/03-conversationUpdate.json new file mode 100644 index 00000000..8f31bb22 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/03-conversationUpdate.json @@ -0,0 +1,13 @@ +{ + "type": "conversationUpdate", + "id": "conversation-update-1", + "timestamp": "2025-09-15T10:00:03.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-2", "name": "Jane Smith" }, + "conversation": { "isGroup": true, "id": "conv-1", "name": "Project Discussion" }, + "recipient": { "id": "bot-id-1", "name": "HelpBot" }, + "membersAdded": [ { "id": "user-id-3", "name": "Sam Brown" } ], + "membersRemoved": [], + "topicName": "Project Discussion Kickoff" +} diff --git a/specs/activity/schema/validator/examples/json/04-typing.json b/specs/activity/schema/validator/examples/json/04-typing.json new file mode 100644 index 00000000..a085fed1 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/04-typing.json @@ -0,0 +1,10 @@ +{ + "type": "typing", + "id": "typing-1", + "timestamp": "2025-09-15T10:00:04.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "webchat", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-3" }, + "recipient": { "id": "bot-id-1", "name": "EchoBot" } +} diff --git a/specs/activity/schema/validator/examples/json/05-endOfConversation.json b/specs/activity/schema/validator/examples/json/05-endOfConversation.json new file mode 100644 index 00000000..d612a606 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/05-endOfConversation.json @@ -0,0 +1,12 @@ +{ + "type": "endOfConversation", + "id": "end-of-conv-1", + "timestamp": "2025-09-15T10:00:05.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "bot-id-1", "name": "SurveyBot" }, + "conversation": { "id": "conv-4" }, + "recipient": { "id": "user-id-4", "name": "Peter Jones" }, + "code": "completedSuccessfully", + "text": "Thanks for completing the survey!" +} diff --git a/specs/activity/schema/validator/examples/json/06-event.json b/specs/activity/schema/validator/examples/json/06-event.json new file mode 100644 index 00000000..5e6a504d --- /dev/null +++ b/specs/activity/schema/validator/examples/json/06-event.json @@ -0,0 +1,12 @@ +{ + "type": "event", + "id": "event-1", + "timestamp": "2025-09-15T10:00:06.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "webchat", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-5" }, + "recipient": { "id": "bot-id-1", "name": "LocationBot" }, + "name": "locationReceived", + "value": { "latitude": 47.6062, "longitude": -122.3321 } +} diff --git a/specs/activity/schema/validator/examples/json/07-invoke.json b/specs/activity/schema/validator/examples/json/07-invoke.json new file mode 100644 index 00000000..ca29e277 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/07-invoke.json @@ -0,0 +1,12 @@ +{ + "type": "invoke", + "id": "invoke-1", + "timestamp": "2025-09-15T10:00:07.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-6" }, + "recipient": { "id": "bot-id-1", "name": "TaskBot" }, + "name": "task/submit", + "value": { "taskId": "task-987", "data": { "comments": "All done." } } +} diff --git a/specs/activity/schema/validator/examples/json/08-messageUpdate.json b/specs/activity/schema/validator/examples/json/08-messageUpdate.json new file mode 100644 index 00000000..b4cf0870 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/08-messageUpdate.json @@ -0,0 +1,12 @@ +{ + "type": "messageUpdate", + "id": "message-activity-1", + "timestamp": "2025-09-15T10:00:08.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-1" }, + "recipient": { "id": "bot-id-1", "name": "HelpBot" }, + "text": "Hello, I need help with my order #12345.", + "locale": "en-US" +} diff --git a/specs/activity/schema/validator/examples/json/09-messageDelete.json b/specs/activity/schema/validator/examples/json/09-messageDelete.json new file mode 100644 index 00000000..44b86ce9 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/09-messageDelete.json @@ -0,0 +1,10 @@ +{ + "type": "messageDelete", + "id": "message-activity-to-delete", + "timestamp": "2025-09-15T10:00:09.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-1", "name": "John Doe" }, + "conversation": { "id": "conv-1" }, + "recipient": { "id": "bot-id-1", "name": "HelpBot" } +} diff --git a/specs/activity/schema/validator/examples/json/10-installationUpdate.json b/specs/activity/schema/validator/examples/json/10-installationUpdate.json new file mode 100644 index 00000000..09f494d6 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/10-installationUpdate.json @@ -0,0 +1,11 @@ +{ + "type": "installationUpdate", + "id": "install-update-1", + "timestamp": "2025-09-15T10:00:10.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-admin", "name": "Admin User" }, + "conversation": { "id": "conv-tenant-level" }, + "recipient": { "id": "bot-id-1", "name": "AdminBot" }, + "action": "add" +} diff --git a/specs/activity/schema/validator/examples/json/11-messageReaction.json b/specs/activity/schema/validator/examples/json/11-messageReaction.json new file mode 100644 index 00000000..1cb415fe --- /dev/null +++ b/specs/activity/schema/validator/examples/json/11-messageReaction.json @@ -0,0 +1,13 @@ +{ + "type": "messageReaction", + "id": "reaction-1", + "timestamp": "2025-09-15T10:00:11.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "msteams", + "from": { "id": "user-id-2", "name": "Jane Smith" }, + "conversation": { "id": "conv-1" }, + "recipient": { "id": "bot-id-1", "name": "HelpBot" }, + "replyToId": "message-activity-1", + "reactionsAdded": [ { "type": "like" } ], + "reactionsRemoved": [] +} diff --git a/specs/activity/schema/validator/examples/json/12-suggestion.json b/specs/activity/schema/validator/examples/json/12-suggestion.json new file mode 100644 index 00000000..6b68ad16 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/12-suggestion.json @@ -0,0 +1,13 @@ +{ + "type": "suggestion", + "id": "suggestion-1", + "timestamp": "2025-09-15T10:00:12.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "cortana", + "from": { "id": "bot-id-1", "name": "CalendarBot" }, + "conversation": { "id": "conv-7" }, + "recipient": { "id": "user-id-1", "name": "John Doe" }, + "replyToId": "original-message-id", + "text": "I can create that meeting for you.", + "textHighlights": [ { "text": "meet on Monday", "occurrence": 1 } ] +} diff --git a/specs/activity/schema/validator/examples/json/13-trace.json b/specs/activity/schema/validator/examples/json/13-trace.json new file mode 100644 index 00000000..94d6e9bb --- /dev/null +++ b/specs/activity/schema/validator/examples/json/13-trace.json @@ -0,0 +1,18 @@ +{ + "type": "trace", + "id": "trace-1", + "timestamp": "2025-09-15T10:00:13.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "emulator", + "from": { "id": "bot-id-1", "name": "DebuggingBot" }, + "conversation": { "id": "conv-debug" }, + "recipient": { "id": "user-id-dev", "name": "Developer" }, + "name": "LUIS_TRACE", + "label": "LUIS Trace", + "valueType": "https://www.luis.ai/schemas/trace", + "value": { + "query": "book a flight to paris", + "topScoringIntent": { "intent": "BookFlight", "score": 0.98 }, + "entities": [ { "type": "Location", "entity": "paris" } ] + } +} diff --git a/specs/activity/schema/validator/examples/json/14-handoff.json b/specs/activity/schema/validator/examples/json/14-handoff.json new file mode 100644 index 00000000..35e86e3d --- /dev/null +++ b/specs/activity/schema/validator/examples/json/14-handoff.json @@ -0,0 +1,11 @@ +{ + "type": "handoff", + "id": "handoff-1", + "timestamp": "2025-09-15T10:00:14.000Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "channelId": "custom-channel", + "from": { "id": "bot-id-1", "name": "TriageBot" }, + "conversation": { "id": "conv-8" }, + "recipient": { "id": "human-agent-system", "name": "Live Agent Hub" }, + "value": { "reason": "User requested human agent", "transcript": [ "User: I need to speak to a person.", "Bot: OK, I'll connect you to a live agent." ] } +} diff --git a/specs/activity/schema/validator/examples/json/15-simple-message.json b/specs/activity/schema/validator/examples/json/15-simple-message.json new file mode 100644 index 00000000..6b631190 --- /dev/null +++ b/specs/activity/schema/validator/examples/json/15-simple-message.json @@ -0,0 +1,9 @@ +{ + "type": "message", + "channelId": "emulator", + "from": { "id": "user1", "name": "User One" }, + "conversation": { "id": "conv1", "name": "Conversation 1" }, + "recipient": { "id": "bot", "name": "Bot" }, + "serviceUrl": "https://example.org", + "text": "Hello, world!" +} diff --git a/specs/activity/schema/validator/js/README.md b/specs/activity/schema/validator/js/README.md new file mode 100644 index 00000000..60eea49b --- /dev/null +++ b/specs/activity/schema/validator/js/README.md @@ -0,0 +1,106 @@ +# Activity Schema Validator (JavaScript) + +This directory contains a lightweight validation script for the Activity Protocol schema. It compiles the JSON Schema with Ajv and validates all embedded JSON examples found in `activity-examples.md`. + +## Contents + +- `validate-activity.js` – Loads `activity-protocol.schema.json`, extracts JSON code blocks from `activity-examples.md`, and validates each example. +- `README.md` – This documentation. + +## Prerequisites + +- Node.js 18+ (supports ES Modules & top-level features used here) +- Dependencies: `ajv`, `ajv-formats` + +Install (from the repo root or this folder): + +```bash +npm install ajv ajv-formats --save-dev +``` + +> If a top-level `package.json` already exists (it does in this repo), you can just install once at the root and run the script from this subfolder. + +## Running the Validator + +From this directory: + +```bash +node validate-activity.js +``` + +You can also run from the repo root: + +```bash +node specs/activity/schema/validator/js/validate-activity.js +``` + +Exit codes: +- `0` – All examples are valid. +- `1` – At least one example failed validation (errors printed to stderr). + +## How It Works + +1. Reads the schema at `../../activity-protocol.schema.json`. +2. Compiles it with Ajv (draft-07, `discriminator` support enabled, all errors collected). +3. Reads `../../activity-examples.md` and extracts every fenced block marked as: + ``` + ```json + { ... } + ``` + ``` +4. Parses each block to an object (ignoring blocks that don't parse). +5. Validates each example and prints a per-example result plus a final summary. + + +## Modifying the Schema + +When adding a new Activity type: + +1. Create a new definition under `definitions` (e.g., `"myCustomActivity"`). +2. Give it a `properties.type.const` with its unique discriminator value. +3. Add a `$ref` to it in the top-level `oneOf` array. +4. Re-run the validator to ensure examples still pass. + +For additional message-like variants: + +- Reuse the shared `messageBase` (introduced to avoid conflicting `const` inheritance) via: + ```json + "allOf": [ { "$ref": "#/definitions/messageBase" } ] + ``` + then add a `type.const` specific to your new variant. + +## Extending Example Extraction + +If you want to also validate examples stored as separate `.json` files, you could enhance `validate-activity.js`: + +```js +// Pseudocode addition +const examplesDir = path.join(__dirname, '../../examples'); +if (fs.existsSync(examplesDir)) { + for (const f of fs.readdirSync(examplesDir)) { + if (f.endsWith('.json')) { + examples.push(JSON.parse(fs.readFileSync(path.join(examplesDir,f), 'utf8'))); + } + } +} +``` + +## Troubleshooting + +| Problem | Likely Cause | Fix | +|---------|--------------|-----| +| `MODULE_NOT_FOUND: ajv` | Dependencies not installed | Run `npm install ajv ajv-formats` | +| All examples suddenly failing | Schema syntax error | Validate schema JSON (e.g. with an online validator) | +| Discriminator not selecting subtype | Missing `type.const` or absent subtype in `oneOf` | Add const + add `$ref` in `oneOf` | +| ES Module import error | Older Node.js or `type` not set | Run with Node 18+; package root may need `{ "type": "module" }` | + +## License + +See the repository's top-level `LICENSE` (MIT). + +## Contributing + +Open a PR with schema changes plus updated examples. Ensure `node validate-activity.js` returns success. + +--- +Generated documentation. diff --git a/specs/activity/schema/validator/js/validate-activity.js b/specs/activity/schema/validator/js/validate-activity.js new file mode 100644 index 00000000..4570c90c --- /dev/null +++ b/specs/activity/schema/validator/js/validate-activity.js @@ -0,0 +1,76 @@ +import fs from 'fs' +import path from 'path' +import Ajv from 'ajv' +import addFormats from 'ajv-formats' + +const __dirname = path.dirname(new URL(import.meta.url).pathname.substring(1)) + +// Initialize AJV +const ajv = new Ajv({ allErrors: true, discriminator: true }) +addFormats(ajv) + +// Load the schema +const schemaPath = path.join(__dirname, '../../activity-protocol.schema.json') +const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')) + +// Compile the schema +const validate = ajv.compile(schema) + +// Collect examples from markdown +const examples = [] +const mdExamplesPath = path.join(__dirname, '../../activity-examples.md') +if (fs.existsSync(mdExamplesPath)) { + const examplesContent = fs.readFileSync(mdExamplesPath, 'utf8') + const codeBlockRegex = /```json\n([\s\S]*?)\n```/g + let match + while ((match = codeBlockRegex.exec(examplesContent)) !== null) { + try { + examples.push({ source: 'markdown', data: JSON.parse(match[1]) }) + } catch (e) { + console.error('Failed to parse JSON example from markdown:', e) + } + } +} + +// Collect standalone JSON example files +const jsonExamplesDir = path.join(__dirname, '../examples/json') +if (fs.existsSync(jsonExamplesDir)) { + for (const file of fs.readdirSync(jsonExamplesDir)) { + if (file.toLowerCase().endsWith('.json')) { + try { + const data = JSON.parse(fs.readFileSync(path.join(jsonExamplesDir, file), 'utf8')) + examples.push({ source: file, data }) + } catch (e) { + console.error(`Failed to parse JSON example file ${file}:`, e) + } + } + } +} + +if (examples.length === 0) { + console.warn('No examples found to validate.') + process.exit(0) +} + +// Validate each example +let allValid = true +examples.forEach((example, index) => { + const valid = validate(example.data) + const label = example.data.type || 'unknown' + const origin = example.source + if (valid) { + console.log(`Example ${index + 1} [${origin}] (${label}) is valid.`) + } else { + allValid = false + console.error(`Example ${index + 1} [${origin}] (${label}) is invalid:`) + console.error(validate.errors) + } + console.log('---') +}) + +if (allValid) { + console.log('All examples are valid!') +} else { + console.error('Some examples are invalid.') + process.exit(1) +}