|
5 | 5 | import random
|
6 | 6 | import re
|
7 | 7 | from contextlib import suppress
|
8 |
| -from typing import Optional |
| 8 | +from typing import Optional, NamedTuple |
9 | 9 | from urllib.parse import quote_plus
|
10 | 10 |
|
11 | 11 | import discord
|
| 12 | +import dateparser |
12 | 13 | import gspread
|
13 |
| -from discord.ext import commands |
| 14 | +from discord.ext import commands, tasks |
14 | 15 | from discord.ext.commands import Context
|
15 | 16 | from environs import Env
|
16 | 17 | from google.auth.crypt._python_rsa import RSASigner
|
17 | 18 | from google.oauth2.service_account import Credentials
|
| 19 | +import pytz |
18 | 20 |
|
19 | 21 | import handshapes
|
20 | 22 | import cuteid
|
|
34 | 36 | SECRET_KEY = env.str("SECRET_KEY", required=True)
|
35 | 37 | COMMAND_PREFIX = env.str("COMMAND_PREFIX", "?")
|
36 | 38 |
|
37 |
| - |
38 | 39 | GOOGLE_PROJECT_ID = env.str("GOOGLE_PROJECT_ID", required=True)
|
39 | 40 | GOOGLE_PRIVATE_KEY = env.str("GOOGLE_PRIVATE_KEY", required=True)
|
40 | 41 | GOOGLE_PRIVATE_KEY_ID = env.str("GOOGLE_PRIVATE_KEY_ID", required=True)
|
41 | 42 | GOOGLE_CLIENT_EMAIL = env.str("GOOGLE_CLIENT_EMAIL", required=True)
|
42 | 43 | GOOGLE_TOKEN_URI = env.str("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
|
43 | 44 | FEEDBACK_SHEET_KEY = env.str("FEEDBACK_SHEET_KEY", required=True)
|
| 45 | +SCHEDULE_SHEET_KEYS = env.dict("SCHEDULE_SHEET_KEYS", required=True, subcast_key=int) |
| 46 | +SCHEDULE_CHANNELS = env.dict( |
| 47 | + "SCHEDULE_CHANNELS", required=True, subcast_key=int, subcast=int |
| 48 | +) |
| 49 | + |
44 | 50 |
|
45 | 51 | ZOOM_USER_ID = env.str("ZOOM_USER_ID", required=True)
|
46 | 52 | ZOOM_JWT = env.str("ZOOM_JWT", required=True)
|
47 | 53 |
|
48 | 54 | WATCH2GETHER_API_KEY = env.str("WATCH2GETHER_API_KEY", required=True)
|
| 55 | +# Default to 10 AM EDT |
| 56 | +_daily_practice_send_time_raw = env.str("DAILY_PRACTICE_SEND_TIME", "14:00") |
| 57 | +_hour, _min = _daily_practice_send_time_raw.split(":") |
| 58 | +DAILY_PRACTICE_SEND_TIME = dt.time(hour=int(_hour), minute=int(_min)) |
49 | 59 |
|
50 | 60 | env.seal()
|
51 | 61 |
|
@@ -105,6 +115,7 @@ async def on_ready():
|
105 | 115 | name=f"{COMMAND_PREFIX}sign | {COMMAND_PREFIX}handshapes",
|
106 | 116 | type=discord.ActivityType.playing,
|
107 | 117 | )
|
| 118 | + daily_practice_message.start() |
108 | 119 | await bot.change_presence(activity=activity)
|
109 | 120 |
|
110 | 121 |
|
@@ -176,6 +187,11 @@ def sign_impl(word: str):
|
176 | 187 |
|
177 | 188 | @bot.command(name="sign", aliases=("howsign",), help=SIGN_HELP)
|
178 | 189 | async def sign_command(ctx: Context, *, word: str):
|
| 190 | + # TODO: Remove. This is just for identifying guild and channel IDs for the daily schedule |
| 191 | + if ctx.guild: |
| 192 | + logger.info( |
| 193 | + f"sign command invoked in guild {ctx.guild.id} ({ctx.guild.name}), channel {ctx.channel.id} (#{ctx.channel.name})" |
| 194 | + ) |
179 | 195 | await ctx.send(**sign_impl(word))
|
180 | 196 |
|
181 | 197 |
|
@@ -285,9 +301,136 @@ def get_gsheet_client():
|
285 | 301 | return gspread.authorize(credentials)
|
286 | 302 |
|
287 | 303 |
|
| 304 | +# ----------------------------------------------------------------------------- |
| 305 | + |
| 306 | +EASTERN = pytz.timezone("US/Eastern") |
| 307 | +PACIFIC = pytz.timezone("US/Pacific") |
| 308 | +TIME_FORMAT = "%-I:%M %p %Z" |
| 309 | + |
| 310 | + |
| 311 | +class PracticeSession(NamedTuple): |
| 312 | + dtime: Optional[dt.datetime] |
| 313 | + host: str |
| 314 | + notes: str |
| 315 | + |
| 316 | + |
| 317 | +def get_practice_sessions_today(guild_id: int): |
| 318 | + logger.info(f"fetching today's practice sessions for guild {guild_id}") |
| 319 | + client = get_gsheet_client() |
| 320 | + sheet = client.open_by_key(SCHEDULE_SHEET_KEYS[guild_id]) |
| 321 | + worksheet = sheet.get_worksheet(0) |
| 322 | + all_values = worksheet.get_all_values() |
| 323 | + now = dt.datetime.utcnow() |
| 324 | + today = now.date() |
| 325 | + sessions = [ |
| 326 | + PracticeSession( |
| 327 | + dtime=dateparser.parse( |
| 328 | + row[0], |
| 329 | + # Use eastern time if timezone can't be parsed; return a UTC datetime |
| 330 | + settings={"TIMEZONE": "US/Eastern", "TO_TIMEZONE": "UTC"}, |
| 331 | + ), |
| 332 | + host=row[1], |
| 333 | + notes=row[2], |
| 334 | + ) |
| 335 | + for row in all_values[2:] |
| 336 | + if row |
| 337 | + ] |
| 338 | + return sorted( |
| 339 | + [ |
| 340 | + session |
| 341 | + for session in sessions |
| 342 | + # Assume pacific time when filtering to include all of US |
| 343 | + if session.dtime and session.dtime.astimezone(PACIFIC).date() == today |
| 344 | + ], |
| 345 | + key=lambda s: s.dtime, |
| 346 | + ) |
| 347 | + |
| 348 | + |
| 349 | +def make_practice_sessions_today_embed(guild_id: int): |
| 350 | + sessions = get_practice_sessions_today(guild_id) |
| 351 | + now = dt.datetime.utcnow() |
| 352 | + embed = discord.Embed( |
| 353 | + description=f"{now:%A, %B %-d}", |
| 354 | + color=discord.Color.orange(), |
| 355 | + ) |
| 356 | + if not sessions: |
| 357 | + embed.description += "\n\n*There are no scheduled practices yet!*" |
| 358 | + else: |
| 359 | + for session in sessions: |
| 360 | + pacific_dstr = session.dtime.astimezone(PACIFIC).strftime(TIME_FORMAT) |
| 361 | + eastern_dstr = session.dtime.astimezone(EASTERN).strftime(TIME_FORMAT) |
| 362 | + title = f"{pacific_dstr} / {eastern_dstr}" |
| 363 | + value = "" |
| 364 | + if session.host: |
| 365 | + value += f"Host: {session.host}" |
| 366 | + if session.notes: |
| 367 | + value += f"\nNotes: {session.notes}" |
| 368 | + embed.add_field(name=title, value=value or "Practice", inline=False) |
| 369 | + sheet_key = SCHEDULE_SHEET_KEYS[guild_id] |
| 370 | + embed.add_field( |
| 371 | + name="Schedule a practice here:", |
| 372 | + value=f"[Practice Schedule](https://docs.google.com/spreadsheets/d/{sheet_key}/edit)", |
| 373 | + ) |
| 374 | + return embed |
| 375 | + |
| 376 | + |
| 377 | +async def is_in_guild(ctx: Context): |
| 378 | + return bool(ctx.guild) |
| 379 | + |
| 380 | + |
| 381 | +@bot.command( |
| 382 | + name="practices", help="List today's practice schedule for the current server" |
| 383 | +) |
| 384 | +@commands.check(is_in_guild) |
| 385 | +async def practices_command(ctx: Context): |
| 386 | + guild = ctx.guild |
| 387 | + embed = make_practice_sessions_today_embed(guild.id) |
| 388 | + await ctx.send(embed=embed) |
| 389 | + |
| 390 | + |
| 391 | +@practices_command.error |
| 392 | +async def practices_error(ctx, error): |
| 393 | + if isinstance(error, commands.errors.CheckFailure): |
| 394 | + await ctx.send( |
| 395 | + f"`{COMMAND_PREFIX}{ctx.invoked_with}` must be run within a server." |
| 396 | + ) |
| 397 | + else: |
| 398 | + logger.error( |
| 399 | + f"unexpected error when handling '{ctx.invoked_with}'", exc_info=error |
| 400 | + ) |
| 401 | + |
| 402 | + |
| 403 | +@tasks.loop(seconds=10.0) |
| 404 | +async def daily_practice_message(): |
| 405 | + now = dt.datetime.utcnow() |
| 406 | + date = now.date() |
| 407 | + if now.time() > DAILY_PRACTICE_SEND_TIME: |
| 408 | + date = now.date() + dt.timedelta(days=1) |
| 409 | + then = dt.datetime.combine(date, DAILY_PRACTICE_SEND_TIME) |
| 410 | + logger.info(f"practice schedules will be sent at {then.isoformat()}") |
| 411 | + await discord.utils.sleep_until(then) |
| 412 | + logger.info("sending daily practice schedules") |
| 413 | + for guild_id, channel_id in SCHEDULE_CHANNELS.items(): |
| 414 | + try: |
| 415 | + logger.info( |
| 416 | + f"sending daily practice schedule for guild {guild_id}, channel {channel_id}" |
| 417 | + ) |
| 418 | + guild = bot.get_guild(guild_id) |
| 419 | + channel = guild.get_channel(channel_id) |
| 420 | + embed = make_practice_sessions_today_embed(guild.id) |
| 421 | + await channel.send(embed=embed) |
| 422 | + except Exception: |
| 423 | + logger.exception( |
| 424 | + f"could not send message to guild {guild_id}, channel {channel_id}" |
| 425 | + ) |
| 426 | + |
| 427 | + |
| 428 | +# ----------------------------------------------------------------------------- |
| 429 | + |
| 430 | + |
288 | 431 | def post_feedback(username: str, feedback: str, guild: Optional[str]):
|
289 |
| - # Assumes rows are in the format (date, feedback, guild, version) |
290 | 432 | client = get_gsheet_client()
|
| 433 | + # Assumes rows are in the format (date, feedback, guild, version) |
291 | 434 | sheet = client.open_by_key(FEEDBACK_SHEET_KEY)
|
292 | 435 | now = dt.datetime.now(dt.timezone.utc)
|
293 | 436 | worksheet = sheet.get_worksheet(0)
|
|
0 commit comments