@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 +1 -1
- package/skills/reddit/SKILL.md +65 -52
- package/skills/reddit/scripts/reddit.py +540 -0
- package/skills/reddit/tests/test_reddit.py +378 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.713.
|
|
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",
|
package/skills/reddit/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reddit
|
|
3
|
-
description: Submit posts (link or text) to subreddits and read your Reddit identity /
|
|
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: "
|
|
15
|
+
version: "2.0"
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
UA="web:cloud.acedata.connectors:v1.0 (by /u/acedatacloud)"
|
|
24
|
-
```
|
|
20
|
+
The connector injects exactly one of these credentials:
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
##
|
|
55
|
+
## Submit a post — GATED
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
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()
|