본문 바로가기

AI와 놀기

Ai 활용, Rss discord, kakao 봇 만들기

SECURITY AUTOMATION GUIDE

보안 RSS 알림 봇 구축 가이드: GitHub Actions로 Discord·KakaoTalk 자동화

서버를 따로 운영하지 않고 GitHub Actions만으로 보안 RSS 새 글을 수집해 디스코드에 실시간 전송하고, 하루 두 번 디스코드와 카카오톡으로 다이제스트를 보내는 구축 과정을 정리했습니다.

이 글에서 다루는 내용 RSS 수집 구조 · Discord 웹훅 · GitHub Actions 스케줄 · Kakao OAuth · 오류 해결 · 유지보수

보안 관련 RSS 피드를 주기적으로 확인해서 새 글이 올라오면 **디스코드(실시간)**와 **디스코드+카카오톡(하루 2번 다이제스트)**으로 자동 전송하는 봇을 GitHub Actions만으로 구축한 전체 과정 정리. 서버나 개인 컴퓨터를 켜둘 필요 없이 클라우드에서 무료로 계속 돌아감.

🔐 발행 전 보안 보완 권장
아래 원문에는 카카오 리프레시 토큰을 data/kakao_refresh_token.txt에 커밋하는 구성이 포함되어 있습니다. 비공개 저장소라도 토큰을 Git 이력에 남기면 저장소 접근자·백업·로그를 통해 노출될 수 있습니다. 실제 운영에서는 토큰 파일을 커밋하지 말고 GitHub Actions Secret 또는 별도의 비밀 저장소를 사용하세요. 이미 실제 토큰을 커밋했다면 파일만 지우는 것으로 끝내지 말고 토큰을 폐기·재발급해야 합니다.

0. 최종 결과물 미리보기

  • 저장소: security-rss-discord (private)
  • 채널 3종류: 메인 실시간(뉴스), 리서치 실시간(레드팀 블로그), 다이제스트(디스코드+카카오톡, 하루 2번)
  • 관리 포인트는 딱 2개 텍스트 파일(config/feeds.txt, config/research_feeds.txt)뿐 — 소스 추가/삭제는 코드 수정 없이 URL 한 줄만 넣으면 됨

전체 흐름

RSS 피드 (뉴스 8개 + 리서치 3개)
   └─ 30분마다 ─▶ GitHub Actions: check-feeds.yml
                    ├─ 새 글 ─▶ Discord 실시간 채널
                    ├─ 새 글 ─▶ Discord 리서치 채널
                    └─ digest_queue.json에 적재
                         └─ 하루 2번 ─▶ GitHub Actions: daily-digest.yml
                                          ├─▶ 카카오톡 나에게 보내기
                                          └─▶ Discord 다이제스트 채널

1. 사전 준비물

  • GitHub 계정
  • Discord 서버(채널 3개를 만들 수 있는 권한)
  • Kakao 계정 (카카오톡 다이제스트를 원할 경우만)
  • Windows PC (git, GitHub CLI, Python이 로컬에 없어도 됨 — 이 가이드에서 설치 과정부터 다룸)

2. GitHub 저장소 만들기

2-1. git / GitHub CLI 설치

Windows에서 winget으로 설치 (관리자 권한 프롬프트가 뜨면 승인):

winget install --id Git.Git -e --source winget --accept-package-agreements --accept-source-agreements
winget install --id GitHub.cli -e --source winget --accept-package-agreements --accept-source-agreements

⚠️ 설치 직후 같은 터미널 세션에서는 PATH가 갱신 안 될 수 있음. 그럴 땐 전체 경로로 실행: "C:\Program Files\Git\cmd\git.exe", "C:\Program Files\GitHub CLI\gh.exe"

2-2. GitHub 로그인 인증

gh auth login --hostname github.com --web --git-protocol https

