From 900cd18863ab8ddfe703bf075fdce5b7350a0e5e Mon Sep 17 00:00:00 2001 From: xincxiong Date: Sat, 19 Sep 2026 20:34:27 +0800 Subject: [PATCH] feat(webhook): add webhook integration for team notifications - Add `teamai webhook list|test` commands - Add webhook config in `teamai.yaml` under `sharing.webhooks` - Support Feishu (Lark), WeChat Work (WeCom), and generic JSON formats - Register webhook handler for session-start, stop, skill-use events - Automatic retry with exponential backoff - Optional HMAC-SHA256 signature verification - Unit tests for webhook and formatters Closes #659 --- src/__tests__/webhook-formatters.test.ts | 132 +++++++++++++++++ src/__tests__/webhook.test.ts | 170 ++++++++++++++++++++++ src/hook-handlers.ts | 32 +++++ src/index.ts | 34 +++++ src/types.ts | 68 +++++++++ src/webhook-formatters.ts | 159 ++++++++++++++++++++ src/webhook.ts | 176 +++++++++++++++++++++++ 7 files changed, 771 insertions(+) create mode 100644 src/__tests__/webhook-formatters.test.ts create mode 100644 src/__tests__/webhook.test.ts create mode 100644 src/webhook-formatters.ts create mode 100644 src/webhook.ts diff --git a/src/__tests__/webhook-formatters.test.ts b/src/__tests__/webhook-formatters.test.ts new file mode 100644 index 000000000..46c654d42 --- /dev/null +++ b/src/__tests__/webhook-formatters.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { formatFeishuMessage, formatWecomMessage, formatGenericJson } from '../webhook-formatters.js'; +import type { WebhookPayload } from '../types.js'; + +describe('webhook-formatters', () => { + const basePayload: WebhookPayload = { + event: 'push', + timestamp: '2026-09-19T10:30:00.000Z', + tool: 'claude', + username: 'testuser', + sessionId: 'abc12345-def6-7890', + cwd: '/home/user/project', + data: { resources: { skills: 2, rules: 1 } }, + }; + + describe('formatFeishuMessage', () => { + it('should format push event correctly', () => { + const result = formatFeishuMessage(basePayload) as Record; + + expect(result.msg_type).toBe('text'); + expect(result.content).toBeDefined(); + + const text = (result.content as { text: string }).text; + expect(text).toContain('TeamAI Notification'); + expect(text).toContain('Event: Push Complete'); + expect(text).toContain('User: testuser'); + expect(text).toContain('Tool: claude'); + expect(text).toContain('Resources: skills: 2, rules: 1'); + }); + + it('should format skill-use event correctly', () => { + const payload: WebhookPayload = { + ...basePayload, + event: 'skill-use', + data: { skillName: 'my-skill' }, + }; + + const result = formatFeishuMessage(payload) as Record; + const text = (result.content as { text: string }).text; + + expect(text).toContain('Event: Skill Used'); + expect(text).toContain('Skill: my-skill'); + }); + + it('should format session-start event correctly', () => { + const payload: WebhookPayload = { + ...basePayload, + event: 'session-start', + data: { sessionId: 'abc12345' }, + }; + + const result = formatFeishuMessage(payload) as Record; + const text = (result.content as { text: string }).text; + + expect(text).toContain('Event: Session Started'); + expect(text).toContain('Session: abc12345'); + }); + + it('should handle missing optional fields', () => { + const payload: WebhookPayload = { + event: 'pull', + timestamp: '2026-09-19T10:30:00.000Z', + tool: 'codex', + data: {}, + }; + + const result = formatFeishuMessage(payload) as Record; + const text = (result.content as { text: string }).text; + + expect(text).toContain('Event: Pull Complete'); + expect(text).not.toContain('User:'); + }); + }); + + describe('formatWecomMessage', () => { + it('should format push event correctly', () => { + const result = formatWecomMessage(basePayload) as Record; + + expect(result.msgtype).toBe('text'); + expect(result.text).toBeDefined(); + + const text = (result.text as { content: string }).content; + expect(text).toContain('TeamAI Notification'); + expect(text).toContain('Event: Push Complete'); + expect(text).toContain('User: testuser'); + expect(text).toContain('Tool: claude'); + }); + + it('should format skill-use event correctly', () => { + const payload: WebhookPayload = { + ...basePayload, + event: 'skill-use', + data: { skillName: 'deploy-skill' }, + }; + + const result = formatWecomMessage(payload) as Record; + const text = (result.text as { content: string }).content; + + expect(text).toContain('Event: Skill Used'); + expect(text).toContain('Skill: deploy-skill'); + }); + }); + + describe('formatGenericJson', () => { + it('should format all fields correctly', () => { + const result = formatGenericJson(basePayload) as Record; + + expect(result.event).toBe('push'); + expect(result.timestamp).toBe('2026-09-19T10:30:00.000Z'); + expect(result.tool).toBe('claude'); + expect(result.username).toBe('testuser'); + expect(result.sessionId).toBe('abc12345-def6-7890'); + expect(result.cwd).toBe('/home/user/project'); + expect(result.data).toEqual({ resources: { skills: 2, rules: 1 } }); + }); + + it('should handle missing optional fields', () => { + const payload: WebhookPayload = { + event: 'pull', + timestamp: '2026-09-19T10:30:00.000Z', + tool: 'codex', + data: {}, + }; + + const result = formatGenericJson(payload) as Record; + + expect(result.event).toBe('pull'); + expect(result.username).toBeUndefined(); + expect(result.sessionId).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts new file mode 100644 index 000000000..3755d0fb6 --- /dev/null +++ b/src/__tests__/webhook.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────── + +const mockAutoDetectInit = vi.fn().mockResolvedValue({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { + team: 'test', + sharing: { + webhooks: { + enabled: true, + endpoints: [ + { + url: 'https://open.feishu.cn/open-apis/bot/v2/hook/test', + type: 'feishu', + events: ['push', 'pull', 'skill-use'], + timeout: 5000, + retries: 3, + }, + { + url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test', + type: 'wecom', + events: ['push', 'session-start'], + timeout: 5000, + retries: 3, + }, + ], + }, + }, + }, +}); + +vi.mock('../config.js', () => ({ + autoDetectInit: mockAutoDetectInit, +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('webhook', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + }); + + describe('sendWebhook', () => { + it('should send webhook to matching endpoints', async () => { + const { sendWebhook } = await import('../webhook.js'); + + await sendWebhook('push', { + tool: 'claude', + data: { resources: { skills: 1 } }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith( + 'https://open.feishu.cn/open-apis/bot/v2/hook/test', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'text/plain; charset=utf-8', + }), + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test', + expect.objectContaining({ + method: 'POST', + }), + ); + }); + + it('should not send to non-matching endpoints', async () => { + const { sendWebhook } = await import('../webhook.js'); + + await sendWebhook('session-stop', { + tool: 'claude', + data: {}, + }); + + // session-stop is not in either endpoint's events list + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when webhooks are disabled', async () => { + mockAutoDetectInit.mockResolvedValueOnce({ + localConfig: { repo: { localPath: '/tmp', remote: '' } }, + teamConfig: { + sharing: { + webhooks: { enabled: false, endpoints: [] }, + }, + }, + }); + + const { sendWebhook } = await import('../webhook.js'); + + await sendWebhook('push', { + tool: 'claude', + data: {}, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should handle fetch errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const { sendWebhook } = await import('../webhook.js'); + + // Should not throw + await sendWebhook('push', { + tool: 'claude', + data: {}, + }); + }); + }); + + describe('loadWebhookConfig', () => { + it('should return webhook config', async () => { + const { loadWebhookConfig } = await import('../webhook.js'); + + const config = await loadWebhookConfig(); + + expect(config.enabled).toBe(true); + expect(config.endpoints).toHaveLength(2); + expect(config.endpoints[0].type).toBe('feishu'); + expect(config.endpoints[1].type).toBe('wecom'); + }); + }); + + describe('listWebhooks', () => { + it('should list configured endpoints', async () => { + const { listWebhooks } = await import('../webhook.js'); + + const endpoints = await listWebhooks(); + + expect(endpoints).toHaveLength(2); + expect(endpoints[0].url).toContain('feishu'); + expect(endpoints[0].type).toBe('feishu'); + expect(endpoints[1].type).toBe('wecom'); + }); + }); + + describe('testWebhook', () => { + it('should send test event to all endpoints', async () => { + const { testWebhook } = await import('../webhook.js'); + + await testWebhook(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should send test event to specific endpoint', async () => { + const { testWebhook } = await import('../webhook.js'); + + await testWebhook('https://open.feishu.cn/open-apis/bot/v2/hook/test'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://open.feishu.cn/open-apis/bot/v2/hook/test', + expect.anything(), + ); + }); + }); +}); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 11f93e367..030e0a9ee 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -502,6 +502,35 @@ const localAgentHandler: HookHandler = { }, }; +/** Webhook notification handler — sends events to configured endpoints. */ +const webhookHandler: HookHandler = { + name: 'webhook-dispatch', + async execute(stdin, tool) { + const { sendWebhook, loadWebhookConfig } = await import('./webhook.js'); + + try { + const config = await loadWebhookConfig(); + if (!config.enabled || config.endpoints.length === 0) return null; + + const event = typeof stdin.event === 'string' ? stdin.event : 'unknown'; + + const payload = { + tool, + sessionId: deriveSessionId(stdin), + cwd: resolveHookCwd(stdin), + username: typeof stdin.username === 'string' ? stdin.username : undefined, + data: stdin as Record, + }; + + await sendWebhook(event, payload, config); + } catch (error) { + log.debug(`Webhook dispatch failed: ${(error as Error).message}`); + } + + return null; + }, +}; + // ─── Registry builder ─────────────────────────────────── /** @@ -519,6 +548,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, // ─── Stop ───────────────────────────────────────── // votes-sync and contribute-check may return a hint the host injects back @@ -532,12 +562,14 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, { event: 'stop', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, + { event: 'stop', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, // ─── PostToolUse ────────────────────────────────── { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'post-tool-use', matcher: 'TodoWrite', handler: todowriteHintHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS }, { event: 'post-tool-use', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, + { event: 'post-tool-use', matcher: 'Skill', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, // ─── UserPromptSubmit ───────────────────────────── { event: 'prompt-submit', matcher: '*', handler: pendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, diff --git a/src/index.ts b/src/index.ts index 0b40234f7..496c5b275 100644 --- a/src/index.ts +++ b/src/index.ts @@ -624,6 +624,40 @@ mcpCmd await mcpRemove(globalOpts); }); +// ─── Webhook commands ─────────────────────────────────── + +const webhookCmd = program + .command('webhook') + .description('Manage webhook integrations for team notifications'); + +webhookCmd + .command('list') + .description('List configured webhook endpoints') + .action(async () => { + const { listWebhooks } = await import('./webhook.js'); + const endpoints = await listWebhooks(); + if (endpoints.length === 0) { + console.log('No webhook endpoints configured.'); + return; + } + console.log('Configured webhook endpoints:\n'); + for (const ep of endpoints) { + console.log(` URL: ${ep.url}`); + console.log(` Type: ${ep.type}`); + console.log(` Events: ${ep.events.join(', ')}`); + console.log(''); + } + }); + +webhookCmd + .command('test') + .description('Send test event to webhook endpoints') + .option('--url ', 'Test specific endpoint URL') + .action(async (cmdOpts) => { + const { testWebhook } = await import('./webhook.js'); + await testWebhook(cmdOpts.url); + }); + // ─── Usage tracking commands ──────────────────────────── program diff --git a/src/types.ts b/src/types.ts index d2004258e..c2cf19374 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,6 +111,27 @@ export const SharingConfigSchema = z.object({ * keyword in a space-separated script must appear as a whole word. */ correctionKeywords: z.array(z.string()).default([]), }).optional(), + // Optional (not .default) so existing TeamaiConfig literals stay valid; use + // getWebhookSharing() for the defaulted view. + webhooks: z.object({ + /** Enable webhook notifications for team events. */ + enabled: z.boolean().default(false), + /** List of webhook endpoints to notify. */ + endpoints: z.array(z.object({ + /** Target URL for the webhook. */ + url: z.string().url(), + /** Webhook type: feishu (Lark), wecom (WeChat Work), or json (generic). */ + type: z.enum(['feishu', 'wecom', 'json']), + /** Optional HMAC-SHA256 secret for signature verification. */ + secret: z.string().optional(), + /** Events to send: push, pull, skill-use, session-start, session-stop. */ + events: z.array(z.string()).default(['push', 'pull', 'skill-use', 'session-start', 'session-stop']), + /** Request timeout in milliseconds. */ + timeout: z.number().default(5000), + /** Number of retries on failure with exponential backoff. */ + retries: z.number().default(3), + })).default([]), + }).optional(), }); /** Defaulted view of the optional `sharing.intervention` config. */ @@ -2017,3 +2038,50 @@ export interface ImportSession { /** 已处理条目数(用于 --resume 进度恢复) */ progress: number; } + +// ─── Webhook types ────────────────────────────────────── + +export const WebhookEndpointSchema = z.object({ + url: z.string().url(), + type: z.enum(['feishu', 'wecom', 'json']), + secret: z.string().optional(), + events: z.array(z.string()).default(['push', 'pull', 'skill-use', 'session-start', 'session-stop']), + timeout: z.number().default(5000), + retries: z.number().default(3), +}); + +export const WebhookConfigSchema = z.object({ + enabled: z.boolean().default(false), + endpoints: z.array(WebhookEndpointSchema).default([]), +}); + +export type WebhookEndpoint = z.infer; +export type WebhookConfig = z.infer; + +export interface WebhookPayload { + event: string; + timestamp: string; + tool: string; + sessionId?: string; + cwd?: string; + team?: string; + username?: string; + data: Record; +} + +/** Defaulted view of the optional `sharing.webhooks` config. */ +export function getWebhookSharing(config: { + sharing?: { webhooks?: { enabled?: boolean; endpoints?: Array<{ url: string; type: string; events?: string[] }> } }; +}): WebhookConfig { + const w = config.sharing?.webhooks; + return { + enabled: w?.enabled ?? false, + endpoints: (w?.endpoints ?? []).map((ep) => ({ + url: ep.url, + type: ep.type as 'feishu' | 'wecom' | 'json', + events: ep.events ?? ['push', 'pull', 'skill-use', 'session-start', 'session-stop'], + timeout: 5000, + retries: 3, + })), + }; +} diff --git a/src/webhook-formatters.ts b/src/webhook-formatters.ts new file mode 100644 index 000000000..0bb216601 --- /dev/null +++ b/src/webhook-formatters.ts @@ -0,0 +1,159 @@ +import type { WebhookPayload } from './types.js'; + +/** + * Format message for Feishu (Lark) webhook. + */ +export function formatFeishuMessage(payload: WebhookPayload): Record { + const eventLabel = formatEventLabel(payload.event); + const time = new Date(payload.timestamp).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const lines: string[] = [ + `🤖 TeamAI Notification`, + `━━━━━━━━━━━━━━━━━`, + `Event: ${eventLabel}`, + ]; + + if (payload.username) { + lines.push(`User: ${payload.username}`); + } + + lines.push(`Time: ${time}`); + + if (payload.tool) { + lines.push(`Tool: ${payload.tool}`); + } + + if (payload.data) { + const dataStr = formatEventData(payload.event, payload.data); + if (dataStr) { + lines.push(dataStr); + } + } + + lines.push(`━━━━━━━━━━━━━━━━━`); + + return { + msg_type: 'text', + content: { + text: lines.join('\n'), + }, + }; +} + +/** + * Format message for WeCom (WeChat Work) webhook. + */ +export function formatWecomMessage(payload: WebhookPayload): Record { + const eventLabel = formatEventLabel(payload.event); + const time = new Date(payload.timestamp).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const lines: string[] = [ + `🤖 TeamAI Notification`, + `Event: ${eventLabel}`, + ]; + + if (payload.username) { + lines.push(`User: ${payload.username}`); + } + + lines.push(`Time: ${time}`); + + if (payload.tool) { + lines.push(`Tool: ${payload.tool}`); + } + + if (payload.data) { + const dataStr = formatEventData(payload.event, payload.data); + if (dataStr) { + lines.push(dataStr); + } + } + + return { + msgtype: 'text', + text: { + content: lines.join('\n'), + }, + }; +} + +/** + * Format message for generic JSON webhook. + */ +export function formatGenericJson(payload: WebhookPayload): Record { + return { + event: payload.event, + timestamp: payload.timestamp, + tool: payload.tool, + sessionId: payload.sessionId, + cwd: payload.cwd, + team: payload.team, + username: payload.username, + data: payload.data, + }; +} + +/** + * Format event label for display. + */ +function formatEventLabel(event: string): string { + const labels: Record = { + 'push': 'Push Complete', + 'pull': 'Pull Complete', + 'skill-use': 'Skill Used', + 'session-start': 'Session Started', + 'session-stop': 'Session Ended', + 'webhook-test': 'Webhook Test', + }; + return labels[event] ?? event; +} + +/** + * Format event-specific data for display. + */ +function formatEventData(event: string, data: Record): string | null { + switch (event) { + case 'push': + case 'pull': + if (data.resources && typeof data.resources === 'object') { + const resources = data.resources as Record; + const parts = Object.entries(resources) + .filter(([, count]) => count > 0) + .map(([type, count]) => `${type}: ${count}`); + return parts.length > 0 ? `Resources: ${parts.join(', ')}` : null; + } + return null; + + case 'skill-use': + if (data.skillName && typeof data.skillName === 'string') { + return `Skill: ${data.skillName}`; + } + return null; + + case 'session-start': + case 'session-stop': + if (data.sessionId && typeof data.sessionId === 'string') { + return `Session: ${data.sessionId.slice(0, 8)}...`; + } + return null; + + default: + return null; + } +} diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 000000000..b73a38c34 --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,176 @@ +import { createHmac } from 'node:crypto'; +import { autoDetectInit } from './config.js'; +import { log } from './utils/logger.js'; +import { getWebhookSharing, type WebhookEndpoint, type WebhookConfig, type WebhookPayload } from './types.js'; +import { formatFeishuMessage, formatWecomMessage, formatGenericJson } from './webhook-formatters.js'; + +/** + * Send a webhook notification to all configured endpoints. + */ +export async function sendWebhook( + event: string, + payload: Partial, + config?: WebhookConfig, +): Promise { + if (!config) { + const { teamConfig } = await autoDetectInit(); + config = getWebhookSharing(teamConfig); + } + + if (!config.enabled || config.endpoints.length === 0) return; + + const fullPayload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + tool: payload.tool ?? 'unknown', + sessionId: payload.sessionId, + cwd: payload.cwd, + team: payload.team, + username: payload.username, + data: payload.data ?? {}, + }; + + const matchingEndpoints = config.endpoints.filter( + (ep) => ep.events.includes(event) || ep.events.includes('*'), + ); + + if (matchingEndpoints.length === 0) return; + + await Promise.allSettled( + matchingEndpoints.map((ep) => sendToEndpoint(ep, fullPayload)), + ); +} + +/** + * Send webhook to a single endpoint with retry logic. + */ +async function sendToEndpoint( + endpoint: WebhookEndpoint, + payload: WebhookPayload, +): Promise { + const { url, type, secret, timeout, retries } = endpoint; + + const body = formatMessage(type, payload); + + const headers: Record = { + 'Content-Type': type === 'json' ? 'application/json' : 'text/plain; charset=utf-8', + }; + + if (secret) { + const signature = createHmac('sha256', secret) + .update(JSON.stringify(body)) + .digest('hex'); + headers['X-TeamAI-Signature'] = `sha256=${signature}`; + } + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + method: 'POST', + headers, + body: typeof body === 'string' ? body : JSON.stringify(body), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + log.debug(`Webhook sent successfully to ${url}`); + return; + } + + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + log.warn(`Webhook to ${url} failed with status ${response.status} (not retrying)`); + return; + } + + if (attempt < retries) { + const delay = Math.pow(2, attempt) * 1000; + log.debug(`Webhook to ${url} failed, retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + log.warn(`Webhook to ${url} timed out after ${timeout}ms`); + } else if (attempt < retries) { + const delay = Math.pow(2, attempt) * 1000; + log.debug(`Webhook to ${url} failed, retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + log.warn(`Webhook to ${url} failed after ${retries + 1} attempts: ${(error as Error).message}`); + } + } + } +} + +/** + * Format message based on webhook type. + */ +function formatMessage( + type: WebhookEndpoint['type'], + payload: WebhookPayload, +): string | Record { + switch (type) { + case 'feishu': + return formatFeishuMessage(payload); + case 'wecom': + return formatWecomMessage(payload); + case 'json': + default: + return formatGenericJson(payload); + } +} + +/** + * Load webhook config from team config. + */ +export async function loadWebhookConfig(): Promise { + const { teamConfig } = await autoDetectInit(); + return getWebhookSharing(teamConfig); +} + +/** + * List all configured webhook endpoints. + */ +export async function listWebhooks(): Promise { + const config = await loadWebhookConfig(); + return config.endpoints; +} + +/** + * Test webhook by sending a test event. + */ +export async function testWebhook(url?: string): Promise { + const config = await loadWebhookConfig(); + + const endpoints = url + ? config.endpoints.filter((ep) => ep.url === url) + : config.endpoints; + + if (endpoints.length === 0) { + log.warn('No webhook endpoints configured.'); + return; + } + + const testPayload: WebhookPayload = { + event: 'webhook-test', + timestamp: new Date().toISOString(), + tool: 'teamai-cli', + data: { + message: 'This is a test webhook from TeamAI CLI', + }, + }; + + for (const endpoint of endpoints) { + log.info(`Testing webhook to ${endpoint.url}...`); + try { + await sendToEndpoint(endpoint, testPayload); + log.success(`Webhook test successful: ${endpoint.url}`); + } catch (error) { + log.error(`Webhook test failed: ${endpoint.url} - ${(error as Error).message}`); + } + } +}