feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
322 lines
11 KiB
Python
322 lines
11 KiB
Python
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,
|
||
)
|