웹훅엔 본문이 없다. 서버리스 Webex 수신 봇 첫 삽질
사내에는 이미 레드마인으로 일감 관련 알람을 Webex로 보내는 봇이 있습니다. AWS 클라우드를 사용하는 환경에서 Lambda Function URL을 웹훅 엔드포인트로 만들어 두고, 일감의 상태가 변경되거나 댓글이 달리거나 사용자을 멘션하는등의…
사내에는 이미 레드마인으로 일감 관련 알람을 Webex로 보내는 봇이 있습니다. AWS 클라우드를 사용하는 환경에서 Lambda Function URL을 웹훅 엔드포인트로 만들어 두고, 일감의 상태가 변경되거나 댓글이 달리거나 사용자을 멘션하는등의 액션이 일어나면 레드마인 플러그인이 엔드포인트로 POST를 날리고, Lambda에서 Webex로 API로 DM을 쏘는 구조입니다.
이번 목표는 그 반대, 사용자가 봇에게 메시지를 보내면 서버에서 받아 처리하는 inbound입니다. 먼저 내가 채팅을 치면 서버에서 받아서 그대로 되돌려주는 echo 봇을 만들어봤습니다.
수신 방식은 두가지
| 방식 | 동작 | 공개 엔드포인트 |
|---|---|---|
| Webhook | Webex가 이벤트 발생 시 내 서버로 POST | 필요 (HTTPS) |
| WebSocket | SDK가 Webex로 아웃바운드 연결 유지, 푸시 수신 | 불필요 |
이미 발송 쪽에서 Lambda 와 Function URL, 봇 토큰과 HMAC검증 자산이 있으니 webhook이 압도적으로 유리했습니다. 함수 하나 더 만들고 웹훅만 등록하면 끝 이라고 생각했습니다.
함정1 - 웹훅 Payload에는 본문이 없음.
가장먼저 헤멘 지점입니다. messages/created 웹훅이 오면 data에는 메시지ID와 메타데이터만 있고 텍스트가 없습니다. (보안상 의도된 설계) 본문을 얻으려면 봇 토큰으로 한 번 더 호출해야 합니다.
// 웹훅 data.id로 본문을 다시 가져온다
const res = await fetch(`https://webexapis.com/v1/messages/${id}`, {
headers: { Authorization: `Bearer ${WEBEX_BOT_TOKEN}` },
});
const text = (await res.json()).text;이걸 모르면, 웹훅은 오는데 미시지 내용이 비어있다로 한참 삽질하게 됩니다.
함정2 - 봇 자기 메시지도 웹훅이 울린다 (무한루프)
봇이 답장을 보내면 그것도 messages/created 이벤트라서 또 웹훅이 오게 됩니다. echo 봇이면 그대로 무한루프가 되는 셈이죠. 발신자가 봇 자신인지 걸러야 합니다. 봇 계정 이메일은 한상 @webex.bot으로 옵니다.
if (!data.personEamil || data.personEmail.endWith("@Webex.bot")) {
return { statusCode: 200, body: "skip bot/self" };
}함정3 - 서명은 HMAC-SHA1, 헤더는 X-Spark-Signature
발송 어댑터에서는 직접 만든 SHA256 서명을 사용했는데, 수신 웹훅은 Webex가 정해준 규격입니다. 웹훅 생성 시 secret을 넣으면 Webex가 raw body를 그 시크릿으로 HMAC-SHA1로 서명한뒤 X-Spark-Signature로 보냅니다.
function verify(sigHex, rawBuf, secret) {
const expected = crypto.createHmac("sha1", secret).update(rawBuf).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sigHex, "hex"), Buffer.from(expected, "hex"));
}웹훅 등록은 봇 토큰으로 1회 진행합니다.
curl https://webexapis.com/v1/webhooks
-H "Authorization: Bearer $WEBEX_BOT_TOKEN" -H "Content-Type: application/json"
-d '{ "name":"bot-incoming", "targetUrl":"<FUNCTION_URL>",
"resource":"messages", "event":"created", "secret":"<WEBHOOK_SECRET>" }'함정4 - AuthType NONE인데 403
첫 배포를 하고 최초로 요청을 보내보니 전부 403 Forbidde 에러가 발행합니다. Function URL을 --auth-type NONE으로 만들었는데요. 알고보니 이 계정에선 권한 statement가 2개 다 있어야 했습니다. (정상 동작 중인 다른 함수와 정책을 비교해서 알아냄)
# (1) 이것만 넣으면 403
aws lambda add-permission --function-name bot-incoming
--statement-id InvokeFunction --action lambda:InvokeFunction --principal "*"
# (2) 이게 추가로 있어야 200 — AuthType NONE 조건부 InvokeFunctionUrl
aws lambda add-permission --function-name bot-incoming
--statement-id PublicUrl --action lambda:InvokeFunctionUrl --principal "*"
--function-url-auth-type NONE교훈 - 잘 도는 형제 함수가 있으면
aws lambda get-policy로 정책을 그대로 비교하는 게 제일 빠르다.
함정5 - 함수는 도는데 로그가 안 남는다
새 Lambda 함수가 실행은 되는데 Cloudwatch로그 그룹이 생기지가 않았습니다. 재사용한 실행롤의 정책이 PutLogEvents를 다른 함수의 로그 그룹에만 적용하고 있었던 것이 문제였습니다. 결국 새 함수 로그 그룹 ARN을 정책에 추가해서 해결했습니다.
교훈 - 실행롤을 재사용할 떄 로깅 권한은 로그 그룹 리소스 단위로 묶여 있을 수 있다. "실행은 되는데 로그가 없다"면 이걸 의심해봐야 한다.
합성 테스트로 여기까지 검증
실제 사람이 DM을 보내야 끝까지 확인이 되지만, 서명/봇필터/라우팅은 합성 페이로드로도 검증 가능합니다.
no-sig → 401 invalid signature
bad-sig → 401 invalid signature
bot-sender → 200 skip bot/self (서명검증 + 루프방지 동시 증명)
non-message → 200 ignored event여기까지 오니 봇이 동작합니다. echo: 안녕하세요. 이 돌아옵니다. 야호 !
다음편
echo는 됐고, 이제 /내일감(Redmine 조회), /위키(Outline 검색) 같은 실제 명령을 붙일 차례 입니다. 여기서 "Lambda가 사내망 레드마인에 어떻게 닿지?" 라는, 이 시리즈의 진짜 주제가 시작됩니다.
→ 2편: 폐쇄망이라는 벽
코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.