Files
bazarr/libs/apprise/plugins/wechat.py
2026-06-20 00:29:12 -04:00

650 lines
23 KiB
Python

#
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# WeCom (WeChat Work / Enterprise WeChat) Application Message API.
#
# This plugin sends notifications directly to users, departments, or
# tags within a WeCom enterprise organisation using the WeCom
# Application API. No third-party intermediary is required.
#
# Setup:
# 1. Sign in to the WeCom admin console at
# https://work.weixin.qq.com/wework_admin/frame#/index
# 2. Go to "Applications & Mini Programs" -> "Applications" and
# create or select a self-built application.
# 3. Copy the AgentID shown on the application details page.
# 4. Go to "My Enterprise" -> "Enterprise Information" and note
# the CorpID.
# 5. On the application page click "View" next to Secret and copy it.
#
# Basic Apprise URL forms:
#
# Send to all users in the organisation:
# wechat://{corpid}:{corpsecret}@{agentid}/@all
#
# Send to specific user(s) by WeCom user ID (@ prefix optional
# on input, always emitted in generated URLs):
# wechat://{corpid}:{corpsecret}@{agentid}/@{userid}
# wechat://{corpid}:{corpsecret}@{agentid}/@{user1}/@{user2}
#
# Send to a department by its numeric ID (# encoded as %23 in URLs):
# wechat://{corpid}:{corpsecret}@{agentid}/%23{deptid}
#
# Send to a tag by its numeric ID (prefix with +):
# wechat://{corpid}:{corpsecret}@{agentid}/+{tagid}
#
# Mixed recipients:
# wechat://{corpid}:{corpsecret}@{agentid}/@{user}/%23{dept}/+{tag}
#
# Notes:
# - # must be URL-encoded as %23 in URL paths to avoid being
# treated as a fragment separator.
# - At least one recipient (user, department, or tag) is required.
# - Use @all as a user target to send to the entire organisation.
# - Use ?to= to supply comma-separated extra targets as a query
# parameter alternative.
#
# API References:
# https://developer.work.weixin.qq.com/document/path/90235
# https://developer.work.weixin.qq.com/document/path/90236
import json
import re
import requests
from ..common import NotifyFormat, NotifyType, PersistentStoreMode
from ..locale import gettext_lazy as _
from ..utils.parse import parse_list, validate_regex
from .base import NotifyBase
# WeCom API error code map.
# Reference: https://developer.work.weixin.qq.com/document/path/90313
WECHAT_ERROR_CODES = {
0: "Request succeeded.",
40001: "Invalid credential or access token expired.",
40003: "Invalid user ID.",
40014: "Invalid access token.",
42001: "Access token expired.",
48001: "API not authorized; check application permissions.",
60020: "IP address not in the allowlist.",
81013: "All recipients are invalid or unauthorized.",
}
# Error codes that indicate the cached access token must be discarded
# so the next send() call will fetch a fresh one.
WECHAT_TOKEN_ERROR_CODES = frozenset((40001, 40014, 42001))
# Validates a WeCom user ID. The optional leading @ is stripped by
# the regex so both "johndoe" and "@johndoe" are accepted. The
# special @all broadcast keyword is matched as "all"; the caller
# re-attaches the @ before storing. url() always emits the @ prefix.
IS_USER = re.compile(
r"^@?(?P<user>[A-Za-z0-9][A-Za-z0-9_@.\-]*)$",
)
# Validates a department target: # prefix followed by a numeric ID.
# The # will arrive already decoded from %23 by the URL path parser.
IS_DEPT = re.compile(r"^#(?P<dept>[0-9]+)$")
# Validates a tag target: + prefix followed by a numeric ID.
IS_TAG = re.compile(r"^\+(?P<tag>[0-9]+)$")
class NotifyWeChat(NotifyBase):
"""A wrapper for WeCom (WeChat Work) Application Notifications."""
# The default descriptive name associated with the Notification
service_name = "WeChat (WeCom)"
# The services URL
service_url = "https://work.weixin.qq.com/"
# The default secure protocol
secure_protocol = "wechat"
# A URL that takes you to the setup/help of the specific protocol
setup_url = "https://appriseit.com/services/wechat/"
# WeCom access token endpoint (GET request)
token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
# WeCom message send endpoint (POST); access_token is a query param
notify_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
# WeCom text and markdown bodies are limited to 2048 bytes
body_maxlen = 2048
# WeCom application messages have no native separate title field
title_maxlen = 0
# Default to plain text; markdown is also natively supported
notify_format = NotifyFormat.TEXT
# Enable the persistent store so the access token survives across
# plugin instantiations and process restarts
storage_mode = PersistentStoreMode.AUTO
# Cache the access token for slightly less than its 2-hour lifetime
# to avoid racing the expiry boundary
default_cache_expiry_sec = 7200 - 300 # 1 h 55 min
# URL templates
templates = (
# No path targets: recipients supplied via ?to= or @all
"{schema}://{user}:{password}@{host}",
# One or more targets in the URL path
"{schema}://{user}:{password}@{host}/{targets}",
)
# Define our template tokens
template_tokens = dict(
NotifyBase.template_tokens,
**{
# Corp ID sits in the URL user field
"user": {
"name": _("Corp ID"),
"type": "string",
"required": True,
"map_to": "corpid",
},
# App Secret sits in the URL password field
"password": {
"name": _("App Secret"),
"type": "string",
"private": True,
"required": True,
"map_to": "corpsecret",
},
# Agent ID sits in the URL host field; always numeric
"host": {
"name": _("Agent ID"),
"type": "string",
"required": True,
"regex": (r"^[0-9]+$", ""),
"map_to": "agentid",
},
# A WeCom user ID (@ prefix optional on input,
# always emitted by url())
"target_user": {
"name": _("Target User"),
"type": "string",
"prefix": "@",
"map_to": "targets",
},
# A WeCom department ID (numeric; prefix with #)
"target_department": {
"name": _("Target Department"),
"type": "string",
"prefix": "#",
"map_to": "targets",
},
# A WeCom tag ID (numeric; prefix with +)
"target_tag": {
"name": _("Target Tag"),
"type": "string",
"prefix": "+",
"map_to": "targets",
},
"targets": {
"name": _("Targets"),
"type": "list:string",
},
},
)
# Define our template arguments
template_args = dict(
NotifyBase.template_args,
**{
# ?to= is the standard Apprise comma-separated recipient alias
"to": {
"alias_of": "targets",
},
},
)
def __init__(self, corpid, corpsecret, agentid, targets=None, **kwargs):
"""Initialize WeChat (WeCom Application) Object."""
super().__init__(**kwargs)
# Validate the Corp ID
self.corpid = validate_regex(corpid)
if not self.corpid:
msg = "A WeChat (WeCom) Corp ID must be specified."
self.logger.warning(msg)
raise TypeError(msg)
# Validate the App Secret
self.corpsecret = validate_regex(corpsecret)
if not self.corpsecret:
msg = "A WeChat (WeCom) App Secret must be specified."
self.logger.warning(msg)
raise TypeError(msg)
# Validate the Agent ID (must be a non-negative integer string)
self.agentid = validate_regex(
str(agentid) if agentid is not None else "",
*self.template_tokens["host"]["regex"],
)
if not self.agentid:
msg = (
"The WeChat (WeCom) Agent ID ({}) is invalid;"
" it must be a non-negative integer.".format(agentid)
)
self.logger.warning(msg)
raise TypeError(msg)
# Parsed and validated recipient lists
self.users = []
self.departments = []
self.tag_ids = []
# Preserve unrecognised targets for round-trip URL fidelity
self.invalid_targets = []
# Classify each entry from the targets list
for target in parse_list(targets):
# Department ID: # prefix + numeric
result = IS_DEPT.match(target)
if result:
self.departments.append(result.group("dept"))
continue
# Tag ID: + prefix + numeric
result = IS_TAG.match(target)
if result:
self.tag_ids.append(result.group("tag"))
continue
# User ID or @all broadcast.
# The regex strips the optional leading @ so both "johndoe"
# and "@johndoe" match. "all" (captured from "@all" or bare
# "all") is re-normalised to "@all" for the WeCom API.
result = IS_USER.match(target)
if result:
user = result.group("user")
self.users.append("@all" if user == "all" else user)
continue
# Unrecognised -- log and preserve for round-trip
self.logger.warning(
"Invalid/unrecognized WeChat (WeCom) target preserved"
" for round-trip fidelity: %s",
target,
)
self.invalid_targets.append(target)
return
def _get_access_token(self):
"""Return the cached WeCom access token, fetching a fresh one
if the cache is empty or the token has expired."""
# Check the persistent store for a cached token
token = self.store.get("access_token")
if token:
# Return the cached token
return token
# Build the query parameters for the token request
params = {
"corpid": self.corpid,
"corpsecret": self.corpsecret,
}
self.logger.debug(
"WeChat (WeCom) token GET URL: %s (cert_verify=%r)",
self.token_url,
self.verify_certificate,
)
# Always throttle before any remote server I/O
self.throttle()
try:
r = requests.get(
self.token_url,
params=params,
verify=self.verify_certificate,
timeout=self.request_timeout,
allow_redirects=self.redirects,
)
if r.status_code != requests.codes.ok:
# HTTP-level failure
status_str = NotifyWeChat.http_response_code_lookup(
r.status_code
)
self.logger.warning(
"Failed to fetch WeChat access token: "
"{}{}error={}.".format(
status_str,
", " if status_str else "",
r.status_code,
)
)
self.logger.debug(
"Response Details:\r\n%r",
(r.content or b"")[:2000],
)
# Return None to indicate failure
return None
# Parse the JSON response body
try:
content = json.loads(r.content)
except (AttributeError, TypeError, ValueError):
content = {}
# Check the WeCom application-level error code
errcode = content.get("errcode", 0) if content else -1
if errcode != 0:
errmsg = WECHAT_ERROR_CODES.get(
errcode,
content.get("errmsg", "Unknown error")
if content
else "Unknown error",
)
self.logger.warning(
"WeChat access token request failed: "
"errcode={}: {}.".format(errcode, errmsg)
)
# Return None to indicate failure
return None
# Extract the access token from the response
token = content.get("access_token") if content else None
if not token:
self.logger.warning(
"WeChat access token response contained no token."
)
# Return None to indicate failure
return None
# Derive the cache TTL: expires_in minus a grace period to
# avoid racing the expiry boundary
expires_in = content.get("expires_in", 7200) if content else 7200
ttl = max(60, expires_in - 300)
# Cache the token for reuse across plugin instances
self.store.set("access_token", token, expires=ttl)
# Return the freshly fetched token
return token
except requests.RequestException as e:
self.logger.warning(
"A Connection error occurred fetching the WeChat access token."
)
self.logger.debug("Socket Exception: %s", str(e))
# Return None to indicate failure
return None
def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
"""Perform WeChat (WeCom Application) Notification."""
# Compile the recipient lists
users = list(self.users)
departments = list(self.departments)
tags = list(self.tag_ids)
# At least one valid recipient is required to proceed
if not users and not departments and not tags:
self.logger.warning(
"No WeChat (WeCom) recipients configured; aborting."
)
return False
# Obtain (or reuse from cache) a valid access token
token = self._get_access_token()
if not token:
return False
# Determine the WeCom msgtype from the active notify_format
if self.notify_format == NotifyFormat.MARKDOWN:
msgtype = "markdown"
else:
# Both TEXT and HTML fall back to plain text
msgtype = "text"
# Build the message content wrapper
msgbody = {"content": body}
# Assemble the full request payload
payload = {
"agentid": int(self.agentid),
"msgtype": msgtype,
msgtype: msgbody,
}
# Add recipient fields -- only include lists that are non-empty
if users:
payload["touser"] = "|".join(users)
if departments:
payload["toparty"] = "|".join(departments)
if tags:
payload["totag"] = "|".join(tags)
# Prepare the request headers
headers = {
"User-Agent": self.app_id,
"Content-Type": "application/json",
}
# Append the access token as a query parameter on the send URL
send_url = "{}?access_token={}".format(self.notify_url, token)
self.logger.debug(
"WeChat (WeCom) POST URL: %s (cert_verify=%r)",
self.notify_url,
self.verify_certificate,
)
self.logger.debug("WeChat (WeCom) Payload: %r", payload)
# Always throttle before any remote server I/O
self.throttle()
try:
r = requests.post(
send_url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
allow_redirects=self.redirects,
)
if r.status_code != requests.codes.ok:
# HTTP-level failure
status_str = NotifyWeChat.http_response_code_lookup(
r.status_code
)
self.logger.warning(
"Failed to send WeChat (WeCom) notification: "
"{}{}error={}.".format(
status_str,
", " if status_str else "",
r.status_code,
)
)
self.logger.debug(
"Response Details:\r\n%r",
(r.content or b"")[:2000],
)
# Return; we're done
return False
# Parse the JSON response body
try:
content = json.loads(r.content)
except (AttributeError, TypeError, ValueError):
content = {}
# Check the WeCom application-level error code
errcode = content.get("errcode", 0) if content else -1
if errcode != 0:
# Token error codes mean the cached token has expired;
# evict it so the next call fetches a fresh one
if errcode in WECHAT_TOKEN_ERROR_CODES:
self.store.set("access_token", None, expires=1)
errmsg = WECHAT_ERROR_CODES.get(
errcode,
content.get("errmsg", "Unknown error")
if content
else "Unknown error",
)
self.logger.warning(
"Failed to send WeChat (WeCom) notification: "
"errcode={}: {}.".format(errcode, errmsg)
)
self.logger.debug(
"Response Details:\r\n%r",
content if content else (r.content or b"")[:2000],
)
# Return; we're done
return False
except requests.RequestException as e:
self.logger.warning(
"A Connection error occurred sending"
" WeChat (WeCom) notification."
)
self.logger.debug("Socket Exception: %s", str(e))
# Return; we're done
return False
self.logger.info("Sent WeChat (WeCom) notification.")
return True
@property
def url_identifier(self):
"""Returns all of the identifiers that make this URL unique
from another similar one.
Targets or end points should never be identified here.
"""
# Corp + secret + agentid together uniquely identify the app
return (
self.secure_protocol,
self.corpid,
self.corpsecret,
self.agentid,
)
def url(self, privacy=False, *args, **kwargs):
"""Returns the URL built dynamically based on specified
arguments."""
params = {}
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Re-assemble the targets list, restoring each type's prefix.
# Department IDs use # which must be percent-encoded in paths.
targets = []
for user in self.users:
if user.startswith("@"):
# @all and any other @-prefixed broadcast keyword;
# emit as-is since the @ is already present
targets.append(NotifyWeChat.quote(user, safe="@"))
else:
# Regular user ID; always emit with @ prefix so the
# URL is self-describing and consistent with the
# target_user prefix declaration
targets.append("@{}".format(NotifyWeChat.quote(user, safe="")))
for dept in self.departments:
# Encode # as %23 so it is not treated as a URL fragment
targets.append("%23{}".format(dept))
for tag in self.tag_ids:
targets.append("+{}".format(tag))
# Always include invalid entries so the URL round-trips without
# silent data loss
for inv in self.invalid_targets:
targets.append(NotifyWeChat.quote(inv, safe=""))
default_schema = self.secure_protocol
if targets:
return (
"{schema}://{corpid}:{corpsecret}@{agentid}"
"/{targets}/?{params}".format(
schema=default_schema,
corpid=NotifyWeChat.quote(self.corpid, safe=""),
corpsecret=self.pprint(self.corpsecret, privacy, safe=""),
agentid=self.agentid,
targets="/".join(targets),
params=NotifyWeChat.urlencode(params),
)
)
return "{schema}://{corpid}:{corpsecret}@{agentid}/?{params}".format(
schema=default_schema,
corpid=NotifyWeChat.quote(self.corpid, safe=""),
corpsecret=self.pprint(self.corpsecret, privacy, safe=""),
agentid=self.agentid,
params=NotifyWeChat.urlencode(params),
)
@staticmethod
def parse_url(url):
"""Parses the URL and returns enough arguments that can allow
us to re-instantiate this object."""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Corp ID comes from the URL user field
results["corpid"] = NotifyWeChat.unquote(results["user"])
# App Secret comes from the URL password field
results["corpsecret"] = NotifyWeChat.unquote(results["password"])
# Agent ID comes from the URL host field
results["agentid"] = NotifyWeChat.unquote(results["host"])
# Collect targets from the URL path
results["targets"] = NotifyWeChat.split_path(results["fullpath"])
# Support ?to= for additional comma-separated targets
if "to" in results["qsd"] and results["qsd"]["to"]:
results["targets"] += NotifyWeChat.parse_list(results["qsd"]["to"])
return results
@staticmethod
def parse_native_url(url):
"""WeCom does not expose a shareable single-URL credential form,
so native URL detection is not supported."""
return None