fix: 正确显示话题分析头像

This commit is contained in:
SXP-Simon
2026-02-10 22:43:45 +08:00
parent 3154cf1e56
commit 2e2dbfc81c
6 changed files with 170 additions and 65 deletions
@@ -608,7 +608,19 @@ class TelegramAdapter(PlatformAdapter):
if file.file_path: if file.file_path:
# 构建完整 URL # 构建完整 URL
# 格式: https://api.telegram.org/file/bot<token>/<file_path> # 格式: https://api.telegram.org/file/bot<token>/<file_path>
return file.file_path # python-telegram-bot 的 File.file_path 属性通常只返回路径部分
# 需要手动拼接或使用 instance.file.file_path (取决于版本)
file_path = file.file_path
if file_path.startswith("http"):
return file_path
# 尝试构建完整 URL
if hasattr(client, "token"):
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
# 如果无法获取 token,返回 None
return None
return None return None
except Exception as e: except Exception as e:
logger.debug(f"[Telegram] 获取用户头像失败: {e}") logger.debug(f"[Telegram] 获取用户头像失败: {e}")
@@ -640,7 +652,14 @@ class TelegramAdapter(PlatformAdapter):
if chat.photo: if chat.photo:
file = await client.get_file(chat.photo.big_file_id) file = await client.get_file(chat.photo.big_file_id)
if file.file_path: if file.file_path:
return file.file_path file_path = file.file_path
if file_path.startswith("http"):
return file_path
if hasattr(client, "token"):
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
return None
return None return None
except Exception as e: except Exception as e:
logger.debug(f"[Telegram] 获取群头像失败: {e}") logger.debug(f"[Telegram] 获取群头像失败: {e}")
+134 -48
View File
@@ -119,7 +119,9 @@ class ReportGenerator(IReportGenerator):
if isinstance(image_data, bytes): if isinstance(image_data, bytes):
b64 = base64.b64encode(image_data).decode("utf-8") b64 = base64.b64encode(image_data).decode("utf-8")
image_url = f"base64://{b64}" image_url = f"base64://{b64}"
logger.info(f"图片生成成功 ({image_options}): [Base64 Data {len(image_data)} bytes]") logger.info(
f"图片生成成功 ({image_options}): [Base64 Data {len(image_data)} bytes]"
)
return image_url, html_content return image_url, html_content
elif isinstance(image_data, str): elif isinstance(image_data, str):
# Fallback: 如果返回的是字符串(可能是URL或路径) # Fallback: 如果返回的是字符串(可能是URL或路径)
@@ -129,31 +131,47 @@ class ReportGenerator(IReportGenerator):
if not image_data.startswith(("http://", "https://")): if not image_data.startswith(("http://", "https://")):
try: try:
import os import os
if os.path.exists(image_data): if os.path.exists(image_data):
with open(image_data, "rb") as f: with open(image_data, "rb") as f:
file_bytes = f.read() file_bytes = f.read()
# 校验是否为有效图片 (防止发送 "Internal Server Error" 文本) # 校验是否为有效图片 (防止发送 "Internal Server Error" 文本)
is_valid_image = False is_valid_image = False
if file_bytes.startswith(b"\xff\xd8"): # JPEG if file_bytes.startswith(b"\xff\xd8"): # JPEG
is_valid_image = True is_valid_image = True
elif file_bytes.startswith(b"\x89PNG\r\n\x1a\n"): # PNG elif file_bytes.startswith(
b"\x89PNG\r\n\x1a\n"
): # PNG
is_valid_image = True is_valid_image = True
if not is_valid_image: if not is_valid_image:
try: try:
text_content = file_bytes.decode('utf-8') text_content = file_bytes.decode(
if "Error" in text_content or "Exception" in text_content: "utf-8"
logger.error(f"渲染器生成了错误文件而非图片: {text_content[:200]}") )
if (
"Error" in text_content
or "Exception" in text_content
):
logger.error(
f"渲染器生成了错误文件而非图片: {text_content[:200]}"
)
return None, html_content return None, html_content
except Exception: except Exception:
pass pass
logger.warning(f"生成的图片文件头异常 (非JPEG/PNG): {file_bytes[:10].hex()}") logger.warning(
f"生成的图片文件头异常 (非JPEG/PNG): {file_bytes[:10].hex()}"
)
return None, html_content return None, html_content
b64 = base64.b64encode(file_bytes).decode("utf-8") b64 = base64.b64encode(file_bytes).decode(
"utf-8"
)
image_url = f"base64://{b64}" image_url = f"base64://{b64}"
logger.info(f"本地图片转 Base64 成功: {len(file_bytes)} bytes") logger.info(
f"本地图片转 Base64 成功: {len(file_bytes)} bytes"
)
return image_url, html_content return image_url, html_content
except Exception as e: except Exception as e:
logger.warning(f"尝试读取本地图片失败: {e}") logger.warning(f"尝试读取本地图片失败: {e}")
@@ -415,13 +433,15 @@ class ReportGenerator(IReportGenerator):
"vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;" "vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;"
) )
img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;" img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;"
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;" name_style = (
"font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
)
return ( return (
f'<span class="user-capsule" style="{capsule_style}">' f'<span class="user-capsule" style="{capsule_style}">'
f'<img src="{url}" style="{img_style}">' f'<img src="{url}" style="{img_style}">'
f'<span style="{name_style}">{name}</span>' f'<span style="{name_style}">{name}</span>'
f'</span>' f"</span>"
) )
elif url: elif url:
# 仅有头像回退 # 仅有头像回退
@@ -455,19 +475,24 @@ class ReportGenerator(IReportGenerator):
return result return result
async def _get_user_avatar(self, user_id: str, avatar_getter=None) -> str | None: async def _get_user_avatar(self, user_id: str, avatar_getter=None) -> str:
""" """
获取用户头像的本地文件路径 (file:// URI) 获取用户头像的 Base64 Data URI。
优化策略: 策略:
1. 优先使用本地缓存文件,避免重复下载和 Base64 编解码开销 1. 优先使用本地缓存文件
2. 下载保存到 data/temp/avatars/ 目录 2. 下载保存到 data/plugin_data/.../cache/avatars/
3. 返回 file:// 协议路径,供 Playwright 本地渲染使用 3. 读取文件并转换为 Base64,嵌入 HTML
这是为了解决 Docker/沙箱环境中渲染器无法访问宿主机 file:// 路径的问题。
""" """
import base64
try: try:
# 1. 准备缓存目录 # 1. 准备缓存目录
# 使用 plugin_data 目录以确保持久化和标准结构 # 使用 plugin_data 目录以确保持久化和标准结构
temp_dir = Path("data/plugin_data/astrbot_plugin_qq_group_daily_analysis/cache/avatars") temp_dir = Path(
"data/plugin_data/astrbot_plugin_qq_group_daily_analysis/cache/avatars"
)
if not temp_dir.exists(): if not temp_dir.exists():
await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
@@ -475,45 +500,106 @@ class ReportGenerator(IReportGenerator):
file_name = f"{user_id}_40.jpg" file_name = f"{user_id}_40.jpg"
file_path = temp_dir / file_name file_path = temp_dir / file_name
file_content = None
# 2. 检查缓存 # 2. 检查缓存
if file_path.exists() and file_path.stat().st_size > 0: if file_path.exists() and file_path.stat().st_size > 0:
# 必须使用绝对路径 # 异步读取缓存
return f"file://{file_path.absolute()}"
# 3. 获取 URL
avatar_url = None
if avatar_getter:
try: try:
# avatar_getter 应该返回 URL file_content = await asyncio.to_thread(file_path.read_bytes)
result = await avatar_getter(user_id) except Exception:
if result and result.startswith("http"): pass
avatar_url = result
except Exception as e:
logger.warning(f"使用 custom avatar_getter 获取头像失败: {e}")
# 4. Fallback URL # 3. 如果无缓存,获取 URL 并下载
if not avatar_url: if not file_content:
if user_id.isdigit() and 5 <= len(user_id) <= 12: avatar_url = None
# 强制使用 spec=40 if avatar_getter:
avatar_url = f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40" try:
else: # avatar_getter 应该返回 URL
return None result = await avatar_getter(user_id)
if result and result.startswith("http"):
avatar_url = result
except Exception as e:
logger.warning(f"使用 custom avatar_getter 获取头像失败: {e}")
# 5. 下载并保存 # 4. Fallback URL (仅针对看起来像 QQ 号的 ID)
async with aiohttp.ClientSession() as client: if not avatar_url:
async with client.get(avatar_url, timeout=5) as response: if user_id.isdigit() and 5 <= len(user_id) <= 12:
if response.status == 200: # 强制使用 spec=40
content = await response.read() avatar_url = (
if content: f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
await asyncio.to_thread(file_path.write_bytes, content) )
return f"file://{file_path.absolute()}"
else: else:
logger.warning(f"下载头像失败 {avatar_url}: {response.status}") # 其他平台若无 URL,无法获取头像
return self._get_default_avatar_base64()
# 5. 下载并保存
async with aiohttp.ClientSession() as client:
try:
async with client.get(avatar_url, timeout=5) as response:
if response.status == 200:
content = await response.read()
if content:
# 校验文件头
is_valid_image = False
if content.startswith(b"\xff\xd8"): # JPEG
is_valid_image = True
elif content.startswith(
b"\x89PNG\r\n\x1a\n"
): # PNG
is_valid_image = True
elif content.startswith(b"GIF8"): # GIF
is_valid_image = True
elif (
content.startswith(b"RIFF")
and b"WEBP" in content[:16]
): # WebP
is_valid_image = True
if is_valid_image:
await asyncio.to_thread(
file_path.write_bytes, content
)
file_content = content
else:
logger.warning(
f"下载的头像数据格式无效 ({avatar_url})"
)
else:
logger.warning(
f"下载头像失败 {avatar_url}: {response.status}"
)
except Exception as e:
logger.warning(f"下载头像网络错误 {avatar_url}: {e}")
# 6. 转换为 Base64 Data URI
if file_content:
b64 = base64.b64encode(file_content).decode("utf-8")
# 简单判断 mime type
mime = "image/jpeg"
if file_content.startswith(b"\x89PNG"):
mime = "image/png"
elif file_content.startswith(b"GIF8"):
mime = "image/gif"
elif file_content.startswith(b"RIFF"):
mime = "image/webp"
return f"data:{mime};base64,{b64}"
return self._get_default_avatar_base64()
return None
except Exception as e: except Exception as e:
logger.error(f"获取用户头像失败 {user_id}: {e}") logger.error(f"获取用户头像失败 {user_id}: {e}")
return None return self._get_default_avatar_base64()
def _get_default_avatar_base64(self) -> str:
"""返回默认头像 (灰色圆形占位符)"""
import base64
# 一个简单的灰色圆圈 SVG 转 Base64
svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>'
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
return f"data:image/svg+xml;base64,{b64}"
async def _html_to_pdf(self, html_content: str, output_path: str) -> bool: async def _html_to_pdf(self, html_content: str, output_path: str) -> bool:
"""将 HTML 内容转换为 PDF 文件""" """将 HTML 内容转换为 PDF 文件"""
@@ -9,7 +9,7 @@
<span class="topic-title">{{ topic.topic.topic }}</span> <span class="topic-title">{{ topic.topic.topic }}</span>
</div> </div>
<div class="topic-contributors">参与者: {{ topic.contributors }}</div> <div class="topic-contributors">参与者: {{ topic.contributors }}</div>
<div class="topic-detail">{{ topic.detail }}</div> <div class="topic-detail">{{ topic.detail | safe }}</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -10,7 +10,7 @@
<div class="topic-content"> <div class="topic-content">
<h3 class="topic-title">{{ topic.topic.topic }}</h3> <h3 class="topic-title">{{ topic.topic.topic }}</h3>
<p class="topic-meta">PARTICIPANTS: {{ topic.contributors }}</p> <p class="topic-meta">PARTICIPANTS: {{ topic.contributors }}</p>
<p class="topic-detail">{{ topic.detail }}</p> <p class="topic-detail">{{ topic.detail | safe }}</p>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
@@ -26,7 +26,7 @@
style="font-family: var(--font-hand); font-size: 1.05em; color: var(--ink-secondary); margin-bottom: 8px;"> style="font-family: var(--font-hand); font-size: 1.05em; color: var(--ink-secondary); margin-bottom: 8px;">
🙋‍♀️ 参与者: {{ topic.contributors }} 🙋‍♀️ 参与者: {{ topic.contributors }}
</div> </div>
<div class="topic-detail">{{ topic.detail }}</div> <div class="topic-detail">{{ topic.detail | safe }}</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -5,7 +5,7 @@
<div style="margin-bottom: 10px; border-bottom: 1px dashed #ccc; padding-bottom: 5px;"> <div style="margin-bottom: 10px; border-bottom: 1px dashed #ccc; padding-bottom: 5px;">
<strong>#{{ item.index }} {{ item.topic.topic }}</strong> <strong>#{{ item.index }} {{ item.topic.topic }}</strong>
<span style="color: #666; font-size: 0.8em;">(参与者: {{ item.contributors }})</span> <span style="color: #666; font-size: 0.8em;">(参与者: {{ item.contributors }})</span>
<p style="margin: 5px 0;">{{ item.detail }}</p> <p style="margin: 5px 0;">{{ item.detail | safe }}</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>