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