# # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # 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[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[0-9]+)$") # Validates a tag target: + prefix followed by a numeric ID. IS_TAG = re.compile(r"^\+(?P[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