2026년 11월 13일 금요일

내 일을 대신 지켜보는 알림 봇 만들기 (1) 채팅방에 봇으로 글 올리기

알림봇-따라하기 시리즈 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시 직후에 컨테이너가 재시작하면 같은 알림이 다시 나갈 수 있다. 마지막으로 보낸 날짜를 볼륨의 파일에 적어 두고, 같은 날이면 건너뛴다.
  • 즉시 한 번 실행하는 명령행 옵션을 둔다. 내일 아침까지 기다리지 않고 형식을 확인할 수 있다.

다음 편은 알림 봇의 핵심인 "새 것만 추리기"다. 시리즈 전체 목록은 여기.

댓글 없음:

댓글 쓰기

반도체 주식을 보기 전에 그려 두는 지도 — 설계, 파운드리, 메모리, 장비, 소재, 후공정

"반도체주"라고 한 묶음으로 부르지만, 그 안의 회사들은 하는 일도 돈 버는 방식도 전혀 다르다. 같은 뉴스에 어떤 회사는 오르고 어떤 회사는 내리는 이유가 여기에 있다. 종목을 들여다보기 전에 머릿속에 그려 두면 좋은 지도를 정리한...