diff --git a/README.md b/README.md index 074a562..e33520f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ it started as a simple music bot and became a massive mess of features i actuall - **media & music** — yt-dlp + ffmpeg based music playback. also has commands for random ffmpeg media conversions. - **sysinfo** — live monitoring of my laptop's cpu, ram, temps (lm-sensors), top processes, and fastfetch. - **utilities** — live currency conversion, steam stat tracking (strinova player counts). +- **calendar** — firestore-backed event booking via modal. autoconverts local times to UTC using a country list, feeds a web calendar frontend. +- **timestamp** — `/timestamp` modal that generates Discord `` tags. accepts day numbers or day names ("Sunday"), handles month rollover, outputs all 7 Discord time formats. +- **timestamp (friends)** — passive message listener: when a whitelisted user says "my time" in chat, the bot parses the surrounding text for time expressions, auto-converts to their timezone, and replies with timestamp tags. no commands needed. - **junk** — dice, coinflip, aura meter, rank, gifs, copypastas. ## stack @@ -30,3 +33,5 @@ run these to see the sub-commands: - `!gifs` — the gif list - `!fun` — the junk commands - `!gacha help` — blue archive recruitment simulator (pick banners, pull with live rates) +- `!book` / `!unbook` — calendar event booking (whitelisted users only) +- `/timestamp` — Discord timestamp generator modal diff --git a/cogs/calendar.py b/cogs/calendar.py index 661a3f3..06388bb 100644 --- a/cogs/calendar.py +++ b/cogs/calendar.py @@ -10,7 +10,7 @@ import discord from discord.ext import commands from firebase_website import get_db -from .calendar_data import COUNTRY_TZ +from .tz_data import COUNTRY_TZ, find_offset log = logging.getLogger(__name__) @@ -22,23 +22,6 @@ TR_TZ = timezone(timedelta(hours=3)) WH_NAME = "koharu" -def _find_offset(country: str) -> float | None: - """Look up a country's UTC offset. Case-insensitive fuzzy match.""" - c = country.strip().lower() - if not c: - return None - for name, offset in COUNTRY_TZ.items(): - if c == name.lower(): - return offset - for name, offset in COUNTRY_TZ.items(): - if name.lower().startswith(c): - return offset - for name, offset in COUNTRY_TZ.items(): - if c in name.lower(): - return offset - return None - - # ========================================== # BOOK MODAL # ========================================== @@ -130,7 +113,7 @@ class BookModal(discord.ui.Modal, title="Book an Event"): ) # --- timezone lookup --- - offset = _find_offset(country) + offset = find_offset(country) if offset is None: return await interaction.response.send_message( "Unknown country. Try: Turkey, UK, US East, Japan, etc.", diff --git a/cogs/timestamp.py b/cogs/timestamp.py new file mode 100644 index 0000000..e56d9d8 --- /dev/null +++ b/cogs/timestamp.py @@ -0,0 +1,187 @@ +"""Timestamp converter cog — generate Discord timestamp markdown via modal. +###################################################################### +Usage: + /timestamp → opens a popup form (day, time, country) + and returns all Discord variants. +""" +import calendar as cal_mod +import logging +import re +from datetime import datetime, timezone, timedelta + +import discord +from discord import app_commands +from discord.ext import commands + +from .tz_data import find_offset + +log = logging.getLogger(__name__) + +DAY_RE = re.compile(r"^\d{1,2}$") # "4" or "14" +DAY_NAME_RE = re.compile(r"^(mon|tue|wed|thu|fri|sat|sun|monday|tuesday|wednesday|thursday|friday|saturday|sunday)$", re.IGNORECASE) +TIME_RE = re.compile(r"^\d{2}:\d{2}$") # "14:30" + +_WEEKDAY: dict[str, int] = { + "mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6, + "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, + "friday": 4, "saturday": 5, "sunday": 6, +} + +# --------------------------------------------------------------------------- +# 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""), + ] + + +# ========================================== +# MODAL +# ========================================== + +class TimestampModal(discord.ui.Modal, title="Timestamp Converter"): + """Popup form: day of month, 24h time, and a country for timezone.""" + + day = discord.ui.TextInput( + label="Day (number or name)", + placeholder="e.g. 4 or Sunday", + min_length=2, max_length=9, + ) + time = discord.ui.TextInput( + label="Time (24h, HH:MM)", + placeholder="e.g. 14:30", + min_length=4, max_length=5, + ) + country = discord.ui.TextInput( + label="Country / Timezone", + placeholder="e.g. Turkey, Japan, US East …", + default="Turkey", + max_length=30, + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + day_raw = self.day.value.strip() + time_str = self.time.value.strip() + country = self.country.value.strip() + + # --- day: number or day name --- + day_int: int | None = None + if DAY_RE.match(day_raw): + day_int = int(day_raw) + if not (1 <= day_int <= 31): + return await interaction.response.send_message( + "Day must be between 1 and 31.", ephemeral=True, + ) + elif not DAY_NAME_RE.match(day_raw): + return await interaction.response.send_message( + "Day must be a number (1-31) or day name like Sunday, Mon, etc.", + ephemeral=True, + ) + + # --- validate time --- + if not TIME_RE.match(time_str): + return await interaction.response.send_message( + "Time must be HH:MM (24h), e.g. 14:30", ephemeral=True, + ) + try: + parsed_time = datetime.strptime(time_str, "%H:%M") + except ValueError: + return await interaction.response.send_message( + "Invalid time value.", ephemeral=True, + ) + + # --- timezone lookup --- + offset = find_offset(country) + if offset is None: + return await interaction.response.send_message( + "Unknown country. Try: Turkey, UK, US East, Japan, etc.", + ephemeral=True, + ) + tz = timezone(timedelta(hours=offset)) + + # --- resolve month / year --- + now = datetime.now(tz) + if day_int is not None: + # --- numeric day: same rollover logic as book --- + if day_int < now.day: + next_month = now.month + 1 + if next_month > 12: + month_int = 1 + year_int = now.year + 1 + else: + month_int = next_month + year_int = now.year + else: + month_int = now.month + year_int = now.year + # --- make sure the day exists in the resolved month --- + max_days = cal_mod.monthrange(year_int, month_int)[1] + if day_int > max_days: + month_name = datetime(year_int, month_int, 1).strftime("%B") + return await interaction.response.send_message( + f"Day {day_int} doesn't exist in {month_name} {year_int}.", + ephemeral=True, + ) + else: + # --- day name: find next occurrence of that weekday --- + target_wd = _WEEKDAY[day_raw.lower()] + today_wd = now.weekday() + days_ahead = (target_wd - today_wd) % 7 + target_date = now + timedelta(days=days_ahead) + day_int = target_date.day + month_int = target_date.month + year_int = target_date.year + + # --- build datetime & unix timestamp --- + local_dt = datetime( + year_int, month_int, day_int, + parsed_time.hour, parsed_time.minute, + ).replace(tzinfo=tz) + unix_ts = int(local_dt.astimezone(timezone.utc).timestamp()) + + # --- output all formats --- + lines = [ + f"**{local_dt.strftime('%d %B at %H:%M')}**", + f"Unix: `{unix_ts}`", + "", + ] + for label, tag in _all_formats(unix_ts): + lines.append(f"`{tag}` ← {label}") + lines.append("") + lines.append("*Copy the tag you want — times display in each viewer's local timezone.*") + + await interaction.response.send_message( + "\n".join(lines), + ) + + +# ========================================== +# COG +# ========================================== + +class Timestamp(commands.Cog): + """Slash command that opens the timestamp-converter modal.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="timestamp", description="Convert a date+time into Discord timestamp markdown") + async def timestamp_cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal(TimestampModal()) + + +# ========================================== +# setup +# ========================================== + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Timestamp(bot)) diff --git a/cogs/timestamp_friends.py b/cogs/timestamp_friends.py new file mode 100644 index 0000000..cd11de3 --- /dev/null +++ b/cogs/timestamp_friends.py @@ -0,0 +1,201 @@ +"""Timestamp converter for friends — detects "my time" in messages. +###################################################################### +No slash commands. When a hardcoded friend writes *my time* anywhere +in their message the bot parses the surrounding text for time expressions, +converts them to the friend's local timezone, and replies with ready-to-use +Discord markdown. + +Requires: pip install dateparser +Friend map: TIMESTAMP_FRIEND_{DISCORD_USER_ID}=Country in credentials.env +""" +import logging +import os +import re +from datetime import datetime, timezone, timedelta + +import discord +from discord.ext import commands + +from .tz_data import find_offset + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# optional dependency +# --------------------------------------------------------------------------- +try: + import dateparser + from dateparser.search import search_dates as _search_dates + HAS_DATEPARSER = True +except ImportError: + HAS_DATEPARSER = False + log.warning( + "timestamp_friends: dateparser not installed; " + "install with `pip install dateparser`" + ) + +# --------------------------------------------------------------------------- +# trigger phrase & morning-indicator regex +# --------------------------------------------------------------------------- +_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 _load_friend_map() -> dict[int, tuple[str, float]]: + """Scan environment for ``TIMESTAMP_FRIEND_{ID}=Country`` entries. + + Returns ``{user_id: (country_name, utc_offset)}``. + """ + friends: dict[int, tuple[str, float]] = {} + for key, raw in os.environ.items(): + if not key.startswith("TIMESTAMP_FRIEND_"): + continue + uid_str = key.removeprefix("TIMESTAMP_FRIEND_") + try: + uid = int(uid_str) + except ValueError: + log.warning("timestamp_friends: invalid user ID in env var %s", key) + continue + country = raw.strip() + offset = find_offset(country) + if offset is None: + log.warning( + "timestamp_friends: unknown country '%s' for user %d (env var %s)", + country, uid, key, + ) + continue + friends[uid] = (country, offset) + 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 +# ========================================== + +class TimestampFriends(commands.Cog): + """Automatically convert time expressions when a friend says 'my time'.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.friends = _load_friend_map() + if not self.friends: + log.info("timestamp_friends: no friends configured.") + else: + log.info("timestamp_friends: loaded %d friend(s).", len(self.friends)) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + # --- quick bail-outs --- + if message.author.bot: + return + if not HAS_DATEPARSER: + return + if message.author.id not in self.friends: + return + if not _TRIGGER_RE.search(message.content): + return + + # --- strip the trigger phrase --- + cleaned = _TRIGGER_RE.sub(" ", message.content).strip() + if not cleaned: + return # message was literally just "my time" + + # --- parse time expressions --- + try: + raw_matches = _search_dates( + cleaned, + languages=["en"], + settings={ + "PREFER_DATES_FROM": "future", + "PREFER_DAY_OF_MONTH": "first", + }, + ) + 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] + tz = timezone(timedelta(hours=offset)) + + # --- process each match --- + results: list[tuple[datetime, int]] = [] # (local_dt, unix_ts) + 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) + + # attach friend's timezone, then convert to UTC + dt_aware = dt_adjusted.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)) + + if not results: + return # quiet fail + + # --- build reply --- + lines: list[str] = [] + 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) + + +# ========================================== +# setup +# ========================================== + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(TimestampFriends(bot)) diff --git a/cogs/calendar_data.py b/cogs/tz_data.py similarity index 72% rename from cogs/calendar_data.py rename to cogs/tz_data.py index eba2aec..1d15a2d 100644 --- a/cogs/calendar_data.py +++ b/cogs/tz_data.py @@ -1,4 +1,7 @@ -"""Country → UTC offset mapping for the calendar cog.""" +"""Shared timezone data — country → UTC offset, fuzzy lookup, autocomplete. +###################################################################### +Used by the calendar, /timestamp, and timestamp-friends cogs. +""" ###################################################################### # fmt: off @@ -11,7 +14,8 @@ COUNTRY_TZ: dict[str, float] = { "Spain": 2, "Sweden": 2, "Norway": 2, "Denmark": 2, "Switzerland": 2, "Austria": 2, - "Czechia": 2, "Greece": 3, + "Czechia": 2, "Czech Republic": 2, + "Czech": 2, "Greece": 3, "Finland": 3, "Romania": 3, "Ukraine": 3, "Portugal": 1, "Ireland": 1, "Iceland": 0, @@ -48,6 +52,23 @@ COUNTRY_TZ: dict[str, float] = { # fmt: on +def find_offset(country: str) -> float | None: + """Look up a country's UTC offset. Case-insensitive fuzzy match.""" + c = country.strip().lower() + if not c: + return None + for name, offset in COUNTRY_TZ.items(): + if c == name.lower(): + return offset + for name, offset in COUNTRY_TZ.items(): + if name.lower().startswith(c): + return offset + for name, offset in COUNTRY_TZ.items(): + if c in name.lower(): + return offset + return None + + async def country_autocomplete( _interaction, current: str, diff --git a/credentials_test.env b/credentials_test.env index f7f7261..31626d9 100644 --- a/credentials_test.env +++ b/credentials_test.env @@ -9,7 +9,7 @@ DISCORD_WEBHOOK_URL="yo channel webhook here" TELEGRAM_GROUP_ID="yo telegram group id here" #twitter things -TWITTER_RSS_URL="https://nitter.net/CalabiyauLeaks/rss" +TWITTER_RSS_URL="https://nitter.net/CalabiyauLeaks/rss" TWITTER_RSS_FALLBACKS= TWITTER_DISCORD_WEBHOOK="yo discord twitter channel here" TWITTER_DISCORD_WEBHOOK_MAO="yo discord twitter channel here for mao server" @@ -34,4 +34,10 @@ MELLO="" FEROX="" #calendar special webhook -CALENDAR_WEBHOOK_AGENT=webhook agent loll \ No newline at end of file +CALENDAR_WEBHOOK_AGENT=webhook agent loll + +timestamp friends — format: TIMESTAMP_FRIEND_{DISCORD_USER_ID}=Country +# Examples: +TIMESTAMP_FRIEND_123456789012345678=Turkey +TIMESTAMP_FRIEND_987654321098765432=Japan +TIMESTAMP_FRIEND_555555555555555555=US East \ No newline at end of file diff --git a/main.py b/main.py index f79bf64..71bdc69 100644 --- a/main.py +++ b/main.py @@ -75,6 +75,8 @@ async def main(): await bot.load_extension('blue_archive.inventory') await bot.load_extension('firebase_website') await bot.load_extension('cogs.calendar') + await bot.load_extension('cogs.timestamp') + await bot.load_extension('cogs.timestamp_friends') try: await bot.start(os.getenv('DISCORD_TOKEN'))