simplify timestamp_friends: single tag reply, bare hour fallback, shared utils in tz_data

This commit is contained in:
esef committed 2026-07-20 21:54:40 +03:00
1 parent 7018366068
commit 697012963a
2 files changed
+85 -59

No files matched your search

+23 -59
View File
@@ -11,12 +11,12 @@ Friend map: TIMESTAMP_FRIEND_{DISCORD_USER_ID}=Country in credentials.env
import logging import logging
import os import os
import re import re
from datetime import datetime, timezone, timedelta from datetime import timezone, timedelta
import discord import discord
from discord.ext import commands 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__) 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) _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 # helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _all_formats(unix_ts: int) -> list[tuple[str, str]]: def _tag(unix_ts: int) -> str:
"""Return every Discord timestamp format for *unix_ts*.""" """Return a single Discord Short Date+Time tag."""
return [ return f"<t:{unix_ts}:f>"
("Short Time", f"<t:{unix_ts}:t>"),
("Long Time", f"<t:{unix_ts}:T>"),
("Short Date", f"<t:{unix_ts}:d>"),
("Long Date", f"<t:{unix_ts}:D>"),
("Short Date+Time", f"<t:{unix_ts}:f>"),
("Long Date+Time", f"<t:{unix_ts}:F>"),
("Relative", f"<t:{unix_ts}:R>"),
]
def _load_friend_map() -> dict[int, tuple[str, float]]: def _load_friend_map() -> dict[int, tuple[str, float]]:
@@ -91,18 +75,6 @@ def _load_friend_map() -> dict[int, tuple[str, float]]:
return friends 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 # COG
# ========================================== # ==========================================
@@ -148,49 +120,41 @@ class TimestampFriends(commands.Cog):
except Exception: except Exception:
log.debug("timestamp_friends: search_dates failed", exc_info=True) log.debug("timestamp_friends: search_dates failed", exc_info=True)
return # quiet fail return # quiet fail
if not raw_matches:
return # quiet fail — nothing parseable
country, offset = self.friends[message.author.id] 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)) tz = timezone(timedelta(hours=offset))
# --- process each match --- # --- process each match ---
results: list[tuple[datetime, int]] = [] # (local_dt, unix_ts) results: list[int] = []
seen: set[int] = set() # dedupe by unix ts seen: set[int] = set() # dedupe by unix ts
for matched_text, dt_naive in raw_matches: for matched_text, dt_naive in raw_matches:
# PM default when no meridian given # bare times without AM/PM default to PM
dt_adjusted = _apply_pm_default(dt_naive, matched_text) 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 # 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()) unix_ts = int(dt_aware.astimezone(timezone.utc).timestamp())
if unix_ts not in seen: if unix_ts not in seen:
seen.add(unix_ts) seen.add(unix_ts)
results.append((dt_adjusted, unix_ts)) results.append(unix_ts)
if not results: if not results:
return # quiet fail return # quiet fail
# --- build reply --- tags = [_tag(ts) for ts in results]
lines: list[str] = [] await message.reply("\n".join(tags), mention_author=False)
if len(results) > 1:
lines.append(f"*{len(results)} times found*")
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)
# ========================================== # ==========================================
+62
View File
@@ -2,8 +2,70 @@
###################################################################### ######################################################################
Used by the calendar, /timestamp, and timestamp-friends cogs. 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 # fmt: off
COUNTRY_TZ: dict[str, float] = { COUNTRY_TZ: dict[str, float] = {
# Europe # Europe