mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
feat(notification): replace feishu-only flow with pluggable providers
This commit is contained in:
617
docs/design/notification-service-refactoring.md
Normal file
617
docs/design/notification-service-refactoring.md
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
// 场景特定方法
|
||||||
|
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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) |
|
||||||
|
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `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<void>;
|
||||||
|
|
||||||
|
// 通用模板方法
|
||||||
|
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.broadcast(s => s.sendIssueCreatedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast(s => s.sendIssueClosedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast(s => s.sendIssueAssignedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast(s => s.sendPrCreatedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||||
|
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<Response> {
|
||||||
|
// ... 原有逻辑 ...
|
||||||
|
|
||||||
|
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<Response> {
|
||||||
|
// ...
|
||||||
|
|
||||||
|
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 <at user_id=\"ou_xxx\">Tom</at>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 企业微信消息格式
|
||||||
|
```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
|
||||||
|
**状态**: 已实施(持续验证中)
|
||||||
@@ -82,8 +82,9 @@ describe('ConfigManager (DB backend)', () => {
|
|||||||
|
|
||||||
test('optional fields with no default return undefined', () => {
|
test('optional fields with no default return undefined', () => {
|
||||||
const cfg = configManager.getCurrent();
|
const cfg = configManager.getCurrent();
|
||||||
expect(cfg.feishu.webhookUrl).toBeUndefined();
|
expect(cfg.notification.feishu.webhookUrl).toBeUndefined();
|
||||||
expect(cfg.feishu.webhookSecret).toBeUndefined();
|
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||||
|
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ export interface AppConfig {
|
|||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
};
|
};
|
||||||
feishu: {
|
notification: {
|
||||||
webhookUrl: string | undefined;
|
feishu: {
|
||||||
webhookSecret: string | undefined;
|
enabled: boolean;
|
||||||
|
webhookUrl: string | undefined;
|
||||||
|
webhookSecret: string | undefined;
|
||||||
|
};
|
||||||
|
wecom: {
|
||||||
|
enabled: boolean;
|
||||||
|
webhookUrl: string | undefined;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -46,13 +53,11 @@ export interface AppConfig {
|
|||||||
tokenBudgetSmall: number;
|
tokenBudgetSmall: number;
|
||||||
tokenBudgetMedium: number;
|
tokenBudgetMedium: number;
|
||||||
tokenBudgetLarge: number;
|
tokenBudgetLarge: number;
|
||||||
// Codex engine
|
|
||||||
codexApiUrl: string;
|
codexApiUrl: string;
|
||||||
codexApiKey: string | undefined;
|
codexApiKey: string | undefined;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
codexTimeoutMs: number;
|
codexTimeoutMs: number;
|
||||||
codexReviewPrompt: string | undefined;
|
codexReviewPrompt: string | undefined;
|
||||||
// Memory (shared)
|
|
||||||
qdrantUrl: string | undefined;
|
qdrantUrl: string | undefined;
|
||||||
enableMemory: boolean;
|
enableMemory: boolean;
|
||||||
fewShotExamplesCount: number;
|
fewShotExamplesCount: number;
|
||||||
@@ -137,9 +142,16 @@ class ConfigManager {
|
|||||||
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
|
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
|
||||||
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token',
|
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token',
|
||||||
},
|
},
|
||||||
feishu: {
|
notification: {
|
||||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
feishu: {
|
||||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
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: {
|
app: {
|
||||||
port,
|
port,
|
||||||
@@ -178,13 +190,11 @@ class ConfigManager {
|
|||||||
tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000),
|
tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000),
|
||||||
tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000),
|
tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000),
|
||||||
tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000),
|
tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000),
|
||||||
// Codex engine
|
|
||||||
codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1',
|
codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1',
|
||||||
codexApiKey: values.CODEX_API_KEY,
|
codexApiKey: values.CODEX_API_KEY,
|
||||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||||
// Memory
|
|
||||||
qdrantUrl: values.QDRANT_URL,
|
qdrantUrl: values.QDRANT_URL,
|
||||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Types
|
// 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';
|
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
|||||||
icon: 'link',
|
icon: 'link',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'feishu',
|
key: 'notification',
|
||||||
label: '飞书通知',
|
label: '通知服务',
|
||||||
description: '飞书 Webhook 通知配置',
|
description: '飞书、企业微信等通知渠道配置',
|
||||||
icon: 'bell',
|
icon: 'bell',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,24 +110,50 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
|||||||
sensitive: false,
|
sensitive: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 飞书 ────────────────────────────────────────────────────────────────
|
{
|
||||||
|
envKey: 'FEISHU_ENABLED',
|
||||||
|
group: 'notification',
|
||||||
|
label: '启用飞书通知',
|
||||||
|
description: '是否启用飞书通知',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
envKey: 'FEISHU_WEBHOOK_URL',
|
envKey: 'FEISHU_WEBHOOK_URL',
|
||||||
group: 'feishu',
|
group: 'notification',
|
||||||
label: 'Webhook 地址',
|
label: '飞书 Webhook 地址',
|
||||||
description: '飞书机器人 Webhook URL',
|
description: '飞书机器人 Webhook URL',
|
||||||
type: 'url',
|
type: 'url',
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||||
group: 'feishu',
|
group: 'notification',
|
||||||
label: 'Webhook 签名密钥',
|
label: '飞书 Webhook 密钥',
|
||||||
description: '飞书 Webhook 签名密钥(可选)',
|
description: '飞书 Webhook 签名密钥(可选)',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
sensitive: true,
|
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',
|
envKey: 'WEBHOOK_SECRET',
|
||||||
group: 'security',
|
group: 'security',
|
||||||
|
|||||||
@@ -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, {
|
const config = new Proxy({} as AppConfig, {
|
||||||
get(_target, prop) {
|
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 { configManager };
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { configManager } from '../config/config-manager';
|
import { configManager } from '../config/config-manager';
|
||||||
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
|
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';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// ── Constants ────────────────────────────────────────────────────────────────
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
@@ -19,6 +21,36 @@ const INTEGER_FIELDS = new Set([
|
|||||||
|
|
||||||
/** Fast lookup from envKey → field metadata. */
|
/** Fast lookup from envKey → field metadata. */
|
||||||
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.envKey, f]));
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -203,3 +235,56 @@ configRouter.post('/reset', async (c) => {
|
|||||||
return c.json({ message: '保存配置失败', error: errMsg }, 500);
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { codexEngine } from '../review/codex/codex-engine';
|
|||||||
import { LocalRepoManager } from '../review/context/local-repo-manager';
|
import { LocalRepoManager } from '../review/context/local-repo-manager';
|
||||||
import { SandboxExec } from '../review/context/sandbox-exec';
|
import { SandboxExec } from '../review/context/sandbox-exec';
|
||||||
import { reviewEngine } from '../review/engine';
|
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 { PullRequestDetails, giteaService } from '../services/gitea';
|
||||||
import { logger } from '../utils/logger';
|
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 });
|
logger.info('收到PR事件', { owner, repo: repoName, prNumber, action: body.action });
|
||||||
|
|
||||||
// 处理PR审阅者通知(仅在飞书启用时)
|
// 处理PR审阅者通知(支持多平台)
|
||||||
if (feishuService.isEnabled()) {
|
try {
|
||||||
try {
|
const reviewerUsernames = map(
|
||||||
// 获取PR的审阅者列表
|
pullRequest.requested_reviewers,
|
||||||
const reviewerUsernames = map(
|
(reviewer) => reviewer.full_name || reviewer.login
|
||||||
pullRequest.requested_reviewers,
|
);
|
||||||
(reviewer) => reviewer.full_name || reviewer.login
|
|
||||||
);
|
|
||||||
|
|
||||||
// 记录审阅者信息
|
if (reviewerUsernames.length > 0) {
|
||||||
if (reviewerUsernames.length > 0) {
|
logger.info('PR有指定审阅者', {
|
||||||
logger.info('PR有指定审阅者', {
|
prNumber,
|
||||||
prNumber,
|
reviewers: reviewerUsernames.join(','),
|
||||||
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);
|
|
||||||
// 继续执行代码审查流程,不因通知失败而中断
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
// Fork PR策略:始终clone base repo(保证有baseSha),headCloneUrl作为额外remote(保证有headSha)
|
||||||
@@ -365,26 +371,28 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
|||||||
assigneeUsernames: assigneeUsernames.join(','),
|
assigneeUsernames: assigneeUsernames.join(','),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!feishuService.isEnabled()) {
|
|
||||||
return c.json({ status: 'ignored', message: '飞书通知未启用,工单事件已跳过' }, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
if (action === 'opened' && assigneeUsernames.length > 0) {
|
||||||
await feishuService.sendIssueCreatedNotification(issueTitle, issueUrl, assigneeUsernames);
|
await notificationManager.notifyIssueCreated(context);
|
||||||
}
|
} else if (action === 'closed') {
|
||||||
// 处理工单关闭事件
|
await notificationManager.notifyIssueClosed(context);
|
||||||
else if (action === 'closed' && creatorUsername) {
|
} else if (action === 'assigned' && assigneeUsernames.length > 0) {
|
||||||
await feishuService.sendIssueClosedNotification(issueTitle, issueUrl, creatorUsername);
|
await notificationManager.notifyIssueAssigned(context);
|
||||||
}
|
|
||||||
// 处理工单指派事件
|
|
||||||
else if (action === 'assigned' && assigneeUsernames.length > 0) {
|
|
||||||
await feishuService.sendIssueAssignedNotification(issueTitle, issueUrl, assigneeUsernames);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('处理工单事件失败:', error);
|
logger.error('新通知系统处理工单事件失败:', error);
|
||||||
return c.json({ error: '处理工单事件失败' }, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ status: 'success', message: '工单事件处理完成' }, 200);
|
return c.json({ status: 'success', message: '工单事件处理完成' }, 200);
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`;
|
|
||||||
await this.sendMessage(content, reviewerUsernames);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const feishuService = new FeishuService();
|
|
||||||
11
src/services/notification-manager.ts
Normal file
11
src/services/notification-manager.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
61
src/services/notification/base-notification-service.ts
Normal file
61
src/services/notification/base-notification-service.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
const message = this.buildIssueCreatedMessage(context);
|
||||||
|
await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendIssueClosedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
const message = this.buildIssueClosedMessage(context);
|
||||||
|
await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendIssueAssignedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
const message = this.buildIssueAssignedMessage(context);
|
||||||
|
await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPrCreatedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
const message = this.buildPrCreatedMessage(context);
|
||||||
|
await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
14
src/services/notification/index.ts
Normal file
14
src/services/notification/index.ts
Normal file
@@ -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';
|
||||||
25
src/services/notification/notification-factory.ts
Normal file
25
src/services/notification/notification-factory.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/services/notification/notification-manager.ts
Normal file
105
src/services/notification/notification-manager.ts
Normal file
@@ -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<void>
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.broadcast((s) => s.sendIssueCreatedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast((s) => s.sendIssueClosedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast((s) => s.sendIssueAssignedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast((s) => s.sendPrCreatedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||||
|
await this.broadcast((s) => s.sendPrReviewerAssignedNotification(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestMessage(provider: NotificationProvider): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
if (!this.config.webhookUrl) {
|
||||||
|
throw new Error('Feishu webhook URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
if (!this.config.webhookUrl) {
|
||||||
|
throw new Error('WeCom webhook URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
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<string, unknown>, {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/services/notification/types.ts
Normal file
47
src/services/notification/types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INotificationService {
|
||||||
|
readonly provider: NotificationProvider;
|
||||||
|
isEnabled(): boolean;
|
||||||
|
sendMessage(message: NotificationMessage): Promise<void>;
|
||||||
|
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user