Merge pull request #182 from ziperlee/codex/bluesky-refresh-token
fix: refresh expired bluesky sessions
This commit is contained in:
+2
-2
@@ -216,7 +216,7 @@ def show_briefing(date: str = None) -> dict:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"status": "not_found", "message": f"No briefing found for {date}."}
|
return {"status": "not_found", "message": f"No briefing found for {date}."}
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ def _save_briefing(data: dict, suffix: str = ""):
|
|||||||
BRIEFS_DIR.mkdir(parents=True, exist_ok=True)
|
BRIEFS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
date = datetime.now().strftime("%Y-%m-%d")
|
date = datetime.now().strftime("%Y-%m-%d")
|
||||||
path = BRIEFS_DIR / f"{date}{suffix}.json"
|
path = BRIEFS_DIR / f"{date}{suffix}.json"
|
||||||
with open(path, "w") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=2, default=str)
|
json.dump(data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-20
@@ -75,6 +75,12 @@ def _create_session(handle: str, app_password: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_session_cache() -> None:
|
||||||
|
global _cached_token, _session_error
|
||||||
|
_cached_token = None
|
||||||
|
_session_error = None
|
||||||
|
|
||||||
|
|
||||||
def _extract_core_subject(topic: str) -> str:
|
def _extract_core_subject(topic: str) -> str:
|
||||||
"""Extract core subject from verbose query for Bluesky search."""
|
"""Extract core subject from verbose query for Bluesky search."""
|
||||||
from .query import extract_core_subject
|
from .query import extract_core_subject
|
||||||
@@ -129,12 +135,6 @@ def search_bluesky(
|
|||||||
if not handle or not app_password:
|
if not handle or not app_password:
|
||||||
return {"posts": [], "error": "Bluesky credentials not configured"}
|
return {"posts": [], "error": "Bluesky credentials not configured"}
|
||||||
|
|
||||||
# Authenticate
|
|
||||||
token = _create_session(handle, app_password)
|
|
||||||
if not token:
|
|
||||||
error_msg = _session_error or "Bluesky session creation failed (unknown error)"
|
|
||||||
return {"posts": [], "error": error_msg}
|
|
||||||
|
|
||||||
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
core_topic = _extract_core_subject(topic)
|
core_topic = _extract_core_subject(topic)
|
||||||
|
|
||||||
@@ -148,20 +148,38 @@ def search_bluesky(
|
|||||||
}
|
}
|
||||||
url = f"{BSKY_SEARCH_URL}?{urlencode(params)}"
|
url = f"{BSKY_SEARCH_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
try:
|
def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
response = http.request(
|
token = _create_session(handle, app_password)
|
||||||
"GET", url,
|
if not token:
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
error_msg = _session_error or "Bluesky session creation failed (unknown error)"
|
||||||
timeout=30,
|
return None, error_msg
|
||||||
)
|
try:
|
||||||
except http.HTTPError as e:
|
response = http.request(
|
||||||
_log(f"Search failed: {e}")
|
"GET", url,
|
||||||
if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
return {"posts": [], "error": "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."}
|
timeout=30,
|
||||||
return {"posts": [], "error": f"Bluesky search failed: {e}"}
|
)
|
||||||
except Exception as e:
|
return response, None
|
||||||
_log(f"Search failed: {e}")
|
except http.HTTPError as e:
|
||||||
return {"posts": [], "error": f"Bluesky search failed: {type(e).__name__}: {e}"}
|
_log(f"Search failed: {e}")
|
||||||
|
if e.status_code == 401:
|
||||||
|
_reset_session_cache()
|
||||||
|
return None, "refresh"
|
||||||
|
if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
|
||||||
|
return None, "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."
|
||||||
|
return None, f"Bluesky search failed: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"Search failed: {e}")
|
||||||
|
return None, f"Bluesky search failed: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
response, error_msg = _auth_and_search()
|
||||||
|
if error_msg == "refresh":
|
||||||
|
_log("Session expired; recreating token and retrying once")
|
||||||
|
response, error_msg = _auth_and_search()
|
||||||
|
if error_msg:
|
||||||
|
return {"posts": [], "error": error_msg}
|
||||||
|
if response is None:
|
||||||
|
return {"posts": [], "error": "Bluesky search failed (unknown error)"}
|
||||||
|
|
||||||
posts = response.get("posts", [])
|
posts = response.get("posts", [])
|
||||||
_log(f"Found {len(posts)} posts")
|
_log(f"Found {len(posts)} posts")
|
||||||
|
|||||||
@@ -194,6 +194,22 @@ class TestSearchBlueskyAuth(unittest.TestCase):
|
|||||||
search_call = mock_request.call_args_list[1]
|
search_call = mock_request.call_args_list[1]
|
||||||
self.assertEqual(search_call.kwargs.get("headers", {}), {"Authorization": "Bearer tok123"})
|
self.assertEqual(search_call.kwargs.get("headers", {}), {"Authorization": "Bearer tok123"})
|
||||||
|
|
||||||
|
@patch("lib.bluesky.http.request")
|
||||||
|
def test_401_search_refreshes_session_once(self, mock_request):
|
||||||
|
from lib.http import HTTPError
|
||||||
|
|
||||||
|
mock_request.side_effect = [
|
||||||
|
{"accessJwt": "tok-old", "refreshJwt": "ref-old"},
|
||||||
|
HTTPError("HTTP 401: Unauthorized", 401, ""),
|
||||||
|
{"accessJwt": "tok-new", "refreshJwt": "ref-new"},
|
||||||
|
{"posts": [{"uri": "at://did/app.bsky.feed.post/abc", "author": {"handle": "u1"}, "record": {"text": "hi"}}]},
|
||||||
|
]
|
||||||
|
config = {"BSKY_HANDLE": "user.bsky.social", "BSKY_APP_PASSWORD": "pw"}
|
||||||
|
result = bluesky.search_bluesky("test", "2026-01-01", "2026-03-09", config=config)
|
||||||
|
self.assertEqual(len(result["posts"]), 1)
|
||||||
|
self.assertEqual(mock_request.call_count, 4)
|
||||||
|
self.assertEqual(mock_request.call_args_list[3].kwargs.get("headers", {}), {"Authorization": "Bearer tok-new"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
|
|
||||||
@@ -30,6 +31,27 @@ class BriefingV3Tests(unittest.TestCase):
|
|||||||
store._db_override = old_db_override
|
store._db_override = old_db_override
|
||||||
briefing.BRIEFS_DIR = old_briefs_dir
|
briefing.BRIEFS_DIR = old_briefs_dir
|
||||||
|
|
||||||
|
def test_save_briefing_uses_utf8_encoding(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
old_briefs_dir = briefing.BRIEFS_DIR
|
||||||
|
try:
|
||||||
|
briefing.BRIEFS_DIR = Path(tmpdir) / "briefs"
|
||||||
|
payload = {"status": "ok", "message": "emoji 💬 and accents café"}
|
||||||
|
with mock.patch("briefing.open", create=True) as mock_open:
|
||||||
|
handle = mock.Mock()
|
||||||
|
handle.__enter__ = mock.Mock(return_value=handle)
|
||||||
|
handle.__exit__ = mock.Mock(return_value=False)
|
||||||
|
mock_open.return_value = handle
|
||||||
|
|
||||||
|
briefing._save_briefing(payload)
|
||||||
|
|
||||||
|
mock_open.assert_called_once()
|
||||||
|
_, kwargs = mock_open.call_args
|
||||||
|
self.assertEqual("w", kwargs["mode"] if "mode" in kwargs else mock_open.call_args.args[1])
|
||||||
|
self.assertEqual("utf-8", kwargs["encoding"])
|
||||||
|
finally:
|
||||||
|
briefing.BRIEFS_DIR = old_briefs_dir
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user