feat(notification): replace feishu-only flow with pluggable providers

This commit is contained in:
jeffusion
2026-03-24 12:59:02 +08:00
committed by 路遥知码力
parent b10b8dd7d5
commit e40daddf0d
16 changed files with 1339 additions and 230 deletions

View File

@@ -1,6 +1,8 @@
import { Hono } from 'hono';
import { configManager } from '../config/config-manager';
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
import { getNotificationManager } from '../services/notification-manager';
import type { NotificationProvider } from '../services/notification/types';
import { logger } from '../utils/logger';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -19,6 +21,36 @@ const INTEGER_FIELDS = new Set([
/** Fast lookup from envKey → field metadata. */
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.envKey, f]));
const TESTABLE_PROVIDERS = new Set<NotificationProvider>(['feishu', 'wecom']);
const NOTIFICATION_TEST_HISTORY_LIMIT = 30;
type NotificationTestRecord = {
id: string;
provider: string;
status: 'success' | 'error';
message: string;
timestamp: string;
};
const notificationTestHistory: NotificationTestRecord[] = [];
function appendNotificationTestRecord(
provider: string,
status: 'success' | 'error',
message: string
): void {
notificationTestHistory.unshift({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
provider,
status,
message,
timestamp: new Date().toISOString(),
});
if (notificationTestHistory.length > NOTIFICATION_TEST_HISTORY_LIMIT) {
notificationTestHistory.splice(NOTIFICATION_TEST_HISTORY_LIMIT);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -203,3 +235,56 @@ configRouter.post('/reset', async (c) => {
return c.json({ message: '保存配置失败', error: errMsg }, 500);
}
});
configRouter.post('/notification/test', async (c) => {
try {
let body: Record<string, unknown>;
try {
const parsed = await c.req.json();
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
appendNotificationTestRecord('unknown', 'error', '请求体必须是 JSON 对象');
return c.json({ message: '发送测试通知失败', error: '请求体必须是 JSON 对象' }, 400);
}
body = parsed;
} catch {
appendNotificationTestRecord('unknown', 'error', '请求体必须是 JSON 对象');
return c.json({ message: '发送测试通知失败', error: '请求体必须是 JSON 对象' }, 400);
}
const provider = typeof body.provider === 'string' ? body.provider : '';
if (!TESTABLE_PROVIDERS.has(provider as NotificationProvider)) {
appendNotificationTestRecord(
provider || 'unknown',
'error',
'provider 必须是 feishu 或 wecom'
);
return c.json({ message: '发送测试通知失败', error: 'provider 必须是 feishu 或 wecom' }, 400);
}
const notificationManager = getNotificationManager();
const providerName = provider as NotificationProvider;
if (!notificationManager.hasService(providerName)) {
appendNotificationTestRecord(providerName, 'error', `${providerName} 未启用或未配置`);
return c.json({ message: '发送测试通知失败', error: `${providerName} 未启用或未配置` }, 400);
}
await notificationManager.sendTestMessage(providerName);
appendNotificationTestRecord(providerName, 'success', `${providerName} 测试通知已发送`);
return c.json({
success: true,
message: `${providerName} 测试通知已发送`,
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
appendNotificationTestRecord('unknown', 'error', errMsg);
logger.error('发送测试通知失败:', error);
return c.json({ message: '发送测试通知失败', error: errMsg }, 500);
}
});
configRouter.get('/notification/test/history', (c) => {
return c.json({ data: notificationTestHistory });
});

View File

@@ -6,7 +6,8 @@ import { codexEngine } from '../review/codex/codex-engine';
import { LocalRepoManager } from '../review/context/local-repo-manager';
import { SandboxExec } from '../review/context/sandbox-exec';
import { reviewEngine } from '../review/engine';
import { feishuService } from '../services/feishu';
import { getNotificationManager } from '../services/notification-manager';
import type { NotificationContext } from '../services/notification/types';
import { PullRequestDetails, giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
@@ -117,42 +118,47 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
logger.info('收到PR事件', { owner, repo: repoName, prNumber, action: body.action });
// 处理PR审阅者通知仅在飞书启用时
if (feishuService.isEnabled()) {
try {
// 获取PR的审阅者列表
const reviewerUsernames = map(
pullRequest.requested_reviewers,
(reviewer) => reviewer.full_name || reviewer.login
);
// 处理PR审阅者通知支持多平台
try {
const reviewerUsernames = map(
pullRequest.requested_reviewers,
(reviewer) => reviewer.full_name || reviewer.login
);
// 记录审阅者信息
if (reviewerUsernames.length > 0) {
logger.info('PR有指定审阅者', {
prNumber,
reviewers: reviewerUsernames.join(','),
});
}
// 处理PR创建事件如果有审阅者则通知
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await feishuService.sendPrCreatedNotification(prTitle, prUrl, reviewerUsernames);
}
// 处理审阅者指派事件
if (body.action === 'review_requested' && body.requested_reviewer) {
const newReviewerUsername =
body.requested_reviewer.full_name || body.requested_reviewer.login;
if (newReviewerUsername) {
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [
newReviewerUsername,
]);
}
}
} catch (error) {
logger.error('处理PR审阅者通知失败:', error);
// 继续执行代码审查流程,不因通知失败而中断
if (reviewerUsernames.length > 0) {
logger.info('PR有指定审阅者', {
prNumber,
reviewers: reviewerUsernames.join(','),
});
}
const notificationManager = getNotificationManager();
const context: NotificationContext = {
prTitle,
prUrl,
prNumber,
reviewers: reviewerUsernames,
repository: repoName,
owner,
actor: body.sender?.login,
};
// 处理PR创建事件
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await notificationManager.notifyPrCreated(context);
}
// 处理审阅者指派事件
if (body.action === 'review_requested' && body.requested_reviewer) {
const newReviewerUsername =
body.requested_reviewer.full_name || body.requested_reviewer.login;
if (newReviewerUsername) {
context.assignees = [newReviewerUsername];
await notificationManager.notifyPrReviewerAssigned(context);
}
}
} catch (error) {
logger.error('处理PR审阅者通知失败:', error);
}
// Fork PR策略始终clone base repo保证有baseShaheadCloneUrl作为额外remote保证有headSha
@@ -365,26 +371,28 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
assigneeUsernames: assigneeUsernames.join(','),
});
if (!feishuService.isEnabled()) {
return c.json({ status: 'ignored', message: '飞书通知未启用,工单事件已跳过' }, 200);
}
try {
// 处理工单创建事件
const notificationManager = getNotificationManager();
const context: NotificationContext = {
issueTitle,
issueUrl,
issueNumber: issue.number,
creator: creatorUsername,
assignees: assigneeUsernames,
repository: repository.name,
owner: repository.owner?.login,
actor: body.sender?.login,
};
if (action === 'opened' && assigneeUsernames.length > 0) {
await feishuService.sendIssueCreatedNotification(issueTitle, issueUrl, assigneeUsernames);
}
// 处理工单关闭事件
else if (action === 'closed' && creatorUsername) {
await feishuService.sendIssueClosedNotification(issueTitle, issueUrl, creatorUsername);
}
// 处理工单指派事件
else if (action === 'assigned' && assigneeUsernames.length > 0) {
await feishuService.sendIssueAssignedNotification(issueTitle, issueUrl, assigneeUsernames);
await notificationManager.notifyIssueCreated(context);
} else if (action === 'closed') {
await notificationManager.notifyIssueClosed(context);
} else if (action === 'assigned' && assigneeUsernames.length > 0) {
await notificationManager.notifyIssueAssigned(context);
}
} catch (error) {
logger.error('处理工单事件失败:', error);
return c.json({ error: '处理工单事件失败' }, 500);
logger.error('新通知系统处理工单事件失败:', error);
}
return c.json({ status: 'success', message: '工单事件处理完成' }, 200);