Skip to content
This repository was archived by the owner on Mar 12, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ SIGN_CAFE_ENABLE_UNMUTE_WARNING="true"
DAILY_PRACTICE_SEND_TIME=14:00
GUILD_SETTINGS=""

# Zoom server-to-server OAuth
ZOOM_ACCOUNT_ID="CHANGEME"
ZOOM_CLIENT_ID="CHANGEME"
ZOOM_CLIENT_SECRET="CHANGEME"

# Mapping of Discord usernames w/ discriminator => Zoom user email addresses or IDs

ZOOM_USERS="1234=bob@example.com,5678=alice@example.com"
ZOOM_JWT="CHANGEME"
ZOOM_HOOK_TOKEN="CHANGEME"
ZOOM_HOOK_SECRET="CHANGEME"
ZZZZOOM_URL=https://zzzzooom.us

WATCH2GETHER_API_KEY="CHANGEME"
Expand Down
4 changes: 2 additions & 2 deletions bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from aiohttp import web

from . import __version__, settings
from .app import app
from .app import app, bot

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

logger.info(f"starting bot version {__version__}")
web.run_app(app, port=settings.PORT)
web.run_app(app, loop=bot.loop, port=settings.PORT)
5 changes: 2 additions & 3 deletions bot/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
import logging

import aiohttp_cors
Expand Down Expand Up @@ -60,11 +59,11 @@ async def start_bot():
await bot.close()


async def on_startup(app):
async def on_startup(app: web.Application):
for ext in walk_extensions():
bot.load_extension(ext)
await store.connect()
app["bot_task"] = asyncio.create_task(start_bot())
app["bot_task"] = bot.loop.create_task(start_bot())
app["bot"] = bot


Expand Down
15 changes: 9 additions & 6 deletions bot/exts/meetings/_zoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@

import disnake
import holiday_emojis
import meetings
from aiohttp import client
from disnake.ext.commands import Bot, Context, errors, has_any_role
from disnake.ext.commands.errors import MissingAnyRole, NoPrivateMessage
from meetings.zoom import ZoomClient
from nameparser import HumanName

from bot import settings
Expand Down Expand Up @@ -83,6 +83,12 @@
"🙀",
)

zoom_client = ZoomClient(
account_id=settings.ZOOM_ACCOUNT_ID,
client_id=settings.ZOOM_CLIENT_ID,
client_secret=settings.ZOOM_CLIENT_SECRET,
)


