텔레그램 봇 개발 일지 #2 — 음성·이미지·MCP 통합
결론부터
음성 메시지는 Whisper로 텍스트 변환, 이미지는 봇이 inbox 디렉토리에 저장한 뒤 절대 경로를 프롬프트에 끼워 Claude가 Read 도구로 열게 했다. MCP는 환경변수 토글로 옵트인 — Playwright(브라우저)와 blog(Supabase 글쓰기) 두 개를 붙였다.
음성 입력
OPENAI_API_KEY가 있으면 음성 메시지를 받아서 Whisper로 transcribe하고, 그 텍스트를 일반 메시지처럼 처리한다.
audio = await context.bot.get_file(message.voice.file_id)
ogg = await audio.download_as_bytearray()
transcript = openai_client.audio.transcriptions.create(
model="whisper-1",
file=("voice.ogg", bytes(ogg)),
).text
await handle_text(update, context, transcript)키가 비어 있으면 음성 메시지를 거부한다 — 모르는 사이 비용이 누적되지 않도록 하는 안전장치.
이미지 첨부
텔레그램이 보내는 사진은 download_to_drive로 inbox/<chat_id>/<unix_ts>.jpg에 저장하고, 프롬프트 앞쪽에 절대 경로를 끼워 Claude에 넘긴다. Claude는 Read 도구로 그 경로를 그대로 열어 멀티모달 인식한다.
프롬프트에 들어가는 형태는 대략 이렇다:
사용자가 첨부한 이미지: D:/playground/telegram-claude-bot/inbox/123456789/1715166432.jpg
[원본 메시지]
이 스크린샷에서 빨간 버튼 위치 좌표 알려줘inbox는 시작 시 + 매시간 cleanup_inbox()가 INBOX_RETENTION_DAYS(기본 7일) 지난 파일을 정리한다.
멀티모달 비용 Claude는 이미지 한 장당 토큰을 꽤 먹는다. 가벼운 시각 확인이라면 좋지만, "스샷 50장 한꺼번에 분석" 같은 건 그대로 청구서로 돌아오니 주의.
MCP 서버 통합
MCP는 환경변수 토글로 켠다. 안 쓰는 사람은 startup 비용 0이고, 런타임 의존성이 미설치돼도 봇이 죽지 않도록 opt-in 구조로 갔다.
def build_mcp_servers() -> dict[str, dict]:
servers = {}
if env("MCP_PLAYWRIGHT_ENABLED"):
servers["playwright"] = {
"type": "stdio", "command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
}
if env("MCP_BLOG_ENABLED") and env("MCP_BLOG_ENTRY"):
servers["blog"] = {
"type": "stdio", "command": "node",
"args": [env("MCP_BLOG_ENTRY")],
}
return serversPlaywright는 브라우저 자동화·스크린샷, blog는 my-blog의 Supabase 콘텐츠를 Claude가 직접 CRUD하게 해준다 — 실제로 이 글도 같은 경로로 발행했다.
자식에 환경변수 전달 stdio MCP 서버를 spawn할 때 entry에
env를 명시하지 않으면 SDK가 부모 환경의 일부만 상속해 BLOG_API_URL 같은 커스텀 변수가 누락될 수 있다. 자식에서 못 받아본다면 entry에 env={"BLOG_API_URL": os.environ.get("BLOG_API_URL", "")}처럼 명시적으로 박아주는 게 안전하다.
다음 편 예고
3편은 멀티 프로젝트 — 텔레그램 supergroup의 토픽 단위로 cwd를 분리하고, 토픽 이름을 폴더명에 매핑해 자동 라우팅한 구조를 다룬다.