feat(slskd): answer anti-leech chat challenges from uploaders, off by default (#1591)

Some Soulseek uploaders run a bot that blocks your downloads until you
type a word to them in a private message ("please type 'download' in
this chat"). The files sit queued until someone answers, and nobody
looking at Shelfmark would know why.

While a slskd download is polling, this checks slskd's unacknowledged
private messages about once a minute, replies with the word the message
asks for and marks the conversation read. It only matches three known
phrasings, the word has to fit `[A-Za-z0-9_-]{1,24}`, and each user gets
at most one reply per 12 hours (recorded after a successful send). Every
reply is logged at INFO.

The challenge patterns and the one-reply-per-12-hours idea come from
@Nezreka's [SoulSync](https://github.com/Nezreka/SoulSync)
(`core/chat_autoprove.py`, commit 445473b, MIT). I reworked it for
slskd's client and this download handler. The credit is in the module
docstring and the commit message too.

This sends messages to other people on your behalf without asking, so
the setting `SLSKD_CHAT_AUTOPROVE` is off by default here. Say if you'd
rather it not exist at all, or want it somewhere other than the slskd
settings.

It has run on my instance since October 22 without seeing a real
challenge, so the reply path is covered by tests but not by a live one.
The same code works in nezreka/soulsync with the same slskd client so I
am confident in it. Ruff, basedpyright and the slskd tests pass, and
`docs/environment-variables.md` and the readme line for slskd are
updated.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added an optional setting to automatically answer supported Soulseek
private-message challenges while a download is running. When enabled,
Shelfmark checks unread messages, replies to each person at most once
every 12 hours, logs replies, and marks answered conversations as read.
The setting is off by default.
* **Documentation**
* Updated the environment-variable and Soulseek setup documentation with
details about the setting and its behavior, including a reminder to
leave it off if you read slskd messages yourself.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-08 22:48:25 -07:00
1 parent a01e700c81
commit e828bdbe9c
11 files changed
+671 -1

No files matched your search

+10
View File
@@ -1907,6 +1907,7 @@ Automatically retry search without category filtering if no results are found
| `SLSKD_SEARCH_TIMEOUT` | How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. | number | `15` |
| `SLSKD_RESPONSE_LIMIT` | Stop a search early once this many peers have answered | number | `100` |
| `SLSKD_QUEUE_TIMEOUT_MINUTES` | Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. | number | `60` |
| `SLSKD_CHAT_AUTOPROVE` | Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running. | boolean | `false` |
| `SLSKD_REMOVE_COMPLETED` | Clear the transfer from slskd's download list after Shelfmark imports it. Applies when each download does not get its own folder. | boolean | `true` |
<details>
@@ -2005,6 +2006,15 @@ Give up when the peer has not started sending within this long. Set to 0 to wait
- **Default:** `60`
- **Constraints:** min: 0, max: 1440
#### `SLSKD_CHAT_AUTOPROVE`
**Answer anti-leech chat challenges**
Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running.
- **Type:** boolean
- **Default:** `false`
#### `SLSKD_REMOVE_COMPLETED`
**Remove completed transfers from slskd**
+1 -1
View File
@@ -153,7 +153,7 @@ Some of the additional options available in Settings:
- **Webshare.cz** - Search Webshare.cz by file name and download ebooks and audiobooks with your Webshare account (a VIP account is recommended for full speed)
- **Direct Download mirrors** - Supply your own Anna's Archive mirror URLs; Auto mode tries them in the order listed. The `annas-archive.is` domain does not currently work as a source — use `annas-archive.gl` instead (checked August 2026; mirror availability changes)
- **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release. Some uploaders block downloads until you type a word to them in a private message; turn on **Answer anti-leech chat challenges** and Shelfmark types it for you, at most once per user every 12 hours. It reads every unread private message in slskd and marks a conversation it answered as read, so leave it off if you also read slskd's messages yourself
- **Library Link** - Add links to your book library (Calibre-Web or Grimmory) and your audiobook library (Audiobookshelf, Plex) in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
+30
View File
@@ -427,6 +427,36 @@ class SlskdClient:
raise
return True
# ── private messages ──────────────────────────────────────────────────────
def list_conversations(self) -> list[dict[str, Any]]:
"""Return slskd's private-message conversations (no message bodies)."""
data = self._json(self._request("GET", "conversations"))
return [c for c in data if isinstance(c, dict)] if isinstance(data, list) else []
def get_conversation(self, username: str) -> dict[str, Any] | None:
"""Return one conversation with its messages, or None when slskd has none."""
try:
response = self._request(
"GET",
f"conversations/{quote(username, safe='')}",
params={"includeMessages": "true"},
)
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND:
return None
raise
data = self._json(response)
return data if isinstance(data, dict) else None
def send_private_message(self, username: str, message: str) -> None:
"""Send a private message; slskd takes the body as a bare JSON string."""
self._request("POST", f"conversations/{quote(username, safe='')}", json=message)
def acknowledge_conversation(self, username: str) -> None:
"""Mark every message in a conversation as read."""
self._request("PUT", f"conversations/{quote(username, safe='')}")
def _flatten_transfers(data: Any) -> list[dict[str, Any]]:
"""Flatten slskd's ``{username, directories: [{directory, files: [...]}]}`` shape."""
@@ -0,0 +1,219 @@
"""Answer anti-leech challenges that Soulseek uploaders send as private messages.
Some uploaders run bots that block your downloads until you type a token in a
private message ("please type 'download' in this chat to be added to my
whitelist"). The downloads then sit queued until someone answers.
The challenge patterns and the one-reply-per-cooldown idea come from SoulSync's
``core/chat_autoprove.py`` (https://github.com/Nezreka/SoulSync, commit 445473b, MIT license,
by Nezreka), reworked here for slskd's client and Shelfmark's download handler.
Deliberately conservative:
- private messages only; only inbound, unacknowledged messages can trigger a reply
- the token comes from the message itself and must match ``[A-Za-z0-9_-]{1,24}``
- only the known phrasings below match; anything else is ignored
- one reply per user per cooldown window (bots repeat their challenge)
- the cooldown is recorded only after the reply was actually sent
- every reply is logged at INFO so the operator can see it
"""
from __future__ import annotations
import re
import threading
from typing import TYPE_CHECKING, Any
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.slskd.api import SlskdError
if TYPE_CHECKING:
from collections.abc import Callable
from shelfmark.release_sources.slskd.api import SlskdClient
logger = setup_logger(__name__)
SCAN_INTERVAL_SECONDS = 60.0
# A scan runs inside a download's poll loop, so it stops reading conversations after this long
# and leaves the rest for the next pass.
SCAN_BUDGET_SECONDS = 10.0
COOLDOWN_SECONDS = 12 * 3600.0
_ERRORS = (SlskdError, requests.exceptions.RequestException)
_WORD = r"[A-Za-z0-9_-]{1,24}"
_PATTERNS = (
# please type "human" in this chat
re.compile(rf"""type\s+["']?({_WORD})["']?\s+in\s+(?:this\s+)?chat""", re.IGNORECASE),
# reply with "human" to be whitelisted
re.compile(rf"""reply\s+(?:with\s+)?["']?({_WORD})["']?\s+to\s+(?:be|get)""", re.IGNORECASE),
# say "human" in chat / say human to be added (not "I'd say thanks to everyone")
re.compile(
rf"""say\s+["']?({_WORD})["']?\s+(?:in\s+(?:this\s+|the\s+)?chat|to\s+(?:be|get))\b""",
re.IGNORECASE,
),
)
def extract_token(text: object) -> str | None:
"""Return the token a challenge message demands, or None when it is not one."""
body = str(text or "")
for pattern in _PATTERNS:
match = pattern.search(body)
if match:
return match.group(1)
return None
def _is_unread_inbound(message: Any) -> bool:
"""Whether a chat message came from the peer and has not been acknowledged yet."""
if not isinstance(message, dict):
return False
if str(message.get("direction") or "").lower() != "in":
return False
return not message.get("acknowledged", message.get("isAcknowledged", False))
def _has_unread(conversation: dict[str, Any]) -> bool:
"""Whether slskd marks a conversation as having unread messages, by flag or by count."""
count = conversation.get("unAcknowledgedMessageCount")
return bool(conversation.get("hasUnAcknowledgedMessages")) or (
isinstance(count, int) and count > 0
)
def _resume_order(
conversations: list[dict[str, Any]], progress: dict[str, str] | None
) -> list[dict[str, Any]]:
"""The conversations, starting after the one the last pass read last and wrapping round."""
last = (progress or {}).get("last")
names = [str(c.get("username") or "") for c in conversations]
if not last or last not in names:
return list(conversations)
start = names.index(last) + 1
return list(conversations[start:]) + list(conversations[:start])
def scan_and_respond(
client: SlskdClient,
state: dict[str, float],
*,
now: float,
cooldown: float = COOLDOWN_SECONDS,
clock: Callable[[], float] | None = None,
budget: float | None = None,
progress: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Run one pass and return the ``(username, token)`` replies sent.
``state`` maps a username to the time of the last reply and is owned by the
caller. A failure on one conversation does not stop the others, but slskd timing
out or refusing the connection ends the pass, and with ``clock`` and ``budget`` no
further conversation is read once ``budget`` seconds have passed since ``now``.
``progress`` (owned by the caller) remembers the last conversation read, and the next pass
starts after it and wraps round, so unread conversations that are not challenges cannot
use up every pass's budget ahead of one that is.
"""
replies: list[tuple[str, str]] = []
for conversation in _resume_order(client.list_conversations(), progress):
username = str(conversation.get("username") or "")
if not username or not _has_unread(conversation):
continue
last = state.get(username)
if last is not None and now - last < cooldown:
continue
if clock is not None and budget is not None and clock() - now >= budget:
logger.debug("slskd chat challenge: scan budget spent, the rest waits a pass")
break
if progress is not None:
progress["last"] = username
try:
detail = client.get_conversation(username) or {}
token = None
for message in detail.get("messages") or []:
if _is_unread_inbound(message):
token = extract_token(message.get("message"))
if token:
break
if not token:
continue
client.send_private_message(username, token)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
logger.warning("slskd chat challenge: slskd did not answer, scan stopped: %s", e)
break
except _ERRORS as e:
logger.warning("slskd chat challenge: reply to %s failed: %s", username, e)
continue
state[username] = now
logger.info("slskd chat challenge: answered %r to %s", token, username)
replies.append((username, token))
try:
client.acknowledge_conversation(username)
except _ERRORS as e:
logger.debug("slskd chat challenge: acknowledge for %s failed: %s", username, e)
return replies
class ChatAutoProver:
"""Runs ``scan_and_respond`` at most once per interval, from whoever calls it.
The slskd handler already loops while a download is queued, so it calls
``maybe_scan`` each iteration; no thread or scheduler of its own. Only one scan runs
at a time: a caller that arrives while another thread is scanning returns at once.
"""
def __init__(
self,
*,
interval: float = SCAN_INTERVAL_SECONDS,
cooldown: float = COOLDOWN_SECONDS,
) -> None:
self._interval = interval
self._cooldown = cooldown
self._lock = threading.Lock()
self._last_scan: float | None = None
self._scanning = False
self._state: dict[str, float] = {}
self._progress: dict[str, str] = {}
def maybe_scan(
self,
get_client: Callable[[], SlskdClient | None],
clock: Callable[[], float],
) -> list[tuple[str, str]]:
"""Scan when the interval has passed; never raises, quiet when slskd is down."""
now = clock()
with self._lock:
if self._scanning:
return []
if self._last_scan is not None and now - self._last_scan < self._interval:
return []
self._last_scan = now
self._scanning = True
try:
client = get_client()
if client is None:
return []
return scan_and_respond(
client,
self._state,
now=now,
cooldown=self._cooldown,
clock=clock,
budget=SCAN_BUDGET_SECONDS,
progress=self._progress,
)
except _ERRORS as e:
logger.debug("slskd chat challenge: scan skipped: %s", e)
return []
except Exception: # a side feature must never stop the download polling it
logger.exception("slskd chat challenge: unexpected scan error")
return []
finally:
with self._lock:
self._scanning = False
@@ -47,6 +47,7 @@ from shelfmark.release_sources.slskd.api import (
transfer_is_complete,
transfer_succeeded,
)
from shelfmark.release_sources.slskd.chat_autoprove import ChatAutoProver
from shelfmark.release_sources.slskd.source import (
SOURCE_NAME,
build_client_from_config,
@@ -63,6 +64,9 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
POLL_INTERVAL = 2.0
# Shared by every download so concurrent polls still scan private messages once a minute.
_CHAT_AUTOPROVER = ChatAutoProver()
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
# Polls with the transfer missing from slskd before giving up (~30s at POLL_INTERVAL).
@@ -469,6 +473,11 @@ class SlskdHandler(DownloadHandler):
def _get_client(self) -> SlskdClient | None:
return build_client_from_config()
def _answer_chat_challenges(self, client: SlskdClient) -> None:
"""Reply to anti-leech bots that block downloads until they get a chat message."""
if _bool_setting("SLSKD_CHAT_AUTOPROVE", False):
_CHAT_AUTOPROVER.maybe_scan(lambda: client, time.monotonic)
# ── download ──────────────────────────────────────────────────────────────
def download(
@@ -580,6 +589,10 @@ class SlskdHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> _TransferSnapshot | None:
"""Follow a transfer until it finishes, fails, is cancelled or times out in the queue.
Answers chat challenges on the way when that is switched on.
"""
poll_interval = self._poll_interval()
queue_timeout = self._queue_timeout_seconds()
started = self._clock()
@@ -589,6 +602,7 @@ class SlskdHandler(DownloadHandler):
try:
while not cancel_flag.is_set():
self._answer_chat_challenges(client)
try:
transfers = client.list_downloads(spec.username)
except _SLSKD_ERRORS as e:
@@ -929,6 +943,10 @@ class SlskdHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Follow an isolated download's files until they are all in its folder.
Returns None on success, else the reason it failed.
"""
poll_interval = self._poll_interval()
queue_timeout = self._queue_timeout_seconds()
started = self._clock()
@@ -940,6 +958,7 @@ class SlskdHandler(DownloadHandler):
try:
while not cancel_flag.is_set():
self._answer_chat_challenges(client)
now = self._clock()
records: dict[str, dict[str, Any] | None] = {}
try:
@@ -181,6 +181,20 @@ def slskd_config_settings() -> list[SettingsField]:
max_value=1440,
show_when={"field": "SLSKD_ENABLED", "value": True},
),
CheckboxField(
key="SLSKD_CHAT_AUTOPROVE",
label="Answer anti-leech chat challenges",
default=False,
description=(
"Some uploaders block downloads until you type a word in a private message "
"(\"please type 'download' in this chat\"). Shelfmark replies with the word "
"the message asks for, at most once per user every 12 hours, and logs each "
"reply. It reads every unread private message in slskd, not only those from "
"the peer of a running download, and marks a conversation it answered as read "
"in slskd. Checked while a download is running."
),
show_when={"field": "SLSKD_ENABLED", "value": True},
),
CheckboxField(
key="SLSKD_REMOVE_COMPLETED",
label="Remove completed transfers from slskd",
+38
View File
@@ -554,3 +554,41 @@ class TestBatchDownloads:
client = self._client()
with patch.object(client._session, "request", return_value=_make_response(status=404)):
assert client.get_events() == []
# ── private messages ───────────────────────────────────────────────────────────
def test_list_conversations_returns_dicts():
client = SlskdClient("http://slskd:5030", "key")
with patch.object(client._session, "request") as req:
req.return_value = _make_response([{"username": "a"}, "junk"])
assert client.list_conversations() == [{"username": "a"}]
assert req.call_args.args == ("GET", "http://slskd:5030/api/v0/conversations")
def test_get_conversation_quotes_username_and_tolerates_404():
client = SlskdClient("http://slskd:5030", "key")
with patch.object(client._session, "request") as req:
req.return_value = _make_response({"username": "a/b", "messages": []})
assert client.get_conversation("a/b") == {"username": "a/b", "messages": []}
assert req.call_args.args[1].endswith("/conversations/a%2Fb")
req.return_value = _make_response(status=404)
assert client.get_conversation("gone") is None
def test_send_private_message_posts_a_bare_json_string():
client = SlskdClient("http://slskd:5030", "key")
with patch.object(client._session, "request") as req:
req.return_value = _make_response(status=201)
client.send_private_message("peer", "download")
assert req.call_args.args == ("POST", "http://slskd:5030/api/v0/conversations/peer")
assert req.call_args.kwargs["json"] == "download"
def test_acknowledge_conversation_puts():
client = SlskdClient("http://slskd:5030", "key")
with patch.object(client._session, "request") as req:
req.return_value = _make_response(status=204)
client.acknowledge_conversation("peer")
assert req.call_args.args == ("PUT", "http://slskd:5030/api/v0/conversations/peer")
+331
View File
@@ -0,0 +1,331 @@
"""Unit tests for the slskd anti-leech chat challenge responder."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from shelfmark.release_sources.slskd import handler as handler_module
from shelfmark.release_sources.slskd.api import SlskdClient, SlskdError
from shelfmark.release_sources.slskd.chat_autoprove import (
COOLDOWN_SECONDS,
SCAN_BUDGET_SECONDS,
ChatAutoProver,
extract_token,
scan_and_respond,
)
# ── helpers ────────────────────────────────────────────────────────────────────
def _convo(username="peer", *, unread=True):
return {"username": username, "hasUnAcknowledgedMessages": unread}
def _msg(text, *, direction="In", acknowledged=False):
return {"direction": direction, "acknowledged": acknowledged, "message": text}
def _client(conversations, messages_by_user):
client = MagicMock(spec=SlskdClient)
client.list_conversations.return_value = conversations
client.get_conversation.side_effect = lambda u: {
"username": u,
"messages": messages_by_user.get(u, []),
}
return client
CHALLENGE = "ProveIt: To prove you are human, please type 'download' in this chat."
# ── token extraction ───────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("text", "token"),
[
(CHALLENGE, "download"),
('please type "human" in chat to be whitelisted', "human"),
('Please reply with "human" to be added to my whitelist', "human"),
("reply open_sesame to get access", "open_sesame"),
('say "ok-1" in chat', "ok-1"),
("SAY hello TO be added", "hello"),
("say human in the chat", "human"),
],
)
def test_extract_token_known_phrasings(text, token):
assert extract_token(text) == token
@pytest.mark.parametrize(
"text",
[
"",
None,
"thanks for downloading!",
"i will type the title in a minute",
f"please type '{'a' * 25}' in this chat",
"please type 'rm -rf /' in this chat",
"please type 'bad;word' in this chat",
"reply with 'x y' to be added",
"I would say thanks to everyone",
"say hello to me",
"just say yes to the next one",
],
)
def test_extract_token_ignores_non_challenges(text):
assert extract_token(text) is None
# ── scan_and_respond ───────────────────────────────────────────────────────────
def test_replies_with_token_and_acknowledges():
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
state: dict[str, float] = {}
replies = scan_and_respond(client, state, now=100.0)
assert replies == [("peer", "download")]
client.send_private_message.assert_called_once_with("peer", "download")
client.acknowledge_conversation.assert_called_once_with("peer")
assert state == {"peer": 100.0}
def test_acknowledge_happens_after_send():
order = []
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
client.send_private_message.side_effect = lambda *a: order.append("send")
client.acknowledge_conversation.side_effect = lambda *a: order.append("ack")
scan_and_respond(client, {}, now=1.0)
assert order == ["send", "ack"]
def test_cooldown_blocks_second_reply_until_it_expires():
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
state: dict[str, float] = {}
assert scan_and_respond(client, state, now=0.0)
assert scan_and_respond(client, state, now=COOLDOWN_SECONDS - 1) == []
assert scan_and_respond(client, state, now=COOLDOWN_SECONDS + 1) == [("peer", "download")]
assert client.send_private_message.call_count == 2
def test_only_unacknowledged_inbound_messages_are_answered():
client = _client(
[_convo()],
{
"peer": [
_msg(CHALLENGE, acknowledged=True),
_msg(CHALLENGE, direction="Out"),
]
},
)
assert scan_and_respond(client, {}, now=0.0) == []
client.send_private_message.assert_not_called()
def test_conversation_without_unread_flag_is_not_fetched():
client = _client([_convo(unread=False)], {"peer": [_msg(CHALLENGE)]})
assert scan_and_respond(client, {}, now=0.0) == []
client.get_conversation.assert_not_called()
def test_unread_count_alone_marks_a_conversation_unread():
convo = {"username": "peer", "unAcknowledgedMessageCount": 2}
client = _client([convo], {"peer": [_msg(CHALLENGE)]})
assert scan_and_respond(client, {}, now=0.0) == [("peer", "download")]
def test_send_failure_does_not_record_cooldown_or_acknowledge():
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
client.send_private_message.side_effect = SlskdError("boom")
state: dict[str, float] = {}
assert scan_and_respond(client, state, now=0.0) == []
assert state == {}
client.acknowledge_conversation.assert_not_called()
# The next pass may try again.
client.send_private_message.side_effect = None
assert scan_and_respond(client, state, now=1.0) == [("peer", "download")]
def test_acknowledge_failure_keeps_the_reply_and_cooldown():
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
client.acknowledge_conversation.side_effect = requests.exceptions.ConnectionError()
state: dict[str, float] = {}
assert scan_and_respond(client, state, now=5.0) == [("peer", "download")]
assert state == {"peer": 5.0}
def test_one_failing_conversation_does_not_stop_the_others():
client = _client(
[_convo("a"), _convo("b")],
{"a": [_msg(CHALLENGE)], "b": [_msg(CHALLENGE)]},
)
client.send_private_message.side_effect = [SlskdError("no"), None]
assert scan_and_respond(client, {}, now=0.0) == [("b", "download")]
def test_slskd_timing_out_ends_the_pass():
client = _client(
[_convo("a"), _convo("b")],
{"a": [_msg(CHALLENGE)], "b": [_msg(CHALLENGE)]},
)
client.get_conversation.side_effect = requests.exceptions.Timeout()
assert scan_and_respond(client, {}, now=0.0) == []
assert client.get_conversation.call_count == 1
def test_scan_stops_reading_conversations_once_its_budget_is_spent():
client = _client(
[_convo("a"), _convo("b"), _convo("c")],
{"a": [_msg(CHALLENGE)], "b": [_msg(CHALLENGE)], "c": [_msg(CHALLENGE)]},
)
ticks = iter([1.0, 11.0])
replies = scan_and_respond(client, {}, now=0.0, clock=lambda: next(ticks), budget=10.0)
assert replies == [("a", "download")]
assert client.get_conversation.call_count == 1
def test_a_later_pass_resumes_after_the_last_conversation_read():
"""Unread chats that are not challenges cannot crowd out a challenge further down."""
conversations = [_convo("a"), _convo("b"), _convo("c")]
client = _client(conversations, {"a": [_msg("hi")], "b": [_msg("hey")], "c": [_msg(CHALLENGE)]})
progress: dict[str, str] = {}
def one_read_per_pass(ticks):
"""A clock that spends the budget after the first conversation of a pass."""
values = iter(ticks)
return lambda: next(values)
first = scan_and_respond(
client, {}, now=0.0, clock=one_read_per_pass([0.0, 11.0]), budget=10.0, progress=progress
)
second = scan_and_respond(
client, {}, now=0.0, clock=one_read_per_pass([0.0, 11.0]), budget=10.0, progress=progress
)
third = scan_and_respond(
client, {}, now=0.0, clock=one_read_per_pass([0.0, 11.0]), budget=10.0, progress=progress
)
assert (first, second) == ([], [])
assert third == [("c", "download")]
def test_the_pass_order_wraps_round():
"""After the last conversation, the next pass starts again at the first."""
client = _client([_convo("a"), _convo("b")], {"a": [_msg(CHALLENGE)]})
replies = scan_and_respond(client, {}, now=0.0, progress={"last": "b"})
assert replies == [("a", "download")]
def test_reply_is_logged():
client = _client([_convo()], {"peer": [_msg(CHALLENGE)]})
with patch("shelfmark.release_sources.slskd.chat_autoprove.logger") as log:
scan_and_respond(client, {}, now=0.0)
log.info.assert_called_once()
assert log.info.call_args.args[1:] == ("download", "peer")
# ── ChatAutoProver ─────────────────────────────────────────────────────────────
def test_prover_scans_at_most_once_per_interval():
"""A second call inside the interval does not scan; one after it does."""
client = _client([], {})
prover = ChatAutoProver(interval=60)
clock = iter([0.0, 30.0, 61.0])
for _ in range(3):
prover.maybe_scan(lambda: client, lambda: next(clock))
assert client.list_conversations.call_count == 2
def test_prover_runs_one_scan_at_a_time():
prover = ChatAutoProver(interval=60)
inner = MagicMock(spec=SlskdClient)
inner.list_conversations.return_value = []
def get_client_mid_scan():
# Another download thread polls while this scan is still running, past the interval.
assert prover.maybe_scan(lambda: inner, lambda: 120.0) == []
return inner
prover.maybe_scan(get_client_mid_scan, lambda: 0.0)
assert inner.list_conversations.call_count == 1
def test_prover_gives_each_scan_a_budget():
prover = ChatAutoProver()
with patch(
"shelfmark.release_sources.slskd.chat_autoprove.scan_and_respond", return_value=[]
) as scan:
prover.maybe_scan(lambda: MagicMock(spec=SlskdClient), lambda: 0.0)
assert scan.call_args.kwargs["budget"] == SCAN_BUDGET_SECONDS
def test_prover_survives_a_malformed_reply_from_slskd():
"""A reply the scan cannot read is logged, never raised into the download polling it."""
client = _client([_convo()], {})
client.get_conversation.side_effect = lambda _u: {"messages": 5}
prover = ChatAutoProver()
with patch("shelfmark.release_sources.slskd.chat_autoprove.logger") as log:
assert prover.maybe_scan(lambda: client, lambda: 0.0) == []
log.exception.assert_called_once()
assert prover.maybe_scan(lambda: client, lambda: 120.0) == [] # and the next scan still runs
assert client.list_conversations.call_count == 2
def test_prover_is_quiet_when_slskd_is_unreachable():
client = MagicMock(spec=SlskdClient)
client.list_conversations.side_effect = requests.exceptions.ConnectionError()
prover = ChatAutoProver()
assert prover.maybe_scan(lambda: client, lambda: 0.0) == []
def test_prover_skips_when_no_client_configured():
assert ChatAutoProver().maybe_scan(lambda: None, lambda: 0.0) == []
# ── handler wiring / setting ───────────────────────────────────────────────────
def _handler():
return handler_module.SlskdHandler()
def test_handler_scans_when_setting_is_on():
client = MagicMock(spec=SlskdClient)
with (
patch.object(handler_module, "_bool_setting", return_value=True),
patch.object(handler_module._CHAT_AUTOPROVER, "maybe_scan") as scan,
):
_handler()._answer_chat_challenges(client)
scan.assert_called_once()
def test_handler_does_nothing_when_setting_is_off():
client = MagicMock(spec=SlskdClient)
with (
patch.object(handler_module, "_bool_setting", return_value=False) as setting,
patch.object(handler_module._CHAT_AUTOPROVER, "maybe_scan") as scan,
):
_handler()._answer_chat_challenges(client)
setting.assert_called_once_with("SLSKD_CHAT_AUTOPROVE", False)
scan.assert_not_called()
client.list_conversations.assert_not_called()
+3
View File
@@ -91,6 +91,9 @@ class FakeSlskd:
for i, f in enumerate(files)
]
def list_conversations(self):
return []
def list_downloads(self, username):
self.poll_count += 1
if not self.polls:
+3
View File
@@ -117,6 +117,9 @@ class FakeSlskd:
"placeInQueue": 3 if "Remotely" in state else None,
}
def list_conversations(self):
return []
def list_downloads(self, username):
self.list_calls += 1
return [
@@ -96,6 +96,9 @@ def test_handler_round_trip_from_queued_task(monkeypatch, tmp_path):
assert username == "peer"
return [{"id": "t1", "filename": files[0]["filename"]}]
def list_conversations(self):
return []
def list_downloads(self, username):
return [
{