1
0
forked from erp-dev/erp

feat: message-api for mission via wecomm agent

This commit is contained in:
2026-05-14 09:30:38 +08:00
parent 9359013b4c
commit f409f2e6ee
38 changed files with 2085 additions and 49 deletions

View File

@@ -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
View 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 []

View File

@@ -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='事件标识'),
),
]

View File

@@ -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 {}

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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]:

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View 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 }}"
}

View 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 }}"
}

View 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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 %}"
}

View File

@@ -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 }}"
}

View File

@@ -0,0 +1,3 @@
{
"msgtype": "text"
}

View File

@@ -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"
}

View File

@@ -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("任务ID12", 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")

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.