From cc8e37d6fe9714b7e67072375307820d6191629b Mon Sep 17 00:00:00 2001 From: hyeonggil <> Date: Fri, 27 Mar 2026 22:10:26 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Telegram=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20Slack=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notification-hook.sh에서 Slack 웹훅 관련 코드 제거하고 Telegram 알림으로 변경 - lotto_auto_buy.py에서 Slack 알림 기능을 Telegram으로 대체 - 환경 변수 설정에 Telegram 관련 변수 추가 - 설정 파일에서 Slack 웹훅 URL 제거 --- .claude/hooks/notification-hook.sh | 79 +++------- .claude/hooks/notify_telegram.sh | 41 +++++ .claude/hooks/pre-tool-hook.sh | 26 ++++ .claude/hooks/stop-hook copy.sh | 20 +++ .claude/settings.local.json | 15 +- .github/workflows/lotto-buy.yml | 2 + lotto-runner/lotto_auto_buy.py | 45 +++--- telegram_bot.py | 236 +++++++++++++++++++++++++++++ 8 files changed, 384 insertions(+), 80 deletions(-) create mode 100755 .claude/hooks/notify_telegram.sh create mode 100755 .claude/hooks/pre-tool-hook.sh create mode 100755 .claude/hooks/stop-hook copy.sh create mode 100644 telegram_bot.py diff --git a/.claude/hooks/notification-hook.sh b/.claude/hooks/notification-hook.sh index da3f20b..27820d5 100755 --- a/.claude/hooks/notification-hook.sh +++ b/.claude/hooks/notification-hook.sh @@ -1,67 +1,24 @@ -#!/bin/bash -# Claude Code Notification 훅 - 권한 요청 및 사용자 입력 대기 알림 +#!/usr/bin/env bash +# Claude Code - Notification 훅 +# Claude가 사용자 주의가 필요한 알림을 보낼 때 실행된다. # -# 이 스크립트는 Claude Code가 Notification 이벤트를 발생시킬 때 실행됩니다. -# 주로 권한 요청이나 사용자 입력 대기 상황에서 Slack 알림을 보냅니다. +# stdin JSON 형식: {"session_id": "...", "message": "..."} -# .env 파일에서 Slack 웹훅 URL 로드 (CRLF 호환) -ENV_FILE="${CLAUDE_PROJECT_DIR}/.env" -# Windows 경로 백슬래시를 슬래시로 변환 -ENV_FILE="${ENV_FILE//\\//}" -if [ -f "$ENV_FILE" ]; then - while IFS='=' read -r key value || [[ -n "$key" ]]; do - [[ "$key" =~ ^[[:space:]]*# ]] && continue - [[ -z "${key// }" ]] && continue - key="${key//$'\r'/}" - value="${value//$'\r'/}" - export "$key=$value" - done < "$ENV_FILE" -else - echo "오류: .env 파일을 찾을 수 없습니다: $ENV_FILE" >&2 - exit 1 +set -euo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# stdin에서 JSON 읽기 +INPUT="$(cat)" + +MESSAGE="$(echo "$INPUT" | jq -r '.message // ""')" + +if [[ -z "$MESSAGE" ]]; then + exit 0 fi -# Slack 웹훅 URL 확인 -if [ -z "$SLACK_WEBHOOK_URL" ]; then - echo "오류: SLACK_WEBHOOK_URL이 설정되지 않았습니다." >&2 - exit 1 -fi +TELEGRAM_MESSAGE="🔔 *Claude 알림* -# stdin에서 JSON 입력 읽기 -STDIN_DATA=$(cat) +${MESSAGE}" -# JSON 입력에서 메시지 추출 -MESSAGE=$(echo "$STDIN_DATA" | jq -r '.message // empty') - -# 프로젝트명 추출 (Windows 경로 백슬래시 변환) -NORMALIZED_DIR="${CLAUDE_PROJECT_DIR//\\//}" -PROJECT_NAME=$(basename "$NORMALIZED_DIR") - -# 현재 시간 -TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') - -# jq를 사용해 안전하게 JSON payload 생성 (특수문자 이스케이프 처리) -PAYLOAD=$(jq -n \ - --arg project "$PROJECT_NAME" \ - --arg message "$MESSAGE" \ - --arg timestamp "$TIMESTAMP" \ - '{ - channel: "#claude-code", - username: "Claude Code", - icon_emoji: ":bell:", - text: ("🔔 권한 요청 알림\n\n프로젝트: " + $project + "\n상태: " + $message + "\n시간: " + $timestamp + "\n\nClaude Code에서 알림이 도착했습니다.") - }') - -# Slack으로 알림 전송 -curl -s -X POST \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - "$SLACK_WEBHOOK_URL" > /dev/null 2>&1 - -# 성공 여부 확인 -if [ $? -eq 0 ]; then - echo "Slack 알림이 성공적으로 전송되었습니다." >&2 -else - echo "Slack 알림 전송에 실패했습니다." >&2 - exit 1 -fi +"$HOOK_DIR/notify_telegram.sh" "$TELEGRAM_MESSAGE" diff --git a/.claude/hooks/notify_telegram.sh b/.claude/hooks/notify_telegram.sh new file mode 100755 index 0000000..574089b --- /dev/null +++ b/.claude/hooks/notify_telegram.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Telegram 메시지 발송 공통 유틸리티 +# 사용법: notify_telegram.sh "메시지 본문" +# +# 필요 환경변수: +# TELEGRAM_BOT_TOKEN - BotFather 발급 토큰 +# TELEGRAM_CHAT_ID - 알림을 받을 채팅 ID + +set -euo pipefail + +# 환경변수 미설정 시 .env 파일에서 로드 (Claude Code 훅 실행 환경 대응) +ENV_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/.env" +if [[ -f "$ENV_FILE" ]]; then + # export 구문을 포함한 .env 파일 소싱 + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" +TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}" +MESSAGE="${1:-}" + +# 환경변수 미설정 시 조용히 종료 (훅 실패로 Claude 작업 중단 방지) +if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then + exit 0 +fi + +if [[ -z "$MESSAGE" ]]; then + exit 0 +fi + +curl -s -X POST \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --argjson chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')" \ + > /dev/null diff --git a/.claude/hooks/pre-tool-hook.sh b/.claude/hooks/pre-tool-hook.sh new file mode 100755 index 0000000..a6fa05f --- /dev/null +++ b/.claude/hooks/pre-tool-hook.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Claude Code - PreToolUse 훅 +# 툴 실행 직전에 실행되어 Telegram으로 알림을 보낸다. +# +# stdin JSON 형식: +# {"session_id": "...", "tool_name": "...", "tool_input": {...}} + +set -euo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +INPUT="$(cat)" + +TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // ""')" +TOOL_INPUT="$(echo "$INPUT" | jq -r '.tool_input // {} | to_entries | map("\(.key): \(.value)") | join(", ")')" + +if [[ -z "$TOOL_NAME" ]]; then + exit 0 +fi + +TELEGRAM_MESSAGE="⚙️ *툴 실행* + +🔧 \`${TOOL_NAME}\` +📝 ${TOOL_INPUT}" + +"$HOOK_DIR/notify_telegram.sh" "$TELEGRAM_MESSAGE" diff --git a/.claude/hooks/stop-hook copy.sh b/.claude/hooks/stop-hook copy.sh new file mode 100755 index 0000000..048f2ef --- /dev/null +++ b/.claude/hooks/stop-hook copy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Claude Code - Stop 훅 +# Claude가 응답을 완료하고 멈출 때 실행된다. +# +# stdin JSON 형식: {"session_id": "...", "stop_hook_active": true} + +set -euo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# stdin에서 JSON 읽기 +INPUT="$(cat)" + +SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // ""')" + +TELEGRAM_MESSAGE="✅ *Claude 작업 완료* + +세션 ID: \`${SESSION_ID}\`" + +"$HOOK_DIR/notify_telegram.sh" "$TELEGRAM_MESSAGE" diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6ab1e15..2d60223 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,18 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/notification-hook.sh\"" + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notification-hook.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-hook.sh" } ] } @@ -23,7 +34,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-hook.sh\"" + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-hook.sh" } ] } diff --git a/.github/workflows/lotto-buy.yml b/.github/workflows/lotto-buy.yml index 5d0b09f..f8f2cf5 100644 --- a/.github/workflows/lotto-buy.yml +++ b/.github/workflows/lotto-buy.yml @@ -76,4 +76,6 @@ jobs: env: LOTTO_USER_ID: ${{ secrets.LOTTO_USER_ID }} LOTTO_USER_PW: ${{ secrets.LOTTO_USER_PW }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} run: .venv/bin/python3 lotto-runner/lotto_auto_buy.py diff --git a/lotto-runner/lotto_auto_buy.py b/lotto-runner/lotto_auto_buy.py index 987be08..8bd93c1 100644 --- a/lotto-runner/lotto_auto_buy.py +++ b/lotto-runner/lotto_auto_buy.py @@ -35,12 +35,13 @@ from selenium.webdriver.common.keys import Keys # 설정 # ───────────────────────────────────────────── CONFIG = { - "USER_ID": os.environ.get("LOTTO_USER_ID", "hyeonggil2"), - "USER_PW": os.environ.get("LOTTO_USER_PW", "Rlaehdbs062$"), + "USER_ID": os.environ.get("LOTTO_USER_ID", ""), + "USER_PW": os.environ.get("LOTTO_USER_PW", ""), "BUY_COUNT": 1, # 구매 게임 수 (1~10) "HEADLESS": True, # True: 브라우저 창 숨김, False: 브라우저 창 표시 "LOG_FILE": "logs/lotto_log.json", - "SLACK_WEBHOOK_URL": os.environ.get("SLACK_WEBHOOK_URL", ""), + "TELEGRAM_BOT_TOKEN": os.environ.get("TELEGRAM_BOT_TOKEN", ""), + "TELEGRAM_CHAT_ID": os.environ.get("TELEGRAM_CHAT_ID", ""), } # 로깅 설정 @@ -263,10 +264,11 @@ def extract_purchase_result(driver): return result -def send_slack(result): - """Slack Webhook으로 구매 결과 알림 전송""" - url = CONFIG["SLACK_WEBHOOK_URL"] - if not url: +def send_telegram(result): + """Telegram Bot으로 구매 결과 알림 전송""" + token = CONFIG["TELEGRAM_BOT_TOKEN"] + chat_id = CONFIG["TELEGRAM_CHAT_ID"] + if not token or not chat_id: return status = result.get("status", "unknown") @@ -280,26 +282,30 @@ def send_slack(result): for i, nums in enumerate(games, 1) ) text = ( - f":ticket: *로또 구매 완료!*\n" + f"🎟 *로또 구매 완료\!*\n" f"회차: {round_no} 추첨일: {draw_date}\n" f"{games_text}" ) elif status == "failed": - text = f":warning: *로또 구매 실패*\n{result.get('error', '')}" + text = f"⚠️ *로또 구매 실패*\n{result.get('error', '')}" else: - text = f":x: *로또 구매 오류*\n{result.get('error', status)}" + text = f"❌ *로또 구매 오류*\n{result.get('error', status)}" try: - payload = json.dumps({"text": text}).encode("utf-8") + payload = json.dumps({ + "chat_id": int(chat_id), + "text": text, + "parse_mode": "MarkdownV2", + }).encode("utf-8") req = urllib.request.Request( - url, + f"https://api.telegram.org/bot{token}/sendMessage", data=payload, headers={"Content-Type": "application/json"}, ) urllib.request.urlopen(req, timeout=10) - logger.info("Slack 알림 전송 완료") + logger.info("Telegram 알림 전송 완료") except Exception as e: - logger.warning(f"Slack 알림 전송 실패: {e}") + logger.warning(f"Telegram 알림 전송 실패: {e}") def save_log(result): @@ -350,6 +356,11 @@ def main(): if balance >= 0 and balance < cost: logger.error(f"예치금 부족! 현재: {balance:,}원, 필요: {cost:,}원") logger.error("동행복권 사이트에서 예치금을 충전해주세요.") + send_telegram({ + "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "status": "failed", + "error": f"예치금 부족! 현재: {balance:,}원, 필요: {cost:,}원", + }) sys.exit(1) # 3. 로또 구매 @@ -358,7 +369,7 @@ def main(): # 4. 결과 저장 및 Slack 알림 if result: save_log(result) - send_slack(result) + send_telegram(result) else: failed = { "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -366,7 +377,7 @@ def main(): "error": "구매 결과를 확인할 수 없습니다.", } save_log(failed) - send_slack(failed) + send_telegram(failed) logger.info("스크립트 실행 완료!") @@ -378,7 +389,7 @@ def main(): "error": str(e), } save_log(error_result) - send_slack(error_result) + send_telegram(error_result) finally: if driver: diff --git a/telegram_bot.py b/telegram_bot.py new file mode 100644 index 0000000..d2c76b0 --- /dev/null +++ b/telegram_bot.py @@ -0,0 +1,236 @@ +""" +Telegram 봇 - FE AI 표준화 프로젝트 팀 업무 지원 봇 + +기능: + - 기본 에코 + - 스탠드업 리마인더 자동 발송 (매일 09:30 KST) + - 로드맵 태스크 상태 조회 + +필요 패키지: + pip install python-telegram-bot[job-queue] + +환경변수: + TELEGRAM_BOT_TOKEN - BotFather에서 발급받은 토큰 (필수) + STANDUP_CHAT_IDS - 스탠드업 리마인더를 받을 채팅 ID 목록 (쉼표 구분, 선택) + 예) STANDUP_CHAT_IDS=123456789,987654321 +""" + +import json +import logging +import os +from datetime import time +from pathlib import Path +from typing import Final + +from telegram import Update +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, +) + +# ── 로깅 설정 ───────────────────────────────────────────── +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) + +# ── 설정 ───────────────────────────────────────────────── +BOT_TOKEN: Final[str] = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TASKS_FILE: Final[Path] = Path(__file__).parent / "shrimp_data" / "tasks.json" + +# 스탠드업 리마인더 발송 시간 (KST = UTC+9) +STANDUP_HOUR: Final[int] = 9 +STANDUP_MINUTE: Final[int] = 30 + +# 환경변수로 사전 등록할 채팅 ID 목록 +STANDUP_CHAT_IDS: Final[list[int]] = [ + int(cid) + for cid in os.environ.get("STANDUP_CHAT_IDS", "").split(",") + if cid.strip() +] + +STATUS_LABELS: Final[dict[str, str]] = { + "pending": "⏳ 대기", + "in-progress": "🔄 진행중", + "completed": "✅ 완료", + "deferred": "⏸ 보류", + "cancelled": "❌ 취소", +} + + +# ── 유틸리티 ────────────────────────────────────────────── + +def load_tasks() -> list[dict]: + """shrimp_data/tasks.json에서 태스크 목록을 로드한다.""" + if not TASKS_FILE.exists(): + return [] + with TASKS_FILE.open(encoding="utf-8") as f: + data = json.load(f) + return data.get("tasks", []) + + +def format_task_line(task: dict) -> str: + """태스크 한 줄 요약을 반환한다.""" + status = STATUS_LABELS.get(task.get("status", ""), task.get("status", "")) + name = task.get("name", "(이름 없음)") + return f"{status} {name}" + + +# ── 커맨드 핸들러 ───────────────────────────────────────── + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/start — 봇 소개 및 명령어 안내""" + text = ( + "안녕하세요! FE AI 표준화 프로젝트 봇입니다. 👋\n\n" + "사용 가능한 명령어\n" + "/tasks — 전체 태스크 목록\n" + "/tasks\\_pending — 대기 중인 태스크\n" + "/tasks\\_done — 완료된 태스크\n" + "/standup — 스탠드업 리마인더 구독\n" + "/help — 도움말" + ) + await update.message.reply_text(text, parse_mode="Markdown") + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/help — 도움말 (start와 동일)""" + await start(update, context) + + +async def tasks_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/tasks — 전체 태스크를 상태별로 보여준다.""" + all_tasks = load_tasks() + if not all_tasks: + await update.message.reply_text("태스크 데이터를 불러올 수 없습니다.") + return + + total = len(all_tasks) + done = sum(1 for t in all_tasks if t.get("status") == "completed") + lines = [f"📋 *전체 태스크 ({done}/{total} 완료)*\n"] + for task in all_tasks: + lines.append(format_task_line(task)) + + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def tasks_pending_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/tasks_pending — 대기 중인 태스크만 조회한다.""" + pending = [t for t in load_tasks() if t.get("status") == "pending"] + + if not pending: + await update.message.reply_text("✅ 대기 중인 태스크가 없습니다!") + return + + lines = [f"⏳ *대기 중인 태스크 ({len(pending)}개)*\n"] + for task in pending: + lines.append(format_task_line(task)) + + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def tasks_done_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/tasks_done — 완료된 태스크만 조회한다.""" + done = [t for t in load_tasks() if t.get("status") == "completed"] + + if not done: + await update.message.reply_text("아직 완료된 태스크가 없습니다.") + return + + lines = [f"✅ *완료된 태스크 ({len(done)}개)*\n"] + for task in done: + lines.append(format_task_line(task)) + + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def standup_subscribe(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/standup — 이 채팅을 스탠드업 리마인더 수신 대상으로 등록한다.""" + chat_id = update.effective_chat.id + registered: set[int] = context.bot_data.setdefault("standup_chats", set()) + + if chat_id in registered: + await update.message.reply_text("이미 등록된 채팅입니다.") + return + + registered.add(chat_id) + await update.message.reply_text( + f"✅ 스탠드업 리마인더에 등록되었습니다.\n" + f"매일 *{STANDUP_HOUR:02d}:{STANDUP_MINUTE:02d} KST*에 알림을 보내드립니다.", + parse_mode="Markdown", + ) + + +# ── 에코 핸들러 ─────────────────────────────────────────── + +async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """명령어가 아닌 텍스트 메시지를 그대로 반환한다.""" + await update.message.reply_text(update.message.text) + + +# ── 스케줄 작업 ─────────────────────────────────────────── + +async def send_standup_reminder(context: ContextTypes.DEFAULT_TYPE) -> None: + """등록된 모든 채팅에 스탠드업 리마인더를 발송한다.""" + message = ( + "🌅 *스탠드업 리마인더*\n\n" + "오늘의 스탠드업을 시작할 시간입니다!\n\n" + "1️⃣ *어제 한 일*:\n" + "2️⃣ *오늘 할 일*:\n" + "3️⃣ *블로커*:" + ) + + # 환경변수 등록 + 런타임 /standup 구독 채팅 합산 + target_chats: set[int] = set(STANDUP_CHAT_IDS) | context.bot_data.get( + "standup_chats", set() + ) + + for chat_id in target_chats: + try: + await context.bot.send_message( + chat_id=chat_id, + text=message, + parse_mode="Markdown", + ) + logger.info("스탠드업 리마인더 발송 완료 (chat_id=%s)", chat_id) + except Exception as e: + logger.error("스탠드업 리마인더 발송 실패 (chat_id=%s): %s", chat_id, e) + + +# ── 앱 빌드 & 실행 ──────────────────────────────────────── + +def main() -> None: + if not BOT_TOKEN: + raise ValueError( + "TELEGRAM_BOT_TOKEN 환경변수를 설정해주세요.\n" + "예) export TELEGRAM_BOT_TOKEN=your_token_here" + ) + + app = Application.builder().token(BOT_TOKEN).build() + + # 커맨드 핸들러 등록 + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CommandHandler("tasks", tasks_command)) + app.add_handler(CommandHandler("tasks_pending", tasks_pending_command)) + app.add_handler(CommandHandler("tasks_done", tasks_done_command)) + app.add_handler(CommandHandler("standup", standup_subscribe)) + + # 에코 핸들러 (명령어가 아닌 텍스트) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) + + # 매일 09:30 KST (UTC 00:30)에 스탠드업 리마인더 발송 + app.job_queue.run_daily( + send_standup_reminder, + time=time(hour=0, minute=30), # UTC 기준 (KST -9h) + ) + + logger.info("봇 시작 — polling 모드") + app.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main()