알림봇-따라하기 시리즈 1편. 알림의 종착지인 채팅방에 글을 올리는 부분부터 만든다. 나는 직접 운영하는 Mattermost 를 쓰지만, 봇 토큰으로 REST API 를 부르는 방식은 다른 채팅 도구도 비슷하다.
웹훅 대신 봇 계정
수신 웹훅(Incoming Webhook)이 가장 간단하지만, 나는 봇 계정과 액세스 토큰을 쓴다. 토큰 하나로 여러 채널에 쓸 수 있고, 나중에 채널의 글을 읽어 오는 기능(다른 봇의 알림을 대시보드에 모으기)에도 같은 토큰을 쓸 수 있기 때문이다. 봇을 만든 뒤 글을 올릴 채널에 봇을 멤버로 추가해야 한다. 빠뜨리면 403 이 온다.
게시 모듈
// lib/chat.js — 설정이 없으면 조용히 꺼진다
function createChat(env) {
const base = (env.CHAT_URL || '').replace(/\/$/, '');
const token = env.CHAT_TOKEN || '';
const team = env.CHAT_TEAM || '', name = env.CHAT_CHANNEL_NAME || '';
let channel = env.CHAT_CHANNEL || '';
const enabled = !!(base && token && (channel || (team && name)));
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
async function channelId() {
if (channel) return channel;
const url = `${base}/api/v4/teams/name/${encodeURIComponent(team)}/channels/name/${encodeURIComponent(name)}`;
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15e3) });
if (!res.ok) throw new Error(`채널 조회 ${res.status}`);
channel = (await res.json()).id;
return channel;
}
async function post(message) {
if (!enabled) return false;
const res = await fetch(`${base}/api/v4/posts`, {
method: 'POST', headers, signal: AbortSignal.timeout(15e3),
body: JSON.stringify({ channel_id: await channelId(), message }),
});
if (!res.ok) throw new Error(`게시 ${res.status}`);
return true;
}
return { enabled, post };
}
module.exports = { createChat };
- 채널은 이름으로 지정할 수 있게 했다. API 는 채널 ID 를 요구하는데, ID 는 화면에서 찾기 번거롭다. 주소창에 보이는 팀 이름과 채널 이름을 설정에 넣으면 처음 한 번 ID 를 조회해 기억한다.
- 설정이 비어 있으면 꺼진다. 로컬에서 개발할 때 토큰을 넣지 않으면 알림만 빠지고 나머지는 그대로 돈다. 개발 중인 봇이 운영 채널에 글을 올리는 사고를 막는다.
- 타임아웃은 필수. 채팅 서버가 응답하지 않을 때 봇 전체가 멈추면 안 된다.
같은 서버 안에서는 내부 주소로
봇과 채팅 서버가 같은 호스트의 다른 컨테이너라면 공개 주소로 돌아 나갔다 들어올 필요가 없다. compose 에 호스트 게이트웨이를 등록하고 내부 포트로 바로 부른다.
services:
bot:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
CHAT_URL: http://host.docker.internal:8065
이렇게 바꾼 계기가 있다. 공개 주소 앞단의 보안 설정이 일부 HTTP 클라이언트의 User-Agent 를 봇으로 보고 403 으로 막았다. 내부 주소로 가면 그 계층을 거치지 않는다.
겪은 함정
- 표가 표로 안 보인다. 마크다운 표는 바로 앞에 빈 줄이 있어야 표로 그려진다. 제목 줄 바로 다음 줄에 표를 붙이면 세로 막대가 그대로 보인다. 또 구분 줄에 정렬용 콜론(
--:)을 넣으면 내 서버 버전에서는 표가 깨졌다. 단순한---만 쓴다. - 컨테이너에 들어가 시험할 때 토큰이 없다. 서버가 시작할 때 설정 파일을 읽어 환경 변수를 채우는 구조라면,
docker exec로 따로 띄운 스크립트에는 그 값이 없다. 이것을 모르고 "토큰이 만료됐나?" 하며 401 의 원인을 엉뚱한 데서 찾았다. 시험 스크립트 첫 줄에서 같은 설정 파일을 읽게 한다:process.loadEnvFile('/config/.env') - 한글이 든 본문을 curl 로 시험할 때는 본문을 파일로 저장해
--data-binary @파일로 보낸다. 명령줄에 직접 쓰면 환경에 따라 인코딩이 깨진다.
매일 정해진 시각에 실행하기
"매일 아침 8시에 뉴스 요약" 같은 알림은 cron 없이 프로세스 안에서 처리한다. 컨테이너 하나로 끝나고, 시각을 환경 변수로 바꿀 수 있다.
// AT="08:00,14:00" 처럼 여러 시각을 받는다
function nextRun(times, now = new Date()) {
const candidates = times.flatMap((hhmm) => {
const [h, m] = hhmm.split(':').map(Number);
const today = new Date(now); today.setHours(h, m, 0, 0);
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
return [today, tomorrow];
});
return candidates.filter((d) => d > now).sort((a, b) => a - b)[0];
}
async function loop() {
for (;;) {
const at = nextRun(TIMES);
await new Promise((r) => setTimeout(r, at - Date.now()));
await runOnce().catch((e) => console.error(e.message));
}
}
- 컨테이너의 시간대를 맞춰 둔다(
TZ: Asia/Seoul). 안 그러면 8시가 UTC 8시가 된다. - 하루 한 번 보장은 상태 파일로. 8시 직후에 컨테이너가 재시작하면 같은 알림이 다시 나갈 수 있다. 마지막으로 보낸 날짜를 볼륨의 파일에 적어 두고, 같은 날이면 건너뛴다.
- 즉시 한 번 실행하는 명령행 옵션을 둔다. 내일 아침까지 기다리지 않고 형식을 확인할 수 있다.
다음 편은 알림 봇의 핵심인 "새 것만 추리기"다. 시리즈 전체 목록은 여기.
댓글 없음:
댓글 쓰기