An Akkoma/Mastodon compatible API bridge that translates Mastodon/Akkoma client API requests into ATProto XRPC calls.
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix DI migration being incomplete

fizzAI 614bbb02 e06cf9ac

+213 -190
+4 -5
app/auth.py
··· 5 5 from litestar import Request 6 6 from litestar.exceptions import NotAuthorizedException 7 7 8 - from .atproto import Session 9 - from .state import sessions 8 + from .atproto import Session, SessionStore 10 9 11 10 12 - def require_auth(request: Request) -> Session: 11 + def require_auth(request: Request, sessions: SessionStore) -> Session: 13 12 """Extract and validate the Bearer token, returning the active *Session*. 14 13 15 14 Raises :class:`NotAuthorizedException` when the token is missing or unknown. ··· 24 23 return session 25 24 26 25 27 - def optional_auth(request: Request) -> Session | None: 26 + def optional_auth(request: Request, sessions: SessionStore) -> Session | None: 28 27 """Like *require_auth* but returns ``None`` instead of raising.""" 29 28 try: 30 - return require_auth(request) 29 + return require_auth(request, sessions) 31 30 except NotAuthorizedException: 32 31 return None
+49 -25
app/routes/accounts.py
··· 7 7 from litestar import Request, Router, get, post 8 8 from litestar.enums import MediaType 9 9 10 + from app.atproto import ATProtoClient, SessionStore, parse_at_uri 10 11 from app.auth import optional_auth, require_auth 11 12 from app.convert import convert_account, convert_relationship, convert_status 12 - from app.state import client 13 13 from app.utils import now_iso 14 14 15 15 ··· 17 17 18 18 19 19 @get("/api/v1/accounts/verify_credentials", media_type=MediaType.JSON) 20 - async def verify_credentials(request: Request) -> dict[str, Any]: 21 - session = require_auth(request) 20 + async def verify_credentials( 21 + request: Request, sessions: SessionStore, client: ATProtoClient, 22 + ) -> dict[str, Any]: 23 + session = require_auth(request, sessions) 22 24 profile = await client.get(session, "app.bsky.actor.getProfile", actor=session.did) 23 25 acct = convert_account(profile, is_self=True) 24 26 # Mastodon clients expect a "source" key on verify_credentials ··· 37 39 38 40 39 41 @get("/api/v1/accounts/lookup", media_type=MediaType.JSON) 40 - async def lookup_account(request: Request, acct: str = "") -> dict[str, Any]: 42 + async def lookup_account( 43 + request: Request, sessions: SessionStore, client: ATProtoClient, acct: str = "", 44 + ) -> dict[str, Any]: 41 45 """Look up an account by ``acct`` (handle).""" 42 - session = optional_auth(request) 46 + session = optional_auth(request, sessions) 43 47 handle = acct.split("@")[0] if "@" in acct else acct 44 48 if not handle: 45 49 handle = acct ··· 56 60 @get("/api/v1/accounts/search", media_type=MediaType.JSON) 57 61 async def search_accounts( 58 62 request: Request, 63 + sessions: SessionStore, 64 + client: ATProtoClient, 59 65 q: str = "", 60 66 limit: int = 40, 61 67 resolve: bool = False, 62 68 following: bool = False, 63 69 ) -> list[dict[str, Any]]: 64 70 """Search for accounts. Akkoma does not require auth for this endpoint.""" 65 - session = optional_auth(request) 71 + session = optional_auth(request, sessions) 66 72 if not q: 67 73 return [] 68 74 lim = min(limit, 80) ··· 91 97 92 98 93 99 @get("/api/v1/accounts/relationships", media_type=MediaType.JSON) 94 - async def relationships(request: Request) -> list[dict[str, Any]]: 100 + async def relationships( 101 + request: Request, sessions: SessionStore, client: ATProtoClient, 102 + ) -> list[dict[str, Any]]: 95 103 """Return relationships for the given account IDs (DIDs).""" 96 - session = require_auth(request) 104 + session = require_auth(request, sessions) 97 105 # Mastodon sends id[]=... or id=... (repeated) 98 106 ids = request.query_params.getall("id[]") or request.query_params.getall("id") or [] 99 107 if not ids: ··· 118 126 119 127 120 128 @get("/api/v1/accounts/{account_id:str}", media_type=MediaType.JSON) 121 - async def get_account(request: Request, account_id: str) -> dict[str, Any]: 122 - session = optional_auth(request) 129 + async def get_account( 130 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 131 + ) -> dict[str, Any]: 132 + session = optional_auth(request, sessions) 123 133 if session: 124 134 profile = await client.get(session, "app.bsky.actor.getProfile", actor=account_id) 125 135 else: ··· 135 145 async def account_statuses( 136 146 request: Request, 137 147 account_id: str, 148 + sessions: SessionStore, 149 + client: ATProtoClient, 138 150 max_id: str | None = None, 139 151 since_id: str | None = None, 140 152 limit: int = 20, ··· 143 155 only_media: bool = False, 144 156 pinned: bool = False, 145 157 ) -> list[dict[str, Any]]: 146 - session = optional_auth(request) 158 + session = optional_auth(request, sessions) 147 159 148 160 # Handle pinned posts request 149 161 if pinned: ··· 220 232 async def account_followers( 221 233 request: Request, 222 234 account_id: str, 235 + sessions: SessionStore, 236 + client: ATProtoClient, 223 237 limit: int = 40, 224 238 ) -> list[dict[str, Any]]: 225 - session = optional_auth(request) 239 + session = optional_auth(request, sessions) 226 240 params: dict[str, Any] = {"actor": account_id, "limit": min(limit, 100)} 227 241 if session: 228 242 data = await client.get(session, "app.bsky.graph.getFollowers", **params) ··· 238 252 async def account_following( 239 253 request: Request, 240 254 account_id: str, 255 + sessions: SessionStore, 256 + client: ATProtoClient, 241 257 limit: int = 40, 242 258 ) -> list[dict[str, Any]]: 243 - session = optional_auth(request) 259 + session = optional_auth(request, sessions) 244 260 params: dict[str, Any] = {"actor": account_id, "limit": min(limit, 100)} 245 261 if session: 246 262 data = await client.get(session, "app.bsky.graph.getFollows", **params) ··· 253 269 254 270 255 271 @post("/api/v1/accounts/{account_id:str}/follow", media_type=MediaType.JSON) 256 - async def follow_account(request: Request, account_id: str) -> dict[str, Any]: 257 - session = require_auth(request) 272 + async def follow_account( 273 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 274 + ) -> dict[str, Any]: 275 + session = require_auth(request, sessions) 258 276 # Resolve DID if necessary (account_id might be a handle) 259 277 did = account_id 260 278 if not did.startswith("did:"): ··· 279 297 280 298 281 299 @post("/api/v1/accounts/{account_id:str}/unfollow", media_type=MediaType.JSON) 282 - async def unfollow_account(request: Request, account_id: str) -> dict[str, Any]: 283 - session = require_auth(request) 300 + async def unfollow_account( 301 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 302 + ) -> dict[str, Any]: 303 + session = require_auth(request, sessions) 284 304 did = account_id 285 305 if not did.startswith("did:"): 286 306 did = await client.resolve_handle(did) ··· 288 308 profile = await client.get(session, "app.bsky.actor.getProfile", actor=did) 289 309 follow_uri = (profile.get("viewer") or {}).get("following") 290 310 if follow_uri: 291 - from app.atproto import parse_at_uri 292 - 293 311 repo, collection, rkey = parse_at_uri(follow_uri) 294 312 await client.post( 295 313 session, ··· 303 321 304 322 305 323 @post("/api/v1/accounts/{account_id:str}/mute", media_type=MediaType.JSON) 306 - async def mute_account(request: Request, account_id: str) -> dict[str, Any]: 307 - session = require_auth(request) 324 + async def mute_account( 325 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 326 + ) -> dict[str, Any]: 327 + session = require_auth(request, sessions) 308 328 did = account_id if account_id.startswith("did:") else await client.resolve_handle(account_id) 309 329 await client.post(session, "app.bsky.graph.muteActor", {"actor": did}) 310 330 return convert_relationship(did, {"muted": True}) ··· 314 334 315 335 316 336 @post("/api/v1/accounts/{account_id:str}/unmute", media_type=MediaType.JSON) 317 - async def unmute_account(request: Request, account_id: str) -> dict[str, Any]: 318 - session = require_auth(request) 337 + async def unmute_account( 338 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 339 + ) -> dict[str, Any]: 340 + session = require_auth(request, sessions) 319 341 did = account_id if account_id.startswith("did:") else await client.resolve_handle(account_id) 320 342 await client.post(session, "app.bsky.graph.unmuteActor", {"actor": did}) 321 343 return convert_relationship(did, {"muted": False}) ··· 325 347 326 348 327 349 @post("/api/v1/accounts/{account_id:str}/block", media_type=MediaType.JSON) 328 - async def block_account(request: Request, account_id: str) -> dict[str, Any]: 329 - session = require_auth(request) 350 + async def block_account( 351 + request: Request, account_id: str, sessions: SessionStore, client: ATProtoClient, 352 + ) -> dict[str, Any]: 353 + session = require_auth(request, sessions) 330 354 did = account_id if account_id.startswith("did:") else await client.resolve_handle(account_id) 331 355 await client.post( 332 356 session,
+17 -9
app/routes/media.py
··· 8 8 from litestar import Request, Response, Router, get, post 9 9 from litestar.enums import MediaType 10 10 11 + from app.atproto import ATProtoClient, SessionStore 11 12 from app.auth import require_auth 12 - from app.state import client 13 13 14 14 15 15 # ── POST /api/v1/media or /api/v2/media ─────────────────────────────────── 16 16 17 17 18 18 @post("/api/v2/media", media_type=MediaType.JSON) 19 - async def upload_media_v2(request: Request) -> Response: 20 - return await _handle_upload(request) 19 + async def upload_media_v2( 20 + request: Request, sessions: SessionStore, client: ATProtoClient, 21 + ) -> Response: 22 + return await _handle_upload(request, sessions, client) 21 23 22 24 23 25 @post("/api/v1/media", media_type=MediaType.JSON) 24 - async def upload_media_v1(request: Request) -> Response: 25 - return await _handle_upload(request) 26 + async def upload_media_v1( 27 + request: Request, sessions: SessionStore, client: ATProtoClient, 28 + ) -> Response: 29 + return await _handle_upload(request, sessions, client) 26 30 27 31 28 - async def _handle_upload(request: Request) -> Response: 29 - session = require_auth(request) 32 + async def _handle_upload( 33 + request: Request, sessions: SessionStore, client: ATProtoClient, 34 + ) -> Response: 35 + session = require_auth(request, sessions) 30 36 31 37 form = await request.form() 32 38 file = form.get("file") ··· 83 89 84 90 85 91 @get("/api/v1/media/{media_id:str}", media_type=MediaType.JSON) 86 - async def get_media(request: Request, media_id: str) -> dict[str, Any]: 92 + async def get_media( 93 + request: Request, media_id: str, sessions: SessionStore, 94 + ) -> dict[str, Any]: 87 95 """Return the current state of an uploaded media attachment.""" 88 - session = require_auth(request) 96 + session = require_auth(request, sessions) 89 97 blob_info = session.pending_blobs.get(media_id) 90 98 if not blob_info: 91 99 return Response( # type: ignore[return-value]
+8 -5
app/routes/notifications.py
··· 7 7 from litestar import Request, Router, get, post 8 8 from litestar.enums import MediaType 9 9 10 - from app.atproto import get_cursor, store_cursor 10 + from app.atproto import ATProtoClient, SessionStore, get_cursor, store_cursor 11 11 from app.auth import require_auth 12 12 from app.convert import convert_notification, convert_status 13 - from app.state import client 14 13 from app.utils import now_iso 15 14 16 15 ··· 20 19 @get("/api/v1/notifications", media_type=MediaType.JSON) 21 20 async def list_notifications( 22 21 request: Request, 22 + sessions: SessionStore, 23 + client: ATProtoClient, 23 24 max_id: str | None = None, 24 25 since_id: str | None = None, 25 26 min_id: str | None = None, ··· 27 28 types: list[str] | None = None, 28 29 exclude_types: list[str] | None = None, 29 30 ) -> list[dict[str, Any]]: 30 - session = require_auth(request) 31 + session = require_auth(request, sessions) 31 32 params: dict[str, Any] = {"limit": min(limit, 50)} 32 33 33 34 if max_id: ··· 94 95 95 96 96 97 @post("/api/v1/notifications/clear", media_type=MediaType.JSON) 97 - async def clear_notifications(request: Request) -> dict[str, Any]: 98 - session = require_auth(request) 98 + async def clear_notifications( 99 + request: Request, sessions: SessionStore, client: ATProtoClient, 100 + ) -> dict[str, Any]: 101 + session = require_auth(request, sessions) 99 102 await client.post(session, "app.bsky.notification.updateSeen", { 100 103 "seenAt": now_iso(), 101 104 })
+8 -4
app/routes/oauth.py
··· 18 18 from litestar import Request, Response, Router, get, post 19 19 from litestar.enums import MediaType 20 20 21 - from app.state import client, sessions 21 + from app.atproto import ATProtoClient, SessionStore 22 22 from app.utils import parse_request_body 23 23 24 24 ··· 98 98 99 99 100 100 @post("/oauth/authorize/submit", media_type=MediaType.HTML) 101 - async def authorize_submit(request: Request) -> Response: 101 + async def authorize_submit( 102 + request: Request, sessions: SessionStore, client: ATProtoClient, 103 + ) -> Response: 102 104 """Process the login form submission — authenticate and redirect.""" 103 105 body = await parse_request_body(request) 104 106 handle = body.get("handle", "") ··· 166 168 167 169 168 170 @post("/oauth/token", media_type=MediaType.JSON) 169 - async def token(request: Request) -> Response: 171 + async def token( 172 + request: Request, sessions: SessionStore, client: ATProtoClient, 173 + ) -> Response: 170 174 """Exchange credentials for an access token. 171 175 172 176 Supports ``grant_type=password`` (handle + app-password) and ··· 255 259 256 260 257 261 @post("/oauth/revoke", media_type=MediaType.JSON) 258 - async def revoke(request: Request) -> dict[str, Any]: 262 + async def revoke(request: Request, sessions: SessionStore) -> dict[str, Any]: 259 263 body = await parse_request_body(request) 260 264 tok = body.get("token", "") 261 265 if tok:
+43 -32
app/routes/pleroma.py
··· 11 11 from litestar import Request, Response, Router, delete, get, post, put 12 12 from litestar.enums import MediaType 13 13 14 - from app.atproto import decode_id 15 - from app.auth import optional_auth, require_auth 16 - from app.convert import convert_status 17 - from app.state import client 14 + from app.atproto import ATProtoClient, SessionStore, decode_id 15 + from app.auth import require_auth 16 + from app.convert import convert_relationship, convert_status 18 17 19 18 20 19 # --------------------------------------------------------------------------- ··· 132 131 133 132 134 133 @post("/api/v1/pleroma/notifications/read", media_type=MediaType.JSON) 135 - async def pleroma_notifications_read(request: Request) -> list[dict[str, Any]]: 134 + async def pleroma_notifications_read( 135 + request: Request, sessions: SessionStore, 136 + ) -> list[dict[str, Any]]: 136 137 """Mark notifications as read. Stub — returns empty list.""" 137 - _ = require_auth(request) 138 + _ = require_auth(request, sessions) 138 139 return [] 139 140 140 141 ··· 144 145 145 146 146 147 @post("/api/v1/pleroma/accounts/{account_id:str}/subscribe", media_type=MediaType.JSON) 147 - async def pleroma_subscribe(request: Request, account_id: str) -> dict[str, Any]: 148 + async def pleroma_subscribe( 149 + request: Request, account_id: str, sessions: SessionStore, 150 + ) -> dict[str, Any]: 148 151 """Subscribe to an account's posts. Stub — returns relationship.""" 149 - session = require_auth(request) 150 - from app.convert import convert_relationship 151 - 152 + _ = require_auth(request, sessions) 152 153 return convert_relationship(account_id, {"following": True, "subscribing": True}) 153 154 154 155 155 156 @post("/api/v1/pleroma/accounts/{account_id:str}/unsubscribe", media_type=MediaType.JSON) 156 - async def pleroma_unsubscribe(request: Request, account_id: str) -> dict[str, Any]: 157 + async def pleroma_unsubscribe( 158 + request: Request, account_id: str, sessions: SessionStore, 159 + ) -> dict[str, Any]: 157 160 """Unsubscribe from an account's posts. Stub — returns relationship.""" 158 - session = require_auth(request) 159 - from app.convert import convert_relationship 160 - 161 + _ = require_auth(request, sessions) 161 162 return convert_relationship(account_id, {"following": True, "subscribing": False}) 162 163 163 164 ··· 182 183 183 184 184 185 @get("/api/v1/pleroma/mascot", media_type=MediaType.JSON) 185 - async def pleroma_get_mascot(request: Request) -> dict[str, Any]: 186 - _ = require_auth(request) 186 + async def pleroma_get_mascot(request: Request, sessions: SessionStore) -> dict[str, Any]: 187 + _ = require_auth(request, sessions) 187 188 return { 188 189 "id": "0", 189 190 "url": "", ··· 193 194 194 195 195 196 @put("/api/v1/pleroma/mascot", media_type=MediaType.JSON) 196 - async def pleroma_put_mascot(request: Request) -> dict[str, Any]: 197 - _ = require_auth(request) 197 + async def pleroma_put_mascot(request: Request, sessions: SessionStore) -> dict[str, Any]: 198 + _ = require_auth(request, sessions) 198 199 return { 199 200 "id": "0", 200 201 "url": "", ··· 209 210 210 211 211 212 @put("/api/pleroma/notification_settings", media_type=MediaType.JSON) 212 - async def pleroma_notification_settings(request: Request) -> dict[str, str]: 213 - _ = require_auth(request) 213 + async def pleroma_notification_settings( 214 + request: Request, sessions: SessionStore, 215 + ) -> dict[str, str]: 216 + _ = require_auth(request, sessions) 214 217 return {"status": "success"} 215 218 216 219 ··· 234 237 235 238 236 239 @put("/api/v1/pleroma/statuses/{status_id:str}/reactions/{emoji:str}", media_type=MediaType.JSON) 237 - async def put_reaction(request: Request, status_id: str, emoji: str) -> dict[str, Any]: 240 + async def put_reaction( 241 + request: Request, status_id: str, emoji: str, sessions: SessionStore, client: ATProtoClient, 242 + ) -> dict[str, Any]: 238 243 """React to a status with an emoji. Stub — returns the status unchanged.""" 239 - session = require_auth(request) 244 + session = require_auth(request, sessions) 240 245 at_uri = decode_id(status_id) 241 246 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) 242 247 posts = fetched.get("posts", []) ··· 248 253 249 254 250 255 @delete("/api/v1/pleroma/statuses/{status_id:str}/reactions/{emoji:str}", media_type=MediaType.JSON, status_code=200) 251 - async def delete_reaction(request: Request, status_id: str, emoji: str) -> dict[str, Any]: 256 + async def delete_reaction( 257 + request: Request, status_id: str, emoji: str, sessions: SessionStore, client: ATProtoClient, 258 + ) -> dict[str, Any]: 252 259 """Remove an emoji reaction. Stub — returns the status unchanged.""" 253 - session = require_auth(request) 260 + session = require_auth(request, sessions) 254 261 at_uri = decode_id(status_id) 255 262 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) 256 263 posts = fetched.get("posts", []) ··· 268 275 269 276 270 277 @put("/api/v1/statuses/{status_id:str}/emoji_reactions/{emoji:str}", media_type=MediaType.JSON) 271 - async def fedibird_put_reaction(request: Request, status_id: str, emoji: str) -> dict[str, Any]: 278 + async def fedibird_put_reaction( 279 + request: Request, status_id: str, emoji: str, sessions: SessionStore, client: ATProtoClient, 280 + ) -> dict[str, Any]: 272 281 """Fedibird-compatible emoji reaction. Stub — returns the status.""" 273 - session = require_auth(request) 282 + session = require_auth(request, sessions) 274 283 at_uri = decode_id(status_id) 275 284 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) 276 285 posts = fetched.get("posts", []) ··· 287 296 288 297 289 298 @post("/api/v1/pleroma/conversations/read", media_type=MediaType.JSON) 290 - async def pleroma_conversations_read(request: Request) -> list[dict[str, Any]]: 291 - _ = require_auth(request) 299 + async def pleroma_conversations_read( 300 + request: Request, sessions: SessionStore, 301 + ) -> list[dict[str, Any]]: 302 + _ = require_auth(request, sessions) 292 303 return [] 293 304 294 305 ··· 298 309 299 310 300 311 @get("/api/v1/pleroma/backups", media_type=MediaType.JSON) 301 - async def pleroma_list_backups(request: Request) -> list[dict[str, Any]]: 302 - _ = require_auth(request) 312 + async def pleroma_list_backups(request: Request, sessions: SessionStore) -> list[dict[str, Any]]: 313 + _ = require_auth(request, sessions) 303 314 return [] 304 315 305 316 306 317 @post("/api/v1/pleroma/backups", media_type=MediaType.JSON) 307 - async def pleroma_create_backup(request: Request) -> list[dict[str, Any]]: 308 - _ = require_auth(request) 318 + async def pleroma_create_backup(request: Request, sessions: SessionStore) -> list[dict[str, Any]]: 319 + _ = require_auth(request, sessions) 309 320 return [] 310 321 311 322
+10 -4
app/routes/search.py
··· 7 7 from litestar import Request, Router, get 8 8 from litestar.enums import MediaType 9 9 10 + from app.atproto import ATProtoClient, SessionStore 10 11 from app.auth import optional_auth 11 12 from app.convert import convert_account, convert_status 12 - from app.state import client 13 13 14 14 15 15 async def _do_search( 16 16 request: Request, 17 + sessions: SessionStore, 18 + client: ATProtoClient, 17 19 q: str = "", 18 20 type: str | None = None, 19 21 resolve: bool = False, ··· 21 23 limit: int = 20, 22 24 offset: int = 0, 23 25 ) -> dict[str, Any]: 24 - session = optional_auth(request) 26 + session = optional_auth(request, sessions) 25 27 26 28 accounts: list[dict[str, Any]] = [] 27 29 statuses: list[dict[str, Any]] = [] ··· 102 104 @get("/api/v2/search", media_type=MediaType.JSON) 103 105 async def search_v2( 104 106 request: Request, 107 + sessions: SessionStore, 108 + client: ATProtoClient, 105 109 q: str = "", 106 110 type: str | None = None, 107 111 resolve: bool = False, ··· 109 113 limit: int = 20, 110 114 offset: int = 0, 111 115 ) -> dict[str, Any]: 112 - return await _do_search(request, q, type, resolve, following, limit, offset) 116 + return await _do_search(request, sessions, client, q, type, resolve, following, limit, offset) 113 117 114 118 115 119 # ── GET /api/v1/search (legacy) ─────────────────────────────────────────── ··· 118 122 @get("/api/v1/search", media_type=MediaType.JSON) 119 123 async def search_v1( 120 124 request: Request, 125 + sessions: SessionStore, 126 + client: ATProtoClient, 121 127 q: str = "", 122 128 resolve: bool = False, 123 129 limit: int = 20, 124 130 ) -> dict[str, Any]: 125 - return await _do_search(request, q=q, resolve=resolve, limit=limit) 131 + return await _do_search(request, sessions, client, q=q, resolve=resolve, limit=limit) 126 132 127 133 128 134 # ── Router ────────────────────────────────────────────────────────────────
+50 -29
app/routes/statuses.py
··· 7 7 from litestar import Request, Response, Router, delete, get, post 8 8 from litestar.enums import MediaType 9 9 10 - from app.atproto import decode_id, encode_id, parse_at_uri 10 + from app.atproto import ATProtoClient, SessionStore, decode_id, encode_id, parse_at_uri 11 11 from app.auth import optional_auth, require_auth 12 - from app.convert import convert_status, detect_facets 13 - from app.state import client 12 + from app.convert import convert_account, convert_status, detect_facets 14 13 from app.utils import now_iso, parse_request_body 15 14 16 15 ··· 18 17 19 18 20 19 @get("/api/v1/statuses/{status_id:str}", media_type=MediaType.JSON) 21 - async def get_status(request: Request, status_id: str) -> dict[str, Any]: 22 - session = optional_auth(request) 20 + async def get_status( 21 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 22 + ) -> dict[str, Any]: 23 + session = optional_auth(request, sessions) 23 24 at_uri = decode_id(status_id) 24 25 25 26 if session: ··· 37 38 38 39 39 40 @get("/api/v1/statuses/{status_id:str}/context", media_type=MediaType.JSON) 40 - async def get_context(request: Request, status_id: str) -> dict[str, Any]: 41 - session = optional_auth(request) 41 + async def get_context( 42 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 43 + ) -> dict[str, Any]: 44 + session = optional_auth(request, sessions) 42 45 at_uri = decode_id(status_id) 43 46 44 47 # Step 1: Fetch thread anchored at target post to discover the root. ··· 135 138 136 139 137 140 @post("/api/v1/statuses", media_type=MediaType.JSON) 138 - async def create_status(request: Request) -> dict[str, Any]: 139 - session = require_auth(request) 141 + async def create_status( 142 + request: Request, sessions: SessionStore, client: ATProtoClient, 143 + ) -> dict[str, Any]: 144 + session = require_auth(request, sessions) 140 145 body = await parse_request_body(request) 141 146 142 147 text = body.get("status", "") ··· 277 282 278 283 279 284 @delete("/api/v1/statuses/{status_id:str}", media_type=MediaType.JSON, status_code=200) 280 - async def delete_status(request: Request, status_id: str) -> dict[str, Any]: 281 - session = require_auth(request) 285 + async def delete_status( 286 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 287 + ) -> dict[str, Any]: 288 + session = require_auth(request, sessions) 282 289 at_uri = decode_id(status_id) 283 290 repo, collection, rkey = parse_at_uri(at_uri) 284 291 ··· 299 306 300 307 301 308 @post("/api/v1/statuses/{status_id:str}/favourite", media_type=MediaType.JSON) 302 - async def favourite_status(request: Request, status_id: str) -> dict[str, Any]: 303 - session = require_auth(request) 309 + async def favourite_status( 310 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 311 + ) -> dict[str, Any]: 312 + session = require_auth(request, sessions) 304 313 at_uri = decode_id(status_id) 305 314 306 315 # Fetch post CID ··· 336 345 337 346 338 347 @post("/api/v1/statuses/{status_id:str}/unfavourite", media_type=MediaType.JSON) 339 - async def unfavourite_status(request: Request, status_id: str) -> dict[str, Any]: 340 - session = require_auth(request) 348 + async def unfavourite_status( 349 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 350 + ) -> dict[str, Any]: 351 + session = require_auth(request, sessions) 341 352 at_uri = decode_id(status_id) 342 353 343 354 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) ··· 364 375 365 376 366 377 @post("/api/v1/statuses/{status_id:str}/reblog", media_type=MediaType.JSON) 367 - async def reblog_status(request: Request, status_id: str) -> dict[str, Any]: 368 - session = require_auth(request) 378 + async def reblog_status( 379 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 380 + ) -> dict[str, Any]: 381 + session = require_auth(request, sessions) 369 382 at_uri = decode_id(status_id) 370 383 371 384 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) ··· 403 416 404 417 405 418 @post("/api/v1/statuses/{status_id:str}/unreblog", media_type=MediaType.JSON) 406 - async def unreblog_status(request: Request, status_id: str) -> dict[str, Any]: 407 - session = require_auth(request) 419 + async def unreblog_status( 420 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 421 + ) -> dict[str, Any]: 422 + session = require_auth(request, sessions) 408 423 at_uri = decode_id(status_id) 409 424 410 425 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) ··· 431 446 432 447 433 448 @post("/api/v1/statuses/{status_id:str}/bookmark", media_type=MediaType.JSON) 434 - async def bookmark_status(request: Request, status_id: str) -> dict[str, Any]: 449 + async def bookmark_status( 450 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 451 + ) -> dict[str, Any]: 435 452 """Bookmark stub — Bluesky doesn't have bookmarks yet.""" 436 - session = require_auth(request) 453 + session = require_auth(request, sessions) 437 454 at_uri = decode_id(status_id) 438 455 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) 439 456 posts = fetched.get("posts", []) ··· 445 462 446 463 447 464 @post("/api/v1/statuses/{status_id:str}/unbookmark", media_type=MediaType.JSON) 448 - async def unbookmark_status(request: Request, status_id: str) -> dict[str, Any]: 449 - session = require_auth(request) 465 + async def unbookmark_status( 466 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 467 + ) -> dict[str, Any]: 468 + session = require_auth(request, sessions) 450 469 at_uri = decode_id(status_id) 451 470 fetched = await client.get(session, "app.bsky.feed.getPosts", uris=at_uri) 452 471 posts = fetched.get("posts", []) ··· 461 480 462 481 463 482 @get("/api/v1/statuses/{status_id:str}/favourited_by", media_type=MediaType.JSON) 464 - async def favourited_by(request: Request, status_id: str) -> list[dict[str, Any]]: 465 - session = optional_auth(request) 483 + async def favourited_by( 484 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 485 + ) -> list[dict[str, Any]]: 486 + session = optional_auth(request, sessions) 466 487 at_uri = decode_id(status_id) 467 - from app.convert import convert_account 468 488 469 489 if session: 470 490 data = await client.get(session, "app.bsky.feed.getLikes", uri=at_uri, limit=40) ··· 477 497 478 498 479 499 @get("/api/v1/statuses/{status_id:str}/reblogged_by", media_type=MediaType.JSON) 480 - async def reblogged_by(request: Request, status_id: str) -> list[dict[str, Any]]: 481 - session = optional_auth(request) 500 + async def reblogged_by( 501 + request: Request, status_id: str, sessions: SessionStore, client: ATProtoClient, 502 + ) -> list[dict[str, Any]]: 503 + session = optional_auth(request, sessions) 482 504 at_uri = decode_id(status_id) 483 - from app.convert import convert_account 484 505 485 506 if session: 486 507 data = await client.get(session, "app.bsky.feed.getRepostedBy", uri=at_uri, limit=40)
+14 -9
app/routes/timelines.py
··· 7 7 from litestar import Request, Router, get 8 8 from litestar.enums import MediaType 9 9 10 - from app.atproto import get_cursor, store_cursor 10 + from app.atproto import ATProtoClient, SessionStore, get_cursor, store_cursor 11 11 from app.auth import optional_auth, require_auth 12 - from app.convert import convert_feed_item 13 - from app.state import client 12 + from app.convert import convert_feed_item, convert_status 14 13 15 14 16 15 # ── GET /api/v1/timelines/home ───────────────────────────────────────────── ··· 19 18 @get("/api/v1/timelines/home", media_type=MediaType.JSON) 20 19 async def home_timeline( 21 20 request: Request, 21 + sessions: SessionStore, 22 + client: ATProtoClient, 22 23 max_id: str | None = None, 23 24 since_id: str | None = None, 24 25 min_id: str | None = None, 25 26 limit: int = 20, 26 27 ) -> list[dict[str, Any]]: 27 - session = require_auth(request) 28 + session = require_auth(request, sessions) 28 29 params: dict[str, Any] = {"limit": min(limit, 50)} 29 30 30 31 # Try to map Mastodon pagination to Bluesky cursor ··· 53 54 @get("/api/v1/timelines/public", media_type=MediaType.JSON) 54 55 async def public_timeline( 55 56 request: Request, 57 + sessions: SessionStore, 58 + client: ATProtoClient, 56 59 local: bool = False, 57 60 remote: bool = False, 58 61 only_media: bool = False, ··· 63 66 ) -> list[dict[str, Any]]: 64 67 """Bluesky doesn't have a true public timeline, so we use the 65 68 ``discover`` feed (What's Hot) as a reasonable substitute.""" 66 - session = optional_auth(request) 69 + session = optional_auth(request, sessions) 67 70 params: dict[str, Any] = {"limit": min(limit, 50)} 68 71 69 72 if max_id: ··· 99 102 async def hashtag_timeline( 100 103 request: Request, 101 104 hashtag: str, 105 + sessions: SessionStore, 106 + client: ATProtoClient, 102 107 max_id: str | None = None, 103 108 limit: int = 20, 104 109 ) -> list[dict[str, Any]]: ··· 107 112 Note: searchPosts requires auth on the public API, so unauthenticated 108 113 requests return an empty list. 109 114 """ 110 - session = optional_auth(request) 115 + session = optional_auth(request, sessions) 111 116 if not session: 112 117 return [] # searchPosts requires auth 113 118 ··· 120 125 121 126 data = await client.get(session, "app.bsky.feed.searchPosts", **params) 122 127 123 - from app.convert import convert_status 124 - 125 128 statuses = [] 126 129 for post in data.get("posts", []): 127 130 statuses.append(convert_status(post, session=session)) ··· 153 156 @get("/api/v1/timelines/bubble", media_type=MediaType.JSON) 154 157 async def bubble_timeline( 155 158 request: Request, 159 + sessions: SessionStore, 160 + client: ATProtoClient, 156 161 max_id: str | None = None, 157 162 since_id: str | None = None, 158 163 min_id: str | None = None, ··· 163 168 164 169 Since we're a Bluesky bridge, we treat this the same as the public timeline. 165 170 """ 166 - session = optional_auth(request) 171 + session = optional_auth(request, sessions) 167 172 params: dict[str, Any] = {"limit": min(limit, 50)} 168 173 169 174 if max_id:
+10 -68
app/state.py
··· 24 24 client: ATProtoClient 25 25 26 26 27 - # Global instance for backward compatibility during migration 27 + # Global instance — lazily initialized on first access 28 28 _app_state: AppState | None = None 29 29 30 30 31 - def create_state() -> AppState: 32 - """Create and initialize the application state. 33 - 34 - This function is called once during application startup. 35 - """ 31 + def _get_or_create_state() -> AppState: 32 + """Return the global AppState, creating it on first access.""" 36 33 global _app_state 37 - sessions = SessionStore() 38 - client = ATProtoClient(sessions) 39 - _app_state = AppState(sessions=sessions, client=client) 40 - return _app_state 41 - 42 - 43 - def provide_state() -> AppState: 44 - """Dependency provider for AppState. 45 - 46 - Returns the global AppState instance. 47 - """ 48 34 if _app_state is None: 49 - return create_state() 35 + sessions = SessionStore() 36 + client = ATProtoClient(sessions) 37 + _app_state = AppState(sessions=sessions, client=client) 50 38 return _app_state 51 39 52 40 53 41 def provide_sessions() -> SessionStore: 54 - """Dependency provider for SessionStore. 55 - 56 - Returns the session store from the global AppState. 57 - """ 58 - return provide_state().sessions 42 + """Dependency provider for SessionStore.""" 43 + return _get_or_create_state().sessions 59 44 60 45 61 46 def provide_client() -> ATProtoClient: 62 - """Dependency provider for ATProtoClient. 63 - 64 - Returns the client from the global AppState. 65 - """ 66 - return provide_state().client 47 + """Dependency provider for ATProtoClient.""" 48 + return _get_or_create_state().client 67 49 68 50 69 51 # Dependency providers for Litestar DI 70 - # Note: "state" is a reserved keyword in Litestar, so we use "app_state" instead 71 52 dependencies = { 72 - "app_state": Provide(provide_state, sync_to_thread=False), 73 53 "sessions": Provide(provide_sessions, sync_to_thread=False), 74 54 "client": Provide(provide_client, sync_to_thread=False), 75 55 } 76 - 77 - 78 - # Backward compatibility: expose global singletons for legacy code 79 - # These will be removed once all routes are migrated to use DI 80 - def _get_sessions() -> SessionStore: 81 - """Get the global session store (backward compatibility).""" 82 - return provide_state().sessions 83 - 84 - 85 - def _get_client() -> ATProtoClient: 86 - """Get the global client (backward compatibility).""" 87 - return provide_state().client 88 - 89 - 90 - # Module-level singletons for backward compatibility 91 - # These are lazily initialized on first access 92 - class _SessionsProxy: 93 - """Proxy that delegates to the global SessionStore.""" 94 - 95 - def __getattr__(self, name): 96 - return getattr(provide_sessions(), name) 97 - 98 - def __repr__(self) -> str: 99 - return repr(provide_sessions()) 100 - 101 - 102 - class _ClientProxy: 103 - """Proxy that delegates to the global ATProtoClient.""" 104 - 105 - def __getattr__(self, name): 106 - return getattr(provide_client(), name) 107 - 108 - def __repr__(self) -> str: 109 - return repr(provide_client()) 110 - 111 - 112 - sessions = _SessionsProxy() 113 - client = _ClientProxy()