Files
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

301 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)