From a91e6c33d4639e7b6ebaad134d14e5b7558272e2 Mon Sep 17 00:00:00 2001 From: Seokyoung-Hong Date: Mon, 8 Apr 2024 20:41:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20feature/cafeteria-1-view=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20aws-sandol-api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature/cafeteria-1-view 브랜치에서 개발한 cafeteria-view 관련 코드를 aws-sandol-api 폴더에 적용합니다. --- aws-sandol-api/api_server/__init__.py | 2 + aws-sandol-api/api_server/app.py | 11 + aws-sandol-api/api_server/kakao/__init__.py | 22 + aws-sandol-api/api_server/kakao/ai.py | 120 +++ aws-sandol-api/api_server/kakao/base.py | 48 + aws-sandol-api/api_server/kakao/common.py | 915 ++++++++++++++++++ .../api_server/kakao/customerror.py | 26 + aws-sandol-api/api_server/kakao/input.py | 319 ++++++ aws-sandol-api/api_server/kakao/itemcard.py | 160 +++ aws-sandol-api/api_server/kakao/response.py | 453 +++++++++ aws-sandol-api/api_server/kakao/skill.py | 453 +++++++++ .../api_server/kakao/validatiion.py | 27 + aws-sandol-api/api_server/settings.py | 13 + aws-sandol-api/api_server/tests/__init__.py | 0 aws-sandol-api/api_server/tests/test_app.py | 0 aws-sandol-api/api_server/utils.py | 32 + aws-sandol-api/app.py | 81 +- aws-sandol-api/crawler/__init__.py | 3 + aws-sandol-api/crawler/cafeteria_view.py | 124 +++ aws-sandol-api/crawler/settings.py | 54 ++ aws-sandol-api/crawler/test.json | 32 + aws-sandol-api/crawler/tests/__init__.py | 0 aws-sandol-api/tests/__init__.py | 0 aws-sandol-api/tests/test_app.py | 2 + 24 files changed, 2888 insertions(+), 9 deletions(-) create mode 100644 aws-sandol-api/api_server/__init__.py create mode 100644 aws-sandol-api/api_server/app.py create mode 100644 aws-sandol-api/api_server/kakao/__init__.py create mode 100644 aws-sandol-api/api_server/kakao/ai.py create mode 100644 aws-sandol-api/api_server/kakao/base.py create mode 100644 aws-sandol-api/api_server/kakao/common.py create mode 100644 aws-sandol-api/api_server/kakao/customerror.py create mode 100644 aws-sandol-api/api_server/kakao/input.py create mode 100644 aws-sandol-api/api_server/kakao/itemcard.py create mode 100644 aws-sandol-api/api_server/kakao/response.py create mode 100644 aws-sandol-api/api_server/kakao/skill.py create mode 100644 aws-sandol-api/api_server/kakao/validatiion.py create mode 100644 aws-sandol-api/api_server/settings.py create mode 100644 aws-sandol-api/api_server/tests/__init__.py create mode 100644 aws-sandol-api/api_server/tests/test_app.py create mode 100644 aws-sandol-api/api_server/utils.py create mode 100644 aws-sandol-api/crawler/__init__.py create mode 100644 aws-sandol-api/crawler/cafeteria_view.py create mode 100644 aws-sandol-api/crawler/settings.py create mode 100644 aws-sandol-api/crawler/test.json create mode 100644 aws-sandol-api/crawler/tests/__init__.py create mode 100644 aws-sandol-api/tests/__init__.py create mode 100644 aws-sandol-api/tests/test_app.py diff --git a/aws-sandol-api/api_server/__init__.py b/aws-sandol-api/api_server/__init__.py new file mode 100644 index 00000000..d7379f64 --- /dev/null +++ b/aws-sandol-api/api_server/__init__.py @@ -0,0 +1,2 @@ +from .utils import make_meal_cards +from .settings import HELP, CAFETERIA_WEB diff --git a/aws-sandol-api/api_server/app.py b/aws-sandol-api/api_server/app.py new file mode 100644 index 00000000..2b3bdef3 --- /dev/null +++ b/aws-sandol-api/api_server/app.py @@ -0,0 +1,11 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/") +def root(): + return "Hello Sandol" + + +app.run() diff --git a/aws-sandol-api/api_server/kakao/__init__.py b/aws-sandol-api/api_server/kakao/__init__.py new file mode 100644 index 00000000..93a6a3e5 --- /dev/null +++ b/aws-sandol-api/api_server/kakao/__init__.py @@ -0,0 +1,22 @@ +""" +This module provides the Kakao package. +""" +from .skill import SimpleImageResponse, SimpleTextResponse +from .skill import BasicCard, CommerceCard, Carousel +from .input import Payload, Params +from .response import KakaoResponse +from .customerror import InvalidActionError, InvalidLinkError, InvalidTypeError + +__all__ = [ + "SimpleImageResponse", + "SimpleTextResponse", + "BasicCard", + "CommerceCard", + "InvalidActionError", + "InvalidLinkError", + "InvalidTypeError", + "KakaoResponse", + "Payload", + "Params", + "Carousel", +] diff --git a/aws-sandol-api/api_server/kakao/ai.py b/aws-sandol-api/api_server/kakao/ai.py new file mode 100644 index 00000000..8ab09e56 --- /dev/null +++ b/aws-sandol-api/api_server/kakao/ai.py @@ -0,0 +1,120 @@ +from typing import Callable, Optional +from .base import BaseModel + +from .common import Action, Button, Buttons, QuickReplies +from .skill import SimpleTextResponse, TextCard +from .response import KakaoResponse + +from .input import Payload + + +class AI(BaseModel): + def __init__( + self, + payload: Payload, + callback: Optional[Callable] = None, + ): + self.payload = payload + self.callback = callback + self._answer: Optional[str] = None + self.buttons = Buttons() + self.buttons.max_buttons = 20 + self.ButtonWrapper = TextCard + + @staticmethod + def create_dict_with_non_none_values( + base: Optional[dict] = None, **kwargs): + """ + Dict 객체를 생성합니다. + kwargs의 값이 None이 아닌 경우 base에 추가합니다. + + Args: + base (dict): 기본 딕셔너리, 없는 경우 빈 딕셔너리 + **kwargs: 추가할 딕셔너리 + + Returns: + dict: base와 kwargs를 합친 딕셔너리 + """ + if base is None: + base = {} + base.update({k: v for k, v in kwargs.items() if v is not None}) + return base + + @property + def callback_url(self): + return self.payload.user_request.callback_url + + @classmethod + def from_dict( + cls, + user_request_dict: dict, + callback: Optional[Callable] = None): + payload = Payload.from_dict(user_request_dict) + if not payload.user_request.callback_url: + raise ValueError("Callback_url must be set") + return cls(payload=payload, callback=callback) + + def validate(self): + if not self.callback_url: + raise ValueError("Callback_url must be set") + + if not self.callback: + raise ValueError("Callback must be set") + + def set_answer(self, answer: str): + self._answer = answer + + def add_button(self, label: str): + button = Button(label=label, action=Action.MESSAGE, messageText=label) + self.buttons.add_button(button) + + @property + def answer(self): + return self._answer + + def prepare_callback_message( + self, + context: Optional[dict] = None, + data: Optional[dict] = None): + """ + 카카오 서버에 전달할 콜백 사용 여부를 나타내는 JSON 데이터를 준비합니다. + + Args: + context (Optional[dict], optional): 콜백 사용과 관련된 컨텍스트 데이터. + data (Optional[dict], optional): 콜백 사용과 관련된 데이터. + + Returns: + dict: 콜백 사용 여부를 나타내는 JSON 형식의 데이터 + """ + response = { + "version": "2.0", + "useCallback": True, + } + + response = self.create_dict_with_non_none_values( + response, context=context, data=data) + return response + + def render(self, to_json=False): + real_response = KakaoResponse() + if len(self.buttons) > 3: + response = SimpleTextResponse( + self.answer) + real_response.add_skill(response) + quick_replies = QuickReplies.from_buttons(self.buttons) + real_response.add_quick_replies(quick_replies) + return response.get_dict() + + elif len(self.buttons) == 0: + response = SimpleTextResponse(self.answer) + return response.get_dict() + + response = SimpleTextResponse(self.answer) + response_card = self.ButtonWrapper( + "Buttons", + buttons=self.buttons, + ) + multi_response = real_response+response+response_card + if to_json: + return multi_response.get_json() + return multi_response.get_dict() diff --git a/aws-sandol-api/api_server/kakao/base.py b/aws-sandol-api/api_server/kakao/base.py new file mode 100644 index 00000000..bbd2d80f --- /dev/null +++ b/aws-sandol-api/api_server/kakao/base.py @@ -0,0 +1,48 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class BaseModel(ABC): + """카카오 라이브러리의 대부분의 클래스의 부모 클래스 + + 카카오 라이브러리의 대부분의 클래스는 이 클래스를 상속받아 구현됩니다. + create_dict_with_non_none_values 메서드를 통해 None이 아닌 값만을 가진 dict를 생성할 수 있습니다. + render 메서드를 통해 객체를 카카오톡 응답 형식에 알맞게 dict로 변환하도록 구현해야 합니다. + validate 메서드를 통해 카카오톡 응답 규칙에 맞는지 검증하도록 구현해야 합니다. + """ + @staticmethod + def create_dict_with_non_none_values( + base: Optional[dict] = None, **kwargs): + """value가 None이 아닌 key-value 쌍을 base에 추가해 dict를 생성합니다. + + 카카오톡 서버로 반환 시 None인 값을 제외하고 반환하기 위해 사용합니다. + + Args: + base (dict): 기본 딕셔너리, 없는 경우 빈 딕셔너리 + **kwargs: 추가할 딕셔너리 + + Returns: + dict: base와 kwargs를 합친 딕셔너리 + """ + if base is None: + base = {} + base.update({k: v for k, v in kwargs.items() if v is not None}) + return base + + @abstractmethod + def render(self): + """객체를 카카오톡 응답 형식에 알맞게 dict로 변환 + + 변환된 dict는 각 객체가 타깃으로 하는 + 카카오톡 응답 형식의 상세 필드를 key로 가집니다. + + Returns: + dict + """ + + @abstractmethod + def validate(self): + """카카오톡 응답 규칙에 알맞은지 객체를 검증 + + 응답 규칙에 맞지 않을 경우 예외를 발생시키도록 구현해야 합니다. + """ diff --git a/aws-sandol-api/api_server/kakao/common.py b/aws-sandol-api/api_server/kakao/common.py new file mode 100644 index 00000000..c5e7c300 --- /dev/null +++ b/aws-sandol-api/api_server/kakao/common.py @@ -0,0 +1,915 @@ +from abc import ABCMeta +from enum import Enum +from typing import Optional, overload + +from .validatiion import validate_str, validate_type +from .customerror import InvalidActionError, InvalidTypeError, InvalidLinkError +from .base import BaseModel + + +class Common(BaseModel, metaclass=ABCMeta): + """카카오톡 응답 형태의 객체를 생성하는 추상 클래스 + + 스킬 응답의 template/context/data 중 context/data에 해당하는 객체가 이 클래스를 상속받아 구현됩니다. + + Attributes: + response_content_obj (dict): 카카오톡 응답 형태의 객체가 저장되는 딕셔너리, 빈 딕셔너리로 초기화됩니다. + """ + + def __init__(self): + self.response_content_obj: dict | list = {} + + +class Action(Enum): + """Action 열거형 클래스 + + 카카오톡 응답 형태의 버튼의 동작을 나타내는 열거형 클래스입니다. + Button과 QuickReply 클래스에서 사용됩니다. + + Args: + WEBLINK (str): 웹 브라우저를 열고 webLinkUrl 의 주소로 이동 + MESSAGE (str): 사용자의 발화로 messageText를 실행 + PHONE (str): phoneNumber에 있는 번호로 전화 + BLOCK (str): blockId를 갖는 블록을 호출 + SHARE (str): 말풍선을 다른 유저에게 공유 + OPERATOR (str): 상담원 연결 + """ + WEBLINK = "webLink" + MESSAGE = "message" + PHONE = "phone" + BLOCK = "block" + SHARE = "share" + OPERATOR = "operator" + + +class QuickReply(Common): + """카카오톡 응답 형태 QuickReply의 객체를 생성하는 클래스 + + QuickReply는 사용자가 챗봇에게 빠르게 응답할 수 있도록 도와주는 버튼입니다. + quickReplies(바로가기 버튼들)의 버튼으로 사용되며, + QuickReplies 객체의 속성으로 사용됩니다. + + Args: + label (str): 사용자에게 보여질 버튼의 텍스트 + action (str | Action): 바로가기 응답의 기능, 문자열 또는 Action 열거형, 기본값은 "Message" + messageText (str | None): action이 "Message"인 경우 사용자가 챗봇에게 전달할 메시지 + blockId (str | None): action이 "Block"인 경우 호출할 블록의 ID + extra (dict | None): 블록을 호출 시 스킬 서버에 추가로 전달할 데이터 + """ + # QuickReply의 action에 따라 필요한 필드를 매핑하는 딕셔너리 + action_field_map = {Action.MESSAGE: "messageText", Action.BLOCK: "blockId"} + + def __init__( + self, + label: str, + action: str | Action = "Message", + messageText: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None): + super().__init__() + self.label = label + self.action = self.process_action(action) + self.messageText = messageText + self.blockId = blockId + self.extra = extra + + @staticmethod + def process_action(action: str | Action) -> Action: + """문자열 또는 Action 열거형 인스턴스를 Action 열거형으로 변환합니다. + + action이 문자열인 경우 대문자로 변환하여 Action 열거형에 있는지 확인합니다. + Action 열거형에 없는 경우 InvalidActionError를 발생시킵니다. + + Args: + action (str | Action): QuickReply의 action + + Returns: + Action: QuickReply의 action을 나타내는 Action 열거형 + + Raises: + InvalidActionError: 유효하지 않은 action 값인 경우 + """ + validate_type( + (str, Action), + action, + disallow_none=True, + exception_type=InvalidActionError) + + if isinstance(action, str): + # getattr을 사용하여 안전하게 Action 열거형에 접근 + result = getattr(Action, action.upper(), None) + if result is None: + raise InvalidActionError(f"유효하지 않은 action 값: {action}") + return result + + return action + + def validate(self): + """QuickReply 객체의 유효성을 검사합니다. + + label이 문자열이 아닌 경우 InvalidTypeError를 발생시킵니다. + action이 문자열 또는 Action 열거형이 아닌 경우 InvalidActionError를 발생시킵니다. + + Raises: + InvalidTypeError: label이 문자열이 아닌 경우 + InvalidActionError: action이 문자열 또는 Action 열거형이 아닌 경우 + """ + validate_str(self.label) + + validate_type( + (str, Action), + self.action, + disallow_none=True, + exception_type=InvalidActionError) + + def render(self) -> dict: + """QuickReply 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + response_content_obj에 label, action, action에 따른 필드를 저장합니다. + action이 "Message"인 경우 messageText를 저장하고, + action이 "Block"인 경우 blockId를 저장합니다. + extra가 존재하는 경우 extra를 저장합니다. + 최종적으로 response_content_obj를 반환합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 QuickReply 딕셔너리 + """ + self.validate() + self.response_content_obj = { + "label": self.label, + "action": self.action.value, + } + + self.response_content_obj[QuickReply.action_field_map[ + self.action]] = getattr(self, + QuickReply.action_field_map[self.action]) + + if self.extra is not None: + self.response_content_obj["extra"] = self.extra + + return self.response_content_obj + + +class QuickReplies(Common): + """카카오톡 응답 형태 QuickReplies의 객체를 생성하는 클래스 + + QuickReplies는 사용자가 챗봇에게 빠르게 응답할 수 있도록 도와주는 버튼입니다. + QuickReply 객체들의 리스트로 구성됩니다. + + add_quick_reply 메서드를 통해 QuickReply 객체를 추가하고, + delete_quick_reply 메서드를 통해 QuickReply 객체를 삭제할 수 있습니다. + + from_buttons 메서드를 통해 Buttons 객체를 QuickReplies 객체로 변환할 수 있습니다. + + Args: + quickReplies (list[QuickReply]): QuickReply 객체들의 리스트, 기본값은 빈 리스트 + """ + + def __init__(self, quickReplies: Optional[list[QuickReply]] = None): + super().__init__() + if quickReplies is None: + quickReplies = [] + self.quickReplies: list[QuickReply] = quickReplies + self.response_content_obj: list = [] + + def __add__(self, other: "QuickReplies") -> "QuickReplies": + """QuickReplies 객체를 합칩니다. + + Args: + other (QuickReplies): 합칠 QuickReplies 객체 + + Returns: + QuickReplies: 합쳐진 QuickReplies 객체 + """ + validate_type(QuickReplies, other) + return QuickReplies(self.quickReplies + other.quickReplies) + + def validate(self): + """QuickReplies 객체의 유효성을 검사합니다. + + quickReplies의 각 요소가 QuickReply 객체인지 확인합니다. + + Raises: + InvalidTypeError: quickReplies의 각 요소가 QuickReply 객체가 아닌 경우 + """ + for quickReply in self.quickReplies: + validate_type(QuickReply, quickReply) + + @overload # type: ignore + def add_quick_reply(self, quickReply: QuickReply) -> None: # noqa: F811 + ... + + @overload + def add_quick_reply( + self, + label: str, + action: str, + messageText: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None) -> None: + ... + + def add_quick_reply(self, *args, **kwargs) -> None: + """QuickReplies에 QuickReply를 추가합니다. + + QuickReply 객체 또는 QuickReply 생성 인자를 받아 QuickReplies에 추가합니다. + + QuickReply 객체를 받은 경우 + Args: + quickReply (QuickReply): 추가할 QuickReply 객체 + + QuickReply 생성 인자를 받은 경우 + Args: + label (str): QuickReply의 라벨 + action (str): QuickReply의 액션 + messageText (str): QuickReply의 messageText + blockId (str): QuickReply의 blockId + extra (dict): QuickReply의 extra + + Raises: + InvalidTypeError: 받거나 생성한 QuickReply 객체가 QuickReply가 아닌 경우 + """ + if len(args) == 1 and isinstance(args[0], QuickReply): + quickReply = args[0] + else: + quickReply = QuickReply(*args, **kwargs) + validate_type(QuickReply, quickReply) + self.quickReplies.append(quickReply) + + def delete_quick_reply(self, quickReply: QuickReply): + """QuickReplies에서 QuickReply를 삭제합니다. + + Args: + quickReply (QuickReply): 삭제할 QuickReply 객체 + """ + self.quickReplies.remove(quickReply) + + def render(self) -> list: + """QuickReplies 객체를 카카오톡 응답 형식에 맞게 리스트로 변환합니다. + + QuickReplies 객체의 각 QuickReply 객체를 render 메서드를 통해 변환하고, + 변환된 QuickReply 객체들을 리스트로 반환합니다. + + Returns: + list: 카카오톡 응답 형식에 맞게 변환된 QuickReplies 객체의 리스트 + """ + self.validate() + self.response_content_obj = [ + quickReply.render() for quickReply in self.quickReplies + ] + return self.response_content_obj + + def get_list(self, rendering: bool = False) -> list: + """QuickReplies 객체를 카카오톡 응답 형식에 맞게 리스트로 변환합니다. + + QuickReplies 객체의 각 QuickReply 객체를 render 메서드를 통해 변환하고, + 변환된 QuickReply 객체들을 리스트로 반환합니다. + + Returns: + list: 카카오톡 응답 형식에 맞게 변환된 QuickReplies 객체의 리스트 + """ + if rendering: + return self.render() + return self.response_content_obj + + @classmethod + def from_buttons(cls, buttons: "Buttons") -> "QuickReplies": + """Buttons 객체를 QuickReplies 객체로 변환합니다. + + Buttons 객체의 각 Button 객체를 QuickReply 객체로 변환하여 QuickReplies 객체로 반환합니다. + + Args: + buttons (Buttons): 변환할 Buttons 객체 + + Returns: + QuickReplies: Buttons 객체를 변환한 QuickReplies 객체 + + Raises: + InvalidTypeError: buttons의 각 요소가 Button 객체가 아닌 경우 + """ + quick_replies = [] + for button in buttons: + assert isinstance(button, Button) + quick_replies.append( + QuickReply( + label=button.label, + action=button.action, + messageText=button.messageText, + blockId=button.blockId, + )) + return cls(quick_replies) + + +class Button(Common): + """카카오톡 응답 형태 Button의 객체를 생성하는 클래스 + + Button은 사용자가 챗봇에게 빠르게 응답할 수 있도록 도와주는 버튼입니다. + Buttons 객체의 속성으로 사용됩니다. + + Attributes: + label (str): 버튼의 텍스트 + action (Action): 버튼의 동작 + webLinkUrl (str): 웹 링크 + messageText (str): 메시지 + phoneNumber (str): 전화번호 + blockId (str): 블록 ID + extra (dict): 스킬 서버에 추가로 전달할 데이터 + + Raises: + ValueError: text가 문자열이 아닌 경우 + """ + + # Button의 action에 따라 필요한 필드를 매핑하는 딕셔너리 + action_field_map = { + Action.WEBLINK: "webLinkUrl", + Action.MESSAGE: "messageText", + Action.PHONE: "phoneNumber", + Action.BLOCK: "blockId" + } + + def __init__( + self, + label: str, + action: str | Action, + webLinkUrl: Optional[str] = None, + messageText: Optional[str] = None, + phoneNumber: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None): + + super().__init__() + self.label = label + self.action = self.process_action(action) + self.webLinkUrl = webLinkUrl + self.messageText = messageText + self.phoneNumber = phoneNumber + self.blockId = blockId + self.extra = extra + + @staticmethod + def process_action(action: str | Action) -> Action: + """문자열 또는 Action 열거형 인스턴스를 Action 열거형으로 변환합니다. + + action이 문자열인 경우 대문자로 변환하여 Action 열거형에 있는지 확인합니다. + Action 열거형에 없는 경우 InvalidActionError를 발생시킵니다. + + Args: + action (str | Action): Button의 action + + Returns: + Action: Button의 action을 나타내는 Action 열거형 + + Raises: + InvalidActionError: 유효하지 않은 action 값인 경우 + """ + validate_type( + (str, Action), + action, + disallow_none=True, + exception_type=InvalidActionError) + + if isinstance(action, str): + # getattr을 사용하여 안전하게 Action 열거형에 접근 + result = getattr(Action, action.upper(), None) + if result is None: + raise InvalidActionError(f"유효하지 않은 action 값: {action}") + return result + + return action + + def validate(self): + """Button 객체의 유효성을 검사합니다. + + label이 문자열이 아닌 경우 InvalidTypeError를 발생시킵니다. + action에 따라 필요한 필드가 존재하는지 확인합니다. + + Raise: + InvalidTypeError: label이 문자열이 아닌 경우 + InvalidTypeError: action에 따라 필요한 필드가 존재하지 않는 경우 + """ + validate_str(self.label) + + field_to_validate = getattr(self, Button.action_field_map[self.action]) + validate_str(field_to_validate) + + def render(self) -> dict: + """Button 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + response_content_obj에 label, action, action에 따른 필드를 저장합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 Button 딕셔너리 + """ + self.validate() + self.response_content_obj = { + "label": self.label, + "action": self.action.value, + } + + self.response_content_obj[Button.action_field_map[ + self.action]] = getattr(self, Button.action_field_map[self.action]) + + if self.extra is not None: + self.response_content_obj.update(self.extra) + + return self.response_content_obj + + +class Buttons(Common): + """카카오톡 응답 형태 Buttons의 객체를 생성하는 클래스 + + Buttons는 사용자가 챗봇에게 빠르게 응답할 수 있도록 도와주는 버튼입니다. + response_content_obj에 Button 객체들의 리스트로 저장됩니다. + iter 메서드를 통해 Button 객체들을 순회할 수 있습니다. + add_button 메서드를 통해 Button 객체를 추가하고, + delete_button 메서드를 통해 Button 객체를 삭제할 수 있습니다. + render 메서드를 통해 Buttons 객체를 response 객체에 넣을 수 있는 형태로 변환합니다. + """ + + def __init__( + self, + buttons: Optional[list[Button]] = None, + max_buttons: int = 3): + super().__init__() + self._buttons = buttons or [] + self.max_buttons = max_buttons + + def __len__(self): + """버튼의 개수를 반환합니다. + + Returns: + int: 버튼의 개수 + """ + return len(self._buttons) + + def __iter__(self): + """버튼을 순회할 수 있도록 반환합니다. + + Returns: + iter: 버튼을 순회할 수 있는 이터레이터 + """ + return iter(self._buttons) + + def validate(self): + """Buttons 객체의 유효성을 검사합니다. + + 버튼의 개수가 최대 개수를 초과하는 경우 InvalidTypeError를 발생시킵니다. + + Raises: + InvalidTypeError: 버튼의 개수가 최대 개수를 초과하는 경우 + InvalidTypeError: 버튼이 Button 객체가 아닌 경우 + """ + if len(self._buttons) > self.max_buttons: + raise InvalidTypeError(f"버튼은 최대 {self.max_buttons}개까지 가능합니다.") + if False in [isinstance(button, Button) for button in self._buttons]: + raise InvalidTypeError("self._buttons는 Button으로 이루어져야 합니다.") + + @overload + def add_button(self, button: Button) -> None: + ... + + @overload + def add_button( + self, + label: str, + action: str | Action, + webLinkUrl: Optional[str] = None, + messageText: Optional[str] = None, + phoneNumber: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None): + ... + + def add_button(self, *args, **kwargs) -> None: + """버튼을 추가합니다. + + Button 객체 또는 Button 생성 인자를 받아 버튼 리스트에 추가합니다. + + Button 객체를 받은 경우 + Args: + button (Button): 추가할 Button 객체 + + Button 생성 인자를 받은 경우 + Args: + label (str): 버튼의 텍스트 + action (str): 버튼의 동작 + web_link_url (str): 웹 링크 + message_text (str): 메시지 + phone_number (str): 전화번호 + block_id (str): 블록 ID + extra (dict): 스킬 서버에 추가로 전달할 데이터 + + Raises: + InvalidTypeError: 받거나 생성한 Button 객체가 Button이 아닌 경우 + """ + if len(args) == 1 and isinstance(args[0], Button): + button = args[0] + elif len(kwargs) == 1 and "button" in kwargs: + button = kwargs["button"] + else: + button = Button(*args, **kwargs) + validate_type(Button, button, disallow_none=True) + self._buttons.append(button) + + def delete_button(self, button: Button): + """버튼을 삭제합니다. + + Args: + button (Button): 삭제할 버튼 객체 + + Raises: + ValueError: 버튼이 버튼 리스트에 존재하지 않는 경우 + """ + if button not in self._buttons: + raise ValueError("해당 버튼이 존재하지 않습니다.") + self._buttons.remove(button) + + def render(self) -> list: + """Buttons 객체를 카카오톡 응답 형식에 맞게 리스트로 변환합니다. + + 버튼의 각 Button 객체를 render 메서드를 통해 변환하고, + 변환된 Button 객체들을 리스트로 반환합니다. + + Returns: + list: 카카오톡 응답 형식에 맞게 변환된 Button 객체의 리스트 + """ + self.validate() + return [button.render() for button in self._buttons] + + +class Link(Common): + """카카오톡 응답 형태 Link의 객체를 생성하는 클래스 + + Link는 버튼이나 썸네일 등에서 사용되는 링크를 나타냅니다. + Link 객체는 웹, PC, 모바일 링크를 가질 수 있습니다. + web이 가장 우선적으로 실행되며, web이 없는 경우에 플랫폼에 따라 PC 또는 모바일 링크가 실행됩니다. + + Args: + web (Optional[str]): 웹 링크 + pc (Optional[str]): PC 링크 + mobile (Optional[str]): 모바일 링크 + """ + + def __init__( + self, + web: Optional[str] = None, + pc: Optional[str] = None, + mobile: Optional[str] = None): + super().__init__() + self.web = web + self.pc = pc + self.mobile = mobile + + def validate(self): + """Link 객체의 유효성을 검사합니다. + + Link는 최소 하나의 링크를 가져야 합니다. + + Raises: + InvalidLinkError: Link는 최소 하나의 링크를 가져야 합니다. + """ + if self.web is None and self.pc is None and self.mobile is None: + raise InvalidLinkError("Link는 최소 하나의 링크를 가져야 합니다.") + validate_str(self.web, self.pc, self.mobile) + + def render(self) -> dict: + """Link 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + response_content_obj에 web, pc, mobile 링크를 저장합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 Link 딕셔너리 + """ + self.validate() + return self.create_dict_with_non_none_values( + web=self.web, + pc=self.pc, + mobile=self.mobile, + ) + + +class Thumbnail(Common): + """카카오톡 응답 형태 Thumbnail의 객체를 생성하는 클래스 + + Thumbnail은 썸네일 이미지를 나타냅니다. + 썸네일 이미지는 이미지 URL을 가지며, 링크를 가질 수 있습니다. + fixedRatio는 이미지의 비율을 고정할지 여부를 나타냅니다. + + Args: + image_url (str): 썸네일 이미지 URL + link (Link): 썸네일 이미지 클릭 시 이동할 링크, 기본값은 None + fixed_ratio (bool): 이미지의 비율을 고정할지 여부, 기본값은 False + """ + + def __init__( + self, + image_url: str, + link: Optional[Link] = None, + fixed_ratio: bool = False): + super().__init__() + self.image_url = image_url + self.link = link + self.fixed_ratio = fixed_ratio + + def validate(self): + """Thumbnail 객체의 유효성을 검사합니다. + + Raises: + InvalidTypeError: image_url이 문자열이 아닌 경우 + InvalidTypeError: link가 Link 객체가 아닌 경우 + InvalidTypeError: fixed_ratio가 bool이 아닌 경우 + """ + validate_str(self.image_url) + validate_type(Link, self.link, disallow_none=False) + validate_type(bool, self.fixed_ratio) + + def render(self) -> dict: + """Thumbnail 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + response_content_obj에 image_url, fixed_ratio, link를 저장합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 Thumbnail 딕셔너리 + """ + self.validate() + self.response_content_obj = { + "imageUrl": self.image_url, + "fixedRatio": self.fixed_ratio, + } + self.create_dict_with_non_none_values( + base=self.response_content_obj, + link=self.link.render() if self.link is not None else None) + return self.response_content_obj + + +class Thumbnails(Common): + """카카오톡 응답 형태 Thumbnails의 객체를 생성하는 클래스 + + Thumbnails는 썸네일 이미지들의 리스트를 나타냅니다. + CommerceCard 객체의 속성으로 사용됩니다. + 현재 카카오톡 규정에 따라 1개의 썸네일 이미지만 사용할 수 있습니다. + + Args: + thumbnails (list[Thumbnail]): 썸네일 이미지들의 리스트, 기본값은 빈 리스트 + max_thumbnails (int): 썸네일 이미지의 최대 개수, 기본값은 1 + """ + + def __init__(self, thumbnails: list[Thumbnail], max_thumbnails: int = 1): + super().__init__() + self._thubnails = thumbnails + self.max_thumbnails = max_thumbnails + + def validate(self): + """Thumbnails 객체의 유효성을 검사합니다. + + 썸네일 이미지의 개수가 최대 개수를 초과하는 경우 InvalidTypeError를 발생시킵니다. + 썸네일 이미지의 각 요소가 Thumbnail 객체인지 확인합니다. + + Raises: + InvalidTypeError: 썸네일 이미지의 개수가 최대 개수를 초과하는 경우 + """ + if len(self._thubnails) > self.max_thumbnails: + raise InvalidTypeError(f"버튼은 최대 {self.max_thumbnails}개까지 가능합니다.") + for thumbnail in self._thubnails: + validate_type(Thumbnail, thumbnail) + + def add_button(self, thumbnail: Thumbnail): + """Thumnail 버튼을 추가합니다. + + Args: + thumbnail (Thumbnail): 추가할 Thumbnail 객체 + + Raise: + ValueError: 이미 최대 버튼 개수에 도달한 경우 + InvalidTypeError: 인자가 Thumbnail 객체가 아닌 경우 + """ + + if len(self._thubnails) > self.max_thumbnails: + raise ValueError("버튼은 최대 3개까지 가능합니다.") + + validate_type(thumbnail, Thumbnail) + self._thubnails.append(thumbnail) + + def delete_button(self, thumbnail: Thumbnail): + """Thumnail 버튼을 삭제합니다. + + Args: + thumbnail (Thumbnail): 삭제할 Thumbnail 객체 + + Raise: + ValueError: 버튼이 존재하지 않는 경우 + """ + if thumbnail not in self._thubnails: + raise ValueError("해당 버튼이 존재하지 않습니다.") + self._thubnails.remove(thumbnail) + + def render(self) -> list: + """Thumbnails 객체를 카카오톡 응답 형식에 맞게 리스트로 변환합니다. + + 썸네일 이미지의 각 Thumbnail 객체를 render 메서드를 통해 변환하고, + 변환된 Thumbnail 객체들을 리스트로 반환합니다. + + Returns: + list: 카카오톡 응답 형식에 맞게 변환된 Thumbnail 객체의 리스트 + """ + self.validate() + return [thumbnail.render() for thumbnail in self._thubnails] + + +class Profile(Common): + """카카오톡 응답 형태 Profile의 객체를 생성하는 클래스 + + Profile은 사용자의 프로필 정보를 나타냅니다. + 이미지 URL은 선택적으로 사용할 수 있습니다. + 이미지 사이즈는 180px X 180px이 권장됩니다. + CommerceCard 객체의 속성으로 사용됩니다. + ImageCard 객체의 속성으로 사용됩니다. + + Args: + nickname (str): 사용자의 닉네임 + image_url (Optional[str]): 사용자의 프로필 이미지 URL, 기본값은 None + """ + + def __init__(self, nickname: str, image_url: Optional[str] = None): + super().__init__() + self.nickname = nickname + self.image_url = image_url + + def validate(self): + """Profile 객체의 유효성을 검사합니다. + + Raises: + InvalidTypeError: nickname이 문자열이 아닌 경우 + InvalidTypeError: image_url이 문자열이 아닌 경우 + """ + validate_str(self.nickname, self.image_url) + + def render(self): + """Profile 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 Profile 딕셔너리 + """ + return self.create_dict_with_non_none_values( + nickname=self.nickname, + imageUrl=self.image_url) + + +class ListItem(Common): + """카카오톡 응답 형태 ListItem의 객체를 생성하는 클래스 + + ListItem은 리스트 형태의 정보를 나타냅니다. + ListItem은 title, description, imageUrl, link, action, block_id, + message_text, extra를 가질 수 있습니다. + ListCard 객체의 속성인 ListItems의 속성으로 사용됩니다. + + Args: + title (str): header에 들어가는 경우, listCard의 제목, items에 들어가는 경우, 해당 항목의 제목 + description (Optional[str]): items에 들어가는 경우, 해당 항목의 설명 + image_url (Optional[str]): items에 들어가는 경우, 해당 항목의 우측 안내 사진 + link (Optional[Link]): 리스트 아이템 클릭 시 동작할 링크 + action (Optional[str | Action]): 리스트 아이템 클릭시 수행될 작업(block 또는 message) + block_id (Optional[str]): action이 block인 경우 block_id를 갖는 블록을 호출 + message_text (Optional[str]): action이 message인 경우 리스트 아이템 클릭 시 전달할 메시지 + extra (Optional[dict]): 블록 호출시, 스킬 서버에 추가적으로 제공하는 정보 + """ + + def __init__( + self, + title: str, + description: Optional[str] = None, + image_url: Optional[str] = None, + link: Optional[Link] = None, + action: Optional[str | Action] = None, + block_id: Optional[str] = None, + message_text: Optional[str] = None, + extra: Optional[dict] = None): + super().__init__() + self.title = title + self.description = description + self.image_url = image_url + self.link = link + self.action = action + self.block_id = block_id + self.message_text = message_text + self.extra = extra + + def validate(self): + """ListItem 객체의 유효성을 검사합니다. + + Raises: + InvalidTypeError: title이 문자열이 아닌 경우 + InvalidTypeError: description이 문자열이 아닌 경우 + InvalidTypeError: image_url이 문자열이 아닌 경우 + InvalidTypeError: link가 Link 객체가 아닌 경우 + """ + validate_str( + self.title, self.description, self.image_url, + self.block_id, self.message_text) + validate_type(Link, self.link) + + def render(self): + """ListItem 객체를 카카오톡 응답 형식에 맞게 딕셔너리로 변환합니다. + + Returns: + dict: 카카오톡 응답 형식에 맞게 변환된 ListItem 딕셔너리 + """ + self.validate() + self.response_content_obj = { + "title": self.title, + "description": self.description, + "imageUrl": self.image_url, + } + + self.create_dict_with_non_none_values( + base=self.response_content_obj, + link=self.link.render() if self.link is not None else None, + action=self.action, + blockId=self.block_id, + messageText=self.message_text, + ) + return self.response_content_obj + + +class ListItems(Common): + """카카오톡 응답 형태 ListItems의 객체를 생성하는 클래스 + + ListItems는 ListItem 객체들의 리스트를 나타냅니다. + ListCard 객체의 속성으로 사용됩니다. + + Args: + list_items (list[ListItem]): ListItem 객체들의 리스트, 기본값은 빈 리스트 + max_list_items (int): ListItem의 최대 개수, 기본값은 5 + """ + + def __init__( + self, + list_items: Optional[list[ListItem]] = None, + max_list_items: int = 5): + super().__init__() + if list_items is None: + list_items = [] + self._list_items = list_items + self.max_list_items = max_list_items + + def validate(self): + """ListItems 객체의 유효성을 검사합니다. + + ListItem의 개수가 최대 개수를 초과하는 경우 ValueError를 발생시킵니다. + + Raises: + ValueError: ListItem의 개수가 최대 개수를 초과하는 경우 + """ + if len(self._list_items) > self.max_list_items: + raise ValueError( + f"버튼이 최대 {self.max_list_items}개까지 가능하도록 제한되어 있습니다.") + validate_type(ListItem, *self._list_items) + + def add_list_item(self, list_item: ListItem): + """ListItem을 추가합니다. + + Args: + list_item (ListItem): 추가할 ListItem 객체 + + Raise: + ValueError: 이미 최대 버튼 개수에 도달한 경우 + """ + if len(self._list_items) > self.max_list_items: + raise ValueError( + f"버튼이 최대 {self.max_list_items}개까지 가능하도록 제한되어 있습니다.") + validate_type(ListItem, list_item) + self._list_items.append(list_item) + + def delete_list_item(self, list_item: ListItem): + """ListItem을 삭제합니다. + + Args: + list_item (ListItem): 삭제할 ListItem 객체 + + Raise: + ValueError: 버튼이 존재하지 않는 경우 + """ + if list_item not in self._list_items: + raise ValueError("해당 ListItem이 존재하지 않습니다.") + self._list_items.remove(list_item) + + def render(self): + """ListItems 객체를 카카오톡 응답 형식에 맞게 리스트로 변환합니다. + + ListItems 객체의 각 ListItem 객체를 render 메서드를 통해 변환하고, + 변환된 ListItem 객체들을 리스트로 반환합니다. + + Returns: + list: 카카오톡 응답 형식에 맞게 변환된 ListItem 객체의 리스트 + """ + self.validate() + return [list_item.render() for list_item in self._list_items] + + +if __name__ == "__main__": + testbutton = Button( + label="구경하기", + action="webLink", + webLinkUrl="https://sio2.pe.kr/login", + messageText=None) + print(testbutton.render()) diff --git a/aws-sandol-api/api_server/kakao/customerror.py b/aws-sandol-api/api_server/kakao/customerror.py new file mode 100644 index 00000000..3a4b6540 --- /dev/null +++ b/aws-sandol-api/api_server/kakao/customerror.py @@ -0,0 +1,26 @@ +class InvalidTypeError(ValueError): + """유효하지 않은 타입에 대한 예외를 처리하는 클래스""" + + def __init__(self, message: str): + super().__init__(message) + + +class InvalidLinkError(ValueError): + """Link 형식에 대한 예외를 처리하는 클래스""" + + def __init__(self, message: str): + super().__init__(message) + + +class InvalidActionError(ValueError): + """유효하지 않은 action에 대한 예외를 처리하는 클래스""" + + def __init__(self, message: str): + super().__init__(message) + + +class InvalidPayloadError(ValueError): + """유효하지 않은 payload에 대한 예외를 처리하는 클래스""" + + def __init__(self, message: str): + super().__init__(message) diff --git a/aws-sandol-api/api_server/kakao/input.py b/aws-sandol-api/api_server/kakao/input.py new file mode 100644 index 00000000..8227db4e --- /dev/null +++ b/aws-sandol-api/api_server/kakao/input.py @@ -0,0 +1,319 @@ +from abc import ABC, abstractmethod +import json +from typing import Optional + +from .customerror import InvalidPayloadError + + +class ParentPayload(ABC): + @classmethod + def from_json(cls, data: str): + return cls.from_dict(json.loads(data)) + + @classmethod + @abstractmethod + def from_dict(cls, data: dict): ... + + +class Param(ParentPayload): + def __init__( + self, + origin: str, + value: str | dict, + group_name: str = '', + **kwargs): + self.origin = origin + self.value = value + self.group_name = group_name + for key, value in kwargs.items(): + setattr(self, key, value) + + @classmethod + def from_dict(cls, data: dict): + # value가 딕셔너리 타입이면, 이를 **kwargs로 전달 + additional_params = data['value'] if isinstance( + data.get('value'), dict) else {} + try: + origin = data['origin'] + value = data['value'] + group_name = data['groupName'] + return cls( + origin=origin, + value=value, + group_name=group_name, + **additional_params + ) + except KeyError as err: + raise InvalidPayloadError( + 'Param 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class Params(ParentPayload): + def __init__(self, **kwargs: dict[str, Param]): + for key, value in kwargs.items(): + setattr(self, key, value) + + @classmethod + def from_dict(cls, data: dict): + params = { + key: Param.from_dict(value) + for key, value in data.get('detailParams', {}).items() + } + return cls(**params) + + def to_dict(self): + return { + key: value.__dict__ + for key, value in self.__dict__.items() + } + + def __iter__(self): + return iter(self.__dict__.items()) + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + + +class Action(ParentPayload): + def __init__( + self, + id: str, + name: str, + params: Params, + detail_params: dict, + client_extra: dict): + self.id = id + self.name = name + self.params = params + self.detailParams = detail_params + self.clientExtra = client_extra + + @classmethod + def from_dict(cls, data: dict): + try: + _id = data['id'] + name = data['name'] + params = Params.from_dict(data) + detail_params = data['detailParams'] + client_extra = data['clientExtra'] + return cls( + id=_id, + name=name, + params=params, + detail_params=detail_params, + client_extra=client_extra + ) + except KeyError as err: + raise InvalidPayloadError( + 'Action 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class Bot(ParentPayload): + def __init__(self, id: str, name: str): + self.id = id + self.name = name + + @classmethod + def from_dict(cls, data: dict): + try: + _id = data['id'] + name = data['name'] + return cls( + id=_id, + name=name + ) + except KeyError as err: + raise InvalidPayloadError('Bot 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class IntentExtra(ParentPayload): + def __init__(self, reason: dict, knowledge: dict | None = None): + self.reason = reason + self.knowledge = knowledge + + @classmethod + def from_dict(cls, data: dict): + try: + reason = data['reason'] + knowledge = data.get('knowledge') + return cls( + reason=reason, + knowledge=knowledge + ) + except KeyError as err: + raise InvalidPayloadError( + 'IntentExtra 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class Intent(ParentPayload): + def __init__( + self, + id: str, + name: str, + extra: dict): + self.id = id + self.name = name + self.extra = extra + + @classmethod + def from_dict(cls, data: dict): + try: + _id = data['id'] + name = data['name'] + extra = IntentExtra.from_dict(data['extra']) + return cls( + id=_id, + name=name, + extra=extra + ) + except KeyError as err: + raise InvalidPayloadError( + 'Intent 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class UserProperties(ParentPayload): + def __init__( + self, + plusfriend_user_key: str, + app_user_id: str, + is_friend: bool): + + self.plusfriend_user_key = plusfriend_user_key + self.app_user_id = app_user_id + self.is_friend = is_friend + + @classmethod + def from_dict(cls, data: dict): + try: + plusfriend_user_key = data['plusfriendUserKey'] + app_user_id = data['appUserId'] + is_friend = data['isFriend'] + return cls( + plusfriend_user_key=plusfriend_user_key, + app_user_id=app_user_id, + is_friend=is_friend + ) + except KeyError as err: + raise InvalidPayloadError( + 'UserProperties 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class User(ParentPayload): + def __init__( + self, + id: str, + type: str, + properties: Optional[dict] = None): + + if properties is None: + properties = {} + + self.id = id + self.type = type + self.properties = properties + + @classmethod + def from_dict(cls, data: dict): + try: + _id = data['id'] + _type = data['type'] + properties = data.get('properties', {}) + return cls( + id=_id, + type=_type, + properties=properties + ) + except KeyError as err: + raise InvalidPayloadError( + 'User 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class UserRequest(ParentPayload): + def __init__( + self, + timezone: str, + block: dict, + utterance: str, + lang: str, + user: User, + params: dict, + callback_url: Optional[str] = None): + self.timezone = timezone + self.block = block + self.utterance = utterance + self.lang = lang + self.user = user + self.params = params + self.callback_url = callback_url + + @classmethod + def from_dict(cls, data: dict): + try: + timezone = data['timezone'] + block = data['block'] + utterance = data['utterance'] + lang = data['lang'] + user = User.from_dict(data['user']) + params = data['params'] + callback_url = data.get('callbackUrl') + return cls( + timezone=timezone, + block=block, + utterance=utterance, + lang=lang, + user=user, + params=params, + callback_url=callback_url + ) + except KeyError as err: + raise InvalidPayloadError( + 'UserRequest 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + +class Payload(ParentPayload): + def __init__( + self, + intent: Intent, + user_request: UserRequest, + bot: Bot, + action: Action): + self.intent = intent + self.user_request = user_request + self.bot = bot + self.action = action + + @classmethod + def from_dict(cls, data: dict): + try: + intent = Intent.from_dict(data['intent']) + user_request = UserRequest.from_dict(data['userRequest']) + bot = Bot.from_dict(data['bot']) + action = Action.from_dict(data['action']) + return cls( + intent=intent, + user_request=user_request, + bot=bot, + action=action + ) + except KeyError as err: + raise InvalidPayloadError( + 'Payload 객체를 생성하기 위한 키가 존재하지 않습니다.') from err + + @property + def user_id(self): + if ( + hasattr(self, 'user_request') and + hasattr(self.user_request, 'user') and + hasattr(self.user_request.user, 'id') + ): + return self.user_request.user.id + return None + + @property + def params(self): + if hasattr(self, 'action') and hasattr(self.action, 'params'): + return self.action.params + return None diff --git a/aws-sandol-api/api_server/kakao/itemcard.py b/aws-sandol-api/api_server/kakao/itemcard.py new file mode 100644 index 00000000..c73453c4 --- /dev/null +++ b/aws-sandol-api/api_server/kakao/itemcard.py @@ -0,0 +1,160 @@ +from typing import Optional, overload + +from .common import Common, Link +from .validatiion import validate_str, validate_int, validate_type + + +class Thumbnail(Common): + def __init__( + self, image_url: str, + wdith: Optional[int] = None, + height: Optional[int] = None, + link: Optional[Link] = None): + super().__init__() + self.image_url = image_url + self.width = wdith + self.height = height + self.link = link + + def validate(self): + validate_str(self.image_url, disallow_none=True) + validate_int(self.width, self.height) + + def render(self): + self.validate() + return self.create_dict_with_non_none_values( + imageUrl=self.image_url, + width=self.width, + height=self.height, + link=self.link.render() if self.link is not None else None + ) + + +class Head(Common): + def __init__(self, title: str): + super().__init__() + self.title = title + + def validate(self): + validate_str(self.title) + + def render(self): + self.validate() + return {'title': self.title} + + +class ImageTitle(Common): + def __init__(self, title: str, description: Optional[str] = None, image_url: Optional[str] = None): + super().__init__() + self.title = title + self.description = description + self.image_url = image_url + + def validate(self): + validate_str(self.title, disallow_none=True) + validate_str(self.description) + + def render(self): + self.validate() + return self.create_dict_with_non_none_values( + title=self.title, + description=self.description, + imageUrl=self.image_url + ) + + +class Item(Common): + def __init__(self, title: str, description: str): + super().__init__() + self.title = title + self.description = description + + def validate(self): + validate_str(self.title, self.description, disallow_none=True) + + def render(self): + self.validate() + return self.create_dict_with_non_none_values( + title=self.title, + description=self.description + ) + + +class ItemList(Common): + def __init__(self, item_list: Optional[list[Item]] = None): + super().__init__() + if item_list is None: + item_list = [] + self._item_list = item_list + + def validate(self): + assert len(self._item_list) > 0, "ItemList에는 최소 1개 이상의 Item이 있어야 합니다." + validate_type(Item, *[self._item_list], disallow_none=True) + + @overload + def add_item(self, item: Item): ... + @overload + def add_item(self, title: str, description: str): ... + + def add_item(self, *args, **kwargs): + if len(args) == 1: + if isinstance(args[0], Item): + self._item_list.append(args[0]) + elif 'description' in kwargs: + self._item_list.append(Item(*args, **kwargs)) + else: + self._item_list.append(Item(*args, **kwargs)) + + def __add__(self, other): + if isinstance(other, Item): + self.add_item(other) + elif isinstance(other, ItemList): + self.add_items(other._item_list) + return self + + def add_items(self, items: list[Item]): + self._item_list.extend(items) + + @overload + def remove_item(self, item: Item): ... + @overload + def remove_item(self, index: int): ... + + def remove_item(self, arg): + if isinstance(arg, Item): + self._item_list.remove(arg) + elif isinstance(arg, int): + self._item_list.pop(arg) + + def render(self): + self.validate() + assert self._item_list is not None + return [item.render() for item in self._item_list] + + +class ItemListSummary(Item): + ... + + +class Profile(Common): + def __init__(self, title: str, image_url: Optional[str] = None, width: Optional[int] = None, + height: Optional[int] = None): + super().__init__() + self.title = title + self.image_url = image_url + self.width = width + self.height = height + + def validate(self): + validate_str(self.title, disallow_none=True) + validate_str(self.image_url) + validate_int(self.width, self.height) + + def render(self): + self.validate() + return self.create_dict_with_non_none_values( + title=self.title, + imageUrl=self.image_url, + width=self.width, + height=self.height + ) diff --git a/aws-sandol-api/api_server/kakao/response.py b/aws-sandol-api/api_server/kakao/response.py new file mode 100644 index 00000000..9015313a --- /dev/null +++ b/aws-sandol-api/api_server/kakao/response.py @@ -0,0 +1,453 @@ +import json +from abc import abstractmethod +from typing import Any, Optional, Union, overload + +from .common import QuickReplies, QuickReply +from .base import BaseModel + + +class BaseResponse(BaseModel): + """카카오톡 응답 객체의 기본 클래스 + + 카카오톡 응답 객체의 퀵리플라이를 처리하는 메서드를 제공합니다. + + Args: + quick_replies (QuickReplies): 퀵리플라이 객체 + + Attributes: + quick_replies (QuickReplies): 퀵리플라이 객체 + response_content_obj (dict): 카카오톡 응답 형태의 객체 + """ + + def __init__( + self, + quick_replies: Optional[QuickReplies] = None): + self.quick_replies = quick_replies + self.response_content_obj: dict = {} + + def validate(self): + """ + 객체를 카카오톡 응답 규칙에 알맞은지 검증합니다. + 검증에 실패할 경우 예외를 발생시킵니다. + + 검증 내용은 카카오톡 응답 규칙에 따라 구현되어야 합니다. + + (예) 객체 속성의 타입이 올바른지, 값이 올바른지, + 응답의 길이가 적절한지 등을 검증합니다. + """ + + if self.quick_replies: + self.quick_replies.validate() + + def create_common_response( + self, + content: Optional[dict | list] = None, + quick_replies: Optional[QuickReplies] = None) -> dict: + """ + 공통 응답 형태를 생성합니다. + + Args: + content (dict | list[dict]): 'outputs'에 포함될 컨텐츠. + 단일 항목 또는 항목의 리스트가 될 수 있습니다. + quick_replies (QuickReplies): 응답에 포함될 QuickReplies 객체. + """ + # content가 단일 항목인 경우 리스트로 감싸기 + if isinstance(content, dict): + content = [content] + + # 공통 응답 형태 구성 + template = self.create_dict_with_non_none_values( + outputs=content if content else [], + quickReplies=quick_replies.get_list( + rendering=True) if quick_replies else None + ) + + # 최종 응답 형태 구성 + response = { + 'version': '2.0', + 'template': template + } + + return response + + def render(self): + """ + 객체를 카카오톡 응답 형식에 알맞게 dict로 변환합니다. + 변환된 dict는 카카오톡 응답 형식에 맞는 최종 응답형태입니다. + """ + self.validate() + if self.quick_replies: + self.quick_replies.render() + + def get_dict(self, rendering: bool = True) -> dict: + """ + 카카오톡 응답 형식에 알맞은 dict를 반환합니다. + + 이 dict는 카카오톡 응답 형식에 맞는 최종 응답형태입니다. + + Returns: + dict: 카카오톡 응답 형태의 딕셔너리 + """ + if rendering: + self.render() + return self.response_content_obj + + def get_json(self, ensure_ascii: bool = True, **kwargs) -> str: + """ + 카카오톡 응답 형식에 알맞은 JSON 문자열을 반환합니다. + + 이 JSON은 카카오톡 응답 형식에 맞는 최종 응답형태입니다. + + Args: + ensure_ascii (bool): json.dumps() 함수에 전달되는 인자입니다. 기본값은 True입니다. + **kwargs: json.dumps() 함수에 전달되는 추가적인 인자들입니다. + """ + return json.dumps( + self.get_dict(), ensure_ascii=ensure_ascii, **kwargs + ) + + def add_quick_replies(self, quick_replies: QuickReplies) -> "BaseResponse": + """ + QuickReplies를 추가합니다. + + Args: + quick_replies (QuickReplies): 추가할 QuickReplies 객체 + + Returns: + BaseResponse: QuickReplies 객체가 추가된 BaseResponse 객체 + """ + if not self.quick_replies: + self.quick_replies = quick_replies + else: + self.quick_replies += quick_replies + return self + + @overload + def add_quick_reply(self, quick_reply: QuickReply) -> "BaseResponse": + """ + QuickReplies에 QuickReply를 객체로 추가합니다. + + Args: + quick_reply (QuickReply): 추가할 QuickReply 객체 + """ + + @overload + def add_quick_reply( + self, + label: str, + action: str, + messageText: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None) -> "BaseResponse": + """ + QuickReplies에 QuickReply를 인자로 객체를 생성하여 추가합니다. + + Args: + label (str): QuickReply의 라벨 + action (str): QuickReply의 액션 + messageText (str): QuickReply의 messageText + blockId (str): QuickReply의 blockId + extra (dict): QuickReply의 extra + """ + + def add_quick_reply(self, *args, **kwargs) -> "BaseResponse": + """ + QuickReplies에 QuickReply를 추가합니다. + + Args: + *args: QuickReply 객체 또는 QuickReply 생성 인자 + **kwargs: QuickReply 객체 또는 QuickReply 생성 인자 + """ + if not self.quick_replies: + self.quick_replies = QuickReplies() + + self.quick_replies.add_quick_reply(*args, **kwargs) + return self + + def remove_quickReplies(self) -> "BaseResponse": + """ + QuickReplies를 삭제합니다. + + Returns: + BaseResponse: QuickReplies 객체가 삭제된 BaseResponse 객체 + """ + self.quick_replies = None + return self + + +class ParentSkill(BaseResponse): + """ + 카카오톡 응답 형태의 객체를 생성하는 추상 클래스 + + Attributes: + response_content_obj (dict): 카카오톡 응답 형태의 객체 + + Raises: + NotImplementedError: render 또는 validate 메서드가 구현되지 않았을 때 + """ + name = "ParentSkill" # 응답 객체의 템플릿 이름 (상속받은 클래스에서 오버라이딩 필요) + + def __init__(self, solo_mode: bool = True): + super().__init__() + self.solo_mode = solo_mode + + @abstractmethod + def get_response_content(self): + """ + 각 하위 클래스에서 이 메서드를 구현하여 구체적인 응답 내용을 반환합니다. + """ + + def validate(self): + """ + 객체를 카카오톡 응답 규칙에 알맞은지 검증합니다. + 검증에 실패할 경우 예외를 발생시킵니다. + + 검증 내용은 카카오톡 응답 규칙에 따라 구현되어야 합니다. + + (예) 객체 속성의 타입이 올바른지, 값이 올바른지, + 응답의 길이가 적절한지 등을 검증합니다. + """ + if "Parent" in self.__class__.name: + raise NotImplementedError("name 속성을 구현해야 합니다.") + + def render(self) -> dict: + """ + 객체의 응답 내용을 카카오톡 포맷에 맞게 변환합니다. 상속받은 클래스는 + get_response_content 메서드로 구체적인 내용을 정의해야 합니다. + solo_mode가 True일 경우, 카카오톡 전체 응답 형식에 맞게 변환하여 반환합니다. + """ + self.validate() + + if self.solo_mode: + content = {self.__class__.name: self.get_response_content()} + self.response_content_obj = self.create_common_response( + content=content, quick_replies=self.quick_replies) + + else: + self.response_content_obj = self.get_response_content() + + return self.response_content_obj + + def get_dict(self, rendering: bool = True) -> dict: + """ + 카카오톡 응답 형식에 알맞은 dict를 반환합니다. + + Returns: + dict: 카카오톡 응답 형태의 딕셔너리 + """ + if rendering: + self.render() + return self.response_content_obj + + @property + def is_empty(self) -> bool: + """ + 응답 객체가 비어있는지 여부를 반환합니다. + + Returns: + bool: 응답 객체가 비어있으면 True, 아니면 False + """ + return not bool(self.response_content_obj) + + @overload + def __add__(self, other: QuickReplies) -> "ParentSkill": ... + + @overload + def __add__(self, other: "ParentSkill") -> "KakaoResponse": ... + + def __add__( + self, + other: Union[QuickReplies, "ParentSkill"] + ) -> Union["ParentSkill", "KakaoResponse"]: + """합 연산자 정의 + + QuickReplies, ParentSkill 객체를 합할 수 있습니다. + QuickReplies 객체를 추가할 경우, 현재 객체의 QuickReplies에 해당 객체를 추가합니다. + ParentSkill 객체를 추가할 경우, KakaoResponse 객체를 생성하여 + 현재 객체와 추가할 객체를 합친 객체를 반환합니다. + + Args: + other : 추가할 카카오톡 응답 객체 + + Returns: + BaseResponse: 응답 객체가 추가된 BaseResponse + """ + if isinstance(other, QuickReplies): # QuickReplies 객체를 추가할 경우 + self.add_quick_replies(other) + + if isinstance(other, ParentSkill): # ParentSkill 객체를 추가할 경우 + skills = SkillList(self, other) + multi = KakaoResponse(skills) + + # 퀵리플라이가 있는 경우 합쳐줌 + if self.quick_replies: + multi.add_quick_replies(self.quick_replies) + if other.quick_replies: + multi.add_quick_replies(other.quick_replies) + return multi + return self + + +class SkillList(BaseModel): + """ + 여러 ParentSkill을 합치는 클래스 + 각 Skill을 카카오 응답 형식에 맞게 리스트에 담아 변환합니다. + + Args: + ParentSkill : ParentSkill 객체들 + """ + + def __init__(self, *skills: ParentSkill): + super().__init__() + self.skills: list[ParentSkill] = [] + if skills: + self.skills = list(skills) + self.response_content_obj: list[dict[str, Any]] = [] + self.set_solomode() + + def validate(self): + """ + 객체를 카카오톡 응답 규칙에 알맞은지 검증합니다. + """ + for skill in self.skills: + assert isinstance(skill, ParentSkill) + assert not skill.solo_mode + skill.validate() + + def set_solomode(self, solo_mode: bool = False): + for skill in self.skills: + skill.solo_mode = solo_mode + + def render(self): + self.set_solomode() + self.validate() + self.response_content_obj = [ + {skill.__class__.name: skill.render()} for skill in self.skills] + return self.response_content_obj + + def get_list(self, rendering: bool = True) -> list: + if rendering: + self.render() + return self.response_content_obj + + def add_skill(self, skill: ParentSkill): + self.skills.append(skill) + return self + + def __add__(self, skill_list: "SkillList") -> "SkillList": + self.skills.extend(skill_list.skills) + return self + + def __len__(self) -> int: + return len(self.skills) + + +class KakaoResponse(BaseResponse): + def __init__( + self, + skill_list: Optional[SkillList] = None, + quick_replies: Optional[QuickReplies] = None): + super().__init__(quick_replies) + if skill_list is None: + skill_list = SkillList() + self.skill_list = skill_list + self.max_skill_count = 3 + + def validate(self): + super().validate() + self.skill_list.validate() + if len(self.skill_list) > self.max_skill_count: + raise ValueError( + f"최대 {self.max_skill_count}개의 ParentSkill을 포함할 수 있습니다.") + + def render(self): + self.validate() + super().render() + self.skill_list.render() + content_list = self.skill_list.get_list() + self.response_content_obj = self.create_common_response( + content=content_list, quick_replies=self.quick_replies) + return self.response_content_obj + + def add_skill(self, skill: "ParentSkill") -> "KakaoResponse": + """ + ParentSkill을 추가합니다. + + Args: + skill (ParentSkill): 추가할 ParentSkill 객체 + + Returns: + BaseResponse: ParentSkill 객체가 추가된 BaseResponse 객체 + """ + from .skill import Carousel # 순환 참조 방지 + skill.solo_mode = False + if isinstance(skill, Carousel): + if skill.is_empty: + return self + skill.set_solomode() + self.skill_list.add_skill(skill) + return self + + @property + def is_empty(self) -> bool: + """ + 응답 템플릿이 비어있는지 여부를 반환합니다. + + Returns: + bool: 응답 템플릿이 비어있으면 True, 아니면 False + """ + return self.skill_list is None or len(self.skill_list) == 0 + + @overload + def __add__(self, other: QuickReplies) -> BaseResponse: ... + + @overload + def __add__(self, other: ParentSkill) -> "KakaoResponse": ... + + @overload + def __add__(self, other: SkillList) -> "KakaoResponse": ... + + @overload + def __add__(self, other: "KakaoResponse") -> "KakaoResponse": ... + + def __add__( + self, + other: Union[QuickReplies, ParentSkill, SkillList, "KakaoResponse"] + ) -> Union["KakaoResponse", BaseResponse]: + """합 연산자 정의 + + QuickReplies, ParentSkill, SkillList, KakaoResponse 객체를 합할 수 있습니다. + QuickReplies 객체를 추가할 경우, 현재 객체의 QuickReplies에 해당 객체를 추가합니다. + ParentSkill 객체를 추가할 경우, 현재 객체의 SkillList에 해당 객체를 추가합니다. + SkillList 객체를 추가할 경우, 현재 객체의 SkillList에 해당 객체를 추가합니다. + KakaoResponse 객체를 추가할 경우, 현재 객체의 SkillList에 해당 객체의 SkillList를 추가하고, + 현재 객체의 QuickReplies에 해당 객체의 QuickReplies를 추가합니다. + + Args: + other (QuickRelies): 추가할 QuickReplies 객체 + other (ParentSkill): 추가할 ParentSkill 객체 + other (SkillList): 추가할 SkillList 객체 + other (KakaoResponse): 추가할 KakaoResponse 객체 + + Returns: + KakaoResponse: 객체가 추가된 KakaoResponse 객체 + """ + if isinstance(other, QuickReplies): + self.add_quick_replies(other) + + if isinstance(other, ParentSkill): + self.add_skill(other) + self.skill_list.set_solomode() + + if isinstance(other, SkillList): + self.skill_list += other + self.skill_list.set_solomode() + + if isinstance(other, KakaoResponse): + self.skill_list += other.skill_list + self.skill_list.set_solomode() + + # 퀵리플라이가 있는 경우 합쳐줌 + if other.quick_replies: + self.add_quick_replies(other.quick_replies) + return self diff --git a/aws-sandol-api/api_server/kakao/skill.py b/aws-sandol-api/api_server/kakao/skill.py new file mode 100644 index 00000000..46e3b36d --- /dev/null +++ b/aws-sandol-api/api_server/kakao/skill.py @@ -0,0 +1,453 @@ + +from abc import abstractmethod +from typing import Optional, overload + +from .itemcard import Head, ImageTitle, Item, ItemList, ItemListSummary + +from .validatiion import validate_str, validate_type +from .common import ( + Action, Button, Buttons, ListItem, ListItems, Profile, Thumbnail, + Thumbnails) +from .response import ParentSkill + + +class Carousel(ParentSkill): + name = "carousel" + + def __init__( + self, + items: Optional[list[ParentSkill]] = None, + solo_mode: bool = True): + + if items is None: + items = [] + + super().__init__(solo_mode) + + self.items = items + self.type = None + if not self.is_empty: + self.type = type(self.items[0]) + self.set_solomode() + + @property + def is_empty(self): + return len(self.items) == 0 + + def add_item(self, item: ParentSkill): + if self.is_empty: + self.type = type(item) + else: + assert self.type is not None + assert isinstance( + item, self.type), "Carousel 내부의 객체는 동일한 타입이어야 합니다." + + item.solo_mode = False + self.items.append(item) + + def remove_item(self, item: ParentSkill): + self.items.remove(item) + + def validate(self): + super().validate() + assert len(self.items) > 0, "Carousel은 최소 1개의 객체를 포함해야 합니다." + assert self.type is not None + validate_type( + (TextCard, BasicCard, CommerceCard, ListCard, ItemCard), + self.type(), + disallow_none=True, + ) + validate_type(self.type, *self.items, disallow_none=True) + for skill in self.items: + assert ( + not skill.solo_mode + ), ( + "Carousel 내부의 객체는 solo_mode가 False여야 합니다." + ) + skill.validate() + + def set_solomode(self, solo_mode: bool = False): + for skill in self.items: + skill.solo_mode = solo_mode + + def get_response_content(self): + assert self.type is not None + return { + "type": self.type.name, + "items": [skill.get_response_content() for skill in self.items] + } + + +class SimpleTextResponse(ParentSkill): + """ + 카카오톡 응답 형태 SimpleText의 객체를 생성하는 클래스 + + Attributes: + text (str): 응답할 텍스트 + + Raises: + ValueError: text가 문자열이 아닌 경우 + """ + name = "simpleText" + + def __init__(self, text: str, solo_mode: bool = True): + super().__init__(solo_mode) + self.text = text + + def validate(self): + super().validate() + return validate_str(self.text) + + def get_response_content(self): + return { + "text": self.text + } + + +class SimpleImageResponse(ParentSkill): + """ + 카카오톡 응답 형태 SimpleImage의 객체를 생성하는 클래스 + + Attributes: + image_url (str): 이미지의 URL + alt_text (str): 대체 텍스트 + + Raises: + ValueError: image_url, alt_text가 문자열이 아닌 경우 + """ + name = "simpleImage" + + def __init__(self, image_url: str, alt_text: str): + super().__init__() + self.image_url = image_url + self.alt_text = alt_text + + def validate(self): + super().validate() + return validate_str(self.image_url, self.alt_text) + + def get_response_content(self): + return { + "imageUrl": self.image_url, + "altText": self.alt_text + } + + +class ParentCard(ParentSkill): + """ + 부모 카드 클래스입니다. + + Attributes: + buttons (Buttons): 버튼 객체입니다. + """ + + def __init__(self, buttons: Optional[Buttons] = None): + super().__init__() + self.buttons = buttons + + def set_buttons(self, buttons: Buttons): + """ + 버튼을 설정합니다. + + Parameters: + buttons (Buttons): 설정할 버튼 객체 + """ + self.buttons = buttons + + def validate(self): + super().validate() + validate_type(Buttons, self.buttons) + + @overload + def add_button(self, button: Button) -> None: ... + + @overload + def add_button( + self, + label: str, + action: str | Action, + webLinkUrl: Optional[str] = None, + message_text: Optional[str] = None, + phoneNumber: Optional[str] = None, + blockId: Optional[str] = None, + extra: Optional[dict] = None): ... + + def add_button(self, *args, **kwargs) -> None: + """버튼을 추가합니다. + + Button 객체 또는 Button 생성 인자를 받아 버튼 리스트에 추가합니다. + + Button 객체를 받은 경우 + Args: + button (Button): 추가할 Button 객체 + + Button 생성 인자를 받은 경우 + Args: + label (str): 버튼의 텍스트 + action (str): 버튼의 동작 + webLinkUrl (str): 웹 링크 + messageText (str): 메시지 + phoneNumber (str): 전화번호 + blockId (str): 블록 ID + extra (dict): 스킬 서버에 추가로 전달할 데이터 + + Raises: + InvalidTypeError: 받거나 생성한 Button 객체가 Button이 아닌 경우 + """ + if self.buttons is None: + self.buttons = Buttons() + self.buttons.add_button(*args, **kwargs) + + def remove_button(self, button): + """ + 버튼을 제거합니다. + + Parameters: + button: 제거할 버튼 객체 + """ + assert self.buttons is not None + self.buttons.delete_button(button) + + @abstractmethod + def get_response_content(self): ... + + +class TextCard(ParentCard): + """ + 카카오톡 응답 형태 TextCard의 객체를 생성하는 클래스 + + Args: + title (Optional[str], optional): 카드 제목. Defaults to None. + description (Optional[str], optional): 카드 설명. Defaults to None. + buttons (Optional[Buttons], optional): 카드 버튼 정보. Defaults to None. + """ + name = "textCard" + + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + buttons: Optional[Buttons] = None): + super().__init__(buttons=buttons) + self.title = title + self.description = description + + def validate(self): + """ + 객체가 카카오톡 응답 형태에 맞는지 확인합니다. + + title과 description 중 최소 하나는 None이 아니어야 합니다. + """ + super().validate() + if self.title is None and self.description is None: + raise ValueError( + "title과 description 중 최소 하나는 None이 아니어야 합니다.") + if self.title is None: + validate_str(self.description) + else: + validate_str(self.title) + + def get_response_content(self): + return self.create_dict_with_non_none_values( + title=self.title, + description=self.description, + buttons=self.buttons.render() if self.buttons else None + ) + + +class BasicCard(ParentCard): + """ + 카카오톡 응답 형태 BasicCard의 객체를 생성하는 클래스 + + Args: + thumbnail (Thumbnail): 썸네일 이미지 정보 + title (Optional[str] optional): 카드 제목. Defaults to None. + description (Optional[str] optional): 카드 설명. Defaults to None. + buttons (Optional[Buttons] optional): 카드 버튼 정보. Defaults to None. + forwardable (bool, optional): 카드 전달 가능 여부. Defaults to False. + """ + name = "basicCard" + + def __init__( + self, + thumbnail: Thumbnail, + title: Optional[str] = None, + description: Optional[str] = None, + buttons: Optional[Buttons] = None, + forwardable: bool = False): + super().__init__(buttons=buttons) + self.thumbnail = thumbnail + self.title = title + self.description = description + self.forwardable = forwardable + + def validate(self): + super().validate() + validate_str(self.title, self.description) + + def get_response_content(self): + return self.create_dict_with_non_none_values( + thumbnail=self.thumbnail.render(), + title=self.title, + description=self.description, + buttons=self.buttons.render() if self.buttons else None, + forwardable=self.forwardable + ) + + +class CommerceCard(ParentCard): + + name = "commerceCard" + + def __init__( + self, + price: int, + thumbnails: Thumbnails, + title: Optional[str] = None, + description: Optional[str] = None, + buttons: Optional[Buttons] = None, + profile: Optional[Profile] = None, + currency: Optional[str] = None, + discount: Optional[str] = None, + discount_rate: Optional[str] = None, + discount_price: Optional[str] = None, + ): + super().__init__(buttons=buttons) + self.price = price + self.thumbnails = thumbnails + self.title = title + self.description = description + self.currency = currency + self.discount = discount + self.discount_rate = discount_rate + self.discount_price = discount_price + self.profile = profile + + def validate(self): + super().validate() + + def get_response_content(self): + return self.create_dict_with_non_none_values( + price=self.price, + thumbnails=self.thumbnails.render(), + title=self.title, + description=self.description, + currency=self.currency, + discount=self.discount, + discountRate=self.discount_rate, + discountPrice=self.discount_price, + profile=self.profile.render() if self.profile else None, + buttons=self.buttons.render() if self.buttons else None, + ) + + +class ListCard(ParentCard): + + name = "listCard" + + def __init__( + self, + header: ListItem | str, + items: ListItems, + buttons: Optional[Buttons] = None,): + super().__init__(buttons=buttons) + if isinstance(header, str): + header = ListItem(title=header) + else: + self.header = header + self.items = items + + def validate(self): + super().validate() + validate_type(self.header, ListItem) + validate_type(self.items, ListItems) + + def get_response_content(self): + return self.create_dict_with_non_none_values( + header=self.header.render(), + items=self.items.render(), + buttons=self.buttons.render() if self.buttons else None, + ) + + +class ItemCard(ParentCard): + + name = "itemCard" + + def __init__( + self, + item_list: ItemList, + thumbnail: Optional[Thumbnail] = None, + head: Optional[Head] = None, + profile: Optional[Profile] = None, + image_title: Optional[ImageTitle] = None, + item_list_alignment: Optional[str] = None, + item_list_summary: Optional[ItemListSummary] = None, + title: Optional[str] = None, + description: Optional[str] = None, + buttons: Optional[Buttons] = None, + buttonLayout: Optional[str] = None): + super().__init__(buttons=buttons) + self.item_list = item_list + self.thumbnail = thumbnail + self.head = head + self.profile = profile + self.image_title = image_title + self.item_list_alignment = item_list_alignment + self.item_list_summary = item_list_summary + self.title = title + self.description = description + self.buttonLayout = buttonLayout + + def validate(self): + super().validate() + validate_type(self.item_list, ItemList) + validate_type(self.thumbnail, Thumbnail) + validate_type(self.head, Head) + validate_type(self.profile, Profile) + validate_type(self.image_title, ImageTitle) + validate_type(self.item_list_summary, ItemListSummary) + validate_str(self.title, self.description, self.buttonLayout) + + def get_response_content(self): + assert self.thumbnail is not None + assert self.head is not None + assert self.profile is not None + assert self.image_title is not None + assert self.item_list is not None + assert self.item_list_summary is not None + return self.create_dict_with_non_none_values( + thumbnail=self.thumbnail.render(), + head=self.head.render(), + profile=self.profile.render(), + imageTitle=self.image_title.render(), + item_list=self.item_list.render(), + itemListAlignment=self.item_list_alignment, + itemListSummary=self.item_list_summary.render(), + title=self.title, + description=self.description, + buttonLayout=self.buttonLayout, + buttons=self.buttons.render() if self.buttons else None, + ) + + @overload + def add_item(self, item: Item): ... + @overload + def add_item(self, title: str, description: str): ... + + def add_item(self, *args, **kwargs): + self.item_list.add_item(*args, **kwargs) + + @overload + def remove_item(self, item: Item): ... + @overload + def remove_item(self, index: int): ... + + def remove_item(self, arg): + self.item_list.remove_item(arg) + + +if __name__ == "__main__": + # 사용 예시 + simple_text_response = SimpleTextResponse("이것은 간단한 텍스트 메시지입니다.") + print(simple_text_response.get_dict()) diff --git a/aws-sandol-api/api_server/kakao/validatiion.py b/aws-sandol-api/api_server/kakao/validatiion.py new file mode 100644 index 00000000..deb8a56e --- /dev/null +++ b/aws-sandol-api/api_server/kakao/validatiion.py @@ -0,0 +1,27 @@ +from .customerror import InvalidTypeError + + +def validate_type( + allowed_types: tuple | object, + *args, + disallow_none: bool = False, + exception_type=InvalidTypeError): + """특정 타입에 대해 유효성 검사를 하는 함수""" + if not isinstance(allowed_types, tuple): + allowed_types = (allowed_types,) + for value in args: + if disallow_none and value is None: + raise exception_type("None이어서는 안됩니다.") + + if value is not None and not isinstance(value, allowed_types): + raise exception_type(f"{value}는 {allowed_types} 중 하나여야 합니다.") + + +def validate_str(*args, disallow_none: bool = False): + """여러 인자에 대해 문자열인지 확인하는 함수""" + validate_type(str, *args, disallow_none=disallow_none) + + +def validate_int(*args, disallow_none: bool = False): + """여러 인자에 대해 정수형인지 확인하는 함수""" + validate_type(int, *args, disallow_none=disallow_none) diff --git a/aws-sandol-api/api_server/settings.py b/aws-sandol-api/api_server/settings.py new file mode 100644 index 00000000..4af4faab --- /dev/null +++ b/aws-sandol-api/api_server/settings.py @@ -0,0 +1,13 @@ +"""응답에 사용되는 상수들을 정의합니다.""" +from .kakao.response import QuickReply +from .kakao.skill import TextCard + +# 도움말 QuickReply +HELP = QuickReply( + label="도움말", + messageText="도움말" +) + +# TIP와 E동 식당 웹페이지 TextCard +CAFETERIA_WEB = TextCard( + "TIP 및 E동", "https://ibook.kpu.ac.kr/Viewer/menu01") diff --git a/aws-sandol-api/api_server/tests/__init__.py b/aws-sandol-api/api_server/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aws-sandol-api/api_server/tests/test_app.py b/aws-sandol-api/api_server/tests/test_app.py new file mode 100644 index 00000000..e69de29b diff --git a/aws-sandol-api/api_server/utils.py b/aws-sandol-api/api_server/utils.py new file mode 100644 index 00000000..fdac0467 --- /dev/null +++ b/aws-sandol-api/api_server/utils.py @@ -0,0 +1,32 @@ +"""API 서버에서 사용하는 유틸리티 함수들을 정의합니다.""" +from sandol.crawler.cafeteria_view import Restaurant +from .kakao.skill import Carousel, TextCard + + +def make_meal_cards( + restaurants: list[Restaurant]) -> tuple[Carousel, Carousel]: + """ + 주어진 식당 목록에 대해 TextCard들을 생성하여 + 점심 Carousel과 저녁 Carousel을 생성합니다. + + Args: + restaurants (list[Restaurant]): 식당 정보 리스트 + + Returns: + tuple[Carousel, Carousel]: 점심 Carousel, 저녁 Carousel + """ + lunch = Carousel() + dinner = Carousel() + for restaurant in restaurants: + + if restaurant.lunch: # 점심 식단 정보가 있을 경우에만 추가 + lunch.add_item(TextCard( + title=f"{restaurant.name}(점심)", + description="\n".join(restaurant.lunch) + )) + if restaurant.dinner: # 저녁 식단 정보가 있을 경우에만 추가 + dinner.add_item(TextCard( + title=f"{restaurant.name}(저녁)", + description="\n".join(restaurant.dinner) + )) + return lunch, dinner diff --git a/aws-sandol-api/app.py b/aws-sandol-api/app.py index 9f09f9f4..c0a81918 100644 --- a/aws-sandol-api/app.py +++ b/aws-sandol-api/app.py @@ -1,18 +1,81 @@ -from flask import Flask, jsonify, make_response +"""Sandol API 서버를 실행하는 파일입니다.""" +from flask import Flask, request + + +from .api_server import HELP, CAFETERIA_WEB, make_meal_cards +from .api_server.kakao import Payload +from .api_server.kakao.response import KakaoResponse +from .api_server.kakao.skill import TextCard +from .crawler import Restaurant, get_meals app = Flask(__name__) @app.route("/") -def hello_from_root(): - return jsonify(message='Hello from root!') +def root(): + return "Hello Sandol" + + +@app.post("/meal/view") +def meal_view(): + """식단 정보를 Carousel TextCard 형태로 반환합니다.""" + assert request.json is not None + payload = Payload.from_dict(request.json) # 요청 Payload를 파싱합니다. + + # payload에서 cafeteria 값 추출 + cafeteria = getattr(payload.params, "식당", None) or getattr( + payload.params, "cafeteria", None) + target_cafeteria = getattr(cafeteria, "value", None) + + # 식당 정보를 가져옵니다. + cafeteria_list: list[Restaurant] = get_meals() + + # cafeteria 값이 있을 경우 해당 식당 정보로 필터링 + if target_cafeteria: + if target_cafeteria in ["미가", "세미콘", "수호"]: + # 식단 정보를 해당 식당 정보로 필터링 + restaurants = [ + r for r in cafeteria_list if r.name == target_cafeteria] + else: + # TIP 또는 E동 식당인 경우 + return CAFETERIA_WEB.get_json() + else: + # cafeteria 값이 없을 경우 전체 식당 정보 반환 + restaurants = cafeteria_list + + # 점심과 저녁 메뉴를 담은 Carousel 생성 + lunch_carousel, dinner_carousel = make_meal_cards(restaurants) + + response = KakaoResponse() # 응답 객체 생성 + + # 점심과 저녁 메뉴 Carousel을 SkillList에 추가 + # 모듈에서 자동으로 비어있는 Carousel은 추가하지 않음 + response.add_skill(lunch_carousel) + response.add_skill(dinner_carousel) + if not cafeteria or cafeteria.value not in ["미가", "세미콘", "수호"]: + response.add_skill(CAFETERIA_WEB) + + # 식단 정보가 없는 경우 정보 없음 TextCard 추가 + if response.is_empty: + response.add_skill(TextCard("식단 정보가 없습니다.")) + # 도움말 추가 + response.add_quick_reply(HELP) + response.add_quick_reply( + label="모두 보기", + action="message", + messageText="테스트 학식", + ) + for rest in cafeteria_list: + if rest.name != target_cafeteria: + response.add_quick_reply( + label=rest.name, + action="message", + messageText=f"테스트 학식 {rest.name}", + ) -@app.route("/hello") -def hello(): - return jsonify(message='Hello from path!') + return response.get_json() -@app.errorhandler(404) -def resource_not_found(e): - return make_response(jsonify(error='Not found!'), 404) +if __name__ == "__main__": + app.run() diff --git a/aws-sandol-api/crawler/__init__.py b/aws-sandol-api/crawler/__init__.py new file mode 100644 index 00000000..3fece8af --- /dev/null +++ b/aws-sandol-api/crawler/__init__.py @@ -0,0 +1,3 @@ +from .cafeteria_view import Restaurant, get_meals + +__all__ = ["Restaurant", "get_meals"] diff --git a/aws-sandol-api/crawler/cafeteria_view.py b/aws-sandol-api/crawler/cafeteria_view.py new file mode 100644 index 00000000..24305125 --- /dev/null +++ b/aws-sandol-api/crawler/cafeteria_view.py @@ -0,0 +1,124 @@ +import os +import json +from . import settings + + +class Restaurant: # 식당 개체 생성(정보: 아이디, 식당명, 점심리스트, 저녁리스트, 교내외 위치) + def __init__(self, name, lunch, dinner, location): + self.name = name + self.lunch = lunch + self.dinner = dinner + self.location = location + self.id = "" + self.temp = [] + self.menu = [] + self.final_menu = [] + + @classmethod + def by_id(cls, identification): # 식당 별 access id 조회, 식당 이름으로 객체 생성. + # settings. RESTAURANT_ACCESS_ID : {id : name} + restaurant_name = settings.RESTAURANT_ACCESS_ID.get(identification) + + if restaurant_name: + # test.json : {id:"", name: "", lunch : "" ...} + current_dir = os.path.dirname(__file__) + filename = os.path.join(current_dir, 'test.json') + + with open(filename, 'r', encoding='utf-8') as file: + data = json.load(file) + + for restaurant_data in data: + # id 검사 + if restaurant_data["identification"] == identification: + class_name = f"{restaurant_data['name']}" + # 클래스 이름을 각 식당명으로 규정 + new_class = type(class_name, (Restaurant,), {}) + # 생성된 클래스로 객체를 생성하여 반환 + return new_class(restaurant_data["name"], restaurant_data["lunch_menu"], + restaurant_data["dinner_menu"], restaurant_data["location"]) + + else: + raise ValueError(f"해당 식당을 찾을 수 없습니다. ID: '{identification}'") + + def add_menu(self, meal_time, menu): # 단일 메뉴 추가 메서드 + if meal_time.lower() == "lunch": + self.lunch.append(menu) + elif meal_time.lower() == "dinner": + self.dinner.append(menu) + else: + print("[ValueError]: First value should be 'lunch' or 'dinner'.") + + def delete_menu(self, meal_time, menu): # 단일 메뉴 삭제 메서드 + if meal_time.lower() == "lunch": + if menu in self.lunch: + self.lunch.remove(menu) + else: + print("해당 메뉴는 등록되지 않은 메뉴입니다.") + + elif meal_time.lower() == "dinner": + if menu in self.dinner: + self.dinner.remove(menu) + else: + print("해당 메뉴는 등록되지 않은 메뉴입니다.") + + else: + print("[ValueError]: First value should be 'lunch' or 'dinner'.") + + def get_temp_menus(self) -> dict: # 임시저장 메뉴 불러오기 + self.temp_menu = {"lunch": self.lunch, "dinner": self.dinner} + return self.temp_menu + + def submit(self) -> dict: # 확정 메뉴 불러오기 및 temp clear + self.final_menu = {"lunch": self.lunch, "dinner": self.dinner} + # 초기화 + self.lunch = [] + self.dinner = [] + return self.final_menu + + +def get_meals() -> list: + current_dir = os.path.dirname(__file__) + filename = os.path.join(current_dir, 'test.json') + + with open(filename, 'r', encoding='utf-8') as file: + data = json.load(file) + + # 식당 목록 리스트 + restaurants = [] + + for item in data: + name = item.get('name', '') + lunch = item.get('lunch_menu', []) + dinner = item.get('dinner_menu', []) + location = item.get('location', '') + + class_name = f"{item['name']}" + new_class = type(class_name, (Restaurant,), {}) # 클래스 이름을 각 식당명으로 규정 + + restaurant = new_class(name, lunch, dinner, location) + restaurants.append(restaurant) # 식당 객체 -> 식당 목록 리스트에 추가 + + return restaurants + + +if __name__ == "__main__": + identification = "32d8a05a91242ffb4c64b5630ec55953121dffd83a121d985e26e06e2c457197e6" + rest = Restaurant.by_id(identification) + + # restaurants = get_meals() + # print(restaurants) + # + # restaurants.append(rest) + # print(restaurants) + # + # print(rest.get_temp_menus()) + # + # + # rest.add_menu("lunch", "kimchi") + # print(rest.get_temp_menus()) + # + # rest.delete_menu("dinner", "비빔밥") + # + # + # print(rest.submit()) + # print(rest.get_temp_menus()) diff --git a/aws-sandol-api/crawler/settings.py b/aws-sandol-api/crawler/settings.py new file mode 100644 index 00000000..1833d7d0 --- /dev/null +++ b/aws-sandol-api/crawler/settings.py @@ -0,0 +1,54 @@ +import os +import json + +##2024-04-04 기나혜 +##cafeteria 개발 중에 bucket download 관련 애로사항이 있어 +##이전 베타 산돌에 있던 settings 코드를 잠시 긁어왓씁니다.. +##추후수정 or 폐기예정 + +# Settings +_PATH = os.path.abspath(os.path.dirname(__file__)) # 프로젝트 절대경로 +DEBUG = False # True 일때 디버그 모드 작동 + +# BUCKET +BUCKET_NAME: str = 'aws-sandol-bucket' + +# Access Token +SANDOL_ACCESS_ID: dict = {'MANAGER': "d367f2ec55f41b4207156f4b8fce5ce885b05d8c3b238cf8861c55a9012f6f5895", + 'CONT1': "339b0444bfabbffa0f13508ea7c45b61675b5720234cca8f73cd7421c22de9e546", + 'CONT2': "04eabc8b965bf5ae6cccb122a18521969cc391162e3fd5f61b85efe8bb12e5e98a", + 'CONT3': "def99464e022b38389697fe68d54bbba723d1da291094c19bbf5eaace7b059a997", + 'MINJU': "1ddffbb02b29a6beb212d2f6fe2469523b9a90f260344b9d30677abcd977e1b56c", + 'YURIM': "16b3777b75026553a1807d1f361773e080e6441d30c65ea6e89dd0b84c9b58f071", + 'HAMIN': "c0657ad2b0ade045e546d8abb33f45d85f3c826ce797800e0bf25aac0652bf175c", + 'JDONG' : "cbdb6ec7c1427fd603a9c87ee5a1f7d1cc948ca896a2d65f88c770aa742218cef0" + } +# 산돌팀만 접근할 수 있는 컨텐츠에 인증 수단으로 사용 (현재 아이디의 정확한 위치가 기억이 나지 않아.. KEY를 메니저와, CONTRIBUTOR로 명명함.) + +RESTAURANT_ACCESS_ID: dict = {"32d8a05a91242ffb4c64b5630ec55953121dffd83a121d985e26e06e2c457197e6": "미가식당", + "380da59920b84d81eb8c444e684a53290021b38f544fe0029f4b38ab347bc44e08": "세미콘식당" + } + + + +# RESTAURANT_MENU: str = "restaurant_menu.txt" # 학식이 저장된 파일 이름 (Bucket) +# LOCAL_RESTAURANT_MENU: str = "/tmp/" + RESTAURANT_MENU # 람다 서버의 해당 디렉토리에 불러옴 +# +# FEEDBACK_FILE: str = "feedback.txt" # 피드백이 저장된 파일 이름 +# LOCAL_FEEDBACK_FILE: str = "/tmp/" + FEEDBACK_FILE # 람다 서버 tmp 디렉토리에 불러와 실행 + +# # Urls +# SANDOL_CATEGORY_1: str = "https://github.com/hhhminme/kpu_sandol_team/blob/06916e07fe02d36d3384dfe96c8d2dc4cb300aa7/img/card1.png" # 인기 메뉴 +# SANDOL_CATEGORY_2: str = "https://github.com/hhhminme/kpu_sandol_team/blob/06916e07fe02d36d3384dfe96c8d2dc4cb300aa7/img/card2.png" # 놀거리 +# SANDOL_CATEGORY_3: str = "https://github.com/hhhminme/kpu_sandol_team/blob/06916e07fe02d36d3384dfe96c8d2dc4cb300aa7/img/card3.png" # 교내 정보 +# SANDOL_CATEGORY_4: str = "https://github.com/hhhminme/kpu_sandol_team/blob/main/img/card_other.png" # 기타 기능 +# SANDOL_COVID_IMG: str = "https://raw.githubusercontent.com/hhhminme/kpu_sandol_team/main/img/card_covid.png" # 코로나 +# SANDOL_RSTRNT_FOOD_IMG: str = "https://github.com/hhhminme/kpu_sandol_team/blob/main/img/card_food.png" # 푸드라운지 +# SANDOL_RSTRNT_MIGA_IMG: str = "https://github.com/hhhminme/kpu_sandol_team/blob/main/img/card_miga.png" # 미가식당 +# SANDOL_RSTRNT_MAP: str = "https://raw.githubusercontent.com/hhhminme/kpu_sandol_team/main/img/meal_map.png" # 식당지도 +# SANDOL_LOGO1: str = "https://raw.githubusercontent.com/hhhminme/kpu_sandol_team/main/img/logo1.png" # 산돌이 로고 (필요시 사용) +# SANDOL_PROFILE1: str = "https://github.com/hhhminme/kpu_sandol_team/blob/main/img/logo_profile1.png" # 산돌이 프로필 (필요시 사용) +# +# +# # Others +# diff --git a/aws-sandol-api/crawler/test.json b/aws-sandol-api/crawler/test.json new file mode 100644 index 00000000..06fabf46 --- /dev/null +++ b/aws-sandol-api/crawler/test.json @@ -0,0 +1,32 @@ +[ + { + "identification": "32d8a05a91242ffb4c64b5630ec55953121dffd83a121d985e26e06e2c457197e6", + "name": "미가", + "registration_time": "2024-03-27T10:00:00", + "opening_time": "2024-03-27T11:00:00", + "lunch_menu": ["된장찌개", "제육볶음", "김치찌개"], + "dinner_menu": ["생선구이", "돼지고기국밥", "비빔밥"], + "location": "교외", + "price_per_person": 7000 + }, + { + "identification": "380da59920b84d81eb8c444e684a53290021b38f544fe0029f4b38ab347bc44e08", + "name": "세미콘", + "registration_time": "2024-03-27T09:30:00", + "opening_time": "2024-03-27T11:30:00", + "lunch_menu": ["카레라이스", "짜장면", "갈비탕"], + "dinner_menu": ["삼계탕", "순림"], + "location": "교외", + "price_per_person": 8000 + }, + { + "identification": "unknown", + "name": "수호", + "registration_time": "2024-03-27T08:00:00", + "opening_time": "2024-03-27T10:30:00", + "lunch_menu": ["불고기", "김치찌개", "제육덮밥"], + "dinner_menu": ["해물찜", "양념치킨", "매운탕"], + "location": "교내", + "price_per_person": 7500 + } +] \ No newline at end of file diff --git a/aws-sandol-api/crawler/tests/__init__.py b/aws-sandol-api/crawler/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aws-sandol-api/tests/__init__.py b/aws-sandol-api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aws-sandol-api/tests/test_app.py b/aws-sandol-api/tests/test_app.py new file mode 100644 index 00000000..e7c42dbd --- /dev/null +++ b/aws-sandol-api/tests/test_app.py @@ -0,0 +1,2 @@ +def test_root(): + assert False