sendgo-python 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
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.
- sendgo/__init__.py +37 -1
- sendgo/_payload.py +34 -0
- sendgo/brand_template.py +102 -0
- sendgo/client.py +22 -0
- sendgo/http_client.py +88 -0
- sendgo/kakao_image.py +67 -0
- sendgo/kakao_sender.py +127 -0
- sendgo/message_template.py +79 -0
- sendgo/notice_template.py +190 -0
- sendgo/rejected_number.py +38 -0
- sendgo/sender_registration.py +150 -0
- sendgo/webhook.py +87 -0
- {sendgo_python-1.2.0.dist-info → sendgo_python-1.3.0.dist-info}/METADATA +285 -2
- sendgo_python-1.3.0.dist-info/RECORD +23 -0
- sendgo_python-1.2.0.dist-info/RECORD +0 -14
- {sendgo_python-1.2.0.dist-info → sendgo_python-1.3.0.dist-info}/WHEEL +0 -0
- {sendgo_python-1.2.0.dist-info → sendgo_python-1.3.0.dist-info}/top_level.txt +0 -0
sendgo/__init__.py
CHANGED
|
@@ -19,11 +19,47 @@ Sendgo Python SDK — 카카오 알림톡/친구톡, SMS/LMS/MMS
|
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
from .brand_message import BrandMessageService
|
|
22
|
+
from .brand_template import BrandTemplateService
|
|
23
|
+
from .kakao_image import (
|
|
24
|
+
MULTI_IMAGE_TYPES,
|
|
25
|
+
SINGLE_IMAGE_TYPES,
|
|
26
|
+
KakaoImageService,
|
|
27
|
+
)
|
|
28
|
+
from .kakao_sender import KakaoSenderService
|
|
29
|
+
from .message_template import MessageTemplateService
|
|
30
|
+
from .notice_template import NoticeTemplateService
|
|
31
|
+
from .rejected_number import RejectedNumberService
|
|
32
|
+
from .sender_registration import (
|
|
33
|
+
IDENTITY_DOCUMENT_TYPES,
|
|
34
|
+
REGISTRABLE_TYPES,
|
|
35
|
+
SenderRegistrationService,
|
|
36
|
+
)
|
|
37
|
+
from .webhook import WEBHOOK_EVENTS, WebhookService, verify_signature
|
|
22
38
|
from .short_url import ShortUrlService
|
|
23
39
|
from .client import Sendgo
|
|
24
40
|
from .exceptions import SendgoError
|
|
25
41
|
|
|
26
|
-
__all__ = [
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Sendgo",
|
|
44
|
+
"SendgoError",
|
|
45
|
+
"BrandMessageService",
|
|
46
|
+
"ShortUrlService",
|
|
47
|
+
# 관리 API (v2 전용) — 등록 · 심사
|
|
48
|
+
"KakaoSenderService",
|
|
49
|
+
"NoticeTemplateService",
|
|
50
|
+
"BrandTemplateService",
|
|
51
|
+
"SenderRegistrationService",
|
|
52
|
+
"MessageTemplateService",
|
|
53
|
+
"KakaoImageService",
|
|
54
|
+
"RejectedNumberService",
|
|
55
|
+
"WebhookService",
|
|
56
|
+
"REGISTRABLE_TYPES",
|
|
57
|
+
"IDENTITY_DOCUMENT_TYPES",
|
|
58
|
+
"SINGLE_IMAGE_TYPES",
|
|
59
|
+
"MULTI_IMAGE_TYPES",
|
|
60
|
+
"WEBHOOK_EVENTS",
|
|
61
|
+
"verify_signature",
|
|
62
|
+
]
|
|
27
63
|
try:
|
|
28
64
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
29
65
|
|
sendgo/_payload.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""요청 페이로드 헬퍼.
|
|
2
|
+
|
|
3
|
+
Python 쪽 인자는 snake_case, Sendgo API 는 camelCase 다. 서비스마다 매핑을
|
|
4
|
+
손으로 적으면 필드가 늘 때마다 빠뜨리는 곳이 생기므로 한 곳에서 변환한다.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# 자동 변환이 틀리는 예외들. `additional_content` 는 서버가 그대로 받는다.
|
|
12
|
+
_KEEP_AS_IS = {"additional_content"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_camel(key: str) -> str:
|
|
16
|
+
"""`template_name` → `templateName`. 이미 camelCase 면 그대로 둔다."""
|
|
17
|
+
if key in _KEEP_AS_IS or "_" not in key:
|
|
18
|
+
return key
|
|
19
|
+
|
|
20
|
+
head, *rest = key.split("_")
|
|
21
|
+
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def camelize(payload: dict[str, Any], *, drop_none: bool = True) -> dict[str, Any]:
|
|
25
|
+
"""키를 camelCase 로 바꾸고, 기본적으로 ``None`` 값을 걷어낸다.
|
|
26
|
+
|
|
27
|
+
``None`` 을 남기면 서버가 "빈 값으로 덮어쓰기"로 읽어 기존 값을 지운다.
|
|
28
|
+
명시적으로 비우려면 빈 문자열을 넘긴다.
|
|
29
|
+
"""
|
|
30
|
+
return {
|
|
31
|
+
to_camel(key): value
|
|
32
|
+
for key, value in payload.items()
|
|
33
|
+
if not (drop_none and value is None)
|
|
34
|
+
}
|
sendgo/brand_template.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""브랜드메시지(구 친구톡) 템플릿 관리."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ._payload import camelize
|
|
9
|
+
from .http_client import HttpClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrandTemplateService:
|
|
13
|
+
"""브랜드메시지 템플릿 서비스. v2 전용이며 **기업(Team) 계정 전용**이다.
|
|
14
|
+
|
|
15
|
+
알림톡 템플릿과 달리 **검수 요청 단계가 없다.** 등록하면 카카오가 바로
|
|
16
|
+
상태를 돌려주고 그 값이 ``status`` 로 나온다.
|
|
17
|
+
|
|
18
|
+
``template_type`` 은 친구톡 표기(``FT``/``FI``/``FW``/``FL``/``FC``/``FM``/
|
|
19
|
+
``FP``/``FA``)를 그대로 쓴다 — 서버가 chatBubbleType 으로 변환한다.
|
|
20
|
+
|
|
21
|
+
Example::
|
|
22
|
+
|
|
23
|
+
created = client.brand_templates.create(
|
|
24
|
+
kakao_sender_key=kakao_sender_key,
|
|
25
|
+
template_name="여름 세일 안내",
|
|
26
|
+
template_type="FI",
|
|
27
|
+
template_content="여름 세일이 시작되었습니다.",
|
|
28
|
+
image_url="https://mud-kage.kakao.com/....jpg",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 동보 발송(targeting="F")에는 변수가 없는 템플릿만 쓸 수 있다
|
|
32
|
+
created["data"]["template"]["containsVariables"]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_RESOURCE = "brand-templates"
|
|
36
|
+
|
|
37
|
+
def __init__(self, http: HttpClient) -> None:
|
|
38
|
+
self._http = http
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
kakao_sender_key: str | None = None,
|
|
44
|
+
search: str | None = None,
|
|
45
|
+
count: int | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""목록 조회."""
|
|
48
|
+
return self._http.get(
|
|
49
|
+
self._RESOURCE,
|
|
50
|
+
{"kakaoSenderKey": kakao_sender_key, "search": search, "count": count},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def show(self, template_code: str) -> dict[str, Any]:
|
|
54
|
+
"""상세 조회. sendgo 코드(``KFT-...``)와 카카오 브랜드 템플릿 코드 둘 다 받는다."""
|
|
55
|
+
return self._http.get(self._path(template_code))
|
|
56
|
+
|
|
57
|
+
def create(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
kakao_sender_key: str,
|
|
61
|
+
template_name: str,
|
|
62
|
+
template_type: str,
|
|
63
|
+
**extra: Any,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""템플릿 등록. 선택 필드는 ``extra`` 로 넘긴다 (snake_case 자동 변환)."""
|
|
66
|
+
return self._http.post(
|
|
67
|
+
self._RESOURCE,
|
|
68
|
+
camelize(
|
|
69
|
+
{
|
|
70
|
+
"kakao_sender_key": kakao_sender_key,
|
|
71
|
+
"template_name": template_name,
|
|
72
|
+
"template_type": template_type,
|
|
73
|
+
**extra,
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def update(self, template_code: str, **fields: Any) -> dict[str, Any]:
|
|
79
|
+
"""템플릿 수정. 발신프로필은 바꿀 수 없다."""
|
|
80
|
+
return self._http.put(self._path(template_code), camelize(fields))
|
|
81
|
+
|
|
82
|
+
def delete(self, template_code: str) -> dict[str, Any]:
|
|
83
|
+
"""템플릿 삭제. 알림톡과 달리 카카오 쪽에서도 실제로 삭제된다."""
|
|
84
|
+
return self._http.delete(self._path(template_code))
|
|
85
|
+
|
|
86
|
+
def sync(self, template_code: str) -> dict[str, Any]:
|
|
87
|
+
"""동기화. 카카오 쪽에서 이미 삭제됐으면 로컬에서도 제거하고
|
|
88
|
+
``data.deleted: True`` 를 반환한다."""
|
|
89
|
+
return self._http.post(f"{self._path(template_code)}/sync", {})
|
|
90
|
+
|
|
91
|
+
def import_from_sender(self, kakao_sender_key: str) -> dict[str, Any]:
|
|
92
|
+
"""발신프로필 단위 가져오기 — 카카오 쪽에 이미 있는 템플릿을 들여온다.
|
|
93
|
+
|
|
94
|
+
(``import`` 는 예약어라 메서드 이름에 쓸 수 없다.)
|
|
95
|
+
"""
|
|
96
|
+
return self._http.post(
|
|
97
|
+
f"{self._RESOURCE}/import",
|
|
98
|
+
{"kakaoSenderKey": kakao_sender_key},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _path(self, template_code: str) -> str:
|
|
102
|
+
return f"{self._RESOURCE}/{quote(template_code, safe='')}"
|
sendgo/client.py
CHANGED
|
@@ -2,11 +2,19 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from .alimtalk import AlimtalkService
|
|
4
4
|
from .brand_message import BrandMessageService
|
|
5
|
+
from .brand_template import BrandTemplateService
|
|
5
6
|
from .friendtalk import FriendtalkService
|
|
6
7
|
from .http_client import HttpClient
|
|
8
|
+
from .kakao_image import KakaoImageService
|
|
9
|
+
from .kakao_sender import KakaoSenderService
|
|
10
|
+
from .message_template import MessageTemplateService
|
|
11
|
+
from .notice_template import NoticeTemplateService
|
|
12
|
+
from .rejected_number import RejectedNumberService
|
|
13
|
+
from .sender_registration import SenderRegistrationService
|
|
7
14
|
from .short_url import ShortUrlService
|
|
8
15
|
from .sms import SmsService
|
|
9
16
|
from .token_manager import TokenManager
|
|
17
|
+
from .webhook import WebhookService
|
|
10
18
|
|
|
11
19
|
|
|
12
20
|
class Sendgo:
|
|
@@ -55,3 +63,17 @@ class Sendgo:
|
|
|
55
63
|
# 짧은 URL — 링크 단축과 클릭 반응 분석. v2 전용.
|
|
56
64
|
self.short_url = ShortUrlService(http)
|
|
57
65
|
self.sms = SmsService(http, sms_sender_key)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------ 관리 API (v2)
|
|
68
|
+
# 콘솔에서만 되던 등록·심사. 발송과 달리 대부분 즉시 완료되지 않는다 —
|
|
69
|
+
# 등록 성공은 "접수됨"이지 "사용 가능"이 아니다.
|
|
70
|
+
# 카카오 채널 등록의 인증번호와 휴대폰 발신번호의 본인인증은 사람이
|
|
71
|
+
# 개입해야 하므로 API 로 대체되지 않는다.
|
|
72
|
+
self.kakao_senders = KakaoSenderService(http)
|
|
73
|
+
self.notice_templates = NoticeTemplateService(http)
|
|
74
|
+
self.brand_templates = BrandTemplateService(http)
|
|
75
|
+
self.sender_registration = SenderRegistrationService(http)
|
|
76
|
+
self.message_templates = MessageTemplateService(http)
|
|
77
|
+
self.kakao_images = KakaoImageService(http)
|
|
78
|
+
self.rejected_numbers = RejectedNumberService(http)
|
|
79
|
+
self.webhook = WebhookService(http)
|
sendgo/http_client.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
import requests
|
|
6
8
|
|
|
@@ -23,10 +25,35 @@ class HttpClient:
|
|
|
23
25
|
"""GET request, used by the campaign lookup endpoints."""
|
|
24
26
|
return self._request("GET", path, params=params, is_retry=False)
|
|
25
27
|
|
|
28
|
+
def put(self, path: str, body: dict) -> dict:
|
|
29
|
+
return self._request("PUT", path, body=body, is_retry=False)
|
|
30
|
+
|
|
31
|
+
def patch(self, path: str, body: dict) -> dict:
|
|
32
|
+
return self._request("PATCH", path, body=body, is_retry=False)
|
|
33
|
+
|
|
26
34
|
def delete(self, path: str) -> dict:
|
|
27
35
|
"""`_request()` drives the verb, so DELETE only needs to skip the body."""
|
|
28
36
|
return self._request("DELETE", path, is_retry=False)
|
|
29
37
|
|
|
38
|
+
def post_multipart(
|
|
39
|
+
self,
|
|
40
|
+
path: str,
|
|
41
|
+
fields: dict | None = None,
|
|
42
|
+
files: dict | None = None,
|
|
43
|
+
) -> dict:
|
|
44
|
+
"""multipart/form-data POST — 서류·이미지 첨부가 있는 관리 API 전용.
|
|
45
|
+
|
|
46
|
+
발신번호 등록과 이미지 템플릿은 JSON 으로 보낼 수 없다. multipart 에는
|
|
47
|
+
배열도 불리언도 없으므로, 리스트/딕트는 JSON 문자열로 눌러 보낸다 —
|
|
48
|
+
서버가 그렇게 받아 읽는다.
|
|
49
|
+
|
|
50
|
+
``files`` 값은 ``requests`` 가 받는 형태를 그대로 쓴다: 열린 파일
|
|
51
|
+
객체, ``(filename, fileobj)``, ``(filename, fileobj, content_type)``.
|
|
52
|
+
같은 필드에 여러 파일을 붙이려면 리스트로 넘긴다 — 서버가
|
|
53
|
+
``attachments[0]`` 형태를 기대하므로 인덱스를 붙여 보낸다.
|
|
54
|
+
"""
|
|
55
|
+
return self._multipart_request(path, fields or {}, files or {}, is_retry=False)
|
|
56
|
+
|
|
30
57
|
def _request(
|
|
31
58
|
self,
|
|
32
59
|
method: str,
|
|
@@ -61,6 +88,67 @@ class HttpClient:
|
|
|
61
88
|
|
|
62
89
|
return response_body
|
|
63
90
|
|
|
91
|
+
def _multipart_request(
|
|
92
|
+
self,
|
|
93
|
+
path: str,
|
|
94
|
+
fields: dict,
|
|
95
|
+
files: dict,
|
|
96
|
+
*,
|
|
97
|
+
is_retry: bool,
|
|
98
|
+
) -> dict:
|
|
99
|
+
url = f"{self._base_url}/api/{self._api_version}/{path}"
|
|
100
|
+
token = self._token_manager.get_token()
|
|
101
|
+
|
|
102
|
+
data: dict[str, str] = {}
|
|
103
|
+
for key, value in fields.items():
|
|
104
|
+
if value is None:
|
|
105
|
+
continue
|
|
106
|
+
if isinstance(value, bool):
|
|
107
|
+
data[key] = "1" if value else "0"
|
|
108
|
+
elif isinstance(value, (list, dict)):
|
|
109
|
+
data[key] = json.dumps(value, ensure_ascii=False)
|
|
110
|
+
else:
|
|
111
|
+
data[key] = str(value)
|
|
112
|
+
|
|
113
|
+
payload_files: list[tuple[str, Any]] = []
|
|
114
|
+
for key, value in files.items():
|
|
115
|
+
if value is None:
|
|
116
|
+
continue
|
|
117
|
+
if isinstance(value, list):
|
|
118
|
+
for index, entry in enumerate(value):
|
|
119
|
+
payload_files.append((f"{key}[{index}]", entry))
|
|
120
|
+
else:
|
|
121
|
+
payload_files.append((key, value))
|
|
122
|
+
|
|
123
|
+
# 세션 기본 헤더의 Content-Type: application/json 을 반드시 비워야 한다.
|
|
124
|
+
# 남겨 두면 requests 가 붙이는 multipart boundary 를 덮어써서 서버가
|
|
125
|
+
# 본문을 통째로 파싱하지 못한다.
|
|
126
|
+
resp = self._session.request(
|
|
127
|
+
"POST",
|
|
128
|
+
url,
|
|
129
|
+
data=data or None,
|
|
130
|
+
files=payload_files or None,
|
|
131
|
+
headers={
|
|
132
|
+
"Authorization": self._make_bearer(token),
|
|
133
|
+
"Accept": "application/json",
|
|
134
|
+
"Content-Type": None,
|
|
135
|
+
},
|
|
136
|
+
# 파일 업로드는 JSON 요청보다 오래 걸린다.
|
|
137
|
+
timeout=60,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
response_body = resp.json() if resp.content else {}
|
|
141
|
+
|
|
142
|
+
if not resp.ok:
|
|
143
|
+
error_code = response_body.get("code")
|
|
144
|
+
endpoint = path.split("/")[-1]
|
|
145
|
+
if not is_retry and self._token_manager.should_refresh(resp.status_code, error_code):
|
|
146
|
+
self._token_manager.invalidate()
|
|
147
|
+
return self._multipart_request(path, fields, files, is_retry=True)
|
|
148
|
+
raise SendgoError.from_response(resp.status_code, response_body, endpoint, self._api_version)
|
|
149
|
+
|
|
150
|
+
return response_body
|
|
151
|
+
|
|
64
152
|
def _make_bearer(self, token: str) -> str:
|
|
65
153
|
if self._api_version == "v2":
|
|
66
154
|
return f"Bearer {token}"
|
sendgo/kakao_image.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""카카오 이미지 업로드 — 브랜드메시지 템플릿에 넣을 URL 발급."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .http_client import HttpClient
|
|
9
|
+
|
|
10
|
+
#: 파일 하나를 올리고 URL 하나를 받는 유형.
|
|
11
|
+
SINGLE_IMAGE_TYPES = (
|
|
12
|
+
"alimtalk",
|
|
13
|
+
"alimtalk_highlight",
|
|
14
|
+
"default",
|
|
15
|
+
"wide",
|
|
16
|
+
"wide_item_list_first",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
#: 파일 여러 개를 올리고 URL 목록을 받는 유형과 최대 개수.
|
|
20
|
+
MULTI_IMAGE_TYPES = {
|
|
21
|
+
"wide_item_list": 4,
|
|
22
|
+
"carousel_feed": 10,
|
|
23
|
+
"carousel_commerce": 11,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KakaoImageService:
|
|
28
|
+
"""카카오 이미지 업로드. v2 전용이며 **기업(Team) 계정 전용**이다.
|
|
29
|
+
|
|
30
|
+
브랜드메시지 템플릿의 ``imageUrl`` 은 아무 URL 이나 되는 게 아니라
|
|
31
|
+
**카카오가 호스팅하는 URL** 이어야 한다. 그 URL 을 얻는 방법이 이
|
|
32
|
+
업로드뿐이다.
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
with open("banner.jpg", "rb") as f:
|
|
37
|
+
uploaded = client.kakao_images.upload(
|
|
38
|
+
"default", ("banner.jpg", f, "image/jpeg")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
client.brand_templates.create(
|
|
42
|
+
kakao_sender_key=kakao_sender_key,
|
|
43
|
+
template_name="여름 세일 안내",
|
|
44
|
+
template_type="FI",
|
|
45
|
+
image_url=uploaded["data"]["imageUrl"],
|
|
46
|
+
)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_RESOURCE = "kakao-images"
|
|
50
|
+
|
|
51
|
+
def __init__(self, http: HttpClient) -> None:
|
|
52
|
+
self._http = http
|
|
53
|
+
|
|
54
|
+
def types(self) -> dict[str, Any]:
|
|
55
|
+
"""업로드 가능한 유형과 제약."""
|
|
56
|
+
return self._http.get(f"{self._RESOURCE}/types")
|
|
57
|
+
|
|
58
|
+
def upload(self, image_type: str, image: Any) -> dict[str, Any]:
|
|
59
|
+
"""단일 이미지 업로드. jpg/png, 2MB 이하. ``data.imageUrl`` 을 받는다."""
|
|
60
|
+
return self._http.post_multipart(self._path(image_type), files={"image": image})
|
|
61
|
+
|
|
62
|
+
def upload_many(self, image_type: str, images: list[Any]) -> dict[str, Any]:
|
|
63
|
+
"""다중 이미지 업로드. 유형별 최대 개수가 다르다."""
|
|
64
|
+
return self._http.post_multipart(self._path(image_type), files={"images": images})
|
|
65
|
+
|
|
66
|
+
def _path(self, image_type: str) -> str:
|
|
67
|
+
return f"{self._RESOURCE}/{quote(image_type, safe='')}"
|
sendgo/kakao_sender.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""카카오 발신프로필(채널) 관리 — 등록 · 동기화 · 브랜드메시지 타겟팅 신청."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .http_client import HttpClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KakaoSenderService:
|
|
12
|
+
"""카카오 채널 관리 서비스. v2 전용이며 **기업(Team) 계정 전용**이다.
|
|
13
|
+
|
|
14
|
+
채널 등록은 두 단계다. 카카오가 인증번호를 채널 관리자 **휴대폰으로 SMS
|
|
15
|
+
발송**하므로 완전 무인 자동화는 불가능하다 — 사람이 문자를 받아
|
|
16
|
+
:meth:`create` 에 넣어야 한다.
|
|
17
|
+
|
|
18
|
+
Example::
|
|
19
|
+
|
|
20
|
+
# 1단계 — 관리자 휴대폰으로 인증번호 발송 (응답에 번호는 없다)
|
|
21
|
+
client.kakao_senders.request_token("@my-channel", "01012345678")
|
|
22
|
+
|
|
23
|
+
# 2단계 — 사람이 받은 인증번호로 발신프로필 생성
|
|
24
|
+
created = client.kakao_senders.create(
|
|
25
|
+
token="123456",
|
|
26
|
+
yellow_id="@my-channel",
|
|
27
|
+
phone_number="01012345678",
|
|
28
|
+
category_code="001001",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
kakao_sender_key = created["data"]["sender"]["kakaoSenderKey"]
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_RESOURCE = "kakao-senders"
|
|
35
|
+
|
|
36
|
+
def __init__(self, http: HttpClient) -> None:
|
|
37
|
+
self._http = http
|
|
38
|
+
|
|
39
|
+
def request_token(self, yellow_id: str, phone_number: str) -> dict[str, Any]:
|
|
40
|
+
"""1단계 — 채널 인증번호 발송.
|
|
41
|
+
|
|
42
|
+
응답에 인증번호는 들어있지 않다. 카카오가 ``phone_number`` 로 SMS 를 보낸다.
|
|
43
|
+
"""
|
|
44
|
+
return self._http.post(
|
|
45
|
+
f"{self._RESOURCE}/token",
|
|
46
|
+
{"yellowId": yellow_id, "phoneNumber": phone_number},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def create(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
token: str,
|
|
53
|
+
yellow_id: str,
|
|
54
|
+
phone_number: str,
|
|
55
|
+
category_code: str,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""2단계 — 발신프로필 등록.
|
|
58
|
+
|
|
59
|
+
이미 등록된 채널을 다시 등록해도 오류가 아니다. 카카오가 같은 senderKey 를
|
|
60
|
+
돌려주고 서버가 기존 행을 갱신한다.
|
|
61
|
+
"""
|
|
62
|
+
return self._http.post(
|
|
63
|
+
self._RESOURCE,
|
|
64
|
+
{
|
|
65
|
+
"token": token,
|
|
66
|
+
"yellowId": yellow_id,
|
|
67
|
+
"phoneNumber": phone_number,
|
|
68
|
+
"categoryCode": category_code,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def list(self) -> dict[str, Any]:
|
|
73
|
+
"""목록 조회."""
|
|
74
|
+
return self._http.get(self._RESOURCE)
|
|
75
|
+
|
|
76
|
+
def show(self, kakao_sender_key: str) -> dict[str, Any]:
|
|
77
|
+
"""상세 조회."""
|
|
78
|
+
return self._http.get(f"{self._RESOURCE}/{quote(kakao_sender_key, safe='')}")
|
|
79
|
+
|
|
80
|
+
def categories(self, category_code: str | None = None) -> dict[str, Any]:
|
|
81
|
+
"""카테고리 조회. 등록 시 ``category_code`` 로 넣을 값이다."""
|
|
82
|
+
return self._http.get(
|
|
83
|
+
f"{self._RESOURCE}/categories",
|
|
84
|
+
{"categoryCode": category_code} if category_code else None,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def sync(self, kakao_sender_key: str | None = None) -> dict[str, Any]:
|
|
88
|
+
"""상태 동기화. 키를 주면 단건, 없으면 팀 전체.
|
|
89
|
+
|
|
90
|
+
채널이 카카오 쪽에서 차단·휴면되면 발송이 조용히 실패하기 시작한다.
|
|
91
|
+
그 사실을 먼저 알 방법은 이 호출뿐이므로 하루 한 번 정도 돌리는 게 좋다.
|
|
92
|
+
"""
|
|
93
|
+
path = (
|
|
94
|
+
f"{self._RESOURCE}/{quote(kakao_sender_key, safe='')}/sync"
|
|
95
|
+
if kakao_sender_key
|
|
96
|
+
else f"{self._RESOURCE}/sync"
|
|
97
|
+
)
|
|
98
|
+
return self._http.post(path, {})
|
|
99
|
+
|
|
100
|
+
def upload_brand_message_evidence(
|
|
101
|
+
self,
|
|
102
|
+
kakao_sender_key: str,
|
|
103
|
+
evidence: Any,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""브랜드메시지 M 신청에 필요한 광고성 정보 수신동의 증적자료 업로드.
|
|
106
|
+
|
|
107
|
+
jpg/png, 5MB 이하. ``evidence`` 는 ``requests`` 가 받는 형태를 그대로 쓴다
|
|
108
|
+
— 열린 파일 객체나 ``(filename, fileobj)`` 튜플.
|
|
109
|
+
"""
|
|
110
|
+
return self._http.post_multipart(
|
|
111
|
+
f"{self._RESOURCE}/{quote(kakao_sender_key, safe='')}/brand-message/evidence",
|
|
112
|
+
files={"evidence": evidence},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def apply_brand_message_targeting(
|
|
116
|
+
self,
|
|
117
|
+
kakao_sender_key: str,
|
|
118
|
+
target_type: str,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""브랜드메시지 ``M``(마케팅) / ``N``(정보성) 사용 신청.
|
|
121
|
+
|
|
122
|
+
결과는 즉시 확정되지 않는다. 발신프로필의 ``brandMessageStatus`` 로 확인한다.
|
|
123
|
+
"""
|
|
124
|
+
return self._http.post(
|
|
125
|
+
f"{self._RESOURCE}/{quote(kakao_sender_key, safe='')}/brand-message/apply",
|
|
126
|
+
{"targetType": target_type},
|
|
127
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""문자(SMS/LMS/MMS) 상용구 템플릿."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ._payload import camelize
|
|
9
|
+
from .http_client import HttpClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageTemplateService:
|
|
13
|
+
"""문자 템플릿 서비스. v2 전용.
|
|
14
|
+
|
|
15
|
+
카카오 템플릿과 달리 **검수가 없어** 만들면 바로 쓸 수 있고, 기업 계정이
|
|
16
|
+
아니어도 된다.
|
|
17
|
+
|
|
18
|
+
Example::
|
|
19
|
+
|
|
20
|
+
client.message_templates.create(
|
|
21
|
+
message_tran_type="LMS",
|
|
22
|
+
message_tran_subject="주문 안내",
|
|
23
|
+
message_tran_msg="주문이 접수되었습니다.",
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_RESOURCE = "message-templates"
|
|
28
|
+
|
|
29
|
+
def __init__(self, http: HttpClient) -> None:
|
|
30
|
+
self._http = http
|
|
31
|
+
|
|
32
|
+
def list(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
message_type: str | None = None,
|
|
36
|
+
search: str | None = None,
|
|
37
|
+
count: int | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""목록 조회."""
|
|
40
|
+
return self._http.get(
|
|
41
|
+
self._RESOURCE,
|
|
42
|
+
{"messageType": message_type, "search": search, "count": count},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def show(self, template_key: str) -> dict[str, Any]:
|
|
46
|
+
"""상세 조회."""
|
|
47
|
+
return self._http.get(self._path(template_key))
|
|
48
|
+
|
|
49
|
+
def create(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
message_tran_type: str,
|
|
53
|
+
message_tran_msg: str,
|
|
54
|
+
message_tran_subject: str | None = None,
|
|
55
|
+
is_favorite: bool = False,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""등록. LMS·MMS 는 ``message_tran_subject`` 가 필수다."""
|
|
58
|
+
return self._http.post(
|
|
59
|
+
self._RESOURCE,
|
|
60
|
+
camelize(
|
|
61
|
+
{
|
|
62
|
+
"message_tran_type": message_tran_type,
|
|
63
|
+
"message_tran_msg": message_tran_msg,
|
|
64
|
+
"message_tran_subject": message_tran_subject,
|
|
65
|
+
"is_favorite": is_favorite,
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def update(self, template_key: str, **fields: Any) -> dict[str, Any]:
|
|
71
|
+
"""수정."""
|
|
72
|
+
return self._http.put(self._path(template_key), camelize(fields))
|
|
73
|
+
|
|
74
|
+
def delete(self, template_key: str) -> dict[str, Any]:
|
|
75
|
+
"""삭제 (소프트 삭제 — 목록에서만 사라진다)."""
|
|
76
|
+
return self._http.delete(self._path(template_key))
|
|
77
|
+
|
|
78
|
+
def _path(self, template_key: str) -> str:
|
|
79
|
+
return f"{self._RESOURCE}/{quote(template_key, safe='')}"
|