diff --git a/.changeset/teams-comms-adapter.md b/.changeset/teams-comms-adapter.md new file mode 100644 index 000000000..c676b9dd9 --- /dev/null +++ b/.changeset/teams-comms-adapter.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +feat(sdk): Add Microsoft Teams communication adapter + +BREAKING: `createCommunicationAdapter` is now async (returns `Promise`). +Callers must await the result. + +New Teams adapter for bidirectional chat via Microsoft Graph API. +Supports browser auth (PKCE), device code flow, token caching, +1:1 chat and channel messaging. diff --git a/docs/features/teams-adapter.md b/docs/features/teams-adapter.md new file mode 100644 index 000000000..5ecc039ed --- /dev/null +++ b/docs/features/teams-adapter.md @@ -0,0 +1,219 @@ +# Teams Communication Adapter + +**Try this to enable Teams messaging:** +``` +squad config set communications.channel teams-graph +``` + +**Try this to send a test message:** +``` +squad test-notification --agent Keaton --reason blocked +``` + +**Try this to configure the recipient:** +``` +squad config set communications.adapterConfig.teams-graph.recipientUpn "user@company.com" +``` + +Bidirectional Microsoft Teams messaging via the Graph API. Squad agents post updates, poll for human replies, and maintain a live conversation thread — all without webhooks or MCP servers. + +--- + +## What the Teams Adapter Does + +The Teams adapter is a built-in `CommunicationAdapter` that connects your squad directly to Microsoft Teams using the Microsoft Graph API: + +1. **Post updates** — Agents send HTML-formatted messages to a 1:1 chat or Teams channel +2. **Poll for replies** — Agents read human responses from the chat thread +3. **Deep links** — Every message includes a clickable Teams URL so you can jump straight into the conversation + +Unlike the webhook-based approach in [Notifications](../src/content/docs/features/notifications.md), this adapter uses OAuth to authenticate as you and chat directly — no Power Automate, no MCP server, no webhook URLs. + +--- + +## Quick Start + +### 1. Configure your squad + +Add Teams as the communication channel in `.squad/config.json`: + +```json +{ + "communications": { + "channel": "teams-graph", + "adapterConfig": { + "teams-graph": { + "recipientUpn": "your-colleague@company.com" + } + } + } +} +``` + +### 2. Run your squad + +```bash +copilot --agent squad --yolo +``` + +On first run, the adapter opens your browser for a one-time OAuth sign-in (PKCE flow). After that, tokens are cached at `~/.squad/teams-tokens.json` and refreshed automatically. + +### 3. Agents start chatting + +When agents need to post updates or ask for input, they send messages to the configured recipient via Teams. You reply in Teams; agents pick up your replies on the next poll. + +--- + +## Configuration Reference + +All configuration lives in `.squad/config.json` under `communications.adapterConfig["teams-graph"]`: + +```json +{ + "communications": { + "channel": "teams-graph", + "adapterConfig": { + "teams-graph": { + "tenantId": "organizations", + "clientId": "14d82eec-204b-4c2f-b7e8-296a70dab67e", + "recipientUpn": "user@company.com", + "chatId": "19:abc123@thread.v2", + "teamId": "team-guid", + "channelId": "19:channel@thread.tacv2" + } + } + } +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `recipientUpn` | For 1:1 chat | — | UPN of the person to chat with (e.g., `bradyg@microsoft.com`). Use `"me"` to message yourself. | +| `chatId` | No | — | Existing chat ID. If omitted, the adapter creates a new 1:1 chat with the recipient. | +| `teamId` | For channels | — | Team ID (required when using `channelId`). | +| `channelId` | For channels | — | Channel ID. When set with `teamId`, posts go to the team channel instead of a 1:1 chat. | +| `tenantId` | No | `"organizations"` | Entra ID tenant. Default works for any multi-tenant org. | +| `clientId` | No | `"14d82eec-..."` | OAuth app ID. Default uses the Microsoft Graph PowerShell first-party app, which works in any tenant without Entra app registration. | + +### Two messaging modes + +- **1:1 chat** — Set `recipientUpn`. The adapter creates (or reuses) a direct chat. +- **Channel** — Set `teamId` + `channelId`. Messages go to a team channel visible to all members. + +--- + +## Authentication + +The adapter authenticates via OAuth 2.0 with a 4-tier fallback strategy: + +### 1. Cached token +If a valid (non-expired) access token exists in `~/.squad/teams-tokens.json`, it is reused immediately. + +### 2. Token refresh +If the cached token is expired but a refresh token exists, the adapter silently refreshes without user interaction. + +### 3. Browser PKCE flow (interactive) +If no cached tokens exist, the adapter: +- Opens your default browser to the Microsoft login page +- Uses PKCE (Proof Key for Code Exchange) — no client secret needed +- Spins up a temporary localhost server to receive the redirect +- Exchanges the auth code for tokens +- Stores tokens securely at `~/.squad/teams-tokens.json` + +### 4. Device code flow (headless fallback) +If the browser cannot be opened (SSH, CI, headless server): +- Displays a user code (e.g., `ABCD1234`) and a URL +- You visit the URL on any device and enter the code +- The adapter polls until you complete the sign-in + +### Token storage security + +Tokens are stored at `~/.squad/teams-tokens.json` with restricted permissions: +- **Linux/macOS:** `0600` (owner read/write only) +- **Windows:** ICACLS restricted to the current user + +### Required Graph API permissions + +The default client ID requests these scopes: +- `Chat.ReadWrite` — Create and read chats +- `ChatMessage.Send` — Send messages +- `ChatMessage.Read` — Read replies +- `User.Read` — Identify the signed-in user +- `offline_access` — Obtain refresh tokens + +No admin consent is required when using the default first-party client ID. + +--- + +## How It Works + +### Posting a message + +``` +Agent (e.g., Keaton) calls postUpdate() + → adapter.ensureAuthenticated() + → adapter.ensureChat(accessToken) // creates 1:1 chat if needed + → POST /chats/{chatId}/messages // sends HTML message + → returns { id, url } // chat ID + Teams deep link +``` + +Messages are formatted as HTML with the agent's name as author and category as a prefix label. + +### Polling for replies + +``` +Agent calls pollForReplies({ threadId, since }) + → GET /chats/{chatId}/messages?$top=50 + → filter: only messages from other users, after `since` + → strip HTML tags from reply body + → return CommunicationReply[] +``` + +### Retry logic + +All Graph API calls use 3 retries with exponential backoff for transient errors (HTTP 429, 503, 504). + +--- + +## Comparison with Webhook Notifications + +| | Teams Adapter (this) | Webhook Notifications | +|---|---|---| +| **Direction** | Bidirectional (send + receive) | One-way (send only) | +| **Setup** | Just config + OAuth sign-in | Webhook URL + MCP server + Power Automate | +| **Auth** | OAuth 2.0 (PKCE / device code) | Webhook URL | +| **Infrastructure** | None (built-in) | External MCP server required | +| **Reply detection** | Yes (polls for human replies) | No | +| **Best for** | Interactive agent–human conversations | Fire-and-forget alerts | + +--- + +## Troubleshooting + +### "Token expired" or repeated sign-in prompts +Delete the cached tokens and re-authenticate: +```bash +rm ~/.squad/teams-tokens.json +``` + +### "Chat creation failed" +- Verify `recipientUpn` is a valid UPN in your tenant +- Ensure both you and the recipient have Teams licenses +- Check that the Graph API scopes are consented + +### Browser doesn't open (PKCE flow fails) +The adapter falls back to device code automatically. If neither works: +- Ensure you have network access to `login.microsoftonline.com` +- Check that port binding on localhost is not blocked by a firewall + +### Channel messages not appearing +- Verify both `teamId` and `channelId` are set (both are required for channel mode) +- Ensure you are a member of the team + +--- + +## See Also + +- [Notifications](../src/content/docs/features/notifications.md) — Webhook-based one-way notifications (Teams, Discord, Slack) +- [Cross-Squad Orchestration](./cross-squad-orchestration.md) — Delegate work across squads +- [Persistent Ralph](./persistent-ralph.md) — Monitor work with continuous polling diff --git a/packages/squad-sdk/src/platform/comms-teams.ts b/packages/squad-sdk/src/platform/comms-teams.ts new file mode 100644 index 000000000..608edaf6e --- /dev/null +++ b/packages/squad-sdk/src/platform/comms-teams.ts @@ -0,0 +1,643 @@ +/** + * Microsoft Teams communication adapter — bidirectional chat via Graph API. + * + * Auth priority: cached token → refresh → browser PKCE → device code fallback. + * Uses the Microsoft Graph PowerShell first-party client ID by default (works + * in every Microsoft tenant with no custom Entra registration required). + * Tokens stored in ~/.squad/teams-tokens.json. + * + * @module platform/comms-teams + */ + +import { join } from 'node:path'; +import { homedir, platform } from 'node:os'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { randomBytes, createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; + +// ─── Defaults ──────────────────────────────────────────────────────── + +/** Microsoft Graph PowerShell — first-party, present in every tenant */ +const DEFAULT_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e'; +/** Multi-tenant "organizations" endpoint — works for any Entra org */ +const DEFAULT_TENANT_ID = 'organizations'; + +// ─── Config ────────────────────────────────────────────────────────── + +export interface TeamsCommsConfig { + tenantId?: string; + clientId?: string; + /** User to message — UPN like "bradyg@microsoft.com" or "me" for self-chat */ + recipientUpn?: string; + /** Existing chat ID (skip chat creation if known) */ + chatId?: string; + /** Teams channel ID (alternative to 1:1 chat) */ + channelId?: string; + teamId?: string; +} + +// ─── Token Storage ─────────────────────────────────────────────────── + +interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // epoch ms +} + +const SQUAD_DIR = join(homedir(), '.squad'); +const TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); + +function loadTokens(): StoredTokens | null { + try { + if (!existsSync(TOKEN_PATH)) return null; + const raw = readFileSync(TOKEN_PATH, 'utf-8'); + return JSON.parse(raw) as StoredTokens; + } catch { + return null; + } +} + +// Security: tokens stored with 0o600 permissions (owner-only read/write). +// Consider OS keychain integration for production use. +function saveTokens(tokens: StoredTokens): void { + if (!existsSync(SQUAD_DIR)) { + mkdirSync(SQUAD_DIR, { recursive: true, mode: 0o700 }); + } + writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), { encoding: 'utf-8', mode: 0o600 }); + + // Ensure permissions are correct even if file already existed + if (platform() === 'win32') { + execFile('icacls', [TOKEN_PATH, '/inheritance:r', '/grant:r', `${process.env.USERNAME ?? 'CURRENT_USER'}:(R,W)`], () => {}); + } else { + chmodSync(SQUAD_DIR, 0o700); + chmodSync(TOKEN_PATH, 0o600); + } +} + +// ─── Graph Helpers ─────────────────────────────────────────────────── + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; +const SCOPES = 'Chat.ReadWrite ChatMessage.Send ChatMessage.Read User.Read offline_access'; + +async function graphFetch( + url: string, + accessToken: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + const maxRetries = 3; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const res = await fetch(url, { + method: options.method ?? 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (res.status === 429 || res.status === 503 || res.status === 504) { + if (attempt < maxRetries) { + const retryAfter = Math.min( + Number(res.headers.get('Retry-After') || '5'), + 30, + ); + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + continue; + } + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Graph API ${res.status}: ${res.statusText} — ${text}`); + } + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return res.json(); + } + return undefined; + } + // Unreachable, but satisfies TypeScript + throw new Error('Graph API request failed after retries'); +} + +// ─── Auth Flows ────────────────────────────────────────────────────── + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + error?: string; + error_description?: string; +} + +function parseTokens(data: TokenResponse): StoredTokens { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +// ─── Browser Auth (Authorization Code + PKCE) ──────────────────────── + +const SUCCESS_HTML = ` +Squad — Authenticated + +

