auto_send_wx_msg.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/04
  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/11 从 PC 版微信(wxauto)迁到企业微信群机器人;新增 send_wechat_group_file 发文件能力。
  12. """
  13. import os
  14. import re
  15. import json
  16. import requests
  17. from loguru import logger
  18. # 企业微信群机器人 Webhook 地址(key 为群机器人凭证,按需替换;当前为测试群)
  19. # WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b8d398e2-f27e-42ce-af78-336867460122" # 测试
  20. WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fc5619c2-5699-485f-bed1-a5fdc79ca513"
  21. # 素材上传接口:发文件/图片前先把素材传上去换 media_id(type=file/voice;文件 5B~20MB,media_id 有效期 3 天)
  22. UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={key}&type={media_type}"
  23. FILE_MIN_BYTES = 5 # 企微限制:文件不得小于 5 字节
  24. FILE_MAX_BYTES = 20 * 1024 * 1024 # 企微限制:文件不得大于 20MB
  25. def _extract_key(webhook_url: str) -> str | None:
  26. """从群机器人 Webhook 地址里抽出 key(上传素材接口要单独拼 key)。
  27. Args:
  28. webhook_url (str): 形如 ...webhook/send?key=xxxx 的 Webhook 地址。
  29. Returns:
  30. str | None: 抽到的 key;地址不含 key 时返回 None。
  31. """
  32. m = re.search(r"key=([0-9a-fA-F\-]+)", webhook_url)
  33. return m.group(1) if m else None
  34. def build_markdown_content(items: list, title: str) -> str:
  35. """把列表拼成 markdown 文案(每条编号,条间加分割线)。
  36. Args:
  37. items (list): 元素为字符串或 (名称, 链接) 元组。
  38. title (str): 文案标题(渲染为四级标题)。
  39. Returns:
  40. str: 拼好的 markdown 文本。
  41. """
  42. md_content = f"#### {title}\n"
  43. for i, item in enumerate(items, 1):
  44. if isinstance(item, tuple) and len(item) == 2:
  45. name, link = item
  46. md_content += f"{i}. [{name}]({link})\n"
  47. elif isinstance(item, str):
  48. md_content += f"{i}. {item}\n"
  49. else:
  50. md_content += f"{i}. {str(item)}\n"
  51. if i < len(items):
  52. md_content += "\n---\n\n"
  53. return md_content
  54. def build_text_content(items: list) -> str:
  55. """把列表拼成纯文本文案(每条编号,条间加分割线)。
  56. Args:
  57. items (list): 元素为字符串或 (名称, 链接) 元组。
  58. Returns:
  59. str: 拼好的纯文本。
  60. """
  61. content = ""
  62. for i, item in enumerate(items, 1):
  63. if isinstance(item, tuple) and len(item) == 2:
  64. name, link = item
  65. content += f"{i}. {name}: {link}\n"
  66. elif isinstance(item, str):
  67. content += f"{i}. {item}\n"
  68. else:
  69. content += f"{i}. {str(item)}\n"
  70. if i < len(items):
  71. content += "----------------------------------\n"
  72. return content
  73. def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
  74. msg_type="markdown", title="🚀 提醒通知") -> dict | None:
  75. """发送文本 / markdown_v2 消息到企业微信群机器人。
  76. Args:
  77. log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
  78. items (list, optional): 消息条目,元素为字符串或 (名称, 链接) 元组。Defaults to None。
  79. mentioned_list (list, optional): text 类型下 @ 的成员手机号/@all 列表。Defaults to None。
  80. msg_type (str, optional): 消息类型 text / markdown。Defaults to "markdown"。
  81. title (str, optional): markdown 文案标题。Defaults to "🚀 提醒通知"。
  82. Returns:
  83. dict | None: 企微返回的 JSON;发送失败返回 None。
  84. """
  85. if items is None:
  86. items = []
  87. if log is None:
  88. log = logger
  89. headers = {"Content-Type": "application/json"}
  90. if msg_type == "text":
  91. data = {
  92. "msgtype": "text",
  93. "text": {
  94. "content": build_text_content(items),
  95. "mentioned_list": mentioned_list if mentioned_list else [],
  96. },
  97. }
  98. else: # 默认 markdown_v2
  99. data = {
  100. "msgtype": "markdown_v2",
  101. "markdown_v2": {"content": build_markdown_content(items, title)},
  102. }
  103. try:
  104. log.info(f"正在发送企微消息: {title}")
  105. resp = requests.post(WEBHOOK_URL, headers=headers,
  106. data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
  107. timeout=(5, 30))
  108. resp.raise_for_status()
  109. result = resp.json()
  110. if result.get("errcode") not in (0, None): # 企微业务错误码非 0 也算失败
  111. log.error(f"企微消息发送失败: {result}")
  112. return None
  113. log.success("企微消息发送成功")
  114. return result
  115. except requests.exceptions.RequestException as e:
  116. log.error(f"企微消息发送失败: {e}")
  117. return None
  118. def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
  119. """把本地文件上传到企微群机器人素材接口,换取 media_id。
  120. Args:
  121. log (loguru.Logger): 日志对象。
  122. file_path (str): 本地文件绝对/相对路径。
  123. media_type (str, optional): 素材类型 file / voice。Defaults to "file"。
  124. Returns:
  125. str | None: 上传成功返回 media_id(有效期 3 天);文件不存在/超限/上传失败返回 None。
  126. """
  127. key = _extract_key(WEBHOOK_URL)
  128. if not key:
  129. log.error("Webhook 地址里没解析到 key,无法上传素材")
  130. return None
  131. size = os.path.getsize(file_path)
  132. if not (FILE_MIN_BYTES <= size <= FILE_MAX_BYTES): # 企微限制 5B~20MB
  133. log.error(f"文件大小 {size} 字节超出企微限制(5B~20MB):{file_path}")
  134. return None
  135. url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
  136. try:
  137. with open(file_path, "rb") as f:
  138. # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
  139. files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
  140. resp = requests.post(url, files=files, timeout=(5, 60))
  141. resp.raise_for_status()
  142. result = resp.json()
  143. if result.get("errcode") != 0:
  144. log.error(f"企微素材上传失败: {result}")
  145. return None
  146. return result.get("media_id")
  147. except requests.exceptions.RequestException as e:
  148. log.error(f"企微素材上传异常: {e}")
  149. return None
  150. def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
  151. """发送一个本地文件到企业微信群机器人(自动先上传素材换 media_id 再发 file 消息)。
  152. Args:
  153. log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
  154. file_path (str, optional): 待发送文件路径(如 Excel 报表)。Defaults to None。
  155. Returns:
  156. dict | None: 企微返回的 JSON;文件不存在/上传失败/发送失败返回 None。
  157. """
  158. if log is None:
  159. log = logger
  160. if not file_path or not os.path.isfile(file_path):
  161. log.error(f"待发送文件不存在: {file_path}")
  162. return None
  163. media_id = _upload_media(log, file_path, "file")
  164. if not media_id:
  165. return None
  166. data = {"msgtype": "file", "file": {"media_id": media_id}}
  167. try:
  168. log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
  169. resp = requests.post(WEBHOOK_URL, headers={"Content-Type": "application/json"},
  170. data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
  171. resp.raise_for_status()
  172. result = resp.json()
  173. if result.get("errcode") != 0:
  174. log.error(f"企微文件发送失败: {result}")
  175. return None
  176. log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
  177. return result
  178. except requests.exceptions.RequestException as e:
  179. log.error(f"企微文件发送失败: {e}")
  180. return None
  181. if __name__ == "__main__":
  182. # 自测:发送一条示例 markdown 消息
  183. import sys
  184. logger.remove()
  185. logger.add(sys.stderr, level="INFO")
  186. send_wechat_group_msg(
  187. items=["示例商品A(单价¥199,100份)", "示例商品B(单价¥299,50份)"],
  188. title="🧪 企微机器人自测",
  189. )