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
5 changes: 2 additions & 3 deletions examples/nextjs-chat/src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,10 @@ export function buildAdapters(): Adapters {
);
}

// Teams adapter (optional) - env vars: TEAMS_APP_ID, TEAMS_APP_PASSWORD
if (process.env.TEAMS_APP_ID) {
// Teams adapter (optional) - env vars: CLIENT_ID or TEAMS_APP_ID
if (process.env.CLIENT_ID || process.env.TEAMS_APP_ID) {
adapters.teams = withRecording(
createTeamsAdapter({
appType: "SingleTenant",
userName: "Chat SDK Demo",
logger: logger.child("teams"),
}),
Expand Down
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
7 changes: 3 additions & 4 deletions packages/adapter-teams/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,13 @@
"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",
"@types/node": "^25.3.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
Expand Down
32 changes: 32 additions & 0 deletions packages/adapter-teams/src/bridge-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* 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.
*/

import type {
HttpMethod,
HttpRouteHandler,
IHttpServerAdapter,
IHttpServerRequest,
IHttpServerResponse,
} from "@microsoft/teams.apps";

export class BridgeHttpAdapter implements IHttpServerAdapter {
private handler: HttpRouteHandler | null = null;

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

async dispatch(request: IHttpServerRequest): Promise<IHttpServerResponse> {
if (!this.handler) {
return { status: 500, body: { error: "No handler registered" } };
}
return this.handler(request);
}
}
92 changes: 92 additions & 0 deletions packages/adapter-teams/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
AdapterRateLimitError,
AuthenticationError,
NetworkError,
PermissionError,
} from "@chat-adapter/shared";
import { describe, expect, it } from "vitest";
import { handleTeamsError } from "./errors";

describe("handleTeamsError", () => {
it("should throw AuthenticationError for 401 status", () => {
expect(() =>
handleTeamsError(
{ statusCode: 401, message: "Unauthorized" },
"postMessage"
)
).toThrow(AuthenticationError);
});

it("should throw AuthenticationError for 403 status", () => {
expect(() =>
handleTeamsError({ statusCode: 403, message: "Forbidden" }, "postMessage")
).toThrow(AuthenticationError);
});

it("should throw NetworkError for 404 status", () => {
expect(() =>
handleTeamsError({ statusCode: 404, message: "Not found" }, "editMessage")
).toThrow(NetworkError);
});

it("should throw AdapterRateLimitError for 429 status", () => {
expect(() =>
handleTeamsError({ statusCode: 429, retryAfter: 30 }, "postMessage")
).toThrow(AdapterRateLimitError);
});

it("should handle TeamsSDK HttpError with innerHttpError", () => {
expect(() =>
handleTeamsError(
{ innerHttpError: { statusCode: 401 }, message: "Auth failed" },
"postMessage"
)
).toThrow(AuthenticationError);
});

it("should throw AdapterRateLimitError with retryAfter for 429", () => {
try {
handleTeamsError({ statusCode: 429, retryAfter: 60 }, "postMessage");
} catch (error) {
expect(error).toBeInstanceOf(AdapterRateLimitError);
expect((error as AdapterRateLimitError).retryAfter).toBe(60);
}
});

it("should throw PermissionError for messages containing 'permission'", () => {
expect(() =>
handleTeamsError(
{ message: "Insufficient Permission to complete the operation" },
"deleteMessage"
)
).toThrow(PermissionError);
});

it("should throw NetworkError for generic errors with message", () => {
expect(() =>
handleTeamsError({ message: "Connection reset" }, "startTyping")
).toThrow(NetworkError);
});

it("should throw NetworkError for unknown error types", () => {
expect(() => handleTeamsError("some string error", "postMessage")).toThrow(
NetworkError
);
});

it("should throw NetworkError for null/undefined errors", () => {
expect(() => handleTeamsError(null, "postMessage")).toThrow(NetworkError);
});

it("should use status field if statusCode not present", () => {
expect(() =>
handleTeamsError({ status: 401, message: "Unauthorized" }, "postMessage")
).toThrow(AuthenticationError);
});

it("should use code field if statusCode and status not present", () => {
expect(() => handleTeamsError({ code: 429 }, "postMessage")).toThrow(
AdapterRateLimitError
);
});
});
Loading