forked from erp-dev/erp
feat: message-api for mission via wecomm agent
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
|
||||
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__)
|
||||
|
||||
@@ -17,6 +24,9 @@ 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()
|
||||
@@ -36,3 +46,89 @@ class WeComWebhookNotifierBackend(BaseNotifierBackend):
|
||||
}
|
||||
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
|
||||
|
||||
132
notifier/message_api.py
Normal file
132
notifier/message_api.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageAPIResponse:
|
||||
errcode: int
|
||||
errmsg: str
|
||||
raw: dict
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return int(self.errcode) == 0
|
||||
|
||||
|
||||
def _request_json(
|
||||
*,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
headers: dict | None = None,
|
||||
) -> dict:
|
||||
data = None
|
||||
request_headers = dict(headers or {})
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request_headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
req = Request(url=url, data=data, headers=request_headers, method=method)
|
||||
try:
|
||||
with urlopen(req, timeout=float(timeout_seconds)) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
except HTTPError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Message API HTTPError: status={exc.code}, body={body}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"Message API URLError: {exc}") from exc
|
||||
|
||||
try:
|
||||
return json.loads(body) if body else {}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Message API 响应不是合法 JSON: {body}") from exc
|
||||
|
||||
|
||||
def _get_message_api_base_url() -> str:
|
||||
base_url = str(getattr(settings, "MESSAGE_API_BASE_URL", "") or "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise ValueError("MESSAGE_API_BASE_URL 未配置")
|
||||
return base_url
|
||||
|
||||
|
||||
def _get_message_api_authorization() -> str:
|
||||
authorization = str(getattr(settings, "MESSAGE_API_AUTHORIZATION", "") or "").strip()
|
||||
if not authorization:
|
||||
raise ValueError("MESSAGE_API_AUTHORIZATION 未配置")
|
||||
return authorization
|
||||
|
||||
|
||||
def _build_url(path: str) -> str:
|
||||
return urljoin(f"{_get_message_api_base_url()}/", path.lstrip("/"))
|
||||
|
||||
|
||||
def _post_message_api(*, path: str, payload: dict, timeout_seconds: float) -> dict:
|
||||
raw = _request_json(
|
||||
url=_build_url(path),
|
||||
method="POST",
|
||||
payload=payload,
|
||||
timeout_seconds=timeout_seconds,
|
||||
headers={
|
||||
"Authorization": _get_message_api_authorization(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def send_message_api_text_message(
|
||||
*,
|
||||
agent_id: int,
|
||||
content: str,
|
||||
user_ids: list[str] | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> MessageAPIResponse:
|
||||
payload = {
|
||||
"agent_id": int(agent_id),
|
||||
"content": str(content or "").strip(),
|
||||
}
|
||||
if user_ids:
|
||||
payload["user_ids"] = user_ids
|
||||
|
||||
raw = _post_message_api(path="/api/message/send", payload=payload, timeout_seconds=timeout_seconds)
|
||||
return MessageAPIResponse(
|
||||
errcode=int(raw.get("errcode") or 0),
|
||||
errmsg=str(raw.get("errmsg") or ""),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
|
||||
def send_message_api_news_to_agents(
|
||||
*,
|
||||
agent_ids: list[int],
|
||||
title: str,
|
||||
description: str,
|
||||
url: str,
|
||||
image_url: str,
|
||||
user_ids: list[str] | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> list[dict]:
|
||||
payload = {
|
||||
"agent_ids": [int(agent_id) for agent_id in agent_ids],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"image_url": image_url,
|
||||
}
|
||||
if user_ids:
|
||||
payload["user_ids"] = user_ids
|
||||
|
||||
raw = _post_message_api(path="/api/message/send/news", payload=payload, timeout_seconds=timeout_seconds)
|
||||
return raw.get("results") or []
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-13 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifier', '0002_notifierroute_refactor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notifier',
|
||||
name='channel',
|
||||
field=models.CharField(choices=[('wecom_webhook', '企业微信机器人'), ('message_api', '消息发送 API')], default='wecom_webhook', max_length=50, verbose_name='通知渠道'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notifierroute',
|
||||
name='event_key',
|
||||
field=models.CharField(choices=[('mission.created', '任务已创建'), ('mission.replied', '任务有新回应'), ('mission.completed', '任务已完成'), ('mission.unreplied', '任务未回复提醒'), ('mission.reply_rejected', '任务回应已撤销'), ('mission.reopened', '任务已重新打开'), ('mission.cancelled', '任务已取消')], db_index=True, max_length=100, verbose_name='事件标识'),
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from flower.common import ModelBase
|
||||
|
||||
class NotifierChannelEnum(models.TextChoices):
|
||||
WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人"
|
||||
MESSAGE_API = "message_api", "消息发送 API"
|
||||
|
||||
|
||||
class NotificationEventKeyEnum(models.TextChoices):
|
||||
@@ -40,7 +41,8 @@ class Notifier(ModelBase):
|
||||
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
|
||||
|
||||
def get_template_name(self) -> str:
|
||||
return f"notifier/events/{self.template_key}.md"
|
||||
suffix = "json" if self.channel == NotifierChannelEnum.MESSAGE_API else "md"
|
||||
return f"notifier/events/{self.template_key}.{suffix}"
|
||||
|
||||
def get_config_value(self, key: str, default=None):
|
||||
config = self.config or {}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from notifier.backends import WeComWebhookNotifierBackend
|
||||
from notifier.backends import MessageAPINotifierBackend, WeComWebhookNotifierBackend
|
||||
from notifier.models import NotifierChannelEnum
|
||||
|
||||
|
||||
BACKEND_REGISTRY = {
|
||||
NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend,
|
||||
NotifierChannelEnum.MESSAGE_API: MessageAPINotifierBackend,
|
||||
}
|
||||
|
||||
|
||||
|
||||
99
notifier/serializers.py
Normal file
99
notifier/serializers.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
def _normalize_user_ids(values: list[str] | None) -> list[str]:
|
||||
normalized = []
|
||||
for value in values or []:
|
||||
item = str(value or "").strip()
|
||||
if not item:
|
||||
raise serializers.ValidationError("user_ids 中不能包含空值")
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
class MessageTextRequestSerializer(serializers.Serializer):
|
||||
agent_id = serializers.IntegerField(min_value=1)
|
||||
content = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||||
user_ids = serializers.ListField(
|
||||
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
def validate_content(self, value: str) -> str:
|
||||
content = (value or "").strip()
|
||||
if not content:
|
||||
raise serializers.ValidationError("content 不能为空")
|
||||
if len(content.encode("utf-8")) > 2048:
|
||||
raise serializers.ValidationError("content 不能超过 2048 字节")
|
||||
return content
|
||||
|
||||
def validate_user_ids(self, value):
|
||||
return _normalize_user_ids(value)
|
||||
|
||||
|
||||
class MessageNewsRequestSerializer(serializers.Serializer):
|
||||
agent_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
)
|
||||
title = serializers.CharField(allow_blank=False, trim_whitespace=True, max_length=128)
|
||||
description = serializers.CharField(allow_blank=False, trim_whitespace=True, max_length=512)
|
||||
url = serializers.URLField(allow_blank=False)
|
||||
image_url = serializers.URLField(allow_blank=False)
|
||||
user_ids = serializers.ListField(
|
||||
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
def validate_agent_ids(self, value):
|
||||
return list(dict.fromkeys(value or []))
|
||||
|
||||
def validate_user_ids(self, value):
|
||||
return _normalize_user_ids(value)
|
||||
|
||||
|
||||
class MessageTemplatePayloadSerializer(serializers.Serializer):
|
||||
msgtype = serializers.ChoiceField(choices=["text", "news"])
|
||||
agent_id = serializers.IntegerField(min_value=1, required=False)
|
||||
agent_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
content = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True)
|
||||
title = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True, max_length=128)
|
||||
description = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True, max_length=512)
|
||||
url = serializers.URLField(required=False)
|
||||
image_url = serializers.URLField(required=False)
|
||||
user_ids = serializers.ListField(
|
||||
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
def validate_user_ids(self, value):
|
||||
return _normalize_user_ids(value)
|
||||
|
||||
def validate(self, attrs):
|
||||
msgtype = attrs["msgtype"]
|
||||
if msgtype == "text":
|
||||
if not attrs.get("content"):
|
||||
raise serializers.ValidationError({"content": "text 类型必须提供 content"})
|
||||
if "agent_ids" in attrs:
|
||||
raise serializers.ValidationError({"agent_ids": "text 类型不支持 agent_ids"})
|
||||
return attrs
|
||||
|
||||
required = ["title", "description", "url", "image_url"]
|
||||
errors = {}
|
||||
for field in required:
|
||||
if not attrs.get(field):
|
||||
errors[field] = f"news 类型必须提供 {field}"
|
||||
if "agent_id" in attrs:
|
||||
errors["agent_id"] = "news 类型不支持 agent_id"
|
||||
if "content" in attrs:
|
||||
errors["content"] = "news 类型不支持 content"
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
return attrs
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
@@ -10,7 +11,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def render_notification_content(*, notifier: Notifier, payload: dict) -> str:
|
||||
content = render_to_string(notifier.get_template_name(), payload or {}).strip()
|
||||
context = {
|
||||
**(payload or {}),
|
||||
"notifier_config": notifier.config or {},
|
||||
"notifier_config_url": notifier.get_config_value("url", "") or "",
|
||||
"notifier_config_image_url": notifier.get_config_value("image_url", "") or "",
|
||||
"default_news_image_url": getattr(settings, "MESSAGE_API_DEFAULT_NEWS_IMAGE_URL", "") or "",
|
||||
"notifier": notifier,
|
||||
}
|
||||
content = render_to_string(notifier.get_template_name(), context).strip()
|
||||
if not content:
|
||||
raise ValueError("通知模板渲染结果不能为空")
|
||||
return content
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.db.models import Exists, F, OuterRef
|
||||
from django.utils import timezone
|
||||
|
||||
from mission import models as mission_models
|
||||
from mission.payload_processors import apply_mission_payload_processor
|
||||
from mission import services as mission_services
|
||||
from notifier.models import NotificationEventKeyEnum
|
||||
from notifier.services import dispatch_notification_event
|
||||
@@ -20,7 +21,7 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie
|
||||
)
|
||||
content_type = getattr(mission.content_type, "model", None)
|
||||
next_count = mission.unreplied_notify_sent_count + 1
|
||||
return {
|
||||
payload = {
|
||||
"mission_id": mission.id,
|
||||
"merchant_id": mission.merchant_id,
|
||||
"description": mission.description,
|
||||
@@ -35,11 +36,17 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie
|
||||
"participant_names_display": "、".join(participant_names) if participant_names else "无",
|
||||
"content_type": content_type or "",
|
||||
"content_id": mission.content_id or "",
|
||||
"payload_processor": getattr(mission.category, "payload_processor", "") or "",
|
||||
"unreplied_notify_interval_minutes": mission.unreplied_notify_interval_minutes,
|
||||
"unreplied_notify_max_count": mission.unreplied_notify_max_count,
|
||||
"unreplied_notify_sent_count": next_count,
|
||||
"unreplied_last_notified_at": notified_at.isoformat() if notified_at else "",
|
||||
}
|
||||
return apply_mission_payload_processor(
|
||||
mission=mission,
|
||||
event_key=NotificationEventKeyEnum.MISSION_UNREPLIED,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _get_due_unreplied_mission_ids(*, limit: int) -> list[int]:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务已取消\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n取消人:{{ cancelled_by_name|default:creator_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务已完成\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n完成人:{{ completed_by_name|default:responder_name|default:creator_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}"
|
||||
}
|
||||
4
notifier/templates/notifier/events/mission_created.json
Normal file
4
notifier/templates/notifier/events/mission_created.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务已创建\n任务ID:{{ mission_id }}\n创建人:{{ created_by_name|default:creator_name|escapejs }}\n分类:{{ category_name|escapejs }}\n紧急:{% if is_urgent %}是{% else %}否{% endif %}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}"
|
||||
}
|
||||
4
notifier/templates/notifier/events/mission_reopened.json
Normal file
4
notifier/templates/notifier/events/mission_reopened.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务已重新打开\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n操作人:{{ reopened_by_name|escapejs }}\n撤销回应:{{ rejected_reply_ids_display|escapejs }}\n说明:{{ description|escapejs }}"
|
||||
}
|
||||
4
notifier/templates/notifier/events/mission_replied.json
Normal file
4
notifier/templates/notifier/events/mission_replied.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务有新回应\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n回应人:{{ responder_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ reply_content_short|default:description|escapejs }}"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务回应已撤销\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n操作人:{{ rejected_by_name|escapejs }}\n原因:{{ reason|escapejs }}\n回应:{{ reply_content_short|escapejs }}"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"msgtype": "news",
|
||||
"title": "{{ parsed_description_title|default:description|truncatechars:128|escapejs }}",
|
||||
"description": "{{ parsed_description_body|default:parsed_description_title|default:description|truncatechars:512|escapejs }}",
|
||||
"url": "{{ parsed_description_url|default:notifier_config_url|escapejs }}",
|
||||
"image_url": "{{ parsed_description_image_url|default:notifier_config_image_url|default:default_news_image_url|escapejs }}"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务通知\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n发起人:{{ creator_name|escapejs }}\n标题:{{ parsed_description_title|default:description|escapejs }}{% if parsed_description_body %}\n\n正文:{{ parsed_description_body|escapejs }}{% endif %}{% if parsed_description_url %}\n\n链接:{{ parsed_description_url|escapejs }}{% endif %}"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"msgtype": "text",
|
||||
"content": "任务未回复提醒\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n提醒间隔:{{ unreplied_notify_interval_minutes }} 分钟\n提醒次数:{{ unreplied_notify_sent_count }}/{{ unreplied_notify_max_count }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"msgtype": "text"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"msgtype": "news",
|
||||
"title": "任务 {{ mission_id }} 通知",
|
||||
"description": "{{ description|escapejs }}",
|
||||
"url": "https://example.com/missions/{{ mission_id }}",
|
||||
"image_url": "https://example.com/static/mission-cover.png"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
@@ -52,6 +53,103 @@ class NotifierServiceTestCase(TestCase):
|
||||
self.assertIn("任务ID:12", content)
|
||||
self.assertIn("创建人:张三", content)
|
||||
|
||||
def test_render_notification_content_with_structured_description_template(self):
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="结构化描述通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_structured_description_text",
|
||||
config={"agent_id": 1000007},
|
||||
)
|
||||
|
||||
content = render_notification_content(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"description": "原始描述",
|
||||
"parsed_description_title": "标题一\n标题二",
|
||||
"parsed_description_body": "正文一\n正文二",
|
||||
"parsed_description_url": "https://example.com/detail",
|
||||
"parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg",
|
||||
},
|
||||
)
|
||||
|
||||
rendered_payload = json.loads(content)
|
||||
self.assertEqual(rendered_payload["msgtype"], "text")
|
||||
self.assertIn("标题:标题一\n标题二", rendered_payload["content"])
|
||||
self.assertIn("正文:正文一\n正文二", rendered_payload["content"])
|
||||
self.assertIn("链接:https://example.com/detail", rendered_payload["content"])
|
||||
|
||||
def test_render_notification_content_with_structured_description_news_template(self):
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="结构化描述图文通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_structured_description_news",
|
||||
config={
|
||||
"agent_ids": [1000007],
|
||||
"image_url": "https://cdn.example.com/covers/mission-news.png",
|
||||
"url": "https://erp.example.com/missions/fallback",
|
||||
},
|
||||
)
|
||||
|
||||
content = render_notification_content(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"description": "原始描述",
|
||||
"parsed_description_title": "标题一\n标题二",
|
||||
"parsed_description_body": "正文一\n正文二",
|
||||
"parsed_description_url": "https://example.com/detail",
|
||||
"parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg",
|
||||
},
|
||||
)
|
||||
|
||||
rendered_payload = json.loads(content)
|
||||
self.assertEqual(rendered_payload["msgtype"], "news")
|
||||
self.assertEqual(rendered_payload["title"], "标题一\n标题二")
|
||||
self.assertEqual(rendered_payload["description"], "正文一\n正文二")
|
||||
self.assertEqual(rendered_payload["url"], "https://example.com/detail")
|
||||
self.assertEqual(rendered_payload["image_url"], "https://images.yuwen.cloud/abc.jpg")
|
||||
|
||||
def test_render_notification_content_with_structured_description_news_template_falls_back_to_default_image(self):
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="结构化描述图文通知-默认图",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_structured_description_news",
|
||||
config={
|
||||
"agent_ids": [1000007],
|
||||
"url": "https://erp.example.com/missions/fallback",
|
||||
},
|
||||
)
|
||||
|
||||
with self.settings(MESSAGE_API_DEFAULT_NEWS_IMAGE_URL="https://via.placeholder.com/640x360.png?text=No+Image"):
|
||||
content = render_notification_content(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"description": "原始描述",
|
||||
"parsed_description_title": "标题一\n标题二",
|
||||
"parsed_description_body": "正文一\n正文二",
|
||||
"parsed_description_url": "",
|
||||
"parsed_description_image_url": "",
|
||||
},
|
||||
)
|
||||
|
||||
rendered_payload = json.loads(content)
|
||||
self.assertEqual(rendered_payload["url"], "https://erp.example.com/missions/fallback")
|
||||
self.assertEqual(
|
||||
rendered_payload["image_url"],
|
||||
"https://via.placeholder.com/640x360.png?text=No+Image",
|
||||
)
|
||||
|
||||
@patch("notifier.backends.send_wecom_webhook_message")
|
||||
def test_send_notification_with_notifier_uses_wecom_backend(self, mock_send):
|
||||
mock_send.return_value.ok = True
|
||||
@@ -252,3 +350,262 @@ class NotifierServiceTestCase(TestCase):
|
||||
|
||||
self.assertEqual(result["sent_count"], 0)
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
@patch("notifier.tasks.dispatch_notification_event")
|
||||
def test_notify_unreplied_missions_task_applies_category_payload_processor(self, mock_dispatch):
|
||||
mock_dispatch.return_value = [{"status": "sent"}]
|
||||
self.general_category.payload_processor = "structured_description_v1"
|
||||
self.general_category.save(update_fields=["payload_processor", "updated_at"])
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="未回复提醒通知器",
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_unreplied",
|
||||
config={"key": "unreplied-key"},
|
||||
)
|
||||
NotifierRoute.objects.create(
|
||||
merchant=self.merchant,
|
||||
notifier=notifier,
|
||||
event_key=NotificationEventKeyEnum.MISSION_UNREPLIED,
|
||||
)
|
||||
mission = Mission.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.general_category,
|
||||
creator=self.creator,
|
||||
description=(
|
||||
"系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n"
|
||||
"手机端链接: https://example.com/detail"
|
||||
),
|
||||
notify_if_unreplied=True,
|
||||
unreplied_notify_interval_minutes=1,
|
||||
)
|
||||
mission.created_at = timezone.now() - timezone.timedelta(minutes=3)
|
||||
mission.save(update_fields=["created_at", "updated_at"])
|
||||
|
||||
result = notify_unreplied_missions_task.run(limit=10)
|
||||
|
||||
self.assertEqual(result["sent_count"], 1)
|
||||
payload = mock_dispatch.call_args.kwargs["payload"]
|
||||
self.assertEqual(payload["payload_processor"], "structured_description_v1")
|
||||
self.assertEqual(payload["parsed_description_title"], "标题一\n标题二")
|
||||
self.assertEqual(payload["parsed_description_body"], "正文一\n正文二")
|
||||
self.assertEqual(payload["parsed_description_url"], "https://example.com/detail")
|
||||
self.assertEqual(payload["parsed_description_image_url"], "https://images.yuwen.cloud/abc.jpg")
|
||||
|
||||
@patch("notifier.backends.send_message_api_text_message")
|
||||
def test_send_notification_with_notifier_uses_message_api_text_backend(self, mock_send):
|
||||
mock_send.return_value.errcode = 0
|
||||
mock_send.return_value.errmsg = "ok"
|
||||
mock_send.return_value.raw = {"errcode": 0, "errmsg": "ok"}
|
||||
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务创建消息 API 通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_created",
|
||||
config={"agent_id": 1000007},
|
||||
)
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"created_by_name": "张三",
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"is_urgent": True,
|
||||
"participant_names_display": "无",
|
||||
"description": "检查打印质量",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["channel"], NotifierChannelEnum.MESSAGE_API)
|
||||
self.assertEqual(result["msgtype"], "text")
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(mock_send.call_args.kwargs["agent_id"], 1000007)
|
||||
self.assertEqual(mock_send.call_args.kwargs["user_ids"], None)
|
||||
self.assertEqual(mock_send.call_args.kwargs["timeout_seconds"], 10.0)
|
||||
self.assertIn("任务已创建", mock_send.call_args.kwargs["content"])
|
||||
|
||||
@patch("notifier.backends.send_message_api_text_message")
|
||||
def test_send_notification_with_notifier_uses_structured_description_template(self, mock_send):
|
||||
mock_send.return_value.errcode = 0
|
||||
mock_send.return_value.errmsg = "ok"
|
||||
mock_send.return_value.raw = {"errcode": 0, "errmsg": "ok"}
|
||||
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="结构化描述消息 API 通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_structured_description_text",
|
||||
config={"agent_id": 1000007},
|
||||
)
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"description": "原始描述",
|
||||
"parsed_description_title": "标题一\n标题二",
|
||||
"parsed_description_body": "正文一\n正文二",
|
||||
"parsed_description_url": "https://example.com/detail",
|
||||
"parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["channel"], NotifierChannelEnum.MESSAGE_API)
|
||||
self.assertEqual(result["msgtype"], "text")
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertIn("标题:标题一\n标题二", mock_send.call_args.kwargs["content"])
|
||||
self.assertIn("正文:正文一\n正文二", mock_send.call_args.kwargs["content"])
|
||||
self.assertIn("链接:https://example.com/detail", mock_send.call_args.kwargs["content"])
|
||||
|
||||
@patch("notifier.backends.send_message_api_news_to_agents")
|
||||
def test_send_notification_with_notifier_uses_structured_description_news_template(self, mock_send):
|
||||
mock_send.return_value = [
|
||||
{
|
||||
"agent_id": 1000007,
|
||||
"ok": True,
|
||||
"response": {"errcode": 0, "errmsg": "ok"},
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="结构化描述图文消息 API 通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="mission_structured_description_news",
|
||||
config={
|
||||
"agent_ids": [1000007],
|
||||
"image_url": "https://cdn.example.com/covers/mission-news.png",
|
||||
},
|
||||
)
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"description": "原始描述",
|
||||
"parsed_description_title": "标题一\n标题二",
|
||||
"parsed_description_body": "正文一\n正文二",
|
||||
"parsed_description_url": "https://example.com/detail",
|
||||
"parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["msgtype"], "news")
|
||||
mock_send.assert_called_once()
|
||||
self.assertEqual(mock_send.call_args.kwargs["agent_ids"], [1000007])
|
||||
self.assertEqual(mock_send.call_args.kwargs["title"], "标题一\n标题二")
|
||||
self.assertEqual(mock_send.call_args.kwargs["description"], "正文一\n正文二")
|
||||
self.assertEqual(mock_send.call_args.kwargs["url"], "https://example.com/detail")
|
||||
self.assertEqual(
|
||||
mock_send.call_args.kwargs["image_url"],
|
||||
"https://images.yuwen.cloud/abc.jpg",
|
||||
)
|
||||
|
||||
@patch("notifier.backends.send_message_api_news_to_agents")
|
||||
def test_send_notification_with_notifier_uses_message_api_news_backend(self, mock_send):
|
||||
mock_send.return_value = [
|
||||
{
|
||||
"agent_id": 1000007,
|
||||
"ok": True,
|
||||
"response": {"errcode": 0, "errmsg": "ok"},
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务创建图文通知",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="test_message_news",
|
||||
config={"agent_ids": [1000007]},
|
||||
)
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"description": "检查打印质量",
|
||||
"category_name": "通用",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["msgtype"], "news")
|
||||
self.assertEqual(result["ok_count"], 1)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test_send_notification_with_notifier_rejects_invalid_message_api_template(self):
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="非法消息模板",
|
||||
channel=NotifierChannelEnum.MESSAGE_API,
|
||||
template_key="test_message_invalid",
|
||||
config={"agent_id": 1000007},
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(Exception, "content"):
|
||||
send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={"mission_id": 12},
|
||||
)
|
||||
class MessageAPIServiceTestCase(TestCase):
|
||||
@patch("notifier.message_api.urlopen")
|
||||
def test_send_message_api_text_message_calls_message_api_endpoint(self, mock_urlopen):
|
||||
from notifier.message_api import send_message_api_text_message
|
||||
|
||||
response_obj = MagicMock()
|
||||
response_obj.read.return_value = b'{"errcode":0,"errmsg":"ok"}'
|
||||
response_obj.__enter__.return_value = response_obj
|
||||
response_obj.__exit__.return_value = False
|
||||
|
||||
mock_urlopen.return_value = response_obj
|
||||
|
||||
with self.settings(
|
||||
MESSAGE_API_BASE_URL="http://message-api.internal:8198",
|
||||
MESSAGE_API_AUTHORIZATION="secret-1",
|
||||
):
|
||||
response = send_message_api_text_message(agent_id=1000007, content="hello")
|
||||
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(response.errcode, 0)
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
request = mock_urlopen.call_args.args[0]
|
||||
self.assertEqual(request.full_url, "http://message-api.internal:8198/api/message/send")
|
||||
self.assertEqual(request.get_method(), "POST")
|
||||
self.assertEqual(request.get_header("Authorization"), "secret-1")
|
||||
|
||||
@patch("notifier.message_api.urlopen")
|
||||
def test_send_message_api_news_to_agents_calls_message_api_endpoint(self, mock_urlopen):
|
||||
from notifier.message_api import send_message_api_news_to_agents
|
||||
|
||||
response_obj = MagicMock()
|
||||
response_obj.read.return_value = (
|
||||
b'{"results":[{"agent_id":1000007,"ok":true,"response":{"errcode":0,"errmsg":"ok"},"error":null}]}'
|
||||
)
|
||||
response_obj.__enter__.return_value = response_obj
|
||||
response_obj.__exit__.return_value = False
|
||||
mock_urlopen.return_value = response_obj
|
||||
|
||||
with self.settings(
|
||||
MESSAGE_API_BASE_URL="http://message-api.internal:8198",
|
||||
MESSAGE_API_AUTHORIZATION="secret-1",
|
||||
):
|
||||
results = send_message_api_news_to_agents(
|
||||
agent_ids=[1000007],
|
||||
title="销售日报",
|
||||
description="点击查看今日各区域销售汇总",
|
||||
url="https://example.com/reports/daily-sales",
|
||||
image_url="https://example.com/static/daily-sales-cover.png",
|
||||
)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
request = mock_urlopen.call_args.args[0]
|
||||
self.assertEqual(request.full_url, "http://message-api.internal:8198/api/message/send/news")
|
||||
self.assertEqual(request.get_header("Authorization"), "secret-1")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user