Add /discord/ feed
All checks were successful
Deploy to Server / deploy (push) Successful in 10s

This commit is contained in:
Joakim Hellsén 2026-03-14 02:50:40 +01:00
commit 4627d1cea0
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
16 changed files with 569 additions and 6 deletions

View file

@ -39,6 +39,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops")
RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")]
def discord_timestamp(dt: datetime.datetime | None) -> SafeText:
"""Convert a datetime to a Discord relative timestamp format.
Discord timestamps use the format <t:UNIX_TIMESTAMP:R> where R means relative time.
Example: <t:1773450272:R> displays as "2 hours ago" in Discord.
Args:
dt: The datetime to convert. If None, returns an empty string.
Returns:
SafeText: Escaped Discord timestamp token (e.g. &lt;t:1773450272:R&gt;) marked
safe for HTML insertion, or empty string if dt is None.
"""
if dt is None:
return SafeText("")
unix_timestamp: int = int(dt.timestamp())
# Keep this escaped so Atom/RSS HTML renderers don't treat <t:...:R> as an HTML tag.
return SafeText(f"&lt;t:{unix_timestamp}:R&gt;")
class BrowserFriendlyRss201rev2Feed(feedgenerator.Rss201rev2Feed):
"""RSS 2.0 feed generator with a browser-renderable XML content type."""
@ -340,6 +360,49 @@ def generate_date_html(item: Model) -> list[SafeText]:
return parts
def generate_discord_date_html(item: Model) -> list[SafeText]:
"""Generate HTML snippets for dates using Discord relative timestamp format.
Args:
item (Model): The campaign item containing start_at and end_at.
Returns:
list[SafeText]: A list of SafeText elements with Discord timestamp formatted dates.
"""
parts: list[SafeText] = []
end_at: datetime.datetime | None = getattr(item, "end_at", None)
start_at: datetime.datetime | None = getattr(item, "start_at", None)
if start_at or end_at:
start_part: SafeString = (
format_html(
"Starts: {} ({})",
start_at.strftime("%Y-%m-%d %H:%M %Z"),
discord_timestamp(start_at),
)
if start_at
else SafeText("")
)
end_part: SafeString = (
format_html(
"Ends: {} ({})",
end_at.strftime("%Y-%m-%d %H:%M %Z"),
discord_timestamp(end_at),
)
if end_at
else SafeText("")
)
# Start date and end date separated by a line break if both present
if start_part and end_part:
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
elif start_part:
parts.append(format_html("<p>{}</p>", start_part))
elif end_part:
parts.append(format_html("<p>{}</p>", end_part))
return parts
def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
"""Generate HTML summary for drops and append to parts list.
@ -1409,3 +1472,134 @@ class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:reward_campaign_feed_atom")
# Discord feed variants: Atom feeds with Discord relative timestamps
class OrganizationDiscordFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed):
"""Discord feed for latest organizations with Discord relative timestamps."""
subtitle: str = OrganizationRSSFeed.description
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("twitch:organization_feed_discord")
class GameDiscordFeed(TTVDropsAtomBaseFeed, GameFeed):
"""Discord feed for newly added games with Discord relative timestamps."""
subtitle: str = GameFeed.description
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("twitch:game_feed_discord")
class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
"""Discord feed for latest drop campaigns with Discord relative timestamps."""
subtitle: str = DropCampaignFeed.description
def item_description(self, item: DropCampaign) -> SafeText:
"""Return a description of the campaign with Discord timestamps."""
parts: list[SafeText] = []
parts.extend(generate_item_image(item))
parts.extend(generate_description_html(item=item))
parts.extend(generate_discord_date_html(item=item))
parts.extend(generate_drops_summary_html(item=item))
parts.extend(generate_channels_html(item))
parts.extend(genereate_details_link_html(item))
return SafeText("".join(str(p) for p in parts))
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("twitch:campaign_feed_discord")
class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
"""Discord feed for latest drop campaigns for a specific game with Discord relative timestamps."""
def item_description(self, item: DropCampaign) -> SafeText:
"""Return a description of the campaign with Discord timestamps."""
parts: list[SafeText] = []
parts.extend(generate_item_image_tag(item))
parts.extend(generate_details_link(item))
parts.extend(generate_discord_date_html(item))
parts.extend(generate_drops_summary_html(item))
parts.extend(generate_channels_html(item))
return SafeText("".join(str(p) for p in parts))
def feed_url(self, obj: Game) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("twitch:game_campaign_feed_discord", args=[obj.twitch_id])
class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
"""Discord feed for latest reward campaigns with Discord relative timestamps."""
subtitle: str = RewardCampaignFeed.description
def item_description(self, item: RewardCampaign) -> SafeText:
"""Return a description of the reward campaign with Discord timestamps."""
parts: list = []
if item.summary:
parts.append(format_html("<p>{}</p>", item.summary))
if item.starts_at or item.ends_at:
start_part = (
format_html(
"Starts: {} ({})",
item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
discord_timestamp(item.starts_at),
)
if item.starts_at
else ""
)
end_part = (
format_html(
"Ends: {} ({})",
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
discord_timestamp(item.ends_at),
)
if item.ends_at
else ""
)
if start_part and end_part:
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
elif start_part:
parts.append(format_html("<p>{}</p>", start_part))
elif end_part:
parts.append(format_html("<p>{}</p>", end_part))
if item.is_sitewide:
parts.append(
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
)
elif item.game:
parts.append(
format_html(
"<p>Game: {}</p>",
item.game.display_name or item.game.name,
),
)
if item.about_url:
parts.append(
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
)
if item.external_url:
parts.append(
format_html('<p><a href="{}">Redeem reward</a></p>', item.external_url),
)
return SafeText("".join(str(p) for p in parts))
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("twitch:reward_campaign_feed_discord")

View file

@ -1,8 +1,11 @@
"""Test RSS feeds."""
import datetime
import logging
import re
from collections.abc import Callable
from contextlib import AbstractContextManager
from datetime import UTC
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
@ -21,6 +24,7 @@ from twitch.feeds import GameFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignFeed
from twitch.feeds import TTVDropsBaseFeed
from twitch.feeds import discord_timestamp
from twitch.models import Channel
from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet
@ -37,8 +41,6 @@ STYLESHEET_PATH: Path = (
)
if TYPE_CHECKING:
import datetime
from django.test.client import _MonkeyPatchedWSGIResponse
from django.utils.feedgenerator import Enclosure
@ -1183,3 +1185,221 @@ def test_rss_feeds_return_200(
url: str = reverse(viewname=url_name, kwargs=kwargs)
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
class DiscordFeedTestCase(TestCase):
"""Test Discord feeds with relative timestamps."""
def setUp(self) -> None:
"""Set up test fixtures."""
self.org: Organization = Organization.objects.create(
twitch_id="test-org-discord",
name="Test Organization Discord",
)
self.org.save()
self.game: Game = Game.objects.create(
twitch_id="test-game-discord",
slug="test-game-discord",
name="Test Game Discord",
display_name="Test Game Discord",
)
self.game.owners.add(self.org)
self.campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="test-campaign-discord",
name="Test Campaign Discord",
game=self.game,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(days=7),
operation_names=["DropCampaignDetails"],
)
self.game.box_art_size_bytes = 42
self.game.box_art_mime_type = "image/png"
self.game.box_art = "https://example.com/box.png"
self.game.save()
self.campaign.image_size_bytes = 314
self.campaign.image_mime_type = "image/gif"
self.campaign.image_url = "https://example.com/campaign.png"
self.campaign.save()
self.reward_campaign: RewardCampaign = RewardCampaign.objects.create(
twitch_id="test-reward-discord",
name="Test Reward Campaign Discord",
brand="Test Brand",
starts_at=timezone.now() - timedelta(days=1),
ends_at=timezone.now() + timedelta(days=7),
status="ACTIVE",
summary="Test reward summary",
instructions="Watch and complete objectives",
external_url="https://example.com/reward",
about_url="https://example.com/about",
is_sitewide=False,
game=self.game,
)
def test_discord_timestamp_helper(self) -> None:
"""Test discord_timestamp helper function."""
dt: datetime.datetime = datetime.datetime(2026, 3, 14, 12, 0, 0, tzinfo=UTC)
result: str = str(discord_timestamp(dt))
assert result.startswith("&lt;t:")
assert result.endswith(":R&gt;")
# Test None input
assert not str(discord_timestamp(None))
def test_organization_discord_feed(self) -> None:
"""Test organization Discord feed returns 200."""
url: str = reverse("twitch:organization_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
assert response["Content-Disposition"] == "inline"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "http://www.w3.org/2005/Atom" in content
def test_game_discord_feed(self) -> None:
"""Test game Discord feed returns 200."""
url: str = reverse("twitch:game_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "Owned by Test Organization Discord." in content
def test_campaign_discord_feed(self) -> None:
"""Test campaign Discord feed returns 200 with Discord timestamps."""
url: str = reverse("twitch:campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
# Should contain Discord timestamp format (double-escaped in XML payload)
assert "&amp;lt;t:" in content
assert ":R&amp;gt;" in content
assert "()" not in content
def test_game_campaign_discord_feed(self) -> None:
"""Test game-specific campaign Discord feed returns 200."""
url: str = reverse(
"twitch:game_campaign_feed_discord",
args=[self.game.twitch_id],
)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "Test Game Discord" in content
def test_reward_campaign_discord_feed(self) -> None:
"""Test reward campaign Discord feed returns 200."""
url: str = reverse("twitch:reward_campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
# Should contain Discord timestamp format (double-escaped in XML payload)
assert "&amp;lt;t:" in content
assert ":R&amp;gt;" in content
assert "()" not in content
def test_discord_feeds_use_url_ids_and_correct_self_links(self) -> None:
"""All Discord feeds should use absolute URL entry IDs and matching self links."""
discord_feed_cases: list[tuple[str, dict[str, str], str]] = [
(
"twitch:campaign_feed_discord",
{},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
),
(
"twitch:game_feed_discord",
{},
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
),
(
"twitch:game_campaign_feed_discord",
{"twitch_id": self.game.twitch_id},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
),
(
"twitch:organization_feed_discord",
{},
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
),
(
"twitch:reward_campaign_feed_discord",
{},
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
),
]
for url_name, kwargs, expected_entry_id in discord_feed_cases:
url: str = reverse(url_name, kwargs=kwargs)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
expected_self_link: str = f'href="http://testserver{url}"'
msg: str = f"Expected self link in Discord feed {url_name}, got: {content}"
assert 'rel="self"' in content, msg
msg = f"Expected self link to match feed URL for {url_name}, got: {content}"
assert expected_self_link in content, msg
msg = f"Expected entry ID to be absolute URL for {url_name}, got: {content}"
assert f"<id>{expected_entry_id}</id>" in content, msg
def test_discord_feeds_include_stylesheet_processing_instruction(self) -> None:
"""Discord feeds should include an xml-stylesheet processing instruction."""
feed_urls: list[str] = [
reverse("twitch:campaign_feed_discord"),
reverse("twitch:game_feed_discord"),
reverse("twitch:game_campaign_feed_discord", args=[self.game.twitch_id]),
reverse("twitch:organization_feed_discord"),
reverse("twitch:reward_campaign_feed_discord"),
]
for url in feed_urls:
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
assert "<?xml-stylesheet" in content
assert "rss_styles.xslt" in content
assert 'type="text/xsl"' in content
assert 'media="screen"' in content
def test_discord_campaign_feed_contains_discord_timestamps(self) -> None:
"""Discord campaign feed should contain Discord relative timestamps."""
url: str = reverse("twitch:campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
# Should contain Discord timestamp format (double-escaped in XML payload)
discord_pattern: re.Pattern[str] = re.compile(r"&amp;lt;t:\d+:R&amp;gt;")
assert discord_pattern.search(content), (
f"Expected Discord timestamp format &amp;lt;t:UNIX_TIMESTAMP:R&amp;gt; in content, got: {content}"
)
assert "()" not in content
def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None:
"""Discord reward campaign feed should contain Discord relative timestamps."""
url: str = reverse("twitch:reward_campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
# Should contain Discord timestamp format (double-escaped in XML payload)
discord_pattern: re.Pattern[str] = re.compile(r"&amp;lt;t:\d+:R&amp;gt;")
assert discord_pattern.search(content), (
f"Expected Discord timestamp format &amp;lt;t:UNIX_TIMESTAMP:R&amp;gt; in content, got: {content}"
)
assert "()" not in content

View file

@ -4,23 +4,29 @@ from django.urls import path
from twitch import views
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver
app_name = "twitch"
urlpatterns: list[URLPattern] = [
urlpatterns: list[URLPattern | URLResolver] = [
path("", views.dashboard, name="dashboard"),
path("badges/", views.badge_list_view, name="badge_list"),
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
@ -128,4 +134,22 @@ urlpatterns: list[URLPattern] = [
RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom",
),
# Discord feeds (Atom feeds with Discord relative timestamps)
path("discord/campaigns/", DropCampaignDiscordFeed(), name="campaign_feed_discord"),
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"),
path(
"discord/games/<str:twitch_id>/campaigns/",
GameCampaignDiscordFeed(),
name="game_campaign_feed_discord",
),
path(
"discord/organizations/",
OrganizationDiscordFeed(),
name="organization_feed_discord",
),
path(
"discord/reward-campaigns/",
RewardCampaignDiscordFeed(),
name="reward_campaign_feed_discord",
),
]

View file

@ -41,14 +41,19 @@ from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel
from twitch.models import ChatBadge
@ -1829,38 +1834,52 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
"description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
"discord_url": absolute(reverse("twitch:organization_feed_discord")),
"example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(OrganizationDiscordFeed())
if show_atom
else "",
},
{
"title": "All Games",
"description": "Latest games added to TTVDrops",
"url": absolute(reverse("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")),
"discord_url": absolute(reverse("twitch:game_feed_discord")),
"example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
"example_xml_discord": render_feed(GameDiscordFeed()) if show_atom else "",
},
{
"title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games",
"url": absolute(reverse("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"discord_url": absolute(reverse("twitch:campaign_feed_discord")),
"example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(DropCampaignDiscordFeed())
if show_atom
else "",
},
{
"title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
"discord_url": absolute(reverse("twitch:reward_campaign_feed_discord")),
"example_xml": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(RewardCampaignDiscordFeed())
if show_atom
else "",
},
]
@ -1890,6 +1909,16 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"discord_url": (
absolute(
reverse(
"twitch:game_campaign_feed_discord",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/discord/games/<game_id>/campaigns/")
),
"has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game
@ -1899,6 +1928,11 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game and show_atom
else ""
),
"example_xml_discord": (
render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
},
]