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

@ -33,6 +33,10 @@
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'twitch:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added games (RSS)" title="Newly added games (RSS)"
@ -41,6 +45,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'twitch:game_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added organizations (RSS)" title="Newly added organizations (RSS)"
@ -49,6 +57,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added organizations (Atom)" title="Newly added organizations (Atom)"
href="{% url 'twitch:organization_feed_atom' %}" /> href="{% url 'twitch:organization_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added organizations (Discord)"
href="{% url 'twitch:organization_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added reward campaigns (RSS)" title="Newly added reward campaigns (RSS)"
@ -57,6 +69,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added reward campaigns (Atom)" title="Newly added reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'twitch:reward_campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
{# Allow child templates to inject page-specific alternates into the head #} {# Allow child templates to inject page-specific alternates into the head #}
{% block extra_head %} {% block extra_head %}
{% endblock extra_head %} {% endblock extra_head %}

View file

@ -14,6 +14,10 @@
type="application/atom+xml" type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Atom)" title="{{ campaign.game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" /> href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' campaign.game.twitch_id %}" />
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
@ -90,6 +94,8 @@
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a> title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" <a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a> title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
<a href="{% url 'twitch:game_campaign_feed_discord' campaign.game.twitch_id %}"
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -14,6 +14,10 @@
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'twitch:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -25,6 +29,8 @@
title="RSS feed for all campaigns">[rss]</a> title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}" <a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'twitch:campaign_feed_discord' %}"
title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as CSV">[csv]</a> title="Export campaigns as CSV">[csv]</a>
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"

View file

@ -13,6 +13,10 @@
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'twitch:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -33,6 +37,8 @@
title="RSS feed for all campaigns">[rss]</a> title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}" <a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for campaigns">[atom]</a> title="Atom feed for campaigns">[atom]</a>
<a href="{% url 'twitch:campaign_feed_discord' %}"
title="Discord feed for campaigns">[discord]</a>
</div> </div>
<hr /> <hr />
{% if campaigns_by_game %} {% if campaigns_by_game %}

View file

@ -8,9 +8,13 @@
<h1>RSS Feeds Documentation</h1> <h1>RSS Feeds Documentation</h1>
<p>This page lists all available RSS feeds for TTVDrops.</p> <p>This page lists all available RSS feeds for TTVDrops.</p>
<p> <p>
Note: Atom feeds are also available for the same resources under the Atom feeds are also available for the same resources under the
<code>/atom/</code> endpoints (links labeled "Atom" are shown next to RSS links). <code>/atom/</code> endpoints.
Both RSS and Atom formats are supported and served in parallel for backward compatibility. </p>
<p>
Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds
that include Discord relative timestamps (e.g., <code>&lt;t:1773450272:R&gt;</code>) for dates,
making them ideal for Discord bots and integrations.
</p> </p>
<section> <section>
<h2>Global RSS Feeds</h2> <h2>Global RSS Feeds</h2>
@ -26,12 +30,20 @@
&nbsp;|&nbsp; &nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a> <a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a>
{% endif %} {% endif %}
{% if feed.discord_url %}
&nbsp;|&nbsp;
<a href="{{ feed.discord_url }}">Subscribe to {{ feed.title }} Discord Feed</a>
{% endif %}
</p> </p>
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
{% if feed.example_xml_atom %} {% if feed.example_xml_atom %}
<h4>Atom example</h4> <h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre> <pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %} {% endif %}
{% if feed.example_xml_discord %}
<h4>Discord example</h4>
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -49,6 +61,7 @@
<p> <p>
Endpoint: <code>{{ feed.url }}</code> Endpoint: <code>{{ feed.url }}</code>
{% if feed.atom_url %}&nbsp;|&nbsp; Atom: <code>{{ feed.atom_url }}</code>{% endif %} {% if feed.atom_url %}&nbsp;|&nbsp; Atom: <code>{{ feed.atom_url }}</code>{% endif %}
{% if feed.discord_url %}&nbsp;|&nbsp; Discord: <code>{{ feed.discord_url }}</code>{% endif %}
</p> </p>
{% if feed.has_sample %} {% if feed.has_sample %}
<p> <p>
@ -57,6 +70,10 @@
&nbsp;|&nbsp; &nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">View Atom example</a> <a href="{{ feed.atom_url }}">View Atom example</a>
{% endif %} {% endif %}
{% if feed.discord_url %}
&nbsp;|&nbsp;
<a href="{{ feed.discord_url }}">View Discord example</a>
{% endif %}
</p> </p>
{% endif %} {% endif %}
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
@ -64,6 +81,10 @@
<h4>Atom example</h4> <h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre> <pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %} {% endif %}
{% if feed.example_xml_discord %}
<h4>Discord example</h4>
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -13,6 +13,10 @@
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name }} campaigns (Atom)" title="{{ game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" /> href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" />
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
@ -49,6 +53,8 @@
title="RSS feed for {{ game.display_name }} campaigns">[rss]</a> title="RSS feed for {{ game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" <a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a> title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
<a href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}"
title="Discord feed for {{ game.display_name }} campaigns">[discord]</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -12,6 +12,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'twitch:game_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -22,6 +26,8 @@
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}" <a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -11,6 +11,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'twitch:game_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -20,6 +24,8 @@
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}" <a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -9,6 +9,8 @@
title="RSS feed for all organizations">[rss]</a> title="RSS feed for all organizations">[rss]</a>
<a href="{% url 'twitch:organization_feed_atom' %}" <a href="{% url 'twitch:organization_feed_atom' %}"
title="Atom feed for all organizations">[atom]</a> title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'twitch:organization_feed_discord' %}"
title="Discord feed for all organizations">[discord]</a>
<a href="{% url 'twitch:export_organizations_csv' %}" <a href="{% url 'twitch:export_organizations_csv' %}"
title="Export all organizations as CSV">[csv]</a> title="Export all organizations as CSV">[csv]</a>
<a href="{% url 'twitch:export_organizations_json' %}" <a href="{% url 'twitch:export_organizations_json' %}"

