mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 18:27:45 +02:00
[eric] reddit-mcp: drive www.reddit.com JSON API + cookie + modhash (bearer no longer in shreddit HTML); live-verified
This commit is contained in:
@@ -1,92 +1,76 @@
|
||||
"""Low-level authed Reddit transport.
|
||||
|
||||
Borrow the user's session, harvest the bearer token their own logged-in web
|
||||
client already uses (no API key, no app registration), and call the documented
|
||||
oauth.reddit.com surface. Rate-limited and self-refreshing on token expiry.
|
||||
stdlib-only to match the sibling shims and start fast.
|
||||
Talks to Reddit as the user's own logged-in browser: borrow the session cookies and
|
||||
call the classic www.reddit.com JSON API (reads take a .json suffix, writes carry the
|
||||
account's modhash), no OAuth app and no API key. Modern Reddit ("shreddit") stopped
|
||||
embedding a bearer token in its page HTML, so the cookie + modhash path is the durable
|
||||
one. Rate-limited and self-healing on a 401/403 by re-borrowing the session. stdlib-only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from backend.apps.reddit_mcp_shim import rate_limit
|
||||
from backend.apps.social_shims.session_source import get_session, invalidate
|
||||
|
||||
DOMAIN = "reddit.com"
|
||||
WWW = "https://www.reddit.com"
|
||||
OAUTH = "https://oauth.reddit.com"
|
||||
TOKEN_RE = re.compile(r'"accessToken":\s*"([^"]+)"')
|
||||
EXPIRES_RE = re.compile(r'"(?:expiresIn|expires)":\s*"?(\d+)')
|
||||
MODHASH_TTL_S = 300.0
|
||||
|
||||
p_token = ""
|
||||
p_token_exp = 0.0
|
||||
p_modhash = ""
|
||||
p_modhash_exp = 0.0
|
||||
|
||||
|
||||
class RedditError(Exception):
|
||||
"""A Reddit request failed in a way worth surfacing to the agent."""
|
||||
|
||||
|
||||
def bearer(force: bool = False) -> str:
|
||||
"""Return a valid bearer token, harvesting a fresh one from authed HTML when stale."""
|
||||
global p_token, p_token_exp
|
||||
def modhash(force: bool = False) -> str:
|
||||
"""Return the logged-in account's modhash (Reddit's per-session write token), cached briefly."""
|
||||
global p_modhash, p_modhash_exp
|
||||
now = time.time()
|
||||
if not force and p_token and now < p_token_exp - 60:
|
||||
return p_token
|
||||
|
||||
cookie, ua = get_session(DOMAIN)
|
||||
req = urllib.request.Request(
|
||||
f"{WWW}/",
|
||||
headers={"Cookie": cookie, "User-Agent": ua, "Accept": "text/html"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20.0) as resp:
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.URLError as e:
|
||||
raise RedditError(f"Reddit unreachable: {getattr(e, 'reason', e)}")
|
||||
|
||||
m = TOKEN_RE.search(text)
|
||||
if not m:
|
||||
if not force and p_modhash and now < p_modhash_exp:
|
||||
return p_modhash
|
||||
me = p_send("GET", "/api/me.json", params=None, form=None, action="read", retried=False)
|
||||
mh = (me or {}).get("data", {}).get("modhash") or ""
|
||||
if not mh:
|
||||
invalidate(DOMAIN)
|
||||
raise RedditError(
|
||||
"Could not read a Reddit session token. Open reddit.com in the OpenSwarm browser, sign in, then retry."
|
||||
"Not logged in to Reddit. Open reddit.com in the OpenSwarm browser, sign in, then retry."
|
||||
)
|
||||
p_token = m.group(1)
|
||||
exp = EXPIRES_RE.search(text)
|
||||
ttl = float(exp.group(1)) if exp else 3600.0
|
||||
# The web client reports expiry in ms; fold that down and clamp to a sane window.
|
||||
if ttl > 86400:
|
||||
ttl = ttl / 1000.0
|
||||
p_token_exp = now + min(max(ttl, 300.0), 86400.0)
|
||||
return p_token
|
||||
p_modhash, p_modhash_exp = mh, now + MODHASH_TTL_S
|
||||
return mh
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
form: dict | None = None,
|
||||
action: str = "read",
|
||||
) -> dict:
|
||||
"""Authenticated oauth.reddit.com call with rate-limiting + one auto token refresh."""
|
||||
return p_api(method, path, params=params, form=form, action=action, retried=False)
|
||||
def api(method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
||||
form: Optional[Dict[str, Any]] = None, action: str = "read") -> Any:
|
||||
"""Authenticated www.reddit.com call: reads get a .json suffix, writes carry the modhash."""
|
||||
return p_send(method, path, params=params, form=form, action=action, retried=False)
|
||||
|
||||
|
||||
def p_api(method, path, *, params, form, action, retried) -> dict:
|
||||
def p_send(method: str, path: str, *, params: Optional[Dict[str, Any]],
|
||||
form: Optional[Dict[str, Any]], action: str, retried: bool) -> Any:
|
||||
rate_limit.acquire(action)
|
||||
_, ua = get_session(DOMAIN)
|
||||
token = bearer()
|
||||
qs = dict(params or {})
|
||||
qs.setdefault("raw_json", 1)
|
||||
url = f"{OAUTH}{path}?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v is not None})
|
||||
data = urllib.parse.urlencode({k: v for k, v in form.items() if v is not None}).encode() if form is not None else None
|
||||
headers = {"Authorization": f"Bearer {token}", "User-Agent": ua, "Accept": "application/json"}
|
||||
if data is not None:
|
||||
cookie, ua = get_session(DOMAIN)
|
||||
headers = {"Cookie": cookie, "User-Agent": ua, "Accept": "application/json"}
|
||||
data = None
|
||||
if method == "GET":
|
||||
p = path if path.endswith(".json") else path + ".json"
|
||||
qs = dict(params or {})
|
||||
qs.setdefault("raw_json", 1)
|
||||
url = f"{WWW}{p}?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v is not None})
|
||||
else:
|
||||
url = f"{WWW}{path}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||||
body = dict(form or {})
|
||||
body.setdefault("api_type", "json")
|
||||
body["uh"] = modhash()
|
||||
data = urllib.parse.urlencode({k: v for k, v in body.items() if v is not None}).encode()
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
@@ -99,10 +83,10 @@ def p_api(method, path, *, params, form, action, retried) -> dict:
|
||||
raise RedditError(f"Reddit unreachable: {getattr(e, 'reason', e)}")
|
||||
|
||||
rate_limit.note_response(status, {k.lower(): v for k, v in rhdr.items()})
|
||||
|
||||
if status == 401 and not retried:
|
||||
bearer(force=True)
|
||||
return p_api(method, path, params=params, form=form, action=action, retried=True)
|
||||
if status in (401, 403) and not retried:
|
||||
invalidate(DOMAIN)
|
||||
p_reset_modhash()
|
||||
return p_send(method, path, params=params, form=form, action=action, retried=True)
|
||||
if status == 429:
|
||||
raise RedditError("Reddit is rate-limiting this account; slow down and retry shortly.")
|
||||
if status >= 400:
|
||||
@@ -111,3 +95,8 @@ def p_api(method, path, *, params, form, action, retried) -> dict:
|
||||
return json.loads(raw.decode("utf-8", errors="replace") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
def p_reset_modhash() -> None:
|
||||
global p_modhash_exp
|
||||
p_modhash_exp = 0.0
|
||||
|
||||
@@ -56,7 +56,7 @@ def p_listing(resp: dict) -> dict:
|
||||
|
||||
|
||||
def whoami() -> dict:
|
||||
me = api("GET", "/api/v1/me")
|
||||
me = api("GET", "/api/me.json").get("data", {})
|
||||
return {
|
||||
"name": me.get("name"),
|
||||
"id": me.get("id"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Unit coverage for the Reddit MCP shim's pure logic: the session-token harvest
|
||||
regex, the rate limiter's 429 backoff, and tool dispatch + response normalizers
|
||||
with the network mocked. Writes can't be live-verified without a logged-in
|
||||
session, so the oauth.reddit.com contract is pinned here against canned Reddit
|
||||
payloads (the json.errors envelope, the comment/submit shapes) instead."""
|
||||
"""Unit coverage for the Reddit MCP shim's pure logic: the modhash write-token harvest,
|
||||
the rate limiter's 429 backoff, and tool dispatch + response normalizers with the
|
||||
network mocked. Reddit is driven via www.reddit.com's JSON API + the session cookie
|
||||
(reads get .json, writes carry the modhash); the contract is pinned here against canned
|
||||
Reddit payloads (the me.json envelope, the json.errors envelope, comment/submit shapes)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
@@ -11,7 +11,6 @@ from unittest.mock import patch
|
||||
|
||||
from backend.apps.reddit_mcp_shim import rate_limit, reddit_reads, reddit_writes
|
||||
from backend.apps.reddit_mcp_shim.handlers import handle_tool_call
|
||||
from backend.apps.reddit_mcp_shim.reddit_http import EXPIRES_RE, TOKEN_RE
|
||||
from backend.apps.social_shims.session_source import SessionUnavailable
|
||||
|
||||
|
||||
@@ -19,18 +18,14 @@ def p_text(result: dict) -> str:
|
||||
return result["content"][0]["text"]
|
||||
|
||||
|
||||
# -- session-token harvest -------------------------------------------------
|
||||
# -- whoami reads the me.json envelope (kind/data), not the old flat oauth shape ----
|
||||
|
||||
def test_token_regex_harvests_bearer_from_html():
|
||||
html = '<script>window.___r={"session":{"accessToken":"eyJabc.def","expiresIn":"86400000"}}</script>'
|
||||
m = TOKEN_RE.search(html)
|
||||
assert m and m.group(1) == "eyJabc.def"
|
||||
e = EXPIRES_RE.search(html)
|
||||
assert e and e.group(1) == "86400000"
|
||||
|
||||
|
||||
def test_token_regex_absent_when_logged_out():
|
||||
assert TOKEN_RE.search("<html>login wall, no token here</html>") is None
|
||||
def test_whoami_reads_me_json_envelope():
|
||||
with patch.object(reddit_reads, "api", return_value={"kind": "t2", "data": {"name": "someuser", "total_karma": 5}}):
|
||||
out = handle_tool_call("reddit_whoami", {})
|
||||
data = json.loads(p_text(out))
|
||||
assert "isError" not in out
|
||||
assert data["name"] == "someuser" and data["total_karma"] == 5
|
||||
|
||||
|
||||
# -- rate limiter ----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user