"""KakaoTalk self-memo notification client.""" from __future__ import annotations import os from typing import Optional from loguru import logger from tenacity import retry, stop_after_attempt, wait_exponential KAKAO_MEMO_URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send" KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token" MAX_TEXT_LENGTH = 9000 # KakaoTalk text message limit class KakaoTalkClient: def __init__( self, rest_api_key: Optional[str] = None, client_secret: Optional[str] = None, access_token: Optional[str] = None, refresh_token: Optional[str] = None, ): self.rest_api_key = rest_api_key or os.environ.get("KAKAO_REST_API_KEY", "") self.client_secret = client_secret or os.environ.get("KAKAO_CLIENT_SECRET", "") self.access_token = access_token or os.environ.get("KAKAO_ACCESS_TOKEN", "") self.refresh_token = refresh_token or os.environ.get("KAKAO_REFRESH_TOKEN", "") def is_configured(self) -> bool: return bool(self.rest_api_key and self.access_token) def send_self_memo(self, text: str) -> bool: """Send a self-memo to KakaoTalk. Returns True on success.""" if not self.is_configured(): logger.warning("KakaoTalk not configured (missing tokens)") return False # Truncate if needed if len(text) > MAX_TEXT_LENGTH: text = text[: MAX_TEXT_LENGTH - 20] + "\n\n[...truncated]" try: return self._do_send(text) except Exception as e: logger.error(f"KakaoTalk send failed: {e}") # Try token refresh once if self.refresh_token: try: new_token = self.refresh_access_token() if new_token: self.access_token = new_token return self._do_send(text) except Exception as e2: logger.error(f"Token refresh also failed: {e2}") return False @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5)) def _do_send(self, text: str) -> bool: import httpx from jinja2 import Environment, FileSystemLoader from gimme_job.utils.paths import project_root import json # Build template object template_path = project_root() / "gimme_job" / "templates" / "kakao_default.json.j2" if template_path.exists(): env = Environment(loader=FileSystemLoader(str(template_path.parent))) tmpl = env.get_template("kakao_default.json.j2") template_object_str = tmpl.render(text=text) template_object = json.loads(template_object_str) else: template_object = {"object_type": "text", "text": text, "link": {}} response = httpx.post( KAKAO_MEMO_URL, headers={"Authorization": f"Bearer {self.access_token}"}, data={"template_object": json.dumps(template_object, ensure_ascii=False)}, timeout=15.0, ) if response.status_code == 200: result = response.json() if result.get("result_code") == 0: logger.info("KakaoTalk self-memo sent successfully") return True else: logger.warning(f"KakaoTalk API returned: {result}") return False elif response.status_code == 401: raise Exception("Unauthorized — token may be expired") else: logger.error(f"KakaoTalk HTTP {response.status_code}: {response.text[:200]}") return False def refresh_access_token(self) -> Optional[str]: """Refresh the access token using the refresh token.""" import httpx data = { "grant_type": "refresh_token", "client_id": self.rest_api_key, "refresh_token": self.refresh_token, } if self.client_secret: data["client_secret"] = self.client_secret response = httpx.post(KAKAO_TOKEN_URL, data=data, timeout=15.0) response.raise_for_status() data = response.json() new_token = data.get("access_token") if new_token: logger.info("KakaoTalk access token refreshed") # Persist to env if possible os.environ["KAKAO_ACCESS_TOKEN"] = new_token self._update_env_file(new_token) return new_token def _update_env_file(self, new_access_token: str) -> None: """Update the .env file with the new access token.""" from gimme_job.utils.paths import project_root env_path = project_root() / ".env" if not env_path.exists(): return try: content = env_path.read_text() lines = content.splitlines() updated = [] found = False for line in lines: if line.startswith("KAKAO_ACCESS_TOKEN="): updated.append(f"KAKAO_ACCESS_TOKEN={new_access_token}") found = True else: updated.append(line) if not found: updated.append(f"KAKAO_ACCESS_TOKEN={new_access_token}") env_path.write_text("\n".join(updated) + "\n") except Exception as e: logger.warning(f"Could not update .env with new token: {e}")