Files
agessaman 295d757914 refactor(commands): share joke and dadjoke length handling
The two commands carried near-identical copies of fetch-until-short, split-at-a-natural-break and two-part send. modules/joke_text.py now holds them as plain functions (a mixin module under modules/commands would be loaded as a plugin and recorded as a failure). Each command keeps its public method names, its emoji, its own split points (joke also splits on newlines) and its own pause: joke still waits 2 s between the two parts, dadjoke does not. Lengths stay counted in characters against 130, as before.
2026-10-01 02:16:16 -07:00

89 lines
3.2 KiB
Python

"""Length handling shared by the joke-style commands (joke, dadjoke).
Both fetch a joke from an API, retry for a short one unless long jokes are
allowed, and split a long one into two messages at a natural break. Lengths are
counted in characters against a fixed limit, as these commands always have.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Optional
JOKE_CHAR_LIMIT = 130
MAX_FETCH_ATTEMPTS = 5
async def fetch_fitting_joke(
fetch: Callable[[], Awaitable[Optional[dict[str, Any]]]],
format_joke: Callable[[dict[str, Any]], str],
*,
allow_long: bool,
logger: Any,
label: str,
) -> Optional[dict[str, Any]]:
"""Fetch jokes until one fits ``JOKE_CHAR_LIMIT`` (or any, when long ones are allowed).
Returns None when a fetch fails; after ``MAX_FETCH_ATTEMPTS`` long jokes,
returns the last one anyway.
"""
joke_data = None
for _attempt in range(MAX_FETCH_ATTEMPTS):
joke_data = await fetch()
if joke_data is None:
return None
joke_text = format_joke(joke_data)
if len(joke_text) <= JOKE_CHAR_LIMIT or allow_long:
return joke_data
logger.debug(f"{label.capitalize()} too long ({len(joke_text)} chars), fetching another...")
logger.warning(f"Could not get short {label} after {MAX_FETCH_ATTEMPTS} attempts")
return joke_data
def split_joke_text(joke_text: str, emoji: str, split_points: Sequence[str]) -> list[str]:
"""Split ``joke_text`` (which starts with ``"<emoji> "``) into two parts.
Splits after the first of ``split_points`` found, else at the first space
from the middle; both parts get the emoji prefix back.
"""
prefix = f"{emoji} "
clean_joke = joke_text[len(emoji) + 1:] if joke_text.startswith(prefix) else joke_text
for split_point in split_points:
if split_point in clean_joke:
parts = clean_joke.split(split_point, 1)
if len(parts) == 2:
return [f"{prefix}{parts[0]}{split_point}", f"{prefix}{parts[1]}"]
mid_point = len(clean_joke) // 2
for i in range(mid_point, len(clean_joke)):
if clean_joke[i] == ' ':
mid_point = i
break
return [f"{prefix}{clean_joke[:mid_point]}", f"{prefix}{clean_joke[mid_point + 1:]}"]
async def send_joke(
send: Callable[..., Awaitable[Any]],
message: Any,
joke_text: str,
split: Callable[[str], list[str]],
*,
pause_seconds: float = 0.0,
) -> None:
"""Send a joke, as two messages when it is long and splits cleanly.
The per-user rate limit applies to the first message only. A joke that
does not split into two parts within the limit goes out whole.
"""
if len(joke_text) <= JOKE_CHAR_LIMIT:
await send(message, joke_text)
return
parts = split(joke_text)
if len(parts) == 2 and len(parts[0]) <= JOKE_CHAR_LIMIT and len(parts[1]) <= JOKE_CHAR_LIMIT:
await send(message, parts[0])
if pause_seconds:
await asyncio.sleep(pause_seconds)
await send(message, parts[1], skip_user_rate_limit=True)
else:
await send(message, joke_text)