diff --git a/Cargo.lock b/Cargo.lock index 66afb6e99..9fb23a9d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4546,6 +4546,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -6391,6 +6406,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -8299,6 +8324,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -8469,8 +8506,10 @@ dependencies = [ "futures", "hex", "ignore", + "image", "imap", "indoc", + "jsonwebtoken", "lance-index", "lancedb", "lettre", diff --git a/Cargo.toml b/Cargo.toml index f6b6a0195..14757ece8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,10 +149,16 @@ bollard = "0.18" # Semver parsing (for update version comparison) semver = "1" +# JWT validation for inbound Bot Framework requests (ring-based, no openssl) +jsonwebtoken = "9.3" + # Skill installation zip = "2" tempfile = "3" +# Icon resizing for the generated Teams app package (already in the lockfile transitively). +image = { version = "0.25", default-features = false, features = ["png"] } + # Prometheus metrics (optional, behind "metrics" feature) prometheus = { version = "0.13", optional = true } pdf-extract = "0.10.0" diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index 2856c4dbe..d71b3a56d 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -1,6 +1,6 @@ --- title: Messaging -description: How Spacebot connects to Discord, Slack, Telegram, Twitch, Email, and webhooks. +description: How Spacebot connects to Discord, Slack, Microsoft Teams, Telegram, Twitch, Email, and webhooks. --- # Messaging @@ -13,6 +13,7 @@ Spacebot connects to chat platforms so your agent can talk to people where they |----------|--------|-------------| | [Discord](/docs/discord-setup) | Supported | Bot token + gateway connection | | [Slack](/docs/slack-setup) | Supported | Bot token + app token via Socket Mode | +| [Microsoft Teams](/docs/teams-setup) | Supported | Azure Bot + Entra app, public HTTPS endpoint | | [Telegram](/docs/telegram-setup) | Supported | Bot token via BotFather | | [Twitch](/docs/twitch-setup) | Supported | OAuth token via Twitch IRC | | [Email](/docs/email-setup) | Supported | IMAP polling + SMTP replies | diff --git a/docs/content/docs/(messaging)/meta.json b/docs/content/docs/(messaging)/meta.json index 3e5764b49..a9a7945c1 100644 --- a/docs/content/docs/(messaging)/meta.json +++ b/docs/content/docs/(messaging)/meta.json @@ -1,4 +1,4 @@ { "title": "Messaging", - "pages": ["messaging", "discord-setup", "slack-setup", "telegram-setup", "twitch-setup", "email-setup"] + "pages": ["messaging", "discord-setup", "slack-setup", "teams-setup", "telegram-setup", "twitch-setup", "email-setup"] } diff --git a/docs/content/docs/(messaging)/teams-setup.mdx b/docs/content/docs/(messaging)/teams-setup.mdx new file mode 100644 index 000000000..a590512a5 --- /dev/null +++ b/docs/content/docs/(messaging)/teams-setup.mdx @@ -0,0 +1,159 @@ +--- +title: Teams Setup +description: Connect Spacebot to Microsoft Teams. +--- + +# Teams Setup + +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. + +You need three values from Azure — an **App ID**, a **Client Secret**, and a **Tenant ID** — plus a public URL that forwards to Spacebot. + + +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. + + +## Step 1: Register an Entra app + +In the [Azure portal](https://portal.azure.com), go to **Microsoft Entra ID** → **App registrations** → **New registration**. + +Name it (e.g. "Spacebot"), pick the supported account types for your org, and register. + +From the app's **Overview**, copy the **Application (client) ID** and the **Directory (tenant) ID**. + +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**. + +## Step 2: Create the Azure Bot + +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). + +In the bot's **Configuration**, set the **Messaging endpoint** to your public URL with the `/api/messages` path: + +``` +https://your-public-host/api/messages +``` + +Then open **Channels** and add the **Microsoft Teams** channel. + +## Step 3: Build and install the Teams app + +Teams needs an app package (a zip with a `manifest.json` and two icons). + + + + +1. In **Settings → Channels**, open the Microsoft Teams card and enter your **App ID** (Step 1). +2. Click **Download Teams app package**. Spacebot builds a correct `manifest.json` (with the right schema, a distinct app id, and the Spacebot icon) and downloads `spacebot-teams-app.zip`. +3. Upload it in Teams (**Apps → Manage your apps → Upload an app**). + +The package uses the default Spacebot icon. To use your own, unzip it, replace `icon-color.png` (192×192) and `icon-outline.png` (32×32, transparent), and re-zip. + + + + +Build the package by hand if you prefer. The `manifest.json`: + + +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`); `validDomains` and `accentColor` are required; do **not** add a `packageName` key (schema 1.17 rejects it). + + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json", + "manifestVersion": "1.17", + "version": "1.0.0", + "id": "GENERATE-A-FRESH-GUID", + "developer": { "name": "Spacebot", "websiteUrl": "https://example.com", "privacyUrl": "https://example.com", "termsOfUseUrl": "https://example.com" }, + "icons": { "color": "icon-color.png", "outline": "icon-outline.png" }, + "name": { "short": "Spacebot", "full": "Spacebot — AI assistant" }, + "description": { "short": "AI assistant powered by Spacebot.", "full": "Chat with your Spacebot agent in Microsoft Teams." }, + "accentColor": "#6264A7", + "bots": [{ "botId": "YOUR-APP-ID", "scopes": ["personal", "team", "groupchat"], "supportsFiles": false, "isNotificationOnly": false }], + "validDomains": [] +} +``` + +Zip `manifest.json` + `icon-color.png` (192×192) + `icon-outline.png` (32×32) at the **root** of the archive. + + + + +Most tenants require admin approval: approve it in the **Teams admin center** under **Manage apps**, where you can also restrict who may install it. + +## Step 4: Configure Spacebot + + + + +1. Open your Spacebot dashboard +2. Go to **Settings** → **Channels** +3. Click **Setup** on the Microsoft Teams card +4. Paste your **App ID**, **Client Secret**, and **Tenant ID** from the steps above +5. Click **Save** + +Spacebot writes the config and starts the adapter. Make sure the bot's port is reachable through your public HTTPS endpoint. + + + + +Add a `[messaging.teams]` block to your config. Keep the client secret out of the file by referencing an environment variable. + +```toml +[messaging.teams] +enabled = true + +# App ID and Tenant ID from Step 1 (not secret). +app_id = "00000000-0000-0000-0000-000000000000" +tenant_id = "00000000-0000-0000-0000-000000000000" + +# Reference the secret from the environment; never hardcode it. +client_secret = "env:TEAMS_CLIENT_SECRET" + +# Inbound listener. The reverse proxy / tunnel forwards here. +port = 3979 +bind = "0.0.0.0" + +# Teams user IDs allowed to DM the bot. "*" allows everyone. +dm_allowed_users = ["*"] + +[[bindings]] +agent_id = "main" +channel = "teams" +``` + +Start Spacebot with the secret in the environment: + +```bash +TEAMS_CLIENT_SECRET="your-secret-value" spacebot start +``` + + + + +## DM permissions + +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. + +- To allow specific people, list their Teams user IDs (the `29:...` MRI strings). +- To allow everyone (org-wide deployments), set `dm_allowed_users = ["*"]`. + + +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. + + +## What works + +| Capability | Notes | +|------------|-------| +| Text replies | Plain text in DMs, channels, and group chats | +| @mentions | The bot responds when @mentioned in a channel or group | +| Adaptive Cards | Structured cards rendered from rich replies | +| Typing indicator | Shown while the bot is working | +| Inbound files and images | Delivered to the agent (personal scope) | +| Buttons | Adaptive Card buttons; a click comes back to the agent | + +## Limitations + +- **One Teams bot per instance.** A single default `[messaging.teams]` adapter is supported. Named multi-bot instances are not yet available. +- **Files are personal-scope.** Inbound file attachments work in 1:1 chats; channel files require Microsoft Graph and are out of scope. +- **Rotate the client secret** before relying on it in production, and keep it in the environment rather than the config file. diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 51f4038f8..a80ea816c 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -2163,6 +2163,11 @@ export const api = { ); }, + teamsAppPackageUrl: (appId: string) => { + const params = new URLSearchParams({ app_id: appId }); + return `${getApiBase()}/messaging/teams/app-package?${params.toString()}`; + }, + attachmentUrl: (agentId: string, attachmentId: string, opts?: { thumbnail?: boolean; download?: boolean }) => { const params = new URLSearchParams(); if (opts?.thumbnail) params.set("thumbnail", "true"); diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 01ec025cd..596325118 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -685,7 +685,7 @@ export interface paths { }; /** * Serve a saved attachment file. - * @description Streams the file from disk with the correct Content-Type. + * @description Reads the file from disk with the correct Content-Type. * Use `?download=true` to force a download prompt. * Use `?thumbnail=true` to request a thumbnail (currently serves full file). */ @@ -736,6 +736,28 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/{agent_id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Manually wake a (typically dormant) agent. + * @description Fires the same wake path that `send_agent_message`, cron, and other + * trigger sources use. Useful for debugging dormant deployments and + * recovering an agent stuck on a missed trigger. + */ + post: operations["wake_agent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bindings": { parameters: { query?: never; @@ -1417,6 +1439,22 @@ export interface paths { patch?: never; trace?: never; }; + "/messaging/teams/app-package": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["download_teams_app_package"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/messaging/toggle": { parameters: { query?: never; @@ -3160,6 +3198,11 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Scope this secret belongs to. Older exports without a `scope` field + * import as `InstanceShared` so existing backups continue to load. + */ + scope?: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; value: string; @@ -3272,6 +3315,9 @@ export interface components { signal_http_url?: string | null; slack_app_token?: string | null; slack_bot_token?: string | null; + teams_app_id?: string | null; + teams_client_secret?: string | null; + teams_tenant_id?: string | null; telegram_token?: string | null; twitch_client_id?: string | null; twitch_client_secret?: string | null; @@ -3923,12 +3969,36 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Visibility scope. `InstanceShared` is the default for system / + * admin-managed secrets; `Agent` rows are per-agent tool credentials. + */ + scope: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; }; SecretListResponse: { secrets: components["schemas"]["SecretListItem"][]; }; + /** + * @description Secret scope determines visibility across agents on a shared instance. + * + * Orthogonal to `SecretCategory` — `System` secrets are always + * `InstanceShared` (singleton consumers like `LlmManager` / + * `MessagingManager`); `Tool` secrets default to `Agent(...)` for + * agentic-backend deployments where each tenant's worker subprocess must + * not see another tenant's credentials, but can also be `InstanceShared` + * when a single-tenant deployment legitimately wants every agent to share + * the same `Tool` secret (e.g. one repo-wide `GH_TOKEN`). + */ + SecretScope: { + /** @enum {string} */ + kind: "instance_shared"; + } | { + agent_id: string; + /** @enum {string} */ + kind: "agent"; + }; SetChannelArchiveRequest: { agent_id: string; archived: boolean; @@ -4079,6 +4149,8 @@ export interface components { /** Format: int64 */ reasoning: number; }; + /** @enum {string} */ + ToolResultStatus: "pending" | "final" | "waiting_for_input"; ToolsResponse: { binaries: components["schemas"]["BinaryEntry"][]; tools_bin: string; @@ -4133,7 +4205,10 @@ export interface components { type: "system_text"; } | { call_id: string; + /** @description Accumulated streaming output for live display. Cleared when tool completes. */ + live_output?: string | null; name: string; + status?: components["schemas"]["ToolResultStatus"]; text: string; /** @enum {string} */ type: "tool_result"; @@ -4340,6 +4415,11 @@ export interface components { /** Format: int64 */ request_count: number; }; + WakeAgentResponse: { + agent_id: string; + fired: boolean; + message: string; + }; WarmupSection: { eager_embedding_load: boolean; enabled: boolean; @@ -6483,6 +6563,35 @@ export interface operations { }; }; }; + wake_agent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent ID */ + agent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WakeAgentResponse"]; + }; + }; + /** @description Wake manager not running */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_bindings: { parameters: { query?: { @@ -8079,6 +8188,43 @@ export interface operations { }; }; }; + download_teams_app_package: { + parameters: { + query: { + /** @description Bot App (client) ID */ + app_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Teams app package (zip) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/zip": unknown; + }; + }; + /** @description Missing or invalid app_id */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Failed to build package */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; toggle_platform: { parameters: { query?: never; diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index 3f45baacd..0a0a62198 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -40,7 +40,8 @@ type Platform = | "email" | "webhook" | "mattermost" - | "signal"; + | "signal" + | "teams"; const PLATFORM_LABELS: Record = { discord: "Discord", @@ -51,6 +52,7 @@ const PLATFORM_LABELS: Record = { webhook: "Webhook", mattermost: "Mattermost", signal: "Signal", + teams: "Microsoft Teams", }; const DOC_LINKS: Partial> = { @@ -60,6 +62,7 @@ const DOC_LINKS: Partial> = { twitch: "https://docs.spacebot.sh/twitch-setup", mattermost: "https://docs.spacebot.sh/mattermost-setup", signal: "https://docs.spacebot.sh/signal-setup", + teams: "https://docs.spacebot.sh/teams-setup", }; // --- Platform Catalog (Left Column) --- @@ -78,6 +81,7 @@ export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) { "webhook", "mattermost", "signal", + "teams", ]; const COMING_SOON = [ @@ -811,6 +815,22 @@ export function AddInstanceCard({ credentials.signal_dm_allowed_users = result.entries.join(","); } } + } else if (platform === "teams") { + if ( + !credentialInputs.teams_app_id?.trim() || + !credentialInputs.teams_client_secret?.trim() || + !credentialInputs.teams_tenant_id?.trim() + ) { + setMessage({ + text: "App ID, client secret, and tenant ID are required", + type: "error", + }); + return; + } + credentials.teams_app_id = credentialInputs.teams_app_id.trim(); + credentials.teams_client_secret = + credentialInputs.teams_client_secret.trim(); + credentials.teams_tenant_id = credentialInputs.teams_tenant_id.trim(); } if (!isDefault && !instanceName.trim()) { @@ -1304,6 +1324,80 @@ export function AddInstanceCard({ )} + {platform === "teams" && ( + <> +
+ + + setCredentialInputs({ + ...credentialInputs, + teams_app_id: e.target.value, + }) + } + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + teams_client_secret: e.target.value, + }) + } + placeholder="Your Azure app client secret" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + teams_tenant_id: e.target.value, + }) + } + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + }} + /> +
+ {(credentialInputs.teams_app_id ?? "").trim() !== "" && ( + <> +
+ + Download Teams app package + +
+

+ The package uses the default Spacebot icon. To use your own, unzip it, replace + icon-color.png (192×192) and icon-outline.png (32×32), and re-zip. +

+ + )} + + )} + {platform === "signal" && ( <>
@@ -1521,7 +1615,7 @@ function BindingForm({
)} - {(platform === "discord" || platform === "slack") && ( + {(platform === "discord" || platform === "slack" || platform === "teams") && (