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';