mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
fix: telegram avatar handling (#176)
* feat: add avatar caching support and improve error handling in Telegram adapter * feat(avatar): implement avatar negative cache management and improve logging levels
This commit is contained in:
@@ -605,6 +605,7 @@ class GroupDailyAnalysis(Star):
|
||||
self.html_render,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
)
|
||||
|
||||
if image_url:
|
||||
@@ -626,6 +627,7 @@ class GroupDailyAnalysis(Star):
|
||||
group_id,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
)
|
||||
if html_path:
|
||||
is_only_url = self.config_manager.get_html_only_url()
|
||||
|
||||
@@ -20,6 +20,7 @@ class IReportGenerator(ABC):
|
||||
html_render_func: Any,
|
||||
avatar_url_getter: Any = None,
|
||||
nickname_getter: Any = None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成图片报告"""
|
||||
pass
|
||||
@@ -31,6 +32,7 @@ class IReportGenerator(ABC):
|
||||
group_id: str,
|
||||
avatar_url_getter: Any = None,
|
||||
nickname_getter: Any = None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""生成 HTML 报告"""
|
||||
pass
|
||||
|
||||
@@ -8,6 +8,7 @@ Telegram 平台适配器
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import BytesIO
|
||||
@@ -39,6 +40,10 @@ except ImportError:
|
||||
TELEGRAM_AVAILABLE = False
|
||||
|
||||
|
||||
TELEGRAM_AVATAR_NEGATIVE_CACHE_TTL = 600
|
||||
TELEGRAM_AVATAR_NEGATIVE_CACHE_MAX_SIZE = 1024
|
||||
|
||||
|
||||
class TelegramAdapter(PlatformAdapter):
|
||||
"""
|
||||
Telegram Bot API 适配器
|
||||
@@ -71,6 +76,8 @@ class TelegramAdapter(PlatformAdapter):
|
||||
else:
|
||||
self._plugin_instance = None
|
||||
self._platform_id = str(config.get("platform_id", "")).strip() if config else ""
|
||||
# user_id -> (expires_at, reason)
|
||||
self._avatar_negative_cache: dict[str, tuple[float, str]] = {}
|
||||
|
||||
def set_context(self, context: "Context") -> None:
|
||||
"""
|
||||
@@ -804,10 +811,30 @@ class TelegramAdapter(PlatformAdapter):
|
||||
"""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id}: Telegram 客户端未初始化"
|
||||
)
|
||||
return None
|
||||
|
||||
user_id_str = str(user_id).strip()
|
||||
cached_reason = self._get_avatar_negative_cache_reason(user_id_str)
|
||||
if cached_reason:
|
||||
logger.debug(
|
||||
f"[Telegram] 跳过用户头像获取 uid={user_id_str}: negative cache 命中,"
|
||||
f"上次失败原因: {cached_reason}"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
photos = await client.get_user_profile_photos(user_id=int(user_id), limit=1)
|
||||
tg_user_id = int(user_id_str)
|
||||
except (TypeError, ValueError):
|
||||
reason = f"用户 ID 不是有效整数: {user_id!r}"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id}: {reason}")
|
||||
return None
|
||||
|
||||
try:
|
||||
photos = await client.get_user_profile_photos(user_id=tg_user_id, limit=1)
|
||||
if photos.photos:
|
||||
# 获取最大尺寸的头像
|
||||
photo_sizes = photos.photos[0]
|
||||
@@ -830,10 +857,30 @@ class TelegramAdapter(PlatformAdapter):
|
||||
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
|
||||
|
||||
# 如果无法获取 token,返回 None
|
||||
reason = "get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
reason = "get_file 未返回 file_path"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(
|
||||
f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}"
|
||||
)
|
||||
return None
|
||||
reason = "get_user_profile_photos 返回的首张头像没有可用尺寸"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
return None
|
||||
reason = "get_user_profile_photos 返回空列表,用户可能没有公开头像或隐私设置不可见"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.info(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 获取用户头像失败: {e}")
|
||||
reason = f"{type(e).__name__}: {e}"
|
||||
self._remember_avatar_negative(user_id_str, reason)
|
||||
logger.warning(f"[Telegram] 获取用户头像失败 uid={user_id_str}: {reason}")
|
||||
return None
|
||||
|
||||
async def get_user_avatar_data(
|
||||
@@ -843,6 +890,9 @@ class TelegramAdapter(PlatformAdapter):
|
||||
) -> str | None:
|
||||
"""获取头像的 Base64 数据"""
|
||||
# 暂不实现,返回 None
|
||||
logger.debug(
|
||||
f"[Telegram] 获取用户头像数据失败 uid={user_id}: get_user_avatar_data 暂未实现"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_group_avatar_url(
|
||||
@@ -853,6 +903,9 @@ class TelegramAdapter(PlatformAdapter):
|
||||
"""获取群组头像 URL"""
|
||||
client = self._telegram_client
|
||||
if not client:
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: Telegram 客户端未初始化"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -869,12 +922,67 @@ class TelegramAdapter(PlatformAdapter):
|
||||
if hasattr(client, "token"):
|
||||
return f"https://api.telegram.org/file/bot{client.token}/{file_path}"
|
||||
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: "
|
||||
"get_file 返回相对 file_path,但 client 没有 token,无法拼接下载 URL"
|
||||
)
|
||||
return None
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: get_file 未返回 file_path"
|
||||
)
|
||||
return None
|
||||
logger.info(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: 群组未设置头像或 bot 不可见"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[Telegram] 获取群头像失败: {e}")
|
||||
logger.warning(
|
||||
f"[Telegram] 获取群头像失败 group_id={group_id}: {type(e).__name__}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _prune_avatar_negative_cache(self) -> None:
|
||||
"""清理过期项并限制 negative cache 大小,避免长期运行时无界增长。"""
|
||||
cache = self._avatar_negative_cache
|
||||
if not cache:
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
expired_keys = [
|
||||
user_id
|
||||
for user_id, (expires_at, _reason) in cache.items()
|
||||
if expires_at <= now
|
||||
]
|
||||
for user_id in expired_keys:
|
||||
cache.pop(user_id, None)
|
||||
|
||||
overflow = len(cache) - TELEGRAM_AVATAR_NEGATIVE_CACHE_MAX_SIZE
|
||||
if overflow <= 0:
|
||||
return
|
||||
|
||||
for user_id, _ in sorted(cache.items(), key=lambda item: item[1][0])[:overflow]:
|
||||
cache.pop(user_id, None)
|
||||
|
||||
def _get_avatar_negative_cache_reason(self, user_id: str) -> str | None:
|
||||
self._prune_avatar_negative_cache()
|
||||
cached = self._avatar_negative_cache.get(user_id)
|
||||
if not cached:
|
||||
return None
|
||||
|
||||
expires_at, reason = cached
|
||||
if time.monotonic() >= expires_at:
|
||||
self._avatar_negative_cache.pop(user_id, None)
|
||||
return None
|
||||
return reason
|
||||
|
||||
def _remember_avatar_negative(self, user_id: str, reason: str) -> None:
|
||||
self._prune_avatar_negative_cache()
|
||||
self._avatar_negative_cache[user_id] = (
|
||||
time.monotonic() + TELEGRAM_AVATAR_NEGATIVE_CACHE_TTL,
|
||||
reason,
|
||||
)
|
||||
self._prune_avatar_negative_cache()
|
||||
|
||||
async def batch_get_avatar_urls(
|
||||
self,
|
||||
user_ids: list[str],
|
||||
|
||||
@@ -86,6 +86,7 @@ class ReportDispatcher:
|
||||
group_id,
|
||||
self._html_render_func,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate image report: {e}")
|
||||
@@ -119,8 +120,20 @@ class ReportDispatcher:
|
||||
|
||||
html_path = None
|
||||
try:
|
||||
|
||||
async def avatar_url_getter(user_id: str):
|
||||
if not platform_id:
|
||||
return None
|
||||
adapter = self.message_sender.bot_manager.get_adapter(platform_id)
|
||||
if adapter and hasattr(adapter, "get_user_avatar_url"):
|
||||
return await adapter.get_user_avatar_url(user_id, size=40)
|
||||
return None
|
||||
|
||||
html_path, json_path = await self.report_generator.generate_html_report(
|
||||
analysis_result, group_id
|
||||
analysis_result,
|
||||
group_id,
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{trace_id}] Failed to generate HTML report: {e}")
|
||||
|
||||
@@ -330,6 +330,7 @@ class ReportGenerator(IReportGenerator):
|
||||
html_render_func,
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成图片格式的分析报告
|
||||
@@ -352,6 +353,7 @@ class ReportGenerator(IReportGenerator):
|
||||
chart_template="activity_chart.html",
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=avatar_cache_namespace,
|
||||
)
|
||||
|
||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||
@@ -487,6 +489,7 @@ class ReportGenerator(IReportGenerator):
|
||||
group_id: str,
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
生成HTML格式的分析报告,保存到指定目录
|
||||
@@ -530,6 +533,7 @@ class ReportGenerator(IReportGenerator):
|
||||
chart_template="activity_chart.html",
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=avatar_cache_namespace,
|
||||
)
|
||||
logger.info(f"HTML 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
|
||||
@@ -671,6 +675,7 @@ class ReportGenerator(IReportGenerator):
|
||||
chart_template: str = "activity_chart.html",
|
||||
avatar_url_getter=None,
|
||||
nickname_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> dict:
|
||||
"""准备渲染数据"""
|
||||
stats = analysis_result["statistics"]
|
||||
@@ -686,7 +691,11 @@ class ReportGenerator(IReportGenerator):
|
||||
for i, topic in enumerate(topics[:max_topics], 1):
|
||||
# 处理话题详情中的用户引用头像
|
||||
processed_detail = await self._render_mentions(
|
||||
topic.detail, avatar_url_getter, nickname_getter, user_analysis
|
||||
topic.detail,
|
||||
avatar_url_getter,
|
||||
nickname_getter,
|
||||
user_analysis,
|
||||
avatar_cache_namespace,
|
||||
)
|
||||
topics_list.append(
|
||||
{
|
||||
@@ -710,7 +719,7 @@ class ReportGenerator(IReportGenerator):
|
||||
for title in user_titles[:max_user_titles]:
|
||||
# 获取用户头像
|
||||
avatar_data = await self._get_user_avatar(
|
||||
str(title.user_id), avatar_url_getter
|
||||
str(title.user_id), avatar_url_getter, avatar_cache_namespace
|
||||
)
|
||||
profile_info = self._resolve_profile_info(
|
||||
title.mbti, profile_mode, profile_mapping_overrides
|
||||
@@ -736,14 +745,20 @@ class ReportGenerator(IReportGenerator):
|
||||
for golden_quote in stats.golden_quotes[:max_golden_quotes]:
|
||||
avatar_url = (
|
||||
await self._get_user_avatar(
|
||||
str(golden_quote.user_id), avatar_url_getter
|
||||
str(golden_quote.user_id),
|
||||
avatar_url_getter,
|
||||
avatar_cache_namespace,
|
||||
)
|
||||
if golden_quote.user_id
|
||||
else None
|
||||
)
|
||||
# 处理解析锐评中的用户引用头像
|
||||
processed_reason = await self._render_mentions(
|
||||
golden_quote.reason, avatar_url_getter, nickname_getter, user_analysis
|
||||
golden_quote.reason,
|
||||
avatar_url_getter,
|
||||
nickname_getter,
|
||||
user_analysis,
|
||||
avatar_cache_namespace,
|
||||
)
|
||||
quotes_list.append(
|
||||
{
|
||||
@@ -833,6 +848,7 @@ class ReportGenerator(IReportGenerator):
|
||||
avatar_url_getter,
|
||||
nickname_getter=None,
|
||||
user_analysis: dict | None = None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> Markup:
|
||||
"""
|
||||
处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
|
||||
@@ -848,7 +864,7 @@ class ReportGenerator(IReportGenerator):
|
||||
async def render_capsule(match: re.Match[str]) -> Markup:
|
||||
uid = match.group(1)
|
||||
url = await self._get_user_avatar(
|
||||
uid, avatar_url_getter
|
||||
uid, avatar_url_getter, avatar_cache_namespace
|
||||
) # 内部已有缓存,无需顶层并发获取
|
||||
|
||||
name = None
|
||||
@@ -926,14 +942,27 @@ class ReportGenerator(IReportGenerator):
|
||||
# Telegram file URL: .../file/bot<token>/<file_path>
|
||||
return re.sub(r"/bot[^/]+/", "/bot<redacted>/", url)
|
||||
|
||||
async def _get_user_avatar(self, avatar_id: str, avatar_url_getter=None) -> str:
|
||||
def _get_avatar_cache_key(
|
||||
self, avatar_id: str, avatar_cache_namespace: str | None = None
|
||||
) -> str:
|
||||
"""生成头像缓存键,避免不同平台的同一数字 ID 互相污染。"""
|
||||
namespace = str(avatar_cache_namespace or "legacy").strip() or "legacy"
|
||||
return f"{namespace}:{avatar_id}"
|
||||
|
||||
async def _get_user_avatar(
|
||||
self,
|
||||
avatar_id: str,
|
||||
avatar_url_getter=None,
|
||||
avatar_cache_namespace: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
获取用户头像的 Base64 Data URI。
|
||||
使用磁盘缓存,支持跨任务复用。获取失败时不缓存结果,以便后续请求重试。
|
||||
"""
|
||||
cache_key = self._get_avatar_cache_key(avatar_id, avatar_cache_namespace)
|
||||
# 1. 检查缓存 (仅包含成功的头像数据)
|
||||
if avatar_id in self._avatar_cache:
|
||||
data = self._avatar_cache[avatar_id]
|
||||
if cache_key in self._avatar_cache:
|
||||
data = self._avatar_cache[cache_key]
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return str(data)
|
||||
@@ -949,7 +978,7 @@ class ReportGenerator(IReportGenerator):
|
||||
# 3. 获取成功:转换并缓存
|
||||
avatar = self._b64_with_mime(avatar_bytes)
|
||||
if avatar:
|
||||
self._avatar_cache.set(avatar_id, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
|
||||
self._avatar_cache.set(cache_key, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
|
||||
return avatar
|
||||
|
||||
# 最终兜底
|
||||
@@ -1007,7 +1036,11 @@ class ReportGenerator(IReportGenerator):
|
||||
logger.warning(f"使用 custom avatar_url_getter 获取头像失败: {e}")
|
||||
|
||||
if not avatar_url:
|
||||
if user_id.isdigit() and 5 <= len(user_id) <= 12:
|
||||
if (
|
||||
avatar_url_getter is None
|
||||
and user_id.isdigit()
|
||||
and 5 <= len(user_id) <= 12
|
||||
):
|
||||
# 强制使用 spec=40
|
||||
avatar_url = (
|
||||
f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
|
||||
|
||||
Reference in New Issue
Block a user