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

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

View File

@@ -1,9 +1,29 @@
from app.application.feishu.handlers.admin import (
handle_admin_command,
is_admin_command,
)
from app.application.feishu.handlers.finance import handle_finance_command
from app.application.feishu.handlers.market import handle_market_command
from app.application.feishu.handlers.rules import handle_rule_command
from app.application.feishu.handlers.personalization import (
handle_personalization_command,
)
from app.application.feishu.handlers.personal_data import (
handle_personal_data_command,
)
from app.application.feishu.handlers.rules import (
handle_rule_command,
is_company_rule_command,
)
from app.application.feishu.handlers.subscriptions import handle_subscription_command
__all__ = [
"handle_admin_command",
"handle_finance_command",
"handle_market_command",
"handle_personalization_command",
"handle_personal_data_command",
"handle_rule_command",
"handle_subscription_command",
"is_admin_command",
"is_company_rule_command",
]

View File

@@ -0,0 +1,192 @@
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.constants import (
FEISHU_USER_TARGET_TYPE,
FeishuUserAuditAction,
FeishuUserRole,
FeishuUserStatus,
)
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
from app.modules.feishu_users.services import (
FeishuIdentityService,
FeishuUserManagementService,
)
ADMIN_COMMAND_TITLE = "飞书用户管理"
ADMIN_COMMAND_HELP = (
"用户管理命令必须使用一个真实的飞书 @用户:\n"
"设为管理员 @用户\n"
"设为普通用户 @用户\n"
"停用用户 @用户\n"
"启用用户 @用户"
)
_ADMIN_COMMANDS: dict[str, tuple[FeishuCommandName, dict[str, str], str]] = {
"设为管理员": (
FeishuCommandName.USER_SET_ADMIN,
{"role": FeishuUserRole.ADMIN},
"已设为管理员",
),
"设为普通用户": (
FeishuCommandName.USER_SET_USER,
{"role": FeishuUserRole.USER},
"已设为普通用户",
),
"停用用户": (
FeishuCommandName.USER_DISABLE,
{"status": FeishuUserStatus.DISABLED},
"已停用",
),
"启用用户": (
FeishuCommandName.USER_ENABLE,
{"status": FeishuUserStatus.ACTIVE},
"已启用",
),
}
def is_admin_command(command_text: str) -> bool:
return any(command_text.startswith(prefix) for prefix in _ADMIN_COMMANDS)
def handle_admin_command(
db: Session,
feishu: FeishuService,
*,
raw_text: str,
command_text: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Manage a user selected only from verified structured mention metadata."""
matched = next(
(
(prefix, definition)
for prefix, definition in _ADMIN_COMMANDS.items()
if command_text.startswith(prefix)
),
None,
)
if matched is None:
return None
command_prefix, definition = matched
command, changes, success_text = definition
target = _target_mention(raw_text, command_prefix, principal.mentions)
if target is None or not target.open_id:
_audit_denied(db, principal, "missing_or_ambiguous_structured_mention")
return _result(
feishu,
command,
ADMIN_COMMAND_HELP,
principal,
auto_reply,
)
target_tenant = target.tenant_key or principal.tenant_key
if target_tenant != principal.tenant_key:
_audit_denied(db, principal, "cross_tenant_target")
return _result(
feishu,
command,
"只能管理当前租户内通过飞书 @ 提及的用户。",
principal,
auto_reply,
)
target_principal = FeishuIdentityService(db).resolve_or_register(
tenant_key=target_tenant,
open_id=target.open_id,
union_id=target.union_id,
user_id=target.user_id,
actor=principal.user_code,
)
try:
updated = FeishuUserManagementService(db).update_user(
target_principal.user_code,
changes=changes,
actor=principal.user_code,
)
display_name = target.name or updated.code
content = f"{display_name} {success_text}"
except HTTPException as exc:
if exc.status_code == 409:
content = "操作已拒绝:不能停用或降级最后一个有效管理员。"
else:
content = "用户状态未修改,请确认目标用户后重试。"
return _result(feishu, command, content, principal, auto_reply)
def _target_mention(
raw_text: str,
command_text: str,
mentions: tuple[FeishuMention, ...],
) -> FeishuMention | None:
command_index = raw_text.find(command_text)
if command_index >= 0:
command_tail = raw_text[command_index + len(command_text) :]
candidates = [
mention
for mention in mentions
if mention.key and mention.key in command_tail
]
if len(candidates) == 1:
return candidates[0]
return None
if len(mentions) == 1:
return mentions[0]
return None
def _audit_denied(
db: Session,
principal: FeishuPrincipal,
reason: str,
) -> None:
AuditService(db).record(
AuditLogCreate(
actor=principal.user_code,
source=AuditSource.FEISHU,
action=FeishuUserAuditAction.UPDATE_DENIED,
target_type=FEISHU_USER_TARGET_TYPE,
risk_level=AuditRiskLevel.HIGH,
response_payload={"result": "denied", "reason": reason},
status="denied",
)
)
db.commit()
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,
ADMIN_COMMAND_TITLE,
content,
response,
)

View File

@@ -9,6 +9,7 @@ from app.application.feishu.results import command_result
from app.core.config import get_settings
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.market.chart import render_market_chart
from app.modules.market.service import MarketService
@@ -38,6 +39,7 @@ def handle_market_command(
chat_id: str | None,
actor: str,
auto_reply: bool,
principal: FeishuPrincipal | None = None,
) -> dict[str, Any] | None:
"""Handle market analysis and watchlist commands."""
@@ -54,6 +56,31 @@ def handle_market_command(
and not comparison
):
return None
service = MarketService(db)
owner_id = principal.owner_id if principal is not None else None
if add:
item = service.add_watchlist(actor, add.group(1), owner_id=owner_id)
return _text_result(
feishu,
FeishuCommandName.WATCHLIST_ADD,
"自选股",
f"已加入自选:{item['symbol']}",
chat_id,
actor,
auto_reply,
)
if text == "查看自选":
items = service.watchlist(actor, owner_id=owner_id)
content = "自选股:" + ("".join(item["symbol"] for item in items) or "暂无")
return _text_result(
feishu,
FeishuCommandName.WATCHLIST_LIST,
"自选股",
content,
chat_id,
actor,
auto_reply,
)
if not get_settings().market_analysis_enabled:
return _text_result(
feishu,
@@ -65,40 +92,6 @@ def handle_market_command(
auto_reply,
)
service = MarketService(db)
if add:
if get_settings().read_only_mode:
return _text_result(
feishu,
FeishuCommandName.WATCHLIST_ADD,
"自选股",
"当前为只读模式,不能修改自选股。请由管理员启用操作后重试。",
chat_id,
actor,
auto_reply,
)
item = service.add_watchlist(actor, add.group(1))
return _text_result(
feishu,
FeishuCommandName.WATCHLIST_ADD,
"自选股",
f"已加入自选:{item['symbol']}",
chat_id,
actor,
auto_reply,
)
if text == "查看自选":
items = service.watchlist(actor)
content = "自选股:" + ("".join(item["symbol"] for item in items) or "暂无")
return _text_result(
feishu,
FeishuCommandName.WATCHLIST_LIST,
"自选股",
content,
chat_id,
actor,
auto_reply,
)
if text == "最新公告":
items = service.announcements(limit=10)["items"]
content = (

View File

@@ -0,0 +1,267 @@
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,
)

View File

@@ -0,0 +1,300 @@
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,
)

View File

@@ -11,32 +11,60 @@ from app.modules.ai_memory.constants import AIMemoryStatus
from app.modules.ai_memory.service import AIMemoryService
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.constants import FeishuCapability
from app.modules.feishu_users.principal import FeishuPrincipal
RULE_TITLE = "AI 学习规则"
RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[:]\s*(.*)$")
MARKET_RULE_CREATE_PATTERN = re.compile(
r"^学习市场规则(?:\s+(\d{1,3}))?\s*[:]\s*(.*)$"
)
RULE_UPDATE_PATTERN = re.compile(
r"^修改规则\s+(MEM-[A-Za-z0-9-]+)(?:\s+(\d{1,3}))?\s*[:]\s*(.*)$",
re.IGNORECASE,
)
RULE_DISABLE_PATTERN = re.compile(r"^停用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_ENABLE_PATTERN = re.compile(r"^启用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_DELETE_PATTERN = re.compile(r"^删除规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_LIST_COMMANDS = {"查看规则", "规则列表", "查看市场规则"}
RULE_COMMAND_PREFIXES = (
"学习公司市场规则",
"学习公司规则",
"查看公司市场规则",
"查看公司规则",
"修改公司规则",
"启用公司规则",
"停用公司规则",
"删除公司规则",
"学习市场规则",
"学习规则",
"查看市场规则",
"查看规则",
"规则列表",
"修改规则",
"停用规则",
"启用规则",
"删除规则",
)
RULE_COMMAND_HELP = (
"规则指令格式:\n"
"学习规则:<规则内容>\n"
"学习规则 80<规则内容>\n"
"学习市场规则 80<仅用于市场分析的规则内容>\n"
"查看规则\n"
"停用规则 <规则编号>\n"
"启用规则 <规则编号>"
"修改规则 <编号> 80<新内容>\n"
"启用规则 <编号>\n"
"停用规则 <编号>\n"
"删除规则 <编号>"
)
_COMPANY_REPLACEMENTS = (
("学习公司市场规则", "学习市场规则"),
("查看公司市场规则", "查看市场规则"),
("学习公司规则", "学习规则"),
("查看公司规则", "查看规则"),
("修改公司规则", "修改规则"),
("启用公司规则", "启用规则"),
("停用公司规则", "停用规则"),
("删除公司规则", "删除规则"),
)
@@ -47,29 +75,40 @@ def handle_rule_command(
chat_id: str | None,
actor: str,
auto_reply: bool,
principal: FeishuPrincipal | None = None,
) -> dict[str, Any] | None:
"""Handle persistent AI rule commands."""
"""Handle company or owner-scoped persistent AI rule commands."""
if not command_text.startswith(RULE_COMMAND_PREFIXES):
return None
command = _command_name(command_text)
if command in {
FeishuCommandName.RULE_CREATE,
FeishuCommandName.RULE_DISABLE,
FeishuCommandName.RULE_ENABLE,
} and get_settings().read_only_mode:
return _result(
feishu,
command,
"当前为只读模式,不能新增或修改学习规则。请由管理员启用操作后重试。",
chat_id,
actor,
auto_reply,
)
normalized, company_rule = _normalize_company_command(command_text)
owner_id: int | None = None
if get_settings().feishu_user_features_enabled:
if principal is None:
return _result(
feishu,
FeishuCommandName.PERMISSION_DENIED,
"个人规则只能由已验证的飞书账号管理。",
chat_id,
actor,
auto_reply,
)
if company_rule:
principal.require_capability(FeishuCapability.COMPANY_RULES)
else:
owner_id = principal.owner_id
command = _command_name(normalized)
content = RULE_COMMAND_HELP
try:
command, content = _execute(db, command_text, command, actor)
command, content = _execute(
db,
normalized,
command,
actor,
owner_id=owner_id,
company_rule=company_rule or owner_id is None,
)
except HTTPException as exc:
detail = str(exc.detail)
if "secret-like" in detail:
@@ -83,21 +122,65 @@ def handle_rule_command(
return _result(feishu, command, content, chat_id, actor, auto_reply)
def is_company_rule_command(command_text: str) -> bool:
return any(command_text.startswith(prefix) for prefix, _ in _COMPANY_REPLACEMENTS)
def _normalize_company_command(command_text: str) -> tuple[str, bool]:
for prefix, replacement in _COMPANY_REPLACEMENTS:
if command_text.startswith(prefix):
return replacement + command_text[len(prefix) :], True
return command_text, False
def _execute(
db: Session,
command_text: str,
command: FeishuCommandName,
actor: str,
*,
owner_id: int | None,
company_rule: bool,
) -> tuple[FeishuCommandName, str]:
market_create_match = MARKET_RULE_CREATE_PATTERN.fullmatch(command_text)
create_match = market_create_match or RULE_CREATE_PATTERN.fullmatch(command_text)
update_match = RULE_UPDATE_PATTERN.fullmatch(command_text)
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
delete_match = RULE_DELETE_PATTERN.fullmatch(command_text)
memory = AIMemoryService(db)
if create_match:
return command, _create_rule(memory, create_match, market_create_match is not None, actor)
return (
command,
_create_rule(
memory,
create_match,
market_create_match is not None,
actor,
owner_id,
company_rule,
),
)
if command_text in RULE_LIST_COMMANDS:
return FeishuCommandName.RULE_LIST, _list_rules(memory, command_text)
return (
FeishuCommandName.RULE_LIST,
_list_rules(memory, command_text, owner_id, company_rule),
)
if update_match:
priority = int(update_match.group(2) or 50)
content = update_match.group(3).strip()
if not content:
return FeishuCommandName.RULE_UPDATE, "规则内容不能为空。"
rule = memory.update_rule(
code=update_match.group(1),
content=content,
priority=priority,
tags=None,
enabled=None,
actor=actor,
owner_id=owner_id,
)
return FeishuCommandName.RULE_UPDATE, _rule_state(rule, "已修改")
if disable_match or enable_match:
enabled = enable_match is not None
match = enable_match or disable_match
@@ -108,16 +191,16 @@ def _execute(
tags=None,
enabled=enabled,
actor=actor,
owner_id=owner_id,
)
state = "已启用" if enabled else "已停用"
return (
FeishuCommandName.RULE_ENABLE if enabled else FeishuCommandName.RULE_DISABLE,
f"规则{state}\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
f"状态:{state}",
_rule_state(rule, state),
)
if delete_match:
memory.delete_rule(delete_match.group(1), actor=actor, owner_id=owner_id)
return FeishuCommandName.RULE_DELETE, "规则已删除。"
return command, RULE_COMMAND_HELP
@@ -126,6 +209,8 @@ def _create_rule(
match: re.Match[str],
market_rule: bool,
actor: str,
owner_id: int | None,
company_rule: bool,
) -> str:
priority = int(match.group(1) or 50)
content = match.group(2).strip()
@@ -136,29 +221,39 @@ def _create_rule(
rule = memory.create_rule(
content=content,
scope="market" if market_rule else "global",
subject="market" if market_rule else "company",
subject=(
"market"
if market_rule
else ("company" if company_rule else "personal")
),
priority=priority,
tags=["feishu", *(["market"] if market_rule else [])],
tags=[
"feishu",
"company" if company_rule else "personal",
*(["market"] if market_rule else []),
],
actor=actor,
owner_id=owner_id,
)
return (
"规则已学习。\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
"状态:已启用"
)
return _rule_state(rule, "已学习")
def _list_rules(memory: AIMemoryService, command_text: str) -> str:
def _list_rules(
memory: AIMemoryService,
command_text: str,
owner_id: int | None,
company_rule: bool,
) -> str:
rules = memory.list_rules(
scope="market" if command_text == "查看市场规则" else None,
status_filter=AIMemoryStatus.ACTIVE,
limit=20,
owner_id=owner_id,
)
if not rules:
return "当前没有已启用的学习规则。"
lines = ["当前已启用的学习规则"]
target = "公司" if company_rule else "个人"
return f"当前没有已启用的{target}规则"
lines = ["当前已启用的公司规则:" if company_rule else "当前已启用的个人规则:"]
for rule in rules:
rule_text = str(rule["content"])
if len(rule_text) > 80:
@@ -170,11 +265,25 @@ def _list_rules(memory: AIMemoryService, command_text: str) -> str:
return "\n\n".join(lines)
def _rule_state(rule: dict[str, Any], state: str) -> str:
return (
f"规则{state}\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
f"状态:{state}"
)
def _command_name(command_text: str) -> FeishuCommandName:
if command_text.startswith("停用规则"):
return FeishuCommandName.RULE_DISABLE
if command_text.startswith("启用规则"):
return FeishuCommandName.RULE_ENABLE
if command_text.startswith("修改规则"):
return FeishuCommandName.RULE_UPDATE
if command_text.startswith("删除规则"):
return FeishuCommandName.RULE_DELETE
if command_text.startswith(("查看市场规则", "查看规则", "规则列表")):
return FeishuCommandName.RULE_LIST
return FeishuCommandName.RULE_CREATE

View File

@@ -0,0 +1,321 @@
import re
from datetime import UTC, datetime
from typing import Any
from zoneinfo import ZoneInfo
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.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
ScheduleParseError,
SubscriptionManagementService,
parse_schedule,
)
SUBSCRIPTION_TITLE = "订阅管理"
SUBSCRIPTION_HELP = (
"订阅指令格式:\n"
"订阅 每天 09:00提示词\n"
"订阅 工作日 18:00提示词\n"
"订阅 每周一 09:00提示词\n"
"订阅 每月1号 09:00提示词\n"
"订阅 每隔30分钟提示词\n"
"我的订阅\n"
"暂停订阅 <订阅编号>\n"
"恢复订阅 <订阅编号>\n"
"退订 <订阅编号>\n"
"设置时区 Asia/Shanghai\n"
"设置安静时段 22:00-07:00\n"
"关闭安静时段"
)
_LIST_COMMANDS = {"我的订阅", "查看订阅"}
_PAUSE_PATTERN = re.compile(
r"^(?:暂停订阅|停用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_RESUME_PATTERN = re.compile(
r"^(?:恢复订阅|启用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_CANCEL_PATTERN = re.compile(
r"^(?:退订|取消订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_TIMEZONE_PATTERN = re.compile(r"^设置时区\s+(\S+)$")
_QUIET_PATTERN = re.compile(
r"^设置安静时段\s+(\d{1,2}(?:[:]\d{1,2}))"
r"\s*(?:-|~|至|到)\s*(\d{1,2}(?:[:]\d{1,2}))$"
)
_CLOSE_QUIET_COMMAND = "关闭安静时段"
_COMMAND_PREFIXES = (
"订阅",
"我的订阅",
"查看订阅",
"暂停订阅",
"停用订阅",
"恢复订阅",
"启用订阅",
"退订",
"取消订阅",
"设置时区",
"设置安静时段",
"关闭安静时段",
)
def handle_subscription_command(
db: Session,
feishu: FeishuService,
command_text: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle self-service subscriptions for a verified Feishu principal."""
text = command_text.strip()
if not text.startswith(_COMMAND_PREFIXES):
return None
service = SubscriptionManagementService(db)
command = _command_name(text)
try:
content = _execute(service, text, principal)
except HTTPException as exc:
db.rollback()
content = _error_content(exc)
return _result(feishu, command, content, principal, auto_reply)
def _execute(
service: SubscriptionManagementService,
command_text: str,
principal: FeishuPrincipal,
) -> str:
if command_text.startswith("订阅"):
parts = _create_parts(command_text, principal.timezone)
if parts is None:
return f"无法识别订阅时间或提示词。\n\n{SUBSCRIPTION_HELP}"
schedule_expression, prompt = parts
if principal.chat_type in {"group", "group_chat"}:
subscription, schedule = service.create_group(
principal,
schedule_expression,
prompt,
)
else:
subscription, schedule = service.create_private(
principal,
schedule_expression,
prompt,
)
return (
"订阅已启用。\n"
f"编号:{subscription.code}\n"
f"计划:{schedule.display}\n"
f"时区:{schedule.timezone}\n"
f"下次执行:{_format_next(schedule.next_run_at, schedule.timezone)}\n"
f"暂停命令:暂停订阅 {subscription.code}"
)
if command_text in _LIST_COMMANDS:
return _list_content(
service.list_for_owner(principal),
service.latest_deliveries_for_owner(principal),
)
pause_match = _PAUSE_PATTERN.fullmatch(command_text)
if pause_match:
record = service.pause(principal, pause_match.group(1))
return f"订阅已暂停。\n编号:{record.code}\n恢复命令:恢复订阅 {record.code}"
resume_match = _RESUME_PATTERN.fullmatch(command_text)
if resume_match:
record = service.resume(principal, resume_match.group(1))
return (
"订阅已恢复。\n"
f"编号:{record.code}\n"
f"下次执行:{_format_next(record.next_run_at, record.timezone)}\n"
f"暂停命令:暂停订阅 {record.code}"
)
cancel_match = _CANCEL_PATTERN.fullmatch(command_text)
if cancel_match:
record = service.cancel(principal, cancel_match.group(1))
return f"已退订。\n编号:{record.code}"
timezone_match = _TIMEZONE_PATTERN.fullmatch(command_text)
if timezone_match:
owner = service.set_timezone(principal, timezone_match.group(1))
return f"时区已设置为 {owner.timezone}"
quiet_match = _QUIET_PATTERN.fullmatch(command_text)
if quiet_match:
owner = service.set_quiet_hours(
principal,
quiet_match.group(1),
quiet_match.group(2),
)
return (
"安静时段已设置。\n"
f"{owner.quiet_hours_start.strftime('%H:%M')}"
f"-{owner.quiet_hours_end.strftime('%H:%M')}"
)
if command_text == _CLOSE_QUIET_COMMAND:
service.clear_quiet_hours(principal)
return "安静时段已关闭。"
return SUBSCRIPTION_HELP
def _create_parts(command_text: str, timezone_name: str) -> tuple[str, str] | None:
payload = command_text.removeprefix("订阅").strip()
separator_indexes = [
index for index, character in enumerate(payload) if character in {":", ""}
]
for index in reversed(separator_indexes):
schedule_expression = payload[:index].strip()
prompt = payload[index + 1 :].strip()
if not schedule_expression or not prompt:
continue
try:
parse_schedule(schedule_expression, timezone_name)
except ScheduleParseError:
continue
return schedule_expression, prompt
return None
def _list_content(
records: list[PushSubscription],
latest_deliveries: dict[int, PushDelivery],
) -> str:
if not records:
return "当前没有订阅。\n\n" + SUBSCRIPTION_HELP.splitlines()[0]
lines = ["我的订阅:"]
for record in records:
prompt = record.prompt if len(record.prompt) <= 40 else f"{record.prompt[:40]}"
target = (
"私聊"
if record.target_type == SubscriptionTargetType.USER
else "当前群"
)
lines.append(
f"{record.code}{_status_name(record.status)}{target}\n"
f"{_schedule_name(record)}|下次 {_format_next(record.next_run_at, record.timezone)}\n"
f"{prompt}{_delivery_note(latest_deliveries.get(record.id))}"
)
return "\n\n".join(lines)
def _schedule_name(record: PushSubscription) -> str:
config = record.schedule_config
if record.schedule_type == SubscriptionScheduleType.ONCE:
return "单次"
if record.schedule_type == SubscriptionScheduleType.INTERVAL:
return f"每隔 {config['minutes']} 分钟"
clock = f"{int(config['hour']):02d}:{int(config['minute']):02d}"
if record.schedule_type == SubscriptionScheduleType.DAILY:
return f"每天 {clock}"
if record.schedule_type == SubscriptionScheduleType.WEEKDAY:
return f"工作日 {clock}"
if record.schedule_type == SubscriptionScheduleType.WEEKLY:
names = "一二三四五六日"
return f"每周{names[int(config['weekday'])]} {clock}"
return f"每月 {config['day']}{clock}"
def _status_name(status_value: str) -> str:
return {
PushSubscriptionStatus.ACTIVE: "已启用",
PushSubscriptionStatus.PAUSED: "已暂停",
PushSubscriptionStatus.CANCELLED: "已退订",
PushSubscriptionStatus.COMPLETED: "已完成",
}.get(status_value, status_value)
def _delivery_note(delivery: PushDelivery | None) -> str:
if delivery is None:
return ""
if (
delivery.status == PushDeliveryStatus.SKIPPED
and delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
):
return "\n最近投递:因每日最多 96 条限制已跳过"
if delivery.status == PushDeliveryStatus.RETRY:
return "\n最近投递:发送失败,正在按 1/5/15 分钟重试"
if delivery.status == PushDeliveryStatus.FAILED:
return "\n最近投递:重试后仍失败,请联系管理员"
if delivery.status == PushDeliveryStatus.SKIPPED:
return "\n最近投递:因账号、订阅状态或安静时段限制已跳过"
return ""
def _format_next(value: datetime | None, timezone_name: str) -> str:
if value is None:
return ""
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return aware.astimezone(ZoneInfo(timezone_name)).strftime("%Y-%m-%d %H:%M")
def _command_name(command_text: str) -> FeishuCommandName:
if command_text in _LIST_COMMANDS:
return FeishuCommandName.SUBSCRIPTION_LIST
if _PAUSE_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_PAUSE
if _RESUME_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_RESUME
if _CANCEL_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_CANCEL
if _TIMEZONE_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_TIMEZONE
if _QUIET_PATTERN.fullmatch(command_text) or command_text == _CLOSE_QUIET_COMMAND:
return FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
return FeishuCommandName.SUBSCRIPTION_CREATE
def _error_content(exc: HTTPException) -> str:
detail = str(exc.detail)
if exc.status_code == 404:
return "没有找到该订阅,请先发送“我的订阅”确认编号。"
if exc.status_code == 409 and "50" in detail:
return "已达到最多 50 个启用订阅,请先暂停或退订现有订阅。"
if exc.status_code == 403:
return "当前飞书账号无权执行该订阅操作。"
return f"订阅指令未执行:{detail}\n\n{SUBSCRIPTION_HELP}"
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,
SUBSCRIPTION_TITLE,
content,
response,
)