Skip to content

Commit 4c8dde1

Browse files
committed
Added Zoom (Incoming Webhook) Support
1 parent f8fef5a commit 4c8dde1

5 files changed

Lines changed: 827 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ The table below identifies the services this tool supports and some example serv
159159
| [WxPusher](https://appriseit.com/services/wxpusher/) | wxpusher:// | (TCP) 443 | wxpusher://AppToken@UserID1/UserID2/UserIDN<br/>wxpusher://AppToken@Topic1/Topic2/Topic3<br/>wxpusher://AppToken@UserID1/Topic1/
160160
| [XBMC](https://appriseit.com/services/xbmc/) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port
161161
| [XMPP](https://appriseit.com/services/xmpp/) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://user:pass@hostname<br />xmpps://user:pass@hostname/jid<br />xmpps://user:pass@hostname/jid1/jid2@example.ca
162+
| [Zoom](https://appriseit.com/services/zoom/) | zoom:// | (TCP) 443 | zoom://WebhookID/Token
162163
| [Zulip Chat](https://appriseit.com/services/zulip/) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token<br />zulip://botname@Organization/Token/Stream<br />zulip://botname@Organization/Token/Email
163164

164165
## SMS Notifications

apprise/plugins/zoom.py

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
# BSD 2-Clause License
2+
#
3+
# Apprise - Push Notification Library.
4+
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# 1. Redistributions of source code must retain the above copyright notice,
10+
# this list of conditions and the following disclaimer.
11+
#
12+
# 2. Redistributions in binary form must reproduce the above copyright
13+
# notice, this list of conditions and the following disclaimer in the
14+
# documentation and/or other materials provided with the distribution.
15+
#
16+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
20+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
# POSSIBILITY OF SUCH DAMAGE.
27+
28+
# Steps to set up a Zoom Team Chat Incoming Webhook:
29+
# 1. Sign in to https://marketplace.zoom.us and search for
30+
# "Incoming Webhook".
31+
# 2. Click "Add" to install the Incoming Webhook app to your account.
32+
# 3. In any Zoom Team Chat channel, type the slash command:
33+
# /inc connect
34+
# Follow the prompts to complete the connection. You will receive:
35+
# - Endpoint URL:
36+
# https://inbots.zoom.us/incoming/hook/WEBHOOK_ID
37+
# - Verification Token: VERIFICATION_TOKEN
38+
#
39+
# Assemble your Apprise URL using both values:
40+
#
41+
# zoom://WEBHOOK_ID/VERIFICATION_TOKEN
42+
#
43+
# By default, messages are sent in "full" structured format, which renders
44+
# the notification title as a heading and the body as the message text.
45+
# To send a plain-text message instead, append ?mode=simple:
46+
#
47+
# zoom://WEBHOOK_ID/VERIFICATION_TOKEN?mode=simple
48+
#
49+
# The native webhook URL is also supported when a ?token= parameter is
50+
# appended containing the verification token:
51+
#
52+
# https://inbots.zoom.us/incoming/hook/WEBHOOK_ID?token=TOKEN
53+
#
54+
# References:
55+
# - https://marketplace.zoom.us/apps/eH_dLuquRd-VYcOsNGy-hQ
56+
# - https://support.zoom.com/hc/en/article?id=zm_kb&\
57+
# sysparm_article=KB0067640
58+
59+
from json import dumps
60+
import re
61+
62+
import requests
63+
64+
from ..common import NotifyType
65+
from ..locale import gettext_lazy as _
66+
from ..utils.parse import validate_regex
67+
from .base import NotifyBase
68+
69+
# Extend HTTP Error Messages
70+
ZOOM_HTTP_ERROR_MAP = {
71+
401: "Unauthorized - Invalid verification token.",
72+
404: "Webhook not found; check the Webhook ID.",
73+
429: "Rate limit exceeded; too many consecutive requests.",
74+
500: "Zoom internal server error.",
75+
503: "Service unavailable; try again later.",
76+
}
77+
78+
79+
class ZoomMode:
80+
"""Tracks the notification mode for Zoom Team Chat."""
81+
82+
# Plain-text message; sent without JSON wrapping
83+
SIMPLE = "simple"
84+
85+
# Structured message with head/body sections (supports title)
86+
FULL = "full"
87+
88+
89+
# Valid Zoom notification modes
90+
ZOOM_MODES = (
91+
ZoomMode.SIMPLE,
92+
ZoomMode.FULL,
93+
)
94+
95+
# Default notification mode
96+
ZOOM_MODE_DEFAULT = ZoomMode.FULL
97+
98+
99+
class NotifyZoom(NotifyBase):
100+
"""A wrapper for Zoom Team Chat Notifications."""
101+
102+
# The default descriptive name associated with the Notification
103+
service_name = "Zoom"
104+
105+
# The services URL
106+
service_url = "https://zoom.us/"
107+
108+
# The default secure protocol
109+
secure_protocol = "zoom"
110+
111+
# A URL that takes you to the setup/help of the specific protocol
112+
setup_url = "https://appriseit.com/services/zoom/"
113+
114+
# Zoom Team Chat incoming webhooks do not support file attachments
115+
attachment_support = False
116+
117+
# Zoom incoming-webhook base URL
118+
zoom_webhook_url = "https://inbots.zoom.us/incoming/hook/{}"
119+
120+
# The maximum body length for a Zoom notification
121+
body_maxlen = 4000
122+
123+
# Maximum length for the head.text field (full mode only)
124+
title_maxlen = 250
125+
126+
# Define object URL templates
127+
templates = ("{schema}://{webhook_id}/{token}",)
128+
129+
# Define our template tokens
130+
template_tokens = dict(
131+
NotifyBase.template_tokens,
132+
**{
133+
"webhook_id": {
134+
"name": _("Webhook ID"),
135+
"type": "string",
136+
"private": True,
137+
"required": True,
138+
},
139+
"token": {
140+
"name": _("Verification Token"),
141+
"type": "string",
142+
"private": True,
143+
"required": True,
144+
},
145+
},
146+
)
147+
148+
# Define our template arguments
149+
template_args = dict(
150+
NotifyBase.template_args,
151+
**{
152+
"mode": {
153+
"name": _("Mode"),
154+
"type": "choice:string",
155+
"values": ZOOM_MODES,
156+
"default": ZOOM_MODE_DEFAULT,
157+
},
158+
# token= is already defined in template_tokens; this
159+
# alias_of entry simply advertises that ?token= is also
160+
# accepted as a query-string override on the URL.
161+
"token": {
162+
"alias_of": "token",
163+
},
164+
},
165+
)
166+
167+
def __init__(self, webhook_id, token, mode=None, **kwargs):
168+
"""Initialize Zoom Object."""
169+
super().__init__(**kwargs)
170+
171+
# Validate our webhook ID
172+
self.webhook_id = validate_regex(webhook_id, r"^[A-Za-z0-9_-]+$")
173+
if not self.webhook_id:
174+
msg = (
175+
"A Zoom webhook ID must be specified and contain"
176+
" only alphanumeric, hyphen, or underscore"
177+
" characters."
178+
)
179+
self.logger.warning(msg)
180+
raise TypeError(msg)
181+
182+
# Validate our verification token
183+
self.token = validate_regex(token)
184+
if not self.token:
185+
msg = "A Zoom verification token must be specified."
186+
self.logger.warning(msg)
187+
raise TypeError(msg)
188+
189+
# Validate notification mode
190+
if mode is None:
191+
# Default to full structured mode
192+
self.mode = ZOOM_MODE_DEFAULT
193+
194+
else:
195+
# Allow partial prefix matching (e.g. "sim" -> "simple")
196+
self.mode = next(
197+
(m for m in ZOOM_MODES if m.startswith(mode.lower())),
198+
None,
199+
)
200+
if not self.mode:
201+
msg = "The Zoom mode ({}) is invalid.".format(mode)
202+
self.logger.warning(msg)
203+
raise TypeError(msg)
204+
205+
def send(
206+
self,
207+
body,
208+
title="",
209+
notify_type=NotifyType.INFO,
210+
attach=None,
211+
**kwargs,
212+
):
213+
"""Perform Zoom Team Chat Notification."""
214+
215+
# Build the base webhook endpoint URL
216+
base_url = self.zoom_webhook_url.format(
217+
NotifyZoom.quote(self.webhook_id, safe="")
218+
)
219+
220+
if self.mode == ZoomMode.FULL:
221+
# Build the structured content object
222+
content = {}
223+
224+
# Include the head section only when a title is present
225+
if title:
226+
content["head"] = {"text": title}
227+
228+
# Always include the body section
229+
content["body"] = [
230+
{
231+
"type": "message",
232+
"text": body,
233+
}
234+
]
235+
236+
# Append the ?format=full query parameter
237+
url = "{}?format=full".format(base_url)
238+
239+
# Deliver the structured payload as JSON
240+
return self._fetch(
241+
url,
242+
payload=dumps({"content": content}),
243+
content_type="application/json",
244+
)
245+
246+
# Simple mode: plain text, title colon-prepended if provided
247+
text = "{}: {}".format(title, body) if title else body
248+
249+
# Deliver plain text directly
250+
return self._fetch(base_url, payload=text)
251+
252+
def _fetch(self, url, payload, content_type=None):
253+
"""Wrapper to a Zoom webhook POST request."""
254+
255+
# Prepare our headers
256+
headers = {
257+
"User-Agent": self.app_id,
258+
"Authorization": self.token,
259+
}
260+
261+
# Set Content-Type when a structured payload is provided
262+
if content_type:
263+
headers["Content-Type"] = content_type
264+
265+
self.logger.debug(
266+
"Zoom POST URL: {} (cert_verify={!r})".format(
267+
url, self.verify_certificate
268+
)
269+
)
270+
self.logger.debug("Zoom Payload: {!r}".format(payload))
271+
272+
# Always call throttle before any remote server i/o is made
273+
self.throttle()
274+
275+
try:
276+
r = requests.post(
277+
url,
278+
data=payload,
279+
headers=headers,
280+
verify=self.verify_certificate,
281+
timeout=self.request_timeout,
282+
)
283+
if r.status_code not in (
284+
requests.codes.ok,
285+
requests.codes.no_content,
286+
):
287+
# Report the error
288+
status_str = NotifyZoom.http_response_code_lookup(
289+
r.status_code, ZOOM_HTTP_ERROR_MAP
290+
)
291+
self.logger.warning(
292+
"Failed to send Zoom notification: {}{}error={}.".format(
293+
status_str,
294+
", " if status_str else "",
295+
r.status_code,
296+
)
297+
)
298+
self.logger.debug(
299+
"Response Details:\r\n%r",
300+
(r.content or b"")[:2000],
301+
)
302+
return False
303+
304+
self.logger.info("Sent Zoom notification.")
305+
306+
except requests.RequestException as e:
307+
self.logger.warning(
308+
"A Connection error occurred sending Zoom notification."
309+
)
310+
self.logger.debug("Socket Exception: {}".format(str(e)))
311+
return False
312+
313+
return True
314+
315+
@property
316+
def url_identifier(self):
317+
"""Returns all of the identifiers that make this URL unique
318+
from another simliar one.
319+
320+
Targets or end points should never be identified here.
321+
"""
322+
return (self.secure_protocol, self.webhook_id, self.token)
323+
324+
def url(self, privacy=False, *args, **kwargs):
325+
"""Returns the URL built dynamically based on specified
326+
arguments."""
327+
328+
# Always include mode so the round-trip is stable
329+
params = {"mode": self.mode}
330+
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
331+
332+
return "{schema}://{webhook_id}/{token}/?{params}".format(
333+
schema=self.secure_protocol,
334+
webhook_id=self.pprint(self.webhook_id, privacy, safe=""),
335+
token=self.pprint(self.token, privacy, safe=""),
336+
params=NotifyZoom.urlencode(params),
337+
)
338+
339+
@staticmethod
340+
def parse_url(url):
341+
"""Parses the URL and returns enough arguments that can allow
342+
us to re-instantiate this object."""
343+
results = NotifyBase.parse_url(url, verify_host=False)
344+
if not results:
345+
return results
346+
347+
# The host holds the webhook ID
348+
results["webhook_id"] = NotifyZoom.unquote(results["host"])
349+
350+
# The first path segment carries the verification token
351+
path = NotifyZoom.split_path(results["fullpath"])
352+
if path:
353+
results["token"] = NotifyZoom.unquote(path.pop(0))
354+
355+
# Support ?token= override (useful with native URLs)
356+
if "token" in results["qsd"] and results["qsd"]["token"]:
357+
results["token"] = NotifyZoom.unquote(results["qsd"]["token"])
358+
359+
# Support ?mode= parameter
360+
if "mode" in results["qsd"] and results["qsd"]["mode"]:
361+
results["mode"] = NotifyZoom.unquote(results["qsd"]["mode"])
362+
363+
return results
364+
365+
@staticmethod
366+
def parse_native_url(url):
367+
"""Support https://inbots.zoom.us/incoming/hook/WEBHOOK_ID"""
368+
result = re.match(
369+
r"^https?://inbots\.zoom\.us/incoming/hook/"
370+
r"(?P<webhook_id>[A-Za-z0-9_-]+)/?"
371+
r"(?P<params>\?.+)?$",
372+
url,
373+
re.I,
374+
)
375+
if result:
376+
return NotifyZoom.parse_url(
377+
"{schema}://{webhook_id}/{params}".format(
378+
schema=NotifyZoom.secure_protocol,
379+
webhook_id=result.group("webhook_id"),
380+
params=result.group("params") or "",
381+
)
382+
)
383+
return None

0 commit comments

Comments
 (0)