# =================================================================== # services/notification/notion.py # Notion 웹훅 처리 서비스 모듈 # =================================================================== # Notion 웹훅 이벤트를 처리하고 알림 메시지를 생성합니다. # 페이지 생성, 수정, 삭제 등의 이벤트를 지원합니다. # =================================================================== """ Notion 웹훅 처리 서비스 모듈 Notion 웹훅 이벤트를 수신하고 처리하여 알림 메시지를 생성합니다. 사용 예시: from services.notification.notion import NotionWebhookHandler handler = NotionWebhookHandler(api_secret) message = handler.handle_event(event_data) """ import json import logging from datetime import datetime from typing import Dict, List, Optional, Any import requests from core.logging_utils import get_logger from core.config import get_config logger = get_logger(__name__) # Notion API 설정 NOTION_API_BASE = "https://api.notion.com/v1" NOTION_VERSION = "2022-06-28" # 이벤트 타입별 라벨 EVENT_TYPE_LABELS = { "page.created": "📝 새 페이지 생성", "page.content_updated": "✏️ 페이지 내용 수정", "page.properties_updated": "🔄 페이지 속성 변경", "page.deleted": "🗑️ 페이지 삭제", "page.restored": "♻️ 페이지 복구", "page.moved": "📁 페이지 이동", "page.locked": "🔒 페이지 잠금", "page.unlocked": "🔓 페이지 잠금 해제", "database.created": "📊 새 데이터베이스 생성", "database.content_updated": "📊 데이터베이스 업데이트", "block.created": "➕ 블록 생성", "block.changed": "📝 블록 변경", "block.deleted": "➖ 블록 삭제", "comment.created": "💬 새 댓글", "comment.updated": "💬 댓글 수정", "comment.deleted": "💬 댓글 삭제", } def get_page_details(page_id: str, api_secret: str) -> Optional[Dict]: """ Notion 페이지 상세 정보 조회 Args: page_id: 페이지 ID api_secret: Notion API 시크릿 Returns: 페이지 상세 정보 또는 None """ url = f"{NOTION_API_BASE}/pages/{page_id}" headers = { "Authorization": f"Bearer {api_secret}", "Notion-Version": NOTION_VERSION, "Content-Type": "application/json" } try: response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: return response.json() else: logger.error(f"페이지 조회 실패: {response.status_code} - {response.text}") return None except Exception as e: logger.error(f"페이지 조회 예외: {e}") return None def get_database_details(database_id: str, api_secret: str) -> Optional[Dict]: """ Notion 데이터베이스 상세 정보 조회 Args: database_id: 데이터베이스 ID api_secret: Notion API 시크릿 Returns: 데이터베이스 상세 정보 또는 None """ url = f"{NOTION_API_BASE}/databases/{database_id}" headers = { "Authorization": f"Bearer {api_secret}", "Notion-Version": NOTION_VERSION, "Content-Type": "application/json" } try: response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: return response.json() else: logger.error(f"데이터베이스 조회 실패: {response.status_code}") return None except Exception as e: logger.error(f"데이터베이스 조회 예외: {e}") return None class NotionWebhookHandler: """ Notion 웹훅 이벤트 핸들러 Notion에서 전송하는 웹훅 이벤트를 처리하고 알림 메시지를 생성합니다. Attributes: api_secret: Notion API 시크릿 allowed_workspaces: 허용된 워크스페이스 이름 목록 """ def __init__( self, api_secret: Optional[str] = None, allowed_workspaces: Optional[List[str]] = None ): """ Args: api_secret: Notion API 시크릿 (None이면 설정에서 로드) allowed_workspaces: 허용된 워크스페이스 목록 (None이면 모두 허용) """ if api_secret is None: config = get_config() api_secret = config.notion.get('api_secret', '') self.api_secret = api_secret self.allowed_workspaces = allowed_workspaces or [] self.headers = { "Authorization": f"Bearer {self.api_secret}", "Notion-Version": NOTION_VERSION, "Content-Type": "application/json" } def fetch_entity_detail(self, entity_type: str, entity_id: str) -> Optional[Dict]: """ 엔티티 상세 정보 조회 Args: entity_type: 엔티티 타입 ('page', 'database', 'block' 등) entity_id: 엔티티 ID Returns: 엔티티 상세 정보 또는 None """ if entity_type == "page": url = f"{NOTION_API_BASE}/pages/{entity_id}" elif entity_type == "database": url = f"{NOTION_API_BASE}/databases/{entity_id}" elif entity_type == "block": url = f"{NOTION_API_BASE}/blocks/{entity_id}" else: logger.warning(f"지원하지 않는 엔티티 타입: {entity_type}") return None try: response = requests.get(url, headers=self.headers, timeout=10) if response.status_code == 200: return response.json() else: logger.error(f"엔티티 조회 실패: {response.status_code}") return None except Exception as e: logger.error(f"엔티티 조회 예외: {e}") return None def extract_title_from_properties(self, properties: Dict) -> str: """ 속성에서 제목 추출 Args: properties: Notion 속성 딕셔너리 Returns: 제목 문자열 (없으면 기본값) """ # 일반적인 제목 속성 이름들 title_keys = ['Name', 'name', '이름', '제목', 'Title', '작업 이름'] for key in title_keys: if key in properties: prop = properties[key] if prop.get('type') == 'title': title_arr = prop.get('title', []) if title_arr: return ''.join(t.get('plain_text', '') for t in title_arr) # title 타입 속성 찾기 for prop in properties.values(): if prop.get('type') == 'title': title_arr = prop.get('title', []) if title_arr: return ''.join(t.get('plain_text', '') for t in title_arr) return "(제목 없음)" def extract_editor_from_properties(self, properties: Dict) -> str: """ 속성에서 편집자 정보 추출 Args: properties: Notion 속성 딕셔너리 Returns: 편집자 이름 (없으면 기본값) """ editor_keys = ['최종 편집자', 'Last edited by', 'Editor'] for key in editor_keys: if key in properties: prop = properties[key] if prop.get('type') == 'last_edited_by': editor = prop.get('last_edited_by', {}) return editor.get('name', '(알 수 없음)') return "(알 수 없음)" def handle_event(self, event: Dict) -> Optional[str]: """ Notion 웹훅 이벤트 처리 Args: event: 웹훅 이벤트 데이터 Returns: 알림 메시지 또는 None (무시할 이벤트) """ # 이벤트 로깅 logger.info(f"수신된 Notion 이벤트: {json.dumps(event, ensure_ascii=False)[:500]}") # 워크스페이스 확인 workspace_id = event.get('workspace_id') if not workspace_id: logger.warning("workspace_id 없음 - 이벤트 무시") return None # 허용된 워크스페이스 확인 (설정된 경우) if self.allowed_workspaces: workspace_name = self.get_workspace_name(workspace_id) if workspace_name not in self.allowed_workspaces: logger.info(f"허용되지 않은 워크스페이스: {workspace_name}") return None # 이벤트 정보 추출 event_type = event.get('type', 'unknown') timestamp = event.get('timestamp') # 시간 포맷팅 readable_time = None if timestamp: try: readable_time = datetime.fromisoformat( timestamp.replace('Z', '+00:00') ).strftime('%Y-%m-%d %H:%M:%S') except Exception: readable_time = timestamp # 엔티티 정보 entity = event.get('entity', {}) entity_id = entity.get('id', 'unknown') entity_type = entity.get('type', 'unknown') # 상세 정보 조회 detail = self.fetch_entity_detail(entity_type, entity_id) if not detail: logger.warning("상세 정보 조회 실패") return None # 정보 추출 properties = detail.get('properties', {}) page_title = self.extract_title_from_properties(properties) editor_name = self.extract_editor_from_properties(properties) page_url = detail.get('url', 'URL 없음') # 이벤트 라벨 event_label = EVENT_TYPE_LABELS.get(event_type, f"📢 Notion 이벤트: `{event_type}`") # 메시지 구성 message = ( f"{event_label}\n" f"- 🕒 시간: {readable_time or '알 수 없음'}\n" f"- 📄 페이지: {page_title}\n" f"- 👤 작업자: {editor_name}\n" f"- 🔗 [바로가기]({page_url})" ) logger.info(f"생성된 메시지: {message[:200]}...") return message def get_workspace_name(self, workspace_id: str) -> str: """ 워크스페이스 이름 조회 Note: Notion API에서 별도의 워크스페이스 조회 엔드포인트가 없어 현재는 하드코딩되어 있습니다. Args: workspace_id: 워크스페이스 ID Returns: 워크스페이스 이름 """ # TODO: 실제 API가 지원되면 구현 return "퍼스트가든" def create_summary_from_page(self, page_data: Dict) -> str: """ 페이지 데이터에서 요약 메시지 생성 Args: page_data: 페이지 상세 데이터 Returns: 요약 메시지 """ properties = page_data.get('properties', {}) title = self.extract_title_from_properties(properties) url = page_data.get('url', 'URL 없음') return f"📌 노션 페이지 업데이트됨\n**제목**: {title}\n🔗 [바로가기]({url})" if __name__ == '__main__': """ Notion 웹훅 처리 서비스 모듈 테스트 사용법: python services/notification/notion.py """ logger.info("=== Notion 웹훅 처리 서비스 모듈 테스트 ===") try: config = get_config() logger.info(f"설정 로드 완료") # 핸들러 초기화 api_secret = config.notion['api_secret'] or "TEST_KEY" handler = NotionWebhookHandler(api_secret) logger.info("\nNotionWebhookHandler 초기화 완료") logger.info("\n제공 기능:") logger.info("- handle_event: 웹훅 이벤트 처리") logger.info("- verify_signature: 요청 서명 검증") logger.info("- generate_message: 알림 메시지 생성") logger.info("\n지원 이벤트:") logger.info("- 페이지 생성 (page.created)") logger.info("- 페이지 수정 (page.updated)") logger.info("- 페이지 삭제 (page.deleted)") logger.info("- 데이터베이스 항목 생성 (object.created)") logger.info("\n✓ Notion 웹훅 처리 서비스 모듈 테스트 완료") except Exception as e: logger.error(f"Notion 모듈 테스트 실패: {e}") import traceback logger.error(traceback.format_exc())