import logging import json from api_v1.utils.wecom_webhook import send_wecom_webhook_message from notifier.jpush import send_jpush_payload from notifier.models import NotifierChannelEnum from notifier.message_api import send_message_api_news_to_agents, send_message_api_text_message from notifier.serializers import ( MessageNewsRequestSerializer, MessageTemplatePayloadSerializer, MessageTextRequestSerializer, ) logger = logging.getLogger(__name__) class BaseNotifierBackend: channel = "" def notify(self, *, notifier, content: str, context: dict) -> dict: raise NotImplementedError class WeComWebhookNotifierBackend(BaseNotifierBackend): channel = NotifierChannelEnum.WECOM_WEBHOOK def notify(self, *, notifier, content: str, context: dict) -> dict: content = (content or "").strip() if not content: raise ValueError("企业微信机器人消息 content 不能为空") msgtype = str(notifier.get_config_value("msgtype", "markdown") or "markdown").strip().lower() timeout_seconds = float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0) key = str(notifier.get_config_value("key", "") or "").strip() response = send_wecom_webhook_message( content=content, msgtype=msgtype, key=key or None, timeout_seconds=timeout_seconds, ) if not response.ok: raise RuntimeError(f"WeCom webhook 返回失败: errcode={response.errcode}, errmsg={response.errmsg}") result = { "channel": self.channel, "msgtype": msgtype, "errcode": response.errcode, "errmsg": response.errmsg, } logger.info("[notifier.backends] wecom webhook sent: notifier_id=%s result=%s", notifier.id, result) return result class MessageAPINotifierBackend(BaseNotifierBackend): channel = NotifierChannelEnum.MESSAGE_API def _load_rendered_payload(self, *, content: str) -> dict: content = (content or "").strip() if not content: raise ValueError("消息发送 API 模板渲染结果不能为空") try: payload = json.loads(content) except json.JSONDecodeError as exc: raise ValueError(f"消息发送 API 模板渲染结果不是合法 JSON: {exc}") from exc if not isinstance(payload, dict): raise ValueError("消息发送 API 模板渲染结果必须是 JSON 对象") serializer = MessageTemplatePayloadSerializer(data=payload) serializer.is_valid(raise_exception=True) return serializer.validated_data def notify(self, *, notifier, content: str, context: dict) -> dict: rendered_payload = self._load_rendered_payload(content=content) msgtype = rendered_payload["msgtype"] if msgtype == "text": payload = { "agent_id": rendered_payload.get("agent_id", notifier.get_config_value("agent_id")), "content": rendered_payload["content"], } resolved_user_ids = rendered_payload.get("user_ids") or notifier.get_config_value("user_ids") if resolved_user_ids: payload["user_ids"] = resolved_user_ids serializer = MessageTextRequestSerializer(data=payload) serializer.is_valid(raise_exception=True) data = serializer.validated_data response = send_message_api_text_message( agent_id=data["agent_id"], content=data["content"], user_ids=data.get("user_ids"), timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), ) result = { "channel": self.channel, "msgtype": "text", "agent_id": data["agent_id"], "errcode": response.errcode, "errmsg": response.errmsg, } logger.info("[notifier.backends] message api text sent: notifier_id=%s result=%s", notifier.id, result) return result payload = { "agent_ids": rendered_payload.get("agent_ids", notifier.get_config_value("agent_ids")), "title": rendered_payload["title"], "description": rendered_payload["description"], "url": rendered_payload["url"], "image_url": rendered_payload["image_url"], } resolved_user_ids = rendered_payload.get("user_ids") or notifier.get_config_value("user_ids") if resolved_user_ids: payload["user_ids"] = resolved_user_ids serializer = MessageNewsRequestSerializer(data=payload) serializer.is_valid(raise_exception=True) data = serializer.validated_data results = send_message_api_news_to_agents( agent_ids=data["agent_ids"], title=data["title"], description=data["description"], url=data["url"], image_url=data["image_url"], user_ids=data.get("user_ids"), timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), ) if not any(item.get("ok") for item in results): raise RuntimeError("All agent sends failed") result = { "channel": self.channel, "msgtype": "news", "agent_ids": data["agent_ids"], "ok_count": sum(1 for item in results if item.get("ok")), "total_count": len(results), "results": results, } logger.info("[notifier.backends] message api news sent: notifier_id=%s result=%s", notifier.id, result) return result class JPushNotifierBackend(BaseNotifierBackend): channel = NotifierChannelEnum.JPUSH def _load_rendered_payload(self, *, content: str) -> dict: content = (content or "").strip() if not content: raise ValueError("极光推送模板渲染结果不能为空") try: payload = json.loads(content) except json.JSONDecodeError as exc: raise ValueError(f"极光推送模板渲染结果不是合法 JSON: {exc}") from exc if not isinstance(payload, dict): raise ValueError("极光推送模板渲染结果必须是 JSON 对象") return payload def _resolve_aliases(self, *, notifier, rendered_payload: dict, context: dict) -> list[str]: aliases = rendered_payload.get("aliases") or notifier.get_config_value("aliases") if aliases: return self._normalize_aliases(aliases) recipient_source = str( rendered_payload.get("recipient_source") or notifier.get_config_value("recipient_source", "participants") or "participants" ) employee_ids = [] if recipient_source == "participants": employee_ids = context.get("participant_ids") or [] elif recipient_source == "creator": employee_ids = [context.get("creator_id")] elif recipient_source == "participants_and_creator": employee_ids = [*(context.get("participant_ids") or []), context.get("creator_id")] else: raise ValueError(f"不支持的极光接收人来源: {recipient_source}") alias_prefix = str(notifier.get_config_value("alias_prefix", "emp_") or "emp_") resolved = [] for employee_id in employee_ids: if employee_id is None or employee_id == "": continue resolved.append(f"{alias_prefix}{int(employee_id)}") return self._normalize_aliases(resolved) def _normalize_aliases(self, values) -> list[str]: aliases = [] for value in values or []: alias = str(value or "").strip() if not alias: raise ValueError("极光 aliases 中不能包含空值") if len(alias.encode("utf-8")) > 40: raise ValueError(f"极光 alias 超过 40 字节限制: {alias}") aliases.append(alias) aliases = list(dict.fromkeys(aliases)) if not aliases: raise ValueError("极光推送接收 alias 不能为空") if len(aliases) > 1000: raise ValueError("极光单次推送 alias 不能超过 1000 个") return aliases def _build_payload(self, *, notifier, rendered_payload: dict, context: dict) -> dict: aliases = self._resolve_aliases( notifier=notifier, rendered_payload=rendered_payload, context=context, ) platform = rendered_payload.get("platform") or notifier.get_config_value("platform", "all") title = str(rendered_payload.get("title") or notifier.get_config_value("title", "任务提醒")).strip() alert = str(rendered_payload.get("alert") or rendered_payload.get("content") or "").strip() if not alert: raise ValueError("极光推送 alert 不能为空") extras = rendered_payload.get("extras") or {} if not isinstance(extras, dict): raise ValueError("极光推送 extras 必须是 JSON 对象") notification = { "alert": alert, "android": { "alert": alert, "title": title, "extras": extras, }, "ios": { "alert": alert, "sound": rendered_payload.get("ios_sound") or notifier.get_config_value("ios_sound", "default"), "extras": extras, }, } android_channel_id = rendered_payload.get("android_channel_id") or notifier.get_config_value("android_channel_id") if android_channel_id: notification["android"]["channel_id"] = str(android_channel_id) android_intent = rendered_payload.get("android_intent") or notifier.get_config_value("android_intent") if android_intent: notification["android"]["intent"] = {"url": str(android_intent)} android_category = rendered_payload.get("android_category") or notifier.get_config_value("android_category") if android_category: notification["android"]["category"] = str(android_category) payload = { "platform": platform, "audience": {"alias": aliases}, "notification": notification, "options": { "apns_production": bool(notifier.get_config_value("apns_production", False)), }, } time_to_live = rendered_payload.get("time_to_live", notifier.get_config_value("time_to_live")) if time_to_live is not None: payload["options"]["time_to_live"] = int(time_to_live) message = rendered_payload.get("message") if message: if not isinstance(message, dict): raise ValueError("极光推送 message 必须是 JSON 对象") payload["message"] = message return payload def notify(self, *, notifier, content: str, context: dict) -> dict: rendered_payload = self._load_rendered_payload(content=content) payload = self._build_payload( notifier=notifier, rendered_payload=rendered_payload, context=context, ) response = send_jpush_payload( app_key=notifier.get_config_value("app_key"), master_secret=notifier.get_config_value("master_secret"), payload=payload, timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), ) result = { "channel": self.channel, "aliases": payload["audience"]["alias"], "msg_id": response.raw.get("msg_id"), "sendno": response.raw.get("sendno"), "raw": response.raw, } logger.info("[notifier.backends] jpush sent: notifier_id=%s result=%s", notifier.id, result) return result