Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/__tests__/webhook-formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;

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<string, unknown>;
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<string, unknown>;

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<string, unknown>;

expect(result.event).toBe('pull');
expect(result.username).toBeUndefined();
expect(result.sessionId).toBeUndefined();
});
});
});
170 changes: 170 additions & 0 deletions src/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
);
});
});
});
32 changes: 32 additions & 0 deletions src/hook-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
};

await sendWebhook(event, payload, config);
} catch (error) {
log.debug(`Webhook dispatch failed: ${(error as Error).message}`);
}

return null;
},
};

// ─── Registry builder ───────────────────────────────────

/**
Expand All @@ -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
Expand All @@ -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 },
Expand Down
Loading
Loading