실행하면 터미널에 코드와 링크(https://github.com/login/device)가 출력됨. 브라우저에서 그 링크를 열고 코드를 입력하면 인증 완료. gh auth status로 확인.

2-3. git 커밋용 이름/이메일 설정

실제 이메일을 공개하고 싶지 않다면 GitHub이 제공하는 비공개 noreply 주소를 사용:

git config --global user.name "내깃허브아이디"
git config --global user.email "내깃허브아이디@users.noreply.github.com"

2-4. 로컬 폴더 + 저장소 생성

mkdir security-rss-discord && cd security-rss-discord
git init
git add README.md .gitignore
git commit -m "Initial commit"
gh repo create security-rss-discord --private --source=. --remote=origin --push

3. 프로젝트 구조

security-rss-discord/
config/feeds.txt              메인 뉴스 소스 목록
config/research_feeds.txt     리서치 블로그 소스 목록
data/seen.json                메인 채널 중복 전송 방지
data/research_seen.json       리서치 채널 중복 전송 방지
data/digest_queue.json        다이제스트 대기열
data/kakao_refresh_token.txt  카카오 리프레시 토큰
scripts/check_feeds.py        실시간 체크 + 전송
scripts/send_digest.py        디스코드 다이제스트 전송
scripts/send_kakao_digest.py  카카오톡 다이제스트 전송
.github/workflows/check-feeds.yml    30분마다 실행
.github/workflows/daily-digest.yml   하루 2번 실행
requirements.txt
README.md

4. Discord 웹훅 만들기

  1. 디스코드 채널 설정(⚙️) → 연동(Integrations) → 웹훅(Webhooks) → "새 웹훅 만들기"
  2. 이름 정하고 URL 복사
  3. 이 과정을 채널 3개(메인 실시간 / 리서치 실시간 / 다이제스트)에 대해 반복

🔒 웹훅 URL은 그 자체로 누구나 메시지를 보낼 수 있는 키라서 코드에 직접 넣지 않고 GitHub Secrets로만 저장함.


5. 실시간 체크 파이프라인

5-1. 소스 목록 파일

config/feeds.txt:

# 한 줄에 RSS/Atom 피드 URL 하나씩 작성
# '#'으로 시작하는 줄은 무시됨

https://feeds.feedburner.com/TheHackersNews
https://krebsonsecurity.com/feed/
https://www.darkreading.com/rss.xml
https://www.theregister.com/security/headlines.atom
https://www.schneier.com/feed/atom/
https://www.boannews.com/media/news_rss.xml
https://www.dailysecu.com/rss/allArticle.xml
https://feeds.feedburner.com/eset/blog

config/research_feeds.txt:

# TrustedSec / SpecterOps / MDSec 등 레드팀/오펜시브 리서치 블로그 전용

https://trustedsec.com/feed.rss
https://specterops.io/feed/
https://www.mdsec.co.uk/feed/

💡 RSS 소스 URL을 찾을 때는 단순히 사이트/feed/를 추측하지 말고 실제로 curl -A "Mozilla/5.0" URL로 접속해서 xml/rss/feed로 시작하는지 확인하고 등록할 것. 일부 사이트(BleepingComputer, SecurityWeek 등)는 자동화 요청을 아예 403으로 차단해서 애초에 못 씀.

5-2. scripts/check_feeds.py

소스 그룹(메인/리서치)마다 재사용할 수 있도록 CLI 인자를 받는 구조:

import argparse
import json
import os
import time
from pathlib import Path

import feedparser
import requests

ROOT = Path(__file__).resolve().parent.parent
MAX_SEEN = 3000  # 무한정 커지는 걸 막기 위한 seen 목록 상한
# feedparser 기본 User-Agent는 일부 사이트(WAF/Cloudflare)에서 차단되거나
# 빈 응답을 주는 경우가 있어서 브라우저처럼 보이게 지정 (예: mdsec.co.uk)
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"


def load_feeds(feeds_file: Path) -> list[str]:
    lines = feeds_file.read_text(encoding="utf-8").splitlines()
    return [line.strip() for line in lines if line.strip() and not line.startswith("#")]


def load_seen(seen_file: Path) -> list[str]:
    if not seen_file.exists():
        return []
    return json.loads(seen_file.read_text(encoding="utf-8"))


def save_seen(seen_file: Path, seen_ids: list[str]) -> None:
    seen_file.parent.mkdir(parents=True, exist_ok=True)
    trimmed = seen_ids[-MAX_SEEN:]
    seen_file.write_text(json.dumps(trimmed, ensure_ascii=False, indent=2), encoding="utf-8")


def entry_id(entry) -> str:
    # RSS는 guid를, Atom은 id를 쓰는데 둘 다 없는 피드가 있어서 link로 폴백
    return entry.get("id") or entry.get("link")


def load_digest_queue(digest_queue_file: Path) -> list[dict]:
    if not digest_queue_file.exists():
        return []
    return json.loads(digest_queue_file.read_text(encoding="utf-8"))


def save_digest_queue(digest_queue_file: Path, queue: list[dict]) -> None:
    digest_queue_file.parent.mkdir(parents=True, exist_ok=True)
    digest_queue_file.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")


def send_to_discord(webhook_url: str, feed_title: str, entry) -> None:
    payload = {
        "embeds": [
            {
                "title": entry.get("title", "(제목 없음)"),
                "url": entry.get("link"),
                "description": f"출처: {feed_title}",
                "color": 0xE74C3C,
            }
        ]
    }
    for _ in range(5):
        resp = requests.post(webhook_url, json=payload, timeout=10)
        if resp.status_code != 429:
            resp.raise_for_status()
            return
        retry_after = resp.json().get("retry_after", 1)
        time.sleep(retry_after + 0.5)
    resp.raise_for_status()


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--feeds-file", required=True)
    parser.add_argument("--seen-file", required=True)
    parser.add_argument("--webhook-env", default="DISCORD_WEBHOOK_URL")
    parser.add_argument("--digest-queue-file", default=None)
    args = parser.parse_args()

    webhook_url = os.environ[args.webhook_env]
    feeds_file = ROOT / args.feeds_file
    seen_file = ROOT / args.seen_file
    digest_queue_file = ROOT / args.digest_queue_file if args.digest_queue_file else None

    feeds = load_feeds(feeds_file)
    seen_ids = load_seen(seen_file)
    seen_set = set(seen_ids)
    is_first_run = len(seen_set) == 0

    new_ids: list[str] = []
    digest_queue = load_digest_queue(digest_queue_file) if digest_queue_file else None

    for feed_url in feeds:
        parsed = feedparser.parse(feed_url, agent=USER_AGENT)
        if parsed.bozo and not parsed.entries:
            print(f"[WARN] 피드를 읽지 못함: {feed_url}")
            continue

        feed_title = parsed.feed.get("title", feed_url)
        entries = list(reversed(parsed.entries))  # 오래된 순으로

        for entry in entries:
            uid = entry_id(entry)
            if not uid or uid in seen_set:
                continue

            # 처음 등록하는 피드는 과거 글이 한꺼번에 쏟아지는 걸 막기 위해
            # 알림 없이 seen 목록에만 기록(기준선 설정)하고 넘어감
            if not is_first_run:
                send_to_discord(webhook_url, feed_title, entry)
                time.sleep(1.5)
                if digest_queue is not None:
                    digest_queue.append(
                        {
                            "feed": feed_title,
                            "title": entry.get("title", "(제목 없음)"),
                            "link": entry.get("link"),
                        }
                    )

            seen_set.add(uid)
            new_ids.append(uid)

    save_seen(seen_file, seen_ids + new_ids)
    if digest_queue_file:
        save_digest_queue(digest_queue_file, digest_queue)


if __name__ == "__main__":
    main()

💡 첫 실행(bootstrap) 처리가 핵심: seen 목록이 비어있으면 현재 있는 글들을 알림 없이 그냥 기록만 함. 이걸 안 하면 소스 하나 추가할 때마다 과거 글이 한꺼번에 쏟아짐 — 실제로 겪은 문제.

5-3. GitHub Secrets 등록

printf '%s' "디스코드_웹훅_URL" | gh secret set DISCORD_WEBHOOK_URL --repo owner/security-rss-discord
printf '%s' "리서치_웹훅_URL" | gh secret set DISCORD_RESEARCH_WEBHOOK_URL --repo owner/security-rss-discord

5-4. 워크플로우 .github/workflows/check-feeds.yml

name: Check security RSS feeds

on:
  schedule:
    # 한국시간(KST, UTC+9) 09:00~22:00에만 30분 간격 실행
    - cron: "*/30 0-12 * * *"
    - cron: "0 13 * * *"
  workflow_dispatch: {}

permissions:
  contents: write

jobs:
  check-feeds:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt

      - run: >
          python scripts/check_feeds.py
          --feeds-file config/feeds.txt
          --seen-file data/seen.json
          --digest-queue-file data/digest_queue.json
          --webhook-env DISCORD_WEBHOOK_URL
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}

      - run: >
          python scripts/check_feeds.py
          --feeds-file config/research_feeds.txt
          --seen-file data/research_seen.json
          --digest-queue-file data/digest_queue.json
          --webhook-env DISCORD_RESEARCH_WEBHOOK_URL
        env:
          DISCORD_RESEARCH_WEBHOOK_URL: ${{ secrets.DISCORD_RESEARCH_WEBHOOK_URL }}

      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add data/seen.json data/digest_queue.json data/research_seen.json
          git diff --cached --quiet || git commit -m "Update seen entries"
          git pull --rebase origin master
          git push

