2026-03-09 09:24:08 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
飞书机器人通知器
|
|
|
|
|
|
发送代码质量扫描报告到飞书
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import logging
|
2026-03-10 17:22:07 +08:00
|
|
|
|
import os
|
2026-03-09 09:24:08 +08:00
|
|
|
|
import requests
|
2026-03-10 17:22:07 +08:00
|
|
|
|
from typing import Dict, Any, Optional
|
2026-03-09 09:24:08 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeishuNotifier:
|
|
|
|
|
|
"""飞书机器人通知器"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: Dict[str, Any]):
|
|
|
|
|
|
"""
|
|
|
|
|
|
初始化飞书通知器
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
config: 飞书配置
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.config = config
|
|
|
|
|
|
self.webhook_url = config.get('webhook_url', '')
|
|
|
|
|
|
self.secret = config.get('secret', '')
|
2026-03-10 17:22:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 文件上传配置
|
|
|
|
|
|
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
|
2026-03-09 09:24:08 +08:00
|
|
|
|
|
|
|
|
|
|
if not self.webhook_url:
|
|
|
|
|
|
logger.warning('飞书 Webhook URL 未配置')
|
|
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
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]:
|
|
|
|
|
|
"""
|
2026-03-11 12:30:45 +08:00
|
|
|
|
上传文件到飞书
|
2026-03-10 17:22:07 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
file_path: 文件本地路径
|
|
|
|
|
|
file_name: 文件名
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
file_key 用于发送消息,如果失败返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
token = self._get_tenant_access_token()
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
logger.error("无法获取 token,上传文件失败")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-11 12:30:45 +08:00
|
|
|
|
url = "https://open.feishu.cn/open-apis/drive/v1/files/upload_all"
|
2026-03-10 17:22:07 +08:00
|
|
|
|
headers = {
|
|
|
|
|
|
"Authorization": f"Bearer {token}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 读取文件
|
|
|
|
|
|
with open(file_path, 'rb') as f:
|
|
|
|
|
|
file_content = f.read()
|
2026-03-11 12:30:45 +08:00
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# 构建 multipart 请求
|
|
|
|
|
|
files = {
|
|
|
|
|
|
'file': (file_name, file_content, 'application/octet-stream')
|
|
|
|
|
|
}
|
|
|
|
|
|
data = {
|
|
|
|
|
|
'file_name': file_name,
|
2026-03-11 12:30:45 +08:00
|
|
|
|
'parent_node': 'root' # 根目录
|
2026-03-10 17:22:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response = requests.post(url, headers=headers, files=files, data=data, timeout=60)
|
|
|
|
|
|
result = response.json()
|
|
|
|
|
|
|
|
|
|
|
|
if result.get("code") == 0:
|
2026-03-11 12:30:45 +08:00
|
|
|
|
file_key = result.get("data", {}).get("file", {}).get("token")
|
|
|
|
|
|
logger.info(f"文件上传成功: {file_name}")
|
2026-03-10 17:22:07 +08:00
|
|
|
|
return file_key
|
|
|
|
|
|
else:
|
2026-03-11 12:30:45 +08:00
|
|
|
|
logger.error(f"文件上传失败: {result.get('msg')}")
|
2026-03-10 17:22:07 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"文件上传异常: {str(e)}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
def send_report(self, report: Dict[str, Any]) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
发送扫描报告到飞书
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
report: 报告数据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.webhook_url:
|
|
|
|
|
|
logger.error('飞书 Webhook URL 未配置')
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# 上传报告文件(如果配置了)
|
|
|
|
|
|
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)
|
2026-03-09 09:24:08 +08:00
|
|
|
|
else:
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# 使用 Webhook 发送
|
|
|
|
|
|
message = self._build_message(report, file_key=file_key)
|
|
|
|
|
|
self._send_webhook_message(message)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-09 09:24:08 +08:00
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# 发送消息到群聊
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-09 09:24:08 +08:00
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
response = requests.post(url, params=params, headers=headers, json=payload, timeout=30)
|
2026-03-09 09:24:08 +08:00
|
|
|
|
result = response.json()
|
|
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
if result.get("code") == 0:
|
|
|
|
|
|
logger.info("应用机器人消息发送成功")
|
2026-03-09 09:24:08 +08:00
|
|
|
|
return True
|
|
|
|
|
|
else:
|
2026-03-10 17:22:07 +08:00
|
|
|
|
logger.error(f"应用机器人消息发送失败: {result.get('msg')}")
|
2026-03-09 09:24:08 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-03-10 17:22:07 +08:00
|
|
|
|
logger.error(f"应用机器人消息发送异常: {str(e)}")
|
2026-03-09 09:24:08 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
def _generate_sign(self) -> tuple:
|
|
|
|
|
|
"""
|
|
|
|
|
|
生成飞书签名
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(timestamp, sign) 元组
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 当前时间戳(秒)
|
|
|
|
|
|
timestamp = str(int(time.time()))
|
|
|
|
|
|
|
|
|
|
|
|
# 拼接字符串
|
|
|
|
|
|
string_to_sign = '{}\n{}'.format(timestamp, self.secret)
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 HmacSHA256 计算签名
|
|
|
|
|
|
hmac_code = hmac.new(
|
|
|
|
|
|
string_to_sign.encode('utf-8'),
|
|
|
|
|
|
digestmod=hashlib.sha256
|
|
|
|
|
|
).digest()
|
|
|
|
|
|
|
|
|
|
|
|
# 进行 Base64 编码
|
|
|
|
|
|
sign = base64.b64encode(hmac_code).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
return timestamp, sign
|
|
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
def _build_message(self, report: Dict[str, Any], file_key: str = None) -> Dict[str, Any]:
|
2026-03-09 09:24:08 +08:00
|
|
|
|
"""
|
|
|
|
|
|
构建飞书卡片消息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
report: 报告数据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
飞书卡片消息结构
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 根据状态选择颜色
|
|
|
|
|
|
status = report.get('status', 'pass')
|
|
|
|
|
|
if status == 'pass':
|
|
|
|
|
|
theme_color = 'green'
|
|
|
|
|
|
status_icon = '✅'
|
|
|
|
|
|
elif status == 'fail':
|
|
|
|
|
|
theme_color = 'red'
|
|
|
|
|
|
status_icon = '❌'
|
|
|
|
|
|
else:
|
|
|
|
|
|
theme_color = 'orange'
|
|
|
|
|
|
status_icon = '⚠️'
|
|
|
|
|
|
|
|
|
|
|
|
# 构建问题摘要
|
|
|
|
|
|
total_issues = report.get('total_issues', 0)
|
|
|
|
|
|
total_errors = report.get('total_errors', 0)
|
|
|
|
|
|
total_warnings = report.get('total_warnings', 0)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取扫描结果详情
|
|
|
|
|
|
scan_details = []
|
|
|
|
|
|
for scanner_name, result in report.get('scan_results', {}).items():
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# AI 审查的 summary 是字符串,跳过
|
|
|
|
|
|
if scanner_name == 'ai':
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
tool_name = result.get('tool', scanner_name)
|
|
|
|
|
|
summary = result.get('summary', {})
|
2026-03-10 17:22:07 +08:00
|
|
|
|
if not isinstance(summary, dict):
|
|
|
|
|
|
continue
|
2026-03-09 09:24:08 +08:00
|
|
|
|
files_scanned = result.get('files_scanned', 0)
|
|
|
|
|
|
total = summary.get('total', 0)
|
2026-03-10 11:18:39 +08:00
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
if total > 0:
|
|
|
|
|
|
detail_text = f"{tool_name}: 扫描 {files_scanned} 个文件,发现 {total} 个问题"
|
|
|
|
|
|
else:
|
|
|
|
|
|
detail_text = f"{tool_name}: 扫描 {files_scanned} 个文件,无问题"
|
2026-03-10 11:18:39 +08:00
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
scan_details.append(detail_text)
|
|
|
|
|
|
|
2026-03-10 11:18:39 +08:00
|
|
|
|
# 检查是否为 PR 扫描
|
|
|
|
|
|
pr_url = report.get('pr_url')
|
|
|
|
|
|
target_branch = report.get('target_branch')
|
|
|
|
|
|
|
|
|
|
|
|
# 构建基本信息文本
|
|
|
|
|
|
if pr_url and target_branch:
|
|
|
|
|
|
# PR 扫描
|
|
|
|
|
|
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:
|
|
|
|
|
|
# Push 扫描
|
|
|
|
|
|
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')}")
|
|
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
# 构建卡片消息
|
|
|
|
|
|
card = {
|
|
|
|
|
|
"header": {
|
|
|
|
|
|
"title": {
|
|
|
|
|
|
"tag": "plain_text",
|
2026-03-10 11:18:39 +08:00
|
|
|
|
"content": title
|
2026-03-09 09:24:08 +08:00
|
|
|
|
},
|
|
|
|
|
|
"template": theme_color
|
|
|
|
|
|
},
|
|
|
|
|
|
"elements": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
2026-03-10 11:18:39 +08:00
|
|
|
|
"content": basic_info
|
2026-03-09 09:24:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": f"**扫描状态:** {report.get('status_text', 'unknown')}\n"
|
|
|
|
|
|
f"📊 总问题: {total_issues} | "
|
|
|
|
|
|
f"🔴 错误: {total_errors} | "
|
|
|
|
|
|
f"🟡 警告: {total_warnings}"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 添加扫描详情
|
|
|
|
|
|
if scan_details:
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": "**扫描详情:**\n" + "\n".join([f"- {d}" for d in scan_details])
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 添加主要问题列表(最多显示5个)
|
|
|
|
|
|
all_issues = []
|
|
|
|
|
|
for scanner_name, result in report.get('scan_results', {}).items():
|
|
|
|
|
|
for issue in result.get('issues', [])[:3]: # 每个扫描器最多显示3个
|
|
|
|
|
|
all_issues.append(issue)
|
|
|
|
|
|
|
|
|
|
|
|
if all_issues:
|
|
|
|
|
|
issues_text = "**主要问题:**\n"
|
|
|
|
|
|
for i, issue in enumerate(all_issues[:5], 1):
|
|
|
|
|
|
severity = issue.get('severity', 'Unknown')
|
|
|
|
|
|
severity_emoji = {
|
|
|
|
|
|
'HIGH': '🔴',
|
|
|
|
|
|
'MEDIUM': '🟡',
|
|
|
|
|
|
'LOW': '🔵',
|
|
|
|
|
|
'ERROR': '🔴',
|
|
|
|
|
|
'WARNING': '🟡'
|
|
|
|
|
|
}.get(severity.upper(), '⚪')
|
|
|
|
|
|
|
|
|
|
|
|
file_path = issue.get('file', 'unknown')
|
|
|
|
|
|
line_num = issue.get('line', 0)
|
|
|
|
|
|
message = issue.get('message', 'No message')[:50]
|
|
|
|
|
|
|
|
|
|
|
|
issues_text += f"{i}. {severity_emoji} `{file_path}:{line_num}` - {message}\n"
|
|
|
|
|
|
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": issues_text
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 添加时间戳
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": f"🕐 扫描时间: {report.get('timestamp', '')}"
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-11 12:30:45 +08:00
|
|
|
|
# 添加 PR 操作按钮(仅 PR 扫描且扫描通过时显示)
|
|
|
|
|
|
if pr_url and target_branch and status == 'pass':
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": "**请选择操作:**"
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 解析仓库信息用于按钮回调
|
|
|
|
|
|
repo_full_name = report.get('repo_name', '')
|
|
|
|
|
|
if '/' in repo_full_name:
|
|
|
|
|
|
owner, repo = repo_full_name.split('/', 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
owner, repo = '', repo_full_name
|
|
|
|
|
|
|
|
|
|
|
|
pr_number = report.get('pr_number', 0)
|
|
|
|
|
|
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "action",
|
|
|
|
|
|
"actions": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "button",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "plain_text",
|
|
|
|
|
|
"content": "✅ 同意合并"
|
|
|
|
|
|
},
|
|
|
|
|
|
"type": "primary",
|
|
|
|
|
|
"value": {
|
|
|
|
|
|
"action": "merge",
|
|
|
|
|
|
"owner": owner,
|
|
|
|
|
|
"repo": repo,
|
|
|
|
|
|
"pr_number": pr_number,
|
|
|
|
|
|
"pr_url": pr_url
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "button",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "plain_text",
|
|
|
|
|
|
"content": "❌ 取消合并"
|
|
|
|
|
|
},
|
|
|
|
|
|
"type": "danger",
|
|
|
|
|
|
"value": {
|
|
|
|
|
|
"action": "close",
|
|
|
|
|
|
"owner": owner,
|
|
|
|
|
|
"repo": repo,
|
|
|
|
|
|
"pr_number": pr_number,
|
|
|
|
|
|
"pr_url": pr_url
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-10 17:22:07 +08:00
|
|
|
|
# 添加报告文件附件
|
|
|
|
|
|
if file_key:
|
|
|
|
|
|
card["elements"].append({
|
|
|
|
|
|
"tag": "file",
|
|
|
|
|
|
"file_key": file_key
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-09 09:24:08 +08:00
|
|
|
|
return card
|
|
|
|
|
|
|
|
|
|
|
|
def send_simple_message(self, title: str, content: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
发送简单文本消息
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
title: 标题
|
|
|
|
|
|
content: 内容
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.webhook_url:
|
|
|
|
|
|
logger.error('飞书 Webhook URL 未配置')
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 构建消息
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"msg_type": "text",
|
|
|
|
|
|
"content": {
|
|
|
|
|
|
"text": f"{title}\n{content}"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 发送请求
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f'发送飞书通知失败: {str(e)}')
|
|
|
|
|
|
return False
|