✅ Authentication successful!

You can close this tab and return to the terminal.

`; + +/** Open a URL in the user's default browser. */ +function openBrowser(url: string): void { + const p = platform(); + if (p === 'win32') { + // Use PowerShell Start-Process to avoid cmd.exe & metacharacter injection + execFile('powershell.exe', ['-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`], () => {}); + } else if (p === 'darwin') { + execFile('open', [url], () => {}); + } else { + execFile('xdg-open', [url], () => {}); + } +} + +/** Base64-URL encode (no padding). */ +function base64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function startBrowserAuthFlow(tenantId: string, clientId: string): Promise { + const codeVerifier = base64url(randomBytes(32)); + const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest()); + const oauthState = base64url(randomBytes(16)); + + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const reqUrl = new URL(req.url ?? '/', `http://localhost`); + const code = reqUrl.searchParams.get('code'); + const error = reqUrl.searchParams.get('error'); + const returnedState = reqUrl.searchParams.get('state'); + + if (error) { + const errorDesc = reqUrl.searchParams.get('error_description') ?? ''; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

Authentication failed

${escapeHtml(error)}

${escapeHtml(errorDesc)}

`); + cleanup(); + reject(new Error(`Browser auth denied: ${error}`)); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing authorization code'); + return; + } + + // Validate state to prevent CSRF + if (returnedState !== oauthState) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Invalid state parameter — possible CSRF attack'); + cleanup(); + reject(new Error('OAuth state mismatch — possible CSRF')); + return; + } + + // Exchange code for tokens + const redirectUri = `http://localhost:${(server.address() as { port: number }).port}`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + scope: SCOPES, + }), + }) + .then(async (tokenRes) => { + const data = (await tokenRes.json()) as TokenResponse; + if (!data.access_token) { + throw new Error(`Token exchange failed: ${data.error} — ${data.error_description}`); + } + const tokens = parseTokens(data); + saveTokens(tokens); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(SUCCESS_HTML); + cleanup(); + resolve(tokens); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Token exchange failed'); + cleanup(); + reject(err); + }); + }); + + // Timeout — 120 seconds to complete browser auth + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Browser auth timed out after 120 seconds')); + }, 120_000); + + function cleanup() { + clearTimeout(timer); + server.close(); + } + + // Handle server startup errors (port exhaustion, permission denied, etc.) + server.on('error', (err) => { + cleanup(); + reject(new Error(`OAuth callback server failed: ${(err as Error).message}`)); + }); + + // Bind to a random available port + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + const redirectUri = `http://localhost:${port}`; + const authorizeUrl = + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize` + + `?client_id=${encodeURIComponent(clientId)}` + + `&response_type=code` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(SCOPES)}` + + `&code_challenge=${encodeURIComponent(codeChallenge)}` + + `&code_challenge_method=S256` + + `&state=${encodeURIComponent(oauthState)}` + + `&prompt=select_account`; + + console.log('🌐 Opening browser for Teams authentication...'); + openBrowser(authorizeUrl); + }); + }); +} + +// ─── Device Code Flow (fallback) ───────────────────────────────────── + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + message: string; +} + +async function startDeviceCodeFlow(tenantId: string, clientId: string): Promise { + const deviceCodeUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const dcRes = await fetch(deviceCodeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + scope: SCOPES, + }), + }); + if (!dcRes.ok) { + throw new Error(`Device code request failed: ${dcRes.status} ${await dcRes.text()}`); + } + const dcData = (await dcRes.json()) as DeviceCodeResponse; + + console.log(`\n🔐 Teams authentication required`); + console.log(` ${dcData.message}\n`); + + const pollInterval = (dcData.interval || 5) * 1000; + const deadline = Date.now() + dcData.expires_in * 1000; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollInterval)); + + const tokenRes = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: clientId, + device_code: dcData.device_code, + }), + }); + + const tokenData = (await tokenRes.json()) as TokenResponse; + + if (tokenData.access_token) { + const tokens = parseTokens(tokenData); + saveTokens(tokens); + console.log(`✅ Teams authentication successful — tokens saved\n`); + return tokens; + } + + if (tokenData.error === 'authorization_pending') continue; + + if (tokenData.error === 'slow_down') { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } + + throw new Error(`Device code auth failed: ${tokenData.error} — ${tokenData.error_description}`); + } + + throw new Error('Device code flow timed out — user did not authenticate in time'); +} + +// ─── Token Refresh ─────────────────────────────────────────────────── + +async function refreshAccessToken( + tenantId: string, + clientId: string, + refreshToken: string, +): Promise { + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + scope: SCOPES, + }), + }); + + const data = (await res.json()) as TokenResponse; + if (!data.access_token) { + throw new Error(`Token refresh failed: ${data.error} — ${data.error_description}`); + } + + const tokens: StoredTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + }; + saveTokens(tokens); + return tokens; +} + +// ─── User ID Cache ─────────────────────────────────────────────────── + +let cachedUserId: string | null = null; + +async function getMyUserId(accessToken: string): Promise { + if (cachedUserId) return cachedUserId; + try { + const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; + cachedUserId = me.id; + return cachedUserId; + } catch (err) { + console.warn(`⚠️ Teams /me fetch failed: ${(err as Error).message}`); + return null; + } +} + +// ─── Adapter ───────────────────────────────────────────────────────── + +export class TeamsCommunicationAdapter implements CommunicationAdapter { + readonly channel: CommunicationChannel = 'teams-graph'; + + private tokens: StoredTokens | null = null; + private resolvedChatId: string | null; + private readonly clientId: string; + private readonly tenantId: string; + + constructor(private readonly config: TeamsCommsConfig) { + this.resolvedChatId = config.chatId ?? null; + this.clientId = config.clientId ?? DEFAULT_CLIENT_ID; + this.tenantId = config.tenantId ?? DEFAULT_TENANT_ID; + } + + /** + * Ensure we have a valid access token. + * Priority: cached → refresh → browser PKCE → device code fallback. + */ + private async ensureAuthenticated(): Promise { + if (!this.tokens) { + this.tokens = loadTokens(); + } + + // Valid token — return it + if (this.tokens && Date.now() < this.tokens.expiresAt - 60_000) { + return this.tokens.accessToken; + } + + // Expired but have refresh token — try refresh + if (this.tokens?.refreshToken) { + try { + this.tokens = await refreshAccessToken( + this.tenantId, + this.clientId, + this.tokens.refreshToken, + ); + return this.tokens.accessToken; + } catch { + console.warn('⚠️ Token refresh failed — re-authenticating...'); + } + } + + // Try browser auth code flow with PKCE first + try { + this.tokens = await startBrowserAuthFlow(this.tenantId, this.clientId); + console.log('✅ Teams authentication successful — tokens saved'); + return this.tokens.accessToken; + } catch { + console.log('Browser auth unavailable, falling back to device code...'); + } + + // Fallback — device code flow (works in headless/SSH environments) + this.tokens = await startDeviceCodeFlow(this.tenantId, this.clientId); + return this.tokens.accessToken; + } + + /** + * Find or create a 1:1 chat with the recipient. + */ + private async ensureChat(accessToken: string): Promise { + if (this.resolvedChatId) return this.resolvedChatId; + + const upn = this.config.recipientUpn; + + // "me" mode requires an explicit chat ID to avoid selecting an arbitrary 1:1 chat. + if (!upn || upn === 'me') { + if (!this.config.chatId) { + throw new Error( + 'Teams "me" mode requires an explicit chatId to avoid routing messages to the wrong one-on-one chat. Provide config.chatId or set recipientUpn to a specific user.', + ); + } + validateGraphId(this.config.chatId, 'chatId'); + this.resolvedChatId = this.config.chatId; + return this.resolvedChatId; + } + + // Create 1:1 chat with recipient UPN — Graph requires both participants + const safeUpn = validateGraphId(upn, 'recipientUpn'); + const meRes = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; + const chatRes = (await graphFetch(`${GRAPH_BASE}/chats`, accessToken, { + method: 'POST', + body: { + chatType: 'oneOnOne', + members: [ + { + '@odata.type': '#microsoft.graph.aadUserConversationMember', + roles: ['owner'], + 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${meRes.id}')`, + }, + { + '@odata.type': '#microsoft.graph.aadUserConversationMember', + roles: ['owner'], + 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${safeUpn}')`, + }, + ], + }, + })) as { id: string }; + this.resolvedChatId = chatRes.id; + return this.resolvedChatId; + } + + async postUpdate(options: { + title: string; + body: string; + category?: string; + author?: string; + }): Promise<{ id: string; url?: string }> { + const accessToken = await this.ensureAuthenticated(); + + // Use channel message if teamId + channelId are configured + if (this.config.teamId && this.config.channelId) { + const safeTeamId = validateGraphId(this.config.teamId, 'teamId'); + const safeChannelId = validateGraphId(this.config.channelId, 'channelId'); + const url = `${GRAPH_BASE}/teams/${safeTeamId}/channels/${safeChannelId}/messages`; + await graphFetch(url, accessToken, { + method: 'POST', + body: { + body: { + contentType: 'html', + content: formatTeamsMessage(options.title, options.body, options.author), + }, + }, + }); + + // Return stable composite ID so pollForReplies can locate the channel + return { + id: `${this.config.teamId}|${this.config.channelId}`, + url: `https://teams.microsoft.com/l/channel/${this.config.channelId}`, + }; + } + + // 1:1 chat mode + const chatId = await this.ensureChat(accessToken); + const safeChatId = validateGraphId(chatId, 'chatId'); + const url = `${GRAPH_BASE}/chats/${safeChatId}/messages`; + await graphFetch(url, accessToken, { + method: 'POST', + body: { + body: { + contentType: 'html', + content: formatTeamsMessage(options.title, options.body, options.author), + }, + }, + }); + + // Return chatId so pollForReplies can locate the right chat + return { + id: chatId, + url: this.getNotificationUrl(chatId), + }; + } + + async pollForReplies(options: { + threadId: string; + since: Date; + }): Promise { + const accessToken = await this.ensureAuthenticated(); + const sinceIso = options.since.toISOString(); + + // Channel mode: threadId is "teamId|channelId" composite from postUpdate + let messagesUrl: string; + if (options.threadId.includes('|')) { + const [teamId, channelId] = options.threadId.split('|', 2); + if (teamId && channelId) { + const safeTeamId = validateGraphId(teamId, 'teamId'); + const safeChannelId = validateGraphId(channelId, 'channelId'); + const url = new URL(`${GRAPH_BASE}/teams/${safeTeamId}/channels/${safeChannelId}/messages`); + url.searchParams.set('$filter', `createdDateTime gt ${sinceIso}`); + url.searchParams.set('$top', '50'); + url.searchParams.set('$orderby', 'createdDateTime asc'); + messagesUrl = url.toString(); + } else { + return []; + } + } else { + // 1:1 chat mode: threadId is the chatId + const chatId = this.resolvedChatId ?? options.threadId; + const safeChatId = validateGraphId(chatId, 'chatId'); + const url = new URL(`${GRAPH_BASE}/chats/${safeChatId}/messages`); + url.searchParams.set('$filter', `createdDateTime gt ${sinceIso}`); + url.searchParams.set('$top', '50'); + url.searchParams.set('$orderby', 'createdDateTime asc'); + messagesUrl = url.toString(); + } + + let data: { value: Array<{ id: string; body: { content: string }; from: { user: { displayName: string; id: string } } | null; createdDateTime: string }> }; + try { + data = (await graphFetch(messagesUrl, accessToken)) as typeof data; + } catch (err) { + console.warn(`⚠️ Teams pollForReplies failed: ${(err as Error).message}`); + return []; + } + + const myId = await getMyUserId(accessToken); + + return data.value + .filter((m) => { + if (!m.from?.user) return false; + if (myId && m.from.user.id === myId) return false; + if (new Date(m.createdDateTime) <= options.since) return false; + return true; + }) + .map((m) => ({ + author: m.from?.user?.displayName ?? 'unknown', + body: stripHtml(m.body.content), + timestamp: new Date(m.createdDateTime), + id: m.id, + })); + } + + getNotificationUrl(threadId: string): string | undefined { + const chatId = this.resolvedChatId ?? threadId; + return `https://teams.microsoft.com/l/chat/${encodeURIComponent(chatId)}`; + } +} + +// ─── Graph ID Validation ───────────────────────────────────────────── + +/** Validate and encode a Graph API path segment. */ +function validateGraphId(id: string, label: string): string { + if (!/^[\w:@.\-]+$/.test(id)) { + throw new Error(`Invalid ${label}: contains unsafe characters`); + } + return encodeURIComponent(id); +} + +// ─── Formatting ────────────────────────────────────────────────────── + +function formatTeamsMessage(title: string, body: string, author?: string): string { + const authorLine = author ? `Posted by ${escapeHtml(author)}
` : ''; + return `${escapeHtml(title)}
${authorLine}
${escapeHtml(body).replace(/\n/g, '
')}`; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim(); +} + +export { escapeHtml, stripHtml, formatTeamsMessage, parseTokens, base64url, validateGraphId }; diff --git a/packages/squad-sdk/src/platform/comms.ts b/packages/squad-sdk/src/platform/comms.ts index 9f503b2bb..e8d2404a0 100644 --- a/packages/squad-sdk/src/platform/comms.ts +++ b/packages/squad-sdk/src/platform/comms.ts @@ -41,12 +41,12 @@ function readCommsConfig(repoRoot: string): CommunicationConfig | undefined { * 2. Auto-detect from platform: GitHub → GitHubDiscussions, ADO → ADOWorkItemDiscussions * 3. Fallback: FileLog (always works) */ -export function createCommunicationAdapter(repoRoot: string): CommunicationAdapter { +export async function createCommunicationAdapter(repoRoot: string): Promise { const config = readCommsConfig(repoRoot); // Explicit config wins if (config?.channel) { - return createAdapterByChannel(config.channel, repoRoot); + return createAdapterByChannel(config.channel, repoRoot, config); } // Auto-detect from platform @@ -84,7 +84,11 @@ export function createCommunicationAdapter(repoRoot: string): CommunicationAdapt return new FileLogCommunicationAdapter(repoRoot); } -function createAdapterByChannel(channel: CommunicationChannel, repoRoot: string): CommunicationAdapter { +async function createAdapterByChannel( + channel: CommunicationChannel, + repoRoot: string, + config?: CommunicationConfig, +): Promise { const remoteUrl = getRemoteUrl(repoRoot); switch (channel) { @@ -100,13 +104,39 @@ function createAdapterByChannel(channel: CommunicationChannel, repoRoot: string) if (!info) throw new Error(`Cannot parse ADO remote: ${remoteUrl}`); return new ADODiscussionCommunicationAdapter(info.org, info.project); } - case 'teams-webhook': - // Teams webhook adapter would go here — for now fall back to file log - console.warn('Teams webhook adapter not yet implemented — using file log fallback'); - return new FileLogCommunicationAdapter(repoRoot); + case 'teams-graph': { + const { TeamsCommunicationAdapter } = await import('./comms-teams.js'); + const teamsConfig = (config?.adapterConfig?.['teams-graph'] ?? readTeamsConfig(repoRoot) ?? {}) as Record; + return new TeamsCommunicationAdapter(teamsConfig); + } case 'file-log': return new FileLogCommunicationAdapter(repoRoot); default: return new FileLogCommunicationAdapter(repoRoot); } } + +/** + * Read Teams-specific config from `.squad/config.json`. + * Looks for `communications.adapterConfig['teams-graph']` (preferred) + * then falls back to legacy `communications.teams` key. + */ +function readTeamsConfig(repoRoot: string): Record | undefined { + const configPath = join(repoRoot, '.squad', 'config.json'); + if (!storage.existsSync(configPath)) return undefined; + try { + const raw = storage.readSync(configPath) ?? ''; + const parsed = JSON.parse(raw) as Record; + const comms = parsed.communications as Record | undefined; + // Prefer adapterConfig['teams-graph'] for consistency with CommunicationConfig + const adapter = comms?.adapterConfig as Record | undefined; + if (adapter?.['teams-graph'] && typeof adapter['teams-graph'] === 'object') { + return adapter['teams-graph'] as Record; + } + // Legacy fallback: communications.teams + if (comms?.teams && typeof comms.teams === 'object') { + return comms.teams as Record; + } + } catch { /* ignore */ } + return undefined; +} diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index d993348de..39d7599d4 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -79,7 +79,7 @@ export interface PlatformAdapter { // ─── Communication Adapter ──────────────────────────────────────────── /** Where communication happens — which channel/service */ -export type CommunicationChannel = 'github-discussions' | 'ado-work-items' | 'teams-webhook' | 'file-log'; +export type CommunicationChannel = 'github-discussions' | 'ado-work-items' | 'teams-graph' | 'file-log'; /** A reply from a human on a communication channel */ export interface CommunicationReply { @@ -99,6 +99,8 @@ export interface CommunicationConfig { postDecisions?: boolean; /** Post escalations when agents are blocked */ postEscalations?: boolean; + /** Adapter-specific configuration, keyed by channel name */ + adapterConfig?: Record; } /** diff --git a/test/comms-teams.test.ts b/test/comms-teams.test.ts new file mode 100644 index 000000000..5dd427f73 --- /dev/null +++ b/test/comms-teams.test.ts @@ -0,0 +1,222 @@ +/** + * Unit tests for pure helpers exported from the Teams communication adapter. + */ + +import { describe, it, expect } from 'vitest'; +import { + escapeHtml, + stripHtml, + formatTeamsMessage, + parseTokens, + base64url, + validateGraphId, + TeamsCommunicationAdapter, +} from '../packages/squad-sdk/src/platform/comms-teams.js'; + +// ─── escapeHtml ────────────────────────────────────────────────────── + +describe('escapeHtml', () => { + it('escapes ampersands', () => { + expect(escapeHtml('a & b')).toBe('a & b'); + }); + + it('escapes less-than', () => { + expect(escapeHtml('a < b')).toBe('a < b'); + }); + + it('escapes greater-than', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('say "hello"')).toBe('say "hello"'); + }); + + it('passes through normal text unchanged', () => { + expect(escapeHtml('hello world')).toBe('hello world'); + }); + + it('escapes all special chars in one string', () => { + expect(escapeHtml('"Tom & Jerry"')).toBe( + '<b>"Tom & Jerry"</b>', + ); + }); +}); + +// ─── stripHtml ─────────────────────────────────────────────────────── + +describe('stripHtml', () => { + it('removes HTML tags', () => { + expect(stripHtml('bold')).toBe('bold'); + }); + + it('removes nested tags', () => { + expect(stripHtml('

nested

')).toBe('nested'); + }); + + it('decodes   to space', () => { + expect(stripHtml('hello world')).toBe('hello world'); + }); + + it('decodes & to &', () => { + expect(stripHtml('a&b')).toBe('a&b'); + }); + + it('decodes < to <', () => { + expect(stripHtml('a<b')).toBe('a', () => { + expect(stripHtml('a>b')).toBe('a>b'); + }); + + it('decodes " to "', () => { + expect(stripHtml('say "hi"')).toBe('say "hi"'); + }); + + it('trims whitespace', () => { + expect(stripHtml(' hello ')).toBe('hello'); + }); + + it('handles combined tags and entities', () => { + expect(stripHtml('

Tom & Jerry <3

')).toBe('Tom & Jerry <3'); + }); +}); + +// ─── formatTeamsMessage ────────────────────────────────────────────── + +describe('formatTeamsMessage', () => { + it('formats without author', () => { + const result = formatTeamsMessage('Title', 'Body text'); + expect(result).toContain('Title'); + expect(result).toContain('Body text'); + expect(result).not.toContain(''); + }); + + it('formats with author', () => { + const result = formatTeamsMessage('Title', 'Body', 'Alice'); + expect(result).toContain('Posted by Alice'); + }); + + it('converts newlines in body to
', () => { + const result = formatTeamsMessage('T', 'line1\nline2'); + expect(result).toContain('line1
line2'); + }); + + it('escapes HTML in title and body', () => { + const result = formatTeamsMessage('