Skip to content

Commit e61e094

Browse files
committed
feat(messaging): add Microsoft Teams channel adapter
Add a Microsoft Teams channel adapter built on the Azure Bot Service / Bot Framework Connector protocol. Inbound: an axum webhook server (POST /api/messages) validates the Azure-signed JWT (RS256, JWKS from login.botframework.com, iss/aud/exp), parses Activities into InboundMessage, and enforces per-conversation permissions (DM allowlist with a "*" wildcard; channels open on @mention). Outbound: replies and proactive broadcasts go to the Bot Connector with an Azure AD client-credentials token, guarded by a serviceUrl SSRF allowlist (*.botframework.com / *.trafficmanager.net), with a per-conversation serviceUrl sidecar so proactive sends survive restarts. Capabilities: text and @mentions; Adaptive Cards and card buttons (interactive_elements render as Action.Submit; a click returns as MessageContent::Interaction); a typing indicator; inbound file and image attachments (MessageContent::Media, with the download bearer gated to allowlisted hosts). Config: a [messaging.teams] section (shaped like the other adapters), hot-reloadable permissions via the config watcher, and the client secret sourced from the environment. Setup guide in docs/content/docs/(messaging). Adds one dependency, jsonwebtoken (ring-based). One Teams bot per instance.
1 parent ac52277 commit e61e094

18 files changed

Lines changed: 4336 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ bollard = "0.18"
149149
# Semver parsing (for update version comparison)
150150
semver = "1"
151151

152+
# JWT validation for inbound Bot Framework requests (ring-based, no openssl)
153+
jsonwebtoken = "9.3"
154+
152155
# Skill installation
153156
zip = "2"
154157
tempfile = "3"

docs/content/docs/(messaging)/messaging.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Messaging
3-
description: How Spacebot connects to Discord, Slack, Telegram, Twitch, Email, and webhooks.
3+
description: How Spacebot connects to Discord, Slack, Microsoft Teams, Telegram, Twitch, Email, and webhooks.
44
---
55

