This commit is contained in:
Dang Zerong
2026-03-10 17:22:07 +08:00
parent 8594cf4d77
commit 17306c6814
9 changed files with 769 additions and 62 deletions

View File

@@ -10,8 +10,9 @@ import hashlib
import hmac
import base64
import logging
import os
import requests
from typing import Dict, Any
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
@@ -29,10 +30,113 @@ class FeishuNotifier:
self.config = config
self.webhook_url = config.get('webhook_url', '')
self.secret = config.get('secret', '')
# 文件上传配置
self.app_id = config.get('app_id', '')
self.app_secret = config.get('app_secret', '')
self.chat_id = config.get('chat_id', '')
self.attach_report_file = config.get('attach_report_file', True)
# 缓存 token
self._tenant_access_token = None
self._token_expires_at = 0
if not self.webhook_url:
logger.warning('飞书 Webhook URL 未配置')
def _get_tenant_access_token(self) -> Optional[str]:
"""
获取飞书 tenant_access_token
Returns:
token 字符串,如果失败返回 None
"""
if not self.app_id or not self.app_secret:
return None
# 检查缓存的 token 是否有效
if self._tenant_access_token and time.time() < self._token_expires_at:
return self._tenant_access_token
try:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json; charset=utf-8"}
payload = {
"app_id": self.app_id,
"app_secret": self.app_secret
}
response = requests.post(url, headers=headers, json=payload, timeout=10)
result = response.json()
if result.get("code") == 0:
self._tenant_access_token = result.get("tenant_access_token")
# 提前 5 分钟过期
self._token_expires_at = time.time() + result.get("expire", 7200) - 300
return self._tenant_access_token
else:
logger.error(f"获取 tenant_access_token 失败: {result.get('msg')}")
return None
except Exception as e:
logger.error(f"获取 tenant_access_token 异常: {str(e)}")
return None
def _upload_file(self, file_path: str, file_name: str) -> Optional[str]:
"""
上传文件到飞书(用于消息中发送)
Args:
file_path: 文件本地路径
file_name: 文件名
Returns:
file_key 用于发送消息,如果失败返回 None
"""
token = self._get_tenant_access_token()
if not token:
logger.error("无法获取 token上传文件失败")
return None
try:
# 使用 im API 上传文件
url = "https://open.feishu.cn/open-apis/im/v1/files"
headers = {
"Authorization": f"Bearer {token}"
}
# 读取文件
with open(file_path, 'rb') as f:
file_content = f.read()
# 获取文件类型
file_ext = os.path.splitext(file_name)[1].lower()
file_type = 'stream' # 默认
# 构建 multipart 请求
files = {
'file': (file_name, file_content, 'application/octet-stream')
}
data = {
'file_name': file_name,
'file_type': file_type
}
response = requests.post(url, headers=headers, files=files, data=data, timeout=60)
result = response.json()
if result.get("code") == 0:
file_key = result.get("data", {}).get("file_key")
logger.info(f"文件上传成功: {file_name}, file_key: {file_key}")
return file_key
else:
logger.error(f"文件上传失败: {result.get('msg')}, code: {result.get('code')}")
return None
except Exception as e:
logger.error(f"文件上传异常: {str(e)}")
return None
def send_report(self, report: Dict[str, Any]) -> bool:
"""
发送扫描报告到飞书
@@ -48,47 +152,169 @@ class FeishuNotifier:
return False
try:
# 构建消息内容
message = self._build_message(report)
# 如果配置了签名,则使用签名验证
if self.secret:
timestamp, sign = self._generate_sign()
payload = {
"timestamp": timestamp,
"sign": sign,
"msg_type": "interactive",
"card": message
}
# 上传报告文件(如果配置了)
file_key = None
if self.attach_report_file and self.app_id and self.app_secret:
report_file = report.get('report_file')
if report_file and os.path.exists(report_file):
file_name = os.path.basename(report_file)
file_key = self._upload_file(report_file, file_name)
# 如果配置了 chat_id使用应用机器人发送消息
if self.chat_id and self.app_id and self.app_secret:
# 使用应用机器人 API 发送
self._send_app_message(report, file_key)
else:
payload = {
"msg_type": "interactive",
"card": message
}
# 使用 Webhook 发送
message = self._build_message(report, file_key=file_key)
self._send_webhook_message(message)
# 发送请求
headers = {'Content-Type': 'application/json'}
response = requests.post(
self.webhook_url,
headers=headers,
data=json.dumps(payload).encode('utf-8'),
timeout=30
)
# 解析响应
result = response.json()
if result.get('code') == 0:
logger.info('飞书消息发送成功')
return True
else:
logger.error(f'飞书消息发送失败: {result.get("msg")}')
return False
logger.info('飞书消息发送成功')
return True
except Exception as e:
logger.error(f'发送飞书通知失败: {str(e)}', exc_info=True)
return False
def _send_webhook_message(self, message: Dict[str, Any]) -> bool:
"""使用 Webhook 发送消息"""
# 如果配置了签名,则使用签名验证
if self.secret:
timestamp, sign = self._generate_sign()
payload = {
"timestamp": timestamp,
"sign": sign,
"msg_type": "interactive",
"card": message
}
else:
payload = {
"msg_type": "interactive",
"card": message
}
# 发送请求
headers = {'Content-Type': 'application/json'}
response = requests.post(
self.webhook_url,
headers=headers,
data=json.dumps(payload).encode('utf-8'),
timeout=30
)
# 解析响应
result = response.json()
if result.get('code') == 0:
return True
else:
logger.error(f'飞书消息发送失败: {result.get("msg")}')
return False
def _send_app_message(self, report: Dict[str, Any], file_key: str = None) -> bool:
"""使用应用机器人发送消息(支持文件)"""
token = self._get_tenant_access_token()
if not token:
logger.error("无法获取 token")
return False
# 构建消息内容
basic_info = self._build_basic_info_text(report)
# 构建消息元素
elements = []
# 添加基本信息
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": basic_info
}
})
# 添加文件
if file_key:
elements.append({
"tag": "file",
"file_key": file_key
})
# 构造消息体
message_content = {
"title": report.get('status_text', '代码扫描报告'),
"elements": elements
}
# 发送消息到群聊
try:
url = "https://open.feishu.cn/open-apis/im/v1/messages"
params = {
"receive_id_type": "chat_id"
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8"
}
payload = {
"receive_id": self.chat_id,
"msg_type": "interactive",
"content": json.dumps(message_content)
}
response = requests.post(url, params=params, headers=headers, json=payload, timeout=30)
result = response.json()
if result.get("code") == 0:
logger.info("应用机器人消息发送成功")
return True
else:
logger.error(f"应用机器人消息发送失败: {result.get('msg')}")
return False
except Exception as e:
logger.error(f"应用机器人消息发送异常: {str(e)}")
return False
def _build_basic_info_text(self, report: Dict[str, Any]) -> str:
"""构建基本信息的文本"""
status = report.get('status', 'pass')
if status == 'pass':
status_icon = ''
elif status == 'fail':
status_icon = ''
else:
status_icon = '⚠️'
pr_url = report.get('pr_url')
target_branch = report.get('target_branch')
if pr_url and target_branch:
title = f"{status_icon} PR 代码质量扫描报告"
basic_info = (f"**仓库:** `{report.get('repo_name', 'unknown')}`\n"
f"**源分支:** `{report.get('branch', 'unknown')}` → **目标分支:** `{target_branch}`\n"
f"**PR链接:** [查看PR]({pr_url})\n"
f"**提交:** `{report.get('commit_id', 'unknown')}`\n"
f"**提交者:** {report.get('author', 'unknown')}")
else:
title = f"{status_icon} 代码质量扫描报告"
basic_info = (f"**仓库:** `{report.get('repo_name', 'unknown')}`\n"
f"**分支:** `{report.get('branch', 'unknown')}`\n"
f"**提交:** `{report.get('commit_id', 'unknown')}`\n"
f"**提交者:** {report.get('author', 'unknown')}")
total_issues = report.get('total_issues', 0)
total_errors = report.get('total_errors', 0)
total_warnings = report.get('total_warnings', 0)
info = f"{title}\n\n{basic_info}\n\n"
info += f"**扫描状态:** {report.get('status_text', 'unknown')}\n"
info += f"📊 总问题: {total_issues} | 🔴 错误: {total_errors} | 🟡 警告: {total_warnings}\n"
info += f"🕐 扫描时间: {report.get('timestamp', '')}"
return info
def _generate_sign(self) -> tuple:
"""
生成飞书签名
@@ -113,7 +339,7 @@ class FeishuNotifier:
return timestamp, sign
def _build_message(self, report: Dict[str, Any]) -> Dict[str, Any]:
def _build_message(self, report: Dict[str, Any], file_key: str = None) -> Dict[str, Any]:
"""
构建飞书卡片消息
@@ -143,8 +369,14 @@ class FeishuNotifier:
# 获取扫描结果详情
scan_details = []
for scanner_name, result in report.get('scan_results', {}).items():
# AI 审查的 summary 是字符串,跳过
if scanner_name == 'ai':
continue
tool_name = result.get('tool', scanner_name)
summary = result.get('summary', {})
if not isinstance(summary, dict):
continue
files_scanned = result.get('files_scanned', 0)
total = summary.get('total', 0)
@@ -257,6 +489,13 @@ class FeishuNotifier:
}
})
# 添加报告文件附件
if file_key:
card["elements"].append({
"tag": "file",
"file_key": file_key
})
return card
def send_simple_message(self, title: str, content: str) -> bool: