Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions examples/simple/send_kakao_bms.py
Original file line number Diff line number Diff line change
@@ -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)}")
38 changes: 38 additions & 0 deletions examples/simple/send_voice_message.py
Original file line number Diff line number Diff line change
@@ -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)}")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
10 changes: 8 additions & 2 deletions solapi/lib/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 에러 처리: 클라이언트 오류일 경우
Expand Down
9 changes: 8 additions & 1 deletion solapi/lib/string_date_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions solapi/model/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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 .request.voice.voice_option import VoiceOption
from .response.message import Message as ResponseMessage

__all__ = [
Expand All @@ -12,4 +14,6 @@
"KakaoOption",
"GetGroupsRequest",
"MessageType",
"Bms",
"VoiceOption",
]
15 changes: 13 additions & 2 deletions solapi/model/kakao/kakao_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,30 @@
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
template_id: Optional[str] = None
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)

@field_validator("variables", mode="before")
@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
2 changes: 1 addition & 1 deletion solapi/model/request/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함!
VERSION = "python/5.0.1"
VERSION = "python/5.0.2"
2 changes: 1 addition & 1 deletion solapi/model/request/groups/get_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions solapi/model/request/kakao/bms.py
Original file line number Diff line number Diff line change
@@ -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",
)
2 changes: 2 additions & 0 deletions solapi/model/request/kakao/kakao_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
26 changes: 25 additions & 1 deletion solapi/model/request/message.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions solapi/model/request/voice/voice_option.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Literal, Optional

from pydantic import BaseModel, ConfigDict, model_validator
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[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,
extra="ignore",
)
4 changes: 2 additions & 2 deletions solapi/services/message_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down