auto_send_wx_msg.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/18
  5. """企业微信群机器人通用发送模块(文本 / markdown_v2 / 文件)。
  6. 对外入口:
  7. - send_wechat_group_msg :发文本或 markdown_v2 文案,items 元素可为字符串或 (名称, 链接) 元组。
  8. - send_wechat_group_file:发文件(先 upload_media 拿 media_id,再发 file 消息),供报表脚本发 Excel。
  9. 作为工具库被其它脚本 import,不在模块级配置 loguru sink,日志默认落到调用方的 logger。
  10. 变更记录:
  11. 2026/08/18 从 deca 项目照搬到集物星球项目;WEBHOOK_URL 待替换为本项目专用群机器人。
  12. 2026/08/27 上传/发送增加 tenacity 重试,专治 TLS 瞬断(UNEXPECTED_EOF_WHILE_READING)导致的偶发发送失败。
  13. """
  14. import os
  15. import re
  16. import json
  17. import requests
  18. from loguru import logger
  19. from tenacity import (retry, stop_after_attempt, wait_exponential,
  20. retry_if_exception_type)
  21. # 企业微信群机器人 Webhook 地址(key 为群机器人凭证)
  22. # 当前为集物星球专用测试群机器人;上线换正式群时只改这一处
  23. WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b8d398e2-f27e-42ce-af78-336867460122"
  24. # WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=2d41b34f-8e30-479b-a39d-331f1d5c233f"
  25. # 素材上传接口:发文件/图片前先把素材传上去换 media_id(type=file/voice;文件 5B~20MB,media_id 有效期 3 天)
  26. UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={key}&type={media_type}"
  27. FILE_MIN_BYTES = 5 # 企微限制:文件不得小于 5 字节
  28. FILE_MAX_BYTES = 20 * 1024 * 1024 # 企微限制:文件不得大于 20MB
  29. # ---- 重试配置:仅对网络/TLS 类瞬断重试,业务错误码(errcode!=0)不重试 ----
  30. RETRY_TIMES = 4 # 最多尝试次数(含首次)
  31. RETRY_WAIT_MIN = 2 # 退避最小间隔(秒)
  32. RETRY_WAIT_MAX = 8 # 退避最大间隔(秒),间隔按 2/4/8 递增
  33. def after_log(retry_state):
  34. """tenacity 重试回调,记录每次尝试的结果。
  35. 约定:被 @retry 装饰的函数首个位置参数为 log(日志对象),本回调据此取用。
  36. Args:
  37. retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
  38. """
  39. log = retry_state.args[0] if retry_state.args else logger
  40. if retry_state.outcome.failed:
  41. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  42. else:
  43. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  44. @retry(stop=stop_after_attempt(RETRY_TIMES),
  45. wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
  46. retry=retry_if_exception_type(requests.exceptions.RequestException),
  47. after=after_log, reraise=True)
  48. def _do_upload_media(log, url: str, file_path: str) -> requests.Response:
  49. """执行素材上传的内部函数,带重试;每次尝试都重新打开文件。
  50. 重点:multipart 上传的文件流被读过一次就到 EOF,重试必须重新 open 文件,
  51. 否则第二次会传空体。故把 open 放进被重试的函数体内,保证每次从头读。
  52. Args:
  53. log: 日志对象(首参约定,供 after_log 取用)。
  54. url (str): upload_media 接口地址(已含 key 与 type)。
  55. file_path (str): 待上传文件路径。
  56. Returns:
  57. requests.Response: HTTP 状态正常的响应对象。
  58. Raises:
  59. requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
  60. """
  61. with open(file_path, "rb") as f:
  62. # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
  63. files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
  64. resp = requests.post(url, files=files, timeout=(5, 60))
  65. resp.raise_for_status() # HTTP 非 2xx 抛 HTTPError(属 RequestException),触发重试
  66. return resp
  67. @retry(stop=stop_after_attempt(RETRY_TIMES),
  68. wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
  69. retry=retry_if_exception_type(requests.exceptions.RequestException),
  70. after=after_log, reraise=True)
  71. def _post_json_with_retry(log, url: str, **kwargs) -> requests.Response:
  72. """带重试的 JSON POST(发文本 / 文件消息共用)。
  73. JSON 请求体是 bytes,可安全重试;仅网络/TLS 类异常触发重试,业务错误码由调用方判断。
  74. Args:
  75. log: 日志对象(首参约定,供 after_log 取用)。
  76. url (str): 请求地址。
  77. **kwargs: 透传给 requests.post 的参数(headers / data / timeout 等)。
  78. Returns:
  79. requests.Response: HTTP 状态正常的响应对象。
  80. Raises:
  81. requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
  82. """
  83. resp = requests.post(url, **kwargs)
  84. resp.raise_for_status()
  85. return resp
  86. def _extract_key(webhook_url: str) -> str | None:
  87. """从群机器人 Webhook 地址里抽出 key(上传素材接口要单独拼 key)。
  88. Args:
  89. webhook_url (str): 形如 ...webhook/send?key=xxxx 的 Webhook 地址。
  90. Returns:
  91. str | None: 抽到的 key;地址不含 key 时返回 None。
  92. """
  93. m = re.search(r"key=([0-9a-fA-F\-]+)", webhook_url)
  94. return m.group(1) if m else None
  95. def build_markdown_content(items: list, title: str) -> str:
  96. """把列表拼成 markdown 文案(每条编号,条间加分割线)。
  97. Args:
  98. items (list): 元素为字符串或 (名称, 链接) 元组。
  99. title (str): 文案标题(渲染为四级标题)。
  100. Returns:
  101. str: 拼好的 markdown 文本。
  102. """
  103. md_content = f"#### {title}\n"
  104. for i, item in enumerate(items, 1):
  105. if isinstance(item, tuple) and len(item) == 2:
  106. name, link = item
  107. md_content += f"{i}. [{name}]({link})\n"
  108. elif isinstance(item, str):
  109. md_content += f"{i}. {item}\n"
  110. else:
  111. md_content += f"{i}. {str(item)}\n"
  112. if i < len(items):
  113. md_content += "\n---\n\n"
  114. return md_content
  115. def build_text_content(items: list) -> str:
  116. """把列表拼成纯文本文案(每条编号,条间加分割线)。
  117. Args:
  118. items (list): 元素为字符串或 (名称, 链接) 元组。
  119. Returns:
  120. str: 拼好的纯文本。
  121. """
  122. content = ""
  123. for i, item in enumerate(items, 1):
  124. if isinstance(item, tuple) and len(item) == 2:
  125. name, link = item
  126. content += f"{i}. {name}: {link}\n"
  127. elif isinstance(item, str):
  128. content += f"{i}. {item}\n"
  129. else:
  130. content += f"{i}. {str(item)}\n"
  131. if i < len(items):
  132. content += "----------------------------------\n"
  133. return content
  134. def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
  135. msg_type="markdown", title="🚀 提醒通知") -> dict | None:
  136. """发送文本 / markdown_v2 消息到企业微信群机器人。
  137. Args:
  138. log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
  139. items (list, optional): 消息条目,元素为字符串或 (名称, 链接) 元组。Defaults to None。
  140. mentioned_list (list, optional): text 类型下 @ 的成员手机号/@all 列表。Defaults to None。
  141. msg_type (str, optional): 消息类型 text / markdown。Defaults to "markdown"。
  142. title (str, optional): markdown 文案标题。Defaults to "🚀 提醒通知"。
  143. Returns:
  144. dict | None: 企微返回的 JSON;发送失败返回 None。
  145. """
  146. if items is None:
  147. items = []
  148. if log is None:
  149. log = logger
  150. headers = {"Content-Type": "application/json"}
  151. if msg_type == "text":
  152. data = {
  153. "msgtype": "text",
  154. "text": {
  155. "content": build_text_content(items),
  156. "mentioned_list": mentioned_list if mentioned_list else [],
  157. },
  158. }
  159. else: # 默认 markdown_v2
  160. data = {
  161. "msgtype": "markdown_v2",
  162. "markdown_v2": {"content": build_markdown_content(items, title)},
  163. }
  164. try:
  165. log.info(f"正在发送企微消息: {title}")
  166. resp = _post_json_with_retry(log, WEBHOOK_URL, headers=headers,
  167. data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
  168. timeout=(5, 30))
  169. result = resp.json()
  170. if result.get("errcode") not in (0, None): # 企微业务错误码非 0 也算失败
  171. log.error(f"企微消息发送失败: {result}")
  172. return None
  173. log.success("企微消息发送成功")
  174. return result
  175. except requests.exceptions.RequestException as e:
  176. log.error(f"企微消息发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
  177. return None
  178. def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
  179. """把本地文件上传到企微群机器人素材接口,换取 media_id。
  180. Args:
  181. log (loguru.Logger): 日志对象。
  182. file_path (str): 本地文件绝对/相对路径。
  183. media_type (str, optional): 素材类型 file / voice。Defaults to "file"。
  184. Returns:
  185. str | None: 上传成功返回 media_id(有效期 3 天);文件不存在/超限/上传失败返回 None。
  186. """
  187. key = _extract_key(WEBHOOK_URL)
  188. if not key:
  189. log.error("Webhook 地址里没解析到 key,无法上传素材")
  190. return None
  191. size = os.path.getsize(file_path)
  192. if not (FILE_MIN_BYTES <= size <= FILE_MAX_BYTES): # 企微限制 5B~20MB
  193. log.error(f"文件大小 {size} 字节超出企微限制(5B~20MB):{file_path}")
  194. return None
  195. url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
  196. try:
  197. resp = _do_upload_media(log, url, file_path) # 带重试上传,内部每次重新打开文件
  198. result = resp.json()
  199. if result.get("errcode") != 0: # 业务错误(如 key 失效/文件超限),重试无意义,直接失败
  200. log.error(f"企微素材上传失败: {result}")
  201. return None
  202. return result.get("media_id")
  203. except requests.exceptions.RequestException as e:
  204. log.error(f"企微素材上传异常(重试{RETRY_TIMES}次后仍失败): {e}")
  205. return None
  206. def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
  207. """发送一个本地文件到企业微信群机器人(自动先上传素材换 media_id 再发 file 消息)。
  208. Args:
  209. log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
  210. file_path (str, optional): 待发送文件路径(如 Excel 报表)。Defaults to None。
  211. Returns:
  212. dict | None: 企微返回的 JSON;文件不存在/上传失败/发送失败返回 None。
  213. """
  214. if log is None:
  215. log = logger
  216. if not file_path or not os.path.isfile(file_path):
  217. log.error(f"待发送文件不存在: {file_path}")
  218. return None
  219. media_id = _upload_media(log, file_path, "file")
  220. if not media_id:
  221. return None
  222. data = {"msgtype": "file", "file": {"media_id": media_id}}
  223. try:
  224. log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
  225. resp = _post_json_with_retry(log, WEBHOOK_URL, headers={"Content-Type": "application/json"},
  226. data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
  227. result = resp.json()
  228. if result.get("errcode") != 0:
  229. log.error(f"企微文件发送失败: {result}")
  230. return None
  231. log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
  232. return result
  233. except requests.exceptions.RequestException as e:
  234. log.error(f"企微文件发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
  235. return None
  236. if __name__ == "__main__":
  237. # 自测:发送一条示例 markdown 消息
  238. import sys
  239. logger.remove()
  240. logger.add(sys.stderr, level="INFO")
  241. send_wechat_group_msg(
  242. items=["示例商品A(单价¥199,100份)", "示例商品B(单价¥299,50份)"],
  243. title="🧪 集物星球企微机器人自测",
  244. )