diff --git a/templates/base.html b/templates/base.html index 2a95814..a8cad8e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -33,6 +33,10 @@ type="application/atom+xml" title="All campaigns (Atom)" href="{% url 'twitch:campaign_feed_atom' %}" /> + + + + {# Allow child templates to inject page-specific alternates into the head #} {% block extra_head %} {% endblock extra_head %} diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index ffabe75..b4bb0df 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -14,6 +14,10 @@ type="application/atom+xml" title="{{ campaign.game.display_name }} campaigns (Atom)" href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" /> + {% endif %} {% endblock extra_head %} {% block content %} @@ -90,6 +94,8 @@ title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss] [atom] + [discord] {% endif %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 67dbec8..a2a1783 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -14,6 +14,10 @@ type="application/atom+xml" title="All campaigns (Atom)" href="{% url 'twitch:campaign_feed_atom' %}" /> + {% endblock extra_head %} {% block content %}
@@ -25,6 +29,8 @@ title="RSS feed for all campaigns">[rss] [atom] + [discord] [csv] + {% endblock extra_head %} {% block content %}
@@ -33,6 +37,8 @@ title="RSS feed for all campaigns">[rss] [atom] + [discord]
{% if campaigns_by_game %} diff --git a/templates/twitch/docs_rss.html b/templates/twitch/docs_rss.html index 6c02768..b43f9b1 100644 --- a/templates/twitch/docs_rss.html +++ b/templates/twitch/docs_rss.html @@ -8,9 +8,13 @@

RSS Feeds Documentation

This page lists all available RSS feeds for TTVDrops.

- Note: Atom feeds are also available for the same resources under the - /atom/ endpoints (links labeled "Atom" are shown next to RSS links). - Both RSS and Atom formats are supported and served in parallel for backward compatibility. + Atom feeds are also available for the same resources under the + /atom/ endpoints. +

+

+ Discord feeds are available under the /discord/ endpoints. These are Atom feeds + that include Discord relative timestamps (e.g., <t:1773450272:R>) for dates, + making them ideal for Discord bots and integrations.

Global RSS Feeds

@@ -26,12 +30,20 @@  |  Subscribe to {{ feed.title }} Atom Feed {% endif %} + {% if feed.discord_url %} +  |  + Subscribe to {{ feed.title }} Discord Feed + {% endif %}

{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}
{% if feed.example_xml_atom %}

Atom example

{{ feed.example_xml_atom|escape }}
{% endif %} + {% if feed.example_xml_discord %} +

Discord example

+
{{ feed.example_xml_discord|escape }}
+ {% endif %} {% endfor %} @@ -49,6 +61,7 @@

Endpoint: {{ feed.url }} {% if feed.atom_url %} |  Atom: {{ feed.atom_url }}{% endif %} + {% if feed.discord_url %} |  Discord: {{ feed.discord_url }}{% endif %}

{% if feed.has_sample %}

@@ -57,6 +70,10 @@  |  View Atom example {% endif %} + {% if feed.discord_url %} +  |  + View Discord example + {% endif %}

{% endif %}
{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}
@@ -64,6 +81,10 @@

Atom example

{{ feed.example_xml_atom|escape }}
{% endif %} + {% if feed.example_xml_discord %} +

Discord example

+
{{ feed.example_xml_discord|escape }}
+ {% endif %} {% endfor %} diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index 6338308..26f6e0c 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -13,6 +13,10 @@ type="application/atom+xml" title="{{ game.display_name }} campaigns (Atom)" href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" /> + {% endif %} {% endblock extra_head %} {% block content %} @@ -49,6 +53,8 @@ title="RSS feed for {{ game.display_name }} campaigns">[rss] [atom] + [discord] diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index c3a11cd..87fe9ee 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -12,6 +12,10 @@ type="application/atom+xml" title="Newly added games (Atom)" href="{% url 'twitch:game_feed_atom' %}" /> + {% endblock extra_head %} {% block content %}
@@ -22,6 +26,8 @@ [rss] [atom] + [discord] [csv] + {% endblock extra_head %} {% block content %}
@@ -20,6 +24,8 @@ [rss] [atom] + [discord] [csv] [rss] [atom] + [discord] [csv] + {% endfor %} {% endif %} {% endblock extra_head %} diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index c3bfe3c..ffd9388 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -13,6 +13,10 @@ type="application/atom+xml" title="Reward campaigns (Atom)" href="{% url 'twitch:reward_campaign_feed_atom' %}" /> + {% endblock extra_head %} {% block content %} @@ -36,6 +40,8 @@ title="RSS feed for all reward campaigns">RSS feed for all reward campaigns [atom] + [discord] {% if reward_campaign.summary %}

{{ reward_campaign.summary|linebreaksbr }}

{% endif %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 6ef71e6..99b8e7f 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -12,6 +12,10 @@ type="application/atom+xml" title="Reward campaigns (Atom)" href="{% url 'twitch:reward_campaign_feed_atom' %}" /> + {% endblock extra_head %} {% block content %}

Reward Campaigns

@@ -21,6 +25,8 @@ title="RSS feed for all reward campaigns">[rss] [atom] + [discord]

This is an archive of old Twitch reward campaigns because we do not monitor them.

diff --git a/twitch/feeds.py b/twitch/feeds.py index 43ab43d..69626ca 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -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 where R means relative time. + Example: 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. <t:1773450272:R>) 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 as an HTML tag. + return SafeText(f"<t:{unix_timestamp}:R>") + + 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("

{}
{}

", start_part, end_part)) + elif start_part: + parts.append(format_html("

{}

", start_part)) + elif end_part: + parts.append(format_html("

{}

", 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("

{}

", 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("

{}
{}

", start_part, end_part)) + elif start_part: + parts.append(format_html("

{}

", start_part)) + elif end_part: + parts.append(format_html("

{}

", end_part)) + + if item.is_sitewide: + parts.append( + SafeText("

This is a sitewide reward campaign

"), + ) + elif item.game: + parts.append( + format_html( + "

Game: {}

", + item.game.display_name or item.game.name, + ), + ) + + if item.about_url: + parts.append( + format_html('

Learn more

', item.about_url), + ) + + if item.external_url: + parts.append( + format_html('

Redeem reward

', 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") diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index 30c1fca..384327d 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -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("<t:") + assert result.endswith(":R>") + + # 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 " 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 " 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 " 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 " 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 " 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"{expected_entry_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 " 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"&lt;t:\d+:R&gt;") + assert discord_pattern.search(content), ( + f"Expected Discord timestamp format &lt;t:UNIX_TIMESTAMP:R&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"&lt;t:\d+:R&gt;") + assert discord_pattern.search(content), ( + f"Expected Discord timestamp format &lt;t:UNIX_TIMESTAMP:R&gt; in content, got: {content}" + ) + assert "()" not in content diff --git a/twitch/urls.py b/twitch/urls.py index 1485220..2947512 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -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//", 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//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", + ), ] diff --git a/twitch/views.py b/twitch/views.py index 2e6d8e7..c4ef275 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -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//campaigns/") ), + "discord_url": ( + absolute( + reverse( + "twitch:game_campaign_feed_discord", + args=[sample_game.twitch_id], + ), + ) + if sample_game + else absolute("/discord/games//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 "" + ), }, ]