kakaome 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kakaome-0.1.0/.gitignore +10 -0
- kakaome-0.1.0/LICENSE +21 -0
- kakaome-0.1.0/PKG-INFO +72 -0
- kakaome-0.1.0/README.md +58 -0
- kakaome-0.1.0/pyproject.toml +34 -0
- kakaome-0.1.0/src/kakao_self/__init__.py +4 -0
- kakaome-0.1.0/src/kakao_self/api.py +101 -0
- kakaome-0.1.0/src/kakao_self/auth.py +76 -0
- kakaome-0.1.0/src/kakao_self/cli.py +79 -0
- kakaome-0.1.0/src/kakao_self/config.py +36 -0
- kakaome-0.1.0/tests/test_api.py +46 -0
- kakaome-0.1.0/tests/test_cli.py +32 -0
- kakaome-0.1.0/uv.lock +691 -0
kakaome-0.1.0/.gitignore
ADDED
kakaome-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kakaome contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kakaome-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kakaome
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send KakaoTalk messages to your own chat from the command line
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: cli,kakao,kakaotalk,message
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# kakaome
|
|
16
|
+
|
|
17
|
+
카카오톡 **나에게 보내기** REST API를 사용하는 작은 Python CLI입니다.
|
|
18
|
+
|
|
19
|
+
> 비공식 프로젝트이며 Kakao Corp.와 제휴하거나 Kakao가 보증하는 제품이 아닙니다.
|
|
20
|
+
|
|
21
|
+
## 설치
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
pipx install kakaome
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
개발 버전은 프로젝트 디렉터리에서 다음과 같이 실행할 수 있습니다.
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
uv sync
|
|
31
|
+
uv run kakaome --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Kakao Developers 설정
|
|
35
|
+
|
|
36
|
+
1. 앱에서 **카카오 로그인**을 활성화합니다.
|
|
37
|
+
2. Redirect URI로 `http://127.0.0.1:8765/callback`을 등록합니다.
|
|
38
|
+
3. 동의 항목에서 **카카오톡 메시지 전송 (`talk_message`)**을 활성화합니다.
|
|
39
|
+
4. 메시지 링크에 사용할 도메인을 **제품 링크 > 웹 도메인**에 등록합니다.
|
|
40
|
+
|
|
41
|
+
REST API 키와 기본 링크를 저장하고 최초 한 번 로그인합니다.
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
kakaome configure --rest-api-key YOUR_KEY --default-link https://example.com
|
|
45
|
+
kakaome auth
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Client Secret 기능을 활성화한 앱은 `--client-secret`도 지정합니다.
|
|
49
|
+
|
|
50
|
+
## 사용
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
kakaome send "배치 작업이 완료되었습니다."
|
|
54
|
+
kakaome send "결과 보기" --link https://example.com/report
|
|
55
|
+
kakaome send "오늘의 결과" --image-url https://example.com/result.png
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
카카오 기본 텍스트 템플릿은 링크를 필수로 요구하고 최대 200자까지 지원합니다.
|
|
59
|
+
`--image-url`은 인터넷에서 공개 접근 가능한 이미지여야 합니다. PDF나 ZIP 같은 로컬
|
|
60
|
+
파일을 카카오톡 첨부파일로 직접 업로드하는 기능은 공식 나에게 보내기 REST API에서
|
|
61
|
+
지원하지 않습니다.
|
|
62
|
+
|
|
63
|
+
설정과 OAuth 토큰은 Windows의 `%APPDATA%\kakaome\config.json` 또는 다른
|
|
64
|
+
운영체제의 `~/.config/kakaome/config.json`에 저장됩니다. 경로는
|
|
65
|
+
`KAKAOME_CONFIG` 환경 변수로 바꿀 수 있습니다.
|
|
66
|
+
|
|
67
|
+
## 배포 빌드
|
|
68
|
+
|
|
69
|
+
```console
|
|
70
|
+
uv build
|
|
71
|
+
uv run twine check dist/*
|
|
72
|
+
```
|
kakaome-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# kakaome
|
|
2
|
+
|
|
3
|
+
카카오톡 **나에게 보내기** REST API를 사용하는 작은 Python CLI입니다.
|
|
4
|
+
|
|
5
|
+
> 비공식 프로젝트이며 Kakao Corp.와 제휴하거나 Kakao가 보증하는 제품이 아닙니다.
|
|
6
|
+
|
|
7
|
+
## 설치
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
pipx install kakaome
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
개발 버전은 프로젝트 디렉터리에서 다음과 같이 실행할 수 있습니다.
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
uv sync
|
|
17
|
+
uv run kakaome --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Kakao Developers 설정
|
|
21
|
+
|
|
22
|
+
1. 앱에서 **카카오 로그인**을 활성화합니다.
|
|
23
|
+
2. Redirect URI로 `http://127.0.0.1:8765/callback`을 등록합니다.
|
|
24
|
+
3. 동의 항목에서 **카카오톡 메시지 전송 (`talk_message`)**을 활성화합니다.
|
|
25
|
+
4. 메시지 링크에 사용할 도메인을 **제품 링크 > 웹 도메인**에 등록합니다.
|
|
26
|
+
|
|
27
|
+
REST API 키와 기본 링크를 저장하고 최초 한 번 로그인합니다.
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
kakaome configure --rest-api-key YOUR_KEY --default-link https://example.com
|
|
31
|
+
kakaome auth
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Client Secret 기능을 활성화한 앱은 `--client-secret`도 지정합니다.
|
|
35
|
+
|
|
36
|
+
## 사용
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
kakaome send "배치 작업이 완료되었습니다."
|
|
40
|
+
kakaome send "결과 보기" --link https://example.com/report
|
|
41
|
+
kakaome send "오늘의 결과" --image-url https://example.com/result.png
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
카카오 기본 텍스트 템플릿은 링크를 필수로 요구하고 최대 200자까지 지원합니다.
|
|
45
|
+
`--image-url`은 인터넷에서 공개 접근 가능한 이미지여야 합니다. PDF나 ZIP 같은 로컬
|
|
46
|
+
파일을 카카오톡 첨부파일로 직접 업로드하는 기능은 공식 나에게 보내기 REST API에서
|
|
47
|
+
지원하지 않습니다.
|
|
48
|
+
|
|
49
|
+
설정과 OAuth 토큰은 Windows의 `%APPDATA%\kakaome\config.json` 또는 다른
|
|
50
|
+
운영체제의 `~/.config/kakaome/config.json`에 저장됩니다. 경로는
|
|
51
|
+
`KAKAOME_CONFIG` 환경 변수로 바꿀 수 있습니다.
|
|
52
|
+
|
|
53
|
+
## 배포 빌드
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
uv build
|
|
57
|
+
uv run twine check dist/*
|
|
58
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kakaome"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Send KakaoTalk messages to your own chat from the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["kakao", "kakaotalk", "cli", "message"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
kakaome = "kakao_self.cli:main"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"build>=1.2",
|
|
26
|
+
"pytest>=8.0",
|
|
27
|
+
"twine>=6.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/kakao_self"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.error import HTTPError, URLError
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
7
|
+
|
|
8
|
+
from .config import save_config
|
|
9
|
+
|
|
10
|
+
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
|
11
|
+
SEND_URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KakaoAPIError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _post_form(url: str, data: dict[str, str], token: str | None = None) -> dict[str, Any]:
|
|
19
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"}
|
|
20
|
+
if token:
|
|
21
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
22
|
+
request = Request(url, data=urlencode(data).encode(), headers=headers, method="POST")
|
|
23
|
+
try:
|
|
24
|
+
with urlopen(request, timeout=30) as response:
|
|
25
|
+
return json.loads(response.read().decode())
|
|
26
|
+
except HTTPError as exc:
|
|
27
|
+
body = exc.read().decode(errors="replace")
|
|
28
|
+
raise KakaoAPIError(f"Kakao API 오류 ({exc.code}): {body}") from exc
|
|
29
|
+
except (URLError, TimeoutError) as exc:
|
|
30
|
+
raise KakaoAPIError(f"Kakao API에 연결할 수 없습니다: {exc}") from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def exchange_code(config: dict[str, Any], code: str) -> dict[str, Any]:
|
|
34
|
+
data = {
|
|
35
|
+
"grant_type": "authorization_code",
|
|
36
|
+
"client_id": config["rest_api_key"],
|
|
37
|
+
"redirect_uri": config["redirect_uri"],
|
|
38
|
+
"code": code,
|
|
39
|
+
}
|
|
40
|
+
if config.get("client_secret"):
|
|
41
|
+
data["client_secret"] = config["client_secret"]
|
|
42
|
+
return _post_form(TOKEN_URL, data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def store_tokens(config: dict[str, Any], tokens: dict[str, Any]) -> None:
|
|
46
|
+
config["access_token"] = tokens["access_token"]
|
|
47
|
+
if tokens.get("refresh_token"):
|
|
48
|
+
config["refresh_token"] = tokens["refresh_token"]
|
|
49
|
+
config["expires_at"] = int(time.time()) + int(tokens.get("expires_in", 0))
|
|
50
|
+
save_config(config)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def access_token(config: dict[str, Any]) -> str:
|
|
54
|
+
token = config.get("access_token")
|
|
55
|
+
if not token:
|
|
56
|
+
raise KakaoAPIError("인증 정보가 없습니다. 먼저 `kakaome auth`를 실행하세요.")
|
|
57
|
+
if int(config.get("expires_at", 0)) > int(time.time()) + 60:
|
|
58
|
+
return str(token)
|
|
59
|
+
|
|
60
|
+
refresh_token = config.get("refresh_token")
|
|
61
|
+
if not refresh_token:
|
|
62
|
+
raise KakaoAPIError("토큰이 만료되었습니다. `kakaome auth`를 다시 실행하세요.")
|
|
63
|
+
data = {
|
|
64
|
+
"grant_type": "refresh_token",
|
|
65
|
+
"client_id": config["rest_api_key"],
|
|
66
|
+
"refresh_token": str(refresh_token),
|
|
67
|
+
}
|
|
68
|
+
if config.get("client_secret"):
|
|
69
|
+
data["client_secret"] = config["client_secret"]
|
|
70
|
+
tokens = _post_form(TOKEN_URL, data)
|
|
71
|
+
store_tokens(config, tokens)
|
|
72
|
+
return str(config["access_token"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def send_message(
|
|
76
|
+
config: dict[str, Any], text: str, link: str, image_url: str | None = None
|
|
77
|
+
) -> None:
|
|
78
|
+
if image_url:
|
|
79
|
+
template = {
|
|
80
|
+
"object_type": "feed",
|
|
81
|
+
"content": {
|
|
82
|
+
"title": text,
|
|
83
|
+
"image_url": image_url,
|
|
84
|
+
"link": {"web_url": link, "mobile_web_url": link},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
else:
|
|
88
|
+
if len(text) > 200:
|
|
89
|
+
raise KakaoAPIError("텍스트 메시지는 200자 이하여야 합니다.")
|
|
90
|
+
template = {
|
|
91
|
+
"object_type": "text",
|
|
92
|
+
"text": text,
|
|
93
|
+
"link": {"web_url": link, "mobile_web_url": link},
|
|
94
|
+
}
|
|
95
|
+
result = _post_form(
|
|
96
|
+
SEND_URL,
|
|
97
|
+
{"template_object": json.dumps(template, ensure_ascii=False)},
|
|
98
|
+
access_token(config),
|
|
99
|
+
)
|
|
100
|
+
if result.get("result_code") != 0:
|
|
101
|
+
raise KakaoAPIError(f"메시지 전송 실패: {result}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import threading
|
|
3
|
+
import webbrowser
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
7
|
+
|
|
8
|
+
from .api import KakaoAPIError, exchange_code, store_tokens
|
|
9
|
+
|
|
10
|
+
AUTHORIZE_URL = "https://kauth.kakao.com/oauth/authorize"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def authenticate(config: dict[str, Any], timeout: int = 180) -> None:
|
|
14
|
+
redirect = urlparse(config["redirect_uri"])
|
|
15
|
+
if redirect.scheme != "http" or redirect.hostname not in {"127.0.0.1", "localhost"}:
|
|
16
|
+
raise KakaoAPIError("Redirect URI는 로컬 HTTP 주소여야 합니다.")
|
|
17
|
+
if not redirect.port:
|
|
18
|
+
raise KakaoAPIError("Redirect URI에 포트가 필요합니다.")
|
|
19
|
+
|
|
20
|
+
state = secrets.token_urlsafe(24)
|
|
21
|
+
result: dict[str, str] = {}
|
|
22
|
+
expected_path = redirect.path or "/"
|
|
23
|
+
|
|
24
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
25
|
+
def do_GET(self) -> None:
|
|
26
|
+
parsed = urlparse(self.path)
|
|
27
|
+
query = parse_qs(parsed.query)
|
|
28
|
+
if parsed.path != expected_path:
|
|
29
|
+
self.send_error(404)
|
|
30
|
+
return
|
|
31
|
+
if query.get("state", [""])[0] != state:
|
|
32
|
+
result["error"] = "OAuth state가 일치하지 않습니다."
|
|
33
|
+
status = 400
|
|
34
|
+
elif query.get("code"):
|
|
35
|
+
result["code"] = query["code"][0]
|
|
36
|
+
status = 200
|
|
37
|
+
else:
|
|
38
|
+
result["error"] = query.get("error_description", ["인증이 취소되었습니다."])[0]
|
|
39
|
+
status = 400
|
|
40
|
+
body = "인증이 완료되었습니다. 이 창을 닫아도 됩니다." if status == 200 else result["error"]
|
|
41
|
+
encoded = body.encode("utf-8")
|
|
42
|
+
self.send_response(status)
|
|
43
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
44
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
45
|
+
self.end_headers()
|
|
46
|
+
self.wfile.write(encoded)
|
|
47
|
+
|
|
48
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
server = HTTPServer((redirect.hostname, redirect.port), CallbackHandler)
|
|
52
|
+
server.timeout = timeout
|
|
53
|
+
query = urlencode(
|
|
54
|
+
{
|
|
55
|
+
"response_type": "code",
|
|
56
|
+
"client_id": config["rest_api_key"],
|
|
57
|
+
"redirect_uri": config["redirect_uri"],
|
|
58
|
+
"scope": "talk_message",
|
|
59
|
+
"state": state,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
print("브라우저에서 카카오 로그인을 완료하세요.")
|
|
63
|
+
webbrowser.open(f"{AUTHORIZE_URL}?{query}")
|
|
64
|
+
timer = threading.Timer(timeout, server.server_close)
|
|
65
|
+
timer.start()
|
|
66
|
+
try:
|
|
67
|
+
server.handle_request()
|
|
68
|
+
finally:
|
|
69
|
+
timer.cancel()
|
|
70
|
+
server.server_close()
|
|
71
|
+
if result.get("error"):
|
|
72
|
+
raise KakaoAPIError(result["error"])
|
|
73
|
+
if not result.get("code"):
|
|
74
|
+
raise KakaoAPIError("인증 대기 시간이 초과되었습니다.")
|
|
75
|
+
store_tokens(config, exchange_code(config, result["code"]))
|
|
76
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from .api import KakaoAPIError, send_message
|
|
6
|
+
from .auth import authenticate
|
|
7
|
+
from .config import config_path, load_config, save_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _url(value: str) -> str:
|
|
11
|
+
parsed = urlparse(value)
|
|
12
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
13
|
+
raise argparse.ArgumentTypeError("http:// 또는 https:// URL이어야 합니다.")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parser() -> argparse.ArgumentParser:
|
|
18
|
+
root = argparse.ArgumentParser(
|
|
19
|
+
prog="kakaome", description="카카오톡 '나에게 보내기' CLI"
|
|
20
|
+
)
|
|
21
|
+
sub = root.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
configure = sub.add_parser("configure", help="Kakao Developers 앱 설정 저장")
|
|
24
|
+
configure.add_argument("--rest-api-key", required=True)
|
|
25
|
+
configure.add_argument("--default-link", required=True, type=_url)
|
|
26
|
+
configure.add_argument("--client-secret")
|
|
27
|
+
configure.add_argument(
|
|
28
|
+
"--redirect-uri", type=_url, default="http://127.0.0.1:8765/callback"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
sub.add_parser("auth", help="브라우저에서 카카오 로그인 및 권한 동의")
|
|
32
|
+
|
|
33
|
+
send = sub.add_parser("send", help="내 채팅방으로 메시지 전송")
|
|
34
|
+
send.add_argument("message", help="보낼 메시지(텍스트 템플릿은 최대 200자)")
|
|
35
|
+
send.add_argument("--link", type=_url, help="메시지를 눌렀을 때 열 URL")
|
|
36
|
+
send.add_argument("--image-url", type=_url, help="공개 접근 가능한 이미지 URL")
|
|
37
|
+
return root
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(argv: list[str] | None = None) -> int:
|
|
41
|
+
args = parser().parse_args(argv)
|
|
42
|
+
config = load_config()
|
|
43
|
+
if args.command == "configure":
|
|
44
|
+
config.update(
|
|
45
|
+
rest_api_key=args.rest_api_key,
|
|
46
|
+
default_link=args.default_link,
|
|
47
|
+
redirect_uri=args.redirect_uri,
|
|
48
|
+
)
|
|
49
|
+
if args.client_secret:
|
|
50
|
+
config["client_secret"] = args.client_secret
|
|
51
|
+
save_config(config)
|
|
52
|
+
print(f"설정을 저장했습니다: {config_path()}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if not config.get("rest_api_key"):
|
|
56
|
+
raise KakaoAPIError("설정이 없습니다. 먼저 `kakaome configure ...`를 실행하세요.")
|
|
57
|
+
if args.command == "auth":
|
|
58
|
+
authenticate(config)
|
|
59
|
+
print("카카오 인증을 저장했습니다.")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
link = args.link or config.get("default_link")
|
|
63
|
+
if not link:
|
|
64
|
+
raise KakaoAPIError("--link 또는 configure의 --default-link가 필요합니다.")
|
|
65
|
+
send_message(config, args.message, str(link), args.image_url)
|
|
66
|
+
print("메시지를 전송했습니다.")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
try:
|
|
72
|
+
raise SystemExit(run())
|
|
73
|
+
except (KakaoAPIError, RuntimeError) as exc:
|
|
74
|
+
print(f"오류: {exc}", file=sys.stderr)
|
|
75
|
+
raise SystemExit(1) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def config_path() -> Path:
|
|
8
|
+
override = os.environ.get("KAKAOME_CONFIG")
|
|
9
|
+
if override:
|
|
10
|
+
return Path(override).expanduser()
|
|
11
|
+
base = Path(os.environ.get("APPDATA", Path.home() / ".config"))
|
|
12
|
+
return base / "kakaome" / "config.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config() -> dict[str, Any]:
|
|
16
|
+
path = config_path()
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return {}
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
22
|
+
raise RuntimeError(f"설정 파일을 읽을 수 없습니다: {path}: {exc}") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
26
|
+
path = config_path()
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
temporary = path.with_suffix(".tmp")
|
|
29
|
+
temporary.write_text(
|
|
30
|
+
json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
31
|
+
)
|
|
32
|
+
temporary.replace(path)
|
|
33
|
+
try:
|
|
34
|
+
path.chmod(0o600)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kakao_self import api
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_send_text_builds_kakao_template() -> None:
|
|
11
|
+
config = {"access_token": "token", "expires_at": int(time.time()) + 3600}
|
|
12
|
+
with patch("kakao_self.api._post_form", return_value={"result_code": 0}) as post:
|
|
13
|
+
api.send_message(config, "완료", "https://example.com")
|
|
14
|
+
url, data, token = post.call_args.args
|
|
15
|
+
assert url == api.SEND_URL
|
|
16
|
+
assert token == "token"
|
|
17
|
+
assert json.loads(data["template_object"]) == {
|
|
18
|
+
"object_type": "text",
|
|
19
|
+
"text": "완료",
|
|
20
|
+
"link": {
|
|
21
|
+
"web_url": "https://example.com",
|
|
22
|
+
"mobile_web_url": "https://example.com",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_send_rejects_text_over_200_characters() -> None:
|
|
28
|
+
with pytest.raises(api.KakaoAPIError, match="200자"):
|
|
29
|
+
api.send_message({}, "가" * 201, "https://example.com")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_access_token_refreshes_expired_token() -> None:
|
|
33
|
+
config = {
|
|
34
|
+
"rest_api_key": "key",
|
|
35
|
+
"access_token": "old",
|
|
36
|
+
"refresh_token": "refresh",
|
|
37
|
+
"expires_at": 0,
|
|
38
|
+
}
|
|
39
|
+
with (
|
|
40
|
+
patch("kakao_self.api._post_form", return_value={"access_token": "new", "expires_in": 3600}),
|
|
41
|
+
patch("kakao_self.api.save_config") as save,
|
|
42
|
+
):
|
|
43
|
+
assert api.access_token(config) == "new"
|
|
44
|
+
assert config["refresh_token"] == "refresh"
|
|
45
|
+
save.assert_called_once_with(config)
|
|
46
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from kakao_self.cli import run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_configure_saves_values() -> None:
|
|
7
|
+
with patch("kakao_self.cli.load_config", return_value={}), patch(
|
|
8
|
+
"kakao_self.cli.save_config"
|
|
9
|
+
) as save:
|
|
10
|
+
result = run(
|
|
11
|
+
[
|
|
12
|
+
"configure",
|
|
13
|
+
"--rest-api-key",
|
|
14
|
+
"key",
|
|
15
|
+
"--default-link",
|
|
16
|
+
"https://example.com",
|
|
17
|
+
]
|
|
18
|
+
)
|
|
19
|
+
assert result == 0
|
|
20
|
+
saved = save.call_args.args[0]
|
|
21
|
+
assert saved["rest_api_key"] == "key"
|
|
22
|
+
assert saved["redirect_uri"] == "http://127.0.0.1:8765/callback"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_send_uses_configured_default_link() -> None:
|
|
26
|
+
config = {"rest_api_key": "key", "default_link": "https://example.com"}
|
|
27
|
+
with patch("kakao_self.cli.load_config", return_value=config), patch(
|
|
28
|
+
"kakao_self.cli.send_message"
|
|
29
|
+
) as send:
|
|
30
|
+
assert run(["send", "hello"]) == 0
|
|
31
|
+
send.assert_called_once_with(config, "hello", "https://example.com", None)
|
|
32
|
+
|