Skip to content

Commit 82e9855

Browse files
committed
Add toast alimtalk Client
1 parent 8a74bc9 commit 82e9855

File tree

1 file changed

+277
-0
lines changed

1 file changed

+277
-0
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# https://docs.nhncloud.com/ko/Notification/KakaoTalk%20Bizmessage/ko/alimtalk-api-guide/
2+
import datetime
3+
import functools
4+
import logging
5+
import typing
6+
import urllib.parse
7+
8+
import chalicelib.config as config_module
9+
import chalicelib.util.decorator_util as decorator_util
10+
import httpx
11+
import pydantic
12+
13+
logger = logging.getLogger(__name__)
14+
15+
# WL: 웹 링크, AL: 앱 링크, BK: 봇 키워드, BC: 상담톡 전환, BT: 봇 전환, BF: 비지니스폼
16+
ActionType = typing.Literal["WL", "AL", "BK", "BC", "BT", "BF"]
17+
18+
19+
class RecepientAction(pydantic.BaseModel):
20+
ordering: int
21+
chatExtra: str | None = None
22+
chatEvent: str | None = None
23+
target: str | None = None
24+
25+
26+
class RecepientButton(RecepientAction, pydantic.BaseModel):
27+
relayId: str | None = None
28+
oneClickId: str | None = None
29+
productId: str | None = None
30+
31+
32+
class MsgSendRequest(pydantic.BaseModel):
33+
class Recipient(pydantic.BaseModel):
34+
class ResendParameter(pydantic.BaseModel):
35+
isResend: bool
36+
resendType: typing.Literal["SMS", "LMS"] | None = None
37+
resendTitle: str | None = None
38+
resendContent: str | None = None
39+
resendSendNo: str
40+
41+
recipientNo: str = pydantic.Field(max_length=15)
42+
resendParameter: ResendParameter | None = None
43+
recipientGroupingKey: str | None = None
44+
45+
templateParameter: dict[str, str] | None = None
46+
buttons: list[RecepientButton] = pydantic.Field(default_factory=list)
47+
quickReplies: list[RecepientAction] = pydantic.Field(default_factory=list)
48+
49+
class MessageOption(pydantic.BaseModel):
50+
price: int | None = None
51+
currencyType: str | None = None
52+
53+
senderKey: str = pydantic.Field(max_length=40)
54+
templateCode: str = pydantic.Field(max_length=20)
55+
requestDate: pydantic.FutureDate | None = None
56+
senderGroupingKey: str | None = None
57+
createUser: str | None = None
58+
recipientList: list[Recipient] = pydantic.Field(default_factory=list)
59+
messageOption: MessageOption | None = None
60+
statsId: str | None = pydantic.Field(max_length=8, default=None)
61+
62+
@pydantic.field_validator("requestDate", mode="before")
63+
def validate_request_date(cls, v: datetime.datetime | None) -> datetime.datetime | None:
64+
if v and v.date() - datetime.date.today() > datetime.timedelta(days=60):
65+
raise ValueError("The request date should not be more than 60 days in the future.")
66+
67+
return v
68+
69+
@pydantic.field_serializer("requestDate", mode="plain", return_type=str)
70+
def serialize_request_date(v: datetime.datetime | None) -> str | None:
71+
return v.strftime("%Y-%m-%d %H:%M") if v else None
72+
73+
74+
class TemplateAtom(pydantic.BaseModel):
75+
title: str
76+
description: str
77+
78+
79+
class TemplateItemHighlight(TemplateAtom):
80+
imageUrl: pydantic.HttpUrl
81+
82+
83+
class TemplateItem(pydantic.BaseModel):
84+
list: list[TemplateAtom]
85+
summary: TemplateAtom
86+
87+
88+
class RepresentLink(pydantic.BaseModel):
89+
linkMo: pydantic.AnyUrl | str | None = pydantic.Field(max_length=500, default=None)
90+
linkPc: pydantic.AnyUrl | str | None = pydantic.Field(max_length=500, default=None)
91+
schemeIos: pydantic.AnyUrl | str | None = pydantic.Field(max_length=500, default=None)
92+
schemeAndroid: pydantic.AnyUrl | str | None = pydantic.Field(max_length=500, default=None)
93+
94+
95+
class RawMsgRecipientAction(RecepientAction, RepresentLink, pydantic.BaseModel):
96+
type: ActionType
97+
name: str = pydantic.Field(max_length=14)
98+
bizFormId: int | None = None
99+
100+
101+
class RawMsgRecepientButton(RawMsgRecipientAction, RecepientButton, pydantic.BaseModel):
102+
pluginId: str | None = None
103+
104+
105+
class RawMsgSendRequest(MsgSendRequest):
106+
class Recipient(MsgSendRequest.Recipient):
107+
templateParameter: None = None
108+
buttons: list[RawMsgRecepientButton] = pydantic.Field(default_factory=list)
109+
quickReplies: list[RawMsgRecipientAction] = pydantic.Field(default_factory=list)
110+
111+
templateTitle: str | None = None
112+
templateHeader: str | None = None
113+
templateItem: TemplateItem | None = None
114+
templateItemHighlight: TemplateItemHighlight | None = None
115+
templateRepresentLink: RepresentLink | None = None
116+
content: str = pydantic.Field(max_length=1000)
117+
118+
recipientList: list[Recipient] = pydantic.Field(default_factory=list)
119+
120+
121+
class TemplateAction(RepresentLink, pydantic.BaseModel):
122+
ordering: int
123+
type: ActionType
124+
name: str
125+
bizFormId: int | None = None
126+
127+
128+
class TemplateButton(TemplateAction, pydantic.BaseModel):
129+
pluginId: str | None = None
130+
131+
132+
class TemplateComment(pydantic.BaseModel):
133+
class Attachment(pydantic.BaseModel):
134+
originalFileName: str
135+
filePath: str
136+
137+
id: int
138+
content: str | None = None
139+
userName: str
140+
createdAt: datetime.datetime
141+
attachment: list[Attachment]
142+
status: str
143+
144+
145+
class Template(pydantic.BaseModel):
146+
plusFriendId: str
147+
plusFriendType: typing.Literal["NORMAL", "GROUP"]
148+
senderKey: str
149+
templateCode: str
150+
kakaoTemplateCode: str
151+
templateName: str
152+
# 템플릿 메시지 유형(BA: 기본형, EX: 부가 정보형, AD: 채널 추가형, MI: 복합형)
153+
templateMessageType: typing.Literal["BA", "EX", "AD", "MI"]
154+
templateEmphasizeType: typing.Literal["NONE", "TEXT", "IMAGE"]
155+
templateContent: str
156+
templateExtra: str | None = None
157+
templateAd: str | None = None
158+
templateTitle: str | None = None
159+
templateSubtitle: str | None = None
160+
templateHeader: str | None = None
161+
templateItem: TemplateItem | None = None
162+
templateItemHighlight: TemplateItemHighlight | None = None
163+
templateRepresentLink: RepresentLink | None = None
164+
templateImageName: str | None = None
165+
templateImageUrl: pydantic.HttpUrl | None = None
166+
buttons: list[TemplateButton]
167+
quickReplies: list[TemplateAction]
168+
comments: list[TemplateComment]
169+
# 템플릿 상태 코드(TSC01: 요청, TSC02: 검수 중, TSC03: 승인, TSC04: 반려)
170+
status: typing.Literal["TSC01", "TSC02", "TSC03", "TSC04"]
171+
statusName: str
172+
securityFlag: bool
173+
categoryCode: str
174+
createDate: datetime.datetime
175+
updateDate: datetime.datetime
176+
177+
178+
class ToastAlimtalkResponseHeader(pydantic.BaseModel):
179+
resultCode: int
180+
resultMessage: str
181+
isSuccessful: bool
182+
183+
184+
class MsgSendResponse(pydantic.BaseModel):
185+
class Message(pydantic.BaseModel):
186+
class SendResult(pydantic.BaseModel):
187+
recipientSeq: int
188+
recipientNo: str
189+
resultCode: int
190+
resultMessage: str
191+
recipientGroupingKey: str | None = None
192+
193+
requestId: str
194+
senderGroupingKey: str | None = None
195+
sendResults: list[SendResult]
196+
197+
header: ToastAlimtalkResponseHeader
198+
message: Message
199+
200+
201+
class TemplateCategoriesResponse(pydantic.BaseModel):
202+
class TemplateCategory(pydantic.BaseModel):
203+
class TemplateSubCategory(pydantic.BaseModel):
204+
code: str
205+
name: str
206+
groupName: str
207+
inclusion: str
208+
exclusion: str
209+
210+
name: str
211+
subCategories: list[TemplateSubCategory]
212+
213+
header: ToastAlimtalkResponseHeader
214+
categories: list[TemplateCategory]
215+
216+
217+
class TemplateListQueryRequest(pydantic.BaseModel):
218+
templateCode: str | None = None
219+
templateName: str | None = None
220+
templateStatus: str | None = None
221+
pageNum: int = 1
222+
pageSize: int = pydantic.Field(ge=1, le=1000, default=1000)
223+
224+
225+
class TemplateListResponse(pydantic.BaseModel):
226+
class TemplateList(pydantic.BaseModel):
227+
templates: list[Template]
228+
totalCount: int
229+
230+
header: ToastAlimtalkResponseHeader
231+
templateListResponse: TemplateList
232+
233+
234+
class TemplateDeletionResponse(pydantic.BaseModel):
235+
header: ToastAlimtalkResponseHeader
236+
237+
238+
class ToastAlimTalkError(Exception):
239+
pass
240+
241+
242+
class ToastAlimTalkClient(pydantic.BaseModel):
243+
244+
exc_cls: type[Exception] = ToastAlimTalkError
245+
246+
@functools.cached_property
247+
def config(self) -> config_module.ToastConfig:
248+
return config_module.config.toast
249+
250+
@functools.cached_property
251+
def session(self) -> httpx.Client:
252+
if not config_module.config.toast.is_configured():
253+
raise ToastAlimTalkError("Toast configuration is not set up properly.")
254+
255+
return config_module.config.toast.get_session("alimtalk")
256+
257+
@decorator_util.retry
258+
def send_alimtalk(self, payload: MsgSendRequest | RawMsgSendRequest) -> MsgSendResponse:
259+
url = "/messages" if payload.__class__ == MsgSendRequest else "/raw-messages"
260+
response = self.session.post(url=url, json=payload.model_dump(mode="json")).raise_for_status()
261+
return MsgSendResponse.model_validate_json(response.content)
262+
263+
@decorator_util.retry
264+
def get_template_categories(self) -> TemplateCategoriesResponse:
265+
url = "/template/categories"
266+
return TemplateCategoriesResponse.model_validate_json(self.session.get(url=url).raise_for_status().content)
267+
268+
@decorator_util.retry
269+
def get_template_list(self, query_params: TemplateListQueryRequest | None = None) -> TemplateListResponse:
270+
query_data: dict[str, str | int] = (query_params or TemplateListQueryRequest()).model_dump(exclude_none=True)
271+
url = f"/senders/{self.config.sender_key.get_secret_value()}/templates?{urllib.parse.urlencode(query_data)}"
272+
return TemplateListResponse.model_validate_json(self.session.get(url=url).raise_for_status().content)
273+
274+
@decorator_util.retry
275+
def delete_template(self, template_code: str) -> TemplateDeletionResponse:
276+
url = f"/senders/{self.config.sender_key.get_secret_value()}/templates/{template_code}"
277+
return TemplateDeletionResponse.model_validate_json(self.session.delete(url=url).raise_for_status().content)

0 commit comments

Comments
 (0)