66
# Messaging
@@ -13,6 +13,7 @@ Spacebot connects to chat platforms so your agent can talk to people where they
1313
|----------|--------|-------------|
1414
| [Discord](/docs/discord-setup) | Supported | Bot token + gateway connection |
1515
| [Slack](/docs/slack-setup) | Supported | Bot token + app token via Socket Mode |
16+
| [Microsoft Teams](/docs/teams-setup) | Supported | Azure Bot + Entra app, public HTTPS endpoint |
1617
| [Telegram](/docs/telegram-setup) | Supported | Bot token via BotFather |
1718
| [Twitch](/docs/twitch-setup) | Supported | OAuth token via Twitch IRC |
1819
| [Email](/docs/email-setup) | Supported | IMAP polling + SMTP replies |
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"title": "Messaging",
3-
"pages": ["messaging", "discord-setup", "slack-setup", "telegram-setup", "twitch-setup", "email-setup"]
3+
"pages": ["messaging", "discord-setup", "slack-setup", "teams-setup", "telegram-setup", "twitch-setup", "email-setup"]
44
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
title: Teams Setup
3+
description: Connect Spacebot to Microsoft Teams.
4+
---
5+
6+
# Teams Setup
7+
8+
Connect Spacebot to Microsoft Teams as a bot. Teams talks to your bot over the Azure Bot Service, so this takes a bit more setup than the other channels: an Entra (Azure AD) app registration, an Azure Bot resource, a Teams app package, and a public HTTPS endpoint Microsoft can reach.
9+
10+
You need three values from Azure — an **App ID**, a **Client Secret**, and a **Tenant ID** — plus a public URL that forwards to Spacebot.
11+
12+
<Callout type="warn">
13+
Teams requires a **public HTTPS endpoint**. The bot does not connect outbound like Slack; the Azure Bot Service delivers messages by POSTing to a URL you register. A localhost bind is not reachable. Put Spacebot behind a reverse proxy (Caddy, nginx) or a tunnel (cloudflared) that terminates TLS and forwards to the bot's port.
14+
</Callout>
15+
16+
## Step 1: Register an Entra app
17+
18+
In the [Azure portal](https://portal.azure.com), go to **Microsoft Entra ID****App registrations****New registration**.
19+
20+
Name it (e.g. "Spacebot"), pick the supported account types for your org, and register.
21+
22+
From the app's **Overview**, copy the **Application (client) ID** and the **Directory (tenant) ID**.
23+
24+
Then go to **Certificates & secrets****New client secret**, create one, and copy its **Value** immediately (it is shown only once). This is your **Client Secret**.
25+
26+
## Step 2: Create the Azure Bot
27+
28+
Create an **Azure Bot** resource (the free **F0** SKU is enough). Point it at the app you just registered (use the existing App ID rather than creating a new identity).
29+
30+
In the bot's **Configuration**, set the **Messaging endpoint** to your public URL with the `/api/messages` path:
31+
32+
```
33+
https://your-public-host/api/messages
34+
```
35+
36+
Then open **Channels** and add the **Microsoft Teams** channel.
37+
38+
## Step 3: Build and install the Teams app
39+
40+
Teams needs an app package (a zip with a `manifest.json` and two icons) to surface the bot to users.
41+
42+
<Callout type="info">
43+
The manifest `id` (the Teams app id) must be its **own** GUID, distinct from the bot's App ID. `bots[].botId` is the App ID from Step 1. Scopes are lowercase (`personal`, `team`, `groupchat`), and `validDomains` plus an `accentColor` are required.
44+
</Callout>
45+
46+
Upload the package in Teams (**Apps****Manage your apps****Upload an app**). Most tenants require admin approval: approve it in the **Teams admin center** under **Manage apps**, where you can also restrict who may install it.
47+
48+
## Step 4: Configure Spacebot
49+
50+
Add a `[messaging.teams]` block to your config. Keep the client secret out of the file by referencing an environment variable.
51+
52+
```toml
53+
[messaging.teams]
54+
enabled = true
55+
56+
# App ID and Tenant ID from Step 1 (not secret).
57+
app_id = "00000000-0000-0000-0000-000000000000"
58+
tenant_id = "00000000-0000-0000-0000-000000000000"
59+
60+
# Reference the secret from the environment; never hardcode it.
61+
client_secret = "env:TEAMS_CLIENT_SECRET"
62+
63+
# Inbound listener. The reverse proxy / tunnel forwards here.
64+
port = 3979
65+
bind = "0.0.0.0"
66+
67+
# Teams user IDs allowed to DM the bot. "*" allows everyone.
68+
dm_allowed_users = ["*"]
69+
70+
[[bindings]]
71+
agent_id = "main"
72+
channel = "teams"
73+
```
74+
75+
Start Spacebot with the secret in the environment:
76+
77+
```bash
78+
TEAMS_CLIENT_SECRET="your-secret-value" spacebot start
79+
```
80+
81+
## DM permissions
82+
83+
Channel and group messages are open: the bot replies wherever it is @mentioned. Direct messages are **fail-closed** — an empty `dm_allowed_users` blocks every DM.
84+
85+
- To allow specific people, list their Teams user IDs (the `29:...` MRI strings).
86+
- To allow everyone (org-wide deployments), set `dm_allowed_users = ["*"]`.
87+
88+
<Callout type="info">
89+
You usually do not know a user's `29:...` MRI up front. The simplest path is to **@mention the bot in a channel** (channel messages are not gated), or set the `"*"` wildcard. To allowlist one person, send a DM once (it is dropped), read the dropped `sender_id` from the logs, and add that value.
90+
</Callout>
91+
92+
## What works
93+
94+
| Capability | Notes |
95+
|------------|-------|
96+
| Text replies | Plain text in DMs, channels, and group chats |
97+
| @mentions | The bot responds when @mentioned in a channel or group |
98+
| Adaptive Cards | Structured cards rendered from rich replies |
99+
| Typing indicator | Shown while the bot is working |
100+
| Inbound files and images | Delivered to the agent (personal scope) |
101+
| Buttons | Adaptive Card buttons; a click comes back to the agent |
102+
103+
## Limitations
104+
105+
- **One Teams bot per instance.** A single default `[messaging.teams]` adapter is supported. Named multi-bot instances are not yet available.
106+
- **Files are personal-scope.** Inbound file attachments work in 1:1 chats; channel files require Microsoft Graph and are out of scope.
107+
- **Rotate the client secret** before relying on it in production, and keep it in the environment rather than the config file.

src/agent/channel.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3886,6 +3886,13 @@ fn compute_listen_mode_invocation(message: &InboundMessage, raw_text: &str) -> (
38863886
.get("twitch_mentions_or_replies_to_bot")
38873887
.and_then(|v| v.as_bool())
38883888
.unwrap_or(false),
3889+
"teams" => {
3890+
message
3891+
.metadata
3892+
.get("teams_mentioned")
3893+
.and_then(|v| v.as_str())
3894+
== Some("true")
3895+
}
38893896
_ => false,
38903897
};
38913898
let invoked_by_reply = match message.source.as_str() {
@@ -4453,6 +4460,36 @@ mod tests {
44534460
));
44544461
}
44554462

