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:
JOUNGWOOK KWON
2026-04-01 18:28:19 +09:00
parent 08e5bfc915
commit 15dfc39f0f
3 changed files with 268 additions and 31 deletions

View File

@@ -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('<', '&lt;').replace('>', '&gt;')
# 인라인 버튼으로 승인/거부
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('<', '&lt;').replace('>', '&gt;')
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))