@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.725.0",
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",
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: reddit
3
- description: Submit posts (link or text) to subreddits and read your Reddit identity / submissions using either official OAuth or your own Reddit login cookies. Use when the user mentions Reddit, posting to a subreddit, submitting a link or self-post, or checking their Reddit profile / submissions.
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 submit a post to a subreddit (link or
6
- self/text post), or read their own Reddit identity and submissions.
7
- Posting to a subreddit is public and subject to that subreddit's rules
8
- / karma requirements — confirm the target subreddit, title and body
9
- before submitting.
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.0"
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, automate engagement, or
83
- cross-post identical content. This skill intentionally exposes none of those
84
- operations.
85
- - Follow each subreddit's rules. Account age, karma and flair requirements can
86
- reject a post; report the rejection without exposing Reddit's raw authenticated
87
- response, which may contain reflected credential material.
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 for a deliberate
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 URL, call the built-in
100
- `publish_artifact` tool ONCE so the user can track this deliverable in **My Outputs**:
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 post outcome is unknown. "
448
- "Check recent submissions before taking any further action and do not replay automatically."
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": "No request was sent. Re-run with --confirm as the final argument after explicit user approval.",
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()