diff --git a/main.py b/main.py index a2ae68e..ea672d0 100644 --- a/main.py +++ b/main.py @@ -188,12 +188,8 @@ class QQGroupDailyAnalysis(Star): raise ValueError(f"群 {group_id}: 无法获取发送者 ID,拒绝存储消息") sender_id = str(sender_id) - # 3. 获取发送者名称(必需) - sender_name = event.get_sender_name() - if not sender_name: - raise ValueError( - f"群 {group_id}: 无法获取发送者名称 (sender_id={sender_id}),拒绝存储消息" - ) + # 3. 获取发送者名称(昵称优先,必要时回退) + sender_name = self._resolve_sender_name(event, sender_id) # 4. 获取平台 ID(必需) platform_id = event.get_platform_id() @@ -207,8 +203,55 @@ class QQGroupDailyAnalysis(Star): f"群 {group_id}: 消息内容为空 (sender={sender_name}),拒绝存储" ) - # 6. 存储到数据库 - await self.context.message_history_manager.insert( + # 6. 临时调试日志:打印入库前关键信息 + message_types = [] + for part in message_parts: + if isinstance(part, dict): + message_types.append(str(part.get("type", "unknown"))) + + preview_parts: list[str] = [] + for part in message_parts: + if not isinstance(part, dict): + continue + + part_type = str(part.get("type", "unknown")) + if part_type in ("plain", "text"): + text = str(part.get("text", "")).strip() + if text: + preview_parts.append(text) + elif part_type == "at": + target = str( + part.get("target_id") + or part.get("qq") + or part.get("at_user_id") + or "" + ).strip() + preview_parts.append(f"@{target}" if target else "@") + elif part_type == "image": + url = str(part.get("url", "")).strip() + preview_parts.append(f"[image]{url}" if url else "[image]") + else: + preview_parts.append(f"[{part_type}]") + + preview_text = " ".join(preview_parts).strip() + if len(preview_text) > 300: + preview_text = preview_text[:300] + "...(truncated)" + + msg_obj = getattr(event, "message_obj", None) + event_message_id = str(getattr(msg_obj, "message_id", "") or "") + unified_msg_origin = str(getattr(event, "unified_msg_origin", "") or "") + + logger.info( + "[TEMP][HistoryStore][BeforeInsert] " + f"platform_id={platform_id} group_id={group_id} " + f"sender_id={sender_id} sender_name={sender_name} " + f"event_message_id={event_message_id} unified_msg_origin={unified_msg_origin} " + f"parts_count={len(message_parts)} part_types={message_types} " + f"content_preview={preview_text}" + ) + + # 7. 存储到数据库 + insert_result = await self.context.message_history_manager.insert( platform_id=platform_id, user_id=group_id, content={"type": "user", "message": message_parts}, @@ -216,10 +259,103 @@ class QQGroupDailyAnalysis(Star): sender_name=sender_name, ) + record_id = str(getattr(insert_result, "id", "") or "") + created_at = getattr(insert_result, "created_at", None) + logger.info( + "[TEMP][HistoryStore][AfterInsert] " + f"record_id={record_id} created_at={created_at} " + f"platform_id={platform_id} group_id={group_id} " + f"sender_id={sender_id} sender_name={sender_name} " + f"parts_count={len(message_parts)}" + ) + logger.debug( f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})" ) + @staticmethod + def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool: + """判断 sender_name 是否为空或占位值。""" + if not name: + return True + normalized = str(name).strip() + if not normalized: + return True + if normalized.lower() in {"unknown", "none", "null", "nil", "undefined"}: + return True + return normalized == str(sender_id).strip() + + def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str: + """ + 解析发送者展示名。 + + 优先级: + - Telegram: + 1. raw_message.from_user.full_name + 2. raw_message.from_user.first_name + 3. event.get_sender_name() / message_obj.sender.nickname + 4. raw_message.from_user.username + 5. sender_id + - 其他平台: + 1. event.get_sender_name() + 2. message_obj.sender.nickname + 3. raw_message.from_user.full_name / first_name / username + 4. sender_id(最终回退,避免消息丢失) + """ + platform_name = str(event.get_platform_name() or "").lower() + candidates: list[str | None] = [] + + msg_obj = getattr(event, "message_obj", None) + sender_obj = getattr(msg_obj, "sender", None) + raw_message = getattr(msg_obj, "raw_message", None) + raw_msg_obj = getattr(raw_message, "message", raw_message) + from_user = getattr(raw_msg_obj, "from_user", None) + + # Telegram 特殊策略:优先显示名,不优先 username + if platform_name == "telegram": + if from_user is not None: + full_name = getattr(from_user, "full_name", None) + first_name = getattr(from_user, "first_name", None) + username = getattr(from_user, "username", None) + logger.info( + "[TEMP][SenderNameRaw] " + f"sender_id={sender_id} full_name={full_name} " + f"first_name={first_name} username={username} " + f"event_sender_name={event.get_sender_name()}" + ) + candidates.extend([full_name, first_name]) + + candidates.append(event.get_sender_name()) + if sender_obj is not None: + candidates.append(getattr(sender_obj, "nickname", None)) + + if from_user is not None: + candidates.append(getattr(from_user, "username", None)) + else: + candidates.append(event.get_sender_name()) + if sender_obj is not None: + candidates.append(getattr(sender_obj, "nickname", None)) + + if from_user is not None: + candidates.extend( + [ + getattr(from_user, "full_name", None), + getattr(from_user, "first_name", None), + getattr(from_user, "username", None), + ] + ) + + for candidate in candidates: + name = str(candidate or "").strip() + if not self._is_placeholder_sender_name(name, sender_id): + return name + + logger.warning( + f"[HistoryStore] 无法解析昵称,回退为 sender_id: {sender_id} " + f"(platform={event.get_platform_id()})" + ) + return sender_id + def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]: """ 从事件中提取消息内容 @@ -252,10 +388,18 @@ class QQGroupDailyAnalysis(Star): elif seg_type in ("At", "at"): target = getattr(seg, "target", None) + if target is None: + target = getattr(seg, "qq", None) if target is None and hasattr(seg, "data"): target = seg.data.get("qq") or seg.data.get("target") if target: - message_parts.append({"type": "at", "target_id": str(target)}) + message_parts.append( + { + "type": "at", + "target_id": str(target), + "name": str(getattr(seg, "name", "") or ""), + } + ) # 如果没有从消息链提取到内容,尝试使用 message_str if not message_parts and event.message_str: diff --git a/src/infrastructure/platform/adapters/telegram_adapter.py b/src/infrastructure/platform/adapters/telegram_adapter.py index a9ae74f..bd48a76 100644 --- a/src/infrastructure/platform/adapters/telegram_adapter.py +++ b/src/infrastructure/platform/adapters/telegram_adapter.py @@ -5,6 +5,7 @@ Telegram 平台适配器 通过 AstrBot 的 message_history_manager 存储和读取消息历史。 """ +from dataclasses import replace from datetime import datetime, timedelta, timezone from io import BytesIO from typing import TYPE_CHECKING, Any @@ -170,6 +171,16 @@ class TelegramAdapter(PlatformAdapter): cutoff_time = datetime.now(timezone.utc) - timedelta(days=days) messages = [] + sender_name_cache: dict[str, str] = {} + # 先用本批历史记录中已有的有效昵称预热缓存,减少额外 API 请求 + for record in history_records: + sender_id = str(getattr(record, "sender_id", "") or "").strip() + sender_name = str(getattr(record, "sender_name", "") or "").strip() + if sender_id and not self._is_placeholder_sender_name( + sender_name, sender_id + ): + sender_name_cache[sender_id] = sender_name + for record in history_records: # before_id 过滤,仅保留更早的记录 if before_id_int is not None: @@ -196,6 +207,9 @@ class TelegramAdapter(PlatformAdapter): continue if msg.sender_id in self.bot_self_ids: continue + msg = await self._fix_sender_name_if_needed( + group_id, msg, sender_name_cache + ) messages.append(msg) messages.sort(key=lambda m: m.timestamp) @@ -230,6 +244,70 @@ class TelegramAdapter(PlatformAdapter): pass return "telegram" + @staticmethod + def _is_placeholder_sender_name(name: str | None, sender_id: str | None) -> bool: + """判断 sender_name 是否属于占位值。""" + if not name: + return True + normalized = str(name).strip() + if not normalized: + return True + if normalized.lower() in {"unknown", "none", "null", "nil", "undefined"}: + return True + if sender_id and normalized == str(sender_id).strip(): + return True + return False + + async def _fix_sender_name_if_needed( + self, + group_id: str, + msg: UnifiedMessage, + sender_name_cache: dict[str, str], + ) -> UnifiedMessage: + """ + 如果 sender_name 是占位值,尝试通过 get_member_info 修复。 + + 说明: + - 兼容历史脏数据(sender_name 写成 user_id / Unknown) + - 使用 sender_id 级缓存,避免重复请求 Telegram API + """ + if not self._is_placeholder_sender_name(msg.sender_name, msg.sender_id): + return msg + + sender_id = str(msg.sender_id) + if sender_id in sender_name_cache: + cached_name = sender_name_cache[sender_id] + if cached_name == msg.sender_name: + return msg + logger.info( + "[TEMP][SenderNameFix][Cache] " + f"group_id={group_id} sender_id={sender_id} " + f"from={msg.sender_name} to={cached_name}" + ) + return replace(msg, sender_name=cached_name) + + resolved_name = msg.sender_name + try: + member = await self.get_member_info(group_id, sender_id) + if member: + candidate = str(member.nickname or "").strip() + if self._is_placeholder_sender_name(candidate, sender_id): + candidate = str(member.card or "").strip() + if not self._is_placeholder_sender_name(candidate, sender_id): + resolved_name = candidate + except Exception as e: + logger.debug(f"[Telegram] 修复 sender_name 失败 (uid={sender_id}): {e}") + + sender_name_cache[sender_id] = resolved_name + if resolved_name == msg.sender_name: + return msg + logger.info( + "[TEMP][SenderNameFix][MemberInfo] " + f"group_id={group_id} sender_id={sender_id} " + f"from={msg.sender_name} to={resolved_name}" + ) + return replace(msg, sender_name=resolved_name) + def _convert_history_record( self, record: Any, group_id: str ) -> UnifiedMessage | None: @@ -287,10 +365,13 @@ class TelegramAdapter(PlatformAdapter): ) ) + sender_id = str(record.sender_id or "") + sender_name = str(record.sender_name or "").strip() or "Unknown" + return UnifiedMessage( message_id=str(record.id), - sender_id=record.sender_id or "", - sender_name=record.sender_name or "Unknown", + sender_id=sender_id, + sender_name=sender_name, sender_card=None, group_id=group_id, text_content=text_content, @@ -561,7 +642,10 @@ class TelegramAdapter(PlatformAdapter): members.append( UnifiedMember( user_id=str(user.id), - nickname=user.first_name or user.username or "Unknown", + nickname=user.full_name + or user.first_name + or user.username + or "Unknown", card=user.username, role="admin" if admin.status == "administrator" else "owner", ) @@ -594,7 +678,10 @@ class TelegramAdapter(PlatformAdapter): return UnifiedMember( user_id=str(user.id), - nickname=user.first_name or user.username or "Unknown", + nickname=user.full_name + or user.first_name + or user.username + or "Unknown", card=user.username, role=role, ) diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 830705f..2cd3f4a 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -380,11 +380,15 @@ class ReportGenerator(IReportGenerator): if user_analysis and uid in user_analysis: stats = user_analysis[uid] name = stats.get("nickname") or stats.get("name") + if self._is_placeholder_display_name(name, uid): + name = None # 2. 尝试通过回调获取实时昵称 if not name and nickname_getter: try: name = await nickname_getter(uid) + if self._is_placeholder_display_name(name, uid): + name = None except Exception as e: logger.warning(f"获取昵称失败 {uid}: {e}") @@ -400,7 +404,7 @@ class ReportGenerator(IReportGenerator): # 3. 最终后备: 确保有头像和名称 if not url: url = self._get_default_avatar_base64() - if not name: + if self._is_placeholder_display_name(name, uid): name = str(uid) return ( @@ -427,6 +431,18 @@ class ReportGenerator(IReportGenerator): return result + @staticmethod + def _is_placeholder_display_name(name: str | None, user_id: str) -> bool: + """判断展示名称是否为占位值。""" + if not name: + return True + normalized = str(name).strip() + if not normalized: + return True + if normalized.lower() in {"unknown", "none", "null", "nil", "undefined"}: + return True + return normalized == str(user_id).strip() + def _render_html_template(self, template: str, data: dict) -> str: """HTML模板渲染,使用 {{key}} 占位符格式