feat: Reddit 수집, 쇼츠 텔레그램 미리보기, 코너 9개 체계 정비
- Reddit 트렌딩 수집기 추가 (/reddit collect, /pick 명령어) - 쇼츠 영상 텔레그램 미리보기 후 승인 기반 YouTube 업로드 - 코너 9개로 통합 (앱추천→제품리뷰, 재테크절약→재테크, TV로보는세상/건강정보 추가) - RSS 피드 73개로 확대 (9개 코너 전체 커버) - 블로그 중복 검토 알림 수정, 글 잘림 방지 (max_tokens 8192) - 제품리뷰 다중 이미지 지원, 저품질 이미지 필터링 강화 - HookOptimizer LLM 연동, 인스타/X/틱톡 스케줄러 비활성화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+692
-93
@@ -25,6 +25,7 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
|
||||
|
||||
import anthropic
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
load_dotenv()
|
||||
@@ -68,7 +69,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어
|
||||
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
||||
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
||||
|
||||
8개 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
|
||||
9개 카테고리: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
|
||||
|
||||
사용 가능한 텔레그램 명령:
|
||||
/status — 봇 상태
|
||||
@@ -94,6 +95,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
||||
_awaiting_image: dict[int, str] = {}
|
||||
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
|
||||
_awaiting_article_image: dict[int, str] = {}
|
||||
# /shorts make 영상+디렉션 대기 상태: {chat_id: {videos: [path], direction: str, corner: str}}
|
||||
_awaiting_shorts_video: dict[int, dict] = {}
|
||||
|
||||
_publish_enabled = True
|
||||
|
||||
@@ -473,6 +476,12 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
|
||||
if not raw_output:
|
||||
raise RuntimeError('Writer 응답이 비어 있습니다.')
|
||||
|
||||
# 글이 잘렸는지 감지 (필수 섹션 누락 또는 마지막 문장 미완성)
|
||||
if '---BODY---' in prompt and '---BODY---' not in raw_output:
|
||||
logger.warning('Writer 응답에 ---BODY--- 섹션 없음 — 잘렸을 수 있음')
|
||||
if raw_output and not raw_output.rstrip().endswith(('.', '!', '?', '다.', '요.', '세요.', '니다.', '까요?', '---')):
|
||||
logger.warning(f'Writer 응답이 불완전하게 끝남: ...{raw_output[-50:]}')
|
||||
|
||||
article = parse_output(raw_output)
|
||||
if not article:
|
||||
raise RuntimeError('Writer 출력 파싱 실패')
|
||||
@@ -500,8 +509,47 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
|
||||
logger.info(f"원고 저장 완료: {output_path.name}")
|
||||
|
||||
|
||||
def _telegram_send_video(video_path: str, caption: str = '') -> bool:
|
||||
"""동기 텔레그램 영상 전송 (백그라운드 스레드 호환)"""
|
||||
import requests as _req
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
return False
|
||||
try:
|
||||
with open(video_path, 'rb') as f:
|
||||
_req.post(
|
||||
f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendVideo',
|
||||
data={'chat_id': TELEGRAM_CHAT_ID, 'caption': caption[:1024], 'parse_mode': 'HTML'},
|
||||
files={'video': f},
|
||||
timeout=120,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 영상 전송 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _telegram_notify_with_buttons(text: str, buttons: list[list[dict]]):
|
||||
"""인라인 버튼 포함 텔레그램 알림 (동기)"""
|
||||
import requests as _req
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
return
|
||||
try:
|
||||
_req.post(
|
||||
f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage',
|
||||
json={
|
||||
'chat_id': TELEGRAM_CHAT_ID,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'reply_markup': {'inline_keyboard': buttons},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 버튼 알림 실패: {e}")
|
||||
|
||||
|
||||
def _publish_next():
|
||||
"""originals/ → pending_review/ 이동 (안전장치 체크)"""
|
||||
"""originals/ → pending_review/ 이동 + 텔레그램 승인 요청 알림"""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import publisher_bot
|
||||
originals_dir = DATA_DIR / 'originals'
|
||||
@@ -526,6 +574,22 @@ def _publish_next():
|
||||
dest.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
f.unlink()
|
||||
logger.info(f"검토 대기로 이동: {pending_name} ({reason})")
|
||||
|
||||
# 텔레그램 승인 요청 알림
|
||||
title = article.get('title', '(제목 없음)')[:60]
|
||||
corner = article.get('corner', '')
|
||||
notify_text = (
|
||||
f"📝 <b>새 글 검토 요청</b>\n"
|
||||
f"제목: {title}\n"
|
||||
f"코너: {corner}\n"
|
||||
f"사유: {reason or '수동 승인 필요'}"
|
||||
)
|
||||
buttons = [[
|
||||
{'text': '✅ 승인 발행', 'callback_data': f'approve:{pending_name}'},
|
||||
{'text': '🗑 거부', 'callback_data': f'reject:{pending_name}'},
|
||||
]]
|
||||
_telegram_notify_with_buttons(notify_text, buttons)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"publish_next 오류 ({f.name}): {e}", exc_info=True)
|
||||
|
||||
@@ -913,21 +977,32 @@ async def cmd_collect(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
return
|
||||
# 텔레그램 메시지 4096자 제한 고려, 페이지 나눠 전송
|
||||
total = len(files)
|
||||
# 글감 데이터를 _topic_selection_cache에 저장 → /pick 으로 선택 가능
|
||||
rss_topics = []
|
||||
for f in files:
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
data['_filepath'] = str(f)
|
||||
data['_filename'] = f.name
|
||||
rss_topics.append(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
chat_id = update.message.chat_id
|
||||
_topic_selection_cache[chat_id] = {'topics': rss_topics, 'source': 'rss'}
|
||||
|
||||
page_size = 30
|
||||
for page_start in range(0, total, page_size):
|
||||
page_files = files[page_start:page_start + page_size]
|
||||
page_topics = rss_topics[page_start:page_start + page_size]
|
||||
if page_start == 0:
|
||||
lines = [f"✅ 수집 완료! 오늘 글감 {total}개:"]
|
||||
else:
|
||||
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_files)}):"]
|
||||
for i, f in enumerate(page_files, page_start + 1):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:40]}")
|
||||
except Exception:
|
||||
pass
|
||||
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_topics)}):"]
|
||||
for i, t in enumerate(page_topics, page_start + 1):
|
||||
lines.append(f" {i}. [{t.get('corner','')}] {t.get('topic','')[:40]}")
|
||||
if page_start + page_size >= total:
|
||||
lines.append(f"\n✍️ /write [번호] 로 글 작성 (1~{total})")
|
||||
lines.append(f"\n✍️ /write [번호] 블로그 글만")
|
||||
lines.append(f"📌 /pick [번호] 블로그/쇼츠/둘다 선택")
|
||||
await update.message.reply_text('\n'.join(lines))
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 수집 오류: {e}")
|
||||
@@ -1003,7 +1078,7 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
topic_file = files[idx]
|
||||
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||
# 두 번째 인자: 유효한 카테고리명이면 corner 오버라이드, 아니면 direction
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||
override_corner = ''
|
||||
direction = ''
|
||||
if len(args) > 1:
|
||||
@@ -1026,39 +1101,8 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
|
||||
# 자동으로 pending_review로 이동
|
||||
# 자동으로 pending_review로 이동 + 승인 알림 전송 (_publish_next 가 처리)
|
||||
await loop.run_in_executor(None, _publish_next)
|
||||
# pending_review에서 방금 작성된 글 찾기
|
||||
pending_dir = DATA_DIR / 'pending_review'
|
||||
pending_name = topic_file.stem + '_pending.json'
|
||||
pending_file = pending_dir / pending_name
|
||||
if pending_file.exists():
|
||||
article = json.loads(pending_file.read_text(encoding='utf-8'))
|
||||
title = article.get('title', '')[:50]
|
||||
corner = article.get('corner', '')
|
||||
body_preview = article.get('body', '')[:200].replace('<', '<').replace('>', '>')
|
||||
# 인라인 버튼으로 승인/거부 (+idea 글이면 이미지 첨부)
|
||||
btn_rows = [
|
||||
[
|
||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
|
||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_name}"),
|
||||
]
|
||||
]
|
||||
if article.get('source') in ('idea', 'manual'):
|
||||
img_count = len(article.get('user_images', []))
|
||||
img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부"
|
||||
btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_name}")])
|
||||
keyboard = InlineKeyboardMarkup(btn_rows)
|
||||
await update.message.reply_text(
|
||||
f"📝 [수동 검토 필요]\n\n"
|
||||
f"<b>{title}</b>\n"
|
||||
f"코너: {corner}\n\n"
|
||||
f"미리보기:\n{body_preview}...\n",
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text("✅ 완료! /pending 으로 검토하세요.")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
|
||||
|
||||
@@ -1074,7 +1118,7 @@ async def cmd_idea(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
)
|
||||
return
|
||||
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||
|
||||
# 마지막 인자가 카테고리인지 확인
|
||||
corner = ''
|
||||
@@ -1244,7 +1288,7 @@ async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text("❌ 유효한 URL을 입력하세요. (http로 시작)")
|
||||
return
|
||||
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||
corner = ''
|
||||
if len(args) > 1 and args[1] in VALID_CORNERS:
|
||||
corner = args[1]
|
||||
@@ -1449,7 +1493,7 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
async def cmd_setcorner(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/setcorner <번호> <카테고리> — pending 글 카테고리 변경"""
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||
args = context.args
|
||||
if len(args) < 2:
|
||||
await update.message.reply_text(
|
||||
@@ -1597,6 +1641,139 @@ async def callback_approve_reject(update: Update, context: ContextTypes.DEFAULT_
|
||||
)
|
||||
|
||||
|
||||
async def callback_shorts_make(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""쇼츠 제작 인라인 버튼 콜백."""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
data = query.data # "shorts_make:go" or "shorts_make:cancel"
|
||||
_, action = data.split(':', 1)
|
||||
chat_id = query.message.chat_id
|
||||
|
||||
session = _awaiting_shorts_video.pop(chat_id, None)
|
||||
|
||||
if action == 'cancel':
|
||||
# 저장된 영상 삭제
|
||||
if session:
|
||||
for vp in session.get('videos', []):
|
||||
Path(vp).unlink(missing_ok=True)
|
||||
await query.edit_message_text("❌ 쇼츠 제작 취소됨. 영상 삭제 완료.")
|
||||
return
|
||||
|
||||
if not session or not session.get('videos'):
|
||||
await query.edit_message_text("⚠️ 저장된 영상이 없습니다. 영상을 다시 보내주세요.")
|
||||
return
|
||||
|
||||
direction = session.get('direction', '')
|
||||
if not direction:
|
||||
await query.edit_message_text("⚠️ 디렉션(설명)이 없습니다. 영상에 캡션을 포함해서 다시 보내주세요.")
|
||||
return
|
||||
|
||||
count = len(session['videos'])
|
||||
await query.edit_message_text(
|
||||
f"🎬 쇼츠 제작 시작!\n"
|
||||
f"📹 영상 {count}개 | 📝 {direction[:50]}\n\n"
|
||||
f"완료되면 알려드릴게요. (2~5분 소요)"
|
||||
)
|
||||
|
||||
# 백그라운드에서 produce 실행
|
||||
import asyncio as _asyncio
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_produce_shorts_from_direction,
|
||||
session, chat_id, context.application,
|
||||
)
|
||||
|
||||
|
||||
async def callback_shorts_upload(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""쇼츠 유튜브 업로드 승인/취소 콜백."""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
data = query.data # "shorts_upload:/path/to/video.mp4" or "shorts_upload:cancel"
|
||||
_, action = data.split(':', 1)
|
||||
|
||||
if action == 'cancel':
|
||||
await query.edit_message_text("❌ 유튜브 업로드 취소됨.")
|
||||
return
|
||||
|
||||
video_path = action
|
||||
if not Path(video_path).exists():
|
||||
await query.edit_message_text(f"⚠️ 영상 파일을 찾을 수 없습니다:\n{video_path}")
|
||||
return
|
||||
|
||||
await query.edit_message_text("📤 유튜브 업로드 중...")
|
||||
|
||||
# 백그라운드에서 업로드
|
||||
import asyncio as _asyncio
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, _upload_shorts_to_youtube, video_path)
|
||||
|
||||
|
||||
def _upload_shorts_to_youtube(video_path: str):
|
||||
"""백그라운드: 쇼츠 유튜브 업로드."""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
try:
|
||||
import shorts_bot
|
||||
result = shorts_bot.upload_existing(video_path)
|
||||
if result.success:
|
||||
_telegram_notify(f"✅ 유튜브 업로드 완료!\n🔗 {result.youtube_url}")
|
||||
else:
|
||||
_telegram_notify(f"❌ 유튜브 업로드 실패: {result.error}")
|
||||
except Exception as e:
|
||||
logger.error(f"쇼츠 업로드 오류: {e}")
|
||||
_telegram_notify(f"❌ 쇼츠 업로드 오류: {e}")
|
||||
|
||||
|
||||
def _produce_shorts_from_direction(session: dict, chat_id: int, app):
|
||||
"""백그라운드: 디렉션 기반 쇼츠 제작."""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import shorts_bot
|
||||
|
||||
direction = session['direction']
|
||||
corner = session.get('corner', '') or 'AI인사이트'
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# 디렉션 → 간이 article 생성 (LLM이 direction 기반 스크립트 생성)
|
||||
article = {
|
||||
'slug': f'shorts-direction-{ts}',
|
||||
'title': direction[:80],
|
||||
'body': direction,
|
||||
'content': direction,
|
||||
'corner': corner,
|
||||
'source': 'telegram_direction',
|
||||
}
|
||||
|
||||
# config를 semi_auto로 임시 전환 (input/videos/ 에 저장된 영상 사용)
|
||||
cfg = shorts_bot._load_config()
|
||||
cfg['production_mode'] = 'semi_auto'
|
||||
|
||||
try:
|
||||
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
|
||||
|
||||
if result.success and result.video_path:
|
||||
# 영상 미리보기 전송
|
||||
caption = (
|
||||
f"🎬 쇼츠 제작 완료!\n"
|
||||
f"📝 {direction[:50]}\n"
|
||||
f"📹 단계: {', '.join(result.steps_completed)}"
|
||||
)
|
||||
_telegram_send_video(result.video_path, caption)
|
||||
# 업로드 승인 버튼
|
||||
_telegram_notify_with_buttons(
|
||||
'⬆️ 유튜브에 업로드할까요?',
|
||||
[[
|
||||
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
|
||||
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
|
||||
]],
|
||||
)
|
||||
else:
|
||||
msg = f"❌ 쇼츠 제작 실패\n오류: {result.error}\n단계: {', '.join(result.steps_completed)}"
|
||||
_telegram_notify(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"쇼츠 디렉션 제작 오류: {e}")
|
||||
_telegram_notify(f"❌ 쇼츠 제작 오류: {e}")
|
||||
|
||||
|
||||
async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text("주간 리포트 생성 중...")
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
@@ -1872,11 +2049,17 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
|
||||
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
|
||||
"""Telegram 파일(문서) 수신 — 이미지 또는 비디오"""
|
||||
doc = update.message.document
|
||||
mime = doc.mime_type or ''
|
||||
chat_id = update.message.chat_id
|
||||
if mime.startswith('video/'):
|
||||
# 쇼츠 대기 중이거나 캡션이 있으면 쇼츠 영상으로 처리
|
||||
if chat_id in _awaiting_shorts_video or (update.message.caption or '').strip():
|
||||
await _receive_shorts_video(update, context, file_getter=lambda: context.bot.get_file(doc.file_id))
|
||||
return
|
||||
if not mime.startswith('image/'):
|
||||
return # 이미지 파일만 처리
|
||||
return
|
||||
caption = update.message.caption or ''
|
||||
await _receive_image(
|
||||
update, context,
|
||||
@@ -1885,6 +2068,71 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
)
|
||||
|
||||
|
||||
async def handle_video(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Telegram 비디오 수신 — 쇼츠 제작용"""
|
||||
video = update.message.video
|
||||
if not video:
|
||||
return
|
||||
chat_id = update.message.chat_id
|
||||
# 쇼츠 대기 중이거나 캡션이 있으면 처리
|
||||
if chat_id in _awaiting_shorts_video or (update.message.caption or '').strip():
|
||||
await _receive_shorts_video(update, context, file_getter=lambda: context.bot.get_file(video.file_id))
|
||||
|
||||
|
||||
async def _receive_shorts_video(update: Update, context: ContextTypes.DEFAULT_TYPE, file_getter):
|
||||
"""영상 수신 → input/videos/ 저장 + 쇼츠 제작 대기."""
|
||||
chat_id = update.message.chat_id
|
||||
caption = (update.message.caption or '').strip()
|
||||
|
||||
# 대기 상태가 없으면 새로 생성
|
||||
if chat_id not in _awaiting_shorts_video:
|
||||
if not caption:
|
||||
await update.message.reply_text(
|
||||
"🎬 영상에 디렉션(캡션)을 함께 보내주세요.\n"
|
||||
"예: 영상 전송 시 캡션에 \"AI가 일자리를 대체하는 현실\" 입력"
|
||||
)
|
||||
return
|
||||
_awaiting_shorts_video[chat_id] = {'videos': [], 'direction': caption, 'corner': ''}
|
||||
|
||||
# 디렉션 업데이트 (캡션이 있으면)
|
||||
session = _awaiting_shorts_video[chat_id]
|
||||
if caption and not session['direction']:
|
||||
session['direction'] = caption
|
||||
|
||||
# 영상 다운로드
|
||||
try:
|
||||
tg_file = await file_getter()
|
||||
file_bytes = bytes(await tg_file.download_as_bytearray())
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 영상 다운로드 실패: {e}")
|
||||
return
|
||||
|
||||
# input/videos/ 에 저장
|
||||
videos_dir = BASE_DIR / 'input' / 'videos'
|
||||
videos_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
vid_num = len(session['videos']) + 1
|
||||
vid_filename = f'shorts_{ts}_{vid_num:02d}.mp4'
|
||||
vid_path = videos_dir / vid_filename
|
||||
vid_path.write_bytes(file_bytes)
|
||||
session['videos'].append(str(vid_path))
|
||||
logger.info(f"쇼츠 영상 저장 ({vid_num}): {vid_path}")
|
||||
|
||||
count = len(session['videos'])
|
||||
direction_preview = session['direction'][:40] + ('...' if len(session['direction']) > 40 else '')
|
||||
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton(f"🎬 쇼츠 제작 시작 ({count}개 영상)", callback_data="shorts_make:go")],
|
||||
[InlineKeyboardButton("❌ 취소", callback_data="shorts_make:cancel")],
|
||||
])
|
||||
await update.message.reply_text(
|
||||
f"✅ 영상 {count}개 저장 (최대 5개)\n"
|
||||
f"📝 디렉션: {direction_preview}\n\n"
|
||||
f"추가 영상을 보내거나 제작 버튼을 눌러주세요.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
# ─── 텍스트 명령 ─────────────────────────────────────
|
||||
|
||||
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
@@ -1958,18 +2206,68 @@ def job_shorts_produce():
|
||||
if not article:
|
||||
logger.info("쇼츠 생산: eligible 글 없음")
|
||||
return
|
||||
result = shorts_bot.produce(article, dry_run=False, cfg=cfg)
|
||||
if result.success:
|
||||
msg = f"🎬 쇼츠 발행 완료: {result.youtube_url}"
|
||||
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
|
||||
if result.success and result.video_path:
|
||||
caption = f"🎬 쇼츠 제작 완료!\n📝 {article.get('title', '')[:50]}"
|
||||
_telegram_send_video(result.video_path, caption)
|
||||
_telegram_notify_with_buttons(
|
||||
'⬆️ 유튜브에 업로드할까요?',
|
||||
[[
|
||||
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
|
||||
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
|
||||
]],
|
||||
)
|
||||
else:
|
||||
msg = f"⚠️ 쇼츠 생산 실패: {result.error}"
|
||||
logger.info(msg)
|
||||
_telegram_notify(msg)
|
||||
logger.info(msg)
|
||||
_telegram_notify(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"쇼츠 잡 오류: {e}")
|
||||
_telegram_notify(f"⚠️ 쇼츠 잡 오류: {e}")
|
||||
|
||||
|
||||
def _job_shorts_produce_smart():
|
||||
"""블로그 기반 쇼츠 — input/videos/ 영상 있으면 semi_auto 모드."""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
try:
|
||||
import shorts_bot
|
||||
cfg = shorts_bot._load_config()
|
||||
|
||||
# input/videos/ 에 영상 있으면 semi_auto
|
||||
input_vids = list((BASE_DIR / 'input' / 'videos').glob('*.mp4'))
|
||||
if input_vids:
|
||||
cfg['production_mode'] = 'semi_auto'
|
||||
logger.info(f"쇼츠: 사용자 영상 {len(input_vids)}개 감지 → semi_auto 모드")
|
||||
else:
|
||||
cfg['production_mode'] = 'auto'
|
||||
|
||||
article = shorts_bot.pick_article(cfg)
|
||||
if not article:
|
||||
_telegram_notify("⚠️ 쇼츠 생산: eligible 블로그 글 없음")
|
||||
return
|
||||
|
||||
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
|
||||
if result.success and result.video_path:
|
||||
caption = (
|
||||
f"🎬 쇼츠 제작 완료!\n"
|
||||
f"📝 {article.get('title', '')[:50]}\n"
|
||||
f"📹 단계: {', '.join(result.steps_completed)}"
|
||||
)
|
||||
_telegram_send_video(result.video_path, caption)
|
||||
_telegram_notify_with_buttons(
|
||||
'⬆️ 유튜브에 업로드할까요?',
|
||||
[[
|
||||
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
|
||||
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
|
||||
]],
|
||||
)
|
||||
else:
|
||||
_telegram_notify(f"❌ 쇼츠 제작 실패: {result.error}")
|
||||
except Exception as e:
|
||||
logger.error(f"쇼츠 스마트 잡 오류: {e}")
|
||||
_telegram_notify(f"⚠️ 쇼츠 잡 오류: {e}")
|
||||
|
||||
|
||||
# ─── Shorts Telegram 명령 ─────────────────────────────
|
||||
|
||||
async def cmd_shorts(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
@@ -2058,26 +2356,330 @@ async def cmd_shorts(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
)
|
||||
await update.message.reply_text(f'✅ 쇼츠 건너뜀 등록: {article_id}')
|
||||
|
||||
elif sub == 'run':
|
||||
await update.message.reply_text('🎬 쇼츠 즉시 생산 시작...')
|
||||
elif sub == 'topic' and len(args) >= 2:
|
||||
# /shorts topic [주제] — 주제만으로 쇼츠 제작 (Pexels 자동)
|
||||
topic_text = ' '.join(args[1:])
|
||||
await update.message.reply_text(
|
||||
f"🎬 쇼츠 제작 시작!\n"
|
||||
f"📝 주제: {topic_text[:50]}\n"
|
||||
f"📹 영상: Pexels 자동 검색\n\n"
|
||||
f"완료되면 알려드릴게요. (2~5분 소요)"
|
||||
)
|
||||
import asyncio as _asyncio
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, job_shorts_produce)
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_produce_shorts_from_direction,
|
||||
{'videos': [], 'direction': topic_text, 'corner': ''},
|
||||
update.message.chat_id,
|
||||
context.application,
|
||||
)
|
||||
|
||||
elif sub == 'make':
|
||||
# /shorts make [디렉션] — 영상 수신 대기 시작
|
||||
direction = ' '.join(args[1:]) if len(args) >= 2 else ''
|
||||
chat_id = update.message.chat_id
|
||||
_awaiting_shorts_video[chat_id] = {'videos': [], 'direction': direction, 'corner': ''}
|
||||
msg = "🎬 쇼츠 제작 모드!\n영상을 보내주세요. (최대 5개)\n"
|
||||
if direction:
|
||||
msg += f"📝 디렉션: {direction}\n"
|
||||
else:
|
||||
msg += "💡 영상 전송 시 캡션에 디렉션을 입력하세요.\n"
|
||||
msg += "\n취소: /shorts cancel"
|
||||
await update.message.reply_text(msg)
|
||||
|
||||
elif sub == 'cancel':
|
||||
chat_id = update.message.chat_id
|
||||
session = _awaiting_shorts_video.pop(chat_id, None)
|
||||
if session:
|
||||
for vp in session.get('videos', []):
|
||||
Path(vp).unlink(missing_ok=True)
|
||||
await update.message.reply_text("❌ 쇼츠 제작 취소됨. 영상 삭제 완료.")
|
||||
else:
|
||||
await update.message.reply_text("대기 중인 쇼츠 제작이 없습니다.")
|
||||
|
||||
elif sub == 'run':
|
||||
# input/videos/ 에 영상 있으면 알림
|
||||
input_vids = list((BASE_DIR / 'input' / 'videos').glob('*.mp4')) if (BASE_DIR / 'input' / 'videos').exists() else []
|
||||
if input_vids:
|
||||
await update.message.reply_text(
|
||||
f'🎬 블로그 기반 쇼츠 제작 시작...\n'
|
||||
f'📹 사용자 영상 {len(input_vids)}개 감지 → 내 영상 사용'
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text('🎬 블로그 기반 쇼츠 제작 시작...\n📹 Pexels 자동 검색')
|
||||
import asyncio as _asyncio
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, _job_shorts_produce_smart)
|
||||
|
||||
else:
|
||||
help_text = (
|
||||
"🎬 /shorts 명령어\n"
|
||||
"🎬 /shorts 명령어\n\n"
|
||||
"📌 제작\n"
|
||||
"/shorts topic [주제] — 주제만으로 쇼츠 (Pexels 자동)\n"
|
||||
"/shorts make [디렉션] — 내 영상+디렉션 쇼츠\n"
|
||||
"/shorts run — 블로그 글 기반 쇼츠\n"
|
||||
" └ 영상 먼저 보내면 내 영상 사용\n"
|
||||
"/shorts cancel — 제작 대기 취소\n\n"
|
||||
"📌 관리\n"
|
||||
"/shorts status — 현황\n"
|
||||
"/shorts mode auto|semi — 모드 전환\n"
|
||||
"/shorts input — input/ 폴더 현황\n"
|
||||
"/shorts character bao|zero — 캐릭터 강제 지정\n"
|
||||
"/shorts upload [경로] — 렌더링된 영상 업로드\n"
|
||||
"/shorts skip [article_id] — 특정 글 쇼츠 제외\n"
|
||||
"/shorts run — 즉시 실행"
|
||||
"/shorts upload [경로] — 영상 업로드\n"
|
||||
"/shorts skip [article_id] — 쇼츠 제외"
|
||||
)
|
||||
await update.message.reply_text(help_text)
|
||||
|
||||
|
||||
# ─── Reddit 수집 + 공통 글감 선택 UI ──────────────────────
|
||||
|
||||
# 글감 선택 대기 상태: {chat_id: {'topics': [...], 'source': 'reddit'|'rss'}}
|
||||
_topic_selection_cache = {}
|
||||
|
||||
|
||||
async def cmd_reddit(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""
|
||||
/reddit collect — Reddit 트렌딩 주제 수집
|
||||
/reddit list — 수집된 주제 보기
|
||||
"""
|
||||
args = context.args or []
|
||||
sub = args[0].lower() if args else 'collect'
|
||||
|
||||
if sub == 'collect':
|
||||
await update.message.reply_text('🔄 Reddit 트렌딩 주제 수집 중... (30초 소요)')
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import reddit_collector
|
||||
topics = await loop.run_in_executor(None, reddit_collector.collect)
|
||||
|
||||
if not topics:
|
||||
await update.message.reply_text('⚠️ 수집된 주제가 없습니다. 나중에 다시 시도하세요.')
|
||||
return
|
||||
|
||||
chat_id = update.message.chat_id
|
||||
_topic_selection_cache[chat_id] = {'topics': topics, 'source': 'reddit'}
|
||||
|
||||
display = reddit_collector.get_display_list(topics)
|
||||
# 4096자 제한
|
||||
if len(display) > 4000:
|
||||
display = display[:4000] + '\n...(더보기: /reddit list)'
|
||||
|
||||
await update.message.reply_text(display, parse_mode='HTML')
|
||||
|
||||
# 선택 안내 버튼
|
||||
await update.message.reply_text(
|
||||
'📌 번호를 입력하세요:\n'
|
||||
'<code>/pick 3</code> — 3번 주제 선택\n'
|
||||
'<code>/pick 1,3,5</code> — 여러 개 선택',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f'❌ Reddit 수집 오류: {e}')
|
||||
|
||||
elif sub == 'list':
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import reddit_collector
|
||||
topics = reddit_collector.load_topics()
|
||||
if not topics:
|
||||
await update.message.reply_text('수집된 Reddit 주제가 없습니다. /reddit collect 먼저 실행하세요.')
|
||||
return
|
||||
|
||||
chat_id = update.message.chat_id
|
||||
_topic_selection_cache[chat_id] = {'topics': topics, 'source': 'reddit'}
|
||||
|
||||
display = reddit_collector.get_display_list(topics)
|
||||
if len(display) > 4000:
|
||||
display = display[:4000] + '\n...'
|
||||
await update.message.reply_text(display, parse_mode='HTML')
|
||||
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
'🔥 /reddit 명령어\n\n'
|
||||
'/reddit collect — 트렌딩 주제 수집\n'
|
||||
'/reddit list — 수집된 주제 보기'
|
||||
)
|
||||
|
||||
|
||||
async def cmd_pick(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""
|
||||
/pick [번호] — 글감 선택 후 블로그/쇼츠/둘다 버튼 표시
|
||||
/pick 3 또는 /pick 1,3,5
|
||||
"""
|
||||
chat_id = update.message.chat_id
|
||||
cache = _topic_selection_cache.get(chat_id)
|
||||
|
||||
if not cache or not cache.get('topics'):
|
||||
await update.message.reply_text(
|
||||
'선택할 글감이 없습니다.\n'
|
||||
'/collect 또는 /reddit collect 로 먼저 수집하세요.'
|
||||
)
|
||||
return
|
||||
|
||||
args = context.args
|
||||
if not args:
|
||||
await update.message.reply_text('사용법: /pick 3 또는 /pick 1,3,5')
|
||||
return
|
||||
|
||||
# 번호 파싱 (쉼표 구분 지원)
|
||||
raw = ' '.join(args).replace(' ', ',')
|
||||
try:
|
||||
indices = [int(x.strip()) - 1 for x in raw.split(',') if x.strip().isdigit()]
|
||||
except ValueError:
|
||||
await update.message.reply_text('❌ 숫자를 입력하세요. 예: /pick 3')
|
||||
return
|
||||
|
||||
topics = cache['topics']
|
||||
source = cache.get('source', 'reddit')
|
||||
selected = []
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(topics):
|
||||
selected.append(topics[idx])
|
||||
|
||||
if not selected:
|
||||
await update.message.reply_text(f'❌ 유효한 번호를 입력하세요. (1~{len(topics)})')
|
||||
return
|
||||
|
||||
# 선택된 주제 표시 + 블로그/쇼츠/둘다 버튼
|
||||
for i, topic in enumerate(selected):
|
||||
title = topic.get('topic', '')[:60]
|
||||
corner = topic.get('corner', '')
|
||||
score_info = ''
|
||||
if source == 'reddit':
|
||||
score = topic.get('score', 0)
|
||||
sub = topic.get('source_name', '')
|
||||
score_info = f'\n⬆️ {score:,} | {sub}'
|
||||
|
||||
# 주제 데이터를 임시 저장 (콜백에서 사용)
|
||||
topic_hash = hashlib.md5(topic.get('topic', '').encode()).hexdigest()[:8]
|
||||
_topic_selection_cache[f'pick_{chat_id}_{topic_hash}'] = {
|
||||
'topic': topic,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton('📝 블로그 글', callback_data=f'topicact:blog:{topic_hash}'),
|
||||
InlineKeyboardButton('🎬 쇼츠', callback_data=f'topicact:shorts:{topic_hash}'),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton('📝+🎬 둘 다', callback_data=f'topicact:both:{topic_hash}'),
|
||||
InlineKeyboardButton('❌ 건너뛰기', callback_data=f'topicact:skip:{topic_hash}'),
|
||||
],
|
||||
]
|
||||
keyboard = InlineKeyboardMarkup(buttons)
|
||||
|
||||
await update.message.reply_text(
|
||||
f'📌 <b>{title}</b>\n🏷 {corner}{score_info}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def callback_topic_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""글감 선택 후 블로그/쇼츠/둘다 액션 콜백."""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
# topicact:blog:abc12345
|
||||
parts = query.data.split(':')
|
||||
if len(parts) < 3:
|
||||
await query.edit_message_text('⚠️ 잘못된 요청입니다.')
|
||||
return
|
||||
|
||||
action = parts[1] # blog, shorts, both, skip
|
||||
topic_hash = parts[2]
|
||||
chat_id = query.message.chat_id
|
||||
|
||||
cache_key = f'pick_{chat_id}_{topic_hash}'
|
||||
cache = _topic_selection_cache.pop(cache_key, None)
|
||||
|
||||
if action == 'skip':
|
||||
await query.edit_message_text('⏭ 건너뜀')
|
||||
return
|
||||
|
||||
if not cache:
|
||||
await query.edit_message_text('⚠️ 주제 데이터가 만료되었습니다. 다시 선택하세요.')
|
||||
return
|
||||
|
||||
topic = cache['topic']
|
||||
title = topic.get('topic', '')[:50]
|
||||
|
||||
if action in ('blog', 'both'):
|
||||
# 블로그 글 작성
|
||||
await query.edit_message_text(f'✍️ 블로그 글 작성 중: {title}...')
|
||||
|
||||
topic_data = {
|
||||
'topic': topic.get('topic', ''),
|
||||
'description': topic.get('description', ''),
|
||||
'corner': topic.get('corner', 'AI인사이트'),
|
||||
'source': topic.get('source', 'reddit'),
|
||||
'source_url': topic.get('source_url', ''),
|
||||
'source_name': topic.get('source_name', ''),
|
||||
'sources': [{'url': topic.get('source_url', ''), 'title': topic.get('topic', '')}],
|
||||
}
|
||||
|
||||
topic_id = hashlib.md5(topic['topic'].encode()).hexdigest()[:8]
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
filename = f'{today}_{topic_id}.json'
|
||||
draft_path = DATA_DIR / 'originals' / filename
|
||||
(DATA_DIR / 'originals').mkdir(exist_ok=True)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, '')
|
||||
await loop.run_in_executor(None, _publish_next)
|
||||
|
||||
pending_dir = DATA_DIR / 'pending_review'
|
||||
pending_name = f'{today}_{topic_id}_pending.json'
|
||||
pending_file = pending_dir / pending_name
|
||||
if pending_file.exists():
|
||||
article = json.loads(pending_file.read_text(encoding='utf-8'))
|
||||
btn_rows = [
|
||||
[
|
||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
|
||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_name}"),
|
||||
],
|
||||
[InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{pending_name}")]
|
||||
]
|
||||
keyboard = InlineKeyboardMarkup(btn_rows)
|
||||
_telegram_notify_with_buttons(
|
||||
f"📝 블로그 글 완성!\n<b>{article.get('title', '')[:50]}</b>\n코너: {article.get('corner', '')}",
|
||||
[[
|
||||
{'text': '✅ 승인 발행', 'callback_data': f'approve:{pending_name}'},
|
||||
{'text': '🗑 거부', 'callback_data': f'reject:{pending_name}'},
|
||||
]],
|
||||
)
|
||||
except Exception as e:
|
||||
_telegram_notify(f'❌ 블로그 글 작성 실패: {e}')
|
||||
|
||||
if action in ('shorts', 'both'):
|
||||
# 쇼츠 제작
|
||||
direction = topic.get('topic', '')
|
||||
description = topic.get('description', '')
|
||||
if description and description != direction:
|
||||
direction = f'{direction}. {description[:200]}'
|
||||
|
||||
_telegram_notify(
|
||||
f'🎬 쇼츠 제작 시작: {title}\n'
|
||||
f'완료되면 미리보기 영상을 보내드릴게요.'
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_produce_shorts_from_direction,
|
||||
{'videos': [], 'direction': direction, 'corner': topic.get('corner', '')},
|
||||
chat_id,
|
||||
context.application,
|
||||
)
|
||||
|
||||
if action == 'blog':
|
||||
# 이미 위에서 처리 완료
|
||||
pass
|
||||
elif action == 'shorts':
|
||||
await query.edit_message_text(f'🎬 쇼츠 제작 중: {title}\n완료 시 영상 미리보기가 전송됩니다.')
|
||||
|
||||
|
||||
# ─── 스케줄러 설정 + 메인 ─────────────────────────────
|
||||
|
||||
def setup_scheduler() -> AsyncIOScheduler:
|
||||
@@ -2104,14 +2706,15 @@ def setup_scheduler() -> AsyncIOScheduler:
|
||||
scheduler.add_job(job_convert, 'cron', hour=8, minute=30, id='convert') # 08:30 변환
|
||||
scheduler.add_job(lambda: job_publish(1), 'cron',
|
||||
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
||||
scheduler.add_job(job_distribute_instagram, 'cron',
|
||||
hour=10, minute=0, id='instagram_dist') # 10:00 인스타 카드
|
||||
scheduler.add_job(job_distribute_instagram_reels, 'cron',
|
||||
hour=10, minute=30, id='instagram_reels_dist') # 10:30 인스타 릴스
|
||||
scheduler.add_job(job_distribute_x, 'cron',
|
||||
hour=11, minute=0, id='x_dist') # 11:00 X
|
||||
scheduler.add_job(job_distribute_tiktok, 'cron',
|
||||
hour=18, minute=0, id='tiktok_dist') # 18:00 틱톡
|
||||
# 인스타/X/틱톡: engine.json에서 비활성 — 잡 제거 (활성화 시 주석 해제)
|
||||
# scheduler.add_job(job_distribute_instagram, 'cron',
|
||||
# hour=10, minute=0, id='instagram_dist')
|
||||
# scheduler.add_job(job_distribute_instagram_reels, 'cron',
|
||||
# hour=10, minute=30, id='instagram_reels_dist')
|
||||
# scheduler.add_job(job_distribute_x, 'cron',
|
||||
# hour=11, minute=0, id='x_dist')
|
||||
# scheduler.add_job(job_distribute_tiktok, 'cron',
|
||||
# hour=18, minute=0, id='tiktok_dist')
|
||||
scheduler.add_job(job_distribute_youtube, 'cron',
|
||||
hour=20, minute=0, id='youtube_dist') # 20:00 유튜브
|
||||
scheduler.add_job(job_analytics_daily, 'cron',
|
||||
@@ -2125,27 +2728,15 @@ def setup_scheduler() -> AsyncIOScheduler:
|
||||
day_of_week='mon', hour=10, minute=0, id='image_batch')
|
||||
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
|
||||
|
||||
# 소설 파이프라인: 매주 월/목 09:00
|
||||
scheduler.add_job(job_novel_pipeline, 'cron',
|
||||
day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
|
||||
logger.info("소설 파이프라인: 매주 월/목 09:00 등록")
|
||||
# 소설 파이프라인: engine.json의 novel.enabled 설정 확인
|
||||
# 현재 비활성 — 필요 시 engine.json에서 novel.enabled: true로 변경
|
||||
# scheduler.add_job(job_novel_pipeline, 'cron',
|
||||
# day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
|
||||
# logger.info("소설 파이프라인: 매주 월/목 09:00 등록")
|
||||
|
||||
# Shorts Bot: 10:35 (첫 번째), 16:00 (두 번째)
|
||||
try:
|
||||
import json as _json
|
||||
shorts_cfg_path = CONFIG_DIR / 'shorts_config.json'
|
||||
if shorts_cfg_path.exists():
|
||||
_shorts_cfg = _json.loads(shorts_cfg_path.read_text(encoding='utf-8'))
|
||||
if _shorts_cfg.get('enabled', True):
|
||||
scheduler.add_job(job_shorts_produce, 'cron',
|
||||
hour=10, minute=35, id='shorts_produce_1') # 10:35 첫 번째 쇼츠
|
||||
scheduler.add_job(job_shorts_produce, 'cron',
|
||||
hour=16, minute=0, id='shorts_produce_2') # 16:00 두 번째 쇼츠
|
||||
logger.info("Shorts Bot: 10:35, 16:00 등록")
|
||||
except Exception as _e:
|
||||
logger.warning(f"Shorts 스케줄 등록 실패: {_e}")
|
||||
# Shorts Bot: 자동 스케줄 비활성 — /shorts topic, /shorts make, /shorts run 으로만 실행
|
||||
|
||||
logger.info("스케줄러 설정 완료 (v3 시차 배포 + 소설 파이프라인 + Shorts Bot)")
|
||||
logger.info("스케줄러 설정 완료 (v3 시차 배포, 쇼츠/소설 수동 전용)")
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -2194,12 +2785,20 @@ async def main():
|
||||
app.add_handler(CommandHandler('novel_gen', cmd_novel_gen))
|
||||
app.add_handler(CommandHandler('novel_status', cmd_novel_status))
|
||||
|
||||
# Reddit + 글감 선택
|
||||
app.add_handler(CommandHandler('reddit', cmd_reddit))
|
||||
app.add_handler(CommandHandler('pick', cmd_pick))
|
||||
app.add_handler(CallbackQueryHandler(callback_topic_action, pattern=r'^topicact:'))
|
||||
|
||||
# Shorts Bot
|
||||
app.add_handler(CommandHandler('shorts', cmd_shorts))
|
||||
app.add_handler(CallbackQueryHandler(callback_shorts_make, pattern=r'^shorts_make:'))
|
||||
app.add_handler(CallbackQueryHandler(callback_shorts_upload, pattern=r'^shorts_upload:'))
|
||||
|
||||
# 이미지 파일 수신
|
||||
# 영상/이미지 파일 수신
|
||||
app.add_handler(MessageHandler(filters.VIDEO, handle_video))
|
||||
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
|
||||
app.add_handler(MessageHandler(filters.Document.IMAGE, handle_document))
|
||||
app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
|
||||
|
||||
# 텍스트 명령
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
|
||||
|
||||
Reference in New Issue
Block a user