주기 설계 이유: 처음엔 24시간 내내 30분 간격이었는데 (1) 새벽에 오는 게 싫다는 피드백으로 09:00~22:00만 활성화, (2) GitHub Actions 스케줄이 가끔 지연되면서 23:30 실행분이 자정을 넘겨버리는 사고가 있어서 활동 시간 끝을 22:00으로 앞당김.


6. 다이제스트 파이프라인 (Discord)

6-1. scripts/send_digest.py

import json
import os
import time
from pathlib import Path

import requests

ROOT = Path(__file__).resolve().parent.parent
DIGEST_QUEUE_FILE = ROOT / "data" / "digest_queue.json"
MAX_ITEMS_PER_FEED = 20
MAX_EMBEDS = 10

WEBHOOK_URL = os.environ["DISCORD_DIGEST_WEBHOOK_URL"]


def load_queue() -> list[dict]:
    if not DIGEST_QUEUE_FILE.exists():
        return []
    return json.loads(DIGEST_QUEUE_FILE.read_text(encoding="utf-8"))


def clear_queue() -> None:
    DIGEST_QUEUE_FILE.write_text("[]", encoding="utf-8")


def group_by_feed(items: list[dict]) -> dict[str, list[dict]]:
    groups: dict[str, list[dict]] = {}
    for item in items:
        groups.setdefault(item["feed"], []).append(item)
    return groups


