diff --git a/fetch_cord/__main__.py b/fetch_cord/__main__.py index f1b185a..f376b11 100755 --- a/fetch_cord/__main__.py +++ b/fetch_cord/__main__.py @@ -26,7 +26,7 @@ NativeProvider, get_infos, ) -from fetch_cord.presence import ResolvedCycle, resolve_cycle +from fetch_cord.presence import ResolvedCycle, parse_buttons, resolve_cycle from fetch_cord.processes import PauseWatcher from fetch_cord.resources import systemd_service from fetch_cord.update import update @@ -152,8 +152,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: # handle_args has already validated this parses as a float. cycle.time = int(float(args.time)) diff --git a/fetch_cord/constants.py b/fetch_cord/constants.py index a108c86..934519c 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" # Last-resort return from get_component_id when nothing in the id table diff --git a/fetch_cord/cycle.py b/fetch_cord/cycle.py index 6941575..dd0c068 100755 --- a/fetch_cord/cycle.py +++ b/fetch_cord/cycle.py @@ -28,6 +28,7 @@ class Cycle: debug: bool = False rpc: Presence | None = None connected: bool = False + buttons: list[dict[str, str]] | None = None stop: Event @@ -97,6 +98,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 df1f593..7196b4f 100644 --- a/fetch_cord/presence.py +++ b/fetch_cord/presence.py @@ -8,7 +8,12 @@ from dataclasses import dataclass from typing import Any -from fetch_cord.constants import RESULT_NOT_FOUND, UNKNOWN_COMPONENT_ID +from fetch_cord.constants import ( + MAX_BUTTON_LABEL, + MAX_BUTTONS, + RESULT_NOT_FOUND, + UNKNOWN_COMPONENT_ID, +) from fetch_cord.fetch import get_component_id # Values that mean "we could not work this out", rather than something worth @@ -81,6 +86,52 @@ def resolve_cycle( ) +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( *, details: str, @@ -90,6 +141,7 @@ 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. @@ -109,7 +161,7 @@ def build_presence_activity( elif tooltip in _PLACEHOLDERS: tooltip = None - return { + payload: dict[str, Any] = { "state": state, "details": details, "large_image": large_image, @@ -118,3 +170,7 @@ def build_presence_activity( "small_text": tooltip, "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 9da9765..ea0cb0d 100644 --- a/fetch_cord/resources/fetchcord_conf.yml +++ b/fetch_cord/resources/fetchcord_conf.yml @@ -8,6 +8,14 @@ # - spotify pause_when: [] +# 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 9bcbab7..7bc0d40 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -8,7 +8,13 @@ from threading import Event from unittest.mock import MagicMock, patch -from fetch_cord.presence import ResolvedCycle, build_presence_activity, resolve_cycle +from fetch_cord.constants import UNKNOWN_COMPONENT_ID +from fetch_cord.presence import ( + ResolvedCycle, + build_presence_activity, + parse_buttons, + resolve_cycle, +) class TestBuildPresenceActivity(unittest.TestCase): @@ -112,6 +118,118 @@ 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) + + def test_buttons_survive_an_unresolved_small_icon(self) -> None: + """Dropping the icon pair must not take the buttons with it.""" + buttons = [{"label": "Dotfiles", "url": "https://example.com"}] + payload = build_presence_activity( + details="d", + state="s", + large_image="big", + large_text="l", + small_image=UNKNOWN_COMPONENT_ID, + small_text="t", + start=1, + buttons=buttons, + ) + + self.assertIsNone(payload["small_image"]) + self.assertIsNone(payload["small_text"]) + self.assertEqual(payload["buttons"], buttons) + + class TestResolveCycle(unittest.TestCase): """One resolver feeds the presence loop, --dry-run and validate.py."""