feat:飞书文档补全

This commit is contained in:
Evilom
2026-06-08 10:04:12 +08:00
parent ab2ca1d836
commit 3b77605591
659 changed files with 51251 additions and 1340 deletions
+538
View File
@@ -0,0 +1,538 @@
import requests
import json
import hmac
import hashlib
import base64
import urllib.parse
import time
import os
from typing import Dict, Any, List, Optional, Tuple
class ImgBBUploader:
"""Upload images to ImgBB and get public URLs for DingTalk messages."""
API_URL = "https://api.imgbb.com/1/upload"
def __init__(self, api_key: str):
self.api_key = api_key
def upload_image(self, image_path: str, name: str = None) -> Optional[str]:
"""
Upload an image file to ImgBB.
:param image_path: Local path to the image file
:param name: Optional name for the image
:return: Public URL of the uploaded image, or None if failed
"""
if not self.api_key:
print("ImgBB API key not configured")
return None
if not os.path.exists(image_path):
print(f"Image file not found: {image_path}")
return None
try:
with open(image_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
payload = {
'key': self.api_key,
'image': image_data,
}
if name:
payload['name'] = name
response = requests.post(self.API_URL, data=payload)
result = response.json()
if result.get('success'):
url = result['data']['url']
print(f"ImgBB upload success: {url}")
return url
else:
print(f"ImgBB upload failed: {result.get('error', {}).get('message', 'Unknown error')}")
return None
except Exception as e:
print(f"ImgBB upload error: {e}")
return None
def upload_base64(self, base64_data: str, name: str = None) -> Optional[str]:
"""
Upload a base64-encoded image to ImgBB.
:param base64_data: Base64-encoded image data
:param name: Optional name for the image
:return: Public URL of the uploaded image, or None if failed
"""
if not self.api_key:
print("ImgBB API key not configured")
return None
try:
payload = {
'key': self.api_key,
'image': base64_data,
}
if name:
payload['name'] = name
response = requests.post(self.API_URL, data=payload)
result = response.json()
if result.get('success'):
url = result['data']['url']
print(f"ImgBB upload success: {url}")
return url
else:
print(f"ImgBB upload failed: {result.get('error', {}).get('message', 'Unknown error')}")
return None
except Exception as e:
print(f"ImgBB upload error: {e}")
return None
class DingTalkSender:
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
self.webhook = webhook_url # Renamed from webhook_url to webhook
def get_sign(self, secret: str):
if not secret:
return None, None
timestamp = str(round(time.time() * 1000))
secret_enc = secret.encode('utf-8')
string_to_sign = '{}\n{}'.format(timestamp, secret)
string_to_sign_enc = string_to_sign.encode('utf-8')
hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return timestamp, sign
def send_markdown(
self,
title: str,
text: str,
is_at_all: bool = False,
at_mobiles: list = None,
secret: str = None
) -> Optional[Dict]:
"""
Send a markdown message to DingTalk.
:param at_mobiles: List of mobile numbers to mention
"""
if not self.webhook:
print("DingTalk webhook not configured")
return None
timestamp, sign = self.get_sign(secret)
url = f"{self.webhook}&timestamp={timestamp}&sign={sign}" if sign else self.webhook
headers = {'Content-Type': 'application/json'}
payload = {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": text
},
"at": {
"atMobiles": at_mobiles if at_mobiles else [],
"isAtAll": is_at_all
}
}
try:
resp = requests.post(url, json=payload, headers=headers)
print(f"DingTalk Response: {resp.text}")
return resp.json()
except Exception as e:
print(f"Failed to send DingTalk message: {e}")
return None
def send_image_link(
self,
title: str,
image_url: str,
message_url: str = "",
secret: str = None
) -> Optional[Dict]:
"""
Send an image as a link card message to DingTalk.
Note: DingTalk doesn't support pure image messages via webhook,
so we use ActionCard format to display images.
:param title: Title of the message
:param image_url: Public URL of the image (e.g., from ImgBB)
:param message_url: Optional click URL
"""
if not self.webhook:
print("DingTalk webhook not configured")
return None
timestamp, sign = self.get_sign(secret)
url = f"{self.webhook}&timestamp={timestamp}&sign={sign}" if sign else self.webhook
headers = {'Content-Type': 'application/json'}
# Use markdown format with image
markdown_text = f"![{title}]({image_url})"
payload = {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": markdown_text
}
}
try:
resp = requests.post(url, json=payload, headers=headers)
print(f"DingTalk Image Response: {resp.text}")
return resp.json()
except Exception as e:
print(f"Failed to send DingTalk image: {e}")
return None
def send_markdown_with_image(
self,
title: str,
text: str,
image_url: str,
is_at_all: bool = False,
at_mobiles: list = None,
secret: str = None
) -> Optional[Dict]:
"""
Send a markdown message with an embedded image to DingTalk.
:param title: Message title
:param text: Markdown text content
:param image_url: Public URL of the image to embed
:param at_mobiles: List of mobile numbers to mention
"""
if not self.webhook:
print("DingTalk webhook not configured")
return None
# Append image to markdown text
text_with_image = f"{text}\n\n![图片]({image_url})"
return self.send_markdown(
title=title,
text=text_with_image,
is_at_all=is_at_all,
at_mobiles=at_mobiles,
secret=secret
)
def format_task_report(self, summary: Dict, user_roles: Dict[str, str] = None, user_mobiles: Dict[str, str] = None, dashboard_url: str = None, switches: Dict[str, bool] = None) -> Tuple[str, List[str]]:
"""
Format task summary into Markdown table for DingTalk.
Splits into "Urgent" and "Normal" tables.
Include 'Role' column mapped from user_roles.
Returns: (Markdown Report, List of mobile numbers to mention)
"""
if not user_roles: user_roles = {}
if not user_mobiles: user_mobiles = {}
if not switches: switches = {}
report_lines = []
updated_at = summary.get('updated_at', '').split('T')[0]
report_lines.append(f"## ⚔️ 工作室战报 ({updated_at})")
# 1. Summary Tables (Urgent / Dev / Design)
if switches.get('daily_summary', True):
if dashboard_url:
report_lines.append(f"\n> 🏰 [**进入英雄殿堂查看实时战绩**]({dashboard_url})\n")
urgent_data = []
dev_data = []
design_data = []
total_tasks = summary.get('total_tasks', 0)
report_lines.append(f"📜 **今日战线总览**: 共有 {total_tasks} 项任务在推进中\n---")
by_user = summary.get('by_user', {})
for user, user_data in by_user.items():
tasks = user_data.get('tasks', [])
role = user_roles.get(user, '冒险者')
user_stats = user_data.get('stats', {})
done_count = user_stats.get('done_count', 0)
level = int((done_count * 10)**0.5) + 1
# Simple Title mapping for DingTalk to match JS engine roughly
hero_title = "见习英雄"
if done_count > 20: hero_title = "任务收割者"
elif done_count > 10: hero_title = "交付大师"
elif user_stats.get('bug_done', 0) > 5: hero_title = "灭虫专家"
elif user_stats.get('urgent_done', 0) > 2: hero_title = "消防先锋"
user_tag = f"**{user}** (LV.{level} {hero_title})"
for t in tasks:
status = t['status']
priority = t.get('priority', '')
# Check attributes
is_urgent = any(p in priority for p in ['紧急', '非常紧急', 'Urgent', 'High'])
is_done = any(s in status for s in ['已完成', '已发布', 'Released', 'Done', '验收中', '待验收', '测完'])
# Row content preparation
t_id = t.get('id')
base_url = "https://www.teambition.com/task/"
title_text = t['title'].replace('|', '\|')
if len(title_text) > 20: title_text = title_text[:20] + "..."
title = f"[{title_text}]({base_url}{t_id})" if t_id else title_text
due = t['due'][:10] if t['due'] else '-'
# Filter Logic
target_list = None
display_status = status
if is_urgent and not is_done:
display_status = f"🔥 {status}"
target_list = urgent_data
elif not is_done:
if role in ['客户端', '服务端']: target_list = dev_data
else: target_list = design_data
if target_list is not None:
target_list.append({
'role': role,
'user': user,
'tag': user_tag,
'row': f"| {user_tag} | {title} | {display_status} | {due} |"
})
# Assemble Report
if not urgent_data and not dev_data and not design_data:
report_lines.append("\n☀️ **今日风平浪静,英雄们正在整装待发。**")
else:
# Urgent Table
if urgent_data:
report_lines.append("### 🔴 紧急突围任务")
report_lines.append("| 英雄单位 | 战事说明 | 当前态势 | 期限 |")
report_lines.append("| :--- | :--- | :--- | :--- |")
for item in urgent_data: report_lines.append(item['row'])
report_lines.append("\n")
# Main Forces
if dev_data:
report_lines.append("### ⚔️ 主力攻坚部队 (程序)")
report_lines.append("| 英雄单位 | 战事说明 | 当前态势 | 期限 |")
report_lines.append("| :--- | :--- | :--- | :--- |")
for item in dev_data: report_lines.append(item['row'])
report_lines.append("\n")
if design_data:
report_lines.append("### 🛡️ 协同支撑编队 (策划/美术)")
report_lines.append("| 英雄单位 | 战事说明 | 当前态势 | 期限 |")
report_lines.append("| :--- | :--- | :--- | :--- |")
for item in design_data: report_lines.append(item['row'])
report_content = "\n".join(report_lines)
else:
# If summary is disabled
report_content = f"### Teambition 任务日报 ({updated_at})"
# 2. Anomaly Alerts (Idle + Parallel)
alert_text = ""
at_mobiles_anomaly = []
if switches.get('daily_anomaly', True):
alert_text, at_mobiles_anomaly = self.generate_anomaly_alerts(summary, user_roles, user_mobiles)
final_report = report_content + alert_text
# Merge at_mobiles from both parts (currently report_content doesn't produce @mobs, only anomaly does)
# But if we had logic in summary to @, we would merge here.
return final_report, at_mobiles_anomaly
def get_rpg_summary(self, summary: Dict) -> str:
"""Generate a punchy RPG-style summary text for DingTalk alerts."""
updated_at = summary.get('updated_at', '').split('T')[0]
total_tasks = summary.get('total_tasks', 0)
# Calculate daily wins (only those completed on the same date)
recent_wins = summary.get('recent_wins', [])
daily_wins_list = [w for w in recent_wins if str(w.get('done_at', '')).startswith(updated_at)]
daily_wins = len(daily_wins_list)
total_recent = len(recent_wins)
if daily_wins > 0:
win_text = f"英雄们势如破竹!今日共斩获 **{daily_wins}** 项战果。"
else:
win_text = f"今日暂无新战果,英雄们正在积蓄力量 (近期共斩获 {total_recent} 项战果)。"
lines = [
f"## ⚔️ 工作室战报 | {updated_at}",
f"> 📢 **重点战况**: {win_text}",
f"\n📜 当前全线共有 {total_tasks} 个战点正在推进中...",
"\n---"
]
return "\n".join(lines)
def generate_anomaly_alerts(self, summary: Dict, user_roles: Dict, user_mobiles: Dict) -> Tuple[str, List[str]]:
"""
Check for Task Planning Anomalies for CLIENT developers:
1. Idle (0 active tasks)
2. Overloaded (>1 active tasks)
Returns: (Alert Markdown Text, List of mobiles to @)
"""
alert_lines = []
at_mobiles = []
# Define Leads
lead_client = "庄鹏程"
# Identify Client Devs only
client_devs = [u for u, r in user_roles.items() if r == '客户端']
# Helper to format mention
def mention(name):
mobile = user_mobiles.get(name, "")
if mobile:
at_mobiles.append(mobile)
return f"@{mobile}"
return f"@{name}"
for dev in client_devs:
if dev == lead_client: continue # Skip lead self-check
user_data = summary.get('by_user', {}).get(dev, {})
tasks = user_data.get('tasks', [])
active_tasks = [t for t in tasks if t['status'] in ['进行中', 'In Progress', 'Doing']]
count = len(active_tasks)
if count == 0:
# Idle
msg = f"🌟 **{dev}** 暂无“进行中”的任务,记得维护 Teambition 状态哦 ~\n如有空闲请与 {mention(lead_client)} 同步后续安排。"
alert_lines.append(msg)
elif count > 1:
# Overloaded / Parallel
task_titles = [t['title'][:10]+"..." if len(t['title'])>10 else t['title'] for t in active_tasks]
msg = f"💪 **{dev}** 当前有 {count} 个任务并行 ({', '.join(task_titles)}),项目组必须注意合理规划,\n请及时联系 {mention(lead_client)}处理。"
alert_lines.append(msg)
if alert_lines:
return "\n\n### 🚫 任务规划异常提醒\n" + "\n\n".join(alert_lines), at_mobiles
return "", []
def format_diff_report(self, changes: List[str], summary_report: str) -> str:
"""
Combine standard summary report with diff report
"""
if not changes:
return summary_report
# Check explicit switch if we pass it here?
# For now, the caller controls this by checking the switch before calling or we add an arg.
# But 'main.py' logic will control whether to add it.
# Let's keep this as purely formatting.
diff_section = ["\n### 📢 最近变动"]
for change in changes:
diff_section.append(f"- {change}")
return "\n".join(diff_section) + "\n\n" + summary_report
def send_parallel_task_alert(self, summary: Dict, user_roles: Dict, user_mobiles: Dict, secret: str = None) -> bool:
"""
Check for users with >1 'In Progress' tasks and send an alert if found.
Returns True if an alert was sent, False otherwise.
"""
if not self.webhook:
return False
alert_lines = []
at_mobiles_list = []
# Helper to format mention
def mention(name):
mobile = user_mobiles.get(name, "")
if mobile:
at_mobiles_list.append(mobile)
return f"@{mobile}"
return f"@{name}"
by_user = summary.get('by_user', {})
for user_name, user_data in by_user.items():
tasks = user_data.get('tasks', [])
# Filter active tasks
active_tasks = [t for t in tasks if t['status'] in ['进行中', 'In Progress', 'Doing']]
# active_tasks > 1 AND Role is '客户端'
role = user_roles.get(user_name, '')
if len(active_tasks) > 1 and role == '客户端':
# Found a user with multiple parallel tasks
lines = [f"⚠️ **{user_name}** 有 {len(active_tasks)} 个并行任务:"]
for t in active_tasks:
title = t['title']
if len(title) > 15: title = title[:15] + "..."
lines.append(f"- {title}")
lines.append(f"{mention(user_name)} 确认进度。")
alert_lines.append("\n".join(lines))
if not alert_lines:
return False
# Send the alert
title = "Teambition 任务规划异常提醒"
text = "### 🚫 任务规划异常提醒 (并行任务)\n\n" + "\n\n---\n\n".join(alert_lines)
# Deduplicate mobiles
at_mobiles_list = list(set(at_mobiles_list))
self.send_markdown(title, text, secret=secret, at_mobiles=at_mobiles_list)
return True
def format_ai_insight(self, ai_analysis: Dict) -> str:
"""Format AI analysis into a DingTalk markdown section."""
if not ai_analysis or ai_analysis.get('parse_error'):
return ""
lines = ["\n---\n### 🧠 AI 智能洞察\n"]
# Summary
summary_text = ai_analysis.get('summary', '')
if summary_text:
lines.append(f"> {summary_text}\n")
# Progress
progress = ai_analysis.get('progress', {})
if progress:
pct = progress.get('percent', 0)
vel = progress.get('velocity_per_day', 0)
days = progress.get('estimated_days_remaining', 0)
trend = progress.get('trend', '')
lines.append(f"**进度**: {pct}% | 日均完成 {vel} 个 | 预计还需 {days} 天 | 趋势: {trend}\n")
# Top risks
risks = ai_analysis.get('risks', [])
if risks:
lines.append("**风险预警:**")
for r in risks[:3]:
sev = r.get('severity', '')
title = r.get('title', '')
detail = r.get('detail', '')
lines.append(f"- [{sev}] {title}: {detail}")
lines.append("")
# Recommendations
recs = ai_analysis.get('recommendations', [])
if recs:
lines.append("**建议:**")
for rec in recs[:3]:
action = rec.get('action', '')
assign = rec.get('assign_to', '')
if assign:
lines.append(f"- {action} → @{assign}")
else:
lines.append(f"- {action}")
return "\n".join(lines)