def build_embeds(items: list[dict]) -> list[dict]:
    groups = group_by_feed(items)
    embeds = []
    for feed_title, entries in groups.items():
        shown = entries[:MAX_ITEMS_PER_FEED]
        lines = [f"- [{e['title']}]({e['link']})" for e in shown]
        if len(entries) > MAX_ITEMS_PER_FEED:
            lines.append(f"...외 {len(entries) - MAX_ITEMS_PER_FEED}건 더")
        embeds.append(
            {
                "title": f"{feed_title} ({len(entries)}건)",
                "description": "\n".join(lines)[:4096],
                "color": 0x3498DB,
            }
        )
    return embeds


def send(payload: dict) -> None:
    for _ in range(5):
        resp = requests.post(WEBHOOK_URL, json=payload, timeout=10)
        if resp.status_code != 429:
            resp.raise_for_status()
            return
        retry_after = resp.json().get("retry_after", 1)
        time.sleep(retry_after + 0.5)
    resp.raise_for_status()


def main() -> None:
    items = load_queue()
    if not items:
        print("[INFO] 다이제스트로 보낼 새 글이 없음")
        return

    embeds = build_embeds(items)
    chunks = [embeds[i : i + MAX_EMBEDS] for i in range(0, len(embeds), MAX_EMBEDS)]

    for i, chunk in enumerate(chunks):
        content = f"**보안 뉴스 다이제스트 — 총 {len(items)}건**" if i == 0 else None
        payload = {"embeds": chunk}
        if content:
            payload["content"] = content
        send(payload)
        time.sleep(1.5)

    print(f"[INFO] 다이제스트 전송 완료: {len(items)}건")
    clear_queue()


