Skip to content
This repository was archived by the owner on Mar 12, 2025. It is now read-only.

Commit 694c7d7

Browse files
authored
feat(zoom): Zoom oauth server-to-server (#572)
1 parent e5fdc25 commit 694c7d7

File tree

9 files changed

+232
-274
lines changed

9 files changed

+232
-274
lines changed

.env.example

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,15 @@ SIGN_CAFE_ENABLE_UNMUTE_WARNING="true"
2323
DAILY_PRACTICE_SEND_TIME=14:00
2424
GUILD_SETTINGS=""
2525

26+
# Zoom server-to-server OAuth
27+
ZOOM_ACCOUNT_ID="CHANGEME"
28+
ZOOM_CLIENT_ID="CHANGEME"
29+
ZOOM_CLIENT_SECRET="CHANGEME"
30+
2631
# Mapping of Discord usernames w/ discriminator => Zoom user email addresses or IDs
32+
2733
28-
ZOOM_JWT="CHANGEME"
29-
ZOOM_HOOK_TOKEN="CHANGEME"
34+
ZOOM_HOOK_SECRET="CHANGEME"
3035
ZZZZOOM_URL=https://zzzzooom.us
3136

3237
WATCH2GETHER_API_KEY="CHANGEME"

bot/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from aiohttp import web
44

55
from . import __version__, settings
6-
from .app import app
6+
from .app import app, bot
77

88
log_format = "%(asctime)s - %(name)s %(levelname)s: %(message)s"
99
logging.getLogger("disnake").setLevel(logging.WARNING)
@@ -14,4 +14,4 @@
1414
logger = logging.getLogger(__name__)
1515

1616
logger.info(f"starting bot version {__version__}")
17-
web.run_app(app, port=settings.PORT)
17+
web.run_app(app, loop=bot.loop, port=settings.PORT)

bot/app.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import logging
32

43
import aiohttp_cors
@@ -60,11 +59,11 @@ async def start_bot():
6059
await bot.close()
6160

6261

63-
async def on_startup(app):
62+
async def on_startup(app: web.Application):
6463
for ext in walk_extensions():
6564
bot.load_extension(ext)
6665
await store.connect()
67-
app["bot_task"] = asyncio.create_task(start_bot())
66+
app["bot_task"] = bot.loop.create_task(start_bot())
6867
app["bot"] = bot
6968

7069

bot/exts/meetings/_zoom.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88
import disnake
99
import holiday_emojis
10-
import meetings
1110
from aiohttp import client
1211
from disnake.ext.commands import Bot, Context, errors, has_any_role
1312
from disnake.ext.commands.errors import MissingAnyRole, NoPrivateMessage
13+
from meetings.zoom import ZoomClient
1414
from nameparser import HumanName
1515

1616
from bot import settings
@@ -83,6 +83,12 @@
8383
"🙀",
8484
)
8585

86+
zoom_client = ZoomClient(
87+
account_id=settings.ZOOM_ACCOUNT_ID,
88+
client_id=settings.ZOOM_CLIENT_ID,
89+
client_secret=settings.ZOOM_CLIENT_SECRET,
90+
)
91+
8692

