From e40daddf0dd168c19251cdb84a3b6b136814f553 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Tue, 24 Mar 2026 12:59:02 +0800 Subject: [PATCH] feat(notification): replace feishu-only flow with pluggable providers --- .../notification-service-refactoring.md | 617 ++++++++++++++++++ src/config/__tests__/config-manager.test.ts | 5 +- src/config/config-manager.ts | 30 +- src/config/config-schema.ts | 44 +- src/config/index.ts | 29 +- src/controllers/config.ts | 85 +++ src/controllers/review.ts | 110 ++-- src/services/feishu.ts | 156 ----- src/services/notification-manager.ts | 11 + .../notification/base-notification-service.ts | 61 ++ src/services/notification/index.ts | 14 + .../notification/notification-factory.ts | 25 + .../notification/notification-manager.ts | 105 +++ .../providers/feishu-notification-service.ts | 129 ++++ .../providers/wecom-notification-service.ts | 101 +++ src/services/notification/types.ts | 47 ++ 16 files changed, 1339 insertions(+), 230 deletions(-) create mode 100644 docs/design/notification-service-refactoring.md delete mode 100644 src/services/feishu.ts create mode 100644 src/services/notification-manager.ts create mode 100644 src/services/notification/base-notification-service.ts create mode 100644 src/services/notification/index.ts create mode 100644 src/services/notification/notification-factory.ts create mode 100644 src/services/notification/notification-manager.ts create mode 100644 src/services/notification/providers/feishu-notification-service.ts create mode 100644 src/services/notification/providers/wecom-notification-service.ts create mode 100644 src/services/notification/types.ts diff --git a/docs/design/notification-service-refactoring.md b/docs/design/notification-service-refactoring.md new file mode 100644 index 0000000..2ab503e --- /dev/null +++ b/docs/design/notification-service-refactoring.md @@ -0,0 +1,617 @@ +# 通知服务抽象化重构方案 + +## 1. 概述 + +### 1.1 背景 +当前项目中的通知功能仅支持飞书(Feishu/Lark)平台,代码高度耦合飞书特定的API实现。随着业务需求扩展,需要支持企业微信(WeCom)等其他通知渠道。 + +### 1.2 目标 +- 抽象通用通知服务接口,支持多平台扩展 +- 支持同时配置多个通知服务(如飞书+企业微信同时推送) +- 统一通知调用入口,避免平台耦合与重复发送 +- 清晰的代码结构,便于后续添加新平台(如Slack、钉钉等) + +### 1.3 非目标 +- 不修改通知的业务触发逻辑 +- 不改变现有的Gitea Webhook处理流程 +- 不引入外部通知服务SDK依赖(保持轻量) + +--- + +## 2. 现有架构分析 + +### 2.1 重构前实现(已下线) +``` +src/ +├── services/feishu.ts # 飞书服务实现(156行) +├── controllers/review.ts # 通知调用点 +├── config/config-schema.ts # 配置定义 +└── config/config-manager.ts # 配置管理 +``` + +### 2.2 关键代码特征 +- **强耦合**:`review.ts` 直接调用 `feishuService.sendXXXNotification()` +- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构 +- **签名逻辑**:HMAC-SHA256(timestamp+"\n"+secret) +- **配置单一**:仅支持一组飞书配置 + +### 2.3 通知场景 +| 场景 | 方法名 | 触发条件 | +|------|--------|----------| +| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 | +| 工单关闭 | `sendIssueClosedNotification` | Issue closed | +| 工单指派 | `sendIssueAssignedNotification` | Issue assigned | +| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 | +| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested | + +--- + +## 3. 目标架构设计 + +### 3.1 架构模式 +采用**策略模式(Strategy)** + **工厂模式(Factory)**: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Notification Service Layer │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ +│ │ INotification │ │ INotification │ │ INotification│ │ +│ │ Service │ │ Service │ │ Service │ │ +│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │ +│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │ +│ │ Feishu │ │ WeCom │ │ Slack │ │ +│ │Service │ │Service │ │ Service │ │ +│ └───────────┘ └─────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────┴─────────┐ + │ NotificationFactory│ + └─────────┬─────────┘ + │ + ┌─────────┴─────────┐ + │ NotificationManager│ ← 统一入口,支持多服务 + └───────────────────┘ +``` + +### 3.2 核心接口设计 + +#### 3.2.1 类型定义 +```typescript +// types.ts +export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk'; + +export interface NotificationContext { + // PR相关 + prTitle?: string; + prUrl?: string; + prNumber?: number; + + // Issue相关 + issueTitle?: string; + issueUrl?: string; + issueNumber?: number; + + // 用户相关 + actor?: string; + assignees?: string[]; + reviewers?: string[]; + creator?: string; + + // 仓库相关 + repository?: string; + owner?: string; + + // 时间戳 + timestamp?: Date; +} + +export interface NotificationMessage { + type: 'text' | 'markdown'; + title?: string; + content: string; + atUsers?: string[]; + url?: string; +} +``` + +#### 3.2.2 服务接口 +```typescript +// INotificationService +export interface INotificationService { + readonly provider: NotificationProvider; + + isEnabled(): boolean; + sendMessage(message: NotificationMessage): Promise; + + // 场景特定方法 + sendIssueCreatedNotification(context: NotificationContext): Promise; + sendIssueClosedNotification(context: NotificationContext): Promise; + sendIssueAssignedNotification(context: NotificationContext): Promise; + sendPrCreatedNotification(context: NotificationContext): Promise; + sendPrReviewerAssignedNotification(context: NotificationContext): Promise; +} +``` + +### 3.3 平台差异对照 + +| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack | +|------|--------------|-----------------|-------| +| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` | +| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) | +| **@用户方式** | `` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` | +| **消息类型字段** | `msg_type` | `msgtype` | `type` | +| **内容字段** | `content.text` | `text.content` | `text` | +| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 | + +--- + +## 4. 详细实现方案 + +### 4.1 目录结构 +``` +src/ +├── services/ +│ ├── notification/ +│ │ ├── index.ts # 导出入口 +│ │ ├── types.ts # 类型定义 +│ │ ├── base-notification-service.ts # 抽象基类 +│ │ ├── notification-factory.ts # 工厂 +│ │ ├── notification-manager.ts # 管理器 +│ │ └── providers/ +│ │ ├── feishu-notification-service.ts +│ │ └── wecom-notification-service.ts +│ └── notification-manager.ts # 运行时通知管理器入口 +``` + +### 4.2 基类实现 + +```typescript +// base-notification-service.ts +export abstract class BaseNotificationService implements INotificationService { + abstract readonly provider: NotificationProvider; + + constructor(protected config: NotificationServiceConfig) {} + + isEnabled(): boolean { + return this.config.enabled && !!this.config.webhookUrl; + } + + abstract sendMessage(message: NotificationMessage): Promise; + + // 通用模板方法 + async sendIssueCreatedNotification(context: NotificationContext): Promise { + const message = this.buildIssueCreatedMessage(context); + await this.sendMessage(message); + } + + // 子类实现消息构建 + protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage; + // ... 其他方法类似 +} +``` + +### 4.3 飞书实现要点 + +```typescript +// feishu-notification-service.ts +export class FeishuNotificationService extends BaseNotificationService { + readonly provider = 'feishu' as const; + + async sendMessage(message: NotificationMessage): Promise { + const payload: any = { + msg_type: 'text', + content: { + text: message.content, + }, + }; + + // 添加签名 + if (this.config.webhookSecret) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + payload.timestamp = timestamp; + payload.sign = this.generateSign(timestamp, this.config.webhookSecret); + } + + const response = await fetch(this.config.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Feishu notification failed: ${response.statusText}`); + } + } + + protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`, + atUsers: context.assignees, + }; + } + + private generateSign(timestamp: string, secret: string): string { + const stringToSign = `${timestamp}\n${secret}`; + const hmac = crypto.createHmac('sha256', stringToSign); + return hmac.digest('base64'); + } +} +``` + +### 4.4 企业微信实现要点 + +```typescript +// wecom-notification-service.ts +export class WeComNotificationService extends BaseNotificationService { + readonly provider = 'wecom' as const; + + async sendMessage(message: NotificationMessage): Promise { + const payload: any = { + msgtype: 'text', + text: { + content: message.content, + }, + }; + + // 企业微信使用 mentioned_list + if (message.atUsers?.length) { + payload.text.mentioned_list = message.atUsers.map(u => + u.toLowerCase() === 'all' ? '@all' : u + ); + } + + const response = await fetch(this.config.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`WeCom notification failed: ${response.statusText}`); + } + } + + protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`, + atUsers: context.assignees, + }; + } +} +``` + +### 4.5 管理器实现 + +```typescript +// notification-manager.ts +export class NotificationManager { + private services: INotificationService[] = []; + + constructor(configs: NotificationServiceConfig[]) { + this.services = configs + .filter(c => c.enabled && c.webhookUrl) + .map(c => NotificationFactory.createService(c)); + } + + // 广播到所有服务 + async broadcast( + operation: (service: INotificationService) => Promise + ): Promise { + const results = await Promise.allSettled( + this.services.map(async service => { + try { + await operation(service); + } catch (error) { + logger.error(`${service.provider} notification failed:`, error); + throw error; // 重新抛出以便Promise.allSettled捕获 + } + }) + ); + + // 记录失败统计 + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + logger.warn(`${failures.length}/${this.services.length} notification services failed`); + } + } + + // 便捷方法 + async notifyIssueCreated(context: NotificationContext): Promise { + await this.broadcast(s => s.sendIssueCreatedNotification(context)); + } + + async notifyIssueClosed(context: NotificationContext): Promise { + await this.broadcast(s => s.sendIssueClosedNotification(context)); + } + + async notifyIssueAssigned(context: NotificationContext): Promise { + await this.broadcast(s => s.sendIssueAssignedNotification(context)); + } + + async notifyPrCreated(context: NotificationContext): Promise { + await this.broadcast(s => s.sendPrCreatedNotification(context)); + } + + async notifyPrReviewerAssigned(context: NotificationContext): Promise { + await this.broadcast(s => s.sendPrReviewerAssignedNotification(context)); + } +} +``` + +--- + +## 5. 配置改造 + +### 5.1 新增配置字段 + +```typescript +// config-schema.ts +export const CONFIG_FIELDS: ConfigFieldMeta[] = [ + // ... 保留原有 ... + + // 飞书配置(改造为可独立启用) + { + envKey: 'FEISHU_ENABLED', + group: 'notification', + label: '启用飞书通知', + description: '是否启用飞书通知', + type: 'boolean', + sensitive: false, + defaultValue: true, + }, + { + envKey: 'FEISHU_WEBHOOK_URL', + group: 'notification', + label: '飞书 Webhook 地址', + description: '飞书机器人 Webhook URL', + type: 'url', + sensitive: false, + }, + { + envKey: 'FEISHU_WEBHOOK_SECRET', + group: 'notification', + label: '飞书 Webhook 密钥', + description: '飞书 Webhook 签名密钥(可选)', + type: 'string', + sensitive: true, + }, + + // 企业微信配置(新增) + { + envKey: 'WECOM_ENABLED', + group: 'notification', + label: '启用企业微信通知', + description: '是否启用企业微信通知', + type: 'boolean', + sensitive: false, + defaultValue: false, + }, + { + envKey: 'WECOM_WEBHOOK_URL', + group: 'notification', + label: '企业微信 Webhook 地址', + description: '企业微信机器人 Webhook URL', + type: 'url', + sensitive: false, + }, +]; +``` + +### 5.2 配置组调整 + +```typescript +// 将 'feishu' 组改为 'notification' 组 +export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory'; + +export const CONFIG_GROUPS: ConfigGroupMeta[] = [ + // ... + { + key: 'notification', + label: '通知服务', + description: '飞书、企业微信等通知渠道配置', + icon: 'bell', + }, + // ... +]; +``` + +--- + +## 6. 调用层迁移 + +### 6.1 review.ts 改造 + +```typescript +import { getNotificationManager } from '../services/notification-manager'; + +// PR事件处理 +async function handlePullRequestEvent(c: Context, body: any): Promise { + // ... 原有逻辑 ... + + const context: NotificationContext = { + prTitle: pullRequest.title, + prUrl: pullRequest.html_url, + prNumber: pullRequest.number, + reviewers: reviewerUsernames, + repository: repo.name, + owner: repo.owner.login, + actor: body.sender?.login, + }; + + const notificationManager = getNotificationManager(); + + if (body.action === 'opened' && reviewerUsernames.length > 0) { + await notificationManager.notifyPrCreated(context); + } + + if (body.action === 'review_requested' && body.requested_reviewer) { + context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login]; + await notificationManager.notifyPrReviewerAssigned(context); + } + + // ... 继续原有逻辑 ... +} + +// Issue事件处理 +async function handleIssueEvent(c: Context, body: any): Promise { + // ... + + const context: NotificationContext = { + issueTitle: issue.title, + issueUrl: issue.html_url, + issueNumber: issue.number, + creator: creatorUsername, + assignees: assigneeUsernames, + repository: repository.name, + actor: body.sender?.login, + }; + + if (action === 'opened' && assigneeUsernames.length > 0) { + await notificationManager.notifyIssueCreated(context); + } else if (action === 'closed') { + await notificationManager.notifyIssueClosed(context); + } else if (action === 'assigned') { + await notificationManager.notifyIssueAssigned(context); + } +} +``` + +--- + +## 7. 落地决策(已执行) + +### 7.1 旧飞书服务下线 + +- 已删除 `src/services/feishu.ts`,不再保留兼容层。 +- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`。 +- 通过单一通知入口避免重复发送与配置路径分裂问题。 + +### 7.2 运行时配置生效策略 + +- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。 +- 后台保存通知配置后,可立即在后续 webhook 事件生效。 + +### 7.3 落地检查清单 + +- [x] 飞书与企业微信通过统一通知抽象发送 +- [x] 旧飞书服务文件已下线 +- [x] 控制器通知链路已去重 +- [x] 前端新增独立“通知管理”菜单与页面 + +--- + +## 8. 实施计划 + +### 8.1 阶段划分 + +| 阶段 | 任务 | 文件 | 优先级 | +|------|------|------|--------| +| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 | +| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 | +| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 | +| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 | +| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 | +| 6 | 调用层迁移 | `review.ts` | P1 | +| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 | +| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 | + +### 8.2 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 | +| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 | +| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 | + +--- + +## 9. 测试策略 + +### 9.1 单元测试 + +```typescript +// __tests__/notification.test.ts +describe('NotificationService', () => { + describe('FeishuNotificationService', () => { + it('should generate correct signature', () => { + // 测试签名算法 + }); + + it('should format message correctly', () => { + // 测试消息格式转换 + }); + }); + + describe('WeComNotificationService', () => { + it('should use mentioned_list for @users', () => { + // 测试@用户格式 + }); + }); + + describe('NotificationManager', () => { + it('should broadcast to all enabled services', async () => { + // 测试广播逻辑 + }); + + it('should not fail if one service fails', async () => { + // 测试容错 + }); + }); +}); +``` + +### 9.2 集成测试 + +- 配置真实飞书机器人测试消息发送 +- 配置企业微信机器人测试消息发送 +- 验证同时配置多个服务时的行为 + +--- + +## 10. 附录 + +### 10.1 飞书与企业微信API对比详情 + +#### 飞书消息格式 +```json +{ + "msg_type": "text", + "content": { + "text": "Hello Tom" + } +} +``` + +#### 企业微信消息格式 +```json +{ + "msgtype": "text", + "text": { + "content": "Hello World", + "mentioned_list": ["wangqing", "@all"], + "mentioned_mobile_list": ["13800001111"] + } +} +``` + +### 10.2 扩展指南 + +添加新通知平台步骤: + +1. 在 `types.ts` 添加新的 `NotificationProvider` 类型 +2. 在 `providers/` 创建新的服务类,继承 `BaseNotificationService` +3. 在 `notification-factory.ts` 添加创建逻辑 +4. 在 `config-schema.ts` 添加配置字段 +5. 在 Admin Dashboard 添加UI配置项 + +--- + +**文档版本**: 1.0 +**创建日期**: 2026-03-24 +**作者**: Sisyphus +**状态**: 已实施(持续验证中) diff --git a/src/config/__tests__/config-manager.test.ts b/src/config/__tests__/config-manager.test.ts index fcdf95f..8b9486c 100644 --- a/src/config/__tests__/config-manager.test.ts +++ b/src/config/__tests__/config-manager.test.ts @@ -82,8 +82,9 @@ describe('ConfigManager (DB backend)', () => { test('optional fields with no default return undefined', () => { const cfg = configManager.getCurrent(); - expect(cfg.feishu.webhookUrl).toBeUndefined(); - expect(cfg.feishu.webhookSecret).toBeUndefined(); + expect(cfg.notification.feishu.webhookUrl).toBeUndefined(); + expect(cfg.notification.feishu.webhookSecret).toBeUndefined(); + expect(cfg.notification.wecom.webhookUrl).toBeUndefined(); expect(cfg.admin.giteaAdminToken).toBeUndefined(); expect(cfg.review.qdrantUrl).toBeUndefined(); }); diff --git a/src/config/config-manager.ts b/src/config/config-manager.ts index 9e3faef..93d8ffd 100644 --- a/src/config/config-manager.ts +++ b/src/config/config-manager.ts @@ -11,9 +11,16 @@ export interface AppConfig { apiUrl: string; accessToken: string; }; - feishu: { - webhookUrl: string | undefined; - webhookSecret: string | undefined; + notification: { + feishu: { + enabled: boolean; + webhookUrl: string | undefined; + webhookSecret: string | undefined; + }; + wecom: { + enabled: boolean; + webhookUrl: string | undefined; + }; }; app: { port: number; @@ -46,13 +53,11 @@ export interface AppConfig { tokenBudgetSmall: number; tokenBudgetMedium: number; tokenBudgetLarge: number; - // Codex engine codexApiUrl: string; codexApiKey: string | undefined; codexModel: string; codexTimeoutMs: number; codexReviewPrompt: string | undefined; - // Memory (shared) qdrantUrl: string | undefined; enableMemory: boolean; fewShotExamplesCount: number; @@ -137,9 +142,16 @@ class ConfigManager { apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1', accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token', }, - feishu: { - webhookUrl: values.FEISHU_WEBHOOK_URL, - webhookSecret: values.FEISHU_WEBHOOK_SECRET, + notification: { + feishu: { + enabled: toBoolean('FEISHU_ENABLED', true), + webhookUrl: values.FEISHU_WEBHOOK_URL, + webhookSecret: values.FEISHU_WEBHOOK_SECRET, + }, + wecom: { + enabled: toBoolean('WECOM_ENABLED', false), + webhookUrl: values.WECOM_WEBHOOK_URL, + }, }, app: { port, @@ -178,13 +190,11 @@ class ConfigManager { tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000), tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000), tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000), - // Codex engine codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1', codexApiKey: values.CODEX_API_KEY, codexModel: values.CODEX_MODEL ?? 'o3', codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000), codexReviewPrompt: values.CODEX_REVIEW_PROMPT, - // Memory qdrantUrl: values.QDRANT_URL, enableMemory: toBoolean('ENABLE_MEMORY', false), fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10), diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e10854e..3cb93c5 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -7,7 +7,7 @@ // Types // --------------------------------------------------------------------------- -export type ConfigGroup = 'gitea' | 'feishu' | 'security' | 'review' | 'memory'; +export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory'; export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum'; @@ -43,9 +43,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [ icon: 'link', }, { - key: 'feishu', - label: '飞书通知', - description: '飞书 Webhook 通知配置', + key: 'notification', + label: '通知服务', + description: '飞书、企业微信等通知渠道配置', icon: 'bell', }, { @@ -110,24 +110,50 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [ sensitive: false, }, - // ── 飞书 ──────────────────────────────────────────────────────────────── + { + envKey: 'FEISHU_ENABLED', + group: 'notification', + label: '启用飞书通知', + description: '是否启用飞书通知', + type: 'boolean', + sensitive: false, + defaultValue: true, + }, { envKey: 'FEISHU_WEBHOOK_URL', - group: 'feishu', - label: 'Webhook 地址', + group: 'notification', + label: '飞书 Webhook 地址', description: '飞书机器人 Webhook URL', type: 'url', sensitive: false, }, { envKey: 'FEISHU_WEBHOOK_SECRET', - group: 'feishu', - label: 'Webhook 签名密钥', + group: 'notification', + label: '飞书 Webhook 密钥', description: '飞书 Webhook 签名密钥(可选)', type: 'string', sensitive: true, }, + { + envKey: 'WECOM_ENABLED', + group: 'notification', + label: '启用企业微信通知', + description: '是否启用企业微信通知', + type: 'boolean', + sensitive: false, + defaultValue: false, + }, + { + envKey: 'WECOM_WEBHOOK_URL', + group: 'notification', + label: '企业微信 Webhook 地址', + description: '企业微信机器人 Webhook URL', + type: 'url', + sensitive: false, + }, + { envKey: 'WEBHOOK_SECRET', group: 'security', diff --git a/src/config/index.ts b/src/config/index.ts index cc07959..64fa5da 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,7 @@ -import { configManager } from './config-manager'; +import { configManager } from './config-manager.js'; +import type { NotificationServiceConfig } from '../services/notification/types.js'; -type AppConfig = import('./config-manager').AppConfig; +type AppConfig = import('./config-manager.js').AppConfig; const config = new Proxy({} as AppConfig, { get(_target, prop) { @@ -8,5 +9,29 @@ const config = new Proxy({} as AppConfig, { }, }); +export function getNotificationConfigs(): NotificationServiceConfig[] { + const current = configManager.getCurrent(); + const configs: NotificationServiceConfig[] = []; + + if (current.notification.feishu.enabled && current.notification.feishu.webhookUrl) { + configs.push({ + provider: 'feishu', + enabled: true, + webhookUrl: current.notification.feishu.webhookUrl, + webhookSecret: current.notification.feishu.webhookSecret, + }); + } + + if (current.notification.wecom.enabled && current.notification.wecom.webhookUrl) { + configs.push({ + provider: 'wecom', + enabled: true, + webhookUrl: current.notification.wecom.webhookUrl, + }); + } + + return configs; +} + export { configManager }; export default config; diff --git a/src/controllers/config.ts b/src/controllers/config.ts index 1712ff1..636bc37 100644 --- a/src/controllers/config.ts +++ b/src/controllers/config.ts @@ -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(CONFIG_FIELDS.map((f) => [f.envKey, f])); +const TESTABLE_PROVIDERS = new Set(['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; + 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 }); +}); diff --git a/src/controllers/review.ts b/src/controllers/review.ts index 1b871a7..c78d848 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -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 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(保证有baseSha),headCloneUrl作为额外remote(保证有headSha) @@ -365,26 +371,28 @@ async function handleIssueEvent(c: Context, body: any): Promise { 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); diff --git a/src/services/feishu.ts b/src/services/feishu.ts deleted file mode 100644 index 91eb202..0000000 --- a/src/services/feishu.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as crypto from 'node:crypto'; -import config from '../config'; -import { logger } from '../utils/logger'; - -export class FeishuService { - /** - * 判断飞书通知是否已启用 - */ - isEnabled(): boolean { - return !!config.feishu.webhookUrl; - } - - /** - * 生成飞书消息签名 - * @param timestamp 时间戳 - * @param secret 密钥 - */ - private generateSign(timestamp: string, secret: string): string { - const stringToSign = `${timestamp}\n${secret}`; - const hmac = crypto.createHmac('sha256', stringToSign); - return hmac.digest('base64'); - } - - /** - * 发送飞书消息 - * @param content 消息内容 - * @param usernames 需要@的用户名列表 - */ - async sendMessage(content: string, usernames: string[] = []): Promise { - const webhookUrl = config.feishu.webhookUrl; - const webhookSecret = config.feishu.webhookSecret; - - if (!webhookUrl) { - logger.debug('飞书通知已跳过: webhook URL未配置'); - return; - } - - try { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const message: any = { - msg_type: 'text', - content: { - text: content, - }, - }; - - // 如果需要@用户,添加at信息 - if (usernames.length > 0) { - message.content.text += '\n'; - usernames.forEach((username) => { - message.content.text += `@${username} `; - }); - } - - // 如果配置了密钥,添加签名 - if (webhookSecret) { - message.timestamp = timestamp; - message.sign = this.generateSign(timestamp, webhookSecret); - } - - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(message), - }); - - if (!response.ok) { - throw new Error(`发送飞书消息失败: ${response.statusText}`); - } - - logger.info('飞书消息发送成功'); - } catch (error) { - logger.error('发送飞书消息失败:', error); - throw error; - } - } - - /** - * 发送工单创建通知 - * @param issueTitle 工单标题 - * @param issueUrl 工单链接 - * @param assigneeUsernames 被指派人用户名列表 - */ - async sendIssueCreatedNotification( - issueTitle: string, - issueUrl: string, - assigneeUsernames: string[] - ): Promise { - const content = `📝 新工单已创建\n标题: ${issueTitle}\n链接: ${issueUrl}`; - await this.sendMessage(content, assigneeUsernames); - } - - /** - * 发送工单关闭通知 - * @param issueTitle 工单标题 - * @param issueUrl 工单链接 - * @param creatorUsername 创建者用户名 - */ - async sendIssueClosedNotification( - issueTitle: string, - issueUrl: string, - creatorUsername: string - ): Promise { - const content = `✅ 工单已关闭\n标题: ${issueTitle}\n链接: ${issueUrl}`; - await this.sendMessage(content, [creatorUsername]); - } - - /** - * 发送工单指派通知 - * @param issueTitle 工单标题 - * @param issueUrl 工单链接 - * @param assigneeUsernames 被指派人用户名列表 - */ - async sendIssueAssignedNotification( - issueTitle: string, - issueUrl: string, - assigneeUsernames: string[] - ): Promise { - const content = `👤 工单已指派给你\n标题: ${issueTitle}\n链接: ${issueUrl}`; - await this.sendMessage(content, assigneeUsernames); - } - - /** - * 发送PR创建通知给审阅者 - * @param prTitle PR标题 - * @param prUrl PR链接 - * @param reviewerUsernames 审阅者用户名列表 - */ - async sendPrCreatedNotification( - prTitle: string, - prUrl: string, - reviewerUsernames: string[] - ): Promise { - const content = `🔄 新PR等待你审阅\n标题: ${prTitle}\n链接: ${prUrl}`; - await this.sendMessage(content, reviewerUsernames); - } - - /** - * 发送PR指派审阅者通知 - * @param prTitle PR标题 - * @param prUrl PR链接 - * @param reviewerUsernames 审阅者用户名列表 - */ - async sendPrReviewerAssignedNotification( - prTitle: string, - prUrl: string, - reviewerUsernames: string[] - ): Promise { - const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`; - await this.sendMessage(content, reviewerUsernames); - } -} - -export const feishuService = new FeishuService(); diff --git a/src/services/notification-manager.ts b/src/services/notification-manager.ts new file mode 100644 index 0000000..03a181f --- /dev/null +++ b/src/services/notification-manager.ts @@ -0,0 +1,11 @@ +import { createNotificationManager, type NotificationManager } from './notification/notification-manager.js'; +import { getNotificationConfigs } from '../config/index.js'; + +export function getNotificationManager(): NotificationManager { + const configs = getNotificationConfigs(); + return createNotificationManager(configs); +} + +export function resetNotificationManager(): void { + return; +} diff --git a/src/services/notification/base-notification-service.ts b/src/services/notification/base-notification-service.ts new file mode 100644 index 0000000..4ed5cf0 --- /dev/null +++ b/src/services/notification/base-notification-service.ts @@ -0,0 +1,61 @@ +import type { + INotificationService, + NotificationContext, + NotificationMessage, + NotificationServiceConfig, +} from './types.js'; + +export abstract class BaseNotificationService implements INotificationService { + abstract readonly provider: import('./types.js').NotificationProvider; + + constructor(protected config: NotificationServiceConfig) {} + + isEnabled(): boolean { + return this.config.enabled && !!this.config.webhookUrl; + } + + abstract sendMessage(message: NotificationMessage): Promise; + + async sendIssueCreatedNotification(context: NotificationContext): Promise { + const message = this.buildIssueCreatedMessage(context); + await this.sendMessage(message); + } + + async sendIssueClosedNotification(context: NotificationContext): Promise { + const message = this.buildIssueClosedMessage(context); + await this.sendMessage(message); + } + + async sendIssueAssignedNotification(context: NotificationContext): Promise { + const message = this.buildIssueAssignedMessage(context); + await this.sendMessage(message); + } + + async sendPrCreatedNotification(context: NotificationContext): Promise { + const message = this.buildPrCreatedMessage(context); + await this.sendMessage(message); + } + + async sendPrReviewerAssignedNotification(context: NotificationContext): Promise { + const message = this.buildPrReviewerAssignedMessage(context); + await this.sendMessage(message); + } + + protected abstract buildIssueCreatedMessage( + context: NotificationContext + ): NotificationMessage; + + protected abstract buildIssueClosedMessage( + context: NotificationContext + ): NotificationMessage; + + protected abstract buildIssueAssignedMessage( + context: NotificationContext + ): NotificationMessage; + + protected abstract buildPrCreatedMessage(context: NotificationContext): NotificationMessage; + + protected abstract buildPrReviewerAssignedMessage( + context: NotificationContext + ): NotificationMessage; +} diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts new file mode 100644 index 0000000..faf2ad6 --- /dev/null +++ b/src/services/notification/index.ts @@ -0,0 +1,14 @@ +export type { + NotificationProvider, + NotificationMessageType, + NotificationContext, + NotificationMessage, + NotificationServiceConfig, + INotificationService, +} from './types.js'; + +export { BaseNotificationService } from './base-notification-service.js'; +export { NotificationFactory } from './notification-factory.js'; +export { NotificationManager, createNotificationManager } from './notification-manager.js'; +export { FeishuNotificationService } from './providers/feishu-notification-service.js'; +export { WeComNotificationService } from './providers/wecom-notification-service.js'; diff --git a/src/services/notification/notification-factory.ts b/src/services/notification/notification-factory.ts new file mode 100644 index 0000000..28aec62 --- /dev/null +++ b/src/services/notification/notification-factory.ts @@ -0,0 +1,25 @@ +import type { + INotificationService, + NotificationServiceConfig, +} from './types.js'; +import { FeishuNotificationService } from './providers/feishu-notification-service.js'; +import { WeComNotificationService } from './providers/wecom-notification-service.js'; + +export class NotificationFactory { + static createService(config: NotificationServiceConfig): INotificationService { + switch (config.provider) { + case 'feishu': + return new FeishuNotificationService(config); + case 'wecom': + return new WeComNotificationService(config); + default: + throw new Error(`Unknown notification provider: ${config.provider}`); + } + } + + static createServices(configs: NotificationServiceConfig[]): INotificationService[] { + return configs + .filter((c) => c.enabled && c.webhookUrl) + .map((c) => this.createService(c)); + } +} diff --git a/src/services/notification/notification-manager.ts b/src/services/notification/notification-manager.ts new file mode 100644 index 0000000..a10839d --- /dev/null +++ b/src/services/notification/notification-manager.ts @@ -0,0 +1,105 @@ +import type { + INotificationService, + NotificationContext, + NotificationMessage, + NotificationProvider, +} from './types.js'; +import { NotificationFactory } from './notification-factory.js'; +import { logger } from '../../utils/logger.js'; + +export class NotificationManager { + private services: INotificationService[] = []; + + constructor(services: INotificationService[] = []) { + this.services = services; + } + + addService(service: INotificationService): void { + this.services.push(service); + } + + removeService(provider: NotificationProvider): void { + this.services = this.services.filter((s) => s.provider !== provider); + } + + hasService(provider: NotificationProvider): boolean { + return this.services.some((s) => s.provider === provider && s.isEnabled()); + } + + getService(provider: NotificationProvider): INotificationService | undefined { + return this.services.find((s) => s.provider === provider); + } + + getEnabledServices(): INotificationService[] { + return this.services.filter((s) => s.isEnabled()); + } + + private async broadcast( + operation: (service: INotificationService) => Promise + ): Promise { + const enabledServices = this.getEnabledServices(); + + if (enabledServices.length === 0) { + logger.debug('No notification services enabled'); + return; + } + + const results = await Promise.allSettled( + enabledServices.map(async (service) => { + try { + await operation(service); + logger.debug(`${service.provider} notification sent successfully`); + } catch (error) { + logger.error(`${service.provider} notification failed:`, error); + throw error; + } + }) + ); + + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + logger.warn(`${failures.length}/${enabledServices.length} notification services failed`); + } + } + + async notifyIssueCreated(context: NotificationContext): Promise { + await this.broadcast((s) => s.sendIssueCreatedNotification(context)); + } + + async notifyIssueClosed(context: NotificationContext): Promise { + await this.broadcast((s) => s.sendIssueClosedNotification(context)); + } + + async notifyIssueAssigned(context: NotificationContext): Promise { + await this.broadcast((s) => s.sendIssueAssignedNotification(context)); + } + + async notifyPrCreated(context: NotificationContext): Promise { + await this.broadcast((s) => s.sendPrCreatedNotification(context)); + } + + async notifyPrReviewerAssigned(context: NotificationContext): Promise { + await this.broadcast((s) => s.sendPrReviewerAssignedNotification(context)); + } + + async sendTestMessage(provider: NotificationProvider): Promise { + const service = this.getService(provider); + if (!service || !service.isEnabled()) { + throw new Error(`${provider} notification service is not enabled`); + } + + const message: NotificationMessage = { + type: 'text', + content: `🧪 通知测试消息\n服务: ${provider}\n时间: ${new Date().toISOString()}`, + }; + + await service.sendMessage(message); + } +} + +export function createNotificationManager( + configs: import('./types.js').NotificationServiceConfig[] +): NotificationManager { + const services = NotificationFactory.createServices(configs); + return new NotificationManager(services); +} diff --git a/src/services/notification/providers/feishu-notification-service.ts b/src/services/notification/providers/feishu-notification-service.ts new file mode 100644 index 0000000..a92eab8 --- /dev/null +++ b/src/services/notification/providers/feishu-notification-service.ts @@ -0,0 +1,129 @@ +import * as crypto from 'crypto'; +import { BaseNotificationService } from '../base-notification-service.js'; +import type { + NotificationContext, + NotificationMessage, + NotificationServiceConfig, +} from '../types.js'; + +type FeishuApiResponse = { + code?: number; + msg?: string; +}; + +function parseFeishuResponse(raw: unknown): FeishuApiResponse { + if (typeof raw === 'object' && raw !== null) { + return raw as FeishuApiResponse; + } + return {}; +} + +export class FeishuNotificationService extends BaseNotificationService { + readonly provider = 'feishu' as const; + + constructor(config: NotificationServiceConfig) { + super(config); + } + + async sendMessage(message: NotificationMessage): Promise { + if (!this.config.webhookUrl) { + throw new Error('Feishu webhook URL is not configured'); + } + + const payload: Record = { + msg_type: 'text', + content: { + text: message.content, + }, + }; + + if (this.config.webhookSecret) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + Object.assign(payload, { + timestamp, + sign: this.generateSign(timestamp, this.config.webhookSecret), + }); + } + + const response = await fetch(this.config.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to send Feishu message: ${response.status} ${response.statusText}`); + } + + const result = parseFeishuResponse(await response.json()); + if (result.code !== 0) { + throw new Error(`Feishu API error: ${result.msg || 'Unknown error'}`); + } + } + + private generateSign(timestamp: string, secret: string): string { + const stringToSign = `${timestamp}\n${secret}`; + const hmac = crypto.createHmac('sha256', stringToSign); + return hmac.digest('base64'); + } + + protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage { + const atPart = context.assignees?.length + ? `\n${context.assignees.map((u) => `@${u}`).join(' ')}` + : ''; + return { + type: 'text', + content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`, + atUsers: context.assignees, + url: context.issueUrl, + }; + } + + protected buildIssueClosedMessage(context: NotificationContext): NotificationMessage { + const atPart = context.creator ? `\n@${context.creator}` : ''; + return { + type: 'text', + content: `✅ 工单已关闭\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`, + atUsers: context.creator ? [context.creator] : undefined, + url: context.issueUrl, + }; + } + + protected buildIssueAssignedMessage(context: NotificationContext): NotificationMessage { + const atPart = context.assignees?.length + ? `\n${context.assignees.map((u) => `@${u}`).join(' ')}` + : ''; + return { + type: 'text', + content: `👤 工单已指派给你\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`, + atUsers: context.assignees, + url: context.issueUrl, + }; + } + + protected buildPrCreatedMessage(context: NotificationContext): NotificationMessage { + const atPart = context.reviewers?.length + ? `\n${context.reviewers.map((u) => `@${u}`).join(' ')}` + : ''; + return { + type: 'text', + content: `🔄 新PR等待你审阅\n标题: ${context.prTitle}\n链接: ${context.prUrl}${atPart}`, + atUsers: context.reviewers, + url: context.prUrl, + }; + } + + protected buildPrReviewerAssignedMessage(context: NotificationContext): NotificationMessage { + const atPart = context.assignees?.length + ? `\n${context.assignees.map((u) => `@${u}`).join(' ')}` + : ''; + return { + type: 'text', + content: `👀 你被指定为PR审阅者\n标题: ${context.prTitle}\n链接: ${context.prUrl}${atPart}`, + atUsers: context.assignees, + url: context.prUrl, + }; + } +} diff --git a/src/services/notification/providers/wecom-notification-service.ts b/src/services/notification/providers/wecom-notification-service.ts new file mode 100644 index 0000000..930b1cd --- /dev/null +++ b/src/services/notification/providers/wecom-notification-service.ts @@ -0,0 +1,101 @@ +import { BaseNotificationService } from '../base-notification-service.js'; +import type { + NotificationContext, + NotificationMessage, + NotificationServiceConfig, +} from '../types.js'; + +type WeComApiResponse = { + errcode?: number; + errmsg?: string; +}; + +export class WeComNotificationService extends BaseNotificationService { + readonly provider = 'wecom' as const; + + constructor(config: NotificationServiceConfig) { + super(config); + } + + async sendMessage(message: NotificationMessage): Promise { + if (!this.config.webhookUrl) { + throw new Error('WeCom webhook URL is not configured'); + } + + const payload: Record = { + msgtype: 'text', + text: { + content: message.content, + }, + }; + + if (message.atUsers?.length) { + const mentionedList = message.atUsers.map((u) => (u.toLowerCase() === 'all' ? '@all' : u)); + Object.assign(payload.text as Record, { + mentioned_list: mentionedList, + }); + } + + const response = await fetch(this.config.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to send WeCom message: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as WeComApiResponse; + if (result.errcode !== 0) { + throw new Error(`WeCom API error: ${result.errmsg || 'Unknown error'}`); + } + } + + protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`, + atUsers: context.assignees, + url: context.issueUrl, + }; + } + + protected buildIssueClosedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `✅ 工单已关闭\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`, + atUsers: context.creator ? [context.creator] : undefined, + url: context.issueUrl, + }; + } + + protected buildIssueAssignedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `👤 工单已指派给你\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`, + atUsers: context.assignees, + url: context.issueUrl, + }; + } + + protected buildPrCreatedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `🔄 新PR等待你审阅\n标题: ${context.prTitle}\n链接: ${context.prUrl}`, + atUsers: context.reviewers, + url: context.prUrl, + }; + } + + protected buildPrReviewerAssignedMessage(context: NotificationContext): NotificationMessage { + return { + type: 'text', + content: `👀 你被指定为PR审阅者\n标题: ${context.prTitle}\n链接: ${context.prUrl}`, + atUsers: context.assignees, + url: context.prUrl, + }; + } +} diff --git a/src/services/notification/types.ts b/src/services/notification/types.ts new file mode 100644 index 0000000..85eea43 --- /dev/null +++ b/src/services/notification/types.ts @@ -0,0 +1,47 @@ +export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk'; + +export type NotificationMessageType = 'text' | 'markdown'; + +export interface NotificationContext { + prTitle?: string; + prUrl?: string; + prNumber?: number; + issueTitle?: string; + issueUrl?: string; + issueNumber?: number; + actor?: string; + assignees?: string[]; + reviewers?: string[]; + creator?: string; + repository?: string; + owner?: string; + timestamp?: Date; + metadata?: Record; +} + +export interface NotificationMessage { + type: NotificationMessageType; + title?: string; + content: string; + atUsers?: string[]; + url?: string; +} + +export interface NotificationServiceConfig { + provider: NotificationProvider; + enabled: boolean; + webhookUrl: string; + webhookSecret?: string; + options?: Record; +} + +export interface INotificationService { + readonly provider: NotificationProvider; + isEnabled(): boolean; + sendMessage(message: NotificationMessage): Promise; + sendIssueCreatedNotification(context: NotificationContext): Promise; + sendIssueClosedNotification(context: NotificationContext): Promise; + sendIssueAssignedNotification(context: NotificationContext): Promise; + sendPrCreatedNotification(context: NotificationContext): Promise; + sendPrReviewerAssignedNotification(context: NotificationContext): Promise; +}