feat(channels): add microsoft teams bot framework adapter

This commit is contained in:
William Valentin
2026-02-16 02:00:14 -08:00
parent c2bd8fa313
commit 8e35d2d674
15 changed files with 455 additions and 3 deletions
+1
View File
@@ -17,4 +17,5 @@ export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js';
export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js';
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TeamsAdapter } from './adapter.js';
import type { InboundMessage } from '../types.js';
const mockFetch = vi.fn();
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as Response;
}
describe('TeamsAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', mockFetch);
});
it('has name teams and starts disconnected', () => {
const adapter = new TeamsAdapter({ appId: 'id', appPassword: 'secret' });
expect(adapter.name).toBe('teams');
expect(adapter.status).toBe('disconnected');
});
it('send fetches bot token and posts activity', async () => {
const adapter = new TeamsAdapter({ appId: 'id', appPassword: 'secret' });
await adapter.connect();
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/oauth2/v2.0/token')) {
return jsonResponse({ access_token: 'token123', expires_in: 3600 });
}
if (url.includes('/v3/conversations/conv1/activities')) {
return jsonResponse({ id: 'activity1' });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
const peer = `teams:${Buffer.from(JSON.stringify({ conversationId: 'conv1', serviceUrl: 'https://smba.trafficmanager.net/amer' }), 'utf8').toString('base64url')}`;
await adapter.send(peer, { text: 'Hello Teams' });
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]?.[0]).toContain('/v3/conversations/conv1/activities');
});
it('handleActivity emits inbound message with replyPeerId', async () => {
const adapter = new TeamsAdapter({ appId: 'id', appPassword: 'secret', requireMention: true });
const inbound: InboundMessage[] = [];
adapter.onMessage((msg) => inbound.push(msg));
await adapter.handleActivity({
type: 'message',
id: 'm1',
text: '<at>Flynn</at> run diagnostics',
serviceUrl: 'https://smba.trafficmanager.net/amer',
conversation: { id: 'conv1', conversationType: 'channel' },
from: { id: 'user1', name: 'Alice' },
recipient: { id: 'bot1', name: 'Flynn' },
entities: [{ type: 'mention', mentioned: { id: 'bot1', name: 'Flynn' } }],
timestamp: '2026-02-16T00:00:00.000Z',
});
expect(inbound).toHaveLength(1);
expect(inbound[0]?.senderId).toBe('user1');
expect(inbound[0]?.text).toBe('run diagnostics');
const metadata = inbound[0]?.metadata as Record<string, unknown>;
expect(typeof metadata?.replyPeerId).toBe('string');
});
it('drops group messages without mention when require_mention=true', async () => {
const adapter = new TeamsAdapter({ appId: 'id', appPassword: 'secret', requireMention: true });
const handler = vi.fn();
adapter.onMessage(handler);
await adapter.handleActivity({
type: 'message',
id: 'm2',
text: 'hello bot',
serviceUrl: 'https://smba.trafficmanager.net/amer',
conversation: { id: 'conv1', conversationType: 'channel' },
from: { id: 'user1', name: 'Alice' },
recipient: { id: 'bot1', name: 'Flynn' },
entities: [],
});
expect(handler).not.toHaveBeenCalled();
});
});
+263
View File
@@ -0,0 +1,263 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type {
InboundMessage,
OutboundMessage,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
import { shouldIgnoreForMissingMention, splitMessage } from '../utils.js';
import { readRequestBody } from '../../utils/httpBody.js';
export interface TeamsAdapterConfig {
appId: string;
appPassword: string;
allowedConversationIds?: string[];
requireMention?: boolean;
}
interface BotTokenCache {
token: string;
expiresAt: number;
}
interface TeamsActivity {
type?: string;
id?: string;
text?: string;
serviceUrl?: string;
conversation?: { id?: string; conversationType?: string };
from?: { id?: string; name?: string };
recipient?: { id?: string; name?: string };
entities?: Array<{ type?: string; mentioned?: { id?: string; name?: string } }>;
timestamp?: string;
}
const MAX_MESSAGE_LENGTH = 2500;
export class TeamsAdapter implements ChannelAdapter {
readonly name = 'teams';
private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void;
private tokenCache: BotTokenCache | null = null;
constructor(private readonly config: TeamsAdapterConfig) {}
get status(): ChannelStatus {
return this._status;
}
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
async connect(): Promise<void> {
this._status = 'connected';
}
async disconnect(): Promise<void> {
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
if (this._status !== 'connected') {
throw new Error('Teams adapter not connected');
}
const ref = this.decodePeerRef(peerId);
const text = message.text.trim();
if (!text) {
return;
}
const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text];
for (const chunk of chunks) {
await this.sendActivity(ref.serviceUrl, ref.conversationId, chunk);
}
}
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
let body = '';
try {
body = await readRequestBody(req, { maxBytes: 1_048_576 });
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request body' }));
return;
}
let activity: TeamsActivity;
try {
activity = JSON.parse(body) as TeamsActivity;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
await this.handleActivity(activity);
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
}
async handleActivity(activity: TeamsActivity): Promise<void> {
if (!this.messageHandler) {
return;
}
if (activity.type !== 'message') {
return;
}
const text = (activity.text ?? '').trim();
const conversationId = activity.conversation?.id?.trim();
const serviceUrl = activity.serviceUrl?.trim();
const fromId = activity.from?.id?.trim();
if (!text || !conversationId || !serviceUrl || !fromId) {
return;
}
if (this.config.allowedConversationIds && this.config.allowedConversationIds.length > 0) {
if (!this.config.allowedConversationIds.includes(conversationId)) {
return;
}
}
const conversationType = (activity.conversation?.conversationType ?? '').toLowerCase();
const isPersonal = conversationType === 'personal';
const mentionsBot = this.activityMentionsBot(activity);
if (shouldIgnoreForMissingMention({
requireMention: this.config.requireMention,
defaultRequireMention: true,
mentionsBot: isPersonal || mentionsBot,
})) {
return;
}
const cleaned = this.stripLeadingMention(text).trim();
if (!cleaned) {
return;
}
const peerRef = this.encodePeerRef({ conversationId, serviceUrl });
this.messageHandler({
id: activity.id ?? `teams-${Date.now()}`,
channel: 'teams',
senderId: fromId,
senderName: activity.from?.name,
text: cleaned,
timestamp: activity.timestamp ? Date.parse(activity.timestamp) || Date.now() : Date.now(),
metadata: {
conversationId,
serviceUrl,
replyPeerId: peerRef,
},
});
}
private stripLeadingMention(text: string): string {
return text
.replace(/^\s*<at>[^<]+<\/at>\s*/i, '')
.replace(/\s+/g, ' ');
}
private activityMentionsBot(activity: TeamsActivity): boolean {
const recipientId = activity.recipient?.id;
if (!recipientId) {
return false;
}
const mentionEntity = activity.entities?.find((entity) =>
entity.type?.toLowerCase() === 'mention' && entity.mentioned?.id === recipientId,
);
return Boolean(mentionEntity);
}
private async sendActivity(serviceUrl: string, conversationId: string, text: string): Promise<void> {
const token = await this.getBotToken();
const base = serviceUrl.replace(/\/+$/, '');
const endpoint = `${base}/v3/conversations/${encodeURIComponent(conversationId)}/activities`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'message',
text,
}),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Teams send failed (${response.status}): ${body}`);
}
}
private async getBotToken(): Promise<string> {
const now = Date.now();
if (this.tokenCache && this.tokenCache.expiresAt > now + 30_000) {
return this.tokenCache.token;
}
const form = new URLSearchParams();
form.set('grant_type', 'client_credentials');
form.set('client_id', this.config.appId);
form.set('client_secret', this.config.appPassword);
form.set('scope', 'https://api.botframework.com/.default');
const response = await fetch('https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Teams token fetch failed (${response.status}): ${body}`);
}
const payload = await response.json() as { access_token?: string; expires_in?: number };
if (!payload.access_token) {
throw new Error('Teams token fetch failed: missing access_token');
}
const expiresIn = typeof payload.expires_in === 'number' ? payload.expires_in : 3600;
this.tokenCache = {
token: payload.access_token,
expiresAt: now + expiresIn * 1000,
};
return payload.access_token;
}
private encodePeerRef(ref: { conversationId: string; serviceUrl: string }): string {
const payload = Buffer.from(JSON.stringify(ref), 'utf8').toString('base64url');
return `teams:${payload}`;
}
private decodePeerRef(peerId: string): { conversationId: string; serviceUrl: string } {
if (!peerId.startsWith('teams:')) {
throw new Error('Teams peerId must use teams:<base64url-json> format');
}
const encoded = peerId.slice('teams:'.length);
let decoded = '';
try {
decoded = Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
throw new Error('Invalid Teams peer reference encoding');
}
let parsed: { conversationId?: string; serviceUrl?: string } = {};
try {
parsed = JSON.parse(decoded) as { conversationId?: string; serviceUrl?: string };
} catch {
throw new Error('Invalid Teams peer reference payload');
}
if (!parsed.conversationId || !parsed.serviceUrl) {
throw new Error('Teams peer reference missing conversationId/serviceUrl');
}
return {
conversationId: parsed.conversationId,
serviceUrl: parsed.serviceUrl,
};
}
}
+1
View File
@@ -0,0 +1 @@
export { TeamsAdapter, type TeamsAdapterConfig } from './adapter.js';
+10
View File
@@ -149,6 +149,16 @@ models:
const matrix = redacted.matrix as Record<string, unknown>;
expect(matrix.access_token).toBe('***');
});
it('redacts app_password (teams)', () => {
const config = {
teams: { app_id: 'id', app_password: 'teams-secret' },
models: { default: { provider: 'anthropic', model: 'claude' } },
};
const redacted = redactSecrets(config);
const teams = redacted.teams as Record<string, unknown>;
expect(teams.app_password).toBe('***');
});
});
describe('getDataDir', () => {
+1 -1
View File
@@ -74,7 +74,7 @@ export function loadConfigSafe(configPath?: string): { config?: Config; error?:
/** Deep-clone config and replace sensitive fields with '***'. */
export function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = ['bot_token', 'api_key', 'auth_token', 'access_token'];
const sensitiveKeys = ['bot_token', 'api_key', 'auth_token', 'access_token', 'app_password'];
function redact(obj: unknown): unknown {
if (obj === null || obj === undefined) {return obj;}
+25
View File
@@ -350,6 +350,31 @@ describe('configSchema — signal', () => {
});
});
describe('configSchema — teams', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('accepts teams config and defaults allowlist/mention fields', () => {
const result = configSchema.parse({
...minimalConfig,
teams: {
app_id: 'app-id',
app_password: 'app-password',
},
});
expect(result.teams).toBeDefined();
if (!result.teams) {
throw new Error('Expected teams config');
}
expect(result.teams.app_id).toBe('app-id');
expect(result.teams.allowed_conversation_ids).toEqual([]);
expect(result.teams.require_mention).toBe(true);
});
});
describe('configSchema — whatsapp', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+9
View File
@@ -411,6 +411,13 @@ const signalSchema = z.object({
send_timeout_ms: z.number().min(1000).max(60000).default(15000),
}).optional();
const teamsSchema = z.object({
app_id: z.string().min(1, 'Teams app_id is required'),
app_password: z.string().min(1, 'Teams app_password is required'),
allowed_conversation_ids: z.array(z.string()).default([]),
require_mention: z.boolean().default(true),
}).optional();
const browserSchema = z.object({
enabled: z.boolean().default(false),
executable_path: z.string().optional(),
@@ -578,6 +585,7 @@ export const configSchema = z.object({
whatsapp: whatsappSchema,
matrix: matrixSchema,
signal: signalSchema,
teams: teamsSchema,
server: serverSchema.default({}),
models: modelsSchema,
backends: backendsSchema.default({}),
@@ -623,6 +631,7 @@ export type SlackConfig = z.infer<typeof slackSchema>;
export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
export type MatrixConfig = z.infer<typeof matrixSchema>;
export type SignalConfig = z.infer<typeof signalSchema>;
export type TeamsConfig = z.infer<typeof teamsSchema>;
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
export type ContextLevel = z.infer<typeof contextLevelSchema>;
export type PromptConfig = z.infer<typeof promptSchema>;
+13 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from '../config/index.js';
import type { HookEngine } from '../hooks/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, PairingManager } from '../channels/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, PairingManager } from '../channels/index.js';
import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
import type { GatewayServer } from '../gateway/index.js';
@@ -101,6 +101,18 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
channelRegistry.register(signalAdapter);
}
// Register Microsoft Teams adapter (if configured)
if (config.teams) {
const teamsAdapter = new TeamsAdapter({
appId: config.teams.app_id,
appPassword: config.teams.app_password,
allowedConversationIds: config.teams.allowed_conversation_ids.length > 0 ? config.teams.allowed_conversation_ids : undefined,
requireMention: config.teams.require_mention,
});
channelRegistry.register(teamsAdapter);
gateway.setTeamsHandler(teamsAdapter);
}
// Register WebChat adapter (wraps the gateway)
const webChatAdapter = new WebChatAdapter({ gateway });
channelRegistry.register(webChatAdapter);
+2
View File
@@ -31,6 +31,7 @@ function makeBaseConfig(): Config {
whatsapp: undefined,
matrix: undefined,
signal: undefined,
teams: undefined,
} as unknown as Config;
}
@@ -45,6 +46,7 @@ describe('discoverServices', () => {
expect.objectContaining({ name: 'telegram', status: 'not_configured' }),
expect.objectContaining({ name: 'matrix', status: 'not_configured' }),
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
expect.objectContaining({ name: 'teams', status: 'not_configured' }),
expect.objectContaining({ name: 'cron', status: 'not_configured' }),
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
expect.objectContaining({ name: 'web_search', status: 'configured' }),
+1
View File
@@ -54,6 +54,7 @@ export function discoverServices(
{ key: 'whatsapp', name: 'whatsapp', description: 'WhatsApp gateway' },
{ key: 'matrix', name: 'matrix', description: 'Matrix bot' },
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
{ key: 'teams', name: 'teams', description: 'Microsoft Teams bot' },
];
for (const { key, name, description } of channelConfigs) {
+14
View File
@@ -43,6 +43,7 @@ import type { ComponentRegistry } from '../intents/index.js';
import type { RoutingPolicy } from '../routing/index.js';
import type { ChannelRegistry } from '../channels/index.js';
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
import type { TeamsAdapter } from '../channels/teams/adapter.js';
export interface GatewayServerConfig {
port: number;
@@ -93,6 +94,8 @@ export interface GatewayServerConfig {
serviceType: string;
txtRecord?: Record<string, string>;
};
/** Optional Teams adapter for inbound Bot Framework activity webhooks. */
teamsHandler?: Pick<TeamsAdapter, 'handleRequest'>;
}
export class GatewayServer {
@@ -476,6 +479,12 @@ export class GatewayServer {
return;
}
// Teams Bot Framework events route — bypass gateway auth (Bot Framework posts directly)
if (this.config.teamsHandler && req.method === 'POST' && req.url?.startsWith('/teams/events')) {
await this.config.teamsHandler.handleRequest(req, res);
return;
}
// Apply auth to HTTP requests when configured
const authConfig = this.config.auth ?? {};
if (this.config.authHttp !== false && authConfig.token) {
@@ -563,6 +572,11 @@ export class GatewayServer {
this.config.gmailHandler = handler;
}
/** Set the Teams handler for inbound Bot Framework activity HTTP routes (late binding). */
setTeamsHandler(handler: Pick<TeamsAdapter, 'handleRequest'>): void {
this.config.teamsHandler = handler;
}
private async startDiscovery(host: string, port: number): Promise<void> {
const discovery = this.config.discovery;
if (!discovery?.enabled) {