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:
leko
2026-04-25 11:08:46 +08:00
committed by GitHub
parent e48716dbf8
commit 2618491246
5 changed files with 172 additions and 14 deletions
+2
View File
@@ -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],
+14 -1
View File
@@ -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}")
+43 -10
View File
@@ -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"