feat: 텔레그램 이미지 첨부 기능 및 이미지 처리 개선
- /idea, /topic 명령어에 최대 3장 이미지 첨부 기능 추가 - 1장: 본문 최상단 배치, 2~3장: 본문 중간 균등 분산 배치 - base64 data URI 임베딩으로 핫링크 차단 문제 해결 - Claude API timeout=120s, max_retries=0 설정 (401 무한대기 방지) - DuckDuckGo 제목 검색 폴백 및 문화/엔터 섹션 이미지 필터링 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
||||
# request 모드에서 이미지 대기 시 사용하는 상태 변수
|
||||
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
|
||||
_awaiting_image: dict[int, str] = {}
|
||||
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
|
||||
_awaiting_article_image: dict[int, str] = {}
|
||||
|
||||
_publish_enabled = True
|
||||
|
||||
@@ -867,13 +869,18 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
title = article.get('title', '')[:50]
|
||||
corner = article.get('corner', '')
|
||||
body_preview = article.get('body', '')[:200].replace('<', '<').replace('>', '>')
|
||||
# 인라인 버튼으로 승인/거부
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
# 인라인 버튼으로 승인/거부 (+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"
|
||||
@@ -1246,7 +1253,7 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
corner = item.get('corner', '')
|
||||
reason = item.get('pending_reason', '')
|
||||
body_preview = item.get('body', '')[:150].replace('<', '<').replace('>', '>')
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
pending_btn_rows = [
|
||||
[
|
||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{filename}"),
|
||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{filename}"),
|
||||
@@ -1254,7 +1261,12 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
[
|
||||
InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{filename}"),
|
||||
]
|
||||
])
|
||||
]
|
||||
if item.get('source') in ('idea', 'manual'):
|
||||
img_count = len(item.get('user_images', []))
|
||||
img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부"
|
||||
pending_btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{filename}")])
|
||||
keyboard = InlineKeyboardMarkup(pending_btn_rows)
|
||||
await update.message.reply_text(
|
||||
f"🔍 [{i}/{len(pending)}] 수동 검토 대기\n\n"
|
||||
f"<b>{title}</b>\n"
|
||||
@@ -1377,6 +1389,21 @@ async def callback_approve_reject(update: Update, context: ContextTypes.DEFAULT_
|
||||
# 카테고리 선택 버튼 표시
|
||||
buttons = [[InlineKeyboardButton(c, callback_data=f"docorner:{filename}:{c}")] for c in VALID_CORNERS]
|
||||
await query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
|
||||
elif action == 'attachimg':
|
||||
# /idea, /topic 글 대표 이미지 첨부 대기
|
||||
article = json.loads(filepath.read_text(encoding='utf-8'))
|
||||
img_count = len(article.get('user_images', []))
|
||||
if img_count >= 3:
|
||||
await query.answer("이미지는 최대 3장까지입니다.", show_alert=True)
|
||||
return
|
||||
chat_id = query.message.chat_id
|
||||
_awaiting_article_image[chat_id] = filename
|
||||
remaining = 3 - img_count
|
||||
await query.edit_message_text(
|
||||
f"📷 대표 이미지로 사용할 사진을 보내주세요. ({img_count}/3, {remaining}장 추가 가능)\n\n"
|
||||
f"사진을 연속으로 보내면 차례대로 저장됩니다.\n"
|
||||
f"취소: /cancelimg"
|
||||
)
|
||||
elif action == 'docorner':
|
||||
# filename:corner 형태로 파싱
|
||||
parts = filename.split(':', 1)
|
||||
@@ -1532,16 +1559,93 @@ async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.")
|
||||
|
||||
|
||||
async def cmd_cancelimg(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""글 대표 이미지 첨부 대기 취소"""
|
||||
chat_id = update.message.chat_id
|
||||
if chat_id in _awaiting_article_image:
|
||||
_awaiting_article_image.pop(chat_id)
|
||||
await update.message.reply_text("📷 이미지 첨부가 취소되었습니다.")
|
||||
else:
|
||||
await update.message.reply_text("대기 중인 이미지 첨부가 없습니다.")
|
||||
|
||||
|
||||
# ─── 이미지/파일 수신 핸들러 ─────────────────────────
|
||||
|
||||
async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE,
|
||||
file_getter, caption: str):
|
||||
"""공통 이미지 수신 처리 (photo / document)"""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import image_bot
|
||||
|
||||
chat_id = update.message.chat_id
|
||||
|
||||
# ── /idea, /topic 글 대표 이미지 첨부 처리 (최우선, 최대 3장) ──
|
||||
pending_filename = _awaiting_article_image.get(chat_id)
|
||||
if pending_filename:
|
||||
pending_dir = DATA_DIR / 'pending_review'
|
||||
pending_filepath = pending_dir / pending_filename
|
||||
if not pending_filepath.exists():
|
||||
_awaiting_article_image.pop(chat_id, None)
|
||||
await update.message.reply_text("⚠️ 해당 대기 글을 찾을 수 없습니다.")
|
||||
return
|
||||
article = json.loads(pending_filepath.read_text(encoding='utf-8'))
|
||||
user_images = article.get('user_images', [])
|
||||
if len(user_images) >= 3:
|
||||
_awaiting_article_image.pop(chat_id, None)
|
||||
await update.message.reply_text("⚠️ 이미지는 최대 3장까지 첨부할 수 있습니다.")
|
||||
return
|
||||
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
|
||||
images_dir = DATA_DIR / 'images'
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
safe_name = pending_filename.replace('.json', '')
|
||||
img_num = len(user_images) + 1
|
||||
img_filename = f"{safe_name}_{img_num}.jpg"
|
||||
img_path = images_dir / img_filename
|
||||
img_path.write_bytes(file_bytes)
|
||||
# pending JSON에 user_images 리스트 추가
|
||||
user_images.append(str(img_path))
|
||||
article['user_images'] = user_images
|
||||
# 하위호환: 첫 번째 이미지를 user_image에도 저장
|
||||
article['user_image'] = user_images[0]
|
||||
pending_filepath.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
img_count = len(user_images)
|
||||
if img_count < 3:
|
||||
# 아직 추가 가능 → 대기 상태 유지
|
||||
img_label = f"📷 이미지 추가 ({img_count}/3)"
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"),
|
||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"),
|
||||
],
|
||||
[InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_filename}")]
|
||||
])
|
||||
await update.message.reply_text(
|
||||
f"✅ 이미지 {img_count}장 저장! (최대 3장)\n\n"
|
||||
f"추가 이미지를 보내거나 승인 버튼을 눌러주세요.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
# 3장 완료 → 대기 해제
|
||||
_awaiting_article_image.pop(chat_id, None)
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"),
|
||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"),
|
||||
]
|
||||
])
|
||||
await update.message.reply_text(
|
||||
f"✅ 이미지 3장 모두 저장!\n\n승인 버튼을 눌러주세요.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"대표 이미지 저장 ({img_count}/3): {pending_filename} → {img_path}")
|
||||
return
|
||||
|
||||
import image_bot
|
||||
|
||||
# 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음
|
||||
prompt_id = _awaiting_image.get(chat_id)
|
||||
if not prompt_id and caption:
|
||||
@@ -1891,7 +1995,7 @@ async def main():
|
||||
app.add_handler(CommandHandler('approve', cmd_approve))
|
||||
app.add_handler(CommandHandler('reject', cmd_reject))
|
||||
app.add_handler(CommandHandler('pending', cmd_pending))
|
||||
app.add_handler(CallbackQueryHandler(callback_approve_reject, pattern=r'^(approve|reject|setcorner|docorner):'))
|
||||
app.add_handler(CallbackQueryHandler(callback_approve_reject, pattern=r'^(approve|reject|setcorner|docorner|attachimg):'))
|
||||
app.add_handler(CommandHandler('setcorner', cmd_setcorner))
|
||||
app.add_handler(CommandHandler('report', cmd_report))
|
||||
app.add_handler(CommandHandler('idea', cmd_idea))
|
||||
@@ -1904,6 +2008,7 @@ async def main():
|
||||
app.add_handler(CommandHandler('imgpick', cmd_imgpick))
|
||||
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
|
||||
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
|
||||
app.add_handler(CommandHandler('cancelimg', cmd_cancelimg))
|
||||
|
||||
# 소설 파이프라인
|
||||
app.add_handler(CommandHandler('novel_list', cmd_novel_list))
|
||||
|
||||
Reference in New Issue
Block a user