if __name__ == "__main__":
    main()

7. 카카오톡 연동 (가장 까다로운 부분)

⚠️ 카카오톡은 디스코드처럼 봇을 채널에 초대하는 개념이 없음. 대신 개인 계정으로 OAuth 로그인해서 "나에게 보내기" API를 쓰는 방식. 아래 과정에서 몇 번 막혔던 지점들을 그대로 남겨둠.

7-1. Kakao Developers 앱 생성

  1. developers.kakao.com 접속 → 로그인 → 애플리케이션 추가
  2. 앱 → 플랫폼 키 메뉴에서 REST API 키 확인
  3. 카카오 로그인 → 일반에서 "사용 설정" ON

7-2. Redirect URI 등록

🧭 헷갈렸던 포인트: Redirect URI 입력란이 처음엔 안 보임. Web 플랫폼이 등록 안 되어 있으면 로그인용 Redirect URI 필드 자체가 안 나타남 (로그아웃 Redirect URI는 별도라 먼저 보일 수 있음). "고급" 탭에서 Web 플랫폼/Redirect URI 등록 화면을 찾아서 아래 값을 추가.

http://localhost:8910/oauth

(로컬에서 인증 코드를 자동으로 받기 위한 임시 리스너 포트)

7-3. 메시지 전송 권한 활성화

카카오 로그인 → 동의항목에서 "카카오톡 메시지 전송"(talk_message) 항목을 사용함으로 켜기.

🚨 주의: 이 항목을 켜기 전에 이미 로그인해서 토큰을 발급받았다면, 그 토큰에는 이 권한이 반영 안 되어 있음 (insufficient scopes 에러, code -402). 동의항목을 켠 이후에 다시 로그인해서 새 토큰을 받아야 함.

7-4. Client Secret 확인

앱 → 플랫폼 키 페이지의 REST API 키 카드 아래 "클라이언트 시크릿" 링크에서 값 확인. 이게 활성화되어 있으면 토큰 교환 요청에 client_secret을 반드시 포함해야 함 (안 넣으면 invalid_client, 에러코드 KOE010).

7-5. OAuth 로그인으로 코드 받기

로컬에서 리다이렉트를 자동으로 받는 간단한 파이썬 서버를 하나 띄움:

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

PORT = 8910


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        query = parse_qs(urlparse(self.path).query)
        code = query.get("code", [None])[0]
        error = query.get("error", [None])[0]
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        if code:
            print(f"CODE={code}", flush=True)
            raise KeyboardInterrupt
        elif error:
            print(f"ERROR={error}", flush=True)
            raise KeyboardInterrupt

    def log_message(self, format, *args):
        pass


server = HTTPServer(("localhost", PORT), Handler)
while True:
    try:
        server.handle_request()
    except KeyboardInterrupt:
        break

💡 브라우저가 favicon.ico 같은 잡요청을 먼저 보내는 경우가 있어서, code/error가 없는 요청은 무시하고 계속 대기하도록 만들어야 함 (처음엔 이걸 안 해서 진짜 콜백이 오기 전에 서버가 죽어버리는 문제가 있었음).

서버를 띄운 뒤 아래 URL을 브라우저에서 열어 로그인/동의:

https://kauth.kakao.com/oauth/authorize?client_id=<REST_API_KEY>&redirect_uri=http://localhost:8910/oauth&response_type=code&scope=talk_message

