from __future__ import annotations import asyncio import logging from datetime import datetime, timedelta, UTC import httpx logger = logging.getLogger(__name__) TOKEN_OAUTH_BASE = "https://api.line.me" TOKEN_TTL_SECONDS = 30 * 24 * 3600 TOKEN_REFRESH_MARGIN = 2 * 24 * 3600 class LineTokenManager: def __init__(self): self._cache: dict[str, dict] = {} self._lock = asyncio.Lock() async def get_token(self, channel_id: str, channel_secret: str) -> str | None: key = f"{channel_id}" async with self._lock: cached = self._cache.get(key) if cached: expires_at = cached["expires_at"] now = datetime.now(UTC) if now + timedelta(seconds=TOKEN_REFRESH_MARGIN) < expires_at: return cached["token"] token_data = await self._issue_token_v21(channel_id, channel_secret) if token_data: async with self._lock: self._cache[key] = { "token": token_data["access_token"], "expires_at": datetime.now(UTC) + timedelta(seconds=TOKEN_TTL_SECONDS), "channel_id": channel_id, } return token_data["access_token"] async with self._lock: cached = self._cache.get(key) return cached["token"] if cached else None async def revoke_token(self, channel_id: str, channel_secret: str) -> bool: key = f"{channel_id}" async with self._lock: cached = self._cache.pop(key, None) token = cached["token"] if cached else None if not token: return False return await self._revoke_token_v21(channel_id, channel_secret, token) async def get_token_info(self, token: str) -> dict | None: return await self._get_token_info_v21(token) async def _issue_token_v21(self, channel_id: str, channel_secret: str) -> dict | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: resp = await client.post( f"{TOKEN_OAUTH_BASE}/oauth2/v2.1/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "client_credentials", "client_id": channel_id, "client_secret": channel_secret, }, ) if resp.status_code == 200: return resp.json() logger.warning("LINE token issue v2.1 failed: status=%s", resp.status_code) except Exception: logger.exception("LINE token issue v2.1 error") return None async def _revoke_token_v21(self, channel_id: str, channel_secret: str, token: str) -> bool: try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: resp = await client.post( f"{TOKEN_OAUTH_BASE}/oauth2/v2.1/token/revoke", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "client_id": channel_id, "client_secret": channel_secret, "access_token": token, }, ) return resp.status_code == 200 except Exception: logger.exception("LINE token revoke v2.1 error") return False async def _get_token_info_v21(self, token: str) -> dict | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: resp = await client.get( f"{TOKEN_OAUTH_BASE}/oauth2/v2.1/token/info", headers={"Authorization": f"Bearer {token}"}, ) if resp.status_code == 200: return resp.json() logger.warning("LINE token info v2.1 failed: status=%s", resp.status_code) except Exception: logger.exception("LINE token info v2.1 error") return None