@acedatacloud/skills 2026.713.0 → 2026.713.1

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.713.0",
3
+ "version": "2026.713.1",
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,6 +1,6 @@
1
1
  ---
2
2
  name: reddit
3
- description: Submit posts (link or text) to subreddits and read your Reddit identity / content via the Reddit API. Use when the user mentions Reddit, posting to a subreddit, submitting a link or self-post, or checking their Reddit profile / submissions.
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.
4
4
  when_to_use: |
5
5
  Trigger when the user wants to submit a post to a subreddit (link or
6
6
  self/text post), or read their own Reddit identity and submissions.
@@ -8,75 +8,88 @@ when_to_use: |
8
8
  / karma requirements — confirm the target subreddit, title and body
9
9
  before submitting.
10
10
  connections: [reddit]
11
- allowed_tools: [Bash]
11
+ allowed_tools: [Bash, publish_artifact]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "1.0"
15
+ version: "2.0"
16
16
  ---
17
17
 
18
- Call the **Reddit API** (OAuth endpoints) with `curl + jq`. The user's bearer
19
- token is in `$REDDIT_TOKEN`. **Every call MUST send a `User-Agent` header** or
20
- Reddit returns `429`. Use the OAuth host `https://oauth.reddit.com`.
18
+ # Reddit OAuth or login-cookie access
21
19
 
22
- ```bash
23
- UA="web:cloud.acedata.connectors:v1.0 (by /u/acedatacloud)"
24
- ```
20
+ The connector injects exactly one of these credentials:
25
21
 
26
- Errors are JSON; a submit returns `{"json":{"errors":[...], "data":{...}}}`
27
- if `errors` is non-empty, show them verbatim. `401` token expired, re-connect.
22
+ - `REDDIT_COOKIES`: JSON cookie array captured by the ACE browser extension.
23
+ It includes `reddit_session` and grants full account access. **Secret never
24
+ echo, print, log or return it.**
25
+ - `REDDIT_TOKEN`: official OAuth bearer token (`identity read submit`).
28
26
 
29
- **Always start by confirming identity:**
27
+ The helper automatically prefers Cookie when present and otherwise uses OAuth.
28
+ It sends Reddit's required descriptive User-Agent and never forwards cookies
29
+ outside `reddit.com`.
30
30
 
31
- ```bash
32
- curl -sS -H "Authorization: Bearer $REDDIT_TOKEN" -H "User-Agent: $UA" \
33
- "https://oauth.reddit.com/api/v1/me" | jq '{name, total_karma, link_karma}'
34
- ```
31
+ ## Script resolution
35
32
 
