fix: auto-detect Feishu webhook format and validate response body (#271)
* feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files. * fix: fix Feishu and Larksuite webhook support to the notification service with updated UI and tests. --------- Co-authored-by: Xiang Li <xiangli>
This commit is contained in:
@@ -306,4 +306,75 @@ describe('notifyService', () => {
|
|||||||
const fetchOptions = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
const fetchOptions = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
expect(fetchOptions.dispatcher).toBeUndefined();
|
expect(fetchOptions.dispatcher).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends feishu webhook payload with msg_type text format', async () => {
|
||||||
|
const { config } = await import('../config.js');
|
||||||
|
config.webhookEnabled = true;
|
||||||
|
config.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/demo-token';
|
||||||
|
config.smtpEnabled = false;
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 0, msg: 'success' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendNotification } = await import('./notifyService.js');
|
||||||
|
await sendNotification('测试通知', 'feishu message', 'info', { bypassThrottle: true, throwOnFailure: true });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = fetchMock.mock.calls[0] as [string, { body?: string }];
|
||||||
|
expect(call[0]).toContain('open.feishu.cn/open-apis/bot/v2/hook/');
|
||||||
|
|
||||||
|
const payload = JSON.parse(call[1]?.body || '{}') as { msg_type?: string; content?: { text?: string } };
|
||||||
|
expect(payload.msg_type).toBe('text');
|
||||||
|
expect(payload.content?.text || '').toContain('[metapi][INFO] 测试通知');
|
||||||
|
expect(payload.content?.text || '').toContain('feishu message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when feishu webhook returns non-zero code', async () => {
|
||||||
|
const { config } = await import('../config.js');
|
||||||
|
config.webhookEnabled = true;
|
||||||
|
config.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/demo-token';
|
||||||
|
config.smtpEnabled = false;
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 19001, msg: 'param invalid' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendNotification } = await import('./notifyService.js');
|
||||||
|
await expect(
|
||||||
|
sendNotification('测试通知', 'message', 'info', {
|
||||||
|
bypassThrottle: true,
|
||||||
|
throwOnFailure: true,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/飞书|19001|param invalid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends feishu webhook payload for larksuite.com domain', async () => {
|
||||||
|
const { config } = await import('../config.js');
|
||||||
|
config.webhookEnabled = true;
|
||||||
|
config.webhookUrl = 'https://open.larksuite.com/open-apis/bot/v2/hook/demo-token';
|
||||||
|
config.smtpEnabled = false;
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 0, msg: 'success' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendNotification } = await import('./notifyService.js');
|
||||||
|
await sendNotification('测试通知', 'lark message', 'warning', { bypassThrottle: true, throwOnFailure: true });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = fetchMock.mock.calls[0] as [string, { body?: string }];
|
||||||
|
expect(call[0]).toContain('open.larksuite.com/open-apis/bot/v2/hook/');
|
||||||
|
|
||||||
|
const payload = JSON.parse(call[1]?.body || '{}') as { msg_type?: string; content?: { text?: string } };
|
||||||
|
expect(payload.msg_type).toBe('text');
|
||||||
|
expect(payload.content?.text || '').toContain('[metapi][WARNING] 测试通知');
|
||||||
|
expect(payload.content?.text || '').toContain('lark message');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,6 +104,30 @@ function buildWeComText(
|
|||||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFeishuBotWebhook(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return (
|
||||||
|
(parsed.hostname === 'open.feishu.cn' || parsed.hostname === 'open.larksuite.com')
|
||||||
|
&& parsed.pathname.includes('/open-apis/bot/v2/hook/')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeishuText(
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
level: 'info' | 'warning' | 'error',
|
||||||
|
timeFootnote: string,
|
||||||
|
): string {
|
||||||
|
const maxLength = 3900;
|
||||||
|
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||||
|
if (raw.length <= maxLength) return raw;
|
||||||
|
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendNotification(
|
export async function sendNotification(
|
||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -142,26 +166,36 @@ export async function sendNotification(
|
|||||||
channel: 'webhook',
|
channel: 'webhook',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const isWeComWebhook = isWeComBotWebhook(config.webhookUrl);
|
const isWeComWebhook = isWeComBotWebhook(config.webhookUrl);
|
||||||
const response = await fetch(config.webhookUrl, {
|
const isFeishuWebhook = isFeishuBotWebhook(config.webhookUrl);
|
||||||
method: 'POST',
|
let body: string;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (isWeComWebhook) {
|
||||||
body: JSON.stringify(
|
body = JSON.stringify({
|
||||||
isWeComWebhook
|
|
||||||
? {
|
|
||||||
msgtype: 'text',
|
msgtype: 'text',
|
||||||
text: {
|
text: {
|
||||||
content: buildWeComText(title, resolvedMessage, level, timeFootnote),
|
content: buildWeComText(title, resolvedMessage, level, timeFootnote),
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
: {
|
} else if (isFeishuWebhook) {
|
||||||
|
body = JSON.stringify({
|
||||||
|
msg_type: 'text',
|
||||||
|
content: {
|
||||||
|
text: buildFeishuText(title, resolvedMessage, level, timeFootnote),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({
|
||||||
title,
|
title,
|
||||||
message: resolvedMessage,
|
message: resolvedMessage,
|
||||||
level,
|
level,
|
||||||
timestamp: now.toISOString(),
|
timestamp: now.toISOString(),
|
||||||
localTime: formatLocalDateTime(now),
|
localTime: formatLocalDateTime(now),
|
||||||
timeZone: getResolvedTimeZone(),
|
timeZone: getResolvedTimeZone(),
|
||||||
},
|
});
|
||||||
),
|
}
|
||||||
|
const response = await fetch(config.webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Webhook 响应状态 ${response.status}`);
|
throw new Error(`Webhook 响应状态 ${response.status}`);
|
||||||
@@ -177,6 +211,17 @@ export async function sendNotification(
|
|||||||
throw new Error(`企业微信 Webhook 返回错误 ${payload.errcode}: ${payload.errmsg || 'unknown error'}`);
|
throw new Error(`企业微信 Webhook 返回错误 ${payload.errcode}: ${payload.errmsg || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isFeishuWebhook) {
|
||||||
|
let payload: { code?: number; msg?: string } | null = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json() as { code?: number; msg?: string };
|
||||||
|
} catch {
|
||||||
|
throw new Error('飞书 Webhook 返回了无效 JSON');
|
||||||
|
}
|
||||||
|
if (typeof payload?.code === 'number' && payload.code !== 0) {
|
||||||
|
throw new Error(`飞书 Webhook 返回错误 ${payload.code}: ${payload.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
|
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送简单消息通知</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送消息通知(自动识别企业微信、飞书格式)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user