From 3437050f0e34a72c7a829320dd7d0e2a74f63461 Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sun, 15 Jun 2025 18:52:38 -0600 Subject: [PATCH] Add snowflake_time utility (#105) --- disagreement/__init__.py | 3 ++- disagreement/utils.py | 21 +++++++++++++++++++++ docs/utils.md | 20 ++++++++++++++++++++ mkdocs.yml | 3 ++- tests/test_utils.py | 16 ++++++++++++++-- 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 docs/utils.md diff --git a/disagreement/__init__.py b/disagreement/__init__.py index b6252c1..4fcac77 100644 --- a/disagreement/__init__.py +++ b/disagreement/__init__.py @@ -51,7 +51,7 @@ from .errors import ( NotFound, ) from .color import Color -from .utils import escape_markdown, escape_mentions, message_pager, utcnow +from .utils import escape_markdown, escape_mentions, message_pager, utcnow, snowflake_time from .enums import ( GatewayIntent, GatewayOpcode, @@ -153,6 +153,7 @@ __all__ = [ "escape_markdown", "escape_mentions", "message_pager", + "snowflake_time", "GatewayIntent", "GatewayOpcode", "ButtonStyle", diff --git a/disagreement/utils.py b/disagreement/utils.py index ab167e0..6648086 100644 --- a/disagreement/utils.py +++ b/disagreement/utils.py @@ -6,6 +6,9 @@ from datetime import datetime, timezone from typing import Any, AsyncIterator, Dict, Optional, TYPE_CHECKING import re +# Discord epoch in milliseconds (2015-01-01T00:00:00Z) +DISCORD_EPOCH = 1420070400000 + if TYPE_CHECKING: # pragma: no cover - for type hinting only from .models import Message, TextChannel @@ -15,6 +18,24 @@ def utcnow() -> datetime: return datetime.now(timezone.utc) +def snowflake_time(snowflake: int) -> datetime: + """Return the creation time of a Discord snowflake. + + Parameters + ---------- + snowflake: + The snowflake ID to convert. + + Returns + ------- + datetime + The UTC timestamp embedded in the snowflake. + """ + + timestamp_ms = (snowflake >> 22) + DISCORD_EPOCH + return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) + + async def message_pager( channel: "TextChannel", *, diff --git a/docs/utils.md b/docs/utils.md new file mode 100644 index 0000000..8681d2d --- /dev/null +++ b/docs/utils.md @@ -0,0 +1,20 @@ +# Utility Helpers + +Disagreement provides a few small utility functions for working with Discord data. + +## `utcnow` + +Returns the current timezone-aware UTC `datetime`. + +## `snowflake_time` + +Converts a Discord snowflake ID into the UTC timestamp when it was generated. + +```python +from disagreement.utils import snowflake_time + +created_at = snowflake_time(175928847299117063) +print(created_at.isoformat()) +``` + +The function extracts the timestamp from the snowflake and returns a `datetime` in UTC. diff --git a/mkdocs.yml b/mkdocs.yml index 0b7dd44..1be64ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,4 +62,5 @@ nav: - 'Mentions': 'mentions.md' - 'OAuth2': 'oauth2.md' - 'Presence': 'presence.md' - - 'Voice Client': 'voice_client.md' \ No newline at end of file + - 'Voice Client': 'voice_client.md' + - 'Utility Helpers': 'utils.md' diff --git a/tests/test_utils.py b/tests/test_utils.py index 4022506..bc9c077 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,11 @@ -from datetime import timezone +from datetime import datetime, timezone -from disagreement.utils import escape_markdown, escape_mentions, utcnow +from disagreement.utils import ( + escape_markdown, + escape_mentions, + utcnow, + snowflake_time +) def test_utcnow_timezone(): @@ -8,6 +13,13 @@ def test_utcnow_timezone(): assert now.tzinfo == timezone.utc +def test_snowflake_time(): + dt = datetime(2020, 1, 1, tzinfo=timezone.utc) + ms = int(dt.timestamp() * 1000) - 1420070400000 + snowflake = ms << 22 + assert snowflake_time(snowflake) == dt + + def test_escape_markdown(): text = "**bold** _under_ ~strike~ `code` > quote | pipe" escaped = escape_markdown(text)