Skip to content

Commit 014d98a

Browse files
authored
Merge pull request SciSharp#1366 from hchen2020/master
Add Microsoft Teams channel
2 parents bfe1884 + 7c78172 commit 014d98a

22 files changed

Lines changed: 1044 additions & 1 deletion

BotSharp.sln

Lines changed: 274 additions & 0 deletions
Large diffs are not rendered by default.

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@
101101
<PackageVersion Include="Shouldly" Version="4.3.0" />
102102
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
103103
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
104+
<PackageVersion Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.22.7" />
105+
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
104106
</ItemGroup>
105107
<ItemGroup>
106108
<PackageVersion Include="BotSharp.Core" Version="$(BotSharpVersion)" />

src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/ConversationChannel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public static class ConversationChannel
77
public const string Phone = "phone";
88
public const string SMS = "sms";
99
public const string Messenger = "messenger";
10+
public const string Teams = "teams";
1011
public const string Email = "email";
1112
public const string Crontab = "crontab";
1213
public const string Database = "database";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(TargetFramework)</TargetFramework>
5+
<LangVersion>$(LangVersion)</LangVersion>
6+
<VersionPrefix>$(BotSharpVersion)</VersionPrefix>
7+
<GeneratePackageOnBuild>$(GeneratePackageOnBuild)</GeneratePackageOnBuild>
8+
<GenerateDocumentationFile>$(GenerateDocumentationFile)</GenerateDocumentationFile>
9+
<Nullable>enable</Nullable>
10+
<OutputPath>$(SolutionDir)packages</OutputPath>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<ProjectReference Include="..\..\Infrastructure\BotSharp.Abstraction\BotSharp.Abstraction.csproj" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" />
19+
<PackageReference Include="AdaptiveCards" />
20+
</ItemGroup>
21+
22+
</Project>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Bot.Builder;
4+
using Microsoft.Bot.Builder.Integration.AspNet.Core;
5+
6+
namespace BotSharp.Plugin.MicrosoftTeams.Controllers;
7+
8+
[ApiController]
9+
public class TeamsMessageController : ControllerBase
10+
{
11+
private readonly IBotFrameworkHttpAdapter _adapter;
12+
private readonly IBot _bot;
13+
private readonly TeamsRequestState _requestState;
14+
private readonly ITeamsNotificationService _notification;
15+
private readonly MicrosoftTeamsSetting _setting;
16+
17+
public TeamsMessageController(
18+
IBotFrameworkHttpAdapter adapter,
19+
IBot bot,
20+
TeamsRequestState requestState,
21+
ITeamsNotificationService notification,
22+
MicrosoftTeamsSetting setting)
23+
{
24+
_adapter = adapter;
25+
_bot = bot;
26+
_requestState = requestState;
27+
_notification = notification;
28+
_setting = setting;
29+
}
30+
31+
/// <summary>
32+
/// Inbound endpoint registered as the Azure Bot "messaging endpoint".
33+
/// Authentication is performed by the Bot Framework JWT pipeline inside the adapter,
34+
/// so the action itself is anonymous.
35+
/// https://learn.microsoft.com/azure/bot-service/bot-builder-basics
36+
/// </summary>
37+
[AllowAnonymous]
38+
[HttpPost("/teams/messages/{agentId}")]
39+
public async Task PostAsync([FromRoute] string agentId)
40+
{
41+
_requestState.AgentId = agentId;
42+
await _adapter.ProcessAsync(Request, Response, _bot);
43+
}
44+
45+
/// <summary>
46+
/// Outbound (proactive) push. Requires the platform's standard authorization.
47+
/// </summary>
48+
[Authorize]
49+
[HttpPost("/teams/notify")]
50+
public async Task<IActionResult> NotifyAsync([FromBody] TeamsNotifyRequest request, CancellationToken cancellationToken)
51+
{
52+
if (string.IsNullOrEmpty(request.UserId))
53+
{
54+
return BadRequest("userId is required.");
55+
}
56+
57+
bool delivered;
58+
if (!string.IsNullOrEmpty(request.Prompt))
59+
{
60+
var agentId = request.AgentId ?? _setting.AgentId;
61+
if (string.IsNullOrEmpty(agentId))
62+
{
63+
return BadRequest("agentId is required when prompt is set (or configure MicrosoftTeams:AgentId).");
64+
}
65+
delivered = await _notification.NotifyAsync(request.UserId, agentId, request.Prompt, cancellationToken);
66+
}
67+
else if (!string.IsNullOrEmpty(request.Text))
68+
{
69+
delivered = await _notification.SendTextAsync(request.UserId, request.Text, cancellationToken);
70+
}
71+
else
72+
{
73+
return BadRequest("Either text or prompt must be provided.");
74+
}
75+
76+
return delivered ? Ok(new { success = true }) : NotFound(new { success = false, reason = "No conversation reference for user." });
77+
}
78+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using Microsoft.Bot.Builder;
2+
using Microsoft.Bot.Builder.Integration.AspNet.Core;
3+
using Microsoft.Bot.Connector.Authentication;
4+
5+
namespace BotSharp.Plugin.MicrosoftTeams;
6+
7+
/// <summary>
8+
/// Two-way Microsoft Teams integration built on Azure Bot Service / Bot Framework.
9+
/// Inbound: Teams activities are routed into the BotSharp conversation engine.
10+
/// Outbound: proactive messages are pushed back via stored conversation references.
11+
/// https://learn.microsoft.com/microsoftteams/platform/bots/what-are-bots
12+
/// </summary>
13+
public class MicrosoftTeamsPlugin : IBotSharpPlugin
14+
{
15+
public string Id => "b6f8e1a2-2c4d-4e6f-8a91-7d3c5b9e0f12";
16+
public string Name => "Microsoft Teams";
17+
public string Description => "Two-way conversational integration with Microsoft Teams via Azure Bot Service.";
18+
public string IconUrl => "https://upload.wikimedia.org/wikipedia/commons/c/c9/Microsoft_Office_Teams_%282018%E2%80%93present%29.svg";
19+
20+
public void RegisterDI(IServiceCollection services, IConfiguration config)
21+
{
22+
var settings = new MicrosoftTeamsSetting();
23+
config.Bind("MicrosoftTeams", settings);
24+
services.AddSingleton(settings);
25+
26+
// Bot Framework authentication. ConfigurationBotFrameworkAuthentication expects the
27+
// canonical Microsoft* keys, so map our section onto them.
28+
var authConfig = new ConfigurationBuilder()
29+
.AddInMemoryCollection(new Dictionary<string, string?>
30+
{
31+
["MicrosoftAppType"] = settings.AppType,
32+
["MicrosoftAppId"] = settings.AppId,
33+
["MicrosoftAppPassword"] = settings.AppPassword,
34+
["MicrosoftAppTenantId"] = settings.TenantId
35+
})
36+
.Build();
37+
services.AddSingleton<BotFrameworkAuthentication>(sp =>
38+
new ConfigurationBotFrameworkAuthentication(authConfig));
39+
40+
// Adapter (shared by inbound ProcessAsync and proactive ContinueConversationAsync).
41+
services.AddSingleton<TeamsAdapter>();
42+
services.AddSingleton<IBotFrameworkHttpAdapter>(sp => sp.GetRequiredService<TeamsAdapter>());
43+
44+
services.AddSingleton<AdaptiveCardConverter>();
45+
services.AddSingleton<IConversationReferenceStore, InMemoryConversationReferenceStore>();
46+
services.AddSingleton<ITeamsNotificationService, TeamsNotificationService>();
47+
48+
// Per-request / per-turn services.
49+
services.AddScoped<TeamsRequestState>();
50+
services.AddScoped<TeamsMessageHandler>();
51+
services.AddTransient<IBot, TeamsActivityBot>();
52+
}
53+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace BotSharp.Plugin.MicrosoftTeams.Models;
2+
3+
public class TeamsNotifyRequest
4+
{
5+
/// <summary>
6+
/// Target user id (AAD object id) the bot has previously interacted with.
7+
/// </summary>
8+
public string UserId { get; set; } = string.Empty;
9+
10+
/// <summary>
11+
/// When set, the agent generates the reply from <see cref="Prompt"/>.
12+
/// Otherwise <see cref="Text"/> is sent verbatim.
13+
/// </summary>
14+
public string? AgentId { get; set; }
15+
16+
public string? Prompt { get; set; }
17+
18+
public string? Text { get; set; }
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace BotSharp.Plugin.MicrosoftTeams.Models;
2+
3+
/// <summary>
4+
/// Per-request scoped holder that carries the routed agentId from the controller
5+
/// into the <c>IBot</c> turn (both are resolved within the same HTTP request scope).
6+
/// </summary>
7+
public class TeamsRequestState
8+
{
9+
public string AgentId { get; set; } = string.Empty;
10+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# BotSharp.Plugin.MicrosoftTeams
2+
3+
Two-way Microsoft Teams integration for BotSharp, built on **Azure Bot Service / Bot Framework SDK**.
4+
5+
- **Inbound** (user → bot): Teams activities arrive at the messaging endpoint and are routed into the BotSharp conversation engine.
6+
- **Outbound / proactive** (bot → user): the bot pushes unsolicited messages back to users it has previously talked to, via stored conversation references.
7+
8+
## Endpoints
9+
10+
| Method | Route | Auth | Purpose |
11+
|--------|-------|------|---------|
12+
| POST | `/teams/messages/{agentId}` | Bot Framework JWT | Azure Bot "messaging endpoint" (inbound) |
13+
| POST | `/teams/notify` | Platform `[Authorize]` | Proactive push (outbound) |
14+
15+
`/teams/notify` body:
16+
17+
```jsonc
18+
// send literal text
19+
{ "userId": "<aadObjectId>", "text": "Your ticket #123 was resolved." }
20+
21+
// or let an agent generate the reply
22+
{ "userId": "<aadObjectId>", "agentId": "<agentId>", "prompt": "Summarize ticket #123 status" }
23+
```
24+
25+
## Configuration
26+
27+
`appsettings.json`:
28+
29+
```jsonc
30+
"MicrosoftTeams": {
31+
"AppType": "MultiTenant", // MultiTenant | SingleTenant | UserAssignedMSI
32+
"AppId": "", // Azure Bot app (client) id
33+
"AppPassword": "", // client secret — use env var / Key Vault, never commit
34+
"TenantId": "", // required for SingleTenant / UserAssignedMSI
35+
"AgentId": "" // default agent for /teams/notify when prompt is used
36+
}
37+
```
38+
39+
> **Security:** `AppPassword` is a client secret — keep it out of source control (User Secrets / Key Vault / env var). For production prefer `UserAssignedMSI` (managed identity, no secret). The inbound action is `[AllowAnonymous]` only because request legitimacy is enforced by the Bot Framework JWT pipeline inside the adapter — that layer must stay enabled.
40+
41+
## Setup
42+
43+
1. **Azure Bot resource** — Azure Portal → create an *Azure Bot* → note the App ID and create a client secret. Under *Channels*, add **Microsoft Teams**. Set *Messaging endpoint* to `https://<host>/teams/messages/{agentId}` (substitute a real agent id).
44+
2. **Teams app package** — author `manifest.json` (see `manifest/` below), zip it with `color.png` (192×192) and `outline.png` (32×32), and side-load via the Teams *Developer Portal* or *Apps → Manage your apps → Upload a custom app*.
45+
3. **Register the plugin** — already added to `WebStarter/appsettings.json` `PluginLoader.Assemblies`. Fill in the `MicrosoftTeams` settings.
46+
4. **Test** — use the *Bot Framework Emulator* for local turn logic, then `devtunnel`/`ngrok` to expose the endpoint for real Teams side-loading.
47+
48+
## Rich content mapping
49+
50+
`AdaptiveCardConverter` maps BotSharp rich messages to Teams:
51+
52+
| BotSharp | Teams |
53+
|----------|-------|
54+
| `TextMessage` / plain content | text activity |
55+
| `QuickReplyMessage` | Adaptive Card with `Action.Submit` buttons (payload preserved) |
56+
| `ButtonTemplateMessage` | Adaptive Card: `web_url``Action.OpenUrl`, else `Action.Submit` |
57+
58+
Submit payloads come back in `Activity.Value.payload`, which the bot unwraps into the next user turn.
59+
60+
## Notes
61+
62+
- The conversation-reference store defaults to in-memory (single node). For multi-instance deployments, implement `IConversationReferenceStore` against a durable store (Mongo/Redis/BotSharp storage) and register it in place of `InMemoryConversationReferenceStore`.
63+
- The Bot Framework SDK is in long-term maintenance; the successor is the **Microsoft 365 Agents SDK**. The integration seam here (adapter + `IBot` + `ActivityHandler`) maps cleanly onto it if you migrate later.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using AdaptiveCards;
2+
using Microsoft.Bot.Builder;
3+
using Microsoft.Bot.Schema;
4+
5+
namespace BotSharp.Plugin.MicrosoftTeams.Services;
6+
7+
/// <summary>
8+
/// Maps a BotSharp <see cref="RoleDialogModel"/> reply to a Teams-renderable activity.
9+
/// Plain text becomes a text activity; rich content becomes an Adaptive Card so buttons /
10+
/// quick replies survive in Teams (which does not reliably support Bot Framework suggestedActions).
11+
/// </summary>
12+
public class AdaptiveCardConverter
13+
{
14+
private static readonly AdaptiveSchemaVersion Schema = new(1, 4);
15+
16+
public IActivity Convert(RoleDialogModel message)
17+
{
18+
var rich = message.RichContent?.Message;
19+
20+
switch (rich)
21+
{
22+
case QuickReplyMessage quickReply:
23+
return BuildCard(quickReply.Text, quickReply.QuickReplies.Select(q =>
24+
(AdaptiveAction)new AdaptiveSubmitAction
25+
{
26+
Title = q.Title,
27+
Data = new { payload = q.Payload ?? q.Title }
28+
}));
29+
30+
case ButtonTemplateMessage buttonTemplate:
31+
return BuildCard(buttonTemplate.Text, buttonTemplate.Buttons.Select(ToAction));
32+
33+
case TextMessage textMessage:
34+
return MessageFactory.Text(textMessage.Text);
35+
36+
case not null when !string.IsNullOrEmpty(rich.Text):
37+
return MessageFactory.Text(rich.Text);
38+
39+
default:
40+
return MessageFactory.Text(message.Content ?? string.Empty);
41+
}
42+
}
43+
44+
private static AdaptiveAction ToAction(ElementButton button)
45+
{
46+
if (string.Equals(button.Type, "web_url", StringComparison.OrdinalIgnoreCase)
47+
&& !string.IsNullOrEmpty(button.Url))
48+
{
49+
return new AdaptiveOpenUrlAction { Title = button.Title, Url = new Uri(button.Url) };
50+
}
51+
52+
return new AdaptiveSubmitAction
53+
{
54+
Title = button.Title,
55+
Data = new { payload = button.Payload ?? button.Title }
56+
};
57+
}
58+
59+
private static IActivity BuildCard(string text, IEnumerable<AdaptiveAction> actions)
60+
{
61+
var card = new AdaptiveCard(Schema);
62+
if (!string.IsNullOrEmpty(text))
63+
{
64+
card.Body.Add(new AdaptiveTextBlock { Text = text, Wrap = true });
65+
}
66+
card.Actions.AddRange(actions);
67+
68+
return MessageFactory.Attachment(new Microsoft.Bot.Schema.Attachment
69+
{
70+
ContentType = AdaptiveCard.ContentType,
71+
Content = card
72+
});
73+
}
74+
}

0 commit comments

Comments
 (0)