Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion fetch_cord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions fetch_cord/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions fetch_cord/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -97,6 +98,7 @@ def update(
small_image=icon_id,
small_text=icon,
start=int(psutil.boot_time()),
buttons=self.buttons,
)
)

Expand Down
60 changes: 58 additions & 2 deletions fetch_cord/presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -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
8 changes: 8 additions & 0 deletions fetch_cord/resources/fetchcord_conf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 119 additions & 1 deletion tests/test_presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""

Expand Down
Loading