diff --git a/.gitignore b/.gitignore index 302cd2a2962..89daa671487 100644 --- a/.gitignore +++ b/.gitignore @@ -387,4 +387,8 @@ mochawesome-report # metrics .metrics -package-lock.json \ No newline at end of file +package-lock.json + +# add claude related ignores +.claude/ +CLAUDE.md \ No newline at end of file diff --git a/packages/cli/src/commands/models/listTemplates.ts b/packages/cli/src/commands/models/listTemplates.ts index 2e0800085c9..57343f7fc12 100644 --- a/packages/cli/src/commands/models/listTemplates.ts +++ b/packages/cli/src/commands/models/listTemplates.ts @@ -27,6 +27,7 @@ export function listAllCapabilities(): OptionItem[] { VSCapabilityOptions.empty(), VSCapabilityOptions.declarativeAgent(), TeamsAgentCapabilityOptions.basicChatbot(), + TeamsAgentCapabilityOptions.collaboratorAgent(), TeamsAgentCapabilityOptions.customCopilotRag(), // TeamsAgentCapabilityOptions.aiAgent(), VSCapabilityOptions.weatherAgentBot(), diff --git a/packages/fx-core/src/component/generator/templates/metadata/teams.ts b/packages/fx-core/src/component/generator/templates/metadata/teams.ts index 2d451268e50..3672a737ec1 100644 --- a/packages/fx-core/src/component/generator/templates/metadata/teams.ts +++ b/packages/fx-core/src/component/generator/templates/metadata/teams.ts @@ -235,5 +235,11 @@ export const teamsAgentsAndAppsTemplates: Template[] = [ language: "typescript", description: "", }, + { + id: "teams-collaborator-agent-csharp", + name: TemplateNames.TeamsCollaboratorAgent, + language: "csharp", + description: "", + }, ...teamsOtherTemplates, ]; diff --git a/packages/fx-core/src/question/scaffold/vs/createRootNode.ts b/packages/fx-core/src/question/scaffold/vs/createRootNode.ts index d22c4c3349f..757a78d6566 100644 --- a/packages/fx-core/src/question/scaffold/vs/createRootNode.ts +++ b/packages/fx-core/src/question/scaffold/vs/createRootNode.ts @@ -97,6 +97,7 @@ export function scaffoldQuestionForVS(): IQTreeNode { VSCapabilityOptions.empty(), VSCapabilityOptions.declarativeAgent(), TeamsAgentCapabilityOptions.basicChatbot(), + TeamsAgentCapabilityOptions.collaboratorAgent(), TeamsAgentCapabilityOptions.customCopilotRag(), // TeamsAgentCapabilityOptions.aiAgent(), VSCapabilityOptions.weatherAgentBot(), @@ -123,6 +124,7 @@ export function scaffoldQuestionForVS(): IQTreeNode { llmServiceNode({ enum: [ TeamsAgentCapabilityOptions.basicChatbot().id, + TeamsAgentCapabilityOptions.collaboratorAgent().id, TeamsAgentCapabilityOptions.customCopilotRag().id, // TeamsAgentCapabilityOptions.aiAgent().id, VSCapabilityOptions.weatherAgentBot().id, diff --git a/packages/tests/src/e2e/vs/TeamsCollaboratorAgent.dotnet.tests.ts b/packages/tests/src/e2e/vs/TeamsCollaboratorAgent.dotnet.tests.ts new file mode 100644 index 00000000000..573192e8591 --- /dev/null +++ b/packages/tests/src/e2e/vs/TeamsCollaboratorAgent.dotnet.tests.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * @author Quke + */ + +import { describe } from "mocha"; +import * as path from "path"; + +import { it } from "@microsoft/extra-shot-mocha"; + +import MockAzureAccountProvider from "@microsoft/m365agentstoolkit-cli/src/commonlib/azureLoginUserPassword"; +import { AzureScopes, environmentNameManager } from "@microsoft/teamsfx-core"; +import { assert } from "chai"; +import fs from "fs-extra"; +import { CliHelper } from "../../commonlib/cliHelper"; +import { EnvConstants } from "../../commonlib/constants"; +import { + getResourceGroupNameFromResourceId, + getSiteNameFromResourceId, + getWebappSettings, +} from "../../commonlib/utilities"; +import { Capability } from "../../utils/constants"; +import { + cleanUpLocalProject, + createResourceGroup, + deleteResourceGroupByName, + getSubscriptionId, + getTestFolder, + getUniqueAppName, + readContextMultiEnvV3, +} from "../commonUtils"; +import { + deleteAadAppByClientId, + deleteBot, + deleteTeamsApp, + getAadAppByClientId, + getBot, + getTeamsApp, +} from "../debug/utility"; + +describe("Teams Collaborator Agent for csharp version", function () { + const testFolder = getTestFolder(); + const subscription = getSubscriptionId(); + const appName = getUniqueAppName(); + const resourceGroupName = `${appName}-rg`; + const projectPath = path.resolve(testFolder, appName); + const envName = environmentNameManager.getDefaultEnvName(); + + after(async () => { + // clean up + let context = await readContextMultiEnvV3(projectPath, "local"); + if (context?.TEAMS_APP_ID) { + await deleteTeamsApp(context.TEAMS_APP_ID); + } + if (context?.BOT_ID) { + await deleteBot(context.BOT_ID); + await deleteAadAppByClientId(context.BOT_ID); + } + + context = await readContextMultiEnvV3(projectPath, "dev"); + if (context?.TEAMS_APP_ID) { + await deleteTeamsApp(context.TEAMS_APP_ID); + } + await deleteResourceGroupByName(resourceGroupName); + await cleanUpLocalProject(projectPath); + }); + + it( + "csharp template", + { + testPlanCaseId: 35527255, + author: "quke@microsoft.com", + }, + async function () { + // Scaffold + const myRecordAzOpenAI: Record = {}; + myRecordAzOpenAI["programming-language"] = "csharp"; + myRecordAzOpenAI["azure-openai-key"] = "fake"; + myRecordAzOpenAI["azure-openai-deployment-name"] = "fake"; + myRecordAzOpenAI["azure-openai-endpoint"] = "https://test.com"; + const options = Object.entries(myRecordAzOpenAI) + .map(([key, value]) => "--" + key + " " + value) + .join(" "); + const env = Object.assign({}, process.env); + env["TEAMSFX_CLI_DOTNET"] = "true"; + await CliHelper.createProjectWithCapability( + appName, + testFolder, + Capability.TeamsCollaboratorAgent, + env, + options + ); + + // Validate Scaffold + const indexFile = path.join(testFolder, appName, "Program.cs"); + fs.access(indexFile, fs.constants.F_OK, (err) => { + assert.notExists(err, "Program.cs should exist"); + }); + + // Local Debug (Provision) + await CliHelper.provisionProject(projectPath, "", "local", { + ...process.env, + BOT_DOMAIN: "test.ngrok.io", + BOT_ENDPOINT: "https://test.ngrok.io", + }); + console.log(`[Successfully] provision for ${projectPath}`); + + let context = await readContextMultiEnvV3(projectPath, "local"); + assert.isDefined(context, "local env file should exist"); + + // validate aad + assert.isUndefined(context.AAD_APP_OBJECT_ID, "AAD should not exist"); + + // validate teams app + assert.isDefined(context.TEAMS_APP_ID, "teams app id should be defined"); + const teamsApp = await getTeamsApp(context.TEAMS_APP_ID); + assert.equal(teamsApp?.teamsAppId, context.TEAMS_APP_ID); + + // validate bot + assert.isDefined(context.BOT_ID); + assert.isNotEmpty(context.BOT_ID); + const aadApp = await getAadAppByClientId(context.BOT_ID); + assert.isDefined(aadApp); + assert.equal(aadApp?.appId, context.BOT_ID); + const bot = await getBot(context.BOT_ID); + assert.equal(bot?.botId, context.BOT_ID); + assert.equal( + bot?.messagingEndpoint, + "https://test.ngrok.io/api/messages" + ); + + // Remote Provision + const result = await createResourceGroup(resourceGroupName, "westus"); + assert.isTrue( + result, + `failed to create resource group: ${resourceGroupName}` + ); + + await CliHelper.provisionProject(projectPath, "", "dev", { + ...process.env, + AZURE_RESOURCE_GROUP_NAME: resourceGroupName, + }); + + context = await readContextMultiEnvV3(projectPath, envName); + assert.exists(context, "env file should exist"); + + // validate teams app + assert.isDefined(context.TEAMS_APP_ID); + const remoteTeamsApp = await getTeamsApp(context.TEAMS_APP_ID); + assert.equal(remoteTeamsApp?.teamsAppId, context.TEAMS_APP_ID); + + const appServiceResourceId = + context[EnvConstants.BOT_AZURE_APP_SERVICE_RESOURCE_ID]; + assert.exists( + appServiceResourceId, + "Azure App Service resource ID should exist" + ); + + const tokenProvider = MockAzureAccountProvider; + const tokenCredential = await tokenProvider.getIdentityCredentialAsync(); + const token = (await tokenCredential?.getToken(AzureScopes))?.token; + assert.exists(token); + + const response = await getWebappSettings( + subscription, + getResourceGroupNameFromResourceId(appServiceResourceId), + getSiteNameFromResourceId(appServiceResourceId), + token as string + ); + assert.exists(response, "Web app settings should exist"); + assert.equal( + response["WEBSITE_RUN_FROM_PACKAGE"], + "1", + "Run from package should be 1" + ); + assert.equal( + response["RUNNING_ON_AZURE"], + "1", + "Running on azure should be 1" + ); + + // Remote Deploy + await CliHelper.deployAll(projectPath); + + // Validate Deploy + context = await readContextMultiEnvV3(projectPath, envName); + assert.exists(context, "env file should exist"); + } + ); +}); diff --git a/templates/vs/csharp/teams-collaborator-agent/.gitignore.tpl b/templates/vs/csharp/teams-collaborator-agent/.gitignore.tpl new file mode 100644 index 00000000000..7d62542e018 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.gitignore.tpl @@ -0,0 +1,30 @@ +# TeamsFx files +build +appPackage/build +env/.env.*.user +env/.env.local +appsettings.Development.json +appsettings.Playground.json +.deployment + +# User-specific files +*.user + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Notification local store +.notification.localstore.json +.notification.playgroundstore.json + +# devTools +devTools/ \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/README.md.tpl b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/README.md.tpl new file mode 100644 index 00000000000..0eb7b7aa1eb --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/README.md.tpl @@ -0,0 +1,133 @@ +# Collaborator Agent for Microsoft Teams + +This intelligent collaboration assistant is built with the [Microsoft Teams SDK](https://aka.ms/teamsai-v2), and showcases how to create a sophisticated bot that can analyze conversations, manage tasks, and search through chat history using advanced AI capabilities and natural language processing. + +This agent can listen to all messages in a group chat (even without being @mentioned) using RSC (Resource Specific Control) permissions defined in [App Manifest](appPackage/manifest.json). For more details, see the documentation [RSC Documentation](https://staticsint.teams.cdn.office.net/evergreen-assets/safelinks/2/atp-safelinks.html). + +## Key Features + +- πŸ“‹ **Intelligent Summarization** - Analyze conversations and provide structured summaries with proper participant attribution and topic identification +- βœ… **Action Items** - Automatically identify and create action items from team discussions with smart assignment +- πŸ” **Conversation Search** - Search through chat history using natural language queries with time-based filtering and deep linking to original messages + +## Adding Custom Capabilities + +Adding your own capabilities only requires a few steps: + +1. Copy the template folder under capabilities [template](Capabilities\Template\TemplateCapability.cs) +2. Customize your capability to do what you want (helpful to look at existing capabilities) +3. Make sure to create a CapabilityDefinition at the bottom of your main file +4. Register your capability by importing the CapabilityDefinition and adding to the definition list in [registry](Agent\CapabilityRegistry.cs) +5. The manager will automatically be instantiated with the capability you defined! + +## Agent Architecture + +![architecture](./img/architecture.png) + +## Flow of the Agent + +![flow](./img/flow.png) + +If Collab Agent is added to a groupchat or private message, it will always listen and log each message to its database. The messages are stored in an SQLite DB by the conversation ID of the given conversation. +The agent will respond whenever @mentioned in groupchats and will always respond in 1-on-1 messages. When the agent responds, the request is first passed through a manger prompt. +This manager may route to a capability based on the request--this capability returns its result back to the manager where it will be passed back to the user. + +### Running the Bot + +**Prerequisites** +> To run the agent template in your local dev machine, you will need: +> +{{#useOpenAI}} +> - an account with [OpenAI](https://platform.openai.com). +{{/useOpenAI}} +{{#useAzureOpenAI}} +> - [Azure OpenAI](https://aka.ms/oai/access) resource +{{/useAzureOpenAI}} + +### Debug agent in Microsoft 365 Agents Playground +{{#useOpenAI}} +1. Ensure your OpenAI API Key is filled in `appsettings.Playground.json`. + ``` + "OpenAI": { + "ApiKey": "" + } + ``` +{{/useOpenAI}} +{{#useAzureOpenAI}} +1. Ensure your Azure OpenAI settings are filled in `appsettings.Playground.json`. + ``` + "Azure": { + "OpenAIApiKey": "", + "OpenAIEndpoint": "", + "OpenAIDeploymentName": "" + } + ``` +{{/useAzureOpenAI}} +1. Set `Startup Item` as `Microsoft 365 Agents Playground (browser)`. +![image](https://raw.githubusercontent.com/OfficeDev/TeamsFx/dev/docs/images/visualstudio/debug/switch-to-test-tool.png) +1. Press F5, or select the Debug > Start Debugging menu in Visual Studio. +1. In Microsoft 365 Agents Playground from the launched browser, type and send anything to your agent to trigger a response. + +### Debug agent in Teams Web Client +{{#useOpenAI}} +1. Ensure your OpenAI API Key is filled in `env/.env.local.user`. + ``` + SECRET_OPENAI_API_KEY="" + ``` +{{/useOpenAI}} +{{#useAzureOpenAI}} +1. Ensure your Azure OpenAI settings are filled in `env/.env.local.user`. + ``` + SECRET_AZURE_OPENAI_API_KEY="" + AZURE_OPENAI_ENDPOINT="" + AZURE_OPENAI_DEPLOYMENT_NAME="" + ``` +{{/useAzureOpenAI}} +1. In the debug dropdown menu, select Dev Tunnels > Create A Tunnel (set authentication type to Public) or select an existing public dev tunnel. +2. Right-click the '{{NewProjectTypeName}}' project in Solution Explorer and select **Microsoft 365 Agents Toolkit > Select Microsoft 365 Account** +3. Sign in to Microsoft 365 Agents Toolkit with a **Microsoft 365 work or school account** +4. Set `Startup Item` as `Microsoft Teams (browser)`. +5. Press F5, or select Debug > Start Debugging menu in Visual Studio to start your app +
![image](https://raw.githubusercontent.com/OfficeDev/TeamsFx/dev/docs/images/visualstudio/debug/debug-button.png) +6. In the opened web browser, select Add button to install the app in Teams +7. In the chat bar, type and send anything to your agent to trigger a response. + +> For local debugging using Microsoft 365 Agents Toolkit CLI, you need to do some extra steps described in [Set up your Microsoft 365 Agents Toolkit CLI for local debugging](https://aka.ms/teamsfx-cli-debugging). +#### Sample Questions + +You can ask the Collaborator agent questions like: + +**Summarization:** +- "@Collaborator summarize yesterday's discussion" +- "@Collaborator what were the main topics from last week?" +- "@Collaborator give me an overview of recent messages" + +**Action Items:** +- "@Collaborator find action items from the past 3 days" +- "@Collaborator create a task to review the proposal by Friday" +- "@Collaborator what tasks are assigned to me?" + +**Search:** +- "@Collaborator find messages about the project deadline" +- "@Collaborator search for conversations between Alice and Bob" +- "@Collaborator locate discussions from this morning about the budget" + +## Architecture + +The Collaborator agent uses a sophisticated multi-capability architecture: + +- **Manager**: Coordinates between specialized capabilities and handles natural language time parsing +- **Summarizer**: Analyzes conversation content and provides structured summaries +- **Action Items**: Identifies tasks, manages assignments, and tracks completion +- **Search**: Performs semantic search across conversation history with citation support +- **Context Management**: Global message context handling for concurrent request support + +## Deployment + +The agent can be deployed to Azure App Service for production use. See following documentation for detailed instructions on setting up Azure resources and configuring the production environment. +- Host your app in Azure by [provision cloud resources](https://learn.microsoft.com/microsoftteams/platform/toolkit/provision) and [deploy the code to cloud](https://learn.microsoft.com/microsoftteams/platform/toolkit/deploy) +- Azure SQL Database is used to store data, you can set admin password in `env/.env.dev.user`. + +If you are trying to local debug / preview deployed version, then either of the two conditions must be met as a min-bar: +1. The user doing the operation should be an admin in the org. +2. The Entra app ID specified in webAppInfo.Id must be homed in the same tenant. diff --git a/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/launchSettings.json.tpl b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/launchSettings.json.tpl new file mode 100644 index 00000000000..b2f97546740 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/launchSettings.json.tpl @@ -0,0 +1,38 @@ +{ + "profiles": { + // Launch project within Microsoft 365 Agents Playground + "Microsoft 365 Agents Playground (browser)": { + "commandName": "Project", + "environmentVariables": { + "UPDATE_TEAMS_APP": "false", + "M365_AGENTS_PLAYGROUND_TARGET_SDK": "teams-ai-v2-dotnet" + }, + "launchTestTool": true, + "launchUrl": "http://localhost:56150", + }, + // Launch project within Teams + "Microsoft Teams (browser)": { + "commandName": "Project", + "launchUrl": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}", + }, + // Launch project within Teams without prepare app dependencies + "Microsoft Teams (browser) (skip update app)": { + "commandName": "Project", + "environmentVariables": { "UPDATE_TEAMS_APP": "false" }, + "launchUrl": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}" + }, +{{#CEAEnabled}} + // Launch project within M365 Copilot + "Microsoft 365 Copilot (browser)": { + "commandName": "Project", + "launchUrl": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${{AGENT_HINT}}?auth=2" + }, + // Launch project within M365 Copilot without prepare app dependencies + "Microsoft 365 Copilot (browser) (skip update app)": { + "commandName": "Project", + "environmentVariables": { "UPDATE_TEAMS_APP": "false" }, + "launchUrl": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${{AGENT_HINT}}?auth=2" + }, +{{/CEAEnabled}} + } +} \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.tpl b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.tpl new file mode 100644 index 00000000000..3a046dfc802 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.tpl @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.user.tpl b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.user.tpl new file mode 100644 index 00000000000..0dd7640acf8 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{NewProjectTypeName}}.{{NewProjectTypeExt}}.user.tpl @@ -0,0 +1,9 @@ + + + + ProjectDebugger + + + Microsoft Teams (browser) + + diff --git a/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{SolutionName}}.slnLaunch.user.tpl b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{SolutionName}}.slnLaunch.user.tpl new file mode 100644 index 00000000000..9d2d3bf9564 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/.{{NewProjectTypeName}}/{{SolutionName}}.slnLaunch.user.tpl @@ -0,0 +1,119 @@ +[ + { + "Name": "Microsoft 365 Agents Playground (browser)", + "Projects": [ + { + "Path": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Name": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft 365 Agents Playground (browser)" + }, + { +{{#PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}.csproj", + "Name": "{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}\\{{ProjectName}}.csproj", + "Name": "{{ProjectName}}\\{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} + "Action": "Start", + "DebugTarget": "Microsoft 365 Agents Playground" + } + ] + }, + { + "Name": "Microsoft Teams (browser)", + "Projects": [ + { + "Path": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Name": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft Teams (browser)" + }, + { +{{#PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}.csproj", + "Name": "{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}\\{{ProjectName}}.csproj", + "Name": "{{ProjectName}}\\{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} + "Action": "Start", + "DebugTarget": "Start Project" + } + ] + }, + { + "Name": "Microsoft Teams (browser) (skip update app)", + "Projects": [ + { + "Path": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Name": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft Teams (browser) (skip update app)" + }, + { +{{#PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}.csproj", + "Name": "{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}\\{{ProjectName}}.csproj", + "Name": "{{ProjectName}}\\{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} + "Action": "Start", + "DebugTarget": "Start Project" + } + ] +{{#CEAEnabled}} + }, + { + "Name": "Microsoft 365 Copilot (browser)", + "Projects": [ + { + "Path": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Name": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft 365 Copilot (browser)" + }, + { +{{#PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}.csproj", + "Name": "{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}\\{{ProjectName}}.csproj", + "Name": "{{ProjectName}}\\{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} + "Action": "Start", + "DebugTarget": "Start Project" + } + ] + }, + { + "Name": "Microsoft 365 Copilot (browser) (skip update app)", + "Projects": [ + { + "Path": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Name": "{{NewProjectTypeName}}\\{{NewProjectTypeName}}.{{NewProjectTypeExt}}", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft 365 Copilot (browser) (skip update app)" + }, + { +{{#PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}.csproj", + "Name": "{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + "Path": "{{ProjectName}}\\{{ProjectName}}.csproj", + "Name": "{{ProjectName}}\\{{ProjectName}}.csproj", +{{/PlaceProjectFileInSolutionDir}} + "Action": "Start", + "DebugTarget": "Start Project" + } + ] +{{/CEAEnabled}} + } +] \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/Agent/Manager.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Agent/Manager.cs.tpl new file mode 100644 index 00000000000..cb51cb51eea --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Agent/Manager.cs.tpl @@ -0,0 +1,220 @@ +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Agent +{ + public class ManagerResult + { + public string Response { get; set; } = string.Empty; + } + + public class Manager + { + private OpenAIChatPrompt? _prompt; + private readonly ILogger _logger; + private readonly List _capabilityDefinitions; + private bool _isInitialized = false; + + public Manager( + ILoggerFactory loggerFactory, + List capabilityDefinitions + ) + { + _logger = loggerFactory.CreateLogger(); + _capabilityDefinitions = capabilityDefinitions; + _logger.LogDebug( + "🎯 Manager instance created with {Count} capabilities", + capabilityDefinitions.Count + ); + } + + /// + /// Get capabilities list (for prompt registration) + /// + public List GetCapabilities() => _capabilityDefinitions; + + /// + /// Generate manager prompt instructions by filling in the template + /// + public string GenerateManagerPrompt(List capabilities) + { + // Create numbered list of capability names + var capabilityList = string.Join( + "\n", + capabilities.Select((cap, i) => $"{i + 1}. **{cap.Name}**") + ); + + // Create capability descriptions + var capabilityDescriptions = string.Join( + "\n", + capabilities.Select(cap => cap.ManagerDescription) + ); + + // Get template and replace placeholders + var template = ManagerPrompt.GetPromptTemplate(); + var instructions = template + .Replace("{capabilityList}", capabilityList) + .Replace("{capabilityDescriptions}", capabilityDescriptions); + + return instructions; + } + + private async Task CreateManagerPrompt(MessageContext context) + { + _logger.LogDebug( + "πŸ“‹ Creating manager prompt with {Count} capabilities", + _capabilityDefinitions.Count + ); + + var instructions = GenerateManagerPrompt(_capabilityDefinitions); + + var promptOptions = new ChatPromptOptions(); + promptOptions.WithInstructions(instructions); + + // Create Manager's own ChatModel (using lighter model) + var managerConfig = ConfigHelper.GetModelConfig("manager"); + OpenAIChatModel chatModel; +{{#useAzureOpenAI}} + chatModel = new OpenAIChatModel( + managerConfig.Model, + managerConfig.ApiKey, + new() { Endpoint = new Uri($"{managerConfig.Endpoint}/openai/v1") } + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + chatModel = new OpenAIChatModel( + managerConfig.ApiKey, + managerConfig.Model + ); +{{/useOpenAI}} + _logger.LogDebug("βœ… Manager chat model created: {Model}", managerConfig.Model); + + var prompt = new OpenAIChatPrompt(chatModel, promptOptions); + + // Register all functions using ManagerPrompt + ManagerPrompt.RegisterFunctions(prompt, context, this, _logger); + ManagerPrompt.RegisterCapabilityFunctions(prompt, context, this, _logger); + + _logger.LogDebug("βœ… Manager prompt created with all functions"); + return prompt; + } + + /// + /// Calculate time range from natural language + /// + public void CalculateTimeRange(MessageContext context, string timePhrase) + { + _logger.LogDebug($"πŸ•’ Calculating time range for: \"{timePhrase}\""); + + var timeRange = TimeRangeUtils.ExtractTimeRange(timePhrase, _logger); + + if (timeRange != null) + { + context.StartTime = timeRange.Value.From.ToString("o"); + context.EndTime = timeRange.Value.To.ToString("o"); + _logger.LogInformation( + $"βœ… Time range calculated: {context.StartTime} to {context.EndTime}" + ); + } + else + { + _logger.LogWarning($"Could not parse time phrase: \"{timePhrase}\""); + } + } + + /// + /// Clear conversation history + /// + public async Task ClearConversationHistoryAsync(MessageContext context) + { + _logger.LogDebug("Clearing conversation history"); + await context.Memory.ClearAsync(); + _logger.LogDebug("The conversation history has been cleared!"); + } + + /// + /// Delegate to a specific capability + /// + public async Task DelegateToCapability( + string capabilityName, + MessageContext context + ) + { + var capability = _capabilityDefinitions.FirstOrDefault(c => + c.Name.Equals(capabilityName, StringComparison.OrdinalIgnoreCase) + ); + + if (capability == null) + { + _logger.LogWarning($"Capability '{capabilityName}' not found"); + return $"Capability '{capabilityName}' is not available."; + } + + _logger.LogInformation($"πŸš€ Delegating to capability: {capabilityName}"); + _logger.LogDebug($"πŸ“ User request: \"{context.Text}\""); + + var result = await capability.Handler(context, _logger); + + _logger.LogInformation($"βœ… Capability {capabilityName} returned result"); + return result; + } + + private async Task InitializeAsync(MessageContext context) + { + if (!_isInitialized) + { + _logger.LogDebug("πŸ”§ Initializing Manager"); + _prompt = await CreateManagerPrompt(context); + _isInitialized = true; + _logger.LogInformation("βœ… Manager fully initialized"); + } + } + + public async Task ProcessRequestAsync(MessageContext context) + { + try + { + _logger.LogInformation( + "πŸ“¨ Processing request from user: {UserId}, text: \"{Text}\"", + context.UserId ?? "unknown", + context.Text + ); + + await InitializeAsync(context); + + if (_prompt == null) + { + throw new InvalidOperationException("Manager prompt is not initialized"); + } + + _logger.LogDebug("πŸ€– Sending request to LLM: {Text}", context.Text); + + // Manager doesn't need conversation history + // Let the LLM decide if it needs to call summarizer (which has access to memory) + var response = await _prompt.Send( + context.Text, + CancellationToken.None + ); + + _logger.LogInformation( + "βœ… LLM response received, content length: {Length} chars", + response.Content?.Length ?? 0 + ); + + return new ManagerResult { Response = response.Content ?? "No response generated" }; + } + catch (Exception error) + { + _logger.LogError(error, "❌ Error in Manager"); + return new ManagerResult + { + Response = + $"Sorry, I encountered an error processing your request: {error.Message}", + }; + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Agent/ManagerPrompt.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Agent/ManagerPrompt.cs.tpl new file mode 100644 index 00000000000..c705da1e67e --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Agent/ManagerPrompt.cs.tpl @@ -0,0 +1,113 @@ +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Agent +{ + /// + /// Manager prompt configuration and functions + /// + public static class ManagerPrompt + { + /// + /// Get the manager prompt template with placeholders + /// + public static string GetPromptTemplate() + { + return @"You are the Manager for the Collaborator β€” a Microsoft Teams bot. You coordinate requests by deciding which specialized capability should handle each @mention. + + +{capabilityList} + + +1. **If the request includes a time expression**, call calculate_time_range first using the exact phrase (e.g., ""last week"", ""past 2 days"", ""today""). +2. Analyze the request’s intent and route it to the best-matching capability. +3. If no capability applies, respond conversationally and describe what Collaborator *can* help with. + + +Use the following descriptions to determine routing logic. Match based on intent, not just keywords. + +{capabilityDescriptions} + + +When using a function call to delegate, return the capability’s response **as-is**, with no added commentary or explanation. MAKE SURE TO NOT WRAP THE RESPONSE IN QUOTES. + +βœ… GOOD: [capability response] +❌ BAD: Here’s what the Summarizer found: [capability response] + + +Be warm and helpful when the request is casual or unclear. Mention your abilities naturally. + +βœ… Hi there! I can help with summaries, task tracking, or finding specific messages. +βœ… Interesting! I specialize in conversation analysis and action items. Want help with that?"; + } + + /// + /// Register all manager functions to the prompt + /// + public static void RegisterFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + Manager manager, + ILogger logger + ) + { + // Register calculate_time_range function + prompt.Function( + "calculate_time_range", + "Parse natural language time expressions and calculate exact start/end times for time-based queries", + (string time_phrase) => + { + logger.LogDebug( + $"?? FUNCTION CALL: calculate_time_range - parsing \"{time_phrase}\"" + ); + manager.CalculateTimeRange(context, time_phrase); + return $"Time range calculated: from {context.StartTime} to {context.EndTime}"; + } + ); + + // Register clear_conversation_history function + prompt.Function( + "clear_conversation_history", + "Clear the conversation history in the database for the current conversation", + async () => + { + logger.LogDebug("??? FUNCTION CALL: clear_conversation_history"); + await manager.ClearConversationHistoryAsync(context); + return "Conversation history has been cleared successfully."; + } + ); + } + + /// + /// Register capability delegation functions + /// + public static void RegisterCapabilityFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + Manager manager, + ILogger logger + ) + { + foreach (var capability in manager.GetCapabilities()) + { + var capabilityName = capability.Name; + var capabilityDescription = capability.ManagerDescription; + + prompt.Function( + $"delegate_to_{capabilityName}", + $"Delegate to {capabilityName} capability: {capabilityDescription}", + async () => + { + logger.LogInformation($"?? FUNCTION CALL: delegate_to_{capabilityName}"); + var result = await manager.DelegateToCapability(capabilityName, context); + logger.LogDebug( + $"? Capability {capabilityName} completed, response length: {result?.Length ?? 0} chars" + ); + return result; + } + ); + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsCapability.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsCapability.cs.tpl new file mode 100644 index 00000000000..710989779db --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsCapability.cs.tpl @@ -0,0 +1,85 @@ +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.ActionItems +{ + /// + /// Action Items capability for extracting action items from conversations + /// + public class ActionItemsCapability : BaseCapability + { + public override string Name => "action_items"; + + public ActionItemsCapability(ILogger logger) + : base(logger) { } + + public override OpenAIChatPrompt CreatePrompt(MessageContext context) + { + var actionItemsConfig = ConfigHelper.GetModelConfig("actionItems"); + OpenAIChatModel model; +{{#useAzureOpenAI}} + model = new OpenAIChatModel( + actionItemsConfig.Model, + actionItemsConfig.ApiKey, + new() { Endpoint = new Uri($"{actionItemsConfig.Endpoint}/openai/v1") } + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + model = new OpenAIChatModel( + actionItemsConfig.ApiKey, + actionItemsConfig.Model + ); +{{/useOpenAI}} + + // Get instructions from ActionItemsPrompt + var instructions = ActionItemsPrompt.GetPromptInstructions(); + var prompt = new OpenAIChatPrompt( + model, + new ChatPromptOptions().WithInstructions(instructions) + ); + + // Register functions using ActionItemsPrompt + ActionItemsPrompt.RegisterFunctions(prompt, context, _logger); + + _logger.LogDebug( + "βœ… Initialized Action Items Capability using {Count} members from context", + context.Members.Count + ); + return prompt; + } + + /// + /// Create capability definition for manager registration + /// + public static CapabilityDefinition CreateDefinition(ILogger logger) + { + return new CapabilityDefinition + { + Name = "action_items", + ManagerDescription = + @"**Action Items**: Use for requests like: +- ""next steps"", ""to-do"", ""assign task"", ""my tasks"", ""what needs to be done""", + Handler = async (context, handlerLogger) => + { + // Create Action Items capability (it will create its own ChatModel) + var capability = new ActionItemsCapability(logger); + var result = await capability.ProcessRequestAsync(context); + + if (!string.IsNullOrEmpty(result.Error)) + { + handlerLogger.LogError( + "Error in Action Items Capability: {Error}", + result.Error + ); + return $"Error in Action Items Capability: {result.Error}"; + } + + return result.Response ?? "No response from Action Items Capability"; + }, + }; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsPrompt.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsPrompt.cs.tpl new file mode 100644 index 00000000000..a20d74f604e --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsPrompt.cs.tpl @@ -0,0 +1,107 @@ +using System.Text.Json; +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.ActionItems +{ + /// + /// Action Items prompt configuration and functions + /// + public static class ActionItemsPrompt + { + /// + /// Get the action items prompt instructions + /// + public static string GetPromptInstructions() + { + return @"You are the Action Items capability of the Collaborator bot. Your role is to analyze team conversations and extract a list of clear action items based on what people said. + + +Your job is to generate a concise, readable list of action items mentioned in the conversation. Focus on identifying: +- What needs to be done +- Who will do it (if mentioned) + + +- ""I'll take care of this"" +- ""Can you follow up on..."" +- ""Let's finish this by tomorrow"" +- ""We still need to decide..."" +- ""Assign this to Alex"" +- ""We should check with finance"" + + +- Return a plain text list of bullet points +- Each item should include a clear task and a person (if known) + + +- βœ… Sarah will create the draft proposal by Friday +- βœ… Alex will check budget numbers before the meeting +- βœ… Follow up with IT on access issues +- βœ… Decide final presenters by end of week + + +- If no one is assigned, just describe the task +- Skip greetings or summary text β€” just the action items +- Do not assign tasks unless the conversation suggests it + +Be clear, helpful, and concise."; + } + + /// + /// Register action items functions to the prompt + /// + public static void RegisterFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + ILogger logger + ) + { + // Register generate_action_items function + prompt.Function( + "generate_action_items", + "Generate a list of action items based on the conversation", + async () => + { + logger.LogDebug( + "πŸ“‹ Action Items Capability - Start Time: {StartTime}", + context.StartTime + ); + logger.LogDebug( + "πŸ“‹ Action Items Capability - End Time: {EndTime}", + context.EndTime + ); + + // Get messages from database by time range + var allMessages = await context.Memory.GetMessagesByTimeRangeAsync( + context.StartTime, + context.EndTime + ); + + logger.LogDebug( + "πŸ“‹ Retrieved {Count} messages for action item extraction", + allMessages.Count + ); + + // Format messages data + var messagesData = new + { + messages = allMessages + .Select(m => new + { + timestamp = m.Timestamp, + name = m.Name, + content = m.Content, + }) + .ToList(), + }; + + return JsonSerializer.Serialize( + messagesData, + new JsonSerializerOptions { WriteIndented = true } + ); + } + ); + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsSchema.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsSchema.cs.tpl new file mode 100644 index 00000000000..244bb41b319 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/ActionItems/ActionItemsSchema.cs.tpl @@ -0,0 +1,13 @@ +namespace {{SafeProjectName}}.Capability.ActionItems +{ + /// + /// Function schemas for the action items capability + /// Not needed for this capability because it has no custom functions with parameters + /// + public static class ActionItemsSchema + { + // The generate_action_items function doesn't require a schema + // because it takes no parameters (async () => {...}) + // The function directly accesses context.Memory to retrieve messages + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/BaseCapability.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/BaseCapability.cs.tpl new file mode 100644 index 00000000000..6a44d4747f8 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/BaseCapability.cs.tpl @@ -0,0 +1,59 @@ +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability +{ + public class CapabilityDefinition + { + public string Name { get; set; } = string.Empty; + public string ManagerDescription { get; set; } = string.Empty; + public Func> Handler { get; set; } = null!; + } + + public class CapabilityResult + { + public string Response { get; set; } = string.Empty; + public string? Error { get; set; } + } + + public interface ICapability + { + string Name { get; } + OpenAIChatPrompt CreatePrompt(MessageContext context); + Task ProcessRequestAsync(MessageContext context); + } + + public abstract class BaseCapability : ICapability + { + protected readonly ILogger _logger; + + public abstract string Name { get; } + + protected BaseCapability(ILogger logger) => _logger = logger; + + public abstract OpenAIChatPrompt CreatePrompt(MessageContext context); + + public virtual async Task ProcessRequestAsync(MessageContext context) + { + try + { + var prompt = CreatePrompt(context); + var response = await prompt.Send( + context.Text, + CancellationToken.None + ); + + return new CapabilityResult + { + Response = response.Content ?? "No response generated", + }; + } + catch (Exception error) + { + _logger.LogError(error, $"Error in {Name} capability"); + return new CapabilityResult { Response = string.Empty, Error = error.Message }; + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchCapability.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchCapability.cs.tpl new file mode 100644 index 00000000000..e0099e85470 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchCapability.cs.tpl @@ -0,0 +1,79 @@ +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Search +{ + /// + /// Search capability for finding specific messages in conversation history + /// + public class SearchCapability : BaseCapability + { + public override string Name => "search"; + + public SearchCapability(ILogger logger) + : base(logger) { } + + public override OpenAIChatPrompt CreatePrompt(MessageContext context) + { + var searchConfig = ConfigHelper.GetModelConfig("search"); + OpenAIChatModel model; +{{#useAzureOpenAI}} + model = new OpenAIChatModel( + searchConfig.Model, + searchConfig.ApiKey, + new() { Endpoint = new Uri($"{searchConfig.Endpoint}/openai/v1") } + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + model = new OpenAIChatModel( + searchConfig.ApiKey, + searchConfig.Model + ); +{{/useOpenAI}} + + // Get instructions from SearchPrompt + var instructions = SearchPrompt.GetPromptInstructions(); + var prompt = new OpenAIChatPrompt( + model, + new ChatPromptOptions().WithInstructions(instructions) + ); + + // Register functions using SearchPrompt + SearchPrompt.RegisterFunctions(prompt, context, _logger); + + _logger.LogDebug("βœ… Initialized Search Capability!"); + return prompt; + } + + /// + /// Create capability definition for manager registration + /// + public static CapabilityDefinition CreateDefinition(ILogger logger) + { + return new CapabilityDefinition + { + Name = "search", + ManagerDescription = + @"**Search**: Use for: +- ""find"", ""search"", ""show me"", ""conversation with"", ""where did [person] say"", ""messages from last week""", + Handler = async (context, handlerLogger) => + { + // Create Search capability (it will create its own ChatModel) + var capability = new SearchCapability(logger); + var result = await capability.ProcessRequestAsync(context); + + if (!string.IsNullOrEmpty(result.Error)) + { + handlerLogger.LogError("❌ Error in Search Capability: {Error}", result.Error); + return $"Error in Search Capability: {result.Error}"; + } + + return result.Response ?? "No response from Search Capability"; + }, + }; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchPrompt.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchPrompt.cs.tpl new file mode 100644 index 00000000000..022d40d0f55 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchPrompt.cs.tpl @@ -0,0 +1,139 @@ +using System.Text.Json; +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Search +{ + /// + /// Search prompt configuration and functions + /// + public static class SearchPrompt + { + /// + /// Get the search prompt instructions + /// + public static string GetPromptInstructions() + { + return @"You are the Search capability of the Collaborator. Your role is to help users find specific conversations or messages from their chat history. + +You can search through message history to find: +- Conversations between specific people +- Messages about specific topics +- Messages from specific time periods (time ranges will be pre-calculated by the Manager) +- Messages containing specific keywords + +When a user asks you to find something, use the search_messages function to search the database. + +RESPONSE FORMAT: +- Your search_messages function returns just the text associated with the search results +- Focus on creating a helpful, conversational summary that complements the citations +- Be specific about what was found and provide context about timing and participants +- If no results are found, suggest alternative search terms or broader criteria + +Be helpful and conversational in your responses. The user will see both your text response and interactive cards that let them jump to the original messages."; + } + + /// + /// Register search functions to the prompt + /// + public static void RegisterFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + ILogger logger + ) + { + // Register search_messages function + prompt.Function( + "search_messages", + "Search the conversation for relevant messages", + async ( + string[] keywords, + string[]? participants = null, + int max_results = 5 + ) => + { + logger.LogDebug( + "πŸ” Search Capability - Keywords: {Keywords}", + string.Join(", ", keywords) + ); + + if (participants != null && participants.Length > 0) + { + logger.LogDebug( + "πŸ” Search Capability - Participants filter: {Participants}", + string.Join(", ", participants) + ); + } + + // Get filtered messages from database + var selected = await context.Memory.GetFilteredMessagesAsync( + keywords, + context.StartTime, + context.EndTime, + participants, + max_results + ); + + logger.LogDebug("πŸ” Found {Count} matching messages", selected.Count); + + if (selected.Count == 0) + { + return "No matching messages found."; + } + + // Create citations and format results + var results = new List(); + + foreach (var msg in selected) + { + // Parse timestamp + DateTime parsedDate = DateTime.TryParse(msg.Timestamp, out var dt) + ? dt + : DateTime.UtcNow; + var date = parsedDate.ToString("g"); // Short date/time format + + var preview = + msg.Content.Length > 100 + ? msg.Content.Substring(0, 100) + "..." + : msg.Content; + + // Create deep link to the message + var deepLink = CreateDeepLink( + msg.ActivityId ?? msg.Id, + context.ConversationId + ); + + // Create citation + var citation = new CitationAppearance + { + Title = $"Message from {msg.Name}", + Url = deepLink, + Content = $"{date}: \"{preview}\"", + AppearanceIndex = context.Citations.Count + 1, + }; + + context.Citations.Add(citation); + + // Format result line + results.Add($"β€’ [{msg.Name}]({deepLink}) at {date}: \"{preview}\""); + } + + return string.Join("\n", results); + } + ); + } + + /// + /// Create a Teams deep link to a specific message + /// + private static string CreateDeepLink(string activityId, string conversationId) + { + var contextParam = Uri.EscapeDataString( + JsonSerializer.Serialize(new { contextType = "chat" }) + ); + var encodedConvId = Uri.EscapeDataString(conversationId); + return $"https://teams.microsoft.com/l/message/{encodedConvId}/{activityId}?context={contextParam}"; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchSchema.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchSchema.cs.tpl new file mode 100644 index 00000000000..0c197a73dee --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Search/SearchSchema.cs.tpl @@ -0,0 +1,75 @@ +using Json.Schema; + +namespace {{SafeProjectName}}.Capability.Search +{ + /// + /// Function schemas and parameter definitions for the search capability + /// + public static class SearchSchema + { + /// + /// Arguments for the search_messages function + /// + public class SearchMessagesArgs + { + /// + /// Keywords to search for in the message content + /// + public string[] Keywords { get; set; } = Array.Empty(); + + /// + /// Optional: list of participant names to filter messages by who said them + /// + public string[]? Participants { get; set; } + + /// + /// Optional: maximum number of results to return (default is 5) + /// + public int MaxResults { get; set; } = 5; + } + + /// + /// Get the JSON schema for search_messages function + /// This defines the parameters that the AI model can use when calling the function + /// + public static JsonSchema GetSearchMessagesSchema() + { + return new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties( + ( + "keywords", + new JsonSchemaBuilder() + .Type(SchemaValueType.Array) + .Items(new JsonSchemaBuilder().Type(SchemaValueType.String)) + .Description("Keywords to search for in the message content") + .Build() + ), + ( + "participants", + new JsonSchemaBuilder() + .Type(SchemaValueType.Array) + .Items(new JsonSchemaBuilder().Type(SchemaValueType.String)) + .Description( + "Optional: list of participant names to filter messages by who said them" + ) + .Build() + ), + ( + "max_results", + new JsonSchemaBuilder() + .Type(SchemaValueType.Integer) + .Description( + "Optional: maximum number of results to return (default is 5)" + ) + .Minimum(1) + .Maximum(50) + .Build() + ) + ) + .Required("keywords") + .AdditionalProperties(false) + .Build(); + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateCapability.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateCapability.cs.tpl new file mode 100644 index 00000000000..906fa3a1764 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateCapability.cs.tpl @@ -0,0 +1,109 @@ +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Template +{ + /// + /// Template capability - Use this as a starting point for creating new capabilities + /// + /// TEMPLATE INSTRUCTIONS: + /// 1. Copy this entire Template folder and rename it to your capability name (e.g., "MyCapability") + /// 2. Rename the class from TemplateCapability to YourCapabilityName (e.g., MyCapability) + /// 3. Update the Name property to return your capability's identifier (lowercase with underscores) + /// 4. Customize TemplatePrompt.cs with your capability's logic + /// 5. Add your capability to Program.cs (see instructions there) + /// + /// TIPS: + /// - Keep capability names short and descriptive (e.g., "summarizer", "action_items", "search") + /// - Use the logger to debug and track what your capability is doing + /// - Test with simple scenarios first before adding complexity + /// + public class TemplateCapability : BaseCapability + { + // TODO: Change this to your capability's name (lowercase, use underscores for spaces) + public override string Name => "template"; + + public TemplateCapability(ILogger logger) + : base(logger) { } + + public override OpenAIChatPrompt CreatePrompt(MessageContext context) + { + // Get model configuration for your capability + // TODO: Update "template" to match your capability name in appsettings.json + var templateConfig = ConfigHelper.GetModelConfig("template"); + + OpenAIChatModel model; +{{#useAzureOpenAI}} + model = new OpenAIChatModel( + templateConfig.Model, + templateConfig.ApiKey, + new() { Endpoint = new Uri($"{templateConfig.Endpoint}/openai/v1") } + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + model = new OpenAIChatModel( + templateConfig.ApiKey, + templateConfig.Model + ); +{{/useOpenAI}} + + // Get instructions from TemplatePrompt + var instructions = TemplatePrompt.GetPromptInstructions(); + var prompt = new OpenAIChatPrompt( + model, + new ChatPromptOptions().WithInstructions(instructions) + ); + + // Register functions using TemplatePrompt + TemplatePrompt.RegisterFunctions(prompt, context, _logger); + + _logger.LogDebug("βœ… Initialized Template Capability!"); + return prompt; + } + + /// + /// Create capability definition for manager registration + /// + /// This method is called from Program.cs to register your capability. + /// The ManagerDescription tells the Manager when to route requests to your capability. + /// + public static CapabilityDefinition CreateDefinition(ILogger logger) + { + return new CapabilityDefinition + { + // TODO: Update to match your capability name + Name = "template", + + // TODO: Describe when this capability should be used + // The Manager uses this to decide which capability handles each request + ManagerDescription = + @"**Template**: Use for requests like: +- TODO: Add keywords and phrases that indicate this capability should be used +- TODO: For example: ""template"", ""example"", ""demo"" +- TODO: Be specific so the Manager can route requests correctly", + + // Handler that processes requests for this capability + Handler = async (context, handlerLogger) => + { + // Create Template capability (it will create its own ChatModel) + var capability = new TemplateCapability(logger); + var result = await capability.ProcessRequestAsync(context); + + if (!string.IsNullOrEmpty(result.Error)) + { + handlerLogger.LogError( + "❌ Error in Template Capability: {Error}", + result.Error + ); + return $"Error in Template Capability: {result.Error}"; + } + + return result.Response ?? "No response from Template Capability"; + }, + }; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplatePrompt.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplatePrompt.cs.tpl new file mode 100644 index 00000000000..278f94118ad --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplatePrompt.cs.tpl @@ -0,0 +1,120 @@ +using System.Text.Json; +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Template +{ + /// + /// Template prompt configuration and functions + /// + /// TEMPLATE INSTRUCTIONS: + /// This file defines the system prompt and registers functions for your capability. + /// Follow these steps to customize it: + /// + /// 1. Update GetPromptInstructions() with your capability's specific instructions + /// 2. In RegisterFunctions(), register your custom functions using prompt.Function() + /// 3. Each function can access context.Memory to retrieve/store conversation data + /// + public static class TemplatePrompt + { + /// + /// Get the template prompt instructions + /// + /// TODO: Replace this with your capability's system prompt + /// This tells the AI model what role it plays and how to behave + /// + public static string GetPromptInstructions() + { + return @"You are the [CAPABILITY_NAME] capability of the Collaborator bot. + + +TODO: Describe what this capability does and when it should be used + + +1. TODO: List the steps this capability should follow +2. TODO: Explain how it should process user requests +3. TODO: Define the expected output format + + +- TODO: Specify how results should be formatted +- TODO: Include examples if helpful + + +- TODO: Any additional guidelines or constraints +- Be clear, helpful, and concise."; + } + + /// + /// Register template functions to the prompt + /// + /// TODO: Register your capability's functions here + /// Each function can be called by the AI model during conversation + /// + public static void RegisterFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + ILogger logger + ) + { + // EXAMPLE 1: Function with NO parameters (like summarizer/actionItems) + // Uncomment and customize this if your function doesn't need parameters: + /* + prompt.Function( + "my_function_name", + "Description of what this function does", + async () => + { + logger.LogDebug("πŸ”§ Template - Executing my_function_name"); + + // Access conversation messages from the database + var messages = await context.Memory.GetMessagesByTimeRangeAsync( + context.StartTime, + context.EndTime + ); + + logger.LogDebug("πŸ”§ Retrieved {Count} messages", messages.Count); + + // TODO: Process the messages and return data + var result = new + { + messageCount = messages.Count, + data = messages.Select(m => new { m.Name, m.Content }) + }; + + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + ); + */ + + // EXAMPLE 2: Function WITH parameters (like search) + // Uncomment and customize this if your function needs parameters: + /* + prompt.Function( + "my_function_with_params", + "Description of what this function does", + TemplateSchema.GetMyFunctionSchema(), // Reference the schema you defined + async (string param1, string[] param2, int param3 = 10) => + { + logger.LogDebug("πŸ”§ Template - param1: {Param1}", param1); + logger.LogDebug("πŸ”§ Template - param2: {Param2}", string.Join(", ", param2)); + + // TODO: Use the parameters to process data + var filteredMessages = await context.Memory.GetFilteredMessagesAsync( + param2, + context.StartTime, + context.EndTime, + maxResults: param3 + ); + + // TODO: Return formatted results + return $"Found {filteredMessages.Count} messages matching criteria"; + } + ); + */ + + // TODO: Add your function registration here + logger.LogWarning("⚠️ Template capability has no functions registered yet. Please implement your functions."); + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateSchema.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateSchema.cs.tpl new file mode 100644 index 00000000000..3e0d61fa7d3 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/Template/TemplateSchema.cs.tpl @@ -0,0 +1,74 @@ +using Json.Schema; + +namespace {{SafeProjectName}}.Capability.Template +{ + /// + /// Defines JSON schema for Template capability functions + /// + /// TEMPLATE INSTRUCTIONS: + /// Define the schema for your capability's functions here. + /// Each schema describes the parameters the AI model must provide. + /// + public static class TemplateSchema + { + /// + /// Arguments for the my_function_with_params function + /// + public class MyFunctionArgs + { + /// + /// TODO: Describe this parameter + /// + public string Param1 { get; set; } = string.Empty; + + /// + /// TODO: Describe this parameter + /// + public string[] Param2 { get; set; } = Array.Empty(); + + /// + /// Maximum number of results to return + /// + public int Param3 { get; set; } = 10; + } + + /// + /// Example schema for a function that retrieves messages with filters + /// TODO: Replace this with schemas for your actual functions + /// + public static JsonSchema GetMyFunctionSchema() + { + return new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties( + ( + "param1", + new JsonSchemaBuilder() + .Type(SchemaValueType.String) + .Description("TODO: Describe this parameter") + .Build() + ), + ( + "param2", + new JsonSchemaBuilder() + .Type(SchemaValueType.Array) + .Items(new JsonSchemaBuilder().Type(SchemaValueType.String)) + .Description("TODO: Describe this parameter") + .Build() + ), + ( + "param3", + new JsonSchemaBuilder() + .Type(SchemaValueType.Integer) + .Description("Maximum number of results to return") + .Minimum(1) + .Maximum(50) + .Build() + ) + ) + .Required("param1", "param2") + .AdditionalProperties(false) + .Build(); + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/Summarizer.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/Summarizer.cs.tpl new file mode 100644 index 00000000000..9fb82e0f81c --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/Summarizer.cs.tpl @@ -0,0 +1,83 @@ +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Summarizer +{ + /// + /// Summarizer capability for analyzing and summarizing conversations + /// + public class SummarizerCapability : BaseCapability + { + public override string Name => "summarizer"; + + public SummarizerCapability(ILogger logger) + : base(logger) { } + + public override OpenAIChatPrompt CreatePrompt(MessageContext context) + { + var summarizerConfig = ConfigHelper.GetModelConfig(Name); + OpenAIChatModel model; +{{#useAzureOpenAI}} + model = new OpenAIChatModel( + summarizerConfig.Model, + summarizerConfig.ApiKey, + new() { Endpoint = new Uri($"{summarizerConfig.Endpoint}/openai/v1") } + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + model = new OpenAIChatModel( + summarizerConfig.ApiKey, + summarizerConfig.Model + ); +{{/useOpenAI}} + + // Get instructions from SummarizerPrompt + var instructions = SummarizerPrompt.GetPromptInstructions(); + var prompt = new OpenAIChatPrompt( + model, + new ChatPromptOptions().WithInstructions(instructions) + ); + + // Register functions using SummarizerPrompt + SummarizerPrompt.RegisterFunctions(prompt, context, _logger); + + _logger.LogDebug("βœ… Initialized Summarizer Capability!"); + return prompt; + } + + /// + /// Create capability definition for manager registration + /// + public static CapabilityDefinition CreateDefinition(ILogger logger) + { + return new CapabilityDefinition + { + Name = "summarizer", + ManagerDescription = + @"**Summarizer**: Use for keywords like: +- 'summarize', 'overview', 'recap', 'conversation history' +- 'what did we discuss', 'catch me up', 'who said what', 'recent messages'", + Handler = async (context, handlerLogger) => + { + // Create Summarizer capability (it will create its own ChatModel) + var capability = new SummarizerCapability(logger); + var result = await capability.ProcessRequestAsync(context); + + if (!string.IsNullOrEmpty(result.Error)) + { + handlerLogger.LogError( + "Error in Summarizer Capability: {Error}", + result.Error + ); + return $"Error in Summarizer Capability: {result.Error}"; + } + + return result.Response ?? "No response from Summarizer Capability"; + }, + }; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerPrompt.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerPrompt.cs.tpl new file mode 100644 index 00000000000..8b0ce14cf14 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerPrompt.cs.tpl @@ -0,0 +1,100 @@ +using System.Text.Json; +using {{SafeProjectName}}.Models; +using Microsoft.Teams.AI.Models.OpenAI; +using Microsoft.Teams.AI.Prompts; + +namespace {{SafeProjectName}}.Capability.Summarizer +{ + /// + /// Summarizer prompt configuration and functions + /// + public static class SummarizerPrompt + { + /// + /// Get the summarizer prompt instructions + /// + public static string GetPromptInstructions() + { + return @"You are the Summarizer capability of the Collaborator that specializes in analyzing conversations between groups of people. +Your job is to retrieve and analyze conversation messages, then provide structured summaries with proper attribution. + + +The system uses the user's actual timezone from Microsoft Teams for all time calculations. +Time ranges will be pre-calculated by the Manager and passed to you as ISO timestamps when needed. + + +1. Use the appropriate function to retrieve the messages you need based on the user's request +2. If time ranges are specified in the request, they will be pre-calculated and provided as ISO timestamps +3. If no specific timespan is mentioned, default to the last 24 hours using get_messages_by_time_range +4. Analyze the retrieved messages and identify participants and topics +5. Return a BRIEF summary with proper participant attribution +6. Include participant names in your analysis and summary points +7. Be concise and focus on the key topics discussed + + +- Use bullet points for main topics +- Include participant names when attributing ideas or statements +- Provide a brief overview if requested"; + } + + /// + /// Register summarizer functions to the prompt + /// + public static void RegisterFunctions( + OpenAIChatPrompt prompt, + MessageContext context, + ILogger logger + ) + { + // Register summarize_conversation function + prompt.Function( + "summarize_conversation", + "Summarize the conversation history from the database within the specified time range", + async () => + { + logger.LogDebug( + "?? Summarizer Capability - Start Time: {StartTime}", + context.StartTime + ); + logger.LogDebug( + "?? Summarizer Capability - End Time: {EndTime}", + context.EndTime + ); + + // Get messages from database by time range + var allMessages = await context.Memory.GetMessagesByTimeRangeAsync( + context.StartTime, + context.EndTime + ); + + logger.LogDebug( + "?? Retrieved {Count} messages from database", + allMessages.Count + ); + + // Format conversation data + var conversationData = new + { + members = context.Members.Select(m => m.Name).ToList(), + timeRange = new { start = context.StartTime, end = context.EndTime }, + messageCount = allMessages.Count, + messages = allMessages + .Select(m => new + { + role = m.Role, + name = m.Name, + content = m.Content, + timestamp = m.Timestamp, + }) + .ToList(), + }; + + return JsonSerializer.Serialize( + conversationData, + new JsonSerializerOptions { WriteIndented = true } + ); + } + ); + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerSchema.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerSchema.cs.tpl new file mode 100644 index 00000000000..c729cae0098 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Capability/summarizer/SummarizerSchema.cs.tpl @@ -0,0 +1,13 @@ +namespace {{SafeProjectName}}.Capability.Summarizer +{ + /// + /// Function schemas for the summarizer capability + /// Not needed for this capability because it has no custom functions with parameters + /// + public static class SummarizerSchema + { + // The summarize_conversation function doesn't require a schema + // because it takes no parameters (async () => {...}) + // The function directly accesses context.Memory to retrieve messages + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Config.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Config.cs.tpl new file mode 100644 index 00000000000..74170a44c06 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Config.cs.tpl @@ -0,0 +1,59 @@ +namespace {{SafeProjectName}} +{ + public class ConfigOptions + { + public TeamsConfigOptions Teams { get; set; } = new(); +{{#useAzureOpenAI}} + public AzureConfigOptions Azure { get; set; } = new(); +{{/useAzureOpenAI}} +{{#useOpenAI}} + public OpenAIConfigOptions OpenAI { get; set; } = new(); +{{/useOpenAI}} + public DatabaseConfigOptions Database { get; set; } = new(); + public string? RunningOnAzure { get; set; } + } + + public class TeamsConfigOptions + { + public string BotType { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + } +{{#useAzureOpenAI}} + /// + /// Options for Azure OpenAI + /// + public class AzureConfigOptions + { + public string OpenAIApiKey { get; set; } = string.Empty; + public string OpenAIEndpoint { get; set; } = string.Empty; + public string OpenAIDeploymentName { get; set; } = string.Empty; + } +{{/useAzureOpenAI}} +{{#useOpenAI}} + /// + /// Options for OpenAI + /// + public class OpenAIConfigOptions + { + public string ApiKey { get; set; } = string.Empty; + public string DefaultModel { get; set; } = "gpt-4o"; + public string? Endpoint { get; set; } + } +{{/useOpenAI}} + + /// + /// Database configuration options + /// + public class DatabaseConfigOptions + { + public string Type { get; set; } = "sqlite"; + public string? ConnectionString { get; set; } + public string? Server { get; set; } + public string? Database { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? SqlitePath { get; set; } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Controllers/Controller.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Controllers/Controller.cs.tpl new file mode 100644 index 00000000000..7a4b5e1f00c --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Controllers/Controller.cs.tpl @@ -0,0 +1,152 @@ +using System.Text.Json; +using {{SafeProjectName}}.Agent; +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Storage; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Api.Entities; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Activities.Invokes; +using Microsoft.Teams.Apps.Annotations; +using MessageActivity = Microsoft.Teams.Api.Activities.MessageActivity; + +namespace {{SafeProjectName}}.Controllers +{ + [TeamsController] + public class Controller( + ILogger logger, + ILoggerFactory loggerFactory, + IDatabase database, + List capabilityDefinitions + ) + { + [Message] + public async Task OnMessage(IContext context) + { + var activity = context.Activity; + + var botMentioned = activity.Entities?.Any(e => e is MentionEntity) ?? false; + var shouldProcess = activity.Conversation.IsGroup != true || botMentioned; + + var messageContext = await MessageContextFactory.CreateAsync( + context, + database, + logger: logger, + includeMembers: shouldProcess && activity.Conversation.IsGroup == true + ); + + var trackedMessages = MessageUtils.CreateMessageRecords(new[] { activity }); + + if (shouldProcess) + { + var manager = new Manager(loggerFactory, capabilityDefinitions); + var result = await manager.ProcessRequestAsync(messageContext); + + var finalizedMessage = MessageUtils.FinalizePromptResponse( + result.Response, + messageContext, + logger + ); + + var response = await context.Send(finalizedMessage); + trackedMessages = MessageUtils.CreateMessageRecords(new[] { activity, response }); + } + + await messageContext.Memory.AddMessagesAsync(trackedMessages); + logger.LogDebug( + "Saved {Count} messages to conversation {ConversationId}", + trackedMessages.Count, + messageContext.ConversationId + ); + } + + [Microsoft.Teams.Apps.Activities.Invokes.Message.SubmitAction] + public async Task OnSubmitAction(IContext context) + { + try + { + var actionValue = context.Activity.Value.ActionValue; + + string reaction = "unknown"; + object? feedbackJson = null; + + if (actionValue != null) + { + try + { + var json = JsonSerializer.Serialize(actionValue); + var dict = JsonSerializer.Deserialize>(json); + + if (dict != null) + { + if (dict.TryGetValue("reaction", out var reactionObj)) + { + reaction = reactionObj?.ToString() ?? "unknown"; + } + + if (dict.TryGetValue("feedback", out var feedbackObj)) + { + feedbackJson = feedbackObj; + } + } + } + catch + { + logger.LogWarning("Failed to parse submit action value payload"); + } + } + + if (string.IsNullOrEmpty(context.Activity.ReplyToId)) + { + logger.LogWarning( + "No replyToId found for messageId {MessageId}", + context.Activity.Id + ); + return; + } + + var success = await database.RecordFeedbackAsync( + context.Activity.ReplyToId, + reaction, + feedbackJson + ); + + if (success) + { + logger.LogDebug( + "Recorded feedback for message {ReplyToId}", + context.Activity.ReplyToId + ); + } + else + { + logger.LogWarning( + "Failed to record feedback for message {ReplyToId}", + context.Activity.ReplyToId + ); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing feedback: {ErrorMessage}", ex.Message); + } + } + + [Conversation.MembersAdded] + public async Task OnMembersAdded(IContext context) + { + foreach (var member in context.Activity.MembersAdded) + { + if (member.Id != context.Activity.Recipient.Id) + { + await context.Send( + "Hi! I'm the Collab Agent. I'll listen to the conversation and can provide summaries, action items, or search for a message when asked." + ); + } + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Models/MessageContext.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Models/MessageContext.cs.tpl new file mode 100644 index 00000000000..e53b0a51484 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Models/MessageContext.cs.tpl @@ -0,0 +1,143 @@ +using {{SafeProjectName}}.Storage; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Apps; + +namespace {{SafeProjectName}}.Models +{ + public class ConversationMember + { + public string Name { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + } + + public class CitationAppearance + { + public string Title { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public int AppearanceIndex { get; set; } + } + + public class MessageContext + { + public string Text { get; set; } = string.Empty; + public string ConversationId { get; set; } = string.Empty; + public string? UserId { get; set; } + public string UserName { get; set; } = string.Empty; + public string Timestamp { get; set; } = string.Empty; + public bool IsPersonalChat { get; set; } + public string ActivityId { get; set; } = string.Empty; + public List Members { get; set; } = new(); + public ConversationMemory Memory { get; set; } = null!; + public string StartTime { get; set; } = string.Empty; + public string EndTime { get; set; } = string.Empty; + public List Citations { get; set; } = new(); + public IContext TeamsContext { get; set; } = null!; + } + + public static class MessageContextFactory + { + public static async Task CreateAsync( + IContext teamsContext, + IDatabase database, + ILogger? logger = null, + bool includeMembers = false + ) + { + var activity = teamsContext.Activity; + + var text = activity.Text ?? string.Empty; + var conversationId = activity.Conversation.Id; + var userId = activity.From?.Id; + var userName = activity.From?.Name ?? "User"; + var timestamp = activity.Timestamp?.ToString("o") ?? DateTime.UtcNow.ToString("o"); + var isPersonalChat = activity.Conversation.IsGroup != true; + var activityId = activity.Id ?? string.Empty; + + var members = includeMembers + ? await GetConversationMembersAsync(teamsContext, logger) + : new List(); + + var memory = new ConversationMemory(database, conversationId); + + var now = DateTime.UtcNow; + var startTime = now.AddDays(-1).ToString("o"); + var endTime = now.ToString("o"); + + return new MessageContext + { + Text = text, + ConversationId = conversationId, + UserId = userId, + UserName = userName, + Timestamp = timestamp, + IsPersonalChat = isPersonalChat, + ActivityId = activityId, + Members = members, + Memory = memory, + StartTime = startTime, + EndTime = endTime, + Citations = new List(), + TeamsContext = teamsContext, + }; + } + + public static async Task> GetConversationMembersAsync( + IContext context, + ILogger? logger = null + ) + { + try + { + if (context.Activity.Conversation.IsGroup != true) + { + return new List + { + new ConversationMember + { + Name = context.Activity.From?.Name ?? "User", + Id = context.Activity.From?.Id ?? string.Empty, + }, + }; + } + + var members = new List(); + + if (context.Activity.From != null) + { + members.Add( + new ConversationMember + { + Name = context.Activity.From.Name ?? "User", + Id = context.Activity.From.Id ?? string.Empty, + } + ); + } + + if (context.Activity.Recipient != null) + { + members.Add( + new ConversationMember + { + Name = context.Activity.Recipient.Name ?? "Bot", + Id = context.Activity.Recipient.Id ?? string.Empty, + } + ); + } + + logger?.LogDebug( + "Retrieved {Count} members for conversation {ConversationId}", + members.Count, + context.Activity.Conversation.Id + ); + + return members; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to retrieve conversation members"); + return new List(); + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Program.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Program.cs.tpl new file mode 100644 index 00000000000..e47f9080d0b --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Program.cs.tpl @@ -0,0 +1,102 @@ +using Azure.Core; +using Azure.Identity; +using {{SafeProjectName}}; +using {{SafeProjectName}}.Capability; +using {{SafeProjectName}}.Capability.ActionItems; +using {{SafeProjectName}}.Capability.Search; +using {{SafeProjectName}}.Capability.Summarizer; +using {{SafeProjectName}}.Controllers; +using {{SafeProjectName}}.Storage; +using {{SafeProjectName}}.Utils; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Extensions; +using Microsoft.Teams.Extensions.Logging; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +using TeamsLogging = Microsoft.Teams.Common.Logging; + +var builder = WebApplication.CreateBuilder(args); +var config = builder.Configuration.Get(); + +if (config == null) +{ + throw new InvalidOperationException("Missing configuration for ConfigOptions"); +} + +var teamsLogger = new TeamsLogging.ConsoleLogger("collaborator", TeamsLogging.LogLevel.Debug); +builder.Logging.AddTeams(teamsLogger); +builder.Services.AddSingleton(teamsLogger); + +ConfigHelper.Initialize(config); +var appBuilder = App.Builder(new AppOptions { Logger = teamsLogger }); + +if (config.Teams.BotType == "UserAssignedMsi") +{ + appBuilder.AddCredentials( + new TokenCredentials( + config.Teams.ClientId ?? string.Empty, + async (tenantId, scopes) => + { + var clientId = config.Teams.ClientId; + var managedIdentityCredential = new ManagedIdentityCredential(clientId); + var tokenRequestContext = new TokenRequestContext(scopes, tenantId: tenantId); + var accessToken = await managedIdentityCredential.GetTokenAsync(tokenRequestContext); + return new TokenResponse { TokenType = "Bearer", AccessToken = accessToken.Token }; + } + ) + ); +} + +builder.Services.AddSingleton(sp => +{ + var loggerFactory = sp.GetRequiredService(); + var dbLogger = loggerFactory.CreateLogger("Database"); + return StorageFactory.CreateStorageAsync(dbLogger).GetAwaiter().GetResult(); +}); + +builder.Services.AddSingleton>(sp => +{ + var loggerFactory = sp.GetRequiredService(); + + var summarizerLogger = loggerFactory.CreateLogger(); + var actionItemsLogger = loggerFactory.CreateLogger(); + var searchLogger = loggerFactory.CreateLogger(); + + var capabilities = new List + { + SummarizerCapability.CreateDefinition(summarizerLogger), + ActionItemsCapability.CreateDefinition(actionItemsLogger), + SearchCapability.CreateDefinition(searchLogger), + + // ============================================================================ + // TO ADD A NEW CAPABILITY: + // ============================================================================ + // 1. Copy the Capability/Template folder and rename it (e.g., "MyCapability"). + // 2. Update the capability files following the TODO comments in Template. + // 3. Add a using statement at the top of this file: + // using {{SafeProjectName}}.Capability.MyCapability; + // 4. Create a logger for your capability: + // var myCapabilityLogger = loggerFactory.CreateLogger(); + // 5. Add your capability definition to this list: + // MyCapability.CreateDefinition(myCapabilityLogger), + // 6. Update appsettings.json to include model config for your capability. + // ============================================================================ + + // EXAMPLE - Uncomment these lines to enable the Template capability: + // var templateLogger = loggerFactory.CreateLogger(); + // TemplateCapability.CreateDefinition(templateLogger), + }; + + return capabilities; +}); + +builder.Services.AddSingleton(); + +builder.AddTeams(appBuilder); +var app = builder.Build(); +var logger = app.Services.GetRequiredService>(); +ConfigHelper.ValidateEnvironment(logger); +ConfigHelper.LogModelConfigs(logger); +app.UseTeams(); +app.Run(); diff --git a/templates/vs/csharp/teams-collaborator-agent/Properties/launchSettings.json.tpl b/templates/vs/csharp/teams-collaborator-agent/Properties/launchSettings.json.tpl new file mode 100644 index 00000000000..8244557a48c --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Properties/launchSettings.json.tpl @@ -0,0 +1,66 @@ +{ + "profiles": { +{{^isNewProjectTypeEnabled}} + // Debug project within Microsoft 365 Agents Playground + "Microsoft 365 Agents Playground (browser)": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchTestTool": true, + "launchUrl": "http://localhost:56150", + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Playground", + "TEAMSFX_NOTIFICATION_STORE_FILENAME": ".notification.playgroundstore.json", + "UPDATE_TEAMS_APP": "false" + }, + "hotReloadProfile": "aspnetcore" + }, + // Debug project within Teams + "Microsoft Teams (browser)": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}", + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "hotReloadProfile": "aspnetcore" + }, + //// Uncomment following profile to debug project only (without launching Teams) + //, + //"Start Project (not in Teams)": { + // "commandName": "Project", + // "dotnetRunMessages": true, + // "applicationUrl": "https://localhost:7130;http://localhost:5130", + // "environmentVariables": { + // "ASPNETCORE_ENVIRONMENT": "Development" + // }, + // "hotReloadProfile": "aspnetcore" + //} +{{/isNewProjectTypeEnabled}} +{{#isNewProjectTypeEnabled}} + "Microsoft 365 Agents Playground": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Playground", + "TEAMSFX_NOTIFICATION_STORE_FILENAME": ".notification.playgroundstore.json", + "UPDATE_TEAMS_APP": "false" + }, + "hotReloadProfile": "aspnetcore" + }, + "Start Project": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "hotReloadProfile": "aspnetcore" + }, +{{/isNewProjectTypeEnabled}} + } +} \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/ConversationMemory.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/ConversationMemory.cs.tpl new file mode 100644 index 00000000000..0f56809f232 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/ConversationMemory.cs.tpl @@ -0,0 +1,47 @@ +namespace {{SafeProjectName}}.Storage +{ + public class ConversationMemory + { + private readonly IDatabase _store; + private readonly string _conversationId; + + public ConversationMemory(IDatabase store, string conversationId) + { + _store = store; + _conversationId = conversationId; + } + + public Task AddMessagesAsync(IEnumerable messages) => + _store.AddMessagesAsync(messages); + + public Task> ValuesAsync() => _store.GetAsync(_conversationId); + + public Task LengthAsync() => _store.CountMessagesAsync(_conversationId); + + public Task ClearAsync() => _store.ClearConversationAsync(_conversationId); + + public Task> GetMessagesByTimeRangeAsync( + string startTime, + string endTime + ) => _store.GetMessagesByTimeRangeAsync(_conversationId, startTime, endTime); + + public Task> GetRecentMessagesAsync(int limit = 10) => + _store.GetRecentMessagesAsync(_conversationId, limit); + + public Task> GetFilteredMessagesAsync( + string[] keywords, + string startTime, + string endTime, + string[]? participants = null, + int? maxResults = null + ) => + _store.GetFilteredMessagesAsync( + _conversationId, + keywords, + startTime, + endTime, + participants, + maxResults + ); + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/IDatabase.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/IDatabase.cs.tpl new file mode 100644 index 00000000000..e4fbf4c4f7e --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/IDatabase.cs.tpl @@ -0,0 +1,29 @@ +namespace {{SafeProjectName}}.Storage +{ + public interface IDatabase + { + Task InitializeAsync(); + Task ClearAllAsync(); + Task> GetAsync(string conversationId); + Task> GetMessagesByTimeRangeAsync( + string conversationId, + string startTime, + string endTime + ); + Task> GetRecentMessagesAsync(string conversationId, int limit = 10); + Task ClearConversationAsync(string conversationId); + Task AddMessagesAsync(IEnumerable messages); + Task CountMessagesAsync(string conversationId); + Task ClearAllMessagesAsync(); + Task> GetFilteredMessagesAsync( + string conversationId, + string[] keywords, + string startTime, + string endTime, + string[]? participants = null, + int? maxResults = null + ); + Task RecordFeedbackAsync(string replyToId, string reaction, object? feedbackJson = null); + Task CloseAsync(); + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/MessageRecord.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/MessageRecord.cs.tpl new file mode 100644 index 00000000000..fa94d7303b3 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/MessageRecord.cs.tpl @@ -0,0 +1,15 @@ +namespace {{SafeProjectName}}.Storage +{ + public class MessageRecord + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string ConversationId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string ActivityId { get; set; } = string.Empty; + public string Timestamp { get; set; } = DateTime.UtcNow.ToString("o"); + public Dictionary? Metadata { get; set; } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/MssqlDatabase.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/MssqlDatabase.cs.tpl new file mode 100644 index 00000000000..17e14603a13 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/MssqlDatabase.cs.tpl @@ -0,0 +1,499 @@ +using System.Data; +using System.Text.Json; +using {{SafeProjectName}}.Utils; +using Microsoft.Data.SqlClient; + +namespace {{SafeProjectName}}.Storage +{ + public class MssqlDatabase : IDatabase + { + private readonly ILogger _logger; + private readonly DatabaseConfigOptions _config; + private SqlConnection? _connection; + private bool _isInitialized; + + public MssqlDatabase(ILogger logger, DatabaseConfigOptions config) + { + _logger = logger; + _config = config; + } + + public async Task InitializeAsync() + { + if (_isInitialized) + { + return; + } + + try + { + var connectionString = ResolveConnectionString(); + + _connection = new SqlConnection(connectionString); + await _connection.OpenAsync(); + + await InitializeDatabaseAsync(); + _isInitialized = true; + + _logger.LogDebug("Connected to MSSQL database"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error connecting to MSSQL database"); + throw; + } + } + + private string ResolveConnectionString() + { + if (!string.IsNullOrEmpty(_config.ConnectionString)) + { + return _config.ConnectionString; + } + + var builder = new SqlConnectionStringBuilder + { + DataSource = _config.Server, + InitialCatalog = _config.Database, + UserID = _config.Username, + Password = _config.Password, + Encrypt = true, + TrustServerCertificate = false, + }; + + return builder.ConnectionString; + } + + private async Task InitializeDatabaseAsync() + { + if (_connection == null) + { + throw new InvalidOperationException("Database not connected"); + } + + try + { + await using var command = _connection.CreateCommand(); + + command.CommandText = + @"IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='conversations' AND xtype='U') +BEGIN + CREATE TABLE conversations ( + id INT IDENTITY(1,1) PRIMARY KEY, + conversation_id NVARCHAR(255) NOT NULL, + role NVARCHAR(50) NOT NULL, + name NVARCHAR(255) NOT NULL, + content NVARCHAR(MAX) NOT NULL, + activity_id NVARCHAR(255) NOT NULL, + timestamp NVARCHAR(50) NOT NULL, + blob NVARCHAR(MAX) NOT NULL + ) +END"; + await command.ExecuteNonQueryAsync(); + + command.CommandText = + @"IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_conversation_id' AND object_id = OBJECT_ID('conversations')) +BEGIN + CREATE INDEX idx_conversation_id ON conversations(conversation_id) +END"; + await command.ExecuteNonQueryAsync(); + + command.CommandText = + @"IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='feedback' AND xtype='U') +BEGIN + CREATE TABLE feedback ( + id INT IDENTITY(1,1) PRIMARY KEY, + reply_to_id NVARCHAR(255) NOT NULL, + reaction NVARCHAR(50) NOT NULL CHECK (reaction IN ('like','dislike')), + feedback NVARCHAR(MAX), + created_at DATETIME NOT NULL DEFAULT GETDATE() + ) +END"; + await command.ExecuteNonQueryAsync(); + + _logger.LogDebug("MSSQL database tables initialized"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing MSSQL tables"); + throw; + } + } + + public async Task ClearAllAsync() + { + EnsureConnection(); + + try + { + await using var command = _connection!.CreateCommand(); + command.CommandText = "DELETE FROM conversations"; + await command.ExecuteNonQueryAsync(); + + _logger.LogDebug("Cleared all conversations from MSSQL store"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing all conversations"); + throw; + } + } + + public async Task> GetAsync(string conversationId) + { + EnsureConnection(); + + try + { + var messages = new List(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = + "SELECT blob FROM conversations WHERE conversation_id = @conversationId ORDER BY timestamp ASC"; + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) { Value = conversationId } + ); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving messages"); + return new List(); + } + } + + public async Task> GetMessagesByTimeRangeAsync( + string conversationId, + string startTime, + string endTime + ) + { + EnsureConnection(); + + try + { + var messages = new List(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = + @"SELECT blob FROM conversations +WHERE conversation_id = @conversationId + AND timestamp >= @startTime + AND timestamp <= @endTime +ORDER BY timestamp ASC"; + + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) { Value = conversationId } + ); + command.Parameters.Add( + new SqlParameter("@startTime", SqlDbType.NVarChar) { Value = startTime } + ); + command.Parameters.Add( + new SqlParameter("@endTime", SqlDbType.NVarChar) { Value = endTime } + ); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving messages by time range"); + return new List(); + } + } + + public async Task> GetRecentMessagesAsync( + string conversationId, + int limit = 10 + ) + { + var messages = await GetAsync(conversationId); + return messages.TakeLast(limit).ToList(); + } + + public async Task ClearConversationAsync(string conversationId) + { + EnsureConnection(); + + try + { + await using var command = _connection!.CreateCommand(); + command.CommandText = + "DELETE FROM conversations WHERE conversation_id = @conversationId"; + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) { Value = conversationId } + ); + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing conversation"); + throw; + } + } + + public async Task AddMessagesAsync(IEnumerable messages) + { + EnsureConnection(); + + await using var transaction = await _connection!.BeginTransactionAsync(); + + try + { + foreach (var message in messages) + { + await using var command = _connection.CreateCommand(); + command.Transaction = transaction as SqlTransaction; + command.CommandText = + @"INSERT INTO conversations (conversation_id, role, name, content, activity_id, timestamp, blob) +VALUES (@conversationId, @role, @name, @content, @activityId, @timestamp, @blob)"; + + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) + { + Value = message.ConversationId, + } + ); + command.Parameters.Add( + new SqlParameter("@role", SqlDbType.NVarChar) { Value = message.Role } + ); + command.Parameters.Add( + new SqlParameter("@name", SqlDbType.NVarChar) { Value = message.Name } + ); + command.Parameters.Add( + new SqlParameter("@content", SqlDbType.NVarChar) { Value = message.Content } + ); + command.Parameters.Add( + new SqlParameter("@activityId", SqlDbType.NVarChar) + { + Value = message.ActivityId, + } + ); + command.Parameters.Add( + new SqlParameter("@timestamp", SqlDbType.NVarChar) + { + Value = message.Timestamp, + } + ); + command.Parameters.Add( + new SqlParameter("@blob", SqlDbType.NVarChar) + { + Value = JsonSerializer.Serialize(message), + } + ); + + await command.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task CountMessagesAsync(string conversationId) + { + EnsureConnection(); + + try + { + await using var command = _connection!.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM conversations WHERE conversation_id = @conversationId"; + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) { Value = conversationId } + ); + + var result = await command.ExecuteScalarAsync(); + return result != null ? Convert.ToInt32(result) : 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error counting messages"); + return 0; + } + } + + public Task ClearAllMessagesAsync() => ClearAllAsync(); + + public async Task> GetFilteredMessagesAsync( + string conversationId, + string[] keywords, + string startTime, + string endTime, + string[]? participants = null, + int? maxResults = null + ) + { + EnsureConnection(); + + try + { + var messages = new List(); + var limit = maxResults ?? 5; + + await using var command = _connection!.CreateCommand(); + + var whereClauses = new List + { + "conversation_id = @conversationId", + "timestamp >= @startTime", + "timestamp <= @endTime", + }; + + command.Parameters.Add( + new SqlParameter("@conversationId", SqlDbType.NVarChar) { Value = conversationId } + ); + command.Parameters.Add( + new SqlParameter("@startTime", SqlDbType.NVarChar) { Value = startTime } + ); + command.Parameters.Add( + new SqlParameter("@endTime", SqlDbType.NVarChar) { Value = endTime } + ); + + if (keywords.Length > 0) + { + var keywordConditions = new List(); + for (int i = 0; i < keywords.Length; i++) + { + var paramName = $"@keyword{i}"; + keywordConditions.Add($"content LIKE {paramName}"); + command.Parameters.Add( + new SqlParameter(paramName, SqlDbType.NVarChar) + { + Value = $"%{keywords[i].ToLower()}%", + } + ); + } + + whereClauses.Add($"({string.Join(" OR ", keywordConditions)})"); + } + + if (participants != null && participants.Length > 0) + { + var participantConditions = new List(); + for (int i = 0; i < participants.Length; i++) + { + var paramName = $"@participant{i}"; + participantConditions.Add($"name LIKE {paramName}"); + command.Parameters.Add( + new SqlParameter(paramName, SqlDbType.NVarChar) + { + Value = $"%{participants[i].ToLower()}%", + } + ); + } + + whereClauses.Add($"({string.Join(" OR ", participantConditions)})"); + } + + command.CommandText = + $"SELECT TOP ({limit}) blob FROM conversations WHERE {string.Join(" AND ", whereClauses)} ORDER BY timestamp DESC"; + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving filtered messages"); + return new List(); + } + } + + public async Task RecordFeedbackAsync( + string replyToId, + string reaction, + object? feedbackJson = null + ) + { + EnsureConnection(); + + try + { + await using var command = _connection!.CreateCommand(); + command.CommandText = + @"INSERT INTO feedback (reply_to_id, reaction, feedback) +VALUES (@replyToId, @reaction, @feedback)"; + + command.Parameters.Add( + new SqlParameter("@replyToId", SqlDbType.NVarChar) { Value = replyToId } + ); + command.Parameters.Add( + new SqlParameter("@reaction", SqlDbType.NVarChar) { Value = reaction } + ); + command.Parameters.Add( + new SqlParameter("@feedback", SqlDbType.NVarChar) + { + Value = feedbackJson != null + ? JsonSerializer.Serialize(feedbackJson) + : (object)DBNull.Value, + } + ); + + var result = await command.ExecuteNonQueryAsync(); + return result > 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording feedback"); + return false; + } + } + + public async Task CloseAsync() + { + if (_connection != null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + _connection = null; + _isInitialized = false; + + _logger.LogDebug("Closed MSSQL database connection"); + } + } + + private void EnsureConnection() + { + if (_connection == null) + { + throw new InvalidOperationException("Database not connected"); + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/SqliteDatabase.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/SqliteDatabase.cs.tpl new file mode 100644 index 00000000000..531981fdfee --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/SqliteDatabase.cs.tpl @@ -0,0 +1,341 @@ +using System.Data; +using System.Text.Json; +using Microsoft.Data.Sqlite; + +namespace {{SafeProjectName}}.Storage +{ + public class SqliteDatabase : IDatabase + { + private readonly ILogger _logger; + private readonly string _dbPath; + private SqliteConnection? _connection; + + public SqliteDatabase(ILogger logger, string? dbPath = null) + { + _logger = logger; + _dbPath = + Environment.GetEnvironmentVariable("CONVERSATIONS_DB_PATH") + ?? dbPath + ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "conversations.db"); + + _logger.LogDebug("SQLite database path: {DbPath}", _dbPath); + } + + public async Task InitializeAsync() + { + try + { + var directory = Path.GetDirectoryName(_dbPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + _connection = new SqliteConnection($"Data Source={_dbPath}"); + await _connection.OpenAsync(); + + await using var command = _connection.CreateCommand(); + + command.CommandText = + @"CREATE TABLE IF NOT EXISTS conversations ( + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + activity_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + blob TEXT NOT NULL +)"; + await command.ExecuteNonQueryAsync(); + + command.CommandText = + @"CREATE INDEX IF NOT EXISTS idx_conversation_id ON conversations(conversation_id)"; + await command.ExecuteNonQueryAsync(); + + command.CommandText = + @"CREATE TABLE IF NOT EXISTS feedback ( + reply_to_id TEXT NOT NULL, + reaction TEXT NOT NULL CHECK (reaction IN ('like','dislike')), + feedback TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +)"; + await command.ExecuteNonQueryAsync(); + + _logger.LogDebug("SQLite database initialized"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing SQLite database"); + throw; + } + } + + public async Task ClearAllAsync() + { + EnsureConnection(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = "DELETE FROM conversations; VACUUM;"; + await command.ExecuteNonQueryAsync(); + + _logger.LogDebug("Cleared all conversations from SQLite store"); + } + + public async Task> GetAsync(string conversationId) + { + EnsureConnection(); + + var messages = new List(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = + "SELECT blob FROM conversations WHERE conversation_id = @conversationId ORDER BY timestamp ASC"; + command.Parameters.AddWithValue("@conversationId", conversationId); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + + public async Task> GetMessagesByTimeRangeAsync( + string conversationId, + string startTime, + string endTime + ) + { + EnsureConnection(); + + var messages = new List(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = + @"SELECT blob FROM conversations +WHERE conversation_id = @conversationId + AND timestamp >= @startTime + AND timestamp <= @endTime +ORDER BY timestamp ASC"; + command.Parameters.AddWithValue("@conversationId", conversationId); + command.Parameters.AddWithValue("@startTime", startTime); + command.Parameters.AddWithValue("@endTime", endTime); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + + public async Task> GetRecentMessagesAsync( + string conversationId, + int limit = 10 + ) + { + var messages = await GetAsync(conversationId); + return messages.TakeLast(limit).ToList(); + } + + public async Task ClearConversationAsync(string conversationId) + { + EnsureConnection(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = "DELETE FROM conversations WHERE conversation_id = @conversationId"; + command.Parameters.AddWithValue("@conversationId", conversationId); + await command.ExecuteNonQueryAsync(); + } + + public async Task AddMessagesAsync(IEnumerable messages) + { + EnsureConnection(); + + await using var transaction = await _connection!.BeginTransactionAsync(); + + try + { + foreach (var message in messages) + { + await using var command = _connection.CreateCommand(); + command.Transaction = transaction as SqliteTransaction; + command.CommandText = + @"INSERT INTO conversations (conversation_id, role, name, content, activity_id, timestamp, blob) +VALUES (@conversationId, @role, @name, @content, @activityId, @timestamp, @blob)"; + + command.Parameters.AddWithValue("@conversationId", message.ConversationId); + command.Parameters.AddWithValue("@role", message.Role); + command.Parameters.AddWithValue("@name", message.Name); + command.Parameters.AddWithValue("@content", message.Content); + command.Parameters.AddWithValue("@activityId", message.ActivityId); + command.Parameters.AddWithValue("@timestamp", message.Timestamp); + command.Parameters.AddWithValue("@blob", JsonSerializer.Serialize(message)); + + await command.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task CountMessagesAsync(string conversationId) + { + EnsureConnection(); + + await using var command = _connection!.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM conversations WHERE conversation_id = @conversationId"; + command.Parameters.AddWithValue("@conversationId", conversationId); + + var result = await command.ExecuteScalarAsync(); + return result != null ? Convert.ToInt32(result) : 0; + } + + public Task ClearAllMessagesAsync() => ClearAllAsync(); + + public async Task> GetFilteredMessagesAsync( + string conversationId, + string[] keywords, + string startTime, + string endTime, + string[]? participants = null, + int? maxResults = null + ) + { + EnsureConnection(); + + var messages = new List(); + var limit = maxResults ?? 5; + + var whereClauses = new List + { + "conversation_id = @conversationId", + "timestamp >= @startTime", + "timestamp <= @endTime", + }; + + if (keywords.Length > 0) + { + var keywordClauses = string.Join( + " OR ", + keywords.Select((_, i) => $"content LIKE @keyword{i}") + ); + whereClauses.Add($"({keywordClauses})"); + } + + if (participants != null && participants.Length > 0) + { + var participantClauses = string.Join( + " OR ", + participants.Select((_, i) => $"name LIKE @participant{i}") + ); + whereClauses.Add($"({participantClauses})"); + } + + await using var command = _connection!.CreateCommand(); + command.CommandText = + $"SELECT blob FROM conversations WHERE {string.Join(" AND ", whereClauses)} ORDER BY timestamp DESC LIMIT @limit"; + + command.Parameters.AddWithValue("@conversationId", conversationId); + command.Parameters.AddWithValue("@startTime", startTime); + command.Parameters.AddWithValue("@endTime", endTime); + command.Parameters.AddWithValue("@limit", limit); + + for (int i = 0; i < keywords.Length; i++) + { + command.Parameters.AddWithValue($"@keyword{i}", $"%{keywords[i].ToLower()}%"); + } + + if (participants != null) + { + for (int i = 0; i < participants.Length; i++) + { + command.Parameters.AddWithValue($"@participant{i}", $"%{participants[i].ToLower()}%"); + } + } + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var blob = reader.GetString(0); + var message = JsonSerializer.Deserialize(blob); + if (message != null) + { + messages.Add(message); + } + } + + return messages; + } + + public async Task RecordFeedbackAsync( + string replyToId, + string reaction, + object? feedbackJson = null + ) + { + EnsureConnection(); + + try + { + await using var command = _connection!.CreateCommand(); + command.CommandText = + @"INSERT INTO feedback (reply_to_id, reaction, feedback) +VALUES (@replyToId, @reaction, @feedback)"; + + command.Parameters.AddWithValue("@replyToId", replyToId); + command.Parameters.AddWithValue("@reaction", reaction); + command.Parameters.AddWithValue( + "@feedback", + feedbackJson != null ? JsonSerializer.Serialize(feedbackJson) : DBNull.Value + ); + + var result = await command.ExecuteNonQueryAsync(); + return result > 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording feedback"); + return false; + } + } + + public async Task CloseAsync() + { + if (_connection != null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + _connection = null; + + _logger.LogDebug("Closed SQLite database connection"); + } + } + + private void EnsureConnection() + { + if (_connection == null) + { + throw new InvalidOperationException("Database not initialized"); + } + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Storage/StorageFactory.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Storage/StorageFactory.cs.tpl new file mode 100644 index 00000000000..4862d5a793f --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Storage/StorageFactory.cs.tpl @@ -0,0 +1,38 @@ +using {{SafeProjectName}}.Utils; + +namespace {{SafeProjectName}}.Storage +{ + public static class StorageFactory + { + public static async Task CreateStorageAsync( + ILogger logger, + DatabaseConfigOptions? config = null + ) + { + var dbConfig = config ?? ConfigHelper.GetDatabaseConfig(); + + if (dbConfig.Type == "mssql") + { + try + { + logger.LogDebug("Initializing MSSQL storage"); + var storage = new MssqlDatabase(logger, dbConfig); + await storage.InitializeAsync(); + logger.LogDebug("MSSQL storage initialized"); + return storage; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to initialize MSSQL, falling back to SQLite"); + } + } + + logger.LogDebug("Initializing SQLite storage"); + var sqliteStorage = new SqliteDatabase(logger, dbConfig.SqlitePath); + await sqliteStorage.InitializeAsync(); + logger.LogDebug("SQLite storage initialized"); + + return sqliteStorage; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Utils/ConfigHelper.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Utils/ConfigHelper.cs.tpl new file mode 100644 index 00000000000..8a0e840f553 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Utils/ConfigHelper.cs.tpl @@ -0,0 +1,304 @@ +namespace {{SafeProjectName}}.Utils +{ + /// + /// Configuration for AI models used by different capabilities + /// + public class ModelConfig + { + public string Model { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public string Endpoint { get; set; } = string.Empty; + } + + /// + /// Utility class for managing AI model configurations and environment validation + /// + public static class ConfigHelper + { + private static ConfigOptions? _config; + + /// + /// Initialize the configuration helper with ConfigOptions + /// + public static void Initialize(ConfigOptions config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + + // Auto-configure database type based on Azure environment + if (config.RunningOnAzure == "1" && string.IsNullOrEmpty(config.Database.Type)) + { + config.Database.Type = "mssql"; + } + } + + /// + /// Get database configuration + /// + public static DatabaseConfigOptions GetDatabaseConfig() + { + if (_config == null) + { + throw new InvalidOperationException( + "ConfigHelper not initialized. Call Initialize() first." + ); + } + + return _config.Database; + } + + /// + /// Model configurations for different capabilities + /// + public static class AIModels + { + /// + /// Manager Capability - Uses lighter, faster model for routing decisions + /// + public static ModelConfig Manager + { + get + { + if (_config == null) + throw new InvalidOperationException("ConfigHelper not initialized."); + + return new ModelConfig + { + Model = "gpt-4o-mini", +{{#useAzureOpenAI}} + ApiKey = _config.Azure.OpenAIApiKey, + Endpoint = _config.Azure.OpenAIEndpoint, +{{/useAzureOpenAI}} +{{#useOpenAI}} + ApiKey = _config.OpenAI.ApiKey, + Endpoint = _config.OpenAI.Endpoint ?? string.Empty, +{{/useOpenAI}} + }; + } + } + + /// + /// Summarizer Capability - Uses more capable model for complex analysis + /// + public static ModelConfig Summarizer + { + get + { + if (_config == null) + throw new InvalidOperationException("ConfigHelper not initialized."); + + return new ModelConfig + { +{{#useAzureOpenAI}} + Model = !string.IsNullOrEmpty(_config.Azure.OpenAIDeploymentName) + ? _config.Azure.OpenAIDeploymentName + : "gpt-4o", + ApiKey = _config.Azure.OpenAIApiKey, + Endpoint = _config.Azure.OpenAIEndpoint, +{{/useAzureOpenAI}} +{{#useOpenAI}} + Model = !string.IsNullOrEmpty(_config.OpenAI.DefaultModel) + ? _config.OpenAI.DefaultModel + : "gpt-4o", + ApiKey = _config.OpenAI.ApiKey, + Endpoint = _config.OpenAI.Endpoint ?? string.Empty, +{{/useOpenAI}} + }; + } + } + + /// + /// Action Items Capability - Uses capable model for analysis and task management + /// + public static ModelConfig ActionItems + { + get + { + if (_config == null) + throw new InvalidOperationException("ConfigHelper not initialized."); + + return new ModelConfig + { +{{#useAzureOpenAI}} + Model = !string.IsNullOrEmpty(_config.Azure.OpenAIDeploymentName) + ? _config.Azure.OpenAIDeploymentName + : "gpt-4o", + ApiKey = _config.Azure.OpenAIApiKey, + Endpoint = _config.Azure.OpenAIEndpoint, +{{/useAzureOpenAI}} +{{#useOpenAI}} + Model = !string.IsNullOrEmpty(_config.OpenAI.DefaultModel) + ? _config.OpenAI.DefaultModel + : "gpt-4o", + ApiKey = _config.OpenAI.ApiKey, + Endpoint = _config.OpenAI.Endpoint ?? string.Empty, +{{/useOpenAI}} + }; + } + } + + /// + /// Search Capability - Uses capable model for semantic search and deep linking + /// + public static ModelConfig Search + { + get + { + if (_config == null) + throw new InvalidOperationException("ConfigHelper not initialized."); + + return new ModelConfig + { +{{#useAzureOpenAI}} + Model = !string.IsNullOrEmpty(_config.Azure.OpenAIDeploymentName) + ? _config.Azure.OpenAIDeploymentName + : "gpt-4o", + ApiKey = _config.Azure.OpenAIApiKey, + Endpoint = _config.Azure.OpenAIEndpoint, +{{/useAzureOpenAI}} +{{#useOpenAI}} + Model = !string.IsNullOrEmpty(_config.OpenAI.DefaultModel) + ? _config.OpenAI.DefaultModel + : "gpt-4o", + ApiKey = _config.OpenAI.ApiKey, + Endpoint = _config.OpenAI.Endpoint ?? string.Empty, +{{/useOpenAI}} + }; + } + } + + /// + /// Default model configuration (fallback) + /// + public static ModelConfig Default + { + get + { + if (_config == null) + throw new InvalidOperationException("ConfigHelper not initialized."); + + return new ModelConfig + { +{{#useAzureOpenAI}} + Model = !string.IsNullOrEmpty(_config.Azure.OpenAIDeploymentName) + ? _config.Azure.OpenAIDeploymentName + : "gpt-4o", + ApiKey = _config.Azure.OpenAIApiKey, + Endpoint = _config.Azure.OpenAIEndpoint, +{{/useAzureOpenAI}} +{{#useOpenAI}} + Model = !string.IsNullOrEmpty(_config.OpenAI.DefaultModel) + ? _config.OpenAI.DefaultModel + : "gpt-4o", + ApiKey = _config.OpenAI.ApiKey, + Endpoint = _config.OpenAI.Endpoint ?? string.Empty, +{{/useOpenAI}} + }; + } + } + } + + /// + /// Helper function to get model config for a specific capability + /// + public static ModelConfig GetModelConfig(string capabilityType) + { + return capabilityType.ToLower() switch + { + "manager" => AIModels.Manager, + "summarizer" => AIModels.Summarizer, + "actionitems" => AIModels.ActionItems, + "search" => AIModels.Search, + _ => AIModels.Default, + }; + } + + /// + /// Environment validation + /// + public static void ValidateEnvironment(ILogger logger) + { + if (_config == null) + { + throw new InvalidOperationException( + "ConfigHelper not initialized. Call Initialize() first." + ); + } + + var hasModelConfig = false; + +{{#useAzureOpenAI}} + var azureApiKey = _config.Azure.OpenAIApiKey; + var azureEndpoint = _config.Azure.OpenAIEndpoint; + hasModelConfig = + !string.IsNullOrEmpty(azureApiKey) && !string.IsNullOrEmpty(azureEndpoint); + + if (hasModelConfig) + { + logger.LogDebug("βœ“ Using Azure OpenAI configuration from appsettings"); + } +{{/useAzureOpenAI}} +{{#useOpenAI}} + var openAiApiKey = _config.OpenAI.ApiKey; + hasModelConfig = !string.IsNullOrEmpty(openAiApiKey); + + if (hasModelConfig) + { + logger.LogDebug("βœ“ Using OpenAI configuration"); + } +{{/useOpenAI}} + + if (!hasModelConfig) + { +{{#useAzureOpenAI}} + throw new InvalidOperationException( + "Missing required Azure OpenAI configuration. Please provide Azure:OpenAIApiKey and Azure:OpenAIEndpoint in appsettings.json" + ); +{{/useAzureOpenAI}} +{{#useOpenAI}} + throw new InvalidOperationException( + "Missing required OpenAI configuration. Please provide OpenAI:ApiKey in appsettings.json" + ); +{{/useOpenAI}} + } + + if (_config.Database.Type == "mssql") + { + if (string.IsNullOrEmpty(_config.Database.ConnectionString)) + { + logger.LogWarning( + "SQL Server configuration incomplete. Missing: SQL_CONNECTION_STRING. Falling back to SQLite." + ); + _config.Database.Type = "sqlite"; + } + else + { + logger.LogDebug("βœ“ SQL Server configuration validated"); + } + } + + logger.LogDebug("πŸ’Ύ Using database: {DatabaseType}", _config.Database.Type); + logger.LogDebug("βœ“ Environment validation passed"); + } + + /// + /// Model configuration logging + /// + public static void LogModelConfigs(ILogger logger) + { + logger.LogDebug("πŸ€– AI Model Configuration:"); + logger.LogDebug(" Manager Capability: {Model}", AIModels.Manager.Model); + logger.LogDebug(" Summarizer Capability: {Model}", AIModels.Summarizer.Model); + logger.LogDebug(" Action Items Capability: {Model}", AIModels.ActionItems.Model); + logger.LogDebug(" Search Capability: {Model}", AIModels.Search.Model); + logger.LogDebug(" Default Model: {Model}", AIModels.Default.Model); + } + + /// + /// Check if running on Azure + /// + public static bool IsRunningOnAzure() + { + return _config?.RunningOnAzure == "1"; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Utils/MessageUtils.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Utils/MessageUtils.cs.tpl new file mode 100644 index 00000000000..45a08a0fce9 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Utils/MessageUtils.cs.tpl @@ -0,0 +1,102 @@ +using {{SafeProjectName}}.Models; +using {{SafeProjectName}}.Storage; +using Microsoft.Teams.Api.Activities; + +namespace {{SafeProjectName}}.Utils +{ + public static class MessageUtils + { + public static MessageActivity FinalizePromptResponse( + string text, + MessageContext context, + ILogger logger + ) + { + var finalText = text; + + if (context.Citations != null && context.Citations.Count > 0) + { + logger.LogDebug( + "Adding {Count} citations to message activity", + context.Citations.Count + ); + + for (int i = 0; i < context.Citations.Count; i++) + { + finalText += $" [{i + 1}]"; + } + } + + var messageActivity = new MessageActivity(finalText); + messageActivity.AddAIGenerated(); + messageActivity.AddFeedback(); + + return messageActivity; + } + + public static List CreateMessageRecords( + IEnumerable activities + ) + { + var activitiesList = activities.ToList(); + if (activitiesList.Count == 0) + { + return new List(); + } + + var conversationId = activitiesList[0].Conversation.Id; + + return activitiesList + .Select(activity => + { + var isAiGenerated = + activity.Entities?.Any(e => + { + var typeStr = e.Type?.ToString() ?? string.Empty; + return typeStr.Contains("AIGenerated", StringComparison.OrdinalIgnoreCase) + || typeStr.Contains("AIGeneratedContent", StringComparison.OrdinalIgnoreCase); + }) ?? false; + + var role = isAiGenerated ? "assistant" : "user"; + var content = + activity.Text?.Replace("", string.Empty) + .Replace("", string.Empty) + .Trim() + ?? string.Empty; + + return new MessageRecord + { + ConversationId = conversationId, + Role = role, + Content = content, + Timestamp = activity.Timestamp?.ToString("o") ?? DateTime.UtcNow.ToString("o"), + ActivityId = activity.Id ?? string.Empty, + Name = activity.From?.Name ?? "Collaborator", + UserId = activity.From?.Id ?? string.Empty, + }; + }) + .ToList(); + } + + public static MessageRecord CreateMessageRecord( + MessageActivity activity, + string role = "user" + ) + { + var content = + activity.Text?.Replace("", string.Empty).Replace("", string.Empty) + ?? string.Empty; + + return new MessageRecord + { + ConversationId = activity.Conversation.Id, + Role = role, + Content = content, + Timestamp = activity.Timestamp?.ToString("o") ?? DateTime.UtcNow.ToString("o"), + ActivityId = activity.Id ?? string.Empty, + Name = activity.From?.Name ?? "User", + UserId = activity.From?.Id ?? string.Empty, + }; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/Utils/TimeRangeUtils.cs.tpl b/templates/vs/csharp/teams-collaborator-agent/Utils/TimeRangeUtils.cs.tpl new file mode 100644 index 00000000000..7d94a8964fb --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/Utils/TimeRangeUtils.cs.tpl @@ -0,0 +1,103 @@ +using Microsoft.Recognizers.Text; +using Microsoft.Recognizers.Text.DateTime; + +namespace {{SafeProjectName}}.Utils +{ + public static class TimeRangeUtils + { + public static (DateTime From, DateTime To)? ExtractTimeRange( + string timePhrase, + ILogger logger + ) + { + if (string.IsNullOrWhiteSpace(timePhrase)) + { + return null; + } + + try + { + var results = DateTimeRecognizer.RecognizeDateTime(timePhrase, Culture.English); + if (results == null || results.Count == 0) + { + logger.LogWarning( + "Could not parse time phrase: \"{Phrase}\"", + timePhrase + ); + return null; + } + + var now = DateTime.UtcNow; + var resolution = results[0].Resolution; + + if (resolution == null || !resolution.ContainsKey("values")) + { + logger.LogWarning( + "No resolution values found for: \"{Phrase}\"", + timePhrase + ); + return null; + } + + if (resolution["values"] is not List> values || values.Count == 0) + { + logger.LogWarning( + "Empty resolution values for: \"{Phrase}\"", + timePhrase + ); + return null; + } + + var firstValue = values[0]; + if (!firstValue.ContainsKey("type")) + { + return null; + } + + var type = firstValue["type"]; + if (type == "daterange" || type == "datetimerange") + { + if ( + firstValue.TryGetValue("start", out var startValue) + && firstValue.TryGetValue("end", out var endValue) + && DateTime.TryParse(startValue, out var start) + && DateTime.TryParse(endValue, out var end) + ) + { + return (start, end); + } + } + else if (type == "date") + { + if ( + firstValue.TryGetValue("value", out var dateValue) + && DateTime.TryParse(dateValue, out var date) + ) + { + return (date.Date, date.Date.AddDays(1).AddSeconds(-1)); + } + } + else if (type == "datetime") + { + if ( + firstValue.TryGetValue("value", out var datetimeValue) + && DateTime.TryParse(datetimeValue, out var dateTime) + ) + { + return dateTime <= now ? (dateTime, now) : (now, dateTime); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error parsing time phrase: {Phrase}", timePhrase); + } + + logger.LogWarning( + "Could not extract time range from resolution for: \"{Phrase}\"", + timePhrase + ); + return null; + } + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/appPackage/color.png b/templates/vs/csharp/teams-collaborator-agent/appPackage/color.png new file mode 100644 index 00000000000..01aa37e347d Binary files /dev/null and b/templates/vs/csharp/teams-collaborator-agent/appPackage/color.png differ diff --git a/templates/vs/csharp/teams-collaborator-agent/appPackage/manifest.json.tpl b/templates/vs/csharp/teams-collaborator-agent/appPackage/manifest.json.tpl new file mode 100644 index 00000000000..209d732d159 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/appPackage/manifest.json.tpl @@ -0,0 +1,55 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", + "manifestVersion": "1.23", + "version": "1.0.0", + "id": "${{TEAMS_APP_ID}}", + "developer": { + "name": "My App, Inc.", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/termofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "{{appName}}${{APP_NAME_SUFFIX}}", + "full": "full name for {{appName}}" + }, + "description": { + "short": "short description for {{appName}}", + "full": "full description for {{appName}}" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "validDomains": [ + "${{BOT_DOMAIN}}", + "*.botframework.com" + ], + "webApplicationInfo": { + "id": "${{BOT_ID}}", + "resource": "api://botid-${{BOT_ID}}" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { + "name": "ChatMessage.Read.Chat", + "type": "Application" + } + ] + } + } +} \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/appPackage/outline.png b/templates/vs/csharp/teams-collaborator-agent/appPackage/outline.png new file mode 100644 index 00000000000..f7a4c864475 Binary files /dev/null and b/templates/vs/csharp/teams-collaborator-agent/appPackage/outline.png differ diff --git a/templates/vs/csharp/teams-collaborator-agent/appsettings.Development.json.tpl b/templates/vs/csharp/teams-collaborator-agent/appsettings.Development.json.tpl new file mode 100644 index 00000000000..accc99a5c83 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/appsettings.Development.json.tpl @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "AllowedHosts": "*", + "Teams": { + "ClientId": "", + "ClientSecret": "", + "BotType": "" + }, +{{#useOpenAI}} + "OpenAI": { + "ApiKey": "", + "DefaultModel": "gpt-3.5-turbo" + }, +{{/useOpenAI}} +{{#useAzureOpenAI}} + "Azure": { + "OpenAIApiKey": "", + "OpenAIEndpoint": "", + "OpenAIDeploymentName": "" + }, +{{/useAzureOpenAI}} + "Database": { + "Type": "sqlite", + "SqlitePath": "conversations.db" + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/appsettings.Playground.json.tpl b/templates/vs/csharp/teams-collaborator-agent/appsettings.Playground.json.tpl new file mode 100644 index 00000000000..60a4100ad35 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/appsettings.Playground.json.tpl @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "AllowedHosts": "*", + "Teams": { + "ClientId": "", + "ClientSecret": "", + "BotType": "" + }, +{{#useOpenAI}} + "OpenAI": { + "ApiKey": "{{{originalOpenAIKey}}}", + "Model": "gpt-3.5-turbo" + } +{{/useOpenAI}} +{{#useAzureOpenAI}} + "Azure": { + "OpenAIApiKey": "{{{originalAzureOpenAIKey}}}", + "OpenAIEndpoint": "{{{azureOpenAIEndpoint}}}", + "OpenAIDeploymentName": "{{{azureOpenAIDeploymentName}}}" + }, +{{/useAzureOpenAI}} + "Database": { + "Type": "sqlite", + "SqlitePath": "conversations.db" + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/appsettings.json.tpl b/templates/vs/csharp/teams-collaborator-agent/appsettings.json.tpl new file mode 100644 index 00000000000..1bb6ef17134 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/appsettings.json.tpl @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "AllowedHosts": "*", + "Teams": { + "ClientId": "", + "ClientSecret": "", + "BotType": "" + }, +{{#useOpenAI}} + "OpenAI": { + "ApiKey": "", + "DefaultModel": "gpt-4o" + }, +{{/useOpenAI}} +{{#useAzureOpenAI}} + "Azure": { + "OpenAIApiKey": "", + "OpenAIEndpoint": "", + "OpenAIDeploymentName": "" + }, +{{/useAzureOpenAI}} + "Database": { + "Type": "", + "Password": "" + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/env/.env.dev b/templates/vs/csharp/teams-collaborator-agent/env/.env.dev new file mode 100644 index 00000000000..df4f9da5080 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/env/.env.dev @@ -0,0 +1,15 @@ +# This file includes environment variables that will be committed to git by default. + +# Built-in environment variables +TEAMSFX_ENV=dev +APP_NAME_SUFFIX=dev + +# Updating AZURE_SUBSCRIPTION_ID or AZURE_RESOURCE_GROUP_NAME after provision may also require an update to RESOURCE_SUFFIX, because some services require a globally unique name across subscriptions/resource groups. +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME= +RESOURCE_SUFFIX= + +# Generated during provision, you can also add your own variables. +BOT_ID= +TEAMS_APP_ID= +BOT_AZURE_APP_SERVICE_RESOURCE_ID= \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/env/.env.dev.user.tpl b/templates/vs/csharp/teams-collaborator-agent/env/.env.dev.user.tpl new file mode 100644 index 00000000000..461989ccc55 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/env/.env.dev.user.tpl @@ -0,0 +1,12 @@ +# This file includes environment variables that will not be committed to git by default. You can set these environment variables in your CI/CD system for your project. + +# Secrets. Keys prefixed with `SECRET_` will be masked in Microsoft 365 Agents Toolkit logs. +{{#useOpenAI}} +SECRET_OPENAI_API_KEY={{{openAIKey}}} +{{/useOpenAI}} +{{#useAzureOpenAI}} +SECRET_AZURE_OPENAI_API_KEY={{{azureOpenAIKey}}} +AZURE_OPENAI_ENDPOINT={{{azureOpenAIEndpoint}}} +AZURE_OPENAI_DEPLOYMENT_NAME={{{azureOpenAIDeploymentName}}} +{{/useAzureOpenAI}} +PASSWORD=YourSecurePassword123! diff --git a/templates/vs/csharp/teams-collaborator-agent/env/.env.local.tpl b/templates/vs/csharp/teams-collaborator-agent/env/.env.local.tpl new file mode 100644 index 00000000000..2646096121f --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/env/.env.local.tpl @@ -0,0 +1,10 @@ +# This file includes environment variables that can be committed to git. It's gitignored by default because it represents your local development environment. + +# Built-in environment variables +TEAMSFX_ENV=local +APP_NAME_SUFFIX=local + +# Generated during provision, you can also add your own variables. +BOT_ID= +TEAMS_APP_ID= +BOT_DOMAIN= \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/env/.env.local.user.tpl b/templates/vs/csharp/teams-collaborator-agent/env/.env.local.user.tpl new file mode 100644 index 00000000000..875ca6b7fad --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/env/.env.local.user.tpl @@ -0,0 +1,12 @@ +# This file includes environment variables that will not be committed to git by default. You can set these environment variables in your CI/CD system for your project. + +# Secrets. Keys prefixed with `SECRET_` will be masked in Microsoft 365 Agents Toolkit logs. +SECRET_BOT_PASSWORD= +{{#useOpenAI}} +SECRET_OPENAI_API_KEY={{{openAIKey}}} +{{/useOpenAI}} +{{#useAzureOpenAI}} +SECRET_AZURE_OPENAI_API_KEY={{{azureOpenAIKey}}} +AZURE_OPENAI_ENDPOINT={{{azureOpenAIEndpoint}}} +AZURE_OPENAI_DEPLOYMENT_NAME={{{azureOpenAIDeploymentName}}} +{{/useAzureOpenAI}} diff --git a/templates/vs/csharp/teams-collaborator-agent/img/architecture.png b/templates/vs/csharp/teams-collaborator-agent/img/architecture.png new file mode 100644 index 00000000000..ff2da4bafd5 Binary files /dev/null and b/templates/vs/csharp/teams-collaborator-agent/img/architecture.png differ diff --git a/templates/vs/csharp/teams-collaborator-agent/img/flow.png b/templates/vs/csharp/teams-collaborator-agent/img/flow.png new file mode 100644 index 00000000000..723daf3f084 Binary files /dev/null and b/templates/vs/csharp/teams-collaborator-agent/img/flow.png differ diff --git a/templates/vs/csharp/teams-collaborator-agent/infra/azure.bicep.tpl b/templates/vs/csharp/teams-collaborator-agent/infra/azure.bicep.tpl new file mode 100644 index 00000000000..79fec726e96 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/infra/azure.bicep.tpl @@ -0,0 +1,192 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string +{{#useOpenAI}} +@secure() +param openAIApiKey string +{{/useOpenAI}} +{{#useAzureOpenAI}} +@secure() +param azureOpenAIApiKey string + +param azureOpenAIEndpoint string +param azureOpenAIDeploymentName string +{{/useAzureOpenAI}} + +param webAppSKU string + +@maxLength(42) +param botDisplayName string + +@secure() +param sqlAdminLogin string = 'sqladmin' +@secure() +param sqlAdminPassword string + +param serverfarmsName string = resourceBaseName +param webAppName string = resourceBaseName +param identityName string = resourceBaseName +param sqlServerName string = '${resourceBaseName}-sqlserver' +param sqlDatabaseName string = '${resourceBaseName}-db' +param location string = resourceGroup().location + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + location: location + name: identityName +} + +// SQL Server +resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = { + name: sqlServerName + location: location + properties: { + administratorLogin: sqlAdminLogin + administratorLoginPassword: sqlAdminPassword + version: '12.0' + } +} + +// SQL Database +resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: sqlDatabaseName + location: location + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: 2147483648 // 2GB + } +} + +// Allow Azure services to access SQL Server +resource sqlFirewallRule 'Microsoft.Sql/servers/firewallRules@2023-05-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +// Compute resources for your Web App +resource serverfarm 'Microsoft.Web/serverfarms@2021-02-01' = { + kind: 'app' + location: location + name: serverfarmsName + sku: { + name: webAppSKU + } +} + +// Web App that hosts your agent +resource webApp 'Microsoft.Web/sites@2021-02-01' = { + kind: 'app' + location: location + name: webAppName + properties: { + serverFarmId: serverfarm.id + httpsOnly: true + siteConfig: { + alwaysOn: true + appSettings: [ + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + { + name: 'RUNNING_ON_AZURE' + value: '1' + } + { + name: 'Teams__ClientId' + value: identity.properties.clientId + } + { + name: 'Teams__TenantId' + value: identity.properties.tenantId + } + { + name: 'Teams__BotType' + value: 'UserAssignedMsi' + } +{{#useOpenAI}} + { + name: 'OpenAI__ApiKey' + value: openAIApiKey + } +{{/useOpenAI}} +{{#useAzureOpenAI}} + { + name: 'Azure__OpenAIApiKey' + value: azureOpenAIApiKey + } + { + name: 'Azure__OpenAIEndpoint' + value: azureOpenAIEndpoint + } + { + name: 'Azure__OpenAIDeploymentName' + value: azureOpenAIDeploymentName + } +{{/useAzureOpenAI}} + { + name: 'Database__Type' + value: 'mssql' + } + { + name: 'Database__ConnectionString' + value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${sqlDatabaseName};Persist Security Info=False;User ID=${sqlAdminLogin};Password=${sqlAdminPassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;' + } + { + name: 'Database__Server' + value: sqlServer.properties.fullyQualifiedDomainName + } + { + name: 'Database__Database' + value: sqlDatabaseName + } + { + name: 'Database__Username' + value: sqlAdminLogin + } + { + name: 'Database__Password' + value: sqlAdminPassword + } + ] + ftpsState: 'FtpsOnly' + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${identity.id}': {} + } + } +} + +// Register your web service as a bot with the Bot Framework +module azureBotRegistration './botRegistration/azurebot.bicep' = { + name: 'Azure-Bot-registration' + params: { + resourceBaseName: resourceBaseName + identityClientId: identity.properties.clientId + identityResourceId: identity.id + identityTenantId: identity.properties.tenantId + botAppDomain: webApp.properties.defaultHostName + botDisplayName: botDisplayName + } +} + +// The output will be persisted in .env.{envName}. Visit https://aka.ms/teamsfx-actions/arm-deploy for more details. +output BOT_AZURE_APP_SERVICE_RESOURCE_ID string = webApp.id +output BOT_DOMAIN string = webApp.properties.defaultHostName +output BOT_ID string = identity.properties.clientId +output BOT_TENANT_ID string = identity.properties.tenantId +output SQL_SERVER_FQDN string = sqlServer.properties.fullyQualifiedDomainName +output SQL_DATABASE_NAME string = sqlDatabaseName diff --git a/templates/vs/csharp/teams-collaborator-agent/infra/azure.parameters.json.tpl b/templates/vs/csharp/teams-collaborator-agent/infra/azure.parameters.json.tpl new file mode 100644 index 00000000000..9878ee0e5f6 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/infra/azure.parameters.json.tpl @@ -0,0 +1,34 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, +{{#useOpenAI}} + "openAIApiKey": { + "value": "${{SECRET_OPENAI_API_KEY}}" + }, +{{/useOpenAI}} +{{#useAzureOpenAI}} + "azureOpenAIApiKey": { + "value": "${{SECRET_AZURE_OPENAI_API_KEY}}" + }, + "azureOpenAIEndpoint": { + "value": "${{AZURE_OPENAI_ENDPOINT}}" + }, + "azureOpenAIDeploymentName": { + "value": "${{AZURE_OPENAI_DEPLOYMENT_NAME}}" + }, +{{/useAzureOpenAI}} + "webAppSKU": { + "value": "B1" + }, + "botDisplayName": { + "value": "{{appName}}" + }, + "sqlAdminPassword": { + "value": "${{PASSWORD}}" + } + } + } \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/azurebot.bicep b/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/azurebot.bicep new file mode 100644 index 00000000000..a5a27b8fe43 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/azurebot.bicep @@ -0,0 +1,42 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' +param identityResourceId string +param identityClientId string +param identityTenantId string +param botAppDomain string + +// Register your web service as a bot with the Bot Framework +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: 'https://${botAppDomain}/api/messages' + msaAppId: identityClientId + msaAppMSIResourceId: identityResourceId + msaAppTenantId:identityTenantId + msaAppType:'UserAssignedMSI' + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} diff --git a/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/readme.md b/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/readme.md new file mode 100644 index 00000000000..d5416243cd3 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/infra/botRegistration/readme.md @@ -0,0 +1 @@ +The `azurebot.bicep` module is provided to help you create Azure Bot service when you don't use Azure to host your app. If you use Azure as infrastrcture for your app, `azure.bicep` under infra folder already leverages this module to create Azure Bot service for you. You don't need to deploy `azurebot.bicep` again. \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/m365agents.local.yml.tpl b/templates/vs/csharp/teams-collaborator-agent/m365agents.local.yml.tpl new file mode 100644 index 00000000000..6d355f8a721 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/m365agents.local.yml.tpl @@ -0,0 +1,127 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.11 + +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: {{appName}}${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + # Create or reuse an existing Microsoft Entra application for bot. + - uses: aadApp/create + with: + # The Microsoft Entra application's display name + name: {{appName}}${{APP_NAME_SUFFIX}} + generateClientSecret: true + generateServicePrincipal: true + signInAudience: AzureADMultipleOrgs + writeToEnvironmentFile: + # The Microsoft Entra application's client id created for bot. + clientId: BOT_ID + # The Microsoft Entra application's client secret created for bot. + clientSecret: SECRET_BOT_PASSWORD + # The Microsoft Entra application's object id created for bot. + objectId: BOT_OBJECT_ID + + # Generate runtime appsettings to JSON file + - uses: file/createOrUpdateJsonFile + with: +{{#isNewProjectTypeEnabled}} +{{#PlaceProjectFileInSolutionDir}} + target: ../appsettings.Development.json +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + target: ../{{appName}}/appsettings.Development.json +{{/PlaceProjectFileInSolutionDir}} +{{/isNewProjectTypeEnabled}} +{{^isNewProjectTypeEnabled}} + target: ./appsettings.Development.json +{{/isNewProjectTypeEnabled}} + content: + Teams: + ClientId: ${{BOT_ID}} + ClientSecret: ${{SECRET_BOT_PASSWORD}} + TenantId: ${{TEAMS_APP_TENANT_ID}} +{{#useOpenAI}} + OpenAI: + ApiKey: ${{SECRET_OPENAI_API_KEY}} +{{/useOpenAI}} +{{#useAzureOpenAI}} + Azure: + OpenAIApiKey: ${{SECRET_AZURE_OPENAI_API_KEY}} + OpenAIEndpoint: ${{AZURE_OPENAI_ENDPOINT}} + OpenAIDeploymentName: ${{AZURE_OPENAI_DEPLOYMENT_NAME}} +{{/useAzureOpenAI}} + + # Create or update the bot registration on dev.botframework.com + - uses: botFramework/create + with: + botId: ${{BOT_ID}} + name: {{appName}} + messagingEndpoint: ${{BOT_ENDPOINT}}/api/messages + description: "" + channels: + - name: msteams + {{^CEAEnabled}} + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + {{/CEAEnabled}} + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in + # Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + +{{#CEAEnabled}} + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID +{{/CEAEnabled}} +{{^isNewProjectTypeEnabled}} + + # Create or update debug profile in lauchsettings file + - uses: file/createOrUpdateJsonFile + with: + target: ./Properties/launchSettings.json + content: + profiles: + Microsoft Teams (browser): + commandName: "Project" + dotnetRunMessages: true + launchBrowser: true + launchUrl: "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}" + applicationUrl: "http://localhost:5130" + environmentVariables: + ASPNETCORE_ENVIRONMENT: "Development" + hotReloadProfile: "aspnetcore" +{{/isNewProjectTypeEnabled}} \ No newline at end of file diff --git a/templates/vs/csharp/teams-collaborator-agent/m365agents.yml.tpl b/templates/vs/csharp/teams-collaborator-agent/m365agents.yml.tpl new file mode 100644 index 00000000000..9560756987e --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/m365agents.yml.tpl @@ -0,0 +1,114 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.9/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.9 + +environmentFolderPath: ./env + +# Triggered when 'teamsapp provision' is executed +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: {{appName}}${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: arm/deploy # Deploy given ARM templates parallelly. + with: + # AZURE_SUBSCRIPTION_ID is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select a subscription. + # Referencing other environment variables with empty values + # will skip the subscription selection prompt. + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select or create one + # resource group. + # Referencing other environment variables with empty values + # will skip the resource group selection prompt. + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure.bicep # Relative path to this file + # Relative path to this yaml file. + # Placeholders will be replaced with corresponding environment + # variable before ARM deployment. + parameters: ./infra/azure.parameters.json + # Required when deploying ARM template + deploymentName: Create-resources-for-bot + # Microsoft 365 Agents Toolkit will download this bicep CLI version from github for you, + # will use bicep CLI in PATH if you remove this config. + bicepCliVersion: v0.9.1 + {{^CEAEnabled}} + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + {{/CEAEnabled}} + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in + # Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + +{{#CEAEnabled}} + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + +{{/CEAEnabled}} +# Triggered when 'teamsapp deploy' is executed +deploy: + - uses: cli/runDotnetCommand + with: + args: publish --configuration Release --runtime win-x86 --self-contained {{ProjectName}}.csproj +{{#isNewProjectTypeEnabled}} +{{#PlaceProjectFileInSolutionDir}} + workingDirectory: .. +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + workingDirectory: ../{{ProjectName}} +{{/PlaceProjectFileInSolutionDir}} +{{/isNewProjectTypeEnabled}} + # Deploy your application to Azure App Service using the zip deploy feature. + # For additional details, refer to https://aka.ms/zip-deploy-to-app-services. + - uses: azureAppService/zipDeploy + with: + # Deploy base folder + artifactFolder: bin/Release/{{TargetFramework}}/win-x86/publish + # The resource id of the cloud resource to be deployed to. + # This key will be generated by arm/deploy action automatically. + # You can replace it with your existing Azure Resource id + # or add it to your environment variable file. + resourceId: ${{BOT_AZURE_APP_SERVICE_RESOURCE_ID}} +{{#isNewProjectTypeEnabled}} +{{#PlaceProjectFileInSolutionDir}} + workingDirectory: .. +{{/PlaceProjectFileInSolutionDir}} +{{^PlaceProjectFileInSolutionDir}} + workingDirectory: ../{{ProjectName}} +{{/PlaceProjectFileInSolutionDir}} +{{/isNewProjectTypeEnabled}} diff --git a/templates/vs/csharp/teams-collaborator-agent/{{ProjectName}}.csproj.tpl b/templates/vs/csharp/teams-collaborator-agent/{{ProjectName}}.csproj.tpl new file mode 100644 index 00000000000..f1c1755d1a7 --- /dev/null +++ b/templates/vs/csharp/teams-collaborator-agent/{{ProjectName}}.csproj.tpl @@ -0,0 +1,36 @@ + + + + {{TargetFramework}} + enable + enable + + + + + + + + + + + + + + + + + + + + + PreserveNewest + None + + + + PreserveNewest + None + + +