import json
import os
from typing import Any, Dict, Mapping, Optional, Sequence, Union
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
try:
from ..logger import get_logger
except ImportError:
from esptest.logger import get_logger
logger = get_logger(__name__)
WECOM_WEBHOOK_ENV = 'WECOM_WEBHOOK_URL'
WECOM_MARKDOWN_MAX_BYTES = 4096
TRUNCATION_SUFFIX = '\n\n> 内容过长,已截断'
def _get_webhook_url(webhook_url: Optional[str]) -> str:
if isinstance(webhook_url, str) and webhook_url.strip():
return webhook_url.strip()
return os.getenv(WECOM_WEBHOOK_ENV, '').strip()
def _is_valid_url(url: str) -> bool:
parsed_url = urlparse(url)
return bool(parsed_url.scheme and parsed_url.netloc)
def _truncate_utf8(content: str, max_bytes: int = WECOM_MARKDOWN_MAX_BYTES) -> str:
content_bytes = content.encode('utf-8')
if len(content_bytes) <= max_bytes:
return content
suffix_bytes = TRUNCATION_SUFFIX.encode('utf-8')
max_content_bytes_len = max_bytes - len(suffix_bytes)
if max_content_bytes_len <= 0:
return TRUNCATION_SUFFIX.encode('utf-8')[:max_bytes].decode('utf-8', errors='ignore')
return content_bytes[:max_content_bytes_len].decode('utf-8', errors='ignore') + TRUNCATION_SUFFIX
def _normalize_mentions(mentions: Optional[Union[str, Sequence[str]]]) -> Sequence[str]:
if mentions is None:
return []
if isinstance(mentions, str):
return [mention.strip() for mention in mentions.split(',') if mention.strip()]
return [mention.strip() for mention in mentions if mention.strip()]
def _append_markdown_mentions(content: str, mentions: Optional[Union[str, Sequence[str]]]) -> str:
mention_list = _normalize_mentions(mentions)
if not mention_list:
return content
return f'{content}\n\n{" ".join(f"<@{mention}>" for mention in mention_list)}'
[docs]
def build_text_payload(
content: str,
mentioned_list: Optional[Sequence[str]] = None,
mentioned_mobile_list: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
text: Dict[str, Any] = {'content': content}
if mentioned_list is not None:
text['mentioned_list'] = list(mentioned_list)
if mentioned_mobile_list is not None:
text['mentioned_mobile_list'] = list(mentioned_mobile_list)
return {'msgtype': 'text', 'text': text}
[docs]
def build_markdown_payload(content: str, msgtype: str = 'markdown') -> Dict[str, Any]:
if msgtype not in ('markdown', 'markdown_v2'):
raise ValueError('msgtype must be "markdown" or "markdown_v2"')
return {'msgtype': msgtype, msgtype: {'content': _truncate_utf8(content)}}
[docs]
def build_image_payload(base64_content: str, md5: str) -> Dict[str, Any]:
return {'msgtype': 'image', 'image': {'base64': base64_content, 'md5': md5}}
[docs]
def build_news_payload(articles: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
return {'msgtype': 'news', 'news': {'articles': [dict(article) for article in articles]}}
[docs]
def build_file_payload(media_id: str) -> Dict[str, Any]:
return {'msgtype': 'file', 'file': {'media_id': media_id}}
[docs]
def build_voice_payload(media_id: str) -> Dict[str, Any]:
return {'msgtype': 'voice', 'voice': {'media_id': media_id}}
[docs]
def build_template_card_payload(template_card: Mapping[str, Any]) -> Dict[str, Any]:
return {'msgtype': 'template_card', 'template_card': dict(template_card)}
def _response_status_code(response: Any) -> int:
status_code = getattr(response, 'status', None)
if status_code is None:
status_code = response.getcode()
return int(status_code)
[docs]
def send_wecom_message(
message: Union[str, Mapping[str, Any]],
webhook_url: Optional[str] = None,
msgtype: str = 'markdown',
mentions: Optional[Union[str, Sequence[str]]] = None,
timeout: float = 10,
) -> bool:
"""Send a WeCom group robot message.
String messages are sent as markdown by default. Mapping messages are sent
as already-built WeCom payloads.
"""
webhook_url = _get_webhook_url(webhook_url)
if not webhook_url or not _is_valid_url(webhook_url):
logger.warning('WeCom notification failed: invalid webhook URL configured.')
return False
if isinstance(message, str):
if not message.strip():
return False
if msgtype == 'text':
payload = build_text_payload(message, mentioned_list=_normalize_mentions(mentions) or None)
else:
payload = build_markdown_payload(_append_markdown_mentions(message, mentions), msgtype=msgtype)
else:
payload = dict(message)
request = Request(
webhook_url,
data=json.dumps(payload, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json; charset=utf-8'},
method='POST',
)
try:
with urlopen(request, timeout=timeout) as response:
status_code = _response_status_code(response)
if not 200 <= status_code < 300:
logger.warning('WeCom notification failed: HTTP status %s', status_code)
return False
response_body = response.read().decode('utf-8')
response_json = json.loads(response_body)
errcode = response_json.get('errcode')
if errcode != 0:
logger.warning(
'WeCom notification failed: errcode=%s, errmsg=%s',
errcode,
response_json.get('errmsg'),
)
return False
return True
except HTTPError as e:
logger.warning('WeCom notification failed: HTTP status %s', e.code)
except (URLError, OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning('WeCom notification failed: %s', type(e).__name__)
except Exception as e: # pylint: disable=broad-except
logger.warning('WeCom notification failed: unexpected %s', type(e).__name__)
return False