Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 56 additions & 59 deletions packages/adapter-teams/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pnpm add @chat-adapter/teams

## Usage

The adapter auto-detects `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, and `TEAMS_APP_TENANT_ID` from environment variables:
The adapter auto-detects `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID` from environment variables:

```typescript
import { Chat } from "chat";
Expand All @@ -22,9 +22,7 @@ import { createTeamsAdapter } from "@chat-adapter/teams";
const bot = new Chat({
userName: "mybot",
adapters: {
teams: createTeamsAdapter({
appType: "SingleTenant",
}),
teams: createTeamsAdapter(),
},
});

Expand All @@ -45,19 +43,18 @@ bot.onNewMention(async (thread, message) => {
- **Subscription**: Your Azure subscription
- **Resource group**: Create new or use existing
- **Pricing tier**: F0 (free) for testing
- **Type of App**: **Single Tenant** (recommended for enterprise)
- **Creation type**: **Create new Microsoft App ID**
5. Click **Review + create** then **Create**

### 2. Get app credentials

1. Go to your Bot resource then **Configuration**
2. Copy **Microsoft App ID** as `TEAMS_APP_ID`
2. Copy **Microsoft App ID** as `CLIENT_ID`
3. Click **Manage Password** (next to Microsoft App ID)
4. In the App Registration page, go to **Certificates & secrets**
5. Click **New client secret**, add description, select expiry, click **Add**
6. Copy the **Value** immediately (shown only once) as `TEAMS_APP_PASSWORD`
7. Go to **Overview** and copy **Directory (tenant) ID** as `TEAMS_APP_TENANT_ID`
6. Copy the **Value** immediately (shown only once) as `CLIENT_SECRET`
7. Go to **Overview** and copy **Directory (tenant) ID** as `TENANT_ID`

### 3. Configure messaging endpoint

Expand Down Expand Up @@ -134,79 +131,83 @@ Create icon files (32x32 `outline.png` and 192x192 `color.png`), then zip all th

## Configuration

All options are auto-detected from environment variables when not provided.
The config extends `AppOptions` from `@microsoft/teams.apps`. All options are auto-detected from environment variables when not provided.

| Option | Required | Description |
|--------|----------|-------------|
| `appId` | No* | Azure Bot App ID. Auto-detected from `TEAMS_APP_ID` |
| `appPassword` | No** | Azure Bot App Password. Auto-detected from `TEAMS_APP_PASSWORD` |
| `certificate` | No** | Certificate-based authentication config |
| `federated` | No** | Federated (workload identity) authentication config |
| `appType` | No | `"MultiTenant"` or `"SingleTenant"` (default: `"MultiTenant"`) |
| `appTenantId` | For SingleTenant | Azure AD Tenant ID. Auto-detected from `TEAMS_APP_TENANT_ID` |
| `clientId` | No* | Azure Bot App ID. Auto-detected from `CLIENT_ID` |
| `clientSecret` | No** | Azure Bot App Secret. Auto-detected from `CLIENT_SECRET` |
| `tenantId` | No | Azure AD Tenant ID. Auto-detected from `TENANT_ID` |
| `token` | No** | Custom token provider function |
| `managedIdentityClientId` | No** | Federated identity: managed identity client ID or `"system"`. Auto-detected from `MANAGED_IDENTITY_CLIENT_ID` |
| `serviceUrl` | No | Override Bot Framework service URL. Auto-detected from `SERVICE_URL` |
| `userName` | No | Bot display name (default: `"bot"`) |
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) |

\*`appId` is required — either via config or `TEAMS_APP_ID` env var.
\*`clientId` is required — either via config or `CLIENT_ID` env var.

\*\*Exactly one authentication method is required: `appPassword`, `certificate`, or `federated`.
\*\*At least one authentication method is required: `clientSecret`, `token`, or `managedIdentityClientId`. When none is provided, `CLIENT_SECRET` is auto-detected from environment.

### Authentication methods

The adapter supports three mutually exclusive authentication methods. When no explicit auth is provided, `TEAMS_APP_PASSWORD` is auto-detected from environment variables.
The adapter supports the same authentication methods as the Teams SDK. When no explicit auth config is provided, credentials are auto-detected from environment variables.

#### Client secret (default)

The simplest option — provide `appPassword` directly or set `TEAMS_APP_PASSWORD`:
The simplest option — provide `clientSecret` directly or set `CLIENT_ID` + `CLIENT_SECRET`:

```typescript
createTeamsAdapter({
appPassword: "your_app_password_here",
clientSecret: "your_app_secret_here",
});
```

#### Certificate
#### User managed identity

Authenticate with a PEM certificate. Provide either `certificateThumbprint` or `x5c` (public certificate for subject-name validation):
Passwordless authentication using Azure managed identities — no secrets to rotate. Activates when `CLIENT_ID` is set without `CLIENT_SECRET`:

```typescript
createTeamsAdapter({
certificate: {
certificatePrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
certificateThumbprint: "AB1234...", // hex-encoded thumbprint
},
// No clientSecret — uses managed identity automatically
});
```

Or with subject-name validation:
#### Federated identity credentials

Advanced identity federation that assigns managed identities to your App Registration. Uses `managedIdentityClientId` (or `MANAGED_IDENTITY_CLIENT_ID` env var):

```typescript
// User-assigned managed identity
createTeamsAdapter({
certificate: {
certificatePrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
x5c: "-----BEGIN CERTIFICATE-----\n...",
},
managedIdentityClientId: "your_managed_identity_client_id",
});

// System-assigned managed identity
createTeamsAdapter({
managedIdentityClientId: "system",
});
```

#### Federated (workload identity)
#### Custom token provider

For environments with managed identities (e.g. Azure Kubernetes Service, GitHub Actions):
Provide a function that returns tokens for full control over authentication:

```typescript
createTeamsAdapter({
federated: {
clientId: "your_managed_identity_client_id_here",
clientAudience: "api://AzureADTokenExchange", // optional, this is the default
token: async (scope, tenantId) => {
return await getTokenFromVault(scope);
},
});
```

## Environment variables

```bash
TEAMS_APP_ID=...
TEAMS_APP_PASSWORD=...
TEAMS_APP_TENANT_ID=... # Required for SingleTenant
CLIENT_ID=...
CLIENT_SECRET=... # Omit to use user managed identity
MANAGED_IDENTITY_CLIENT_ID=... # For federated identity credentials
TENANT_ID=... # Required for single-tenant apps
SERVICE_URL=... # Optional: override Bot Framework service URL
```

## Features
Expand Down Expand Up @@ -240,40 +241,36 @@ TEAMS_APP_TENANT_ID=... # Required for SingleTenant
|---------|-----------|
| Slash commands | No |
| Mentions | Yes |
| Add reactions | No |
| Remove reactions | No |
| Typing indicator | No |
| Add reactions | Yes |
| Remove reactions | Yes |
| Receive reactions | Yes |
| Typing indicator | Yes |
| DMs | Yes |
| Ephemeral messages | No (DM fallback) |

### Message history

| Feature | Supported |
|---------|-----------|
| Fetch messages | Yes |
| Fetch messages | Yes (requires Graph permissions) |
| Fetch single message | No |
| Fetch thread info | Yes |
| Fetch channel messages | Yes |
| List threads | Yes |
| Fetch channel info | Yes |
| Fetch channel messages | Yes (requires Graph permissions) |
| List threads | Yes (requires Graph permissions) |
| Fetch channel info | Yes (requires Graph permissions) |
| Post channel message | Yes |

## Limitations

- **Adding reactions**: Teams Bot Framework doesn't support bots adding reactions. Calling `addReaction()` or `removeReaction()` throws a `NotImplementedError`. The bot can still receive reaction events via `onReaction()`.
- **Typing indicators**: Not available via Bot Framework. `startTyping()` is a no-op.

### Message history (`fetchMessages`)
## Message history (`fetchMessages`)

Fetching message history requires the Microsoft Graph API with client credentials flow. To enable it:

1. Set `appTenantId` in the adapter config
1. Set `tenantId` in the adapter config (or `TENANT_ID` env var)
2. Grant one of these Azure AD app permissions:
- `ChatMessage.Read.Chat`
- `Chat.Read.All`
- `Chat.Read.WhereInstalled`

Without these permissions, `fetchMessages` will not be able to retrieve channel history.
Without these permissions, `fetchMessages` will throw a `NotImplementedError`.

### Receiving all messages

Expand All @@ -300,11 +297,11 @@ Alternatively, configure the bot in Azure to receive all messages.

### "Unauthorized" error

- Verify `TEAMS_APP_ID` and your chosen auth credential are correct
- For client secret auth, check that `TEAMS_APP_PASSWORD` is valid
- For certificate auth, ensure the private key and thumbprint/x5c match what's registered in Azure AD
- For federated auth, verify the managed identity client ID and audience are correct
- For SingleTenant apps, ensure `TEAMS_APP_TENANT_ID` is set
- Verify `CLIENT_ID` and your chosen auth credential are correct
- For client secret auth, check that `CLIENT_SECRET` is valid and not expired
- For user managed identity, ensure `CLIENT_SECRET` is not set so the SDK uses managed identity
- For federated identity, verify `MANAGED_IDENTITY_CLIENT_ID` and that federated credentials are configured in Azure AD
- Ensure `TENANT_ID` is set for single-tenant apps
- Check that the messaging endpoint URL is correct in Azure

### Bot not appearing in Teams
Expand Down
8 changes: 4 additions & 4 deletions packages/adapter-teams/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@azure/identity": "^4.13.0",
"@chat-adapter/shared": "workspace:*",
"botbuilder": "^4.23.1",
"botframework-connector": "^4.23.3",
"@microsoft/teams.api": "^2.0.6",
"@microsoft/teams.apps": "^2.0.6",
"@microsoft/teams.graph-endpoints": "^2.0.6",
"chat": "workspace:*"
},
"devDependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7",
"@microsoft/teams.graph": "^2.0.6",
"@types/node": "^25.3.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
Expand Down
93 changes: 93 additions & 0 deletions packages/adapter-teams/src/bridge-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* BridgeHttpAdapter — a virtual IHttpServerAdapter that captures the route
* handler registered by App.initialize() and exposes dispatch() for
* handleWebhook() to call. We never own the HTTP server.
*
* Also manages per-request WebhookOptions so event handlers can retrieve
* the correct options for their activity without shared mutable state.
*/