36
- ## Submit a post
37
-
38
- **Confirm the subreddit + title + body with the user first.** `sr` is the
39
- subreddit name WITHOUT the `r/` prefix.
40
-
41
- ```bash
42
- # Self (text) post: kind=self + text. Link post: kind=link + url.
43
- curl -sS -X POST "https://oauth.reddit.com/api/submit" \
44
- -H "Authorization: Bearer $REDDIT_TOKEN" -H "User-Agent: $UA" \
45
- --data-urlencode "sr=test" \
46
- --data-urlencode "kind=self" \
47
- --data-urlencode "title=My title" \
48
- --data-urlencode "text=My self-post body in markdown" \
49
- --data-urlencode "api_type=json" \
50
- | jq '.json | {errors, url: .data.url, id: .data.id}'
33
+ Bash calls do not share shell variables. Resolve the helper inside **every**
34
+ fenced Bash invocation before using it:
35
+
36
+ ```sh
37
+ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
38
+ [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
39
+ python3 "$R" whoami
51
40
  ```
52
41
 
53
- For a link post:
42
+ If authentication fails, ask the user to reconnect at
43
+ <https://auth.acedata.cloud/user/connections>. Do not loop-retry a blocked or
44
+ expired session.
45
+
46
+ ## Read
54
47
 
55
- ```bash
56
- curl -sS -X POST "https://oauth.reddit.com/api/submit" \
57
- -H "Authorization: Bearer $REDDIT_TOKEN" -H "User-Agent: $UA" \
58
- --data-urlencode "sr=test" --data-urlencode "kind=link" \
59
- --data-urlencode "title=My title" --data-urlencode "url=https://example.com" \
60
- --data-urlencode "api_type=json" | jq '.json'
48
+ ```sh
49
+ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
50
+ [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
51
+ python3 "$R" whoami
52
+ python3 "$R" submissions --limit 10
61
53
  ```
62
54
 
63
- ## Read my submissions
55
+ ## Submit a post — GATED
64
56
 
65
- ```bash
66
- curl -sS -H "Authorization: Bearer $REDDIT_TOKEN" -H "User-Agent: $UA" \
67
- "https://oauth.reddit.com/user/USERNAME/submitted?limit=10" \
68
- | jq '.data.children[] | .data | {title, subreddit, ups, num_comments, permalink}'
69
- ```
57
+ Posting is public. **Always show the subreddit, final title and final body/URL,
58
+ then obtain explicit confirmation.** Without a trailing `--confirm`, both write
59
+ commands are dry-runs and make no network request.
60
+
61
+ ```sh
62
+ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
63
+ [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
70
64
 
71
- ## Gotchas
65
+ # Text post: use a file for long Markdown.
66
+ python3 "$R" submit-text --subreddit test --title "My title" --text-file post.md
67
+ python3 "$R" submit-text --subreddit test --title "My title" --text-file post.md --confirm
68
+
69
+ # Link post.
70
+ python3 "$R" submit-link --subreddit test --title "My title" --url "https://example.com"
71
+ python3 "$R" submit-link --subreddit test --title "My title" --url "https://example.com" --confirm
72
+ ```
72
73
 
73
- - **User-Agent is mandatory** on every request omitting it → `429`.
74
- - Many subreddits gate posting on **account age / karma / flair**; a submit can
75
- return `errors` like `[["SUBREDDIT_NOTALLOWED", ...]]` — surface it and try
76
- `r/test` to validate the flow.
77
- - Respect rate limits: read the `X-Ratelimit-Remaining` response header; space
78
- out bulk submits.
79
- - Use `r/test` as a safe target when validating that the connection works.
74
+ `--confirm` is honored only when it is the final argument. A title or body that
75
+ contains the text `--confirm` can never trigger a write.
76
+
77
+ ## Safety and failure handling
78
+
79
+ - Never print `REDDIT_COOKIES`, `REDDIT_TOKEN`, `reddit_session` or the modhash.
80
+ - Do not vote, send private messages, evade bans, automate engagement, or
81
+ cross-post identical content. This skill intentionally exposes none of those
82
+ operations.
83
+ - Follow each subreddit's rules. Account age, karma and flair requirements can
84
+ reject a post; report the rejection without exposing Reddit's raw authenticated
85
+ response, which may contain reflected credential material.
86
+ - Do not retry a write automatically. A timeout may occur after Reddit accepted
87
+ it, and replaying could create a duplicate.
88
+ - Respect rate limits and never bulk-submit. Use `r/test` only for a deliberate
89
+ end-to-end validation.
90
+ - Cookie mode drives Reddit's first-party web JSON endpoints and may drift when
91
+ Reddit changes its site. Report unexpected HTML or route errors as upstream
92
+ drift instead of guessing another private endpoint.
80
93
 
81
94
 
82
95
  ## Record the output
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env python3
2
+ """Read and submit Reddit posts with OAuth or the user's login cookies.
3
+
4
+ Cookie mode uses Reddit's first-party web JSON endpoints and the ``modhash``
5
+ returned by ``/api/me.json``. OAuth mode uses ``oauth.reddit.com``. The helper
6
+ automatically selects ``REDDIT_COOKIES`` first, then ``REDDIT_TOKEN``.
7
+
8
+ State-changing commands are dry-runs unless ``--confirm`` is the final argv
9
+ token. Credentials, cookie values and modhashes are never emitted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import contextlib
16
+ import gzip
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+
26
+ WEB_BASE = "https://www.reddit.com"
27
+ OAUTH_BASE = "https://oauth.reddit.com"
28
+ USER_AGENT = "web:cloud.acedata.reddit:v2.0 (by /u/acedatacloud)"
29
+ GATED_COMMANDS = {"submit-text", "submit-link"}
30
+ SUBREDDIT_RE = re.compile(r"^[A-Za-z0-9_]{2,21}$")
31
+ COOKIE_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
32
+
33
+
34
+ def output(value) -> None:
35
+ print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
36
+
37
+
38
+ def die(message: str, code: int = 1) -> None:
39
+ output({"error": message})
40
+ raise SystemExit(code)
41
+
42
+
43
+ def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
44
+ confirmed = bool(argv) and argv[-1] == "--confirm"
45
+ return (argv[:-1] if confirmed else list(argv), confirmed)
46
+
47
+
48
+ def parse_cookie_jar(raw: str) -> list[dict]:
49
+ try:
50
+ jar = json.loads(raw)
51
+ except json.JSONDecodeError as error:
52
+ die(f"REDDIT_COOKIES is not valid JSON: {error}")
53
+ if not isinstance(jar, list):
54
+ die(f"REDDIT_COOKIES must be a JSON list of cookies, got {type(jar).__name__}")
55
+ normalized = []
56
+ for item in jar:
57
+ if not isinstance(item, dict):
58
+ continue
59
+ name = item.get("name")
60
+ value = item.get("value")
61
+ if not isinstance(name, str) or not COOKIE_NAME_RE.fullmatch(name) or not isinstance(value, str):
62
+ continue
63
+ if any(character == ";" or ord(character) < 0x20 or ord(character) == 0x7F for character in value):
64
+ continue
65
+ domain = item.get("domain")
66
+ if not isinstance(domain, str) or not reddit_cookie_domain(domain):
67
+ continue
68
+ path = str(item.get("path") or "/")
69
+ if not path.startswith("/"):
70
+ continue
71
+ expiration = item.get("expirationDate")
72
+ if expiration not in (None, ""):
73
+ try:
74
+ if float(expiration) <= time.time():
75
+ continue
76
+ except (TypeError, ValueError):
77
+ continue
78
+ normalized.append(
79
+ dict(
80
+ item,
81
+ name=name,
82
+ value=value,
83
+ domain=domain,
84
+ path=path,
85
+ secure=bool(item.get("secure")),
86
+ )
87
+ )
88
+ if not any(
89
+ cookie.get("name") == "reddit_session"
90
+ and cookie.get("value")
91
+ and cookie_applies(cookie, WEB_BASE + "/api/me.json")
92
+ for cookie in normalized
93
+ ):
94
+ die(
95
+ "REDDIT_COOKIES is missing a valid reddit_session — log in on reddit.com, "
96
+ "re-capture with the ACE extension, then reconnect at "
97
+ "https://auth.acedata.cloud/user/connections."
98
+ )
99
+ return normalized
100
+
101
+
102
+ def reddit_cookie_domain(domain: str) -> bool:
103
+ normalized_domain = domain.strip().lstrip(".").lower()
104
+ return normalized_domain == "reddit.com" or normalized_domain.endswith(".reddit.com")
105
+
106
+
107
+ def domain_matches(host: str, domain: str) -> bool:
108
+ raw_domain = domain.strip().lower()
109
+ normalized_domain = raw_domain.lstrip(".")
110
+ normalized_host = host.lower()
111
+ if not normalized_domain:
112
+ return False
113
+ if raw_domain.startswith("."):
114
+ return normalized_host == normalized_domain or normalized_host.endswith("." + normalized_domain)
115
+ return normalized_host == normalized_domain
116
+
117
+
118
+ def cookie_domain_matches(cookie: dict, host: str) -> bool:
119
+ domain = str(cookie.get("domain") or "")
120
+ if cookie.get("hostOnly") is True:
121
+ return host.lower() == domain.lstrip(".").lower()
122
+ if cookie.get("hostOnly") is False:
123
+ normalized = domain.lstrip(".")
124
+ return bool(normalized) and (
125
+ host.lower() == normalized.lower() or host.lower().endswith("." + normalized.lower())
126
+ )
127
+ return domain_matches(host, domain)
128
+
129
+
130
+ def path_matches(request_path: str, cookie_path: str) -> bool:
131
+ if request_path == cookie_path:
132
+ return True
133
+ if not request_path.startswith(cookie_path):
134
+ return False
135
+ return cookie_path.endswith("/") or request_path[len(cookie_path) :].startswith("/")
136
+
137
+
138
+ def cookie_applies(cookie: dict, url: str) -> bool:
139
+ parsed = urllib.parse.urlsplit(url)
140
+ host = parsed.hostname or ""
141
+ domain = str(cookie.get("domain") or "")
142
+ path = str(cookie.get("path") or "/")
143
+ if not reddit_cookie_domain(domain) or not cookie_domain_matches(cookie, host):
144
+ return False
145
+ if not path.startswith("/") or not path_matches(parsed.path or "/", path):
146
+ return False
147
+ if cookie.get("secure") and parsed.scheme != "https":
148
+ return False
149
+ expiration = cookie.get("expirationDate")
150
+ if expiration not in (None, ""):
151
+ try:
152
+ if float(expiration) <= time.time():
153
+ return False
154
+ except (TypeError, ValueError):
155
+ return False
156
+ return True
157
+
158
+
159
+ def cookie_header(jar: list[dict], url: str) -> str:
160
+ host = urllib.parse.urlsplit(url).hostname or ""
161
+ if not reddit_cookie_domain(host):
162
+ return ""
163
+ applicable = [
164
+ (index, cookie)
165
+ for index, cookie in enumerate(jar)
166
+ if cookie_applies(cookie, url)
167
+ ]
168
+ applicable.sort(key=lambda item: (-len(str(item[1].get("path") or "/")), item[0]))
169
+ parts = []
170
+ seen = set()
171
+ for _, cookie in applicable:
172
+ identity = (
173
+ cookie.get("name"),
174
+ cookie.get("domain"),
175
+ cookie.get("path") or "/",
176
+ )
177
+ if identity in seen:
178
+ continue
179
+ seen.add(identity)
180
+ parts.append(f"{cookie['name']}={cookie['value']}")
181
+ return "; ".join(parts)
182
+
183
+
184
+ def normalize_subreddit(value: str) -> str:
185
+ subreddit = value.strip().strip("/")
186
+ if subreddit.lower().startswith("r/"):
187
+ subreddit = subreddit[2:]
188
+ if not SUBREDDIT_RE.fullmatch(subreddit):
189
+ die("subreddit must contain only letters, numbers or underscores (2-21 characters)")
190
+ return subreddit
191
+
192
+
193
+ def validate_title(value: str) -> str:
194
+ title = value.strip()
195
+ if not title:
196
+ die("title cannot be empty")
197
+ if len(title) > 300:
198
+ die("title exceeds Reddit's 300-character limit")
199
+ return title
200
+
201
+
202
+ def validate_link(value: str) -> str:
203
+ parsed = urllib.parse.urlsplit(value.strip())
204
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
205
+ die("url must be an http(s) URL without embedded credentials")
206
+ return value.strip()
207
+
208
+
209
+ def read_text(args: argparse.Namespace) -> str:
210
+ if args.text_file:
211
+ try:
212
+ with open(args.text_file, encoding="utf-8") as file_handle:
213
+ text = file_handle.read()
214
+ except OSError as error:
215
+ die(f"cannot read text file {args.text_file}: {error}")
216
+ else:
217
+ text = args.text or ""
218
+ if len(text) > 40_000:
219
+ die("text exceeds Reddit's 40,000-character limit")
220
+ return text
221
+
222
+
223
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
224
+ """Return 30x responses to the caller instead of replaying credentials."""
225
+
226
+ def redirect_request(self, request, file_pointer, code, message, headers, new_url):
227
+ return None
228
+
229
+
230
+ def open_request(request: urllib.request.Request):
231
+ return urllib.request.build_opener(NoRedirect()).open(request, timeout=30)
232
+
233
+
234
+ class RedditClient:
235
+ def __init__(self, mode: str, *, cookies: list[dict] | None = None, token: str = ""):
236
+ self.mode = mode
237
+ self.cookies = cookies or []
238
+ self.token = token
239
+ self.modhash = ""
240
+ self.username = ""
241
+
242
+ @classmethod
243
+ def from_environment(cls) -> "RedditClient":
244
+ raw_cookies = os.environ.get("REDDIT_COOKIES", "").strip()
245
+ if raw_cookies:
246
+ return cls("cookie", cookies=parse_cookie_jar(raw_cookies))
247
+ token = os.environ.get("REDDIT_TOKEN", "").strip()
248
+ if token:
249
+ if "\r" in token or "\n" in token:
250
+ die("REDDIT_TOKEN contains invalid characters")
251
+ return cls("oauth", token=token)
252
+ die(
253
+ "No Reddit credential is available — connect Reddit with Cookie or OAuth at "
254
+ "https://auth.acedata.cloud/user/connections."
255
+ )
256
+
257
+ @property
258
+ def base_url(self) -> str:
259
+ return WEB_BASE if self.mode == "cookie" else OAUTH_BASE
260
+
261
+ def request(self, method: str, path: str, *, query: dict | None = None, form: dict | None = None):
262
+ is_write = method.upper() == "POST"
263
+ url = self.base_url + path
264
+ if query:
265
+ url += "?" + urllib.parse.urlencode(query)
266
+ headers = {
267
+ "User-Agent": USER_AGENT,
268
+ "Accept": "application/json",
269
+ "Accept-Encoding": "gzip",
270
+ }
271
+ if self.mode == "oauth":
272
+ headers["Authorization"] = f"Bearer {self.token}"
273
+ else:
274
+ headers.update({"Origin": WEB_BASE, "Referer": WEB_BASE + "/", "X-Requested-With": "XMLHttpRequest"})
275
+
276
+ data = None
277
+ if form is not None:
278
+ data = urllib.parse.urlencode(form).encode("utf-8")
279
+ headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
280
+
281
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
282
+ if self.mode == "cookie":
283
+ request.add_unredirected_header("Cookie", cookie_header(self.cookies, url))
284
+
285
+ try:
286
+ with open_request(request) as response:
287
+ status = response.status
288
+ raw = response.read()
289
+ if response.headers.get("Content-Encoding") == "gzip":
290
+ raw = gzip.decompress(raw)
291
+ except urllib.error.HTTPError as error:
292
+ try:
293
+ status = error.code
294
+ raw = error.read()
295
+ if error.headers.get("Content-Encoding") == "gzip":
296
+ raw = gzip.decompress(raw)
297
+ except Exception:
298
+ if is_write:
299
+ die_unknown_write_outcome("The Reddit HTTP error response for the write request could not be read")
300
+ die("The Reddit HTTP error response could not be read; authenticated response content was omitted.")
301
+ finally:
302
+ with contextlib.suppress(Exception):
303
+ error.close()
304
+ except urllib.error.URLError as error:
305
+ if is_write:
306
+ die_unknown_write_outcome("A network error interrupted the Reddit write request")
307
+ die(f"network error reaching Reddit: {error.reason}")
308
+ except Exception:
309
+ if is_write:
310
+ die_unknown_write_outcome("The Reddit write response could not be read or decompressed")
311
+ die("The Reddit response could not be read or decompressed; authenticated response content was omitted.")
312
+
313
+ text = raw.decode("utf-8", "replace")
314
+ if 300 <= status < 400:
315
+ if is_write:
316
+ die_unknown_write_outcome("Reddit redirected the write request; credentials were not forwarded")
317
+ die("Reddit returned an unexpected redirect; credentials were not forwarded. Reconnect before retrying the read.")
318
+ if status in {401, 403}:
319
+ die(
320
+ f"Reddit authentication failed ({status}) — the credential may be expired or blocked. "
321
+ "Reconnect at https://auth.acedata.cloud/user/connections."
322
+ )
323
+ if status == 429:
324
+ die("Reddit rate limit reached (429). Wait before retrying; do not loop-retry.")
325
+ if status >= 400:
326
+ if is_write and status >= 500:
327
+ die_unknown_write_outcome(f"Reddit returned HTTP {status} for the write request")
328
+ die(f"Reddit returned HTTP {status}; authenticated response content was omitted.")
329
+ if text.lstrip().startswith("<"):
330
+ if is_write:
331
+ die_unknown_write_outcome("Reddit returned HTML for the write request")
332
+ die("Reddit returned HTML instead of JSON — the session expired or the web endpoint changed.")
333
+ try:
334
+ return json.loads(text)
335
+ except json.JSONDecodeError:
336
+ if is_write:
337
+ die_unknown_write_outcome("Reddit returned invalid JSON for the write request")
338
+ die(f"Reddit returned non-JSON data ({status}); authenticated response content was omitted.")
339
+
340
+ def me(self) -> dict:
341
+ if self.mode == "cookie":
342
+ payload = self.request("GET", "/api/me.json", query={"raw_json": 1})
343
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
344
+ die("Reddit returned a malformed identity response; authenticated response content was omitted.")
345
+ data = payload["data"]
346
+ self.modhash = str(data.get("modhash") or "")
347
+ else:
348
+ data = self.request("GET", "/api/v1/me")
349
+ if not isinstance(data, dict):
350
+ die("Reddit returned a malformed identity response; authenticated response content was omitted.")
351
+ self.username = str(data.get("name") or "")
352
+ if not self.username:
353
+ die("Reddit did not return an authenticated username — reconnect the account.")
354
+ return data
355
+
356
+ def submissions(self, limit: int) -> list[dict]:
357
+ username = self.username or str(self.me().get("name") or "")
358
+ suffix = ".json" if self.mode == "cookie" else ""
359
+ payload = self.request(
360
+ "GET",
361
+ f"/user/{urllib.parse.quote(username, safe='')}/submitted{suffix}",
362
+ query={"limit": limit, "raw_json": 1},
363
+ )
364
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
365
+ die("Reddit returned a malformed submissions response; authenticated response content was omitted.")
366
+ children = payload["data"].get("children")
367
+ if not isinstance(children, list) or any(
368
+ not isinstance(child, dict)
369
+ or not valid_submission_data(child.get("data"))
370
+ for child in children
371
+ ):
372
+ die("Reddit returned a malformed submissions response; authenticated response content was omitted.")
373
+ return [child["data"] for child in children]
374
+
375
+ def submit(self, *, subreddit: str, title: str, kind: str, text: str = "", url: str = "") -> dict:
376
+ if self.mode == "cookie" and not self.modhash:
377
+ self.me()
378
+ form = {
379
+ "api_type": "json",
380
+ "kind": kind,
381
+ "sr": subreddit,
382
+ "title": title,
383
+ "resubmit": "true",
384
+ "sendreplies": "true",
385
+ "raw_json": "1",
386
+ }
387
+ if kind == "self":
388
+ form["text"] = text
389
+ else:
390
+ form["url"] = url
391
+ if self.mode == "cookie":
392
+ if not self.modhash:
393
+ die("Reddit did not return a modhash; the Cookie session cannot perform writes.")
394
+ form["uh"] = self.modhash
395
+
396
+ payload = self.request("POST", "/api/submit", form=form)
397
+ if not isinstance(payload, dict) or not isinstance(payload.get("json"), dict):
398
+ die_unknown_post_response()
399
+ json_payload = payload["json"]
400
+ errors = json_payload.get("errors")
401
+ if not isinstance(errors, list):
402
+ die_unknown_post_response()
403
+ if errors:
404
+ die(
405
+ "Reddit rejected the post. Check subreddit rules, account eligibility, "
406
+ "flair requirements and rate limits; authenticated error details were omitted."
407
+ )
408
+ post = json_payload.get("data")
409
+ if not isinstance(post, dict):
410
+ die_unknown_post_response()
411
+ post_url = post.get("url") or post.get("permalink")
412
+ if not isinstance(post_url, str) or not post_url:
413
+ die_unknown_post_response()
414
+ if post_url.startswith("/"):
415
+ post_url = WEB_BASE + post_url
416
+ parsed_post_url = urllib.parse.urlsplit(post_url)
417
+ if parsed_post_url.scheme != "https" or not parsed_post_url.hostname or not reddit_cookie_domain(
418
+ parsed_post_url.hostname
419
+ ):
420
+ die_unknown_post_response()
421
+ return {"ok": True, "posted": True, "id": post.get("id"), "name": post.get("name"), "url": post_url}
422
+
423
+
424
+ def format_profile(data: dict, mode: str) -> dict:
425
+ return {
426
+ "auth_mode": mode,
427
+ "id": data.get("id"),
428
+ "name": data.get("name"),
429
+ "total_karma": data.get("total_karma"),
430
+ "link_karma": data.get("link_karma"),
431
+ "comment_karma": data.get("comment_karma"),
432
+ "created_utc": data.get("created_utc"),
433
+ }
434
+
435
+
436
+ def valid_submission_data(item: object) -> bool:
437
+ if not isinstance(item, dict):
438
+ return False
439
+ required_strings = ("id", "title", "subreddit", "permalink")
440
+ return all(isinstance(item.get(key), str) and item.get(key) for key in required_strings) and str(
441
+ item["permalink"]
442
+ ).startswith("/")
443
+
444
+
445
+ def die_unknown_write_outcome(reason: str) -> None:
446
+ 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."
449
+ )
450
+
451
+
452
+ def die_unknown_post_response() -> None:
453
+ die_unknown_write_outcome("Reddit returned a malformed write response")
454
+
455
+
456
+ def format_submission(item: dict) -> dict:
457
+ permalink = item.get("permalink")
458
+ return {
459
+ "id": item.get("id"),
460
+ "title": item.get("title"),
461
+ "subreddit": item.get("subreddit"),
462
+ "url": WEB_BASE + permalink if isinstance(permalink, str) and permalink.startswith("/") else item.get("url"),
463
+ "score": item.get("score"),
464
+ "num_comments": item.get("num_comments"),
465
+ "created_utc": item.get("created_utc"),
466
+ }
467
+
468
+
469
+ def dry_run(args: argparse.Namespace) -> None:
470
+ value = {
471
+ "dry_run": True,
472
+ "command": args.command,
473
+ "subreddit": normalize_subreddit(args.subreddit),
474
+ "title": validate_title(args.title),
475
+ "note": "No request was sent. Re-run with --confirm as the final argument after explicit user approval.",
476
+ }
477
+ if args.command == "submit-text":
478
+ value["text_length"] = len(read_text(args))
479
+ else:
480
+ value["url"] = validate_link(args.url)
481
+ output(value)
482
+
483
+
484
+ def positive_limit(value: str) -> int:
485
+ parsed = int(value)
486
+ if parsed < 1 or parsed > 100:
487
+ raise argparse.ArgumentTypeError("limit must be between 1 and 100")
488
+ return parsed
489
+
490
+
491
+ def build_parser() -> argparse.ArgumentParser:
492
+ parser = argparse.ArgumentParser(description="Reddit via OAuth or login cookies")
493
+ commands = parser.add_subparsers(dest="command", required=True)
494
+ commands.add_parser("whoami", help="show the connected Reddit identity")
495
+
496
+ submissions = commands.add_parser("submissions", help="list my recent submissions")
497
+ submissions.add_argument("--limit", type=positive_limit, default=10)
498
+
499
+ text_post = commands.add_parser("submit-text", help="submit a text post (gated)")
500
+ text_post.add_argument("--subreddit", "-r", required=True)
501
+ text_post.add_argument("--title", required=True)
502
+ text_source = text_post.add_mutually_exclusive_group(required=True)
503
+ text_source.add_argument("--text")
504
+ text_source.add_argument("--text-file", dest="text_file")
505
+
506
+ link_post = commands.add_parser("submit-link", help="submit a link post (gated)")
507
+ link_post.add_argument("--subreddit", "-r", required=True)
508
+ link_post.add_argument("--title", required=True)
509
+ link_post.add_argument("--url", required=True)
510
+ return parser
511
+
512
+
513
+ def main(argv: list[str] | None = None) -> None:
514
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
515
+ parsed_argv, confirmed = split_confirmation(raw_argv)
516
+ args = build_parser().parse_args(parsed_argv)
517
+
518
+ if args.command in GATED_COMMANDS and not confirmed:
519
+ dry_run(args)
520
+ return
521
+
522
+ client = RedditClient.from_environment()
523
+ if args.command == "whoami":
524
+ output(format_profile(client.me(), client.mode))
525
+ return
526
+ if args.command == "submissions":
527
+ items = client.submissions(args.limit)
528
+ output({"auth_mode": client.mode, "count": len(items), "submissions": [format_submission(item) for item in items]})
529
+ return
530
+
531
+ subreddit = normalize_subreddit(args.subreddit)
532
+ title = validate_title(args.title)
533
+ if args.command == "submit-text":
534
+ output(client.submit(subreddit=subreddit, title=title, kind="self", text=read_text(args)))
535
+ elif args.command == "submit-link":
536
+ output(client.submit(subreddit=subreddit, title=title, kind="link", url=validate_link(args.url)))
537
+
538
+
539
+ if __name__ == "__main__":
540
+ main()
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ import time
10
+ import unittest
11
+ import urllib.error
12
+ import urllib.parse
13
+ from email.message import Message
14
+ from contextlib import redirect_stdout
15
+ from unittest.mock import patch
16
+
17
+
18
+ SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "reddit.py"
19
+ SPEC = importlib.util.spec_from_file_location("reddit_skill_script", SCRIPT)
20
+ reddit = importlib.util.module_from_spec(SPEC)
21
+ assert SPEC.loader is not None
22
+ sys.modules[SPEC.name] = reddit
23
+ SPEC.loader.exec_module(reddit)
24
+
25
+
26
+ class FakeResponse:
27
+ def __init__(self, payload: dict, status: int = 200):
28
+ self.status = status
29
+ self.payload = json.dumps(payload).encode()
30
+ self.headers = {}
31
+
32
+ def __enter__(self):
33
+ return self
34
+
35
+ def __exit__(self, *_args):
36
+ return False
37
+
38
+ def read(self):
39
+ return self.payload
40
+
41
+
42
+ class BrokenResponse(FakeResponse):
43
+ def read(self):
44
+ raise OSError("truncated response containing session-secret")
45
+
46
+
47
+ class BrokenErrorBody(io.BytesIO):
48
+ def read(self, *_args, **_kwargs):
49
+ raise OSError("truncated HTTP error containing session-secret")
50
+
51
+
52
+ COOKIE_JAR = json.dumps(
53
+ [
54
+ {"name": "reddit_session", "value": "session-secret", "domain": ".reddit.com", "path": "/"},
55
+ {"name": "loid", "value": "loid-value", "domain": ".reddit.com", "path": "/"},
56
+ ]
57
+ )
58
+
59
+
60
+ class RedditSkillTests(unittest.TestCase):
61
+ def test_cookie_header_is_restricted_to_reddit(self):
62
+ jar = reddit.parse_cookie_jar(COOKIE_JAR)
63
+ self.assertIn("reddit_session=session-secret", reddit.cookie_header(jar, "https://www.reddit.com/api/me.json"))
64
+ self.assertEqual("", reddit.cookie_header(jar, "https://example.com/"))
65
+
66
+ def test_cookie_jar_rejects_missing_wrong_domain_or_empty_session(self):
67
+ invalid_jars = [
68
+ [{"name": "reddit_session", "value": "secret", "path": "/"}],
69
+ [{"name": "reddit_session", "value": "secret", "domain": ".example.com", "path": "/"}],
70
+ [{"name": "reddit_session", "value": "", "domain": ".reddit.com", "path": "/"}],
71
+ [{"name": "reddit_session", "value": "secret; injected=yes", "domain": ".reddit.com", "path": "/"}],
72
+ [{"name": "reddit_session", "value": {"secret": True}, "domain": ".reddit.com", "path": "/"}],
73
+ [
74
+ {
75
+ "name": "reddit_session",
76
+ "value": "secret",
77
+ "domain": ".reddit.com",
78
+ "path": "/",
79
+ "expirationDate": time.time() - 1,
80
+ }
81
+ ],
82
+ ]
83
+ for jar in invalid_jars:
84
+ with self.subTest(jar=jar), self.assertRaises(SystemExit), redirect_stdout(io.StringIO()):
85
+ reddit.parse_cookie_jar(json.dumps(jar))
86
+
87
+ def test_cookie_header_respects_host_only_domain_path_secure_and_expiry(self):
88
+ jar = reddit.parse_cookie_jar(
89
+ json.dumps(
90
+ [
91
+ {"name": "reddit_session", "value": "root", "domain": ".reddit.com", "path": "/", "secure": True},
92
+ {"name": "host_only", "value": "yes", "domain": "www.reddit.com", "path": "/"},
93
+ {
94
+ "name": "domain_cookie",
95
+ "value": "yes",
96
+ "domain": "reddit.com",
97
+ "hostOnly": False,
98
+ "path": "/",
99
+ },
100
+ {"name": "login_only", "value": "no", "domain": ".reddit.com", "path": "/login"},
101
+ {
102
+ "name": "expired",
103
+ "value": "no",
104
+ "domain": ".reddit.com",
105
+ "path": "/",
106
+ "expirationDate": time.time() - 1,
107
+ },
108
+ ]
109
+ )
110
+ )
111
+ api_header = reddit.cookie_header(jar, "https://www.reddit.com/api/me.json")
112
+ self.assertIn("reddit_session=root", api_header)
113
+ self.assertIn("host_only=yes", api_header)
114
+ self.assertIn("domain_cookie=yes", api_header)
115
+ self.assertNotIn("login_only", api_header)
116
+ self.assertNotIn("expired", api_header)
117
+ self.assertNotIn("host_only", reddit.cookie_header(jar, "https://old.reddit.com/api/me.json"))
118
+ self.assertIn("domain_cookie=yes", reddit.cookie_header(jar, "https://old.reddit.com/api/me.json"))
119
+ self.assertNotIn("reddit_session", reddit.cookie_header(jar, "http://www.reddit.com/api/me.json"))
120
+
121
+ def test_cookie_mode_fetches_modhash_and_submits_text(self):
122
+ responses = [
123
+ FakeResponse({"data": {"id": "abc", "name": "tester", "modhash": "csrf-secret"}}),
124
+ FakeResponse({"json": {"errors": [], "data": {"id": "post1", "name": "t3_post1", "url": "https://www.reddit.com/r/test/comments/post1/title/"}}}),
125
+ ]
126
+ with patch.dict(os.environ, {"REDDIT_COOKIES": COOKIE_JAR}, clear=True), patch.object(
127
+ reddit, "open_request", side_effect=responses
128
+ ) as urlopen:
129
+ client = reddit.RedditClient.from_environment()
130
+ result = client.submit(subreddit="test", title="Title", kind="self", text="Body")
131
+
132
+ self.assertEqual("cookie", client.mode)
133
+ self.assertEqual("https://www.reddit.com/r/test/comments/post1/title/", result["url"])
134
+ me_request = urlopen.call_args_list[0].args[0]
135
+ submit_request = urlopen.call_args_list[1].args[0]
136
+ self.assertEqual("https://www.reddit.com/api/me.json?raw_json=1", me_request.full_url)
137
+ self.assertIn("reddit_session=session-secret", me_request.get_header("Cookie"))
138
+ form = urllib.parse.parse_qs(submit_request.data.decode())
139
+ self.assertEqual(["csrf-secret"], form["uh"])
140
+ self.assertEqual(["self"], form["kind"])
141
+ self.assertEqual(["Body"], form["text"])
142
+
143
+ def test_oauth_mode_uses_bearer_token(self):
144
+ with patch.dict(os.environ, {"REDDIT_TOKEN": "oauth-secret"}, clear=True), patch(
145
+ "reddit_skill_script.open_request",
146
+ return_value=FakeResponse({"id": "abc", "name": "tester", "total_karma": 12}),
147
+ ) as urlopen:
148
+ client = reddit.RedditClient.from_environment()
149
+ profile = client.me()
150
+
151
+ self.assertEqual("oauth", client.mode)
152
+ self.assertEqual("tester", profile["name"])
153
+ request = urlopen.call_args.args[0]
154
+ self.assertEqual("https://oauth.reddit.com/api/v1/me", request.full_url)
155
+ self.assertEqual("Bearer oauth-secret", request.get_header("Authorization"))
156
+
157
+ def test_dry_run_never_loads_credentials_or_calls_network(self):
158
+ stream = io.StringIO()
159
+ with patch.dict(os.environ, {}, clear=True), patch.object(reddit, "open_request") as urlopen, redirect_stdout(stream):
160
+ reddit.main(["submit-text", "--subreddit", "r/test", "--title", "Title", "--text", "Body"])
161
+
162
+ value = json.loads(stream.getvalue())
163
+ self.assertTrue(value["dry_run"])
164
+ self.assertEqual(4, value["text_length"])
165
+ urlopen.assert_not_called()
166
+
167
+ def test_confirm_is_only_recognized_as_final_argument(self):
168
+ args, confirmed = reddit.split_confirmation(["submit-text", "--text", "--confirm", "--title", "Title"])
169
+ self.assertFalse(confirmed)
170
+ self.assertIn("--confirm", args)
171
+ args, confirmed = reddit.split_confirmation(["submit-text", "--title", "Title", "--confirm"])
172
+ self.assertTrue(confirmed)
173
+ self.assertNotIn("--confirm", args)
174
+
175
+ def test_rejects_cookie_jar_without_reddit_session(self):
176
+ with self.assertRaises(SystemExit), redirect_stdout(io.StringIO()):
177
+ reddit.parse_cookie_jar(json.dumps([{"name": "loid", "value": "x", "domain": ".reddit.com"}]))
178
+
179
+ def test_redirect_is_rejected_without_following(self):
180
+ headers = Message()
181
+ headers["Location"] = "https://example.com/steal"
182
+ redirect = urllib.error.HTTPError(
183
+ "https://www.reddit.com/api/me.json",
184
+ 302,
185
+ "Found",
186
+ headers,
187
+ io.BytesIO(b"redirect"),
188
+ )
189
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
190
+ with patch.object(reddit, "open_request", side_effect=redirect) as open_request, self.assertRaises(
191
+ SystemExit
192
+ ), redirect_stdout(io.StringIO()):
193
+ client.me()
194
+
195
+ open_request.assert_called_once()
196
+
197
+ def test_post_redirect_reports_unknown_outcome_without_retry_advice(self):
198
+ headers = Message()
199
+ headers["Location"] = "https://www.reddit.com/r/test/comments/post1/title/"
200
+ redirect = urllib.error.HTTPError(
201
+ "https://www.reddit.com/api/submit",
202
+ 303,
203
+ "See Other",
204
+ headers,
205
+ io.BytesIO(b"redirect"),
206
+ )
207
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
208
+ client.modhash = "csrf-secret"
209
+ stream = io.StringIO()
210
+ with patch.object(reddit, "open_request", side_effect=redirect) as open_request, self.assertRaises(
211
+ SystemExit
212
+ ), redirect_stdout(stream):
213
+ client.submit(subreddit="test", title="Title", kind="self", text="Body")
214
+
215
+ rendered = stream.getvalue()
216
+ self.assertIn("outcome is unknown", rendered)
217
+ self.assertIn("Check recent submissions", rendered)
218
+ self.assertIn("do not replay", rendered)
219
+ self.assertNotIn("retry once", rendered)
220
+ open_request.assert_called_once()
221
+
222
+ def test_post_body_failures_report_unknown_outcome_without_secret_details(self):
223
+ cases = [
224
+ FakeResponse({}, status=200),
225
+ FakeResponse({}, status=200),
226
+ BrokenResponse({}),
227
+ ]
228
+ cases[0].payload = b"<html>session-secret</html>"
229
+ cases[1].payload = b"not-json session-secret"
230
+ cases.append(FakeResponse({}, status=200))
231
+ cases[-1].payload = b"not-gzip session-secret"
232
+ cases[-1].headers = {"Content-Encoding": "gzip"}
233
+
234
+ for response in cases:
235
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
236
+ stream = io.StringIO()
237
+ with self.subTest(response=type(response).__name__), patch.object(
238
+ reddit, "open_request", return_value=response
239
+ ) as open_request, self.assertRaises(SystemExit), redirect_stdout(stream):
240
+ client.request("POST", "/api/submit", form={"api_type": "json"})
241
+ rendered = stream.getvalue()
242
+ self.assertIn("outcome is unknown", rendered)
243
+ self.assertIn("do not replay", rendered)
244
+ self.assertNotIn("session-secret", rendered)
245
+ open_request.assert_called_once()
246
+
247
+ def test_post_http_error_body_read_failure_reports_unknown_outcome(self):
248
+ error = urllib.error.HTTPError(
249
+ "https://www.reddit.com/api/submit",
250
+ 500,
251
+ "Internal Server Error",
252
+ Message(),
253
+ BrokenErrorBody(b"unreadable"),
254
+ )
255
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
256
+ stream = io.StringIO()
257
+ with patch.object(reddit, "open_request", side_effect=error) as open_request, self.assertRaises(
258
+ SystemExit
259
+ ), redirect_stdout(stream):
260
+ client.request("POST", "/api/submit", form={"api_type": "json"})
261
+
262
+ rendered = stream.getvalue()
263
+ self.assertIn("outcome is unknown", rendered)
264
+ self.assertIn("Check recent submissions", rendered)
265
+ self.assertIn("do not replay", rendered)
266
+ self.assertNotIn("session-secret", rendered)
267
+ open_request.assert_called_once()
268
+
269
+ def test_authenticated_error_does_not_echo_reflected_secrets(self):
270
+ reflected = "session-secret csrf-secret oauth-secret"
271
+ error = urllib.error.HTTPError(
272
+ "https://www.reddit.com/api/submit",
273
+ 500,
274
+ "Internal Server Error",
275
+ Message(),
276
+ io.BytesIO(reflected.encode()),
277
+ )
278
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
279
+ client.modhash = "csrf-secret"
280
+ stream = io.StringIO()
281
+ with patch.object(reddit, "open_request", side_effect=error), self.assertRaises(SystemExit), redirect_stdout(
282
+ stream
283
+ ):
284
+ client.submit(subreddit="test", title="Title", kind="self", text="Body")
285
+
286
+ rendered = stream.getvalue()
287
+ self.assertIn("HTTP 500", rendered)
288
+ self.assertNotIn("session-secret", rendered)
289
+ self.assertNotIn("csrf-secret", rendered)
290
+ self.assertNotIn("oauth-secret", rendered)
291
+
292
+ def test_application_error_does_not_echo_reflected_secrets(self):
293
+ reflected = "session-secret csrf-secret oauth-secret"
294
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
295
+ client.modhash = "csrf-secret"
296
+ stream = io.StringIO()
297
+ response = FakeResponse({"json": {"errors": [["BAD_REQUEST", reflected, reflected]], "data": {}}})
298
+ with patch.object(reddit, "open_request", return_value=response), self.assertRaises(
299
+ SystemExit
300
+ ), redirect_stdout(stream):
301
+ client.submit(subreddit="test", title="Title", kind="self", text="Body")
302
+
303
+ rendered = stream.getvalue()
304
+ self.assertIn("Reddit rejected the post", rendered)
305
+ self.assertNotIn("session-secret", rendered)
306
+ self.assertNotIn("csrf-secret", rendered)
307
+ self.assertNotIn("oauth-secret", rendered)
308
+
309
+ def test_submissions_rejects_malformed_json_shape(self):
310
+ client = reddit.RedditClient("oauth", token="oauth-secret")
311
+ client.username = "tester"
312
+ for payload in (
313
+ {},
314
+ [],
315
+ {"data": {}},
316
+ {"data": {"children": [{}]}},
317
+ {"data": {"children": [{"data": {}}]}},
318
+ ):
319
+ stream = io.StringIO()
320
+ with self.subTest(payload=payload), patch.object(
321
+ client, "request", return_value=payload
322
+ ), self.assertRaises(SystemExit), redirect_stdout(stream):
323
+ client.submissions(10)
324
+ self.assertIn("malformed submissions response", stream.getvalue())
325
+ self.assertNotIn("oauth-secret", stream.getvalue())
326
+
327
+ def test_submissions_accepts_documented_shape(self):
328
+ client = reddit.RedditClient("oauth", token="oauth-secret")
329
+ client.username = "tester"
330
+ payload = {
331
+ "data": {
332
+ "children": [
333
+ {
334
+ "data": {
335
+ "id": "post1",
336
+ "title": "Title",
337
+ "subreddit": "test",
338
+ "permalink": "/r/test/comments/post1/title/",
339
+ }
340
+ }
341
+ ]
342
+ }
343
+ }
344
+ with patch.object(client, "request", return_value=payload):
345
+ self.assertEqual("post1", client.submissions(10)[0]["id"])
346
+
347
+ def test_identity_and_post_reject_malformed_json_shapes(self):
348
+ identity_client = reddit.RedditClient("oauth", token="oauth-secret")
349
+ identity_stream = io.StringIO()
350
+ with patch.object(identity_client, "request", return_value=[]), self.assertRaises(
351
+ SystemExit
352
+ ), redirect_stdout(identity_stream):
353
+ identity_client.me()
354
+ self.assertIn("malformed identity response", identity_stream.getvalue())
355
+
356
+ post_client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
357
+ post_client.modhash = "csrf-secret"
358
+ malformed_posts = (
359
+ {},
360
+ {"json": {}},
361
+ {"json": {"errors": None, "data": {"url": "https://www.reddit.com/r/test/comments/x/title/"}}},
362
+ {"json": {"errors": [], "data": {"url": ["not", "a", "string"]}}},
363
+ {"json": {"errors": [], "data": {"url": "https://example.com/not-reddit"}}},
364
+ )
365
+ for payload in malformed_posts:
366
+ post_stream = io.StringIO()
367
+ with self.subTest(payload=payload), patch.object(
368
+ post_client, "request", return_value=payload
369
+ ), self.assertRaises(SystemExit), redirect_stdout(post_stream):
370
+ post_client.submit(subreddit="test", title="Title", kind="self", text="Body")
371
+ self.assertIn("malformed write response", post_stream.getvalue())
372
+ self.assertIn("outcome is unknown", post_stream.getvalue())
373
+ self.assertIn("do not replay", post_stream.getvalue())
374
+ self.assertNotIn("csrf-secret", post_stream.getvalue())
375
+
376
+
377
+ if __name__ == "__main__":
378
+ unittest.main()