From adfc93353255366de899b689fe87290b4be1952c Mon Sep 17 00:00:00 2001 From: Nick Marcel <17437302+nyannoying1337@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:34:54 +0200 Subject: [PATCH] Add optional profile buttons Discord Rich Presence supports up to two link buttons under the activity. Nothing exposed them, so there was no way to point people at a dotfiles repo or a stream from the presence. Configured in fetchcord_conf.yml and off by default: buttons: - label: My dotfiles url: https://github.com/me/dotfiles Validation drops a button rather than taking the presence down with it, because "my whole status broke" is a bad outcome for a typo in a config file. Discord's own rules are enforced up front: https only, labels at 32 characters, at most two buttons. Each rejection says which button and why. A malformed config - not a list, entries that aren't mappings - warns and yields no buttons instead of raising. The key is omitted from the payload entirely when nothing is configured, since pypresence rejects an empty list. --- fetch_cord/__main__.py | 3 + fetch_cord/constants.py | 4 ++ fetch_cord/cycle.py | 2 + fetch_cord/presence.py | 55 +++++++++++++- fetch_cord/resources/fetchcord_conf.yml | 8 +++ tests/test_presence.py | 96 ++++++++++++++++++++++++- 6 files changed, 166 insertions(+), 2 deletions(-) diff --git a/fetch_cord/__main__.py b/fetch_cord/__main__.py index 0cc2736..14626f4 100755 --- a/fetch_cord/__main__.py +++ b/fetch_cord/__main__.py @@ -25,6 +25,7 @@ get_component_id, get_infos, ) +from fetch_cord.presence import parse_buttons from fetch_cord.resources import systemd_service from fetch_cord.update import update @@ -105,8 +106,10 @@ def main( "host": args.nohost, } cycles = [c for c in cycles if not hide.get(c.name, False)] + buttons = parse_buttons(config.get("buttons")) for cycle in cycles: cycle.debug = args.debug + cycle.buttons = buttons if args.time: cycle.time = int(args.time) diff --git a/fetch_cord/constants.py b/fetch_cord/constants.py index 4c7c37b..5c06519 100644 --- a/fetch_cord/constants.py +++ b/fetch_cord/constants.py @@ -9,6 +9,10 @@ DEFAULT_BRANCH = "master" TESTING_BRANCH = "testing" +# Discord's limits on Rich Presence buttons. +MAX_BUTTONS = 2 +MAX_BUTTON_LABEL = 32 + # Error / status messages RESULT_NOT_FOUND = "Not Found" CUSTOM_TIME_MESSAGE = "setting custom time {time} seconds" diff --git a/fetch_cord/cycle.py b/fetch_cord/cycle.py index 252df2c..6b47fcc 100755 --- a/fetch_cord/cycle.py +++ b/fetch_cord/cycle.py @@ -27,6 +27,7 @@ class Cycle: debug: bool = False rpc: Presence | None = None + buttons: list[dict[str, str]] | None = None stop: Event @@ -93,6 +94,7 @@ def update( small_image=icon_id, small_text=icon, start=int(psutil.boot_time()), + buttons=self.buttons, ) ) diff --git a/fetch_cord/presence.py b/fetch_cord/presence.py index 427cf79..28ed8a7 100644 --- a/fetch_cord/presence.py +++ b/fetch_cord/presence.py @@ -7,6 +7,54 @@ from typing import Any +from fetch_cord.constants import MAX_BUTTON_LABEL, MAX_BUTTONS + + +def parse_buttons(raw: Any) -> list[dict[str, str]]: + """Validate configured profile buttons, dropping any Discord would reject. + + A bad button is worth a warning and a skip, never a crash: it would + otherwise take the whole presence down over a typo in a config file. + """ + if not raw: + return [] + if not isinstance(raw, list): + print("Warning: buttons must be a list, ignoring") + return [] + + buttons: list[dict[str, str]] = [] + for index, entry in enumerate(raw, start=1): + if not isinstance(entry, dict): + print(f"Warning: button {index} must be a mapping, ignoring") + continue + + label = str(entry.get("label", "")).strip() + url = str(entry.get("url", "")).strip() + + if not label or not url: + print(f"Warning: button {index} needs both a label and a url, ignoring") + continue + if len(label) > MAX_BUTTON_LABEL: + print( + f"Warning: button {index} label is longer than " + f"{MAX_BUTTON_LABEL} characters, ignoring" + ) + continue + # Discord only accepts https links on presence buttons. + if not url.lower().startswith("https://"): + print(f'Warning: button {index} url must start with "https://", ignoring') + continue + + buttons.append({"label": label, "url": url}) + + if len(buttons) > MAX_BUTTONS: + print( + f"Warning: Discord shows at most {MAX_BUTTONS} buttons, dropping the rest" + ) + buttons = buttons[:MAX_BUTTONS] + + return buttons + def build_presence_activity( *, @@ -17,13 +65,14 @@ def build_presence_activity( small_image: str, small_text: str, start: int, + buttons: list[dict[str, str]] | None = None, ) -> dict[str, Any]: """Return the activity payload sent to Discord for a presence update. Mirrors the argument names pypresence's ``Presence.update()`` accepts, so the result can be splatted directly: ``self.rpc.update(**payload)``. """ - return { + payload: dict[str, Any] = { "state": state, "details": details, "large_image": large_image, @@ -32,3 +81,7 @@ def build_presence_activity( "small_text": small_text, "start": start, } + # Only sent when configured - pypresence rejects an empty list. + if buttons: + payload["buttons"] = buttons + return payload diff --git a/fetch_cord/resources/fetchcord_conf.yml b/fetch_cord/resources/fetchcord_conf.yml index 28bc54b..6cfc50f 100644 --- a/fetch_cord/resources/fetchcord_conf.yml +++ b/fetch_cord/resources/fetchcord_conf.yml @@ -1,4 +1,12 @@ --- +# Up to two links shown under the presence on your profile. Discord only +# accepts https urls, and labels are limited to 32 characters. +# +# buttons: +# - label: My dotfiles +# url: https://github.com/me/dotfiles +buttons: [] + cycle_time: &cycle_time 30 cycles: - name: os diff --git a/tests/test_presence.py b/tests/test_presence.py index 60131aa..467eac5 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -8,7 +8,7 @@ from threading import Event from unittest.mock import MagicMock, patch -from fetch_cord.presence import build_presence_activity +from fetch_cord.presence import build_presence_activity, parse_buttons class TestBuildPresenceActivity(unittest.TestCase): @@ -73,6 +73,100 @@ def test_is_pure(self) -> None: self.assertNotEqual(a, b) +class TestParseButtons(unittest.TestCase): + """A typo in the config must cost a button, never the whole presence.""" + + GOOD = {"label": "Dotfiles", "url": "https://github.com/me/dotfiles"} + + def test_accepts_a_valid_button(self) -> None: + self.assertEqual(parse_buttons([self.GOOD]), [self.GOOD]) + + def test_nothing_configured_is_an_empty_list(self) -> None: + self.assertEqual(parse_buttons(None), []) + self.assertEqual(parse_buttons([]), []) + + def test_strips_surrounding_whitespace(self) -> None: + parsed = parse_buttons( + [{"label": " Dotfiles ", "url": " https://example.com "}] + ) + + self.assertEqual(parsed, [{"label": "Dotfiles", "url": "https://example.com"}]) + + @patch("builtins.print") + def test_rejects_http_urls(self, mock_print: MagicMock) -> None: + """Discord only accepts https on presence buttons.""" + self.assertEqual( + parse_buttons([{"label": "Site", "url": "http://example.com"}]), [] + ) + + @patch("builtins.print") + def test_rejects_a_half_filled_button(self, mock_print: MagicMock) -> None: + self.assertEqual(parse_buttons([{"label": "Site"}]), []) + self.assertEqual(parse_buttons([{"url": "https://example.com"}]), []) + + @patch("builtins.print") + def test_rejects_an_over_long_label(self, mock_print: MagicMock) -> None: + self.assertEqual( + parse_buttons([{"label": "x" * 33, "url": "https://example.com"}]), [] + ) + self.assertEqual( + len(parse_buttons([{"label": "x" * 32, "url": "https://example.com"}])), 1 + ) + + @patch("builtins.print") + def test_keeps_the_good_button_when_another_is_bad( + self, mock_print: MagicMock + ) -> None: + parsed = parse_buttons([{"label": "Bad", "url": "ftp://nope"}, self.GOOD]) + + self.assertEqual(parsed, [self.GOOD]) + + @patch("builtins.print") + def test_caps_at_the_discord_limit(self, mock_print: MagicMock) -> None: + parsed = parse_buttons( + [{"label": f"b{n}", "url": "https://example.com"} for n in range(5)] + ) + + self.assertEqual(len(parsed), 2) + + @patch("builtins.print") + def test_survives_a_malformed_config(self, mock_print: MagicMock) -> None: + self.assertEqual(parse_buttons("not a list"), []) + self.assertEqual(parse_buttons(["not a mapping"]), []) + self.assertEqual(parse_buttons([None]), []) + + +class TestButtonsInThePayload(unittest.TestCase): + def test_buttons_are_omitted_when_not_configured(self) -> None: + """pypresence rejects an empty buttons list, so don't send the key.""" + payload = build_presence_activity( + details="d", + state="s", + large_image="big", + large_text="l", + small_image="i", + small_text="t", + start=1, + ) + + self.assertNotIn("buttons", payload) + + def test_buttons_are_included_when_configured(self) -> None: + buttons = [{"label": "Dotfiles", "url": "https://example.com"}] + payload = build_presence_activity( + details="d", + state="s", + large_image="big", + large_text="l", + small_image="i", + small_text="t", + start=1, + buttons=buttons, + ) + + self.assertEqual(payload["buttons"], buttons) + + class TestCycleUpdatePayload(unittest.TestCase): """Cycle.update must hand the mapped values to pypresence as an activity."""