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:
@@ -81,7 +81,11 @@ class ClaudeWriter(BaseWriter):
|
|||||||
return ''
|
return ''
|
||||||
try:
|
try:
|
||||||
import anthropic
|
import anthropic
|
||||||
client_kwargs = {'api_key': self.api_key}
|
client_kwargs = {
|
||||||
|
'api_key': self.api_key,
|
||||||
|
'timeout': 120.0, # 2분 타임아웃
|
||||||
|
'max_retries': 0, # 401 등 에러 시 재시도 안 함 → 즉시 fallback
|
||||||
|
}
|
||||||
if self.base_url:
|
if self.base_url:
|
||||||
client_kwargs['base_url'] = self.base_url
|
client_kwargs['base_url'] = self.base_url
|
||||||
client = anthropic.Anthropic(**client_kwargs)
|
client = anthropic.Anthropic(**client_kwargs)
|
||||||
|
|||||||
@@ -262,24 +262,38 @@ def _is_relevant_image(image_url: str, article: dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _fetch_og_image(url: str) -> str:
|
def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str:
|
||||||
"""원본 기사 URL에서 og:image 메타태그 크롤링 (본문 이미지 검증 포함)"""
|
"""원본 기사 URL에서 og:image 메타태그 크롤링 (본문 이미지 검증 포함)"""
|
||||||
if not url or not url.startswith('http'):
|
if not url or not url.startswith('http'):
|
||||||
return ''
|
return ''
|
||||||
# Google 뉴스 리다이렉트인 경우 실제 기사 URL 추출 시도 (head는 리다이렉트 안됨 → get 사용)
|
# 문화/엔터/스포츠 섹션 기사는 이미지 추출 건너뜀
|
||||||
|
if skip_irrelevant_section and _is_irrelevant_article_url(url):
|
||||||
|
logger.info(f"무관한 섹션 기사 이미지 건너뜀: {url[:80]}")
|
||||||
|
return ''
|
||||||
|
# Google 뉴스 리다이렉트인 경우 실제 기사 URL 추출 시도
|
||||||
if 'news.google.com' in url:
|
if 'news.google.com' in url:
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=15, allow_redirects=True,
|
resp = requests.get(url, timeout=15, allow_redirects=True,
|
||||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||||
if resp.url and 'news.google.com' not in resp.url:
|
if resp.url and 'news.google.com' not in resp.url:
|
||||||
url = resp.url
|
url = resp.url
|
||||||
except Exception:
|
logger.info(f"Google News 리다이렉트 성공: {url[:80]}")
|
||||||
pass
|
# 리다이렉트된 실제 기사 URL도 섹션 검증
|
||||||
|
if skip_irrelevant_section and _is_irrelevant_article_url(url):
|
||||||
|
logger.info(f"리다이렉트된 기사가 무관한 섹션: {url[:80]}")
|
||||||
|
return ''
|
||||||
|
else:
|
||||||
|
logger.info(f"Google News 리다이렉트 실패 — 여전히 news.google.com")
|
||||||
|
return ''
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Google News 리다이렉트 실패: {e}")
|
||||||
|
return ''
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=10, headers={
|
resp = requests.get(url, timeout=10, headers={
|
||||||
'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)',
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
})
|
})
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
|
logger.info(f"기사 페이지 접근 실패 (HTTP {resp.status_code}): {url[:80]}")
|
||||||
return ''
|
return ''
|
||||||
soup = BeautifulSoup(resp.text, 'lxml')
|
soup = BeautifulSoup(resp.text, 'lxml')
|
||||||
|
|
||||||
@@ -290,7 +304,7 @@ def _fetch_og_image(url: str) -> str:
|
|||||||
if src.startswith('http') and not _is_platform_logo(src):
|
if src.startswith('http') and not _is_platform_logo(src):
|
||||||
body_images.append(src)
|
body_images.append(src)
|
||||||
|
|
||||||
# og:image가 본문 이미지와 동일 도메인이면 신뢰, 아니면 사이트 기본 이미지 가능성
|
# og:image 추출
|
||||||
og_url = ''
|
og_url = ''
|
||||||
og = soup.find('meta', property='og:image')
|
og = soup.find('meta', property='og:image')
|
||||||
if og and og.get('content', '').startswith('http'):
|
if og and og.get('content', '').startswith('http'):
|
||||||
@@ -303,23 +317,84 @@ def _fetch_og_image(url: str) -> str:
|
|||||||
og_url = tw['content']
|
og_url = tw['content']
|
||||||
|
|
||||||
if og_url and body_images:
|
if og_url and body_images:
|
||||||
# og:image 도메인이 본문 이미지 도메인과 일치하면 신뢰
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
og_domain = urlparse(og_url).netloc.replace('www.', '')
|
og_domain = urlparse(og_url).netloc.replace('www.', '')
|
||||||
body_domains = {urlparse(u).netloc.replace('www.', '') for u in body_images}
|
body_domains = {urlparse(u).netloc.replace('www.', '') for u in body_images}
|
||||||
if og_domain in body_domains:
|
if og_domain in body_domains:
|
||||||
|
logger.info(f"og:image 도메인 일치 → 사용: {og_url[:80]}")
|
||||||
return og_url
|
return og_url
|
||||||
# 도메인 불일치 → 사이트 기본 og:image일 가능성 → 본문 이미지 우선 사용
|
# 도메인 불일치 → 사이트 기본 og:image일 가능성 → 본문 이미지 우선
|
||||||
logger.info(f"og:image 도메인({og_domain}) ≠ 본문 이미지 도메인 → 본문 이미지 사용")
|
logger.info(f"og:image 도메인({og_domain}) ≠ 본문 이미지 도메인 → 본문 이미지 사용")
|
||||||
return body_images[0]
|
return body_images[0]
|
||||||
elif og_url and not body_images:
|
elif og_url:
|
||||||
# 본문에 이미지가 없으면 og:image를 일단 사용 (검증 불가)
|
logger.info(f"og:image 사용 (본문 이미지 없음): {og_url[:80]}")
|
||||||
return og_url
|
return og_url
|
||||||
elif body_images:
|
elif body_images:
|
||||||
|
logger.info(f"본문 이미지 사용: {body_images[0][:80]}")
|
||||||
return body_images[0]
|
return body_images[0]
|
||||||
|
|
||||||
|
logger.info(f"이미지 없음: {url[:80]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"og:image 크롤링 실패 ({url}): {e}")
|
logger.warning(f"og:image 크롤링 실패 ({url[:60]}): {e}")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _is_irrelevant_article_url(url: str) -> bool:
|
||||||
|
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
|
||||||
|
url_lower = url.lower()
|
||||||
|
irrelevant_paths = [
|
||||||
|
'/culture/', '/entertainment/', '/sport/', '/sports/',
|
||||||
|
'/lifestyle/', '/celebrity/', '/drama/', '/movie/',
|
||||||
|
'/game/', '/gaming/', '/webtoon/', '/comic/',
|
||||||
|
'/tv/', '/ott/', '/show/', '/program/',
|
||||||
|
'/fun/', '/photo/', '/video/', '/gallery/',
|
||||||
|
]
|
||||||
|
return any(p in url_lower for p in irrelevant_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_article_image_by_title(sources: list) -> str:
|
||||||
|
"""Google News 소스 제목으로 DuckDuckGo 검색 → 실제 기사 URL → og:image 크롤링
|
||||||
|
Google News 리다이렉트 실패 시 폴백으로 사용"""
|
||||||
|
from urllib.parse import quote as _quote, urlparse, parse_qs, unquote
|
||||||
|
|
||||||
|
for src in sources[:3]:
|
||||||
|
title = src.get('title', '')
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
# "기사 제목 - 매체명" → 매체명 제거
|
||||||
|
clean_title = re.sub(r'\s*[-–—]\s*\S+$', '', title).strip()
|
||||||
|
if len(clean_title) < 5:
|
||||||
|
clean_title = title
|
||||||
|
try:
|
||||||
|
ddg_url = f'https://html.duckduckgo.com/html/?q={_quote(clean_title)}'
|
||||||
|
resp = requests.get(ddg_url, timeout=10, headers={
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||||
|
})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
soup = BeautifulSoup(resp.text, 'lxml')
|
||||||
|
for a_tag in soup.select('a.result__a')[:3]:
|
||||||
|
href = a_tag.get('href', '')
|
||||||
|
real_url = href
|
||||||
|
if 'uddg=' in href:
|
||||||
|
parsed = parse_qs(urlparse(href).query)
|
||||||
|
uddg = parsed.get('uddg', [''])[0]
|
||||||
|
if uddg:
|
||||||
|
real_url = unquote(uddg)
|
||||||
|
if not real_url.startswith('http'):
|
||||||
|
continue
|
||||||
|
if 'news.google.com' in real_url:
|
||||||
|
continue
|
||||||
|
# 문화/엔터/스포츠 섹션 기사는 건너뜀
|
||||||
|
if _is_irrelevant_article_url(real_url):
|
||||||
|
logger.info(f"무관한 섹션 기사 건너뜀: {real_url[:80]}")
|
||||||
|
continue
|
||||||
|
img = _fetch_og_image(real_url)
|
||||||
|
if img:
|
||||||
|
logger.info(f"제목 검색으로 이미지 발견: {clean_title[:30]} → {img[:60]}")
|
||||||
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"제목 검색 실패: {e}")
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +402,7 @@ def _fetch_og_image(url: str) -> str:
|
|||||||
def fetch_featured_image(article: dict) -> str:
|
def fetch_featured_image(article: dict) -> str:
|
||||||
"""대표 이미지: RSS 이미지 → 참조 기사 og:image → Wikipedia 순으로 시도
|
"""대표 이미지: RSS 이미지 → 참조 기사 og:image → Wikipedia 순으로 시도
|
||||||
참조된 기사 내 이미지만 사용하여 무관한 이미지 유입을 방지한다."""
|
참조된 기사 내 이미지만 사용하여 무관한 이미지 유입을 방지한다."""
|
||||||
|
logger.info(f"대표 이미지 검색 시작: {article.get('title', '')[:40]}")
|
||||||
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 + 관련성 검사)
|
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 + 관련성 검사)
|
||||||
source_image = article.get('source_image', '')
|
source_image = article.get('source_image', '')
|
||||||
if source_image and source_image.startswith('http') and not _is_platform_logo(source_image):
|
if source_image and source_image.startswith('http') and not _is_platform_logo(source_image):
|
||||||
@@ -359,7 +435,14 @@ def fetch_featured_image(article: dict) -> str:
|
|||||||
if og_image and _is_relevant_image(og_image, article):
|
if og_image and _is_relevant_image(og_image, article):
|
||||||
return og_image
|
return og_image
|
||||||
|
|
||||||
# 3) Wikipedia 썸네일 (무료, API 키 불필요)
|
# 3) Google News 리다이렉트 실패 시 → 기사 제목으로 DuckDuckGo 검색 폴백
|
||||||
|
if sources:
|
||||||
|
logger.info("소스 URL 직접 접근 실패 → 기사 제목으로 검색 폴백")
|
||||||
|
title_image = _search_article_image_by_title(sources)
|
||||||
|
if title_image and _is_relevant_image(title_image, article):
|
||||||
|
return title_image
|
||||||
|
|
||||||
|
# 4) Wikipedia 썸네일 (무료, API 키 불필요)
|
||||||
tags = article.get('tags', [])
|
tags = article.get('tags', [])
|
||||||
if isinstance(tags, str):
|
if isinstance(tags, str):
|
||||||
tags = [t.strip() for t in tags.split(',')]
|
tags = [t.strip() for t in tags.split(',')]
|
||||||
@@ -404,17 +487,62 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
|||||||
|
|
||||||
html_parts = []
|
html_parts = []
|
||||||
if not has_image:
|
if not has_image:
|
||||||
image_url = fetch_featured_image(article)
|
title = article.get('title', '').replace('"', '"')
|
||||||
if image_url:
|
user_images = article.get('user_images', [])
|
||||||
title = article.get('title', '').replace('"', '"')
|
# 하위호환: user_image 단일 필드도 지원
|
||||||
# Blogger 호환: div 래핑 없이 직접 img 삽입 (본문 첫 줄에 배치)
|
if not user_images:
|
||||||
img_tag = (
|
single = article.get('user_image', '')
|
||||||
f'<img src="{image_url}" alt="{title}" '
|
if single:
|
||||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
user_images = [single]
|
||||||
f'margin-bottom:1.2em;" />'
|
# 유효한 이미지 파일만 필터링
|
||||||
)
|
valid_user_images = [p for p in user_images if Path(p).exists()]
|
||||||
# body_html 맨 앞에 이미지 삽입 (Blogger가 div를 제거하는 문제 방지)
|
if valid_user_images:
|
||||||
body_html = img_tag + '\n' + body_html
|
# 사용자가 텔레그램으로 첨부한 이미지 → base64 data URI (핫링크 문제 없음)
|
||||||
|
import base64 as _b64
|
||||||
|
import re as _re
|
||||||
|
img_tags = []
|
||||||
|
for img_path in valid_user_images:
|
||||||
|
img_bytes = Path(img_path).read_bytes()
|
||||||
|
ext = Path(img_path).suffix.lower()
|
||||||
|
mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif', '.webp': 'image/webp'}.get(ext, 'image/jpeg')
|
||||||
|
data_uri = f"data:{mime};base64,{_b64.b64encode(img_bytes).decode()}"
|
||||||
|
img_tags.append(
|
||||||
|
f'<img src="{data_uri}" alt="{title}" '
|
||||||
|
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||||
|
f'margin-bottom:1.2em;" />'
|
||||||
|
)
|
||||||
|
if n_imgs == 1:
|
||||||
|
# 1장: 본문 최상단에 배치
|
||||||
|
body_html = img_tags[0] + '\n' + body_html
|
||||||
|
else:
|
||||||
|
# 2~3장: 본문 블록 사이에 균등 분산 배치
|
||||||
|
block_pattern = _re.compile(
|
||||||
|
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
|
||||||
|
r'[\s>])',
|
||||||
|
_re.IGNORECASE,
|
||||||
|
)
|
||||||
|
blocks = block_pattern.split(body_html)
|
||||||
|
boundary_indices = [i for i in range(1, len(blocks), 2)]
|
||||||
|
if len(boundary_indices) >= n_imgs + 1:
|
||||||
|
spacing = len(boundary_indices) // (n_imgs + 1)
|
||||||
|
insert_positions = [spacing * (k + 1) for k in range(n_imgs)]
|
||||||
|
for img_idx, pos in enumerate(reversed(insert_positions)):
|
||||||
|
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
|
||||||
|
blocks.insert(bi, '\n' + img_tags[n_imgs - 1 - img_idx] + '\n')
|
||||||
|
body_html = ''.join(blocks)
|
||||||
|
else:
|
||||||
|
body_html = '\n'.join(img_tags) + '\n' + body_html
|
||||||
|
logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
|
||||||
|
else:
|
||||||
|
image_url = fetch_featured_image(article)
|
||||||
|
if image_url:
|
||||||
|
img_tag = (
|
||||||
|
f'<img src="{image_url}" alt="{title}" '
|
||||||
|
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||||
|
f'margin-bottom:1.2em;" />'
|
||||||
|
)
|
||||||
|
body_html = img_tag + '\n' + body_html
|
||||||
|
|
||||||
html_parts.append(json_ld)
|
html_parts.append(json_ld)
|
||||||
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
|||||||
# request 모드에서 이미지 대기 시 사용하는 상태 변수
|
# request 모드에서 이미지 대기 시 사용하는 상태 변수
|
||||||
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
|
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
|
||||||
_awaiting_image: dict[int, str] = {}
|
_awaiting_image: dict[int, str] = {}
|
||||||
|
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
|
||||||
|
_awaiting_article_image: dict[int, str] = {}
|
||||||
|
|
||||||
_publish_enabled = True
|
_publish_enabled = True
|
||||||
|
|
||||||
@@ -867,13 +869,18 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
title = article.get('title', '')[:50]
|
title = article.get('title', '')[:50]
|
||||||
corner = article.get('corner', '')
|
corner = article.get('corner', '')
|
||||||
body_preview = article.get('body', '')[:200].replace('<', '<').replace('>', '>')
|
body_preview = article.get('body', '')[:200].replace('<', '<').replace('>', '>')
|
||||||
# 인라인 버튼으로 승인/거부
|
# 인라인 버튼으로 승인/거부 (+idea 글이면 이미지 첨부)
|
||||||
keyboard = InlineKeyboardMarkup([
|
btn_rows = [
|
||||||
[
|
[
|
||||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
|
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
|
||||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{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(
|
await update.message.reply_text(
|
||||||
f"📝 [수동 검토 필요]\n\n"
|
f"📝 [수동 검토 필요]\n\n"
|
||||||
f"<b>{title}</b>\n"
|
f"<b>{title}</b>\n"
|
||||||
@@ -1246,7 +1253,7 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
corner = item.get('corner', '')
|
corner = item.get('corner', '')
|
||||||
reason = item.get('pending_reason', '')
|
reason = item.get('pending_reason', '')
|
||||||
body_preview = item.get('body', '')[:150].replace('<', '<').replace('>', '>')
|
body_preview = item.get('body', '')[:150].replace('<', '<').replace('>', '>')
|
||||||
keyboard = InlineKeyboardMarkup([
|
pending_btn_rows = [
|
||||||
[
|
[
|
||||||
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{filename}"),
|
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{filename}"),
|
||||||
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{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}"),
|
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(
|
await update.message.reply_text(
|
||||||
f"🔍 [{i}/{len(pending)}] 수동 검토 대기\n\n"
|
f"🔍 [{i}/{len(pending)}] 수동 검토 대기\n\n"
|
||||||
f"<b>{title}</b>\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]
|
buttons = [[InlineKeyboardButton(c, callback_data=f"docorner:{filename}:{c}")] for c in VALID_CORNERS]
|
||||||
await query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
|
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':
|
elif action == 'docorner':
|
||||||
# filename:corner 형태로 파싱
|
# filename:corner 형태로 파싱
|
||||||
parts = filename.split(':', 1)
|
parts = filename.split(':', 1)
|
||||||
@@ -1532,16 +1559,93 @@ async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.")
|
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,
|
async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE,
|
||||||
file_getter, caption: str):
|
file_getter, caption: str):
|
||||||
"""공통 이미지 수신 처리 (photo / document)"""
|
"""공통 이미지 수신 처리 (photo / document)"""
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
import image_bot
|
|
||||||
|
|
||||||
chat_id = update.message.chat_id
|
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 결정: 대기 상태 > 캡션 파싱 > 없음
|
# 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음
|
||||||
prompt_id = _awaiting_image.get(chat_id)
|
prompt_id = _awaiting_image.get(chat_id)
|
||||||
if not prompt_id and caption:
|
if not prompt_id and caption:
|
||||||
@@ -1891,7 +1995,7 @@ async def main():
|
|||||||
app.add_handler(CommandHandler('approve', cmd_approve))
|
app.add_handler(CommandHandler('approve', cmd_approve))
|
||||||
app.add_handler(CommandHandler('reject', cmd_reject))
|
app.add_handler(CommandHandler('reject', cmd_reject))
|
||||||
app.add_handler(CommandHandler('pending', cmd_pending))
|
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('setcorner', cmd_setcorner))
|
||||||
app.add_handler(CommandHandler('report', cmd_report))
|
app.add_handler(CommandHandler('report', cmd_report))
|
||||||
app.add_handler(CommandHandler('idea', cmd_idea))
|
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('imgpick', cmd_imgpick))
|
||||||
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
|
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
|
||||||
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
|
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
|
||||||
|
app.add_handler(CommandHandler('cancelimg', cmd_cancelimg))
|
||||||
|
|
||||||
# 소설 파이프라인
|
# 소설 파이프라인
|
||||||
app.add_handler(CommandHandler('novel_list', cmd_novel_list))
|
app.add_handler(CommandHandler('novel_list', cmd_novel_list))
|
||||||
|
|||||||
Reference in New Issue
Block a user