From 697012963ab1b5bff4fd93a8340b76ea015466f6 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Mon, 20 Jul 2026 21:54:40 +0300 Subject: [PATCH] simplify timestamp_friends: single tag reply, bare hour fallback, shared utils in tz_data --- cogs/timestamp_friends.py | 82 +++++++++++---------------------------- cogs/tz_data.py | 62 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 59 deletions(-) diff --git a/cogs/timestamp_friends.py b/cogs/timestamp_friends.py index cd11de3..1fc78f7 100644 --- a/cogs/timestamp_friends.py +++ b/cogs/timestamp_friends.py @@ -11,12 +11,12 @@ Friend map: TIMESTAMP_FRIEND_{DISCORD_USER_ID}=Country in credentials.env import logging import os import re -from datetime import datetime, timezone, timedelta +from datetime import timezone, timedelta import discord from discord.ext import commands -from .tz_data import find_offset +from .tz_data import find_offset, has_ampm, parse_bare_hours log = logging.getLogger(__name__) @@ -35,33 +35,17 @@ except ImportError: ) # --------------------------------------------------------------------------- -# trigger phrase & morning-indicator regex +# trigger phrase # --------------------------------------------------------------------------- _TRIGGER_RE = re.compile(r"my time", re.IGNORECASE) -# Morning indicators that prevent the PM default from kicking in. -# (?:\b|(?<=\d)) handles "3 am" (space), "3am" (digit-prefixed), and -# "a.m." at sentence start — \b alone fails when am/a.m. follows a digit. -_MORNING_RE = re.compile( - r"(?:\b|(?<=\d))(?:am|a\.m|morning|dawn|sunrise|early\s*morning)\b", - re.IGNORECASE, -) - # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- -def _all_formats(unix_ts: int) -> list[tuple[str, str]]: - """Return every Discord timestamp format for *unix_ts*.""" - return [ - ("Short Time", f""), - ("Long Time", f""), - ("Short Date", f""), - ("Long Date", f""), - ("Short Date+Time", f""), - ("Long Date+Time", f""), - ("Relative", f""), - ] +def _tag(unix_ts: int) -> str: + """Return a single Discord Short Date+Time tag.""" + return f"" def _load_friend_map() -> dict[int, tuple[str, float]]: @@ -91,18 +75,6 @@ def _load_friend_map() -> dict[int, tuple[str, float]]: return friends -def _apply_pm_default( - dt: datetime, matched_text: str, -) -> datetime: - """If *matched_text* has no morning indicator and the hour is 1–11, - bump by 12 h to treat the bare number as PM.""" - if _MORNING_RE.search(matched_text): - return dt - if 1 <= dt.hour <= 11: - return dt + timedelta(hours=12) - return dt - - # ========================================== # COG # ========================================== @@ -148,49 +120,41 @@ class TimestampFriends(commands.Cog): except Exception: log.debug("timestamp_friends: search_dates failed", exc_info=True) return # quiet fail - if not raw_matches: - return # quiet fail — nothing parseable - country, offset = self.friends[message.author.id] + + if not raw_matches: + # --- fallback: bare hour numbers dateparser missed --- + results = parse_bare_hours(cleaned, offset) + if results: + tags = [_tag(ts) for ts in results] + await message.reply("\n".join(tags), mention_author=False) + return + tz = timezone(timedelta(hours=offset)) # --- process each match --- - results: list[tuple[datetime, int]] = [] # (local_dt, unix_ts) + results: list[int] = [] seen: set[int] = set() # dedupe by unix ts for matched_text, dt_naive in raw_matches: - # PM default when no meridian given - dt_adjusted = _apply_pm_default(dt_naive, matched_text) + # bare times without AM/PM default to PM + if not has_ampm(matched_text) and 1 <= dt_naive.hour <= 11: + dt_naive = dt_naive + timedelta(hours=12) # attach friend's timezone, then convert to UTC - dt_aware = dt_adjusted.replace(tzinfo=tz) + dt_aware = dt_naive.replace(tzinfo=tz) unix_ts = int(dt_aware.astimezone(timezone.utc).timestamp()) if unix_ts not in seen: seen.add(unix_ts) - results.append((dt_adjusted, unix_ts)) + results.append(unix_ts) if not results: return # quiet fail - # --- build reply --- - lines: list[str] = [] - if len(results) > 1: - lines.append(f"*{len(results)} times found*") + tags = [_tag(ts) for ts in results] + await message.reply("\n".join(tags), mention_author=False) - for local_dt, unix_ts in results: - if lines: - lines.append("") - lines.append(f"**{local_dt.strftime('%d %B at %H:%M')}**") - lines.append(f"Unix: `{unix_ts}`") - lines.append("") - for label, tag in _all_formats(unix_ts): - lines.append(f"`{tag}` ← {label}") - - lines.append("") - lines.append("*Times display in each viewer's local timezone.*") - - await message.reply("\n".join(lines), mention_author=False) # ========================================== diff --git a/cogs/tz_data.py b/cogs/tz_data.py index 1d15a2d..da76ef6 100644 --- a/cogs/tz_data.py +++ b/cogs/tz_data.py @@ -2,8 +2,70 @@ ###################################################################### Used by the calendar, /timestamp, and timestamp-friends cogs. """ +import re +from datetime import datetime, timezone, timedelta + ###################################################################### +# --------------------------------------------------------------------------- +# AM/PM detection — used to avoid overriding explicit meridian indicators +# --------------------------------------------------------------------------- +_AMPM_RE = re.compile( + r"(?:\b|(?<=\d))(?:am|a\.m\.?|pm|p\.m\.?)\b", re.IGNORECASE +) + +def has_ampm(text: str) -> bool: + """Return True if *text* contains an explicit am/pm indicator.""" + return bool(_AMPM_RE.search(text)) + + +# --------------------------------------------------------------------------- +# bare hour fallback — for when dateparser ignores bare "3" or "at 11" +# --------------------------------------------------------------------------- +_BARE_HOUR_RE = re.compile( + r"\b(\d{1,2})\b(?!\s*(?:am|a\.m\.?|pm|p\.m\.?|:))", + re.IGNORECASE, +) + +def parse_bare_hours(text: str, offset: float) -> list[int]: + """Find bare hour numbers (1-24) in *text*, convert to UTC unix timestamps. + + Bare hours without AM/PM default to PM. If the resulting time is already + past today in the given UTC offset, it's pushed to tomorrow. + """ + raw = _BARE_HOUR_RE.findall(text) + if not raw: + return [] + + now_local = datetime.now(timezone.utc).astimezone( + timezone(timedelta(hours=offset)) + ) + results: list[int] = [] + seen: set[int] = set() + + for hour_str in raw: + h = int(hour_str) + if h < 1 or h > 24: + continue + if 1 <= h <= 11 and not has_ampm(text): + h += 12 + if h == 24: + h = 0 + dt = now_local.replace(hour=h, minute=0, second=0, microsecond=0) + if dt <= now_local: + dt += timedelta(days=1) + unix_ts = int(dt.astimezone(timezone.utc).timestamp()) + if unix_ts not in seen: + seen.add(unix_ts) + results.append(unix_ts) + + return results + + +# --------------------------------------------------------------------------- +# country offset data +# --------------------------------------------------------------------------- + # fmt: off COUNTRY_TZ: dict[str, float] = { # Europe