View file

@ -13,6 +13,10 @@
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)" title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" /> href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" />
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}

View file

@ -13,6 +13,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Atom)" title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'twitch:reward_campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<!-- Campaign Title --> <!-- Campaign Title -->
@ -36,6 +40,8 @@
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a> title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}" <a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a> title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'twitch:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a>
</div> </div>
<!-- Campaign Summary --> <!-- Campaign Summary -->
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %} {% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}

View file

@ -12,6 +12,10 @@
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Atom)" title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'twitch:reward_campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<h1>Reward Campaigns</h1> <h1>Reward Campaigns</h1>
@ -21,6 +25,8 @@
title="RSS feed for all reward campaigns">[rss]</a> title="RSS feed for all reward campaigns">[rss]</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}" <a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a> title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'twitch:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a>
</div> </div>
<p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p> <p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p>
<p> <p>

View file

@ -39,6 +39,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops")
RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")] 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): class BrowserFriendlyRss201rev2Feed(feedgenerator.Rss201rev2Feed):
"""RSS 2.0 feed generator with a browser-renderable XML content type.""" """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 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]: def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
"""Generate HTML summary for drops and append to parts list. """Generate HTML summary for drops and append to parts list.
@ -1409,3 +1472,134 @@ class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:reward_campaign_feed_atom") 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.""" """Test RSS feeds."""
import datetime
import logging import logging
import re
from collections.abc import Callable from collections.abc import Callable
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from datetime import UTC
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -21,6 +24,7 @@ from twitch.feeds import GameFeed
from twitch.feeds import OrganizationRSSFeed from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
from twitch.feeds import TTVDropsBaseFeed from twitch.feeds import TTVDropsBaseFeed
from twitch.feeds import discord_timestamp
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet from twitch.models import ChatBadgeSet
@ -37,8 +41,6 @@ STYLESHEET_PATH: Path = (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime
from django.test.client import _MonkeyPatchedWSGIResponse from django.test.client import _MonkeyPatchedWSGIResponse
from django.utils.feedgenerator import Enclosure from django.utils.feedgenerator import Enclosure
@ -1183,3 +1185,221 @@ def test_rss_feeds_return_200(
url: str = reverse(viewname=url_name, kwargs=kwargs) url: str = reverse(viewname=url_name, kwargs=kwargs)
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 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 import views
from twitch.feeds import DropCampaignAtomFeed from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver
app_name = "twitch" app_name = "twitch"
urlpatterns: list[URLPattern] = [ urlpatterns: list[URLPattern | URLResolver] = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("badges/", views.badge_list_view, name="badge_list"), path("badges/", views.badge_list_view, name="badge_list"),
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"), path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
@ -128,4 +134,22 @@ urlpatterns: list[URLPattern] = [
RewardCampaignAtomFeed(), RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom", 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 pygments.lexers.data import JsonLexer
from twitch.feeds import DropCampaignAtomFeed from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
@ -1829,38 +1834,52 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
"description": "Latest organizations added to TTVDrops", "description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("twitch:organization_feed")), "url": absolute(reverse("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")), "atom_url": absolute(reverse("twitch:organization_feed_atom")),
"discord_url": absolute(reverse("twitch:organization_feed_discord")),
"example_xml": render_feed(OrganizationRSSFeed()), "example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed()) "example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom if show_atom
else "", else "",
"example_xml_discord": render_feed(OrganizationDiscordFeed())
if show_atom
else "",
}, },
{ {
"title": "All Games", "title": "All Games",
"description": "Latest games added to TTVDrops", "description": "Latest games added to TTVDrops",
"url": absolute(reverse("twitch:game_feed")), "url": absolute(reverse("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")), "atom_url": absolute(reverse("twitch:game_feed_atom")),
"discord_url": absolute(reverse("twitch:game_feed_discord")),
"example_xml": render_feed(GameFeed()), "example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "", "example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
"example_xml_discord": render_feed(GameDiscordFeed()) if show_atom else "",
}, },
{ {
"title": "All Drop Campaigns", "title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games", "description": "Latest drop campaigns across all games",
"url": absolute(reverse("twitch:campaign_feed")), "url": absolute(reverse("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")), "atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"discord_url": absolute(reverse("twitch:campaign_feed_discord")),
"example_xml": render_feed(DropCampaignFeed()), "example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed()) "example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom if show_atom
else "", else "",
"example_xml_discord": render_feed(DropCampaignDiscordFeed())
if show_atom
else "",
}, },
{ {
"title": "All Reward Campaigns", "title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch", "description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("twitch:reward_campaign_feed")), "url": absolute(reverse("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")), "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": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed()) "example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom if show_atom
else "", 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 if sample_game
else absolute("/atom/games/<game_id>/campaigns/") 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), "has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) "example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game if sample_game
@ -1899,6 +1928,11 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game and show_atom if sample_game and show_atom
else "" else ""
), ),
"example_xml_discord": (
render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
}, },
] ]