import type {
HttpMethod,
HttpRouteHandler,
IHttpServerAdapter,
} from "@microsoft/teams.apps";
import type { Logger, WebhookOptions } from "chat";

export class BridgeHttpAdapter implements IHttpServerAdapter {
private handler: HttpRouteHandler | null = null;
private readonly webhookOptionsMap = new Map<string, WebhookOptions>();
private readonly logger: Logger;

constructor(logger: Logger) {
this.logger = logger;
}

registerRoute(
_method: HttpMethod,
_path: string,
handler: HttpRouteHandler
): void {
this.handler = handler;
}

async dispatch(
request: Request,
options?: WebhookOptions
): Promise<Response> {
const body = await request.text();
this.logger.debug("Teams webhook raw body", { body });

let parsedBody: unknown;
try {
parsedBody = JSON.parse(body);
} catch (e) {
this.logger.error("Failed to parse request body", { error: e });
return new Response("Invalid JSON", { status: 400 });
}

if (!this.handler) {
return new Response(
JSON.stringify({ error: "No handler registered" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}

const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});

const activityId = (parsedBody as { id?: string })?.id;
if (activityId && options) {
this.webhookOptionsMap.set(activityId, options);
}

try {
const serverResponse = await this.handler({ body: parsedBody, headers });

return new Response(
serverResponse.body ? JSON.stringify(serverResponse.body) : "{}",
{
status: serverResponse.status,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
this.logger.error("Bridge adapter dispatch error", { error });
return new Response(
JSON.stringify({ error: "Internal error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
} finally {
if (activityId) {
this.webhookOptionsMap.delete(activityId);
}
}
}

getWebhookOptions(activityId: string | undefined): WebhookOptions | undefined {
if (!activityId) return undefined;
return this.webhookOptionsMap.get(activityId);
}
}
45 changes: 45 additions & 0 deletions packages/adapter-teams/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { AppOptions, IPlugin } from "@microsoft/teams.apps";
import type { TeamsAdapterConfig } from "./types";

/**
* Convert TeamsAdapterConfig (public API) to the Teams SDK AppOptions.
*
* Historically, TeamsAdapterConfig was built with BotFramework, which is now deprecated.
*
*/
export function toAppOptions(
config: TeamsAdapterConfig
): Omit<AppOptions<IPlugin>, "httpServerAdapter"> {
if (config.certificate) {
throw new Error(
"Certificate-based authentication is not yet supported by the Teams SDK adapter. " +
"Use appPassword (client secret) or federated (workload identity) authentication instead."
);
}

const clientId = config.appId ?? process.env.TEAMS_APP_ID;
const clientSecret = config.federated
? undefined
: (config.appPassword ?? process.env.TEAMS_APP_PASSWORD);

// For SingleTenant, tenantId is required. For MultiTenant, omit it.
const tenantId =
config.appType === "MultiTenant"
? undefined
: (config.appTenantId ?? process.env.TEAMS_APP_TENANT_ID);

if (config.federated?.clientAudience) {
config.logger?.warn(
"federated.clientAudience is not supported by the Teams SDK and will be ignored."
);
}

const managedIdentityClientId = config.federated?.clientId;

return {
...(clientId ? { clientId } : {}),
...(clientSecret ? { clientSecret } : {}),
...(tenantId ? { tenantId } : {}),
...(managedIdentityClientId ? { managedIdentityClientId } : {}),
};
}
Loading