def display_participant_names(
participants: Sequence[Mapping], meeting: Mapping, max_to_display: int = 15
Expand Down Expand Up @@ -256,9 +262,7 @@ async def maybe_create_zoom_meeting(
meeting_exists = await store.zoom_meeting_exists(meeting_id=meeting_id)
if not meeting_exists:
try:
meeting = await meetings.get_zoom(
token=settings.ZOOM_JWT, meeting_id=meeting_id
)
meeting = await zoom_client.get_zoom(meeting_id=meeting_id)
except client.ClientResponseError as error:
logger.exception(f"error when fetching zoom meeting {meeting_id}")
raise errors.CheckFailure(
Expand Down Expand Up @@ -336,8 +340,7 @@ async def zoom_impl(
return zoom_meeting_id, message
else:
try:
meeting = await meetings.create_zoom(
token=settings.ZOOM_JWT,
meeting = await zoom_client.create_zoom(
user_id=zoom_user,
topic="",
settings={
Expand Down
134 changes: 7 additions & 127 deletions bot/exts/meetings/meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@

import disnake
import meetings
from disnake import (
ApplicationCommandInteraction,
GuildCommandInteraction,
MessageInteraction,
)
from disnake import ApplicationCommandInteraction, GuildCommandInteraction
from disnake.ext.commands import (
Bot,
Cog,
Expand All @@ -34,7 +30,7 @@
maybe_clear_reaction,
should_handle_reaction,
)
from bot.utils.ui import ButtonGroupOption, ButtonGroupView, DropdownView
from bot.utils.ui import ButtonGroupOption, ButtonGroupView

from ._zoom import (
REPOST_EMOJI,
Expand All @@ -44,6 +40,7 @@
get_zoom_meeting_id,
is_allowed_zoom_access,
make_zoom_send_kwargs,
zoom_client,
zoom_impl,
)

Expand Down Expand Up @@ -338,15 +335,17 @@ async def zoom_stop(
async def zoom_users(self, inter: ApplicationCommandInteraction):
"""(Bot owner only) List users who have access to the zoom commands"""
try:
users = await meetings.list_zoom_users(token=settings.ZOOM_JWT)
users = await zoom_client.list_zoom_users()
except asyncio.exceptions.TimeoutError:
logger.exception("zoom request timed out")
await inter.send(
"🚨 _Request to Zoom API timed out. This may be due to rate limiting. Try again later._"
)
return
licensed_user_emails = {
user.email for user in users if user.type == meetings.ZoomPlanType.LICENSED
user.email
for user in users
if user.type == meetings.zoom.ZoomPlanType.LICENSED
}
description = "\n".join(
tuple(
Expand All @@ -362,125 +361,6 @@ async def zoom_users(self, inter: ApplicationCommandInteraction):
embed.set_footer(text="👑 = Licensed")
await inter.send(embed=embed)

@zoom_command.sub_command(
name="license", hidden=True, help="Upgrade a user to the Licensed plan type."
)
@is_owner()
async def zoom_license(
self,
inter: ApplicationCommandInteraction,
user: disnake.User,
):
"""(Bot owner only) Upgrade a Zoom user to a Licensed plan"""
assert inter.user is not None
if user.id not in settings.ZOOM_USERS:
await inter.send(f"🚨 _{user.mention} is not a configured Zoom user._")
return

await inter.send(
f"✋ **{user.mention} will be upgraded to a Licensed plan**.",
)
zoom_user_id = settings.ZOOM_USERS[user.id]
try:
logger.info(f"attempting to upgrade user {user.id} to licensed plan")
await meetings.update_zoom_user(
token=settings.ZOOM_JWT,
user_id=zoom_user_id,
data={"type": meetings.ZoomPlanType.LICENSED},
)
except meetings.MaxZoomLicensesError:
try:
users = await meetings.list_zoom_users(token=settings.ZOOM_JWT)
except asyncio.exceptions.TimeoutError:
logger.exception("zoom request timed out")
await inter.send(
"🚨 _Request to Zoom API timed out. This may be due to rate limiting. Try again later._"
)
return
zoom_to_discord_user_mapping = {
email.lower(): disnake_id
for disnake_id, email in settings.ZOOM_USERS.items()
}
# Discord user IDs for Licensed users
licensed_user_discord_ids = tuple(
zoom_to_discord_user_mapping[user.email.lower()]
for user in users
if user.email.lower() in zoom_to_discord_user_mapping
and user.type == meetings.ZoomPlanType.LICENSED
# Don't allow de-licensing the bot owner, of course
and zoom_to_discord_user_mapping[user.email.lower()] != settings.OWNER_ID
)
if len(licensed_user_discord_ids):
options = [
disnake.SelectOption(
label=settings.ZOOM_USERS[discord_user_id], value=discord_user_id
)
for discord_user_id in licensed_user_discord_ids
]

async def on_select(select_interaction: MessageInteraction, value: str):
downgraded_user_id = int(value)
await select_interaction.response.edit_message(
content=f"☑️ Selected <@!{downgraded_user_id}> to downgrade.",
view=None,
)
try:
logger.info(
f"attempting to downgrade user {downgraded_user_id} to basic plan"
)
await meetings.update_zoom_user(
token=settings.ZOOM_JWT,
user_id=settings.ZOOM_USERS[downgraded_user_id],
data={"type": meetings.ZoomPlanType.BASIC},
)
except meetings.ZoomClientError:
logger.exception(f"failed to downgrade user {downgraded_user_id}")
await inter.send(
f"🚨 _Failed to downgrade <@!{downgraded_user_id}>. Check the logs for details._"
)
try:
logger.info(
f"re-attempting to upgrade user {user.id} to licensed plan"
)
await meetings.update_zoom_user(
token=settings.ZOOM_JWT,
user_id=zoom_user_id,
data={"type": meetings.ZoomPlanType.LICENSED},
)
except meetings.ZoomClientError:
logger.exception(f"failed to upgrade user {user.id}")
await inter.send(
f"🚨 _Failed to upgrade {user.mention}. Check the logs for details._"
)
await inter.send(
f"👑 **{user.mention} successfully upgraded to Licensed plan.**\n<@!{downgraded_user_id}> downgraded to Basic."
)

view = DropdownView.from_options(
options=options,
on_select=on_select,
placeholder="Choose a user",
creator_id=inter.user.id,
)
await inter.send("Choose a user to downgrade to Basic.", view=view)
else:
await inter.send(
"🚨 _No available users to downgrade on Discord. Go to the Zoom account settings to manage licenses_."
)
return
return
except meetings.ZoomClientError as error:
await inter.send(f"🚨 _{error.args[0]}_")
return
except Exception:
logger.exception(f"failed to license user {user}")
await inter.send(
f"🚨 _Failed to license user {user.mention}. Check the logs for details._"
)
return
else:
await inter.send(f"👑 **{user.mention} upgraded to a Licensed plan**.")

@slash_command(name="watch2gether")
async def watch2gether_command(
self, inter: ApplicationCommandInteraction, video_url: Optional[str] = None
Expand Down
32 changes: 29 additions & 3 deletions bot/exts/meetings/zoom_webhooks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import asyncio
import datetime as dt
import hashlib
import hmac
import json
import logging
from typing import cast

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


def setup(bot: Bot) -> None:
async def zoom(request):
if request.headers["authorization"] != settings.ZOOM_HOOK_TOKEN:
async def zoom(request: web.Request):
text = await request.text()
data = json.loads(text)
event = data["event"]
# https://developers.zoom.us/docs/api/rest/webhook-reference/#validate-your-webhook-endpoint
if event == "endpoint.url_validation":
plain_token = data["payload"]["plainToken"]
encrypted_token = hmac.new(
settings.ZOOM_HOOK_SECRET.encode("utf-8"),
plain_token.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return web.json_response(
{"plainToken": plain_token, "encryptedToken": encrypted_token}
)
# https://developers.zoom.us/docs/api/rest/webhook-reference/#verify-webhook-events
message = f"v0:{request.headers['x-zm-request-timestamp']}:{text}"
signature = hmac.new(
settings.ZOOM_HOOK_SECRET.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
expected_signature = request.headers["x-zm-signature"]
actual_signature = f"v0={signature}"
if expected_signature != actual_signature:
return web.Response(body="", status=403)
data = await request.json()

# Zoom expects responses within 3 seconds, so run the handler logic asynchronously
# https://marketplace.zoom.us/docs/api-reference/webhook-reference#notification-delivery
asyncio.create_task(handle_zoom_event(bot, data))
Expand Down
7 changes: 5 additions & 2 deletions bot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,15 @@

# Mapping of Discord user IDs => emails
ZOOM_USERS = env.dict("ZOOM_USERS", subcast_keys=int, required=True)
ZOOM_ACCOUNT_ID = env.str("ZOOM_ACCOUNT_ID", required=True)
ZOOM_CLIENT_ID = env.str("ZOOM_CLIENT_ID", required=True)
ZOOM_CLIENT_SECRET = env.str("ZOOM_CLIENT_SECRET", required=True)
# Emails for Zoom users that should never be downgraded to Basic
ZOOM_NO_DOWNGRADE = env.list("ZOOM_NO_DOWNGRADE", default=[], subcast=str)
ZOOM_EMAILS = {email: zoom_id for zoom_id, email in ZOOM_USERS.items()}
ZOOM_JWT = env.str("ZOOM_JWT", required=True)
ZOOM_HOOK_TOKEN = env.str("ZOOM_HOOK_TOKEN", required=True)
ZOOM_HOOK_SECRET = env.str("ZOOM_HOOK_SECRET", required=True)
ZOOM_REPOST_COOLDOWN = env.int("ZOOM_REPOST_COOLDOWN", 30)

ZZZZOOM_URL = env.str("ZZZZOOM_URL", "https://zzzzoom.us")

WATCH2GETHER_API_KEY = env.str("WATCH2GETHER_API_KEY", required=True)
Expand Down
Loading