import re from collections import Counter from typing import Any from fastapi import HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session from app.application.feishu.delivery import send_text_if_configured from app.application.feishu.personal_data import FeishuPersonalDataService from app.application.feishu.results import command_result from app.modules.ai_memory.constants import AIMemoryKind from app.modules.ai_memory.models import AIMemoryEntry from app.modules.audit.constants import AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.business.models import MarketWatchlist from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType from app.modules.feishu.service import FeishuService from app.modules.feishu_users.principal import FeishuPrincipal from app.modules.personalization.constants import PreferenceCategory from app.modules.personalization.models import AIConversation from app.modules.personalization.services import PreferenceService from app.modules.subscriptions.models import PushSubscription PERSONAL_DATA_TITLE = "我的数据" _CONFIRM_PATTERN = re.compile(r"^确认忘记我\s+([A-Fa-f0-9]{8})$") _COMMAND_PREFIXES = ("我的数据", "忘记我", "确认忘记我") def handle_personal_data_command( db: Session, feishu: FeishuService, command_text: str, principal: FeishuPrincipal, auto_reply: bool, ) -> dict[str, Any] | None: """Handle private data summaries and two-step account erasure.""" text = command_text.strip() if not text.startswith(_COMMAND_PREFIXES): return None principal.require_active() if principal.chat_type in {"group", "group_chat"}: return _result( feishu, FeishuCommandName.PERSONAL_DATA_SUMMARY, "为保护个人信息,请私聊机器人使用“我的数据”或“忘记我”。", principal, auto_reply, ) if text == "我的数据": content = _summary(db, principal) _audit_summary(db, principal) return _result( feishu, FeishuCommandName.PERSONAL_DATA_SUMMARY, content, principal, auto_reply, ) if text == "忘记我": confirmation = FeishuPersonalDataService(db).request_confirmation(principal) _audit_erasure_request(db, principal) content = ( "该操作会永久删除你的个人规则、记忆、偏好、兴趣、" "会话、订阅和飞书身份映射。\n" f"确认码:{confirmation.confirmation_code}\n" f"有效期至:{confirmation.expires_at.strftime('%Y-%m-%d %H:%M:%S')} UTC\n" f"确认命令:确认忘记我 {confirmation.confirmation_code}" ) return _result( feishu, FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST, content, principal, auto_reply, ) match = _CONFIRM_PATTERN.fullmatch(text) if match is None: return _result( feishu, FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM, "格式不正确。请先发送“忘记我”获取一次性确认码。", principal, auto_reply, ) try: erased = FeishuPersonalDataService(db).confirm(principal, match.group(1)) except HTTPException as exc: db.rollback() content = ( "不能删除最后一个有效管理员,请先设置另一名管理员。" if exc.status_code == 409 else "确认码无效或已过期,请重新发送“忘记我”。" ) return _result( feishu, FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM, content, principal, auto_reply, ) return _result( feishu, FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM, "你的个人数据和飞书身份映射已删除。再次联系时会创建新的普通用户身份。", principal, auto_reply, audit_actor=erased.anonymous_id, ) def _summary(db: Session, principal: FeishuPrincipal) -> str: owner_id = principal.owner_id preferences = PreferenceService(db).list_preferences(owner_id) interest_categories = { PreferenceCategory.TOPIC, PreferenceCategory.INTEREST, } profile_preferences = [ item for item in preferences if item["category"] not in interest_categories ] interest_preferences = [ item for item in preferences if item["category"] in interest_categories ] watchlist = list( db.execute( select(MarketWatchlist.symbol) .where( MarketWatchlist.owner_id == owner_id, MarketWatchlist.enabled.is_(True), ) .order_by(MarketWatchlist.symbol.asc()) ).scalars() ) subscription_statuses = Counter( db.execute( select(PushSubscription.status).where( PushSubscription.owner_id == owner_id ) ).scalars() ) personal_rule_count = _memory_count( db, owner_id, AIMemoryKind.PERSONAL_RULE, ) memory_count = _memory_count(db, owner_id, AIMemoryKind.MEMORY) conversation_count = int( db.scalar( select(func.count()) .select_from(AIConversation) .where(AIConversation.owner_id == owner_id) ) or 0 ) preference_text = "、".join( f"{item['category']}={item['value']}" for item in profile_preferences[:10] ) interest_values = [ *(str(item["value"]) for item in interest_preferences), *watchlist, ] interest_text = "、".join(interest_values[:10]) subscriptions_text = "、".join( f"{status} {count}" for status, count in sorted(subscription_statuses.items()) ) return "\n".join( [ "我的个人数据摘要:", f"个人规则:{personal_rule_count} 条", f"偏好:{preference_text or '暂无'}", f"兴趣与自选:{interest_text or '暂无'}", f"个人记忆:{memory_count} 条", f"会话:{conversation_count} 个", f"订阅:{subscriptions_text or '暂无'}", "可发送“我的偏好”“查看规则”“我的订阅”查看明细。", ] ) def _memory_count( db: Session, owner_id: int, kind: str, ) -> int: return int( db.scalar( select(func.count()) .select_from(AIMemoryEntry) .where( AIMemoryEntry.owner_id == owner_id, AIMemoryEntry.kind == kind, ) ) or 0 ) def _audit_summary(db: Session, principal: FeishuPrincipal) -> None: _audit( db, principal, action="personalization.data.summary", risk_level=AuditRiskLevel.LOW, ) def _audit_erasure_request(db: Session, principal: FeishuPrincipal) -> None: _audit( db, principal, action="personalization.erasure.request", risk_level=AuditRiskLevel.HIGH, ) def _audit( db: Session, principal: FeishuPrincipal, *, action: str, risk_level: str, ) -> None: AuditService(db).record( AuditLogCreate( actor=principal.user_code, source=AuditSource.FEISHU, action=action, target_type="feishu-user", target_id=principal.user_code, risk_level=risk_level, response_payload={"result": "success"}, ) ) db.commit() def _result( feishu: FeishuService, command: FeishuCommandName, content: str, principal: FeishuPrincipal, auto_reply: bool, *, audit_actor: str | None = None, ) -> dict[str, Any]: response = ( send_text_if_configured( feishu, principal.chat_id, content, audit_actor or principal.user_code, record_audit=audit_actor is None, ) if auto_reply else None ) return command_result( command, FeishuReplyType.TEXT, PERSONAL_DATA_TITLE, content, response, )