-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.py
More file actions
306 lines (250 loc) · 10.8 KB
/
Copy pathserver.py
File metadata and controls
306 lines (250 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
from __future__ import annotations
import base64
import inspect
import json
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Request
from langsmith import traceable
from pydantic import BaseModel, Field
from agent.graph.workflow import app as langgraph_app
from agent.tools.backend_client import BackendClient
from agent.tools.conversation_store import ConversationStateStore
LOG_DIR = Path(__file__).resolve().parent / ".runtime"
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
RotatingFileHandler(LOG_DIR / "app.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"),
],
)
logger = logging.getLogger(__name__)
TOSS_SECRET_KEY = os.getenv("TOSS_SECRET_KEY", "")
TOSS_API_BASE = "https://api.tosspayments.com/v1"
KAKAO_REST_API_KEY = os.getenv("KAKAO_REST_API_KEY", "")
KAKAO_CHANNEL_PUBLIC_ID = os.getenv("KAKAO_CHANNEL_PUBLIC_ID", "")
STATE_STORE = ConversationStateStore()
server = FastAPI()
def _toss_auth_header() -> str:
encoded = base64.b64encode(f"{TOSS_SECRET_KEY}:".encode()).decode()
return f"Basic {encoded}"
def _verify_toss_signature(request: Request) -> bool:
if os.getenv("TOSS_SKIP_SIGNATURE", "false").lower() == "true":
return True
auth = request.headers.get("Authorization", "")
if not auth.startswith("Basic "):
return False
try:
decoded = base64.b64decode(auth.removeprefix("Basic ")).decode()
return decoded.rstrip(":") == TOSS_SECRET_KEY
except Exception:
return False
async def _fetch_toss_payment(payment_key: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{TOSS_API_BASE}/payments/{payment_key}",
headers={"Authorization": _toss_auth_header()},
)
try:
payload = resp.json()
except Exception:
payload = {"status_code": resp.status_code, "message": "Invalid JSON from Toss"}
if resp.status_code >= 400:
logger.warning("Toss payment lookup returned %s: %s", resp.status_code, payload)
return payload
async def _send_kakao_payment_confirmed(plusfriend_user_key: str, name: str, reserve_date: str, reserve_time: str) -> None:
if not KAKAO_REST_API_KEY or not KAKAO_CHANNEL_PUBLIC_ID or not plusfriend_user_key:
return
message_text = (
f"✅ 결제가 확인되었습니다!\n"
f"{name}님의 예약이 확정되었어요.\n"
f"📅 {reserve_date} {reserve_time}"
)
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
f"https://kapi.kakao.com/v1/api/talk/channels/{KAKAO_CHANNEL_PUBLIC_ID}/messages",
headers={
"Authorization": f"KakaoAK {KAKAO_REST_API_KEY}",
"Content-Type": "application/x-www-form-urlencoded",
},
data={
"uuid": plusfriend_user_key,
"template_object": json.dumps(
{
"object_type": "text",
"text": message_text,
"link": {},
}
),
},
)
def _kakao_response(text: str) -> dict[str, Any]:
return {
"version": "2.0",
"template": {"outputs": [{"simpleText": {"text": text}}]},
}
class KakaoRequest(BaseModel):
userRequest: dict[str, Any]
flow: dict[str, Any] = Field(default_factory=dict)
@traceable(name="handle_image_upload")
async def _handle_image(image_url: str, plusfriend_user_key: str) -> str:
if not image_url or not plusfriend_user_key:
return "이미지 정보가 올바르지 않습니다."
try:
async with httpx.AsyncClient(timeout=10) as client:
img_resp = await client.get(image_url)
img_resp.raise_for_status()
result = BackendClient.upload_booking_image(
image_data=img_resp.content,
plusfriend_user_key=plusfriend_user_key,
)
if result.get("success"):
return "이미지가 예약에 첨부되었습니다 📎"
logger.warning("Image upload rejected by backend: %s", result)
return "이미지 업로드에 실패했습니다. 다시 시도해주세요."
except Exception:
logger.exception("Image handling failed")
return "이미지 처리 중 오류가 발생했습니다. 다시 시도해주세요."
async def _update_langgraph_payment_state(thread_id: str, booking_status: str = "payment_confirmed") -> bool:
values = {
"booking_status": booking_status,
"next_action": "notify_success",
"pending_intent": None,
"pending_missing_fields": [],
"pending_followup_question": None,
}
config = {"configurable": {"thread_id": thread_id}}
try:
if hasattr(langgraph_app, "aupdate_state"):
result = langgraph_app.aupdate_state(config=config, values=values)
if inspect.isawaitable(result):
await result
return True
if hasattr(langgraph_app, "update_state"):
result = langgraph_app.update_state(config=config, values=values)
if inspect.isawaitable(result):
await result
return True
except Exception:
logger.exception("Failed to update LangGraph state for thread_id=%s", thread_id)
return False
logger.warning("LangGraph app does not expose update_state/aupdate_state")
return False
@server.post("/chat")
async def chat(req: KakaoRequest):
user_info = req.userRequest.get("user", {})
utterance = req.userRequest.get("utterance", "")
plusfriend_user_key = user_info.get("properties", {}).get("plusfriendUserKey", "")
thread_id = user_info.get("id") or plusfriend_user_key or "default"
if req.flow.get("trigger", {}).get("type") == "IMAGE_UPLOAD":
response_text = await _handle_image(
image_url=utterance,
plusfriend_user_key=plusfriend_user_key,
)
return _kakao_response(response_text)
persisted_state = STATE_STORE.load(thread_id)
graph_input: dict[str, Any] = {
"user_input": utterance,
"kakao_user_id": user_info.get("id"),
"plusfriend_user_key": plusfriend_user_key,
}
if persisted_state:
graph_input = {**persisted_state, **graph_input}
graph_input["user_input"] = utterance
graph_input["kakao_user_id"] = user_info.get("id")
graph_input["plusfriend_user_key"] = plusfriend_user_key
# 이전 대화가 완료된 상태면 슬롯을 초기화해 새 대화 오염 방지
_TERMINAL_STATUSES = {"payment_confirmed", "cancelled", "updated", "rejected"}
if persisted_state.get("booking_status") in _TERMINAL_STATUSES:
graph_input["slots"] = None
graph_input["missing_fields"] = []
graph_input["intent"] = None
graph_input["booking_status"] = "N/A"
graph_input["is_bookable"] = False
graph_input["next_action"] = None
graph_input["pending_intent"] = None
graph_input["pending_missing_fields"] = []
graph_input["pending_followup_question"] = None
result = await langgraph_app.ainvoke(
graph_input,
config={"configurable": {"thread_id": thread_id}},
)
STATE_STORE.save(thread_id, {**graph_input, **result})
response_text = result.get("response_draft", "죄송합니다, 응답을 생성하지 못했습니다.")
return _kakao_response(response_text)
@server.post("/toss/webhook")
async def toss_webhook(request: Request):
if not _verify_toss_signature(request):
raise HTTPException(status_code=401, detail="Invalid signature")
body = await request.json()
payment_key = body.get("paymentKey")
order_id = body.get("orderId", "")
amount = body.get("amount")
status = body.get("status")
if not payment_key:
raise HTTPException(status_code=400, detail="Missing paymentKey")
if status != "DONE":
return {"result": "ignored", "status": status}
if not order_id.startswith("booking_"):
raise HTTPException(status_code=400, detail="Invalid orderId format")
try:
booking_id = int(order_id.removeprefix("booking_").split("_")[0])
except ValueError:
raise HTTPException(status_code=400, detail="Invalid booking ID")
payment_info = await _fetch_toss_payment(payment_key)
if payment_info.get("status") != "DONE":
raise HTTPException(status_code=400, detail="Payment not confirmed by Toss")
try:
toss_amount = int(payment_info.get("totalAmount"))
webhook_amount = int(amount)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid amount")
if toss_amount != webhook_amount:
raise HTTPException(status_code=400, detail="Amount mismatch")
result = BackendClient.update_payment(
booking_id,
{
"amount": webhook_amount,
"payment_status": "PAID",
"payment_key": payment_key,
},
)
if not result.get("success"):
raise HTTPException(status_code=502, detail="Failed to update payment status")
booking = BackendClient.get_reservation(booking_id)
if not booking.get("success"):
logger.warning("Updated payment but could not load reservation %s: %s", booking_id, booking)
return {"result": "ok", "state_updated": False}
data = booking.get("data") or {}
if isinstance(data.get("data"), dict):
data = data["data"]
kakao_user_id = data.get("kakao_user_id")
if kakao_user_id:
state_updated = await _update_langgraph_payment_state(kakao_user_id)
STATE_STORE.update(
kakao_user_id,
{
"booking_status": "payment_confirmed",
"next_action": "notify_success",
"pending_intent": None,
"pending_missing_fields": [],
"pending_followup_question": None,
},
)
logger.info("LangGraph payment state update for %s: %s", kakao_user_id, state_updated)
else:
logger.warning("Reservation %s has no kakao_user_id; skipping LangGraph update", booking_id)
# 운영 환경에서 채널 푸시를 붙일 수 있도록 남겨둠.
# await _send_kakao_payment_confirmed(
# plusfriend_user_key=data.get("plusfriend_user_key", ""),
# name=data.get("name", "고객"),
# reserve_date=data.get("reserve_date", ""),
# reserve_time=data.get("reserve_time", ""),
# )
return {"result": "ok", "state_updated": bool(kakao_user_id)}