4463+
#[test]
4464+
fn teams_mention_metadata_true_string_yields_invoked_by_mention() {
4465+
let message = inbound_message(
4466+
"teams",
4467+
&[("teams_mentioned", serde_json::Value::String("true".into()))],
4468+
"hey bot",
4469+
);
4470+
4471+
let (invoked_by_command, invoked_by_mention, invoked_by_reply) =
4472+
compute_listen_mode_invocation(&message, "hey bot");
4473+
4474+
assert!(!invoked_by_command);
4475+
assert!(invoked_by_mention);
4476+
assert!(!invoked_by_reply);
4477+
}
4478+
4479+
#[test]
4480+
fn teams_mention_metadata_false_string_does_not_invoke() {
4481+
let message = inbound_message(
4482+
"teams",
4483+
&[("teams_mentioned", serde_json::Value::String("false".into()))],
4484+
"hey bot",
4485+
);
4486+
4487+
let (_invoked_by_command, invoked_by_mention, _invoked_by_reply) =
4488+
compute_listen_mode_invocation(&message, "hey bot");
4489+
4490+
assert!(!invoked_by_mention);
4491+
}
4492+
44564493
#[test]
44574494
fn is_dm_conversation_id_detects_dm_patterns() {
44584495
// Slack DMs — channel ID starts with 'D'

src/config.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub use load::set_resolve_secrets_store;
1616
pub use onboarding::run_onboarding;
1717
pub use permissions::{
1818
DiscordPermissions, MattermostPermissions, SignalPermissions, SlackPermissions,
19-
TelegramPermissions, TwitchPermissions,
19+
TeamsPermissions, TelegramPermissions, TwitchPermissions,
2020
};
2121
pub(crate) use providers::default_provider_config;
2222
pub use runtime::RuntimeConfig;
@@ -1637,6 +1637,7 @@ id = "main"
16371637
twitch: None,
16381638
signal: None,
16391639
mattermost: None,
1640+
teams: None,
16401641
};
16411642
let bindings = vec![
16421643
Binding {
@@ -1687,6 +1688,7 @@ id = "main"
16871688
twitch: None,
16881689
signal: None,
16891690
mattermost: None,
1691+
teams: None,
16901692
};
16911693
let bindings = vec![Binding {
16921694
agent_id: "main".into(),
@@ -1755,6 +1757,7 @@ id = "main"
17551757
twitch: None,
17561758
signal: None,
17571759
mattermost: None,
1760+
teams: None,
17581761
};
17591762
let bindings = vec![Binding {
17601763
agent_id: "main".into(),
@@ -1798,6 +1801,7 @@ id = "main"
17981801
twitch: None,
17991802
signal: None,
18001803
mattermost: None,
1804+
teams: None,
18011805
};
18021806
// Binding targets default adapter, but no default credentials exist
18031807
let bindings = vec![Binding {
@@ -1842,6 +1846,7 @@ id = "main"
18421846
twitch: None,
18431847
signal: None,
18441848
mattermost: None,
1849+
teams: None,
18451850
};
18461851
let bindings = vec![
18471852
// Valid: default adapter with credentials
@@ -1923,6 +1928,7 @@ id = "main"
19231928
twitch: None,
19241929
signal: None,
19251930
mattermost: None,
1931+
teams: None,
19261932
};
19271933
let bindings = vec![Binding {
19281934
agent_id: "main".into(),
@@ -1956,6 +1962,7 @@ id = "main"
19561962
twitch: None,
19571963
signal: None,
19581964
mattermost: None,
1965+
teams: None,
19591966
};
19601967
let bindings = vec![Binding {
19611968
agent_id: "main".into(),
@@ -1998,6 +2005,7 @@ id = "main"
19982005
twitch: None,
19992006
signal: None,
20002007
mattermost: None,
2008+
teams: None,
20012009
};
20022010
let bindings = vec![Binding {
20032011
agent_id: "main".into(),
@@ -2036,6 +2044,7 @@ id = "main"
20362044
twitch: None,
20372045
signal: None,
20382046
mattermost: None,
2047+
teams: None,
20392048
};
20402049
let bindings = vec![Binding {
20412050
agent_id: "main".into(),

0 commit comments

Comments
 (0)