feat(channels): add microsoft teams bot framework adapter
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamsAdapter, type TeamsAdapterConfig } from './adapter.js';
|
||||
Reference in New Issue
Block a user