```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
192
app/application/feishu/handlers/admin.py
Normal file
192
app/application/feishu/handlers/admin.py
Normal 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,
|
||||
)
|
||||
@@ -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 = (
|
||||
|
||||
267
app/application/feishu/handlers/personal_data.py
Normal file
267
app/application/feishu/handlers/personal_data.py
Normal 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,
|
||||
)
|
||||
300
app/application/feishu/handlers/personalization.py
Normal file
300
app/application/feishu/handlers/personalization.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
321
app/application/feishu/handlers/subscriptions.py
Normal file
321
app/application/feishu/handlers/subscriptions.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user