You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
"""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}")
|