diff --git a/README.md b/README.md index 2e907ea..eb3b7a6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, and Signal with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, and Microsoft Teams with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -150,6 +150,16 @@ signal: poll_interval_ms: 5000 send_timeout_ms: 15000 +# Optional: Microsoft Teams (Bot Framework) +teams: + app_id: "${TEAMS_APP_ID}" + app_password: "${TEAMS_APP_PASSWORD}" + allowed_conversation_ids: [] + require_mention: true + +# Bot Framework messaging endpoint should point to: +# POST https:///teams/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index bf2fce5..e86fe64 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -20,6 +20,13 @@ telegram: # poll_interval_ms: 5000 # send_timeout_ms: 15000 +# Optional: Microsoft Teams (Bot Framework) +# teams: +# app_id: ${TEAMS_APP_ID} +# app_password: ${TEAMS_APP_PASSWORD} +# allowed_conversation_ids: [] # Empty = allow all conversations +# require_mention: true + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index bf3ae8b..5700c7d 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -146,6 +146,11 @@ fetch('http://localhost:18800/api/health', { }); ``` +Exceptions (handled by their own trust/auth model and therefore bypass gateway token auth): +- `POST /webhooks/:name` (HMAC-validated when webhook secret is configured) +- `POST /gmail/push` (Google Pub/Sub push) +- `POST /teams/events` (Microsoft Bot Framework activity callback) + ## Message Format ### Request (Client → Server) diff --git a/src/channels/index.ts b/src/channels/index.ts index 44b4917..b35f726 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -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'; diff --git a/src/channels/teams/adapter.test.ts b/src/channels/teams/adapter.test.ts new file mode 100644 index 0000000..e5b8b33 --- /dev/null +++ b/src/channels/teams/adapter.test.ts @@ -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: 'Flynn 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; + 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(); + }); +}); diff --git a/src/channels/teams/adapter.ts b/src/channels/teams/adapter.ts new file mode 100644 index 0000000..87b503c --- /dev/null +++ b/src/channels/teams/adapter.ts @@ -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 { + this._status = 'connected'; + } + + async disconnect(): Promise { + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + 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 { + 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 { + 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>\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 { + 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 { + 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: 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, + }; + } +} diff --git a/src/channels/teams/index.ts b/src/channels/teams/index.ts new file mode 100644 index 0000000..27e013d --- /dev/null +++ b/src/channels/teams/index.ts @@ -0,0 +1 @@ +export { TeamsAdapter, type TeamsAdapterConfig } from './adapter.js'; diff --git a/src/cli/shared.test.ts b/src/cli/shared.test.ts index 662fed2..9d25c1f 100644 --- a/src/cli/shared.test.ts +++ b/src/cli/shared.test.ts @@ -149,6 +149,16 @@ models: const matrix = redacted.matrix as Record; 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; + expect(teams.app_password).toBe('***'); + }); }); describe('getDataDir', () => { diff --git a/src/cli/shared.ts b/src/cli/shared.ts index d90deaa..5bf4a0e 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -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): Record { - 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;} diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index cf95733..49d2a6d 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 22deb80..fa73ba8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; export type WhatsAppConfig = z.infer; export type MatrixConfig = z.infer; export type SignalConfig = z.infer; +export type TeamsConfig = z.infer; export type RetryPolicyConfig = z.infer; export type ContextLevel = z.infer; export type PromptConfig = z.infer; diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index d4c30e6..c7db42a 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -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); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index 1ae8953..98d7369 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -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' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index c9ee02d..9dc7cc8 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -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) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index c56556e..30fae42 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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; }; + /** Optional Teams adapter for inbound Bot Framework activity webhooks. */ + teamsHandler?: Pick; } 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): void { + this.config.teamsHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {