import json import re from typing import Any from sqlalchemy.orm import Session from app.application.feishu.delivery import send_card_if_configured, send_text_if_configured from app.application.feishu.handlers import ( handle_admin_command, handle_finance_command, handle_market_command, handle_personal_data_command, handle_personalization_command, handle_rule_command, handle_subscription_command, is_admin_command, is_company_rule_command, ) from app.application.feishu.results import command_result from app.core.config import get_settings from app.core.constants import ActorValue from app.modules.ai_agent.constants import AIResponseKey from app.modules.ai_agent.service import AIService from app.modules.audit.constants import AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.feishu.constants import ( FEISHU_AI_REPLY_TITLE, FEISHU_MENTION_PATTERN, FEISHU_ZERO_WIDTH_SPACE, FeishuCommandKey, FeishuCommandName, FeishuPayloadKey, FeishuReplyType, ) from app.modules.feishu.service import FeishuService from app.modules.feishu_users.constants import ( FEISHU_USER_TARGET_TYPE, FeishuCapability, FeishuUserAuditAction, ) from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal from app.modules.reports.constants import ReportResponseKey from app.modules.reports.services import ReportService DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报") PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报") ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance") RISK_KEYWORDS = ("风险", "预警", "risk") AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ") DEFAULT_AI_PROMPT = "请说明你能做什么。" PERMISSION_DENIED_TITLE = "权限不足" COMPANY_RULE_COMMAND_PREFIXES = ( "学习公司规则", "查看公司规则", "修改公司规则", "启用公司规则", "停用公司规则", "删除公司规则", "学习公司市场规则", "查看公司市场规则", ) FINANCE_COMMAND_PREFIXES = ("资金需求", "未来30天资金需求", "项目资金 ") def _parse_content_text(content: Any) -> str: """Extract plain command text from a Feishu message content payload.""" if isinstance(content, dict): return str( content.get(FeishuPayloadKey.TEXT) or content.get(FeishuPayloadKey.CONTENT) or "" ) if not isinstance(content, str): return "" try: data = json.loads(content) except json.JSONDecodeError: return content if isinstance(data, dict): return str(data.get(FeishuPayloadKey.TEXT) or data.get(FeishuPayloadKey.CONTENT) or "") return content def _clean_command_text(text: str) -> str: """Remove mentions and invisible characters from Feishu command text.""" text = re.sub(FEISHU_MENTION_PATTERN, "", text or "") text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "") return text.strip() class FeishuCommandService: """Route Feishu text commands to focused application handlers.""" def __init__(self, db: Session): self.db = db self.feishu = FeishuService(db) def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None: header = payload.get(FeishuPayloadKey.HEADER) or {} event = payload.get(FeishuPayloadKey.EVENT) or {} message = event.get(FeishuPayloadKey.MESSAGE) or {} if not message: return None raw_text = _parse_content_text(message.get(FeishuPayloadKey.CONTENT)) command_text = _clean_command_text(raw_text) if not command_text: return None sender = event.get(FeishuPayloadKey.SENDER) or {} sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {} actor = ( sender_id.get(FeishuPayloadKey.OPEN_ID) or sender_id.get(FeishuPayloadKey.USER_ID) or ActorValue.FEISHU ) return { FeishuCommandKey.TEXT: ( raw_text if get_settings().feishu_user_features_enabled else command_text ), FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID), FeishuCommandKey.CHAT_TYPE: message.get(FeishuPayloadKey.CHAT_TYPE), FeishuCommandKey.ACTOR: actor, FeishuCommandKey.MENTIONS: _parse_mentions( message.get(FeishuPayloadKey.MENTIONS), str(header.get(FeishuPayloadKey.TENANT_KEY) or ""), ), } def handle_text( self, text: str, chat_id: str | None = None, actor: str = ActorValue.FEISHU, auto_reply: bool = True, principal: FeishuPrincipal | None = None, tenant_key: str | None = None, ) -> dict[str, Any]: if tenant_key is not None: self.feishu.set_tenant_key(tenant_key) raw_text = text or "" command_text = _clean_command_text(text) lowered = command_text.lower() if get_settings().feishu_user_features_enabled: if principal is None: return self._permission_denied( chat_id=chat_id, actor=actor, auto_reply=False, principal=None, reason="missing_verified_identity", content="无法确认飞书账号身份,已拒绝执行该命令。", ) self.feishu.set_tenant_key(principal.tenant_key) chat_id = principal.chat_id or chat_id actor = principal.user_code if not principal.is_active: return self._permission_denied( chat_id=chat_id, actor=actor, auto_reply=auto_reply, principal=principal, reason="disabled_user", content="当前飞书账号已停用,请联系管理员。", ) required_capability = _required_capability(command_text) if ( required_capability is not None and not principal.has_capability(required_capability) ): return self._permission_denied( chat_id=chat_id, actor=actor, auto_reply=auto_reply, principal=principal, reason=f"missing_capability:{required_capability}", content="当前飞书账号无权使用该公司级功能。", ) admin_result = handle_admin_command( self.db, self.feishu, raw_text=raw_text, command_text=command_text, principal=principal, auto_reply=auto_reply, ) if admin_result is not None: return admin_result for handler in ( handle_subscription_command, handle_personal_data_command, handle_personalization_command, ): result = handler( self.db, self.feishu, command_text, principal, auto_reply, ) if result is not None: return result rule_result = handle_rule_command( self.db, self.feishu, command_text, chat_id, actor, auto_reply, principal=principal, ) if rule_result is not None: return rule_result market_result = handle_market_command( self.db, self.feishu, command_text, chat_id, actor, auto_reply, principal=principal, ) if market_result is not None: return market_result for handler in ( handle_finance_command, ): result = handler( self.db, self.feishu, command_text, chat_id, actor, auto_reply, ) if result is not None: return result if not get_settings().feishu_user_features_enabled: for handler in (handle_rule_command, handle_market_command): result = handler( self.db, self.feishu, command_text, chat_id, actor, auto_reply, ) if result is not None: return result report_result = self._handle_report_command(command_text, chat_id, actor, auto_reply) if report_result is not None: return report_result return self._handle_ai_command( command_text, lowered, chat_id, actor, auto_reply, principal=principal, ) def _permission_denied( self, *, chat_id: str | None, actor: str, auto_reply: bool, principal: FeishuPrincipal | None, reason: str, content: str, ) -> dict[str, Any]: self.feishu.audit.log( AuditLogCreate( actor=actor, source=AuditSource.FEISHU, action=FeishuUserAuditAction.PERMISSION_DENIED, target_type=FEISHU_USER_TARGET_TYPE, target_id=principal.user_code if principal else None, risk_level=AuditRiskLevel.MEDIUM, response_payload={"result": "denied", "reason": reason}, status="denied", ) ) response = ( send_text_if_configured(self.feishu, chat_id, content, actor) if auto_reply else None ) return command_result( FeishuCommandName.PERMISSION_DENIED, FeishuReplyType.TEXT, PERMISSION_DENIED_TITLE, content, response, ) def _handle_report_command( self, command_text: str, chat_id: str | None, actor: str, auto_reply: bool, ) -> dict[str, Any] | None: service = ReportService(self.db) if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS): command = FeishuCommandName.DAILY_BRIEF report = service.daily_brief() elif any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS): command = FeishuCommandName.PROJECT_WEEKLY report = service.project_weekly() elif any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS): command = FeishuCommandName.ATTENDANCE_SUMMARY report = service.attendance_summary() elif any(keyword in command_text for keyword in RISK_KEYWORDS): command = FeishuCommandName.RISK_SUMMARY report = service.risk_progress() else: return None response = ( send_card_if_configured( self.feishu, chat_id, report[ReportResponseKey.TITLE], report[ReportResponseKey.LINES], actor, ) if auto_reply else None ) return command_result( command, FeishuReplyType.CARD, report[ReportResponseKey.TITLE], report[ReportResponseKey.CONTENT], response, report[ReportResponseKey.LINES], ) def _handle_ai_command( self, command_text: str, lowered: str, chat_id: str | None, actor: str, auto_reply: bool, principal: FeishuPrincipal | None = None, ) -> dict[str, Any]: prompt = command_text for prefix in AI_COMMAND_PREFIXES: if command_text.startswith(prefix): prompt = command_text[len(prefix) :].strip() break if not prompt: prompt = DEFAULT_AI_PROMPT if get_settings().feishu_user_features_enabled and principal is not None: chat_type, chat_key = _principal_chat_context(principal) ai_result = AIService(self.db).ask_personalized( principal.owner_id, chat_type, chat_key, prompt, actor=actor, source=AuditSource.FEISHU, ) else: ai_result = AIService(self.db).ask( prompt, context={}, actor=actor, source=AuditSource.FEISHU, ) content = ai_result[AIResponseKey.ANSWER] is_explicit_ai = any( command_text.startswith(prefix) or lowered.startswith(prefix) for prefix in AI_COMMAND_PREFIXES ) response = ( send_text_if_configured(self.feishu, chat_id, content, actor) if auto_reply else None ) return command_result( FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI, FeishuReplyType.TEXT, FEISHU_AI_REPLY_TITLE, content, response, ) def _parse_mentions(value: Any, tenant_key: str) -> tuple[FeishuMention, ...]: if not isinstance(value, list): return () mentions: list[FeishuMention] = [] for item in value: if not isinstance(item, dict): continue mention_id = item.get(FeishuPayloadKey.ID) or {} if not isinstance(mention_id, dict): mention_id = {} mentions.append( FeishuMention( key=_optional_text(item.get(FeishuPayloadKey.KEY)), name=_optional_text(item.get(FeishuPayloadKey.NAME)), tenant_key=( _optional_text(item.get(FeishuPayloadKey.TENANT_KEY)) or tenant_key or None ), open_id=_optional_text( mention_id.get(FeishuPayloadKey.OPEN_ID) or item.get(FeishuPayloadKey.OPEN_ID) ), union_id=_optional_text( mention_id.get(FeishuPayloadKey.UNION_ID) or item.get(FeishuPayloadKey.UNION_ID) ), user_id=_optional_text( mention_id.get(FeishuPayloadKey.USER_ID) or item.get(FeishuPayloadKey.USER_ID) ), ) ) return tuple(mentions) def _optional_text(value: Any) -> str | None: text = str(value or "").strip() return text or None def _required_capability(command_text: str) -> FeishuCapability | None: if is_admin_command(command_text): return FeishuCapability.USER_ADMINISTRATION if ( command_text.startswith(COMPANY_RULE_COMMAND_PREFIXES) or is_company_rule_command(command_text) ): return FeishuCapability.COMPANY_RULES if command_text.startswith(FINANCE_COMMAND_PREFIXES): return FeishuCapability.COMPANY_REPORTS if any( keyword in command_text for keyword in ( *DAILY_REPORT_KEYWORDS, *PROJECT_WEEKLY_KEYWORDS, *ATTENDANCE_KEYWORDS, *RISK_KEYWORDS, ) ): return FeishuCapability.COMPANY_REPORTS return None def _principal_chat_context(principal: FeishuPrincipal) -> tuple[str, str]: chat_type = principal.chat_type or "p2p" if chat_type in {"group", "group_chat"}: if not principal.chat_id: raise ValueError("Verified group chat is missing chat_id") return chat_type, principal.chat_id return chat_type, principal.chat_id or principal.open_id