From 2618491246aaabdbbcd98957a1872846f11102d4 Mon Sep 17 00:00:00 2001 From: leko Date: Sat, 25 Apr 2026 11:08:46 +0800 Subject: [PATCH] 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 --- main.py | 2 + src/domain/repositories/report_repository.py | 2 + .../platform/adapters/telegram_adapter.py | 114 +++++++++++++++++- src/infrastructure/reporting/dispatcher.py | 15 ++- src/infrastructure/reporting/generators.py | 53 ++++++-- 5 files changed, 172 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index 21e3af2..3506eef 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/src/domain/repositories/report_repository.py b/src/domain/repositories/report_repository.py index 0c02399..1373e9f 100644 --- a/src/domain/repositories/report_repository.py +++ b/src/domain/repositories/report_repository.py @@ -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 diff --git a/src/infrastructure/platform/adapters/telegram_adapter.py b/src/infrastructure/platform/adapters/telegram_adapter.py index 980df07..c9cdceb 100644 --- a/src/infrastructure/platform/adapters/telegram_adapter.py +++ b/src/infrastructure/platform/adapters/telegram_adapter.py @@ -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], diff --git a/src/infrastructure/reporting/dispatcher.py b/src/infrastructure/reporting/dispatcher.py index f173305..d543eba 100644 --- a/src/infrastructure/reporting/dispatcher.py +++ b/src/infrastructure/reporting/dispatcher.py @@ -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}") diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 99ec734..9a64b81 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -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/ return re.sub(r"/bot[^/]+/", "/bot/", 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"