7-6. 코드를 토큰으로 교환

curl -s -X POST "https://kauth.kakao.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded;charset=utf-8" \
  -d "grant_type=authorization_code" \
  -d "client_id=<REST_API_KEY>" \
  -d "client_secret=<CLIENT_SECRET>" \
  -d "redirect_uri=http://localhost:8910/oauth" \
  -d "code=<위에서 받은 CODE>"

성공하면 access_token, refresh_token이 옴. 인증 코드는 1회용이고 몇 분 안에 만료되므로, 실패하면(KOE320 authorization code not found) 7-5부터 다시 해서 새 코드를 받아야 함.

7-7. 테스트 전송

curl -s -X POST "https://kapi.kakao.com/v2/api/talk/memo/default/send" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  --data-urlencode 'template_object={"object_type":"text","text":"테스트 메시지","link":{"web_url":"https://example.com","mobile_web_url":"https://example.com"}}'

💡 Windows용 curl(mingw32 빌드)에서 --data-urlencode name@파일경로로 UTF-8 payload를 넘길 때, Git Bash 스타일 경로(/c/Users/...)를 못 읽었음. Windows 스타일 경로(C:\Users\...)로 바꾸니 해결됨. 명령줄에 한글을 직접 넣는 것도 셸 인코딩 때문에 깨질 수 있어서, 파일로 UTF-8 저장 후 읽어들이는 방식이 안전함.

7-8. scripts/send_kakao_digest.py

import json
import os
import time
from pathlib import Path

import requests

ROOT = Path(__file__).resolve().parent.parent
DIGEST_QUEUE_FILE = ROOT / "data" / "digest_queue.json"
REFRESH_TOKEN_FILE = ROOT / "data" / "kakao_refresh_token.txt"
MAX_TEXT_LEN = 800

REST_API_KEY = os.environ["KAKAO_REST_API_KEY"]
CLIENT_SECRET = os.environ["KAKAO_CLIENT_SECRET"]


def load_queue() -> list[dict]:
    if not DIGEST_QUEUE_FILE.exists():
        return []
    return json.loads(DIGEST_QUEUE_FILE.read_text(encoding="utf-8"))


