feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
301 lines
9.5 KiB
Python
301 lines
9.5 KiB
Python
import re
|
||
from typing import Any
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.application.feishu.delivery import send_text_if_configured
|
||
from app.application.feishu.results import command_result
|
||
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.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.services import ConversationService, PreferenceService
|
||
|
||
PERSONALIZATION_TITLE = "个人设置"
|
||
PREFERENCE_HELP = (
|
||
"偏好指令格式:\n"
|
||
"记住偏好 语言:中文\n"
|
||
"记住偏好 语气:简洁\n"
|
||
"记住偏好 详略:详细\n"
|
||
"关注主题:人工智能\n"
|
||
"我的偏好\n"
|
||
"删除偏好 <偏好编号>"
|
||
)
|
||
_CATEGORY_LABELS = {
|
||
"语言": PreferenceCategory.LANGUAGE,
|
||
"语气": PreferenceCategory.TONE,
|
||
"详略": PreferenceCategory.DETAIL,
|
||
"主题": PreferenceCategory.TOPIC,
|
||
"兴趣": PreferenceCategory.INTEREST,
|
||
}
|
||
_CATEGORY_NAMES = {
|
||
PreferenceCategory.LANGUAGE: "语言",
|
||
PreferenceCategory.TONE: "语气",
|
||
PreferenceCategory.DETAIL: "详略",
|
||
PreferenceCategory.TOPIC: "关注主题",
|
||
PreferenceCategory.INTEREST: "兴趣",
|
||
}
|
||
_SET_PATTERN = re.compile(
|
||
r"^记住偏好\s+(语言|语气|详略|主题|兴趣)\s*[::]\s*(.+)$"
|
||
)
|
||
_TOPIC_PATTERN = re.compile(r"^关注主题\s*[::]?\s*(.+)$")
|
||
_DELETE_PATTERN = re.compile(
|
||
r"^删除偏好\s+(PREF-[A-Za-z0-9-]+)$",
|
||
re.IGNORECASE,
|
||
)
|
||
_LIST_COMMANDS = {"我的偏好", "查看偏好", "我的兴趣", "查看兴趣"}
|
||
_HELP_COMMANDS = {"帮助", "使用帮助", "命令帮助"}
|
||
_COMMAND_PREFIXES = (
|
||
"记住偏好",
|
||
"关注主题",
|
||
"我的偏好",
|
||
"查看偏好",
|
||
"我的兴趣",
|
||
"查看兴趣",
|
||
"删除偏好",
|
||
"重置对话",
|
||
*_HELP_COMMANDS,
|
||
)
|
||
|
||
|
||
def handle_personalization_command(
|
||
db: Session,
|
||
feishu: FeishuService,
|
||
command_text: str,
|
||
principal: FeishuPrincipal,
|
||
auto_reply: bool,
|
||
) -> dict[str, Any] | None:
|
||
"""Handle owner-scoped preferences, interests, help, and conversation reset."""
|
||
|
||
text = command_text.strip()
|
||
if not text.startswith(_COMMAND_PREFIXES):
|
||
return None
|
||
principal.require_active()
|
||
if text in _HELP_COMMANDS:
|
||
return _result(
|
||
feishu,
|
||
FeishuCommandName.HELP,
|
||
_help_content(principal),
|
||
principal,
|
||
auto_reply,
|
||
)
|
||
if text == "重置对话":
|
||
chat_type, chat_key = _conversation_key(principal)
|
||
reset = ConversationService(db).reset(
|
||
principal.owner_id,
|
||
chat_type,
|
||
chat_key,
|
||
)
|
||
_audit(
|
||
db,
|
||
principal,
|
||
action="personalization.conversation.reset",
|
||
target_type="ai-conversation",
|
||
response={"reset": reset},
|
||
)
|
||
content = "当前会话已重置。" if reset else "当前会话没有可清除的历史。"
|
||
return _result(
|
||
feishu,
|
||
FeishuCommandName.CONVERSATION_RESET,
|
||
content,
|
||
principal,
|
||
auto_reply,
|
||
)
|
||
|
||
service = PreferenceService(db)
|
||
set_match = _SET_PATTERN.fullmatch(text)
|
||
topic_match = _TOPIC_PATTERN.fullmatch(text)
|
||
delete_match = _DELETE_PATTERN.fullmatch(text)
|
||
try:
|
||
if set_match:
|
||
category = _CATEGORY_LABELS[set_match.group(1)]
|
||
record = service.upsert(
|
||
principal.owner_id,
|
||
category,
|
||
set_match.group(2),
|
||
)
|
||
_audit_preference(db, principal, "upsert", record)
|
||
content = (
|
||
"偏好已保存。\n"
|
||
f"编号:{record['code']}\n"
|
||
f"类别:{_category_name(record['category'])}\n"
|
||
f"内容:{record['value']}"
|
||
)
|
||
command = FeishuCommandName.PREFERENCE_SET
|
||
elif topic_match:
|
||
record = service.upsert(
|
||
principal.owner_id,
|
||
PreferenceCategory.TOPIC,
|
||
topic_match.group(1),
|
||
)
|
||
_audit_preference(db, principal, "upsert", record)
|
||
content = (
|
||
"关注主题已保存。\n"
|
||
f"编号:{record['code']}\n"
|
||
f"主题:{record['value']}"
|
||
)
|
||
command = FeishuCommandName.PREFERENCE_SET
|
||
elif text in _LIST_COMMANDS:
|
||
records = service.list_preferences(principal.owner_id)
|
||
_audit(
|
||
db,
|
||
principal,
|
||
action="personalization.preference.list",
|
||
target_type="user-preference",
|
||
response={"count": len(records)},
|
||
)
|
||
content = _preference_list(records)
|
||
command = FeishuCommandName.PREFERENCE_LIST
|
||
elif delete_match:
|
||
code = delete_match.group(1)
|
||
service.delete(principal.owner_id, code)
|
||
_audit(
|
||
db,
|
||
principal,
|
||
action="personalization.preference.delete",
|
||
target_type="user-preference",
|
||
target_id=code,
|
||
response={"deleted": True},
|
||
)
|
||
content = "偏好已删除。"
|
||
command = FeishuCommandName.PREFERENCE_DELETE
|
||
else:
|
||
content = PREFERENCE_HELP
|
||
command = FeishuCommandName.PREFERENCE_SET
|
||
except HTTPException as exc:
|
||
db.rollback()
|
||
if exc.status_code == 404:
|
||
content = "没有找到该偏好,请先发送“我的偏好”确认编号。"
|
||
elif "Sensitive preference" in str(exc.detail):
|
||
content = "该内容可能涉及敏感个人信息或密钥,已拒绝保存。"
|
||
else:
|
||
content = f"偏好指令未执行,请检查格式。\n\n{PREFERENCE_HELP}"
|
||
command = (
|
||
FeishuCommandName.PREFERENCE_DELETE
|
||
if delete_match
|
||
else FeishuCommandName.PREFERENCE_SET
|
||
)
|
||
return _result(feishu, command, content, principal, auto_reply)
|
||
|
||
|
||
def _preference_list(records: list[dict[str, Any]]) -> str:
|
||
if not records:
|
||
return "当前没有已保存的偏好或兴趣。\n\n" + PREFERENCE_HELP
|
||
lines = ["我的偏好与兴趣:"]
|
||
for record in records:
|
||
lines.append(
|
||
f"{record['code']}|{_category_name(record['category'])}"
|
||
f"|{record['value']}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _help_content(principal: FeishuPrincipal) -> str:
|
||
lines = [
|
||
"你可以使用:",
|
||
"问 <问题>",
|
||
"学习规则:<个人规则>;查看/修改/启用/停用/删除规则",
|
||
"记住偏好、关注主题、我的偏好、删除偏好",
|
||
"加入自选 <股票代码>;查看自选",
|
||
"订阅 <自然语言时间>:<提示词>;我的订阅、暂停、恢复、退订",
|
||
"设置时区、设置/关闭安静时段",
|
||
"重置对话、我的数据、忘记我",
|
||
]
|
||
if principal.is_admin:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"管理员还可以使用:",
|
||
"日报、周报、财务、风险和考勤查询",
|
||
"学习/查看/修改/启停/删除公司规则",
|
||
"在当前群创建群订阅",
|
||
"通过真实 @用户 管理角色和状态",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _conversation_key(principal: FeishuPrincipal) -> tuple[str, str]:
|
||
chat_type = principal.chat_type or "p2p"
|
||
is_group = chat_type in {"group", "group_chat"}
|
||
chat_key = principal.chat_id if is_group else (principal.chat_id or principal.open_id)
|
||
if not chat_key:
|
||
raise HTTPException(status_code=422, detail="Missing Feishu chat identity")
|
||
return chat_type, chat_key
|
||
|
||
|
||
def _audit_preference(
|
||
db: Session,
|
||
principal: FeishuPrincipal,
|
||
action: str,
|
||
record: dict[str, Any],
|
||
) -> None:
|
||
_audit(
|
||
db,
|
||
principal,
|
||
action=f"personalization.preference.{action}",
|
||
target_type="user-preference",
|
||
target_id=str(record["code"]),
|
||
response={"category": record["category"]},
|
||
)
|
||
|
||
|
||
def _audit(
|
||
db: Session,
|
||
principal: FeishuPrincipal,
|
||
*,
|
||
action: str,
|
||
target_type: str,
|
||
target_id: str | None = None,
|
||
response: dict[str, Any] | None = None,
|
||
) -> None:
|
||
AuditService(db).record(
|
||
AuditLogCreate(
|
||
actor=principal.user_code,
|
||
source=AuditSource.FEISHU,
|
||
action=action,
|
||
target_type=target_type,
|
||
target_id=target_id,
|
||
risk_level=AuditRiskLevel.LOW,
|
||
response_payload=response,
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _category_name(value: str) -> str:
|
||
try:
|
||
return _CATEGORY_NAMES[PreferenceCategory(value)]
|
||
except ValueError:
|
||
return value
|
||
|
||
|
||
def _result(
|
||
feishu: FeishuService,
|
||
command: FeishuCommandName,
|
||
content: str,
|
||
principal: FeishuPrincipal,
|
||
auto_reply: bool,
|
||
) -> dict[str, Any]:
|
||
response = (
|
||
send_text_if_configured(
|
||
feishu,
|
||
principal.chat_id,
|
||
content,
|
||
principal.user_code,
|
||
)
|
||
if auto_reply
|
||
else None
|
||
)
|
||
return command_result(
|
||
command,
|
||
FeishuReplyType.TEXT,
|
||
PERSONALIZATION_TITLE,
|
||
content,
|
||
response,
|
||
)
|