개발 블로그
회고 3 분 소요

텔레그램 봇 개발 일지 #5 — 라이브 메시지 디테일

telegram claude-code ux

결론부터

Claude의 스트리밍 응답은 한 텔레그램 메시지에 editMessage로 누적해서 덮어쓴다. 메시지가 4096자(MarkdownV2 escape 여유분 제외하고 TG_MAX_CHARS=3500)에 닿으면 새 메시지로 이어붙이고, 도구 호출 라인은 최근 N줄만 보여주고, 응답이 길어지면 마지막에 ✅/⚠️ 핑 메시지를 따로 보내 알림을 깨운다.

왜 라이브 메시지인가

가장 단순한 구현은 응답을 다 모아서 한 번에 보내는 거다. 하지만 Claude가 30초~몇 분짜리 작업을 할 때, 그 시간 동안 텔레그램에는 "Thinking..."만 떠 있다. 사용자는 봇이 살아있는지, 어디까지 진행됐는지 알 수 없다.

그래서 SDK 스트림에서 텍스트가 들어올 때마다 누적 버퍼를 만들고, 그 버퍼를 같은 메시지에 editMessage로 덮어쓴다. 사용자는 글자가 흐르듯 채워지는 걸 본다.

throttle과 4096자

editMessage를 매 토큰마다 부르면 텔레그램 rate limit에 즉시 막힌다. EDIT_THROTTLE_S=1.2초 간격으로만 갱신하고, 그사이 들어온 텍스트는 버퍼에 쌓아둔다.

async def maybe_edit():
    if time.time() - last_edit_at < EDIT_THROTTLE_S:
        return
    await message.edit_text(buf[-TG_MAX_CHARS:], parse_mode="MarkdownV2")
    last_edit_at = time.time()

버퍼가 TG_MAX_CHARS를 넘으면 그 메시지를 마무리하고 새 메시지로 이어붙인다. 텔레그램의 4096자 제한과 MarkdownV2 escape에서 늘어날 여유분을 고려해 3500으로 잡았다 — escape 처리 후 4096을 안 넘으려는 안전 마진이다.

MarkdownV2 escape 함정 Claude가 응답에

_, *, [, ], (, ), ~, `, >, #, +, -, =, |, {, }, ., !를 그냥 쓰면 MarkdownV2 파싱이 깨진다. 코드블록 바깥 영역만 골라 escape하는 작은 헬퍼가 필요하다.

도구 호출 라인 truncate

라이브 메시지 위쪽에는 텍스트, 아래쪽에는 도구 호출 라인을 분리해 보여준다 — Read foo.ts, Bash pnpm test 같은 한 줄 표시. 도구를 많이 부르는 작업은 이게 수십 줄로 늘어나 본문을 밀어낸다.

MAX_TOOL_LINES_SHOWN=6으로 잘라서 가장 최근 6줄만 보여준다.

[메시지 본문 — Claude의 텍스트 응답]
 
⚙️
Read src/lib/supabase.ts
Edit astro.config.ts
Bash pnpm typecheck
…(이전 12개 생략)
Bash git push origin main
✅ 완료

마무리 핑

응답이 30초 이내에 끝나면 사용자는 보통 화면을 보고 있다. 하지만 몇 분짜리 작업이라면 사용자는 다른 일을 하러 갔을 수 있다 — 라이브 메시지를 갱신해도 텔레그램 알림은 (편집은 푸시 알림을 안 깨우니까) 울리지 않는다.

COMPLETION_PING_THRESHOLD_S=3.0초 이상 걸린 응답은 마무리 시점에 별도 ✅(성공) 또는 ⚠️(에러) 메시지를 한 줄짜리로 새로 보낸다. 이건 진짜 알림을 트리거한다 — 사용자가 떠나 있어도 폰이 한 번 울려준다.

다음 편 예고

6편은 옵저버빌리티 — 봇이 지금 무엇을 하고 있는지 브라우저로 실시간 확인하는 로컬 dashboard와, /restart 슬래시 명령으로 자가 재시작하는 구조를 다룬다.