알림봇-따라하기 시리즈 마지막 편. 채팅방에 올라가는 알림 중 놓치면 안 되는 몇 가지를 카카오톡으로도 받는다. 카카오 로그인의 "나에게 보내기" 기능을 쓰면 별도 채널이나 비용 없이 내 카카오톡으로 메시지를 보낼 수 있다.
왜 중계 서버를 따로 두나
알림을 보내는 봇은 여럿인데, 카카오 토큰은 하나다. 봇마다 토큰을 들고 각자 갱신하면 서로의 토큰을 무효로 만들 수 있다. 그래서 토큰을 혼자 소유하는 작은 서버를 하나 두고, 봇들은 그 서버에 "이 문장을 보내 줘"라고만 요청한다.
봇 A ─┐
봇 B ─┼─ POST /send {text, link} ──▶ 중계 서버 ──▶ 카카오 API ──▶ 내 카카오톡
봇 C ─┘ (내부 토큰 헤더) (토큰 파일 단독 소유)
카카오 쪽 준비
- 개발자 콘솔에서 애플리케이션을 만들고 REST API 키를 확인한다.
- 카카오 로그인을 켜고 Redirect URI 를 등록한다. 서버에 공개 주소가 없어도 된다.
http://localhost/callback처럼 열리지 않는 주소를 등록해 두고, 동의 뒤 브라우저 주소창에 남은 주소에서 인가 코드를 복사해 쓰면 된다. - 동의 항목에서 "카카오톡 메시지 전송"을 켠다.
- 메시지의 버튼이 열 사이트 도메인을 등록한다. 등록하지 않은 도메인의 링크는 버튼을 눌러도 열리지 않는다.
겪은 함정 두 가지.
- 지금 콘솔에서는 REST API 키를 만들면 클라이언트 시크릿이 기본으로 켜져 있다. 예전 예제대로 키만 보내면 토큰 발급이 실패한다. 시크릿을 함께 보내거나 콘솔에서 끈다.
- 콘솔 메뉴 구성이 바뀌어서 검색으로 나오는 안내의 메뉴 위치가 맞지 않았다. Redirect URI 와 시크릿은 키 설정 쪽에, 링크 도메인은 제품 링크 관리 쪽에 있었다.
토큰 관리
인가 코드를 토큰으로 바꾸면 액세스 토큰(수 시간)과 리프레시 토큰(약 두 달)이 온다. 중계 서버는 이것을 볼륨의 파일 하나에 저장한다.
async function accessToken() {
if (Date.now() > tok.access_expires - 10 * 60e3) await refresh(); // 만료 10분 전이면 갱신
return tok.access_token;
}
// 알림이 없는 날이 길어도 리프레시 토큰이 죽지 않게 하루 한 번 갱신
setInterval(() => refresh().catch(logError), 24 * 3600e3).unref();
- 리프레시 토큰은 갱신할 때마다 새로 오지 않는다. 남은 기간이 한 달 아래로 내려갔을 때만 새 값이 온다. 응답에 없으면 기존 값을 유지해야 한다. 응답의 값을 무조건 덮어쓰면 빈 값이 저장된다.
- 그래서 주기적으로 갱신하는 것이 곧 연장이다. 알림이 드문 봇이라도 하루 한 번 갱신해 두면 두 달 뒤에 조용히 끊기는 일이 없다.
- 파일은 임시 파일에 쓴 뒤 이름을 바꿔 교체한다. 쓰는 도중 프로세스가 죽어도 반쪽짜리 파일이 남지 않는다.
- 전송에서 401 이 오면 한 번만 갱신 후 재시도한다. 그래도 실패하면 상태 확인 주소에 오류를 남기고, 사람이 동의 절차를 다시 밟는다.
200자 제한과 묶음 전송
텍스트 템플릿의 본문은 200자까지다. 채팅방용으로 만든 알림을 그대로 보낼 수 없어서 중계 서버가 손본다.
// 채팅용 마크다운을 평문으로
const plain = (s) => String(s || '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [글](링크) → 글
.replace(/\*\*|__|`/g, '') // 굵게·코드 기호 제거
.replace(/^#+\s*/gm, '')
.trim();
- 200자를 넘으면 자르고 말줄임표를 붙인다. 전문은 채팅방에 있으니 사본은 "무슨 일이 생겼는지"만 전하면 된다.
- 3초 동안 들어온 요청을 모아 한 통으로 보낸다. 같은 일이 연달아 여러 번 일어나면 알림이 몇 초 사이에 줄줄이 온다. 같은 링크의 메시지는 200자가 찰 때까지 줄바꿈으로 이어 붙인다.
- 요청에는 큐에 넣자마자 202 로 바로 응답한다. 보내는 쪽 봇이 카카오 API 의 응답을 기다리며 멈추지 않는다.
내부에서만 부를 수 있게
- 포트는 루프백과 컨테이너 브리지 주소에만 연다. 다른 compose 프로젝트의 컨테이너는 닿을 수 있지만 LAN 과 인터넷에서는 보이지 않는다.
- 요청에는 공유 토큰 헤더를 요구한다. 비교는 해시한 뒤
crypto.timingSafeEqual로 한다. - 토큰은 서버에서 무작위로 만들어 양쪽 설정 파일에 넣는다. 값을 화면에 찍지 않고도 양쪽이 같은지 확인하려면 각각의 해시를 비교하면 된다.
- 상태 확인 주소는 동의 여부, 만료 시각, 보낸 수와 실패 수, 마지막 오류만 보여 준다. 토큰 값은 내보내지 않는다.
보내는 쪽: 실패해도 본업은 계속
// 채팅 게시와 메신저 사본을 묶는다. 사본이 실패해도 게시 결과는 그대로 돌려준다
function fanout(chat, relay) {
return {
async post(message, link) {
const ok = await chat.post(message);
relay.send(message, link).catch((e) => console.error('사본 실패', e.message));
return ok;
},
};
}
사본은 덤이다. 중계 서버가 내려가 있어도 채팅 알림과 봇의 상태 진행에는 영향이 없어야 한다. 반대로 채팅 게시가 실패했다면 사본도 보내지 않는다. 사본만 오고 원문이 없으면 나중에 찾아볼 곳이 없다.
시리즈를 마치며
알림 봇은 코드가 짧아서 금방 만들지만, 믿고 쓰려면 세 가지가 맞아야 했다. 같은 알림이 두 번 오지 않을 것, 빠뜨리지 않을 것, 그리고 알림 경로 하나가 죽어도 나머지가 계속 돌 것. 이 시리즈의 대부분은 결국 그 세 가지 이야기였다.
댓글 없음:
댓글 쓰기