[feat] solution/backend: 카카오 로그인 — 회원번호로 카톡 채널을 코드 없이 잇는다
★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 **같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 카카오로 로그인만 해 두면 채널에
말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.
- external/kakao_identity: 액세스 토큰을 카카오에 되물어 확인한다. ★ 응답의 app_id 를
우리 앱과 대조하는 것이 구글의 aud 검사에 해당한다 — 이게 없으면 남의 앱 토큰으로
우리 계정이 된다. 이름·이메일은 동의 항목이라 못 받아도 로그인은 되게 했다
- auth_service.kakao_login: google_login 과 같은 세 갈래. 이메일이 겹쳐도 자동으로
잇지 않는다(DECISIONS 1 — 계정 선점)
- kakao_link_service.link_by_app_user_id: 자동 매칭. ★ 이미 다른 사장님에게 묶인
카톡은 빼앗지 않는다 — 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다
- ★ 코드 경로는 그대로 둔다: id/pw·구글 가입자에겐 appUserId 가 없고, 봇에 앱키가
안 물린 환경에서는 값 자체가 안 온다
★ 함께 고친 것 — services/agent/tools.py 가 사라진 site_payload._DEFAULT_THEME 를
보고 있었다(c690862 템플릿 정의 통합에서 이름이 없어졌는데 이 한 줄만 남았다).
**대화의 섹션 기능이 통째로 죽어 있었고** 웹훅이 AttributeError 를 삼켜 "지금은
처리할 수 없어요" 로만 보였다 — common/template_catalog.industry_of 로 바꿨다.
test_kakao_link·test_kakao_webhook·test_agent_runtime·test_auth 194 passed
(신규 4: 자동 매칭·미가입자·빼앗지 않음·재진입). 남은 1건은 컨테이너에 실제
GOOGLE_CLIENT_ID 가 있어 나는 기존 실패다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
283970b2ba
commit
ca0bea77a7
@ -119,6 +119,12 @@ KAKAO_LINK_MAX_ATTEMPTS=5
|
|||||||
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
|
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
|
||||||
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
|
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
|
||||||
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
|
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
|
||||||
|
# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
|
||||||
|
# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
|
||||||
|
# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
|
||||||
|
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다.
|
||||||
|
KAKAO_LOGIN_APP_ID=
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|
||||||
# CORS 허용 오리진. 쉼표로 여럿.
|
# CORS 허용 오리진. 쉼표로 여럿.
|
||||||
|
|||||||
@ -38,6 +38,21 @@
|
|||||||
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
||||||
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
||||||
|
|
||||||
|
## 카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30)
|
||||||
|
|
||||||
|
★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
|
||||||
|
(카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면
|
||||||
|
채널에 말을 거는 순간 누구인지 알 수 있다 — `kakao_link_service.link_by_app_user_id`.
|
||||||
|
|
||||||
|
★ **같은 앱이어야 한다.** 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서
|
||||||
|
**로그인은 되는데 매칭만 조용히 안 된다**. 증상이 안 보이는 종류다.
|
||||||
|
|
||||||
|
★ **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 `appUserId` 가 없고,
|
||||||
|
봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다.
|
||||||
|
|
||||||
|
★ 그 카톡이 **이미 다른 사장님**에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이
|
||||||
|
남의 가게를 보게 된다.
|
||||||
|
|
||||||
## 절차 — 사장님은 두 번 누른다
|
## 절차 — 사장님은 두 번 누른다
|
||||||
|
|
||||||
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
||||||
|
|||||||
@ -48,7 +48,7 @@ class ErrorType(Enum):
|
|||||||
ACCOUNT_NOT_FOUND = auto()
|
ACCOUNT_NOT_FOUND = auto()
|
||||||
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
|
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
|
||||||
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
||||||
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
|
OAUTH_NOT_CONFIGURED = auto() # 그 소셜 로그인의 키 미설정 — 그 수단만 꺼진다
|
||||||
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
||||||
ACCOUNT_SESSION_REVOKED = auto()
|
ACCOUNT_SESSION_REVOKED = auto()
|
||||||
|
|
||||||
@ -160,6 +160,7 @@ class AuthProvider(CodeEnum):
|
|||||||
|
|
||||||
LOCAL = 1 # id/pw
|
LOCAL = 1 # id/pw
|
||||||
GOOGLE = 2 # 구글 ID 토큰
|
GOOGLE = 2 # 구글 ID 토큰
|
||||||
|
KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다
|
||||||
|
|
||||||
|
|
||||||
class CompanyStatus(CodeEnum):
|
class CompanyStatus(CodeEnum):
|
||||||
|
|||||||
@ -102,6 +102,21 @@ class GoogleOAuthConfig(BaseSettings):
|
|||||||
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID")
|
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID")
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoLoginConfig(BaseSettings):
|
||||||
|
"""카카오 로그인. app_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
|
||||||
|
|
||||||
|
★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의
|
||||||
|
`user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이
|
||||||
|
바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만
|
||||||
|
조용히 안 된다** — 증상이 안 보이는 종류라 여기 적어 둔다.
|
||||||
|
★ app_id 는 비밀이 아니다(토큰 응답에 실려 온다). 서버가 이 값을 갖는 이유는 숨기려는 게
|
||||||
|
아니라 **발급처 대조** 때문이다 — 구글의 `aud` 검사와 같은 자리다."""
|
||||||
|
|
||||||
|
model_config = _BASE
|
||||||
|
|
||||||
|
app_id: str = Field("", validation_alias="KAKAO_LOGIN_APP_ID")
|
||||||
|
|
||||||
|
|
||||||
class ExternalApiConfig(BaseSettings):
|
class ExternalApiConfig(BaseSettings):
|
||||||
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
|
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
|
||||||
|
|
||||||
@ -160,3 +175,8 @@ def get_external_api_config() -> ExternalApiConfig:
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_google_oauth_config() -> GoogleOAuthConfig:
|
def get_google_oauth_config() -> GoogleOAuthConfig:
|
||||||
return GoogleOAuthConfig()
|
return GoogleOAuthConfig()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_kakao_login_config() -> KakaoLoginConfig:
|
||||||
|
return KakaoLoginConfig()
|
||||||
|
|||||||
@ -4,12 +4,14 @@ from config.config_models import (
|
|||||||
APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py)
|
APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py)
|
||||||
ExternalApiConfig,
|
ExternalApiConfig,
|
||||||
GoogleOAuthConfig,
|
GoogleOAuthConfig,
|
||||||
|
KakaoLoginConfig,
|
||||||
JwtToken,
|
JwtToken,
|
||||||
LogConfig,
|
LogConfig,
|
||||||
MainDBConfig,
|
MainDBConfig,
|
||||||
WebServerConfig,
|
WebServerConfig,
|
||||||
get_external_api_config,
|
get_external_api_config,
|
||||||
get_google_oauth_config,
|
get_google_oauth_config,
|
||||||
|
get_kakao_login_config,
|
||||||
get_jwt_token_config,
|
get_jwt_token_config,
|
||||||
get_log_config,
|
get_log_config,
|
||||||
get_main_db_config,
|
get_main_db_config,
|
||||||
@ -22,6 +24,7 @@ main_db_config: MainDBConfig = get_main_db_config()
|
|||||||
jwt_token_config: JwtToken = get_jwt_token_config()
|
jwt_token_config: JwtToken = get_jwt_token_config()
|
||||||
external_api_config: ExternalApiConfig = get_external_api_config()
|
external_api_config: ExternalApiConfig = get_external_api_config()
|
||||||
google_oauth_config: GoogleOAuthConfig = get_google_oauth_config()
|
google_oauth_config: GoogleOAuthConfig = get_google_oauth_config()
|
||||||
|
kakao_login_config: KakaoLoginConfig = get_kakao_login_config()
|
||||||
|
|
||||||
# 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다.
|
# 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다.
|
||||||
if not jwt_token_config.access_key or not jwt_token_config.refresh_key:
|
if not jwt_token_config.access_key or not jwt_token_config.refresh_key:
|
||||||
|
|||||||
@ -101,15 +101,16 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
|
|||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
|
||||||
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
|
async def _answer(utterance: str, speaker: str, deadline: float, app_user_id: str = '') -> dict:
|
||||||
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
||||||
try:
|
try:
|
||||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
answer = await asyncio.wait_for(
|
||||||
|
channel.handle(utterance, speaker, app_user_id), timeout=deadline)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
||||||
return _reply(_TIMEOUT_TEXT)
|
return _reply(_TIMEOUT_TEXT)
|
||||||
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
||||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
|
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}: {ex}")
|
||||||
return _reply(_ERROR_TEXT)
|
return _reply(_ERROR_TEXT)
|
||||||
return _reply_from(answer)
|
return _reply_from(answer)
|
||||||
|
|
||||||
@ -158,12 +159,12 @@ async def _approve_click(extra: dict, speaker: str) -> dict:
|
|||||||
return _reply_from(answer)
|
return _reply_from(answer)
|
||||||
|
|
||||||
|
|
||||||
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
|
async def _push(callback_url: str, utterance: str, speaker: str, app_user_id: str = '') -> None:
|
||||||
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
||||||
|
|
||||||
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
||||||
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
||||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC, app_user_id)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
res = await client.post(callback_url, json=payload)
|
res = await client.post(callback_url, json=payload)
|
||||||
@ -187,9 +188,7 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
|||||||
# 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다.
|
# 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다.
|
||||||
# ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다.
|
# ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다.
|
||||||
props = user.get("properties") or {}
|
props = user.get("properties") or {}
|
||||||
if isinstance(props, dict):
|
app_user_id = str(props.get("appUserId") or "") if isinstance(props, dict) else ""
|
||||||
LOG.i(f"[agent/kakao] 발화자 키 — properties={sorted(props)} "
|
|
||||||
f"appUserId={'있음(%d자)' % len(str(props['appUserId'])) if props.get('appUserId') else '없음'}")
|
|
||||||
|
|
||||||
# ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
|
# ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
|
||||||
# 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다.
|
# 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다.
|
||||||
@ -213,10 +212,10 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
|||||||
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
||||||
f"block={(request.get('block') or {}).get('name')!r}")
|
f"block={(request.get('block') or {}).get('name')!r}")
|
||||||
if callback_url:
|
if callback_url:
|
||||||
tasks.add_task(_push, callback_url, utterance, speaker)
|
tasks.add_task(_push, callback_url, utterance, speaker, app_user_id)
|
||||||
return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
|
return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
|
||||||
|
|
||||||
return await _answer(utterance, speaker, DEADLINE_SEC)
|
return await _answer(utterance, speaker, DEADLINE_SEC, app_user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook")
|
@router.post("/webhook")
|
||||||
|
|||||||
@ -4,7 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
|
from .protocol import (
|
||||||
|
Req_GoogleLogin, Req_KakaoLogin, Req_Login, Req_Signup, Req_UpdateMe,
|
||||||
|
Res_Login, Res_Me, Res_RefreshToken,
|
||||||
|
)
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@ -37,6 +40,18 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()):
|
|||||||
return RemoveNoneResponse(await service.google_login(req))
|
return RemoveNoneResponse(await service.google_login(req))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/kakao",
|
||||||
|
response_model=Res_Login,
|
||||||
|
summary="카카오 로그인",
|
||||||
|
description="카카오 **액세스 토큰**을 카카오에 되물어 검증하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. "
|
||||||
|
"★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, "
|
||||||
|
"그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).",
|
||||||
|
)
|
||||||
|
async def kakao_login(req: Req_KakaoLogin, service: AuthService = Depends()):
|
||||||
|
return RemoveNoneResponse(await service.kakao_login(req))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
path="/refresh_token",
|
path="/refresh_token",
|
||||||
dependencies=[Depends(IsValidRefreshToken)],
|
dependencies=[Depends(IsValidRefreshToken)],
|
||||||
|
|||||||
@ -29,6 +29,15 @@ class Req_GoogleLogin(AuthProtocol):
|
|||||||
credential: str = ""
|
credential: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Req_KakaoLogin(AuthProtocol):
|
||||||
|
"""카카오 로그인.
|
||||||
|
|
||||||
|
★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을
|
||||||
|
카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다."""
|
||||||
|
|
||||||
|
access_token: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Res_Login(Res_WebPacketProtocol):
|
class Res_Login(Res_WebPacketProtocol):
|
||||||
access_token: str = ""
|
access_token: str = ""
|
||||||
refresh_token: str = ""
|
refresh_token: str = ""
|
||||||
|
|||||||
@ -196,7 +196,7 @@ def _other_named(rows: list, current, utterance: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def handle(utterance: str, channel_user_key: str) -> dict:
|
async def handle(utterance: str, channel_user_key: str, app_user_id: str = '') -> dict:
|
||||||
"""대화 한 턴."""
|
"""대화 한 턴."""
|
||||||
utterance = (utterance or "").strip()
|
utterance = (utterance or "").strip()
|
||||||
if not utterance:
|
if not utterance:
|
||||||
@ -204,6 +204,14 @@ async def handle(utterance: str, channel_user_key: str) -> dict:
|
|||||||
|
|
||||||
row = await _link_row(channel_user_key)
|
row = await _link_row(channel_user_key)
|
||||||
|
|
||||||
|
# ★ 카카오 로그인으로 가입한 사장님은 **코드 없이** 이어진다 — appUserId 가 로그인
|
||||||
|
# 회원번호와 같은 값이라 그 자리에서 알아볼 수 있다(kakao_link_service 주석).
|
||||||
|
# ★ 코드 경로를 지우지 않는다: id/pw·구글 가입자에게는 이 값이 없고, 봇에 앱키가
|
||||||
|
# 안 물린 환경에서는 아예 오지 않는다 — 그때 유일한 길이다.
|
||||||
|
if row is None and app_user_id:
|
||||||
|
if await link_service.link_by_app_user_id(app_user_id, channel_user_key):
|
||||||
|
row = await _link_row(channel_user_key)
|
||||||
|
|
||||||
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
|
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
|
||||||
if row is None:
|
if row is None:
|
||||||
found = CODE_PATTERN.fullmatch(utterance.upper())
|
found = CODE_PATTERN.fullmatch(utterance.upper())
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from enum import Enum
|
|||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from common.category_schema.loader import get_schema
|
from common.category_schema.loader import get_schema
|
||||||
|
from common.template_catalog import industry_of
|
||||||
from common.enums import ErrorType, PlaceCategory, SourceType
|
from common.enums import ErrorType, PlaceCategory, SourceType
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.fact_crud import FactCRUD
|
from crud.fact_crud import FactCRUD
|
||||||
@ -280,7 +281,11 @@ async def _sections_of(ctx: ToolContext) -> tuple[list, dict]:
|
|||||||
_fact, site_service = _services()
|
_fact, site_service = _services()
|
||||||
res = await site_service.get_site(ctx.user, ctx.place_id)
|
res = await site_service.get_site(ctx.user, ctx.place_id)
|
||||||
theme = dict((res.site.theme if res.site and res.site.theme else {}) or {})
|
theme = dict((res.site.theme if res.site and res.site.theme else {}) or {})
|
||||||
spec = site_payload._DEFAULT_THEME[PlaceCategory(ctx.place.category).value]["sections"]
|
# ★ 업종 기본 섹션은 `common/template_catalog` 이 단일 출처다(templates.json).
|
||||||
|
# 예전 `site_payload._DEFAULT_THEME` 를 보던 자리인데, 템플릿 정의가 한 파일로 모이면서
|
||||||
|
# 그 이름이 사라졌다 — 여기만 남아 있어 대화의 섹션 기능이 통째로 죽어 있었다
|
||||||
|
# (실측 2026-09-30: AttributeError 를 웹훅이 삼켜 "지금은 처리할 수 없어요" 로만 보였다).
|
||||||
|
spec = industry_of(PlaceCategory(ctx.place.category).value)["sections"]
|
||||||
return site_payload._sections(theme.get("sections"), spec), theme
|
return site_payload._sections(theme.get("sections"), spec), theme
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from common.models.gmodel import UserInfo
|
|||||||
from crud.user_crud import IUserCRUD, UserCRUD
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
from router.v1.auth.protocol import (
|
from router.v1.auth.protocol import (
|
||||||
Req_GoogleLogin,
|
Req_GoogleLogin,
|
||||||
|
Req_KakaoLogin,
|
||||||
Req_Signup,
|
Req_Signup,
|
||||||
Req_UpdateMe,
|
Req_UpdateMe,
|
||||||
Res_Login,
|
Res_Login,
|
||||||
@ -24,6 +25,12 @@ from router.v1.validator.dependencies import (
|
|||||||
GetHashedPW,
|
GetHashedPW,
|
||||||
VerifyPW,
|
VerifyPW,
|
||||||
)
|
)
|
||||||
|
from services.external.kakao_identity import (
|
||||||
|
KakaoAccount,
|
||||||
|
KakaoNotConfigured,
|
||||||
|
KakaoTokenInvalid,
|
||||||
|
verify_access_token as verify_kakao_token,
|
||||||
|
)
|
||||||
from services.external.google_identity import (
|
from services.external.google_identity import (
|
||||||
GoogleAccount,
|
GoogleAccount,
|
||||||
GoogleNotConfigured,
|
GoogleNotConfigured,
|
||||||
@ -46,6 +53,11 @@ def _google_login_id(sub: str) -> str:
|
|||||||
return f"{_SOCIAL_ID_PREFIX}{sub}"
|
return f"{_SOCIAL_ID_PREFIX}{sub}"
|
||||||
|
|
||||||
|
|
||||||
|
def _kakao_login_id(uid: str) -> str:
|
||||||
|
"""카카오 계정의 로그인 아이디. 구글과 접두어를 달리해 **수단이 섞이지 않게** 한다."""
|
||||||
|
return f"kakao_{uid}"
|
||||||
|
|
||||||
|
|
||||||
def _fit(value: str | None, limit: int) -> str | None:
|
def _fit(value: str | None, limit: int) -> str | None:
|
||||||
"""컬럼 길이에 맞춰 자른다."""
|
"""컬럼 길이에 맞춰 자른다."""
|
||||||
if value is None:
|
if value is None:
|
||||||
@ -248,6 +260,55 @@ class AuthService:
|
|||||||
LOG.i(f"SIGNUP(google) : sub={account.sub}")
|
LOG.i(f"SIGNUP(google) : sub={account.sub}")
|
||||||
return await self._finish_login(user)
|
return await self._finish_login(user)
|
||||||
|
|
||||||
|
async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login:
|
||||||
|
"""카카오 액세스 토큰 → 우리 세션.
|
||||||
|
|
||||||
|
★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로
|
||||||
|
유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다.
|
||||||
|
★★ 판정 키는 카카오 **회원번호**다. 이 값이 챗봇 웹훅의 `appUserId` 와 같아서,
|
||||||
|
로그인만 해 두면 채널에 말을 거는 순간 자동으로 이어진다
|
||||||
|
(services/kakao_link_service.link_by_app_user_id)."""
|
||||||
|
res = Res_Login()
|
||||||
|
|
||||||
|
try:
|
||||||
|
account: KakaoAccount = await verify_kakao_token(req.access_token)
|
||||||
|
except KakaoNotConfigured:
|
||||||
|
res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED)
|
||||||
|
return res
|
||||||
|
except KakaoTokenInvalid:
|
||||||
|
res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
users.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_user_by_provider_uid(s, AuthProvider.KAKAO.value, account.uid),
|
||||||
|
)
|
||||||
|
if err_type == ErrorType.SUCCESS and user is not None:
|
||||||
|
LOG.i("LOGIN(kakao)")
|
||||||
|
return await self._finish_login(user)
|
||||||
|
|
||||||
|
# ★ 같은 이메일이 다른 수단으로 가입돼 있으면 **잇지 않는다**(DECISIONS 1절).
|
||||||
|
# 자동으로 이으면 남이 먼저 만들어 둔 계정에 그 사람의 카카오 로그인이 들어간다.
|
||||||
|
if account.email and await self._email_taken(account.email):
|
||||||
|
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, user = await self._create_account(
|
||||||
|
login_id=_kakao_login_id(account.uid),
|
||||||
|
password_hash=None,
|
||||||
|
name=account.name or None,
|
||||||
|
email=account.email or None,
|
||||||
|
provider=AuthProvider.KAKAO,
|
||||||
|
provider_uid=account.uid,
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
LOG.i("SIGNUP(kakao)")
|
||||||
|
return await self._finish_login(user)
|
||||||
|
|
||||||
async def get_me(self, user_info: UserInfo) -> Res_Me:
|
async def get_me(self, user_info: UserInfo) -> Res_Me:
|
||||||
res = Res_Me()
|
res = Res_Me()
|
||||||
|
|
||||||
|
|||||||
93
solution/backend/services/external/kakao_identity.py
vendored
Normal file
93
solution/backend/services/external/kakao_identity.py
vendored
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다.
|
||||||
|
|
||||||
|
★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지
|
||||||
|
뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`).
|
||||||
|
그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 —
|
||||||
|
**이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.**
|
||||||
|
|
||||||
|
★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다
|
||||||
|
(카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일").
|
||||||
|
그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데
|
||||||
|
매칭만 조용히 안 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import kakao_login_config
|
||||||
|
|
||||||
|
_TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info"
|
||||||
|
_USER_ME_URL = "https://kapi.kakao.com/v2/user/me"
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT_SEC = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoNotConfigured(RuntimeError):
|
||||||
|
"""KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다)."""
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoTokenInvalid(RuntimeError):
|
||||||
|
"""만료·위조이거나 **남의 앱에 발급된** 토큰이다."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KakaoAccount:
|
||||||
|
"""토큰으로 확인한 신원.
|
||||||
|
|
||||||
|
★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 —
|
||||||
|
구글에서 sub 를 쓰는 것과 같은 이유다."""
|
||||||
|
|
||||||
|
uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다.
|
||||||
|
email: str = ""
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool(kakao_login_config.app_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_access_token(access_token: str) -> KakaoAccount:
|
||||||
|
"""액세스 토큰 → 신원. 실패는 두 예외 중 하나로만 나간다."""
|
||||||
|
if not is_configured():
|
||||||
|
raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다")
|
||||||
|
if not access_token:
|
||||||
|
raise KakaoTokenInvalid("빈 토큰")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
|
||||||
|
try:
|
||||||
|
info = await client.get(_TOKEN_INFO_URL, headers=headers)
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
# 카카오에 못 닿은 것과 토큰이 틀린 것은 다르다 — 로그로 가른다.
|
||||||
|
LOG.w(f"[kakao-login] 토큰 확인 실패: {type(ex).__name__}")
|
||||||
|
raise KakaoTokenInvalid("토큰 확인 실패") from ex
|
||||||
|
if info.status_code != 200:
|
||||||
|
raise KakaoTokenInvalid(f"토큰이 유효하지 않다({info.status_code})")
|
||||||
|
|
||||||
|
data = info.json()
|
||||||
|
# ★ 구글의 aud 검사에 해당한다. 이게 없으면 남의 앱 토큰으로 우리 계정이 된다.
|
||||||
|
if str(data.get("app_id") or "") != str(kakao_login_config.app_id):
|
||||||
|
LOG.w("[kakao-login] 다른 앱에 발급된 토큰 — 거절")
|
||||||
|
raise KakaoTokenInvalid("우리 앱의 토큰이 아니다")
|
||||||
|
|
||||||
|
uid = str(data.get("id") or "")
|
||||||
|
if not uid:
|
||||||
|
raise KakaoTokenInvalid("회원번호가 없다")
|
||||||
|
|
||||||
|
# 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 —
|
||||||
|
# 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다.
|
||||||
|
email = name = ""
|
||||||
|
try:
|
||||||
|
me = await client.get(_USER_ME_URL, headers=headers)
|
||||||
|
if me.status_code == 200:
|
||||||
|
body = me.json()
|
||||||
|
account = body.get("kakao_account") or {}
|
||||||
|
email = (account.get("email") or "") if account.get("is_email_valid") is not False else ""
|
||||||
|
name = ((account.get("profile") or {}).get("nickname")
|
||||||
|
or (body.get("properties") or {}).get("nickname") or "")
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
LOG.w(f"[kakao-login] 프로필 조회 실패(로그인은 계속): {type(ex).__name__}")
|
||||||
|
|
||||||
|
return KakaoAccount(uid=uid, email=email, name=name)
|
||||||
@ -8,8 +8,10 @@ from uuid import UUID
|
|||||||
from sqlalchemy import select, text, update
|
from sqlalchemy import select, text, update
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.logger import LOG
|
||||||
from common.database.model.models import owner_kakao_links as Link
|
from common.database.model.models import owner_kakao_links as Link
|
||||||
from common.enums import KakaoLinkStatus
|
from common.database.model.models import users
|
||||||
|
from common.enums import AuthProvider, ErrorType, KakaoLinkStatus
|
||||||
from config import agent_config as config
|
from config import agent_config as config
|
||||||
|
|
||||||
# 사장님이 카톡 대화창에 손으로 친다.
|
# 사장님이 카톡 대화창에 손으로 친다.
|
||||||
@ -135,6 +137,65 @@ async def redeem(code: str, channel_user_key: str) -> UUID:
|
|||||||
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
||||||
|
|
||||||
|
|
||||||
|
async def link_by_app_user_id(app_user_id: str, channel_user_key: str) -> UUID | None:
|
||||||
|
"""카카오 로그인으로 가입한 사장님을 **코드 없이** 채널에 잇는다.
|
||||||
|
|
||||||
|
★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
|
||||||
|
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 로그인만 해 두면 채널에 말을 거는
|
||||||
|
순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.
|
||||||
|
★ 그래도 **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 appUserId 가
|
||||||
|
없고, 봇에 앱키가 안 물린 환경에서는 이 값 자체가 안 온다 — 그때 유일한 길이다.
|
||||||
|
★ 이미 그 카톡이 **다른 사장님**에게 묶여 있으면 잇지 않는다(부분 유니크가 막는다).
|
||||||
|
조용히 빼앗으면 앞사람이 남의 가게를 보게 된다.
|
||||||
|
|
||||||
|
반환: 이어진 사장님의 user_id. 못 이으면 None."""
|
||||||
|
if not app_user_id or not channel_user_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run(s):
|
||||||
|
user = (
|
||||||
|
await s.execute(
|
||||||
|
select(users).where(
|
||||||
|
users.provider == AuthProvider.KAKAO.value,
|
||||||
|
users.provider_uid == str(app_user_id),
|
||||||
|
users.deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if user is None:
|
||||||
|
return ErrorType.SUCCESS, None
|
||||||
|
|
||||||
|
await _lock_user(s, user.user_id)
|
||||||
|
taken = (
|
||||||
|
await s.execute(
|
||||||
|
select(Link).where(
|
||||||
|
Link.channel_user_key == channel_user_key,
|
||||||
|
Link.deleted.is_(False),
|
||||||
|
Link.status == KakaoLinkStatus.LINKED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if taken is not None:
|
||||||
|
# 이미 이어져 있으면 그대로 둔다 — 같은 사람이면 성공, 다른 사람이면 빼앗지 않는다.
|
||||||
|
return ErrorType.SUCCESS, (user.user_id if taken.user_id == user.user_id else None)
|
||||||
|
|
||||||
|
row = await _active(s, user.user_id)
|
||||||
|
if row is None:
|
||||||
|
row = Link(user_id=user.user_id, status=KakaoLinkStatus.LINKED.value)
|
||||||
|
s.add(row)
|
||||||
|
row.status = KakaoLinkStatus.LINKED.value
|
||||||
|
row.channel_user_key = channel_user_key
|
||||||
|
row.linked_at = _now()
|
||||||
|
row.code_sha = None
|
||||||
|
row.code_expires_at = None
|
||||||
|
return ErrorType.SUCCESS, user.user_id
|
||||||
|
|
||||||
|
_err, user_id = await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
||||||
|
if user_id is not None:
|
||||||
|
LOG.i("[kakao-link] 카카오 로그인 계정과 채널을 자동으로 이었다")
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
async def resolve(channel_user_key: str) -> UUID | None:
|
async def resolve(channel_user_key: str) -> UUID | None:
|
||||||
"""채널 발화자 → user_id."""
|
"""채널 발화자 → user_id."""
|
||||||
|
|
||||||
|
|||||||
@ -179,3 +179,64 @@ def test_코드에는_헷갈리는_글자가_없다():
|
|||||||
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
|
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
|
||||||
assert not set("01OILl") & set(service._CODE_ALPHABET)
|
assert not set("01OILl") & set(service._CODE_ALPHABET)
|
||||||
assert len(service._new_code()) == service._CODE_LENGTH
|
assert len(service._new_code()) == service._CODE_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
# ── 카카오 로그인 자동 매칭 (2026-09-30) ────────────────────────────────
|
||||||
|
|
||||||
|
async def _kakao_user(db_engine, uid: str):
|
||||||
|
"""카카오로 가입한 사장님 하나. provider_uid 가 곧 챗봇의 appUserId 다."""
|
||||||
|
from common.enums import AuthProvider
|
||||||
|
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
async with db_engine.begin() as c:
|
||||||
|
await c.execute(
|
||||||
|
text("""INSERT INTO users(user_id, id, name, provider, provider_uid, status, role)
|
||||||
|
VALUES (:u, :id, '테스트', :p, :uid, 1, 1)"""),
|
||||||
|
{"u": user_id, "id": f"kakao_{uid}", "p": AuthProvider.KAKAO.value, "uid": uid},
|
||||||
|
)
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_카카오로_가입했으면_코드_없이_이어진다(db_engine):
|
||||||
|
"""★ appUserId 가 카카오 로그인 회원번호와 같은 값이라, 말을 거는 순간 알아볼 수 있다."""
|
||||||
|
user_id = await _kakao_user(db_engine, "900001")
|
||||||
|
assert await service.resolve("kakao-speaker-a") is None
|
||||||
|
|
||||||
|
linked = await service.link_by_app_user_id("900001", "kakao-speaker-a")
|
||||||
|
assert linked == user_id
|
||||||
|
assert await service.resolve("kakao-speaker-a") == user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_카카오로_가입하지_않았으면_자동으로_잇지_않는다(db_engine):
|
||||||
|
"""id/pw·구글 가입자에게는 appUserId 가 없다 — 코드 경로가 유일한 길이다."""
|
||||||
|
assert await service.link_by_app_user_id("없는번호", "kakao-speaker-b") is None
|
||||||
|
assert await service.resolve("kakao-speaker-b") is None
|
||||||
|
# 값이 비어 있어도 조용히 지나간다.
|
||||||
|
assert await service.link_by_app_user_id("", "kakao-speaker-b") is None
|
||||||
|
assert await service.link_by_app_user_id("900001", "") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_남의_카톡을_빼앗지_않는다(db_engine):
|
||||||
|
"""★ 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다."""
|
||||||
|
first = await _kakao_user(db_engine, "900002")
|
||||||
|
second = await _kakao_user(db_engine, "900003")
|
||||||
|
await service.link_by_app_user_id("900002", "kakao-speaker-c")
|
||||||
|
|
||||||
|
# 다른 사장님이 같은 카톡으로 오면 잇지 않는다.
|
||||||
|
assert await service.link_by_app_user_id("900003", "kakao-speaker-c") is None
|
||||||
|
assert await service.resolve("kakao-speaker-c") == first
|
||||||
|
assert second is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_같은_사람이_다시_말을_걸어도_그대로다(db_engine):
|
||||||
|
"""이미 이어져 있으면 성공으로 답하고 아무것도 바꾸지 않는다."""
|
||||||
|
user_id = await _kakao_user(db_engine, "900004")
|
||||||
|
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
|
||||||
|
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
|
||||||
|
|
||||||
|
async with db_engine.begin() as c:
|
||||||
|
n = (await c.execute(
|
||||||
|
text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"),
|
||||||
|
{"u": user_id},
|
||||||
|
)).scalar_one()
|
||||||
|
assert n == 1
|
||||||
|
|||||||
@ -323,7 +323,7 @@ async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든
|
|||||||
|
|
||||||
sent = {}
|
sent = {}
|
||||||
|
|
||||||
async def fake_push(callback_url, utterance, speaker):
|
async def fake_push(callback_url, utterance, speaker, app_user_id=""):
|
||||||
sent["url"] = callback_url
|
sent["url"] = callback_url
|
||||||
sent["payload"] = await bot._answer(utterance, speaker, 5.0)
|
sent["payload"] = await bot._answer(utterance, speaker, 5.0)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user