@acedatacloud/skills 2026.725.0 → 2026.726.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/reddit/SKILL.md +68 -17
- package/skills/reddit/scripts/reddit.py +211 -13
- package/skills/reddit/tests/test_reddit.py +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.726.0",
|
|
4
4
|
"description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skills",
|
package/skills/reddit/SKILL.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reddit
|
|
3
|
-
description:
|
|
3
|
+
description: Search subreddits, read rules, reply to threads, and submit posts (link or text) to Reddit using either official OAuth or your own Reddit login cookies. Use when the user mentions Reddit, posting to a subreddit, replying or commenting on a thread, submitting a link or self-post, or checking their Reddit profile / submissions.
|
|
4
4
|
when_to_use: |
|
|
5
|
-
Trigger when the user wants to
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
before
|
|
5
|
+
Trigger when the user wants to find relevant Reddit threads, reply to a
|
|
6
|
+
post or comment, submit a post to a subreddit (link or self/text post),
|
|
7
|
+
or read their own Reddit identity and submissions. Commenting and posting
|
|
8
|
+
are public and subject to that subreddit's rules / karma requirements —
|
|
9
|
+
confirm the target and the exact text before writing.
|
|
10
10
|
connections: [reddit]
|
|
11
11
|
allowed_tools: [Bash, publish_artifact]
|
|
12
12
|
license: Apache-2.0
|
|
13
13
|
metadata:
|
|
14
14
|
author: acedatacloud
|
|
15
|
-
version: "2.
|
|
15
|
+
version: "2.1"
|
|
16
16
|
connection_method_preferences:
|
|
17
17
|
reddit/reddit: [oauth, cookie]
|
|
18
18
|
---
|
|
@@ -54,6 +54,35 @@ python3 "$R" whoami
|
|
|
54
54
|
python3 "$R" submissions --limit 10
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Find threads and check the rules
|
|
58
|
+
|
|
59
|
+
`search` returns each hit's `fullname` (`t3_…`), which is what `comment --parent`
|
|
60
|
+
takes. `subreddit-info` returns the subreddit's rules — **read them before
|
|
61
|
+
writing anything there.**
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
|
|
65
|
+
[ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
66
|
+
|
|
67
|
+
python3 "$R" search --query "suno api" --time week --limit 10
|
|
68
|
+
python3 "$R" search --query "image generation api" --subreddit SideProject --sort new
|
|
69
|
+
python3 "$R" subreddit-info --subreddit SideProject
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Reply to a thread — GATED
|
|
73
|
+
|
|
74
|
+
`--parent` accepts a fullname (`t3_…` post, `t1_…` comment) or a reddit.com
|
|
75
|
+
permalink. **Show the target thread and the exact reply text, then obtain
|
|
76
|
+
explicit confirmation.** Without a trailing `--confirm` this is a dry run.
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
|
|
80
|
+
[ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
81
|
+
|
|
82
|
+
python3 "$R" comment --parent t3_1r9yrtn --body-file reply.md
|
|
83
|
+
python3 "$R" comment --parent t3_1r9yrtn --body-file reply.md --confirm
|
|
84
|
+
```
|
|
85
|
+
|
|
57
86
|
## Submit a post — GATED
|
|
58
87
|
|
|
59
88
|
Posting is public. **Always show the subreddit, final title and final body/URL,
|
|
@@ -79,25 +108,47 @@ contains the text `--confirm` can never trigger a write.
|
|
|
79
108
|
## Safety and failure handling
|
|
80
109
|
|
|
81
110
|
- Never print `REDDIT_COOKIES`, `REDDIT_TOKEN`, `reddit_session` or the modhash.
|
|
82
|
-
- Do not vote, send private messages, evade bans,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
111
|
+
- Do not vote, send private messages, evade bans, or cross-post identical
|
|
112
|
+
content. This skill intentionally exposes none of those operations.
|
|
113
|
+
- **Never post the same reply text to more than one thread.** Reddit treats
|
|
114
|
+
repeated identical comments as spam and actions the account, not the comment.
|
|
115
|
+
Write each reply against the specific thread it answers.
|
|
116
|
+
- Follow each subreddit's rules — run `subreddit-info` first. Account age, karma
|
|
117
|
+
and flair requirements can reject a post or comment; report the rejection
|
|
118
|
+
without exposing Reddit's raw authenticated response, which may contain
|
|
119
|
+
reflected credential material.
|
|
88
120
|
- Do not retry a write automatically. A timeout may occur after Reddit accepted
|
|
89
121
|
it, and replaying could create a duplicate.
|
|
90
|
-
- Respect rate limits and never bulk-submit. Use `r/test` only
|
|
91
|
-
end-to-end validation.
|
|
122
|
+
- Respect rate limits and never bulk-submit or bulk-comment. Use `r/test` only
|
|
123
|
+
for a deliberate end-to-end validation.
|
|
92
124
|
- Cookie mode drives Reddit's first-party web JSON endpoints and may drift when
|
|
93
125
|
Reddit changes its site. Report unexpected HTML or route errors as upstream
|
|
94
126
|
drift instead of guessing another private endpoint.
|
|
95
127
|
|
|
128
|
+
## Self-promotion — read this before marketing anything
|
|
129
|
+
|
|
130
|
+
Reddit bans accounts for promotion patterns, not for individual links. When the
|
|
131
|
+
user's goal is promoting their own product:
|
|
132
|
+
|
|
133
|
+
- **Answer the question first.** A reply must stand on its own as useful even if
|
|
134
|
+
the product link were removed. If it does not, do not send it.
|
|
135
|
+
- **Disclose the affiliation** ("I build X") — undisclosed promotion is the
|
|
136
|
+
fastest route to a ban.
|
|
137
|
+
- **One link at most, at the end**, and never in a post title.
|
|
138
|
+
- **Do not blanket-reply.** A handful of genuinely relevant threads beats volume,
|
|
139
|
+
and volume is exactly what spam detection keys on.
|
|
140
|
+
- If a subreddit has a dedicated self-promo / "Showoff" thread, use it — that is
|
|
141
|
+
the sanctioned channel.
|
|
142
|
+
- A low-karma or new account will have posts auto-removed by AutoModerator in
|
|
143
|
+
most active subreddits. Check `whoami` karma and warn the user rather than
|
|
144
|
+
burning the account on a post that will be silently filtered.
|
|
145
|
+
|
|
96
146
|
|
|
97
147
|
## Record the output
|
|
98
148
|
|
|
99
|
-
After you successfully publish and obtain the live result
|
|
100
|
-
`publish_artifact` tool ONCE so the user can track this
|
|
149
|
+
After you successfully publish a post **or a comment** and obtain the live result
|
|
150
|
+
URL, call the built-in `publish_artifact` tool ONCE so the user can track this
|
|
151
|
+
deliverable in **My Outputs**:
|
|
101
152
|
|
|
102
153
|
```
|
|
103
154
|
publish_artifact(kind="message", channel="reddit", title="<title>", url="<the REAL returned URL>", status="delivered")
|
|
@@ -26,9 +26,11 @@ import urllib.request
|
|
|
26
26
|
WEB_BASE = "https://www.reddit.com"
|
|
27
27
|
OAUTH_BASE = "https://oauth.reddit.com"
|
|
28
28
|
USER_AGENT = "web:cloud.acedata.reddit:v2.0 (by /u/acedatacloud)"
|
|
29
|
-
GATED_COMMANDS = {"submit-text", "submit-link"}
|
|
29
|
+
GATED_COMMANDS = {"submit-text", "submit-link", "comment"}
|
|
30
30
|
SUBREDDIT_RE = re.compile(r"^[A-Za-z0-9_]{2,21}$")
|
|
31
31
|
COOKIE_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
32
|
+
# Only a comment (t1_) or a post (t3_) can be replied to.
|
|
33
|
+
THING_ID_RE = re.compile(r"^t[13]_[A-Za-z0-9]{2,16}$")
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def output(value) -> None:
|
|
@@ -206,6 +208,44 @@ def validate_link(value: str) -> str:
|
|
|
206
208
|
return value.strip()
|
|
207
209
|
|
|
208
210
|
|
|
211
|
+
def normalize_thing_id(value: str) -> str:
|
|
212
|
+
"""Accept a fullname (t3_abc123) or a reddit permalink and return a fullname."""
|
|
213
|
+
candidate = value.strip()
|
|
214
|
+
if THING_ID_RE.fullmatch(candidate):
|
|
215
|
+
return candidate
|
|
216
|
+
parsed = urllib.parse.urlsplit(candidate)
|
|
217
|
+
if parsed.scheme in {"http", "https"}:
|
|
218
|
+
if not parsed.hostname or not reddit_cookie_domain(parsed.hostname):
|
|
219
|
+
die("parent URL must point at reddit.com")
|
|
220
|
+
# /r/<sub>/comments/<post_id>/<slug>/<comment_id>
|
|
221
|
+
segments = [segment for segment in (parsed.path or "").split("/") if segment]
|
|
222
|
+
if "comments" in segments:
|
|
223
|
+
index = segments.index("comments")
|
|
224
|
+
if len(segments) > index + 3 and re.fullmatch(r"[A-Za-z0-9]{2,16}", segments[index + 3]):
|
|
225
|
+
return "t1_" + segments[index + 3]
|
|
226
|
+
if len(segments) > index + 1 and re.fullmatch(r"[A-Za-z0-9]{2,16}", segments[index + 1]):
|
|
227
|
+
return "t3_" + segments[index + 1]
|
|
228
|
+
die("parent must be a post (t3_xxxxxx), a comment (t1_xxxxxx) or a reddit.com permalink")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_comment_body(text: str) -> str:
|
|
232
|
+
body = text.strip()
|
|
233
|
+
if not body:
|
|
234
|
+
die("comment body cannot be empty")
|
|
235
|
+
if len(body) > 10_000:
|
|
236
|
+
die("comment exceeds Reddit's 10,000-character limit")
|
|
237
|
+
return body
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_query(value: str) -> str:
|
|
241
|
+
query = value.strip()
|
|
242
|
+
if not query:
|
|
243
|
+
die("query cannot be empty")
|
|
244
|
+
if len(query) > 512:
|
|
245
|
+
die("query exceeds Reddit's 512-character search limit")
|
|
246
|
+
return query
|
|
247
|
+
|
|
248
|
+
|
|
209
249
|
def read_text(args: argparse.Namespace) -> str:
|
|
210
250
|
if args.text_file:
|
|
211
251
|
try:
|
|
@@ -220,6 +260,19 @@ def read_text(args: argparse.Namespace) -> str:
|
|
|
220
260
|
return text
|
|
221
261
|
|
|
222
262
|
|
|
263
|
+
def read_body(args: argparse.Namespace) -> str:
|
|
264
|
+
"""Read a comment body from --body or --body-file."""
|
|
265
|
+
if getattr(args, "body_file", None):
|
|
266
|
+
try:
|
|
267
|
+
with open(args.body_file, encoding="utf-8") as file_handle:
|
|
268
|
+
text = file_handle.read()
|
|
269
|
+
except OSError as error:
|
|
270
|
+
die(f"cannot read body file {args.body_file}: {error}")
|
|
271
|
+
else:
|
|
272
|
+
text = args.body or ""
|
|
273
|
+
return validate_comment_body(text)
|
|
274
|
+
|
|
275
|
+
|
|
223
276
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
224
277
|
"""Return 30x responses to the caller instead of replaying credentials."""
|
|
225
278
|
|
|
@@ -258,7 +311,7 @@ class RedditClient:
|
|
|
258
311
|
def base_url(self) -> str:
|
|
259
312
|
return WEB_BASE if self.mode == "cookie" else OAUTH_BASE
|
|
260
313
|
|
|
261
|
-
def request(self, method: str, path: str, *, query: dict | None = None, form: dict | None = None):
|
|
314
|
+
def request(self, method: str, path: str, *, query: dict | None = None, form: dict | None = None, write_kind: str = "post"):
|
|
262
315
|
is_write = method.upper() == "POST"
|
|
263
316
|
url = self.base_url + path
|
|
264
317
|
if query:
|
|
@@ -296,24 +349,24 @@ class RedditClient:
|
|
|
296
349
|
raw = gzip.decompress(raw)
|
|
297
350
|
except Exception:
|
|
298
351
|
if is_write:
|
|
299
|
-
die_unknown_write_outcome("The Reddit HTTP error response for the write request could not be read")
|
|
352
|
+
die_unknown_write_outcome("The Reddit HTTP error response for the write request could not be read", kind=write_kind)
|
|
300
353
|
die("The Reddit HTTP error response could not be read; authenticated response content was omitted.")
|
|
301
354
|
finally:
|
|
302
355
|
with contextlib.suppress(Exception):
|
|
303
356
|
error.close()
|
|
304
357
|
except urllib.error.URLError as error:
|
|
305
358
|
if is_write:
|
|
306
|
-
die_unknown_write_outcome("A network error interrupted the Reddit write request")
|
|
359
|
+
die_unknown_write_outcome("A network error interrupted the Reddit write request", kind=write_kind)
|
|
307
360
|
die(f"network error reaching Reddit: {error.reason}")
|
|
308
361
|
except Exception:
|
|
309
362
|
if is_write:
|
|
310
|
-
die_unknown_write_outcome("The Reddit write response could not be read or decompressed")
|
|
363
|
+
die_unknown_write_outcome("The Reddit write response could not be read or decompressed", kind=write_kind)
|
|
311
364
|
die("The Reddit response could not be read or decompressed; authenticated response content was omitted.")
|
|
312
365
|
|
|
313
366
|
text = raw.decode("utf-8", "replace")
|
|
314
367
|
if 300 <= status < 400:
|
|
315
368
|
if is_write:
|
|
316
|
-
die_unknown_write_outcome("Reddit redirected the write request; credentials were not forwarded")
|
|
369
|
+
die_unknown_write_outcome("Reddit redirected the write request; credentials were not forwarded", kind=write_kind)
|
|
317
370
|
die("Reddit returned an unexpected redirect; credentials were not forwarded. Reconnect before retrying the read.")
|
|
318
371
|
if status in {401, 403}:
|
|
319
372
|
die(
|
|
@@ -324,17 +377,17 @@ class RedditClient:
|
|
|
324
377
|
die("Reddit rate limit reached (429). Wait before retrying; do not loop-retry.")
|
|
325
378
|
if status >= 400:
|
|
326
379
|
if is_write and status >= 500:
|
|
327
|
-
die_unknown_write_outcome(f"Reddit returned HTTP {status} for the write request")
|
|
380
|
+
die_unknown_write_outcome(f"Reddit returned HTTP {status} for the write request", kind=write_kind)
|
|
328
381
|
die(f"Reddit returned HTTP {status}; authenticated response content was omitted.")
|
|
329
382
|
if text.lstrip().startswith("<"):
|
|
330
383
|
if is_write:
|
|
331
|
-
die_unknown_write_outcome("Reddit returned HTML for the write request")
|
|
384
|
+
die_unknown_write_outcome("Reddit returned HTML for the write request", kind=write_kind)
|
|
332
385
|
die("Reddit returned HTML instead of JSON — the session expired or the web endpoint changed.")
|
|
333
386
|
try:
|
|
334
387
|
return json.loads(text)
|
|
335
388
|
except json.JSONDecodeError:
|
|
336
389
|
if is_write:
|
|
337
|
-
die_unknown_write_outcome("Reddit returned invalid JSON for the write request")
|
|
390
|
+
die_unknown_write_outcome("Reddit returned invalid JSON for the write request", kind=write_kind)
|
|
338
391
|
die(f"Reddit returned non-JSON data ({status}); authenticated response content was omitted.")
|
|
339
392
|
|
|
340
393
|
def me(self) -> dict:
|
|
@@ -421,6 +474,79 @@ class RedditClient:
|
|
|
421
474
|
return {"ok": True, "posted": True, "id": post.get("id"), "name": post.get("name"), "url": post_url}
|
|
422
475
|
|
|
423
476
|
|
|
477
|
+
def search(self, *, query: str, subreddit: str = "", sort: str = "new", limit: int = 10, time_filter: str = "week") -> list[dict]:
|
|
478
|
+
suffix = ".json" if self.mode == "cookie" else ""
|
|
479
|
+
path = f"/r/{subreddit}/search{suffix}" if subreddit else f"/search{suffix}"
|
|
480
|
+
params = {"q": query, "sort": sort, "limit": limit, "t": time_filter, "raw_json": 1, "type": "link"}
|
|
481
|
+
if subreddit:
|
|
482
|
+
params["restrict_sr"] = "true"
|
|
483
|
+
payload = self.request("GET", path, query=params)
|
|
484
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
|
|
485
|
+
die("Reddit returned a malformed search response; authenticated response content was omitted.")
|
|
486
|
+
children = payload["data"].get("children")
|
|
487
|
+
if not isinstance(children, list):
|
|
488
|
+
die("Reddit returned a malformed search response; authenticated response content was omitted.")
|
|
489
|
+
return [child["data"] for child in children if isinstance(child, dict) and valid_submission_data(child.get("data"))]
|
|
490
|
+
|
|
491
|
+
def subreddit_about(self, subreddit: str) -> dict:
|
|
492
|
+
suffix = ".json" if self.mode == "cookie" else ""
|
|
493
|
+
payload = self.request("GET", f"/r/{subreddit}/about{suffix}", query={"raw_json": 1})
|
|
494
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
|
|
495
|
+
die("Reddit returned a malformed subreddit response; authenticated response content was omitted.")
|
|
496
|
+
about = payload["data"]
|
|
497
|
+
rules = self.request("GET", f"/r/{subreddit}/about/rules{suffix}", query={"raw_json": 1})
|
|
498
|
+
rule_items = rules.get("rules") if isinstance(rules, dict) else None
|
|
499
|
+
return {
|
|
500
|
+
"subreddit": about.get("display_name"),
|
|
501
|
+
"subscribers": about.get("subscribers"),
|
|
502
|
+
"over18": about.get("over18"),
|
|
503
|
+
"submission_type": about.get("submission_type"),
|
|
504
|
+
"description": (about.get("public_description") or "")[:600],
|
|
505
|
+
"rules": [
|
|
506
|
+
{
|
|
507
|
+
"short_name": rule.get("short_name"),
|
|
508
|
+
"description": (rule.get("description") or "")[:400],
|
|
509
|
+
}
|
|
510
|
+
for rule in (rule_items or [])
|
|
511
|
+
if isinstance(rule, dict)
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
def comment(self, *, parent: str, body: str) -> dict:
|
|
516
|
+
if self.mode == "cookie" and not self.modhash:
|
|
517
|
+
self.me()
|
|
518
|
+
form = {"api_type": "json", "thing_id": parent, "text": body, "raw_json": "1"}
|
|
519
|
+
if self.mode == "cookie":
|
|
520
|
+
if not self.modhash:
|
|
521
|
+
die("Reddit did not return a modhash; the Cookie session cannot perform writes.")
|
|
522
|
+
form["uh"] = self.modhash
|
|
523
|
+
|
|
524
|
+
payload = self.request("POST", "/api/comment", form=form, write_kind="comment")
|
|
525
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("json"), dict):
|
|
526
|
+
die_unknown_comment_response()
|
|
527
|
+
json_payload = payload["json"]
|
|
528
|
+
errors = json_payload.get("errors")
|
|
529
|
+
if not isinstance(errors, list):
|
|
530
|
+
die_unknown_comment_response()
|
|
531
|
+
if errors:
|
|
532
|
+
die(
|
|
533
|
+
"Reddit rejected the comment. Check subreddit rules, account age/karma "
|
|
534
|
+
"requirements, whether the thread is locked or archived, and rate limits; "
|
|
535
|
+
"authenticated error details were omitted."
|
|
536
|
+
)
|
|
537
|
+
things = (json_payload.get("data") or {}).get("things")
|
|
538
|
+
if not isinstance(things, list) or not things or not isinstance(things[0], dict):
|
|
539
|
+
die_unknown_comment_response()
|
|
540
|
+
created = things[0].get("data")
|
|
541
|
+
if not isinstance(created, dict):
|
|
542
|
+
die_unknown_comment_response()
|
|
543
|
+
permalink = created.get("permalink")
|
|
544
|
+
comment_url = WEB_BASE + permalink if isinstance(permalink, str) and permalink.startswith("/") else ""
|
|
545
|
+
if not comment_url:
|
|
546
|
+
die_unknown_comment_response()
|
|
547
|
+
return {"ok": True, "commented": True, "id": created.get("id"), "name": created.get("name"), "url": comment_url}
|
|
548
|
+
|
|
549
|
+
|
|
424
550
|
def format_profile(data: dict, mode: str) -> dict:
|
|
425
551
|
return {
|
|
426
552
|
"auth_mode": mode,
|
|
@@ -442,10 +568,15 @@ def valid_submission_data(item: object) -> bool:
|
|
|
442
568
|
).startswith("/")
|
|
443
569
|
|
|
444
570
|
|
|
445
|
-
def die_unknown_write_outcome(reason: str) -> None:
|
|
571
|
+
def die_unknown_write_outcome(reason: str, *, kind: str = "post") -> None:
|
|
572
|
+
where = (
|
|
573
|
+
"Open the parent thread and look for your reply"
|
|
574
|
+
if kind == "comment"
|
|
575
|
+
else "Check recent submissions"
|
|
576
|
+
)
|
|
446
577
|
die(
|
|
447
|
-
f"{reason}; authenticated response content was omitted and the
|
|
448
|
-
"
|
|
578
|
+
f"{reason}; authenticated response content was omitted and the {kind} outcome is unknown. "
|
|
579
|
+
f"{where} before taking any further action and do not replay automatically."
|
|
449
580
|
)
|
|
450
581
|
|
|
451
582
|
|
|
@@ -453,6 +584,27 @@ def die_unknown_post_response() -> None:
|
|
|
453
584
|
die_unknown_write_outcome("Reddit returned a malformed write response")
|
|
454
585
|
|
|
455
586
|
|
|
587
|
+
def die_unknown_comment_response() -> None:
|
|
588
|
+
die_unknown_write_outcome("Reddit returned a malformed comment response", kind="comment")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def format_search_hit(item: dict) -> dict:
|
|
592
|
+
permalink = item.get("permalink")
|
|
593
|
+
return {
|
|
594
|
+
"fullname": item.get("name"),
|
|
595
|
+
"title": item.get("title"),
|
|
596
|
+
"subreddit": item.get("subreddit"),
|
|
597
|
+
"author": item.get("author"),
|
|
598
|
+
"url": WEB_BASE + permalink if isinstance(permalink, str) and permalink.startswith("/") else item.get("url"),
|
|
599
|
+
"score": item.get("score"),
|
|
600
|
+
"num_comments": item.get("num_comments"),
|
|
601
|
+
"created_utc": item.get("created_utc"),
|
|
602
|
+
"over_18": item.get("over_18"),
|
|
603
|
+
"link_flair_text": item.get("link_flair_text"),
|
|
604
|
+
"selftext_excerpt": (item.get("selftext") or "")[:500],
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
456
608
|
def format_submission(item: dict) -> dict:
|
|
457
609
|
permalink = item.get("permalink")
|
|
458
610
|
return {
|
|
@@ -467,12 +619,26 @@ def format_submission(item: dict) -> dict:
|
|
|
467
619
|
|
|
468
620
|
|
|
469
621
|
def dry_run(args: argparse.Namespace) -> None:
|
|
622
|
+
note = "No request was sent. Re-run with --confirm as the final argument after explicit user approval."
|
|
623
|
+
if args.command == "comment":
|
|
624
|
+
body = read_body(args)
|
|
625
|
+
output(
|
|
626
|
+
{
|
|
627
|
+
"dry_run": True,
|
|
628
|
+
"command": args.command,
|
|
629
|
+
"parent": normalize_thing_id(args.parent),
|
|
630
|
+
"body_length": len(body),
|
|
631
|
+
"body_preview": body[:280],
|
|
632
|
+
"note": note,
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
return
|
|
470
636
|
value = {
|
|
471
637
|
"dry_run": True,
|
|
472
638
|
"command": args.command,
|
|
473
639
|
"subreddit": normalize_subreddit(args.subreddit),
|
|
474
640
|
"title": validate_title(args.title),
|
|
475
|
-
"note":
|
|
641
|
+
"note": note,
|
|
476
642
|
}
|
|
477
643
|
if args.command == "submit-text":
|
|
478
644
|
value["text_length"] = len(read_text(args))
|
|
@@ -507,6 +673,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
507
673
|
link_post.add_argument("--subreddit", "-r", required=True)
|
|
508
674
|
link_post.add_argument("--title", required=True)
|
|
509
675
|
link_post.add_argument("--url", required=True)
|
|
676
|
+
|
|
677
|
+
search = commands.add_parser("search", help="search public posts to find threads worth replying to")
|
|
678
|
+
search.add_argument("--query", "-q", required=True)
|
|
679
|
+
search.add_argument("--subreddit", "-r", default="")
|
|
680
|
+
search.add_argument("--sort", choices=["new", "relevance", "top", "comments"], default="new")
|
|
681
|
+
search.add_argument("--time", dest="time_filter", choices=["hour", "day", "week", "month", "year", "all"], default="week")
|
|
682
|
+
search.add_argument("--limit", type=positive_limit, default=10)
|
|
683
|
+
|
|
684
|
+
about = commands.add_parser("subreddit-info", help="read a subreddit's rules and posting requirements")
|
|
685
|
+
about.add_argument("--subreddit", "-r", required=True)
|
|
686
|
+
|
|
687
|
+
reply = commands.add_parser("comment", help="reply to a post or comment (gated)")
|
|
688
|
+
reply.add_argument("--parent", required=True, help="fullname (t3_xxx / t1_xxx) or a reddit.com permalink")
|
|
689
|
+
body_source = reply.add_mutually_exclusive_group(required=True)
|
|
690
|
+
body_source.add_argument("--body")
|
|
691
|
+
body_source.add_argument("--body-file", dest="body_file")
|
|
510
692
|
return parser
|
|
511
693
|
|
|
512
694
|
|
|
@@ -527,6 +709,22 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
527
709
|
items = client.submissions(args.limit)
|
|
528
710
|
output({"auth_mode": client.mode, "count": len(items), "submissions": [format_submission(item) for item in items]})
|
|
529
711
|
return
|
|
712
|
+
if args.command == "search":
|
|
713
|
+
hits = client.search(
|
|
714
|
+
query=validate_query(args.query),
|
|
715
|
+
subreddit=normalize_subreddit(args.subreddit) if args.subreddit else "",
|
|
716
|
+
sort=args.sort,
|
|
717
|
+
limit=args.limit,
|
|
718
|
+
time_filter=args.time_filter,
|
|
719
|
+
)
|
|
720
|
+
output({"auth_mode": client.mode, "count": len(hits), "results": [format_search_hit(item) for item in hits]})
|
|
721
|
+
return
|
|
722
|
+
if args.command == "subreddit-info":
|
|
723
|
+
output(client.subreddit_about(normalize_subreddit(args.subreddit)))
|
|
724
|
+
return
|
|
725
|
+
if args.command == "comment":
|
|
726
|
+
output(client.comment(parent=normalize_thing_id(args.parent), body=read_body(args)))
|
|
727
|
+
return
|
|
530
728
|
|
|
531
729
|
subreddit = normalize_subreddit(args.subreddit)
|
|
532
730
|
title = validate_title(args.title)
|
|
@@ -373,6 +373,189 @@ class RedditSkillTests(unittest.TestCase):
|
|
|
373
373
|
self.assertIn("do not replay", post_stream.getvalue())
|
|
374
374
|
self.assertNotIn("csrf-secret", post_stream.getvalue())
|
|
375
375
|
|
|
376
|
+
def test_normalize_thing_id_accepts_fullnames_and_permalinks(self):
|
|
377
|
+
self.assertEqual("t3_abc123", reddit.normalize_thing_id("t3_abc123"))
|
|
378
|
+
self.assertEqual("t1_def456", reddit.normalize_thing_id("t1_def456"))
|
|
379
|
+
self.assertEqual(
|
|
380
|
+
"t3_1r9yrtn",
|
|
381
|
+
reddit.normalize_thing_id("https://www.reddit.com/r/SunoAI/comments/1r9yrtn/reliable_alternative/"),
|
|
382
|
+
)
|
|
383
|
+
self.assertEqual(
|
|
384
|
+
"t1_def456",
|
|
385
|
+
reddit.normalize_thing_id("https://www.reddit.com/r/test/comments/abc123/some_slug/def456/"),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_normalize_thing_id_rejects_non_reddit_and_malformed(self):
|
|
389
|
+
for value in [
|
|
390
|
+
"https://evil.example.com/r/test/comments/abc123/x/",
|
|
391
|
+
"t9_abc123",
|
|
392
|
+
"not-a-thing",
|
|
393
|
+
"https://www.reddit.com/r/test/",
|
|
394
|
+
# Only comments (t1_) and posts (t3_) can be replied to.
|
|
395
|
+
"t2_abc123",
|
|
396
|
+
"t4_abc123",
|
|
397
|
+
"t5_abc123",
|
|
398
|
+
"t6_abc123",
|
|
399
|
+
]:
|
|
400
|
+
stream = io.StringIO()
|
|
401
|
+
with self.subTest(value=value), self.assertRaises(SystemExit), redirect_stdout(stream):
|
|
402
|
+
reddit.normalize_thing_id(value)
|
|
403
|
+
|
|
404
|
+
def test_comment_dry_run_never_calls_network(self):
|
|
405
|
+
stream = io.StringIO()
|
|
406
|
+
with patch.dict(os.environ, {}, clear=True), patch.object(reddit, "open_request") as urlopen, redirect_stdout(
|
|
407
|
+
stream
|
|
408
|
+
):
|
|
409
|
+
reddit.main(["comment", "--parent", "t3_abc123", "--body", "Body"])
|
|
410
|
+
|
|
411
|
+
value = json.loads(stream.getvalue())
|
|
412
|
+
self.assertTrue(value["dry_run"])
|
|
413
|
+
self.assertEqual("t3_abc123", value["parent"])
|
|
414
|
+
self.assertEqual(4, value["body_length"])
|
|
415
|
+
urlopen.assert_not_called()
|
|
416
|
+
|
|
417
|
+
def test_comment_body_containing_confirm_cannot_trigger_a_write(self):
|
|
418
|
+
stream = io.StringIO()
|
|
419
|
+
with patch.dict(os.environ, {"REDDIT_COOKIES": COOKIE_JAR}, clear=True), patch.object(
|
|
420
|
+
reddit, "open_request"
|
|
421
|
+
) as urlopen, redirect_stdout(stream):
|
|
422
|
+
reddit.main(["comment", "--parent", "t3_abc123", "--body", "please --confirm this"])
|
|
423
|
+
|
|
424
|
+
self.assertTrue(json.loads(stream.getvalue())["dry_run"])
|
|
425
|
+
urlopen.assert_not_called()
|
|
426
|
+
|
|
427
|
+
def test_cookie_mode_comment_sends_modhash_and_returns_permalink(self):
|
|
428
|
+
responses = [
|
|
429
|
+
FakeResponse({"data": {"id": "abc", "name": "tester", "modhash": "csrf-secret"}}),
|
|
430
|
+
FakeResponse(
|
|
431
|
+
{
|
|
432
|
+
"json": {
|
|
433
|
+
"errors": [],
|
|
434
|
+
"data": {
|
|
435
|
+
"things": [
|
|
436
|
+
{
|
|
437
|
+
"data": {
|
|
438
|
+
"id": "c1",
|
|
439
|
+
"name": "t1_c1",
|
|
440
|
+
"permalink": "/r/test/comments/post1/title/c1/",
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
),
|
|
448
|
+
]
|
|
449
|
+
with patch.dict(os.environ, {"REDDIT_COOKIES": COOKIE_JAR}, clear=True), patch.object(
|
|
450
|
+
reddit, "open_request", side_effect=responses
|
|
451
|
+
) as urlopen:
|
|
452
|
+
client = reddit.RedditClient.from_environment()
|
|
453
|
+
result = client.comment(parent="t3_post1", body="Reply body")
|
|
454
|
+
|
|
455
|
+
self.assertTrue(result["commented"])
|
|
456
|
+
self.assertEqual("https://www.reddit.com/r/test/comments/post1/title/c1/", result["url"])
|
|
457
|
+
comment_request = urlopen.call_args_list[1].args[0]
|
|
458
|
+
self.assertEqual("https://www.reddit.com/api/comment", comment_request.full_url)
|
|
459
|
+
form = urllib.parse.parse_qs(comment_request.data.decode())
|
|
460
|
+
self.assertEqual(["csrf-secret"], form["uh"])
|
|
461
|
+
self.assertEqual(["t3_post1"], form["thing_id"])
|
|
462
|
+
self.assertEqual(["Reply body"], form["text"])
|
|
463
|
+
|
|
464
|
+
def test_comment_rejection_and_malformed_shapes_hide_authenticated_details(self):
|
|
465
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
466
|
+
client.modhash = "csrf-secret"
|
|
467
|
+
payloads = [
|
|
468
|
+
{"json": {"errors": [["RATELIMIT", "you are doing that too much csrf-secret", None]]}},
|
|
469
|
+
{"json": {"errors": []}},
|
|
470
|
+
{"json": {"errors": [], "data": {"things": []}}},
|
|
471
|
+
{"json": {"errors": [], "data": {"things": [{"data": {"id": "c1"}}]}}},
|
|
472
|
+
]
|
|
473
|
+
for payload in payloads:
|
|
474
|
+
stream = io.StringIO()
|
|
475
|
+
with self.subTest(payload=payload), patch.object(
|
|
476
|
+
client, "request", return_value=payload
|
|
477
|
+
), self.assertRaises(SystemExit), redirect_stdout(stream):
|
|
478
|
+
client.comment(parent="t3_post1", body="Body")
|
|
479
|
+
self.assertNotIn("csrf-secret", stream.getvalue())
|
|
480
|
+
|
|
481
|
+
def test_comment_body_limits_are_enforced(self):
|
|
482
|
+
for body in ["", " ", "x" * 10_001]:
|
|
483
|
+
stream = io.StringIO()
|
|
484
|
+
with self.subTest(body=len(body)), self.assertRaises(SystemExit), redirect_stdout(stream):
|
|
485
|
+
reddit.validate_comment_body(body)
|
|
486
|
+
|
|
487
|
+
def test_search_returns_fullnames_for_reply_targets(self):
|
|
488
|
+
payload = {
|
|
489
|
+
"data": {
|
|
490
|
+
"children": [
|
|
491
|
+
{
|
|
492
|
+
"data": {
|
|
493
|
+
"id": "p1",
|
|
494
|
+
"name": "t3_p1",
|
|
495
|
+
"title": "Looking for a Suno API",
|
|
496
|
+
"subreddit": "SunoAI",
|
|
497
|
+
"permalink": "/r/SunoAI/comments/p1/looking/",
|
|
498
|
+
"selftext": "x" * 900,
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
{"data": {"id": "bad"}},
|
|
502
|
+
]
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
506
|
+
with patch.object(client, "request", return_value=payload):
|
|
507
|
+
hits = client.search(query="suno api", limit=5)
|
|
508
|
+
|
|
509
|
+
self.assertEqual(1, len(hits))
|
|
510
|
+
formatted = reddit.format_search_hit(hits[0])
|
|
511
|
+
self.assertEqual("t3_p1", formatted["fullname"])
|
|
512
|
+
self.assertEqual("https://www.reddit.com/r/SunoAI/comments/p1/looking/", formatted["url"])
|
|
513
|
+
self.assertEqual(500, len(formatted["selftext_excerpt"]))
|
|
514
|
+
|
|
515
|
+
def test_search_is_a_read_and_rejects_malformed_shape(self):
|
|
516
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
517
|
+
stream = io.StringIO()
|
|
518
|
+
with patch.object(client, "request", return_value={"data": {}}), self.assertRaises(
|
|
519
|
+
SystemExit
|
|
520
|
+
), redirect_stdout(stream):
|
|
521
|
+
client.search(query="suno api")
|
|
522
|
+
self.assertIn("malformed search response", stream.getvalue())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_comment_write_failures_never_advise_checking_submissions(self):
|
|
526
|
+
"""A comment never appears in `submissions`; that advice would cause a duplicate reply."""
|
|
527
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
528
|
+
client.modhash = "csrf-secret"
|
|
529
|
+
|
|
530
|
+
# Malformed shape.
|
|
531
|
+
stream = io.StringIO()
|
|
532
|
+
with patch.object(client, "request", return_value={"json": {"errors": []}}), self.assertRaises(
|
|
533
|
+
SystemExit
|
|
534
|
+
), redirect_stdout(stream):
|
|
535
|
+
client.comment(parent="t3_post1", body="Body")
|
|
536
|
+
self.assertIn("comment outcome is unknown", stream.getvalue())
|
|
537
|
+
self.assertIn("parent thread", stream.getvalue())
|
|
538
|
+
self.assertNotIn("recent submissions", stream.getvalue())
|
|
539
|
+
|
|
540
|
+
# Transport failure inside request().
|
|
541
|
+
stream = io.StringIO()
|
|
542
|
+
with patch.object(
|
|
543
|
+
reddit, "open_request", side_effect=urllib.error.URLError("boom")
|
|
544
|
+
), self.assertRaises(SystemExit), redirect_stdout(stream):
|
|
545
|
+
client.request("POST", "/api/comment", form={"text": "x"}, write_kind="comment")
|
|
546
|
+
self.assertIn("comment outcome is unknown", stream.getvalue())
|
|
547
|
+
self.assertNotIn("recent submissions", stream.getvalue())
|
|
548
|
+
|
|
549
|
+
def test_post_write_failures_still_advise_checking_submissions(self):
|
|
550
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
551
|
+
stream = io.StringIO()
|
|
552
|
+
with patch.object(
|
|
553
|
+
reddit, "open_request", side_effect=urllib.error.URLError("boom")
|
|
554
|
+
), self.assertRaises(SystemExit), redirect_stdout(stream):
|
|
555
|
+
client.request("POST", "/api/submit", form={"title": "x"})
|
|
556
|
+
self.assertIn("post outcome is unknown", stream.getvalue())
|
|
557
|
+
self.assertIn("recent submissions", stream.getvalue())
|
|
558
|
+
|
|
376
559
|
|
|
377
560
|
if __name__ == "__main__":
|
|
378
561
|
unittest.main()
|