from __future__ import annotations import json import time from typing import Annotated, Any, List, Optional, Protocol, Union from fastapi import Depends, Request from starlette.responses import PlainTextResponse from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore from app.schemas.message import MessageClearData as _SchemaMessageClearData from app.schemas.message import MessageClearScope as _SchemaMessageClearScope from app.schemas.message import MessageHistoryItem as _SchemaMessageHistoryItem from app.schemas.message import Subscription as _SchemaSubscription from app.schemas.message import SubscriptionMessage as _SchemaSubscriptionMessage from app.schemas.message import WebMessageItem as _SchemaWebMessageItem from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.chain.message import MessageChain from app.runtime.config import global_vars from app.adapters.web.security.access import verify_token, verify_apitoken from app.api.principal import ApiPrincipal from app.application.configuration import ( get_api_runtime_config_snapshot, get_configured_system_config, ) from app.api.dependencies.agent import get_message_query_service from app.api.dependencies.auth import get_current_active_superuser from app.application.messaging.message import MessageQueryService from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.schemas.types import NotificationChannel, SystemConfigKey from app.api.context import get_background_task_registry, resolve_background_task_registry from app.runtime.tasks import TaskRegistry router = ResponseAPIRouter() _WNS_DEFAULT_TTL = 86400 class WebPushError(Protocol): """Web Push 订阅状态判断所需的最小异常协议。""" response: Any # 推送服务响应,状态码字段由具体 SDK 提供 def is_webpush_subscription_gone(error: WebPushError) -> bool: """判断 Web Push 订阅是否已在浏览器或推送服务侧失效。""" response: Any = getattr(error, "response", None) status_code = getattr(response, "status_code", None) or getattr( response, "status", None, ) return status_code in {404, 410} def is_wns_endpoint(endpoint: str | None) -> bool: """判断是否为 Microsoft WNS 推送端点。""" return bool(endpoint and "notify.windows.com" in endpoint) def webpush_options_for_endpoint(endpoint: str | None) -> dict[str, Any]: """返回指定推送端点需要的 pywebpush 附加参数。""" if not is_wns_endpoint(endpoint): return {} return { "ttl": _WNS_DEFAULT_TTL, "headers": {"X-WNS-Cache-Policy": "cache"}, } def _normalize_notification_clear_timestamp(value: Any) -> int: """ 规范化通知清理时间戳。 """ try: normalized_value = int(value or 0) except (TypeError, ValueError): return 0 return normalized_value if normalized_value > 0 else 0 def _get_notification_clear_before() -> _SchemaMessageClearBefore: """ 读取通知中心清理时间配置。 """ value = get_configured_system_config().get(SystemConfigKey.NotificationClearBefore) if isinstance(value, dict): return _SchemaMessageClearBefore( all=_normalize_notification_clear_timestamp(value.get("all")), system=_normalize_notification_clear_timestamp(value.get("system")), media=_normalize_notification_clear_timestamp(value.get("media")), ) return _SchemaMessageClearBefore( all=_normalize_notification_clear_timestamp(value), ) def _format_notification_clear_time(value: int) -> Optional[str]: """ 将清理时间戳转换为消息表使用的时间字符串。 """ if not value: return None timestamp = value / 1000 if value > 10000000000 else value return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) def start_message_chain(body: Any, form: Any, args: Any): """ 启动链式任务 """ MessageChain().process(body=body, form=form, args=args) @router.post("/", summary="接收用户消息", response_model=_SchemaResponse[None]) async def user_message( task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)], request: Request, _: _SchemaTokenPayload = Depends(verify_apitoken), ): """ 用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名 """ body = await request.body() form = await request.form() args = request.query_params source = args.get("source") content_type = request.headers.get("content-type", "") body_text = body.decode("utf-8", errors="replace") image_markers = [ marker for marker in ( '"photo"', '"document"', '"files"', '"attachments"', '"url_private"', '"image/"', '"image_url"', ) if marker in body_text ] logger.info( "消息入口收到请求: source=%s, content_type=%s, body_bytes=%s, form_keys=%s, image_markers=%s", source, content_type, len(body), list(form.keys()) if form else [], image_markers, ) resolve_background_task_registry(task_registry).create_sync( start_message_chain, body, form, args, owner="api.message.user" ) return _SchemaResponse(success=True) @router.post("/web", summary="接收WEB消息", response_model=_SchemaResponse[None]) async def web_message( request: Request, text: Optional[str] = None, current_user: ApiPrincipal = Depends(get_current_active_superuser), ): """ WEB消息响应 """ images = None content_type = request.headers.get("content-type", "") if "application/json" in content_type: try: payload = await request.json() except Exception: payload = None if isinstance(payload, dict): text = payload.get("text", text) image = payload.get("image") images = payload.get("images") if image: if isinstance(images, list): images = [*images, image] else: images = [image] elif isinstance(images, str): images = [images] MessageChain().handle_message( channel=NotificationChannel.Web, source=current_user.name, userid=current_user.name, username=current_user.name, text=text or "", images=images, ) return _SchemaResponse(success=True) @router.get("/web", summary="获取WEB消息", response_model=List[_SchemaWebMessageItem]) async def get_web_message( _: _SchemaTokenPayload = Depends(verify_token), service: MessageQueryService = Depends(get_message_query_service), page: Optional[int] = 1, count: Optional[int] = 20, ): """ 获取WEB消息列表 """ return await service.list_web(page=page, count=count) @router.get("/notification", summary="获取通知消息", response_model=List[_SchemaMessageHistoryItem]) async def get_notification_message( _: _SchemaTokenPayload = Depends(verify_token), service: MessageQueryService = Depends(get_message_query_service), page: Optional[int] = 1, count: Optional[int] = 20, ): """ 获取系统发送的通知消息列表。 """ clear_before = _get_notification_clear_before() messages = await service.list_notifications( page=page, count=count, all_clear_before=_format_notification_clear_time(clear_before.all), system_clear_before=_format_notification_clear_time(clear_before.system), media_clear_before=_format_notification_clear_time(clear_before.media), ) return [_SchemaMessageHistoryItem(**message) for message in messages] @router.delete( "/notification", summary="清理通知消息", response_model=_SchemaResponse[_SchemaMessageClearData], ) async def clear_notification_message( scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All, _: _SchemaTokenPayload = Depends(verify_token), ): """ 记录通知中心清理时间,后续通知历史查询会在服务端过滤。 """ clear_before = _get_notification_clear_before() value = clear_before.model_dump() value[scope.value] = int(time.time() * 1000) await get_configured_system_config().async_set(SystemConfigKey.NotificationClearBefore, value) return _SchemaResponse(success=True, data={"clear_before": value}) def wechat_verify( echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str, source: Optional[str] = None, ) -> Any: """ 微信验证响应 """ # 获取服务配置 client_configs = ServiceConfigHelper.get_notification_configs() if not client_configs: return "未找到对应的消息配置" client_config = next( ( config for config in client_configs if config.type == "wechat" and config.enabled and config.config.get("WECHAT_MODE", "app") != "bot" and (not source or config.name == source) ), None, ) if not client_config: return "未找到对应的消息配置" try: wxcpt = WXBizMsgCrypt( sToken=client_config.config.get("WECHAT_TOKEN"), sEncodingAESKey=client_config.config.get("WECHAT_ENCODING_AESKEY"), sReceiveId=client_config.config.get("WECHAT_CORPID"), ) ret, sEchoStr = wxcpt.VerifyURL( sMsgSignature=msg_signature, sTimeStamp=timestamp, sNonce=nonce, sEchoStr=echostr, ) if ret == 0: # 验证URL成功,将sEchoStr返回给企业号 return PlainTextResponse(sEchoStr) return "微信验证失败" except Exception as err: logger.error(f"微信请求验证失败: {str(err)}") return str(err) def vocechat_verify() -> Any: """ VoceChat验证响应 """ return {"status": "OK"} @router.get( "/", summary="回调请求验证", response_model=None, responses={ 200: { "description": "消息平台原生验证响应", "content": { "text/plain": {"schema": {"type": "string"}}, "application/json": { "schema": { "type": "object", "properties": {"status": {"type": "string"}}, } }, }, } }, ) def incoming_verify( token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None, timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_apitoken), ) -> Any: """ 微信/VoceChat等验证响应 """ logger.info( f"收到验证请求: token={token}, echostr={echostr}, " f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}" ) if echostr and msg_signature and timestamp and nonce: return wechat_verify(echostr, msg_signature, timestamp, nonce, source) return vocechat_verify() @router.post( "/webpush/subscribe", summary="客户端webpush通知订阅", response_model=_SchemaResponse[None], ) async def subscribe( subscription: _SchemaSubscription, _: _SchemaTokenPayload = Depends(verify_token) ): """ 客户端webpush通知订阅 """ subinfo = subscription.model_dump() global_vars.push_subscription(subinfo) logger.debug(f"通知订阅成功: {subinfo}") return _SchemaResponse(success=True) @router.post( "/webpush/send", summary="发送webpush通知", response_model=_SchemaResponse[None] ) def send_notification( payload: _SchemaSubscriptionMessage, _: _SchemaTokenPayload = Depends(verify_token), ): """ 发送webpush通知 """ from pywebpush import WebPushException, webpush for sub in global_vars.get_subscriptions(): try: webpush( subscription_info=sub, data=json.dumps(payload.model_dump()), vapid_private_key=get_api_runtime_config_snapshot().vapid_private_key, vapid_claims={"sub": get_api_runtime_config_snapshot().vapid_subject}, **webpush_options_for_endpoint(sub.get("endpoint")), ) except WebPushException as err: logger.error(f"WebPush发送失败: {str(err)}") if is_webpush_subscription_gone(err) and global_vars.remove_subscription(sub): logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}") continue return _SchemaResponse(success=True)