feat: Telegram 알림 기능 추가 및 Slack 의존성 제거

- notification-hook.sh에서 Slack 웹훅 관련 코드 제거하고 Telegram 알림으로 변경
- lotto_auto_buy.py에서 Slack 알림 기능을 Telegram으로 대체
- 환경 변수 설정에 Telegram 관련 변수 추가
- 설정 파일에서 Slack 웹훅 URL 제거
This commit is contained in:
hyeonggil
2026-03-27 22:10:26 +09:00
parent 1b9e49c32c
commit cc8e37d6fe
8 changed files with 384 additions and 80 deletions

View File

@@ -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"

View File

@@ -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

26
.claude/hooks/pre-tool-hook.sh Executable file
View File

@@ -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"

20
.claude/hooks/stop-hook copy.sh Executable file
View File

@@ -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"

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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:

236
telegram_bot.py Normal file
View File

@@ -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()