def refresh_access_token() -> str:
    refresh_token = REFRESH_TOKEN_FILE.read_text(encoding="utf-8").strip()
    resp = requests.post(
        "https://kauth.kakao.com/oauth/token",
        data={
            "grant_type": "refresh_token",
            "client_id": REST_API_KEY,
            "client_secret": CLIENT_SECRET,
            "refresh_token": refresh_token,
        },
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()

    # 카카오는 만료가 임박했을 때만 새 refresh_token을 내려줌(항상 오는 게 아님).
    # 내려줄 때마다 파일을 갱신해두면 60일 카운트가 계속 리셋돼서
    # 봇이 꾸준히 돌아가는 한 재로그인 없이 유지됨
    if "refresh_token" in data:
        REFRESH_TOKEN_FILE.write_text(data["refresh_token"], encoding="utf-8")

    return data["access_token"]


def group_by_feed(items: list[dict]) -> dict[str, list[dict]]:
    groups: dict[str, list[dict]] = {}
    for item in items:
        groups.setdefault(item["feed"], []).append(item)
    return groups


def build_messages(items: list[dict]) -> list[str]:
    # 카카오 "리스트형" 템플릿은 항목마다 image_url이 필수라 뉴스 링크엔 안 맞고,
    # "텍스트형"은 링크를 1개만 받을 수 있어서 URL은 그냥 본문 텍스트로 넣음
    groups = group_by_feed(items)
    messages = []
    current = f"보안 뉴스 다이제스트 (총 {len(items)}건)\n"

    for feed_title, entries in groups.items():
        block = f"\n[{feed_title}]\n"
        for e in entries:
            block += f"- {e['title']}\n{e['link']}\n"

        if len(current) + len(block) > MAX_TEXT_LEN:
            messages.append(current)
            current = block
        else:
            current += block

    if current.strip():
        messages.append(current)

    return messages


def send_message(access_token: str, text: str) -> None:
    payload = {
        "object_type": "text",
        "text": text[:MAX_TEXT_LEN],
        "link": {
            "web_url": "https://github.com/J-DLEAF/security-rss-discord",
            "mobile_web_url": "https://github.com/J-DLEAF/security-rss-discord",
        },
    }
    resp = requests.post(
        "https://kapi.kakao.com/v2/api/talk/memo/default/send",
        headers={"Authorization": f"Bearer {access_token}"},
        data={"template_object": json.dumps(payload, ensure_ascii=False)},
        timeout=10,
    )
    resp.raise_for_status()


def main() -> None:
    items = load_queue()
    if not items:
        print("[INFO] 카카오 다이제스트로 보낼 새 글이 없음")
        return

    access_token = refresh_access_token()
    messages = build_messages(items)

    for msg in messages:
        send_message(access_token, msg)
        time.sleep(1)

    print(f"[INFO] 카카오 다이제스트 전송 완료: {len(items)}건, 메시지 {len(messages)}개")


if __name__ == "__main__":
    main()

7-9. GitHub Secrets 등록

printf '%s' "<REST_API_KEY>" | gh secret set KAKAO_REST_API_KEY --repo owner/security-rss-discord
printf '%s' "<CLIENT_SECRET>" | gh secret set KAKAO_CLIENT_SECRET --repo owner/security-rss-discord

⚠️ 보안 권장 구성: 리프레시 토큰을 data/kakao_refresh_token.txt에 커밋하지 말고 KAKAO_REFRESH_TOKEN이라는 GitHub Actions Secret으로 저장하는 편이 안전함. 토큰 자동 갱신값까지 영구 반영하려면 GitHub Secret을 안전하게 갱신하는 별도 절차가 필요함. 편의상 파일 방식으로 운영하더라도 실제 토큰이 Git 이력에 남는 위험을 반드시 인지해야 함.

7-10. 워크플로우 .github/workflows/daily-digest.yml

name: Send daily digest

on:
  schedule:
    - cron: "5 0,13 * * *"
  workflow_dispatch: {}

permissions:
  contents: write

jobs:
  send-digest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt

      - run: python scripts/send_kakao_digest.py
        continue-on-error: true
        env:
          KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
          KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}

      - run: python scripts/send_digest.py
        env:
          DISCORD_DIGEST_WEBHOOK_URL: ${{ secrets.DISCORD_DIGEST_WEBHOOK_URL }}

      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add data/digest_queue.json data/kakao_refresh_token.txt
          git diff --cached --quiet || git commit -m "Clear digest queue after sending"
          git pull --rebase origin master
          git push

continue-on-error로 카카오 스크립트가 실패해도(토큰 만료 등) 아래 디스코드 전송/큐 비우기까지 멈추지 않도록 격리함. 카카오 스크립트가 큐를 비우지 않고 먼저 읽기만 하기 때문에, 순서상 반드시 카카오 스크립트가 디스코드 스크립트보다 먼저 실행되어야 함.


8. 트러블슈팅 모음

429 Too Many Requests (디스코드 웹훅 레이트리밋)

디스코드 웹훅은 짧은 시간에 요청을 몰아서 보내면 429가 뜸. 해결: 요청 사이에 딜레이를 넣고, 429가 오면 응답의 retry_after 값만큼 기다렸다가 재시도하는 로직을 추가.

새 소스 추가 시 과거 글이 한꺼번에 쏟아짐

피드를 새로 추가하면 그 피드의 최근 글 전체가 새 글로 인식되어 한 번에 전송됨. 해결: 소스를 추가하기 전에 로컬에서 미리 그 피드의 현재 글 목록을 가져와 seen.json에 먼저 넣어두고(기준선 설정) 커밋.

일부 사이트가 자동화 요청을 차단함 (403 / 빈 피드)

