From ff4f0740f834d9b0d785457966c3b219937c4258 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 12:55:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fetcher.py=EC=97=90=EC=84=9C=20HTTPTranspor?= =?UTF-8?q?t=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20string=5Fdate=5Ftransfer.py=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0,?= =?UTF-8?q?=20kakao=5Foption.py=EC=97=90=20Bms=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20message.py=EC=97=90=EC=84=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,=20voice=5Foption.py=20?= =?UTF-8?q?=EB=B0=8F=20bms.py=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20GetGroupsCriteriaType=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solapi/lib/fetcher.py | 10 +++++++-- solapi/lib/string_date_transfer.py | 9 +++++++- solapi/model/__init__.py | 2 ++ solapi/model/kakao/kakao_option.py | 15 +++++++++++-- solapi/model/request/__init__.py | 2 +- solapi/model/request/groups/get_groups.py | 2 +- solapi/model/request/kakao/bms.py | 12 ++++++++++ solapi/model/request/kakao/kakao_option.py | 2 ++ solapi/model/request/message.py | 26 +++++++++++++++++++++- solapi/model/request/voice/voice_option.py | 18 +++++++++++++++ solapi/services/message_service.py | 4 ++-- 11 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 solapi/model/request/kakao/bms.py create mode 100644 solapi/model/request/voice/voice_option.py diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py index 85b3002..6b74048 100644 --- a/solapi/lib/fetcher.py +++ b/solapi/lib/fetcher.py @@ -39,11 +39,17 @@ def default_fetcher( headers = { "Authorization": authorization_header_data, "Content-Type": "application/json", + "Connection": "keep-alive", } - with httpx.Client() as client: + transport = httpx.HTTPTransport(retries=3) + + with httpx.Client(transport=transport) as client: response: Response = client.request( - method=request["method"], url=request["url"], headers=headers, json=data + method=request["method"], + url=request["url"], + headers=headers, + json=data, ) # 4xx 에러 처리: 클라이언트 오류일 경우 diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index 8b1c257..ea27406 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -27,7 +27,14 @@ def format_iso(date: datetime) -> str: """ utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone utc_offset = timedelta(seconds=-utc_offset_sec) - return date.replace(tzinfo=timezone(offset=utc_offset)).isoformat() + local_tz = timezone(offset=utc_offset) + + if date.tzinfo is None: + date = date.replace(tzinfo=local_tz) + else: + date = date.astimezone(local_tz) + + return date.isoformat() def parse_iso(date_string: str) -> datetime: diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index 91e1f0a..b363c20 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -1,6 +1,7 @@ from .kakao.kakao_option import KakaoOption from .message_type import MessageType from .request.groups.get_groups import GetGroupsRequest +from .request.kakao.bms import Bms from .request.message import Message as RequestMessage from .request.send_message_request import SendRequestConfig from .response.message import Message as ResponseMessage @@ -12,4 +13,5 @@ "KakaoOption", "GetGroupsRequest", "MessageType", + "Bms", ] diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index 348b665..1c977ea 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, field_validator from pydantic.alias_generators import to_camel +from solapi.model.request.kakao.bms import Bms + class KakaoOption(BaseModel): pf_id: Optional[str] = None @@ -11,6 +13,7 @@ class KakaoOption(BaseModel): variables: Optional[dict[str, str]] = None disable_sms: bool = False image_id: Optional[str] = None + bms: Optional[Bms] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -18,5 +21,13 @@ class KakaoOption(BaseModel): @classmethod def stringify_values(cls, v: Mapping[str, object]): if isinstance(v, Mapping): - # 모든 value를 str로 캐스팅 - return {k: str(val) for k, val in v.items()} + # 키값을 #{변수명} 형태로 변환하고 모든 value를 str로 캐스팅 + processed_dict = {} + for k, val in v.items(): + # 키가 이미 #{변수명} 형태가 아니면 자동으로 감싸기 + if not (k.startswith("#{") and k.endswith("}")): + processed_key = f"#{{{k}}}" + else: + processed_key = k + processed_dict[processed_key] = str(val) + return processed_dict diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index 39bc86c..0fe91e2 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.1" +VERSION = "python/5.0.2" diff --git a/solapi/model/request/groups/get_groups.py b/solapi/model/request/groups/get_groups.py index 7e3eb48..eb43537 100644 --- a/solapi/model/request/groups/get_groups.py +++ b/solapi/model/request/groups/get_groups.py @@ -16,7 +16,7 @@ class GetGroupsRequest(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) -class GetGroupsCrteriaType(str, Enum): +class GetGroupsCriteriaType(str, Enum): group_id = "groupId" date_created = "dateCreated" scheduled_date = "scheduledDate" diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py new file mode 100644 index 0000000..9a59c51 --- /dev/null +++ b/solapi/model/request/kakao/bms.py @@ -0,0 +1,12 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class Bms(BaseModel): + targeting: Literal["M", "N", "I"] + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/model/request/kakao/kakao_option.py b/solapi/model/request/kakao/kakao_option.py index 6656e2b..42f583a 100644 --- a/solapi/model/request/kakao/kakao_option.py +++ b/solapi/model/request/kakao/kakao_option.py @@ -4,6 +4,7 @@ from pydantic.alias_generators import to_camel from solapi.model.kakao.kakao_button import KakaoButton +from solapi.model.request.kakao.bms import Bms class KakaoOption(BaseModel): @@ -13,5 +14,6 @@ class KakaoOption(BaseModel): disable_sms: bool = False image_id: Optional[str] = None buttons: Optional[list[KakaoButton]] = None + bms: Optional[Bms] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py index 486dba5..793edc1 100644 --- a/solapi/model/request/message.py +++ b/solapi/model/request/message.py @@ -1,11 +1,12 @@ from typing import Any, Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from solapi.model import KakaoOption from solapi.model.message_type import MessageType from solapi.model.rcs.rcs_options import RcsOption +from solapi.model.request.voice.voice_option import VoiceOption class FileIdsType(BaseModel): @@ -52,6 +53,29 @@ class Message(BaseModel): fax_options: Optional[FileIdsType] = Field( default=None, serialization_alias="faxOptions", validation_alias="faxOptions" ) + voice_options: Optional[VoiceOption] = Field( + default=None, + serialization_alias="voiceOptions", + validation_alias="voiceOptions", + ) + + @field_validator("from_", mode="before") + @classmethod + def normalize_from_phone_number(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return v.replace("-", "") + + @field_validator("to", mode="before") + @classmethod + def normalize_to_phone_number( + cls, v: Union[str, list[str]] + ) -> Union[str, list[str]]: + if isinstance(v, str): + return v.replace("-", "") + elif isinstance(v, list): + return [phone.replace("-", "") for phone in v] + return v model_config = ConfigDict( extra="ignore", diff --git a/solapi/model/request/voice/voice_option.py b/solapi/model/request/voice/voice_option.py new file mode 100644 index 0000000..32cda58 --- /dev/null +++ b/solapi/model/request/voice/voice_option.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class VoiceOption(BaseModel): + voice_type: Literal["FEMALE", "MALE"] + header_message: Optional[str] = None + tail_message: Optional[str] = None + reply_range: Optional[int] = None + counselor_number: Optional[str] = None + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index 01b8904..0d8a5b9 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -9,7 +9,7 @@ from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer from solapi.model.request.groups.get_groups import ( - GetGroupsCrteriaType, + GetGroupsCriteriaType, GetGroupsFinalizeRequest, GetGroupsRequest, ) @@ -200,7 +200,7 @@ def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsRespo if query is not None: request = request.model_copy(update=query.model_dump(exclude_unset=True)) if query.group_id is not None and query.group_id != "": - request.criteria = GetGroupsCrteriaType.group_id + request.criteria = GetGroupsCriteriaType.group_id request.cond = "eq" request.value = query.group_id From 43b538947c31779afb900f98b0cf5740322c5859 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 14:49:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EB=B0=8F=20=EC=9D=8C=EC=84=B1=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B0=9C=EC=86=A1=20=EC=98=88=EC=A0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20VoiceOption=20=EB=AA=A8=EB=8D=B8=EC=97=90?= =?UTF-8?q?=20reply=5Frange=EC=99=80=20counselor=5Fnumber=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=ED=98=B8=20=EB=B0=B0=ED=83=80=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/simple/send_kakao_bms.py | 41 ++++++++++++++++++++++ examples/simple/send_voice_message.py | 38 ++++++++++++++++++++ solapi/model/__init__.py | 2 ++ solapi/model/request/voice/voice_option.py | 12 +++++-- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 examples/simple/send_kakao_bms.py create mode 100644 examples/simple/send_voice_message.py diff --git a/examples/simple/send_kakao_bms.py b/examples/simple/send_kakao_bms.py new file mode 100644 index 0000000..682e3c3 --- /dev/null +++ b/examples/simple/send_kakao_bms.py @@ -0,0 +1,41 @@ +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 카카오 알림톡 발송을 위한 옵션을 생성합니다. +kakao_option = KakaoOption( + pf_id="계정에 등록된 카카오 비즈니스 채널ID", + template_id="계정에 등록된 카카오 브랜드 메시지 템플릿 ID", + # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # 값은 반드시 문자열로 넣어주셔야 합니다! + # variables={ + # "#{name}": "홍길동", + # "#{age}": "30" + # } + # 브랜드 메시지 발송 대상자 설정, M, N 타입은 카카오측의 별도 인허가를 받은 대상만 사용할 수 있습니다. + # M: 마케팅 수신 동의 대상자 및 카카오 채널 친구 + # N: 마케팅 수신 동의 대상자 및 카카오 채널 친구는 제외한 대상자 + # I: 카카오 채널 친구 + bms=Bms(targeting="M"), +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + kakao_options=kakao_option, +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_voice_message.py b/examples/simple/send_voice_message.py new file mode 100644 index 0000000..12c5db7 --- /dev/null +++ b/examples/simple/send_voice_message.py @@ -0,0 +1,38 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage, VoiceOption + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +""" +단일 메시지를 생성합니다 +header_message를 사용하는 경우, 반드시 아무 버튼이나 눌러야 text 메시지가 재생됩니다. +text 메시지가 재생된 이후, reply_range에 명시된 번호(1~9) 혹은 counselor_number에 값이 있을 경우 0번을 눌러야 tail_message가 재생됩니다. +자세한 사항은 아래 링크를 참고해주세요! + +https://developers.solapi.com/references/voice +""" +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 음성 메시지 발송 예제입니다.", + voice_options=VoiceOption( + voice_type="FEMALE", + header_message="안녕하세요!", + tail_message="안녕하세요!", + reply_range=1, + ), +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index b363c20..8243ebe 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -4,6 +4,7 @@ from .request.kakao.bms import Bms from .request.message import Message as RequestMessage from .request.send_message_request import SendRequestConfig +from .request.voice.voice_option import VoiceOption from .response.message import Message as ResponseMessage __all__ = [ @@ -14,4 +15,5 @@ "GetGroupsRequest", "MessageType", "Bms", + "VoiceOption", ] diff --git a/solapi/model/request/voice/voice_option.py b/solapi/model/request/voice/voice_option.py index 32cda58..e3fbba2 100644 --- a/solapi/model/request/voice/voice_option.py +++ b/solapi/model/request/voice/voice_option.py @@ -1,6 +1,6 @@ from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from pydantic.alias_generators import to_camel @@ -8,9 +8,17 @@ class VoiceOption(BaseModel): voice_type: Literal["FEMALE", "MALE"] header_message: Optional[str] = None tail_message: Optional[str] = None - reply_range: Optional[int] = None + reply_range: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]] = None counselor_number: Optional[str] = None + @model_validator(mode="after") + def check_exclusive_fields(self) -> "VoiceOption": + if self.reply_range is not None and self.counselor_number is not None: + raise ValueError( + "reply_range와 counselor_number는 같이 사용할 수 없습니다." + ) + return self + model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, From 8602eab28c9dc686ac886921a3ba8f154a08d943 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 15:33:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?pyproject.toml=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=EC=A0=84=EC=9D=84=205.0.1?= =?UTF-8?q?=EC=97=90=EC=84=9C=205.0.2=EB=A1=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 135faae..c3b7b39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.1" +version = "5.0.2" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" }