8793
def display_participant_names(
8894
participants: Sequence[Mapping], meeting: Mapping, max_to_display: int = 15
@@ -256,9 +262,7 @@ async def maybe_create_zoom_meeting(
256262
meeting_exists = await store.zoom_meeting_exists(meeting_id=meeting_id)
257263
if not meeting_exists:
258264
try:
259-
meeting = await meetings.get_zoom(
260-
token=settings.ZOOM_JWT, meeting_id=meeting_id
261-
)
265+
meeting = await zoom_client.get_zoom(meeting_id=meeting_id)
262266
except client.ClientResponseError as error:
263267
logger.exception(f"error when fetching zoom meeting {meeting_id}")
264268
raise errors.CheckFailure(
@@ -336,8 +340,7 @@ async def zoom_impl(
336340
return zoom_meeting_id, message
337341
else:
338342
try:
339-
meeting = await meetings.create_zoom(
340-
token=settings.ZOOM_JWT,
343+
meeting = await zoom_client.create_zoom(
341344
user_id=zoom_user,
342345
topic="",
343346
settings={

bot/exts/meetings/meetings.py

Lines changed: 7 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@
55

66
import disnake
77
import meetings
8-
from disnake import (
9-
ApplicationCommandInteraction,
10-
GuildCommandInteraction,
11-
MessageInteraction,
12-
)
8+
from disnake import ApplicationCommandInteraction, GuildCommandInteraction
139
from disnake.ext.commands import (
1410
Bot,
1511
Cog,
@@ -34,7 +30,7 @@
3430
maybe_clear_reaction,
3531
should_handle_reaction,
3632
)
37-
from bot.utils.ui import ButtonGroupOption, ButtonGroupView, DropdownView
33+
from bot.utils.ui import ButtonGroupOption, ButtonGroupView
3834

3935
from ._zoom import (
4036
REPOST_EMOJI,
@@ -44,6 +40,7 @@
4440
get_zoom_meeting_id,
4541
is_allowed_zoom_access,
4642
make_zoom_send_kwargs,
43+
zoom_client,
4744
zoom_impl,
4845
)
4946

@@ -338,15 +335,17 @@ async def zoom_stop(
338335
async def zoom_users(self, inter: ApplicationCommandInteraction):
339336
"""(Bot owner only) List users who have access to the zoom commands"""
340337
try:
341-
users = await meetings.list_zoom_users(token=settings.ZOOM_JWT)
338+
users = await zoom_client.list_zoom_users()
342339
except asyncio.exceptions.TimeoutError:
343340
logger.exception("zoom request timed out")
344341
await inter.send(
345342
"🚨 _Request to Zoom API timed out. This may be due to rate limiting. Try again later._"
346343
)
347344
return
348345
licensed_user_emails = {
349-
user.email for user in users if user.type == meetings.ZoomPlanType.LICENSED
346+
user.email
347+
for user in users
348+
if user.type == meetings.zoom.ZoomPlanType.LICENSED
350349
}
351350
description = "\n".join(
352351
tuple(
@@ -362,125 +361,6 @@ async def zoom_users(self, inter: ApplicationCommandInteraction):
362361
embed.set_footer(text="👑 = Licensed")
363362
await inter.send(embed=embed)
364363

365-
@zoom_command.sub_command(
366-
name="license", hidden=True, help="Upgrade a user to the Licensed plan type."
367-
)
368-
@is_owner()
369-
async def zoom_license(
370-
self,
371-
inter: ApplicationCommandInteraction,
372-
user: disnake.User,
373-
):
374-
"""(Bot owner only) Upgrade a Zoom user to a Licensed plan"""
375-
assert inter.user is not None
376-
if user.id not in settings.ZOOM_USERS:
377-
await inter.send(f"🚨 _{user.mention} is not a configured Zoom user._")
378-
return
379-
380-
await inter.send(
381-
f"✋ **{user.mention} will be upgraded to a Licensed plan**.",
382-
)
383-
zoom_user_id = settings.ZOOM_USERS[user.id]
384-
try:
385-
logger.info(f"attempting to upgrade user {user.id} to licensed plan")
386-
await meetings.update_zoom_user(
387-
token=settings.ZOOM_JWT,
388-
user_id=zoom_user_id,
389-
data={"type": meetings.ZoomPlanType.LICENSED},
390-
)
391-
except meetings.MaxZoomLicensesError:
392-
try:
393-
users = await meetings.list_zoom_users(token=settings.ZOOM_JWT)
394-
except asyncio.exceptions.TimeoutError:
395-
logger.exception("zoom request timed out")
396-
await inter.send(
397-
"🚨 _Request to Zoom API timed out. This may be due to rate limiting. Try again later._"
398-
)
399-
return
400-
zoom_to_discord_user_mapping = {
401-
email.lower(): disnake_id
402-
for disnake_id, email in settings.ZOOM_USERS.items()
403-
}
404-
# Discord user IDs for Licensed users
405-
licensed_user_discord_ids = tuple(
406-
zoom_to_discord_user_mapping[user.email.lower()]
407-
for user in users
408-
if user.email.lower() in zoom_to_discord_user_mapping
409-
and user.type == meetings.ZoomPlanType.LICENSED
410-
# Don't allow de-licensing the bot owner, of course
411-
and zoom_to_discord_user_mapping[user.email.lower()] != settings.OWNER_ID
412-
)
413-
if len(licensed_user_discord_ids):
414-
options = [
415-
disnake.SelectOption(
416-
label=settings.ZOOM_USERS[discord_user_id], value=discord_user_id
417-
)
418-
for discord_user_id in licensed_user_discord_ids
419-
]
420-
421-
async def on_select(select_interaction: MessageInteraction, value: str):
422-
downgraded_user_id = int(value)
423-
await select_interaction.response.edit_message(
424-
content=f"☑️ Selected <@!{downgraded_user_id}> to downgrade.",
425-
view=None,
426-
)
427-
try:
428-
logger.info(
429-
f"attempting to downgrade user {downgraded_user_id} to basic plan"
430-
)
431-
await meetings.update_zoom_user(
432-
token=settings.ZOOM_JWT,
433-
user_id=settings.ZOOM_USERS[downgraded_user_id],
434-
data={"type": meetings.ZoomPlanType.BASIC},
435-
)
436-
except meetings.ZoomClientError:
437-
logger.exception(f"failed to downgrade user {downgraded_user_id}")
438-
await inter.send(
439-
f"🚨 _Failed to downgrade <@!{downgraded_user_id}>. Check the logs for details._"
440-
)
441-
try:
442-
logger.info(
443-
f"re-attempting to upgrade user {user.id} to licensed plan"
444-
)
445-
await meetings.update_zoom_user(
446-
token=settings.ZOOM_JWT,
447-
user_id=zoom_user_id,
448-
data={"type": meetings.ZoomPlanType.LICENSED},
449-
)
450-
except meetings.ZoomClientError:
451-
logger.exception(f"failed to upgrade user {user.id}")
452-
await inter.send(
453-
f"🚨 _Failed to upgrade {user.mention}. Check the logs for details._"
454-
)
455-
await inter.send(
456-
f"👑 **{user.mention} successfully upgraded to Licensed plan.**\n<@!{downgraded_user_id}> downgraded to Basic."
457-
)
458-
459-
view = DropdownView.from_options(
460-
options=options,
461-
on_select=on_select,
462-
placeholder="Choose a user",
463-
creator_id=inter.user.id,
464-
)
465-
await inter.send("Choose a user to downgrade to Basic.", view=view)
466-
else:
467-
await inter.send(
468-
"🚨 _No available users to downgrade on Discord. Go to the Zoom account settings to manage licenses_."
469-
)
470-
return
471-
return
472-
except meetings.ZoomClientError as error:
473-
await inter.send(f"🚨 _{error.args[0]}_")
474-
return
475-
except Exception:
476-
logger.exception(f"failed to license user {user}")
477-
await inter.send(
478-
f"🚨 _Failed to license user {user.mention}. Check the logs for details._"
479-
)
480-
return
481-
else:
482-
await inter.send(f"👑 **{user.mention} upgraded to a Licensed plan**.")
483-
484364
@slash_command(name="watch2gether")
485365
async def watch2gether_command(
486366
self, inter: ApplicationCommandInteraction, video_url: Optional[str] = None

bot/exts/meetings/zoom_webhooks.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import asyncio
22
import datetime as dt
3+
import hashlib
4+
import hmac
5+
import json
36
import logging
47
from typing import cast
58

@@ -140,10 +143,33 @@ async def handle_zoom_event(bot: Bot, data: dict):
140143

141144

142145
def setup(bot: Bot) -> None:
143-
async def zoom(request):
144-
if request.headers["authorization"] != settings.ZOOM_HOOK_TOKEN:
146+
async def zoom(request: web.Request):
147+
text = await request.text()
148+
data = json.loads(text)
149+
event = data["event"]
150+
# https://developers.zoom.us/docs/api/rest/webhook-reference/#validate-your-webhook-endpoint
151+
if event == "endpoint.url_validation":
152+
plain_token = data["payload"]["plainToken"]
153+
encrypted_token = hmac.new(
154+
settings.ZOOM_HOOK_SECRET.encode("utf-8"),
155+
plain_token.encode("utf-8"),
156+
hashlib.sha256,
157+
).hexdigest()
158+
return web.json_response(
159+
{"plainToken": plain_token, "encryptedToken": encrypted_token}
160+
)
161+
# https://developers.zoom.us/docs/api/rest/webhook-reference/#verify-webhook-events
162+
message = f"v0:{request.headers['x-zm-request-timestamp']}:{text}"
163+
signature = hmac.new(
164+
settings.ZOOM_HOOK_SECRET.encode("utf-8"),
165+
message.encode("utf-8"),
166+
hashlib.sha256,
167+
).hexdigest()
168+
expected_signature = request.headers["x-zm-signature"]
169+
actual_signature = f"v0={signature}"
170+
if expected_signature != actual_signature:
145171
return web.Response(body="", status=403)
146-
data = await request.json()
172+
147173
# Zoom expects responses within 3 seconds, so run the handler logic asynchronously
148174
# https://marketplace.zoom.us/docs/api-reference/webhook-reference#notification-delivery
149175
asyncio.create_task(handle_zoom_event(bot, data))

bot/settings.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,15 @@
6767

6868
# Mapping of Discord user IDs => emails
6969
ZOOM_USERS = env.dict("ZOOM_USERS", subcast_keys=int, required=True)
70+
ZOOM_ACCOUNT_ID = env.str("ZOOM_ACCOUNT_ID", required=True)
71+
ZOOM_CLIENT_ID = env.str("ZOOM_CLIENT_ID", required=True)
72+
ZOOM_CLIENT_SECRET = env.str("ZOOM_CLIENT_SECRET", required=True)
7073
# Emails for Zoom users that should never be downgraded to Basic
7174
ZOOM_NO_DOWNGRADE = env.list("ZOOM_NO_DOWNGRADE", default=[], subcast=str)
7275
ZOOM_EMAILS = {email: zoom_id for zoom_id, email in ZOOM_USERS.items()}
73-
ZOOM_JWT = env.str("ZOOM_JWT", required=True)
74-
ZOOM_HOOK_TOKEN = env.str("ZOOM_HOOK_TOKEN", required=True)
76+
ZOOM_HOOK_SECRET = env.str("ZOOM_HOOK_SECRET", required=True)
7577
ZOOM_REPOST_COOLDOWN = env.int("ZOOM_REPOST_COOLDOWN", 30)
78+
7679
ZZZZOOM_URL = env.str("ZZZZOOM_URL", "https://zzzzoom.us")
7780

7881
WATCH2GETHER_API_KEY = env.str("WATCH2GETHER_API_KEY", required=True)

0 commit comments

Comments
 (0)