BleepingComputer, SecurityWeek 등은 요청 자체를 403으로 거부해서 애초에 소스 목록에서 제외함. MDSec은 기본 User-Agent로는 빈 피드(0 entries)를 주길래 브라우저처럼 보이는 User-Agent로 우회함. 그래도 서버 자체가 가끔 불안정(502/522)해서 그 회차만 조용히 스킵되는 경우가 있음 — 스크립트가 크래시 나지 않고 다음 실행에 재시도하도록 설계되어 있어 문제없음.

GitHub Actions 스케줄 지연으로 자정을 넘김

GitHub은 스케줄된 워크플로우를 "정확한 시각"이 아니라 "그 이후 최대한 빨리"로만 보장함. 23:30 KST 실행분이 서버 혼잡으로 밀려서 00:31 KST에 돌아간 사례가 실제로 있었음. 해결: 활동 시간 끝을 22:00으로 앞당겨서 지연 여유분을 확보.

두 워크플로우가 동시에 돌면 git push 충돌

check-feeds와 daily-digest를 거의 동시에 수동 실행했더니, 먼저 push한 쪽 때문에 나중 쪽이 "rejected (fetch first)"로 실패함. 이 경우 그 실행에서 감지된 새 글이 다음 실행에서 중복 전송될 위험이 있음. 해결: push 직전에 git pull --rebase origin master를 추가해서 충돌을 자동으로 해소.

카카오 KOE205 / KOE010 / KOE320 에러

  • KOE205 (insufficient scopes): 동의항목에서 talk_message를 켜기 전에 발급받은 토큰이라 권한이 없음 → 동의항목 켠 뒤 재로그인
  • KOE010 (invalid_client): Client Secret이 활성화된 앱인데 토큰 교환 요청에 안 넣었음 → client_secret 파라미터 추가
  • KOE320 (authorization code not found): 인증 코드는 1회용 + 단기 만료라 재사용/시간초과 시 발생 → 새로 로그인해서 새 코드 발급

카카오 메시지 템플릿 제약

  • 리스트형: 항목마다 image_url이 필수라 RSS 글에는 안 맞음
  • 텍스트형: 링크를 1개만 받을 수 있음 → URL을 본문 텍스트에 그냥 나열 (카카오톡이 자동 링크 인식)
  • 텍스트 길이 제한이 있어서(보수적으로 800자 컷) 내용이 길면 여러 메시지로 나눠서 전송

9. 최종 소스 목록

채널 소스
메인 실시간 The Hacker News, Krebs on Security, Dark Reading, The Register(보안), Schneier on Security, 보안뉴스, 데일리시큐, ESET 리서치 블로그
리서치 실시간 TrustedSec, SpecterOps, MDSec

10. GitHub Secrets 전체 목록

이름 용도
DISCORD_WEBHOOK_URL 메인 실시간 채널
DISCORD_RESEARCH_WEBHOOK_URL 리서치 실시간 채널
DISCORD_DIGEST_WEBHOOK_URL 다이제스트 채널
KAKAO_REST_API_KEY Kakao 앱 REST API 키
KAKAO_CLIENT_SECRET Kakao 앱 Client Secret

11. 유지보수 체크리스트

  • 소스 추가/삭제: config/feeds.txt 또는 config/research_feeds.txt에 URL 한 줄만 수정 (새 소스는 5-2의 bootstrap 로직으로 기준선부터 잡을 것, 별도로 로컬에서 미리 seen 파일을 채우는 것을 추천)
  • 전송 시간대/주기 변경: 두 워크플로우 파일의 cron 값 수정 (UTC 기준, KST는 +9시간)
  • 카카오톡이 갑자기 안 오면: 7-5와 7-6 과정으로 재로그인해서 data/kakao_refresh_token.txt 갱신 (디스코드는 영향 없음, continue-on-error로 격리되어 있음)
  • 실행 상태 확인: gh run list --repo owner/security-rss-discord
발행용 태그

GitHubActions, RSS, Discord봇, 카카오톡API, 보안뉴스, 자동화, Python, 웹훅

11. 결과 화면 

Discord 봇


카카오봇