개발자 문서
한국어

봇 개발

음성 봇

사람이 앱에서 봇에게 전화를 걸면 AI 음성 모델이 받습니다. 켜는 법, 지시문과 목소리, 통화 알림, 도구, MCP 서버, 자기 키, 대화 기록, 원음 모드, 애플 워치와 대화 남기기까지 — 봇 주인을 위한 안내.

음성 봇은 사람이 앱에서 전화를 걸 수 있는 봇입니다. 전화를 받는 것은 봇 주인의 코드가 아니라 파발의 AI 음성 모델이고, 봇 주인은 그 모델이 따를 지시문·인사말·목소리·언어·한도를 Bot API 로 정합니다. 통화가 시작되고 끝날 때마다 voice_call 업데이트를 받고, 통화 중에 모델이 봇의 코드(도구)를 불러 주문을 찾거나 예약을 확인하게 할 수 있습니다. AI 모델 대신 내 서버가 소리를 직접 받는 원음 모드도 있습니다. 봇을 처음 만든다면 봇 만들기를 먼저 보세요.

지금 상태

음성 봇은 서버마다 켜져 있어야 쓸 수 있고, 이 문서를 고친 때(2026-10-02) pabal.me 에서는 꺼져 있습니다 — 개발 서버에서 시험 중입니다

아래 조건이 모두 채워진 서버에서만 음성 봇에 전화할 수 있습니다. 메서드는 음성이 꺼진 서버에도 있어서 설정을 읽을 수는 있습니다. 하지만 이 서버에서 음성이 켜져 있고 운영자가 그 봇을 허용하기 전에는, 끄기(음성 끄기·대화 기록 끄기·원음 모드 끄기·자기 키 지우기·MCP 서버 지우기) 말고는 어떤 설정도 저장되지 않습니다(403 VOICE_NOT_AVAILABLE). 내 봇이 지금 전화를 받을 수 있는지는 getVoiceCallSettings 의 available 과 unavailable_reason 이 알려 줍니다.

기능켜지려면 (모두)pabal.me 지금
음성 봇 통화서버에 음성 게이트웨이와 통화 중계(TURN)가 붙어 있음 · 개인정보처리방침에 봇 통화 절(bot-calls)이 게시됨 · 운영자가 그 봇을 허용함 · 공개 전에는 운영자가 정한 시험자만 걸 수 있음 · 발신자의 앱이 확인 창이 든 빌드(데스크톱 pabal-bc2 — 확인 창이 한국어·영어뿐인 동안은 pabal-bc1 도 받음, 안드로이드 62151, 아이폰 빌드 5 이상, 애플 워치 앱 빌드 1 이상) — 이런 빌드는 아직 배포되지 않았습니다꺼짐
애플 워치에서 걸기음성 봇 통화 + 서버의 워치 API + 워치 소리 입구(pabal-voice-edge) + 직통 통화(direct)를 받는 게이트웨이 · 워치 앱 빌드가 TELEGRAM_VOICE_MIN_BUILD_WATCHOS 이상 · 도구·MCP 서버·자기 키·원음 모드·봇 주인의 대화 기록을 쓰는 봇은 아직 안 됨꺼짐 — 개발 서버에서 시험 중
대화 남기기 (발신자 사본)음성 봇 통화 + 방침의 bot-calls-keep-text 절 + keep_text 를 밝힌 게이트웨이(0.9.0) + 체크 상자가 든 앱 빌드 · 확인 창 문구의 법률 검토꺼짐 — 법률 검토 전
봇이 거는 전화개인정보처리방침의 bot-calls-outbound 절과 이용약관의 voice-bot-owners-outbound 절이 게시됨(봇 주인이 약관 4판에 동의하려면 voice-bot-owners 절도) · 법률 검토 뒤 운영자가 켜는 전역 스위치 · 봇 주인이 @BotFather 에서 켬(약관 4판·목적) · 운영자의 발신 검토 · 받는 사람의 허락(허락 창이 든 앱 빌드) · 승인된 발신 안내 녹음을 가진 게이트웨이 · 봇 언어가 한국어·영어꺼짐 — 법률 검토 대기
한국어·영어 밖의 봇 언어 (7개)음성 봇 통화 + 그 봇의 언어로 통화하는 게이트웨이(0.7.0 부터 9개 언어 모두) · 운영자가 그 봇의 지시문·인사말을 읽고 허용함(모든 봇을 받는 서버에서도)꺼짐 — 배포 전
한국어·영어 밖의 확인 창과 안내 음성 (8개 언어)언어마다 법률 검토와 원어민 확인을 마친 뒤 앱 빌드와 게이트웨이 이미지에 실림. 그 전에는 그 언어의 앱을 쓰는 발신자도 영어 확인 창을 보고 영어 안내를 들음초안 — 검토 전
봇 주인 약관 (판 4)이용약관에 봇 주인 절(voice-bot-owners)이 게시됨. 대화 기록·자기 키·원음 모드·MCP 서버·봇이 거는 전화는 모두 이 약관이 먼저입니다동의할 수 없음
도구음성 봇 통화 + 도구를 받는 게이트웨이꺼짐
MCP 서버MCP 부품 · 전용 송신 주소(운영 조건 — 코드가 확인하지는 않음) · 방침의 bot-calls-mcp 절과 약관의 봇 주인 절 · 봇 주인 약관 동의 · 앱이 봇의 MCP 받는 곳을 확인 창에 보여 주는 빌드(그 전에는 등록해도 어떤 통화도 MCP 도구를 싣지 않음)꺼짐
자기 모델 키서버의 마스터 키 · 앱이 자기 키 봇의 받는 곳을 보여 주는 확인 창(2판)을 낸 뒤 운영자가 켬 · 봇 주인 약관 · 주인 키를 돌리는 게이트웨이(기본은 Google 키만)꺼짐 — 키를 받지도 않음
대화 기록운영자가 법률 검토 뒤 관리 화면에서 허용(기본 꺼짐) · 봇 주인 약관 · 봇 주인의 스위치 · 대화 기록 안내 음성을 가진 게이트웨이 · 관리형 통화꺼짐
원음 모드서버의 마스터 키 · 방침의 bot-calls-stream 절과 약관의 봇 주인 절 · 원음 통화를 받는 게이트웨이 · 원음 확인 창이 든 앱 빌드(아직 없음)꺼짐
  • 발신자(전화를 거는 사람)는 만 19세 이상이어야 하고, 전화를 걸기 전에 "이 통화는 AI가 받습니다" 확인 창을 보며, 연결되면 AI 안내 음성을 먼저 듣습니다(발신자가 보는 것). 확인 창이 없는 앱(텔레그램 원본 앱이나 Telethon 같은 클라이언트 포함)에는 봇의 통화 버튼이 없습니다.
  • 관리형 통화의 소리는 Google 로 갑니다 — Vertex AI 의 Gemini Live API, 미국 아이오와(Google Cloud us-central1). 파발은 소리를 저장하지 않습니다. Google 은 대답을 만든 뒤 소리와 글을 보관하지 않지만, 자동 검사에서 남용이 의심된 내용은 최대 90일 기록될 수 있습니다(확인 창과 같은 내용). 운영 엔진은 이것 하나이고, 다른 제공자로 몰래 넘기지 않습니다(자동 전환 없음).
  • 봇 통화는 사람끼리 하는 통화와 달리 종단 간 암호화가 아닙니다: 파발의 음성 게이트웨이가 소리를 풀어 AI 모델에 건넵니다. 발신자의 확인 창이 그렇게 알립니다.

전화 한 통은 이렇게 흐릅니다

발신자 파발 (운영자의 서버) 파발 밖 파발 앱 확인 창 · 만 19세 고친 빌드 pabal-server MTProto · Bot API 입장 판정 · 한도 통화 기록 봇 주인의 프로그램 Bot API (HTTPS) 내 코드 · 내 서버 coturn 소리 중계만 pabal-voice AI 안내 음성 · 지시문 소리를 저장하지 않음 Google Vertex AI Gemini Live us-central1 (미국) ① 전화 ② 링크 통화 · 설정 · 도구 ③ voice_call · 도구 질의 설정 · 도구의 답 · endVoiceCall 소리 (중계만) ④ 소리 ↔ AI 의 대답
관리형 통화: 신호는 위, 소리는 아래

다이어그램 설명

  • 세 영역: 발신자의 앱 → 파발 운영자의 서버(파발 서버, 음성 게이트웨이, 중계 서버) → 파발 밖(Google 의 AI 모델, 봇 주인의 프로그램). 봇 주인의 코드는 소리를 한 바이트도 보지 않습니다.
  • ① 전화: 앱은 확인 창에서 받은 동의를 서버에 알린 뒤 전화를 겁니다. 서버는 발신자(동의 — 만 19세 이상을 스스로 확인 · 앱 빌드 · 시험자 · 한도)와 봇(허용 · 설정 · 한도)을 모두 확인하고 받거나, 통화 중 신호로 끝냅니다.
  • ② 링크: 서버가 통화를 음성 게이트웨이에 넘기며 봇 주인의 지시문·인사말·목소리·언어·도구를 함께 보냅니다. 게이트웨이는 파발의 기본 규칙(AI 라고 밝히기 등)을 언제나 먼저 두고 그 뒤에 지시문을 둡니다.
  • ③ Bot API: 봇 주인의 프로그램은 설정을 정하고, 통화가 시작·끝날 때 voice_call 을, 모델이 도구를 부르면 voice_call_tool_query 를 받아 답합니다. ③의 파란 화살표는 서버가 봇에게 보내는 것, 그 아래 회색 화살표는 봇이 서버로 보내는 것입니다.
  • ④ 소리(아래 줄): 앱과 게이트웨이는 서로의 주소를 모른 채 중계 서버(coturn)로만 소리를 주고받고, 게이트웨이가 소리를 Google 의 모델에 건네고 대답을 돌려받습니다. 빨간 칸이 파발 밖으로 소리가 나가는 곳입니다.
  • 원음 모드에서는 ④의 Google 자리에 봇 주인의 서버가 들어갑니다(원음 모드).

애플 워치에서 거는 통화 (준비 중)

워치 앱은 MTProto 를 쓰지 않습니다. 워치는 서버의 워치 API(HTTPS JSON)로 로그인하고, 봇 목록과 확인 창을 받고, 전화를 겁니다. 서버는 앱의 전화와 같은 입장 판정을 거친 뒤 그 통화에만 쓰는 한 번짜리 표(15초)를 워치에 주고, 통화를 게이트웨이에 넘깁니다. 워치는 그 표를 들고 소리 입구(WSS, pabal-voice-edge)에 붙고, 입구가 그 통화를 맡은 게이트웨이 작업에 소리를 잇습니다. 키 교환(DH)도 통화 중계(coturn)도 없습니다. 그래도 게이트웨이가 소리를 풀어 모델에 건네는 것은 같으므로 종단 간 암호화가 아니고, 소리가 가는 곳도 같습니다. 봇 주인에게는 앱의 통화와 똑같은 voice_call 이 옵니다(started 의 시점만 다름, 아래).

음성 통화 켜기

음성 통화를 켜는 것은 @BotFather 에서 사람이 합니다 — 음성 봇 규칙에 동의하는 것은 토큰이 아니라 봇 주인의 약속이기 때문입니다. 나머지 설정은 @BotFather 와 Bot API 어디서든 됩니다.

  1. 운영자에게 봇 허용을 부탁합니다. 허용한 봇만 받는 서버(기본)에서는 운영자가 관리 화면의 음성 탭에서 봇 ID(토큰의 : 앞 숫자)로 허용하기 전까지 @BotFather 에 음성 통화 버튼이 없고, 설정을 바꾸면 403 VOICE_NOT_AVAILABLE 입니다. 공개 전이면 전화를 걸어 볼 내 계정도 시험자로 넣어 달라고 하세요(내 사용자 ID 는 봇에게 메시지를 보낸 뒤 getUpdates 의 message.from.id).
  2. 지시문을 정합니다. 봇이 전화에서 무엇을 하는지 적은 글입니다. 아래 setVoiceCallSettings 로 정하거나 @BotFather 의 음성 통화 화면에서 지시문 버튼으로 보냅니다. 지시문이 없으면 켤 수 없습니다(원음 모드는 예외).
  3. @BotFather 에 /mybots → 봇 → 음성 통화 → 켜기. 처음 한 번 음성 봇 규칙이 나오고, 규칙에 동의하고 켜기를 누르면 켜집니다. 다음부터는 "켤까요?" 만 묻습니다.
  4. 다시 허용을 기다립니다. 허용한 봇만 받는 서버에서는 허용이 그때의 지시문·인사말·도구를 허용한 것이라, 허용 뒤에 지시문을 쓰면 unavailable_reason: "awaiting_review" 가 됩니다. 운영자가 확인하고 다시 허용해야 받습니다. 셋 중 하나(원음 모드면 원음 주소)를 바꿀 때마다 같습니다. 봇 언어가 한국어·영어가 아니면 모든 봇을 받는 서버에서도 운영자가 지시문·인사말을 읽고 허용해야 받습니다(목소리와 언어).
  5. getVoiceCallSettings 의 available 이 true 인지 보고, 확인 창이 든 앱 빌드에서 봇 프로필의 전화 버튼으로 걸어 봅니다.
켜기
BotFather음성 통화를 켜기 전에 음성 봇 규칙을 확인해 주세요. 1. 봇은 언제나 AI 라고 밝혀요. 사람인 척하게 할 수 없어요. 2. 실제 기관·회사·사람을 사칭할 수 없어요. 3. 카드 번호·주민등록번호·비밀번호·인증 번호를 묻게 할 수 없어요. 4. 통화 소리는 파발이 저장하지 않고, 대답을 만들기 위해 Google(Vertex AI)로 보내져요. 5. 봇과의 음성 통화는 만 19세 이상만 할 수 있어요. 6. 규칙을 어기면 운영자가 음성 통화나 봇을 멈출 수 있어요.
규칙에 동의하고 켜기
BotFather음성 통화를 켰어요. 음성 통화 · @order_helper_bot 상태: 켜짐 목소리: Kore (단단한) · 언어: 한국어 … 전화를 받으면 먼저 "AI 음성 봇과 연결되었습니다" 안내가 나가고, 그다음 봇이 인사해요.

허용한 봇만 받는 서버에서는 상태가 "켜짐 — 바뀐 지시문·인사말을 운영자가 확인할 때까지 전화를 받지 않아요" 로 보일 수 있습니다(위 4단계). 봇 언어가 한국어·영어가 아니면 어느 서버에서든 운영자가 허용하기 전까지 "켜짐 — 한국어·영어가 아닌 봇은 운영자가 지시문·인사말을 읽고 허용할 때까지 전화를 받지 않아요 (자동 규칙 검사가 한국어와 영어만 읽어요)" 로 보입니다.

음성 통화 화면에는 켜기·끄기, 지시문, 인사말, 목소리, 언어, 한도, 사용량, 대화 기록, 자기 키, 원음 모드, (MCP 가 켜진 서버에서) MCP 서버 버튼이 있습니다. 음성이 꺼지거나 허용이 풀린 뒤에도 끄기와 지우기는 언제나 됩니다 — 음성 끄기, 자기 키 지우기, 원음 모드 끄기, MCP 서버 지우기.

켤 때와 통화마다 봇 이름도 사칭 검사를 받습니다. 실제 기관·회사의 이름을 쓴 봇은 VOICE_CONTENT_REFUSED: name: impersonation 으로 켜지지 않습니다. 그 기관의 봇이라면 서버의 고객센터로 알리세요.

설정 — setVoiceCallSettings

봇의 음성 설정을 정합니다. 보낸 것만 바뀌는 부분 수정이고, 전체를 검사한 뒤 한 번에 쓰거나 전부 거절합니다. 바뀐 것이 없으면 아무것도 쓰지 않습니다.

curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -H 'Content-Type: application/json' -d '{
  "instructions": "주문도우미 가게의 전화 상담원이에요. 존댓말로 짧게 답하세요. 영업시간은 오전 10시부터 오후 8시예요.",
  "greeting": "주문도우미입니다. 무엇을 도와드릴까요?",
  "voice": "kore",
  "language": "ko-KR",
  "max_call_duration": 180
}'
# {"ok":true,"result":true}
파라미터타입설명
enabledBoolean켜기·끄기. 켜려면 @BotFather 에서 규칙에 동의한 뒤여야 하고(아니면 403 VOICE_RULES_NOT_ACCEPTED), 관리형이면 지시문이 있어야 합니다. 끄기는 언제나 됩니다 — 지금 진행 중인 통화는 끝까지 가고 새 전화만 받지 않습니다
instructionsString모델이 따를 지시문. UTF-8 8,192바이트(한글 약 2,700자)까지. "" 는 지움 — 켜진 관리형 봇은 비울 수 없습니다. 길수록 통화 요금이 늘고, 발신자가 알아낼 수 있으니 비밀번호나 키는 넣지 마세요
greetingStringAI 안내 뒤 봇이 처음 할 말. 512바이트(한글 약 170자)까지. "" 면 모델이 알아서 짧게 인사합니다
voiceString목소리 id(목록), 기본 kore
languageString봇이 말하고 알아들을 언어로, 9개 id 중 하나입니다(기본 ko-KR, 목록). zh-CN 은 cmn-CN 으로, 대소문자만 다른 id(ja-jp)는 목록의 모양으로 저장합니다. 한국어·영어가 아니면 운영자가 지시문·인사말을 읽고 허용한 뒤에 전화를 받습니다. 맨 앞의 AI 안내 음성은 이것이 아니라 발신자가 본 확인 창의 언어로 나옵니다
max_call_durationInteger한 통화의 최대 길이(초). 60 이상, limits.max_call_duration(운영자·서버 상한 중 작은 값, 기본 600) 이하. 봇 기본값 300
max_concurrent_callsInteger동시에 받을 통화 수. 1 이상, limits.max_concurrent_calls(기본 2) 이하. 봇 기본값 2
toolsArray통화 중 모델이 부를 수 있는 봇의 도구(도구). [] 는 모두 지움
transcriptBoolean대화 기록 스위치(대화 기록)
mode, stream_url, stream_secretString원음 모드(원음 모드 설정)
model, api_key받지 않음 — VOICE_FIELD_NOT_AVAILABLE. 모델은 서버가 정하고, 키는 setVoiceCallApiKey 로만
  • 내용 규칙: 지시문·인사말·도구 설명은 저장할 때와 통화 때 검사합니다. 사람이라고 말하게 하기(claims_human), AI 임을 숨기게 하기(hides_ai), 파발 규칙을 무시하라기(overrides_rules), 실제 기관·회사 사칭(impersonation), 공식 기관이라 소개하기(official_claim), 카드 번호·주민등록번호·비밀번호·인증 번호 묻기(asks_secrets), 비밀 키처럼 보이는 글(looks_like_secret), 보이지 않는 글자(hidden_text)는 VOICE_CONTENT_REFUSED: <칸>: <코드> 로 거절됩니다. "비밀번호는 절대 묻지 마세요" 처럼 부정하는 문장은 통과합니다.
  • 파발의 기본 규칙이 언제나 먼저입니다. 모델은 통화마다 파발의 규칙 → 봇 주인의 지시문 → 파발 규칙의 맺음말 순서로 읽고, 지시문은 "봇 주인의 글"로 표시된 칸 안에 들어갑니다. 지시문은 파발의 규칙을 대신하거나 앞설 수 없습니다.
  • 한 시간에 30번까지 바꿀 수 있습니다(@BotFather 와 합쳐서, 실제로 바뀐 것만 셈). 넘으면 429 Too Many Requests: retry after N 과 parameters.retry_after.
  • 폼(-d name=value)으로 보낼 때 tools 는 JSON 문자열로 넣습니다.
  • MTProto 로만 로그인한 봇은 같은 JSON 을 bots.sendCustomRequest 로 보냅니다(MTProto 봇).

지금 설정 보기 — getVoiceCallSettings

curl -s "https://pabal.me/bot$BOT_TOKEN/getVoiceCallSettings"
{
  "ok": true,
  "result": {
    "enabled": true,
    "available": true,
    "suspended": false,
    "rules_accepted": true,
    "instructions": "주문도우미 가게의 전화 상담원이에요. …",
    "greeting": "주문도우미입니다. 무엇을 도와드릴까요?",
    "voice": "kore",
    "language": "ko-KR",
    "max_call_duration": 180,
    "max_concurrent_calls": 2,
    "limits": {"max_call_duration": 600, "max_concurrent_calls": 2, "bot_minutes_per_day": 60, "owner_minutes_per_day": 120},
    "voices": [{"id": "zephyr", "name": "Zephyr", "description": "Bright"}, …],
    "languages": ["ko-KR", "en-US", "ja-JP", "cmn-CN", "fr-FR", "es-US", "id-ID", "vi-VN", "ar-EG"],
    "testers_only": true,
    "mode": "managed",
    "has_stream_secret": false,
    "owner_terms_accepted": false,
    "stream_available": false,
    "updated_date": 1790380712,
    "tools": [],
    "tool_limits": {"max_tools": 16, "max_definition_bytes": 8192, "definition_bytes": 0, "mcp_tools": 0},
    "tool_delivery": "none",
    "transcript": false,
    "transcript_available": false,
    "transcript_delivery": "get_updates",
    "has_api_key": false,
    "own_keys_available": false,
    "mcp_available": false
  }
}
필드뜻
enabled봇 주인이 켰는지
available, unavailable_reason지금 누가 걸든 봇이 받을 수 있는지와, 아니면 그 이유(아래 표)
rules_accepted, owner_terms_accepted음성 봇 규칙, 지금의 봇 주인 약관(판 4)에 동의했는지
limits이 서버에서 정할 수 있는 최대값과 하루 한도(분)
voices, languages고를 수 있는 목소리와 언어
testers_only공개 전이라 운영자가 정한 시험자만 걸 수 있으면 true
tools, tool_limits, tool_delivery, tool_delivery_problem도구와 그 한도, 도구 질의가 지금 갈 곳(webhook·get_updates·mtproto·none)과 못 가는 이유(도구 질의 받기)
mode, stream_url, stream_secret_fingerprint, has_stream_secret, stream_available, stream_ignores원음 모드 — 비밀은 나오지 않고 8자리 지문만
transcript, transcript_available, transcript_unavailable_reason, transcript_delivery, transcript_delivery_problem대화 기록 스위치와, 지금 실제로 전해질지
has_api_key, api_key, own_keys_available자기 모델 키 — 키는 나오지 않고 제공자·지문·정한 날·쓸 수 있는지만
mcp_available, mcp_servers, mcp_unavailable_reasonMCP 서버
outbound봇이 거는 전화의 상태(봇이 거는 전화 — 설정과 사용량)
updated_date마지막으로 바꾼 때(유닉스 초)
unavailable_reason뜻
voice_off이 서버에서 음성이 꺼져 있거나, 개인정보처리방침의 봇 통화 절이 아직 없음
kill_switch운영자가 모든 음성 통화를 멈춤
bot_voice_off봇 주인이 켜지 않음
not_allowed운영자가 이 봇을 허용하지 않음
awaiting_review바뀐 지시문·인사말·도구(원음 모드면 원음 주소)를 운영자가 아직 확인하지 않음. 봇 언어가 한국어·영어가 아니면 모든 봇을 받는 서버에서도 운영자가 지시문·인사말을 읽고 허용해야 함
suspended운영자가 이 봇(또는 주인 계정)의 음성을 멈춤
name_refused, text_refused봇 이름, 저장된 지시문·인사말·도구 설명이 지금의 내용 규칙에 맞지 않음. text_refused 는 봇이 고른 언어가 목록에서 빠져 이제 쓸 수 없을 때도 나옵니다 — 언어를 다시 고르세요
no_relay, no_gateway서버에 통화 중계나 음성 게이트웨이가 없음
no_gateway_with_tools, no_own_key_gateway이 봇의 도구나 자기 키를 쓸 수 있는 게이트웨이가 없음
no_gateway_with_language이 봇의 언어로 통화하는 게이트웨이가 연결돼 있지 않음(목소리와 언어)
own_key_unavailable, stream_unavailable정해 둔 자기 키·원음 모드를 지금 쓸 수 없어 받지 않음(플랫폼 키로 대신 받지 않습니다)

목소리와 언어

Gemini Live 의 미리 만든 목소리 30개 중에서 고릅니다(기본 kore). 목소리를 올리거나 복제하는 기능은 없습니다. @BotFather 의 목소리 버튼에서는 이름과 느낌으로 고릅니다.

id느낌id느낌id느낌
zephyr밝은puck경쾌한charon또렷한
kore단단한fenrir들뜬leda앳된
orus단단한aoede산뜻한callirrhoe느긋한
autonoe밝은enceladus숨결 섞인iapetus맑은
umbriel느긋한algieba부드러운despina부드러운
erinome맑은algenib거친rasalgethi또렷한
laomedeia경쾌한achernar여린alnilam단단한
schedar차분한gacrux성숙한pulcherrima적극적인
achird친근한zubenelgenubi편안한vindemiatrix다정한
sadachbia활기찬sadaltager박식한sulafat따뜻한

언어(language)는 아래 9개 중 하나입니다. 봇이 말하고 알아들을 언어이고, 봇은 이 언어로 대답합니다. @BotFather 에서는 음성 통화 화면의 언어 버튼으로 고릅니다.

id언어알아 둘 것
ko-KR한국어기본
en-US영어—
ja-JP일본어—
cmn-CN중국어(보통화)Google 의 공식 지원 목록 밖이라 품질을 Google 이 보장하지 않습니다(시험에서는 알아듣기와 대답 모두 정확했습니다). zh-CN 으로 보내도 받고 cmn-CN 으로 저장합니다
fr-FR프랑스어—
es-US스페인어—
id-ID인도네시아어—
vi-VN베트남어—
ar-EG아랍어(표준)현대 표준 아랍어로 말합니다. 코드는 Google 의 실시간 모델에 있는 유일한 아랍어 코드인 ar-EG 이지만 방언을 정하지 않습니다 — 지시문이 방언을 청할 때만 그 방언으로 말합니다
  • 한국어·영어가 아닌 봇은 운영자가 지시문·인사말을 읽고 허용한 뒤에 전화를 받습니다 — 모든 봇을 받는 서버에서도 그렇습니다. 저장할 때의 자동 규칙 검사(사람인 척하기, 사칭 등)가 한국어와 영어만 읽기 때문입니다. 허용 전에는 unavailable_reason: "awaiting_review" 이고, 허용 뒤에 지시문·인사말·도구를 바꾸면 다시 허용을 기다립니다. @BotFather 는 언어를 고르는 화면과 고른 뒤에 "한국어·영어가 아닌 언어는 자동 규칙 검사가 읽지 못해서, 운영자가 지시문·인사말을 읽고 허용한 뒤에 전화를 받아요." 라고 알립니다. 운영자의 관리 화면 음성 탭에는 그 봇이 "한국어·영어가 아닌 봇 (자동 규칙 검사가 읽지 못함): 지시문·인사말을 읽어 주세요" 로 뜹니다.
  • 봇의 언어로 통화하는 게이트웨이가 있어야 받습니다. 게이트웨이는 서버에 붙을 때 자기가 통화할 수 있는 봇 언어를 밝히고(0.7.0 부터 9개 모두), 언어를 밝히지 않는 게이트웨이는 한국어·영어만 한다고 봅니다. 봇의 언어로 통화하는 게이트웨이가 없으면 no_gateway_with_language 이고 통화 버튼이 숨습니다. 다른 언어로 대신 통화하지 않습니다.
  • id 는 대소문자를 가리지 않고 받아(ja-jp) 목록의 모양으로 저장합니다. 목록에 없는 값은 받는 id 를 알려 주는 VOICE_LANGUAGE_INVALID: language is one of ko-KR, en-US, ja-JP, cmn-CN, fr-FR, es-US, id-ID, vi-VN, ar-EG 로 거절됩니다.
  • 고른 언어가 나중에 목록에서 빠지면 그 봇은 설정을 지닌 채 전화를 받지 않습니다(text_refused). 다른 언어로 대신 받지 않으니 @BotFather 의 언어에서 다시 고르세요.
  • 크메르어는 아직 봇 언어로 고를 수 없습니다. 모델이 알아듣기는 하지만, 자유롭게 한 대답을 크메르어 원어민이 확인하기 전입니다.

발신자가 계속 다른 언어로 말하면 모델은 그 언어로 답합니다. AI 안내 음성은 봇의 language 가 아니라 발신자가 본 확인 창의 언어로 나옵니다(발신자가 보는 것).

통화 알림 받기 — voice_call

봇에게 전화가 오면 voice_call 업데이트가 옵니다. 음성 업데이트 세 가지(voice_call, voice_call_tool_query, voice_call_transcript)는 이름을 적어서 청해야만 옵니다: getUpdates 나 setWebhook 의 allowed_updates 에 넣으세요. 목록이 비어 있어도 오지 않습니다(다른 종류는 빈 목록이면 모두 옵니다).

curl -s "https://pabal.me/bot$BOT_TOKEN/getUpdates" -H 'Content-Type: application/json' \
  -d '{"allowed_updates": ["message", "voice_call", "voice_call_tool_query"], "timeout": 30}'
{
  "update_id": 7,
  "voice_call": {
    "id": "4611686018427387905",
    "direction": "incoming",
    "event": "started",
    "from": {"id": 100002, "is_bot": false, "first_name": "민지", "language_code": "ko"},
    "date": 1790380803,
    "max_duration": 180
  }
}
{
  "update_id": 9,
  "voice_call": {
    "id": "4611686018427387905",
    "direction": "incoming",
    "event": "ended",
    "from": {"id": 100002, "is_bot": false, "first_name": "민지", "language_code": "ko"},
    "date": 1790380952,
    "start_date": 1790380803,
    "duration": 149,
    "max_duration": 180,
    "end_reason": "caller"
  }
}
  • allowed_updates 는 봇마다 하나의 설정이고, 마지막으로 getUpdates 나 setWebhook 이 준 것이 남습니다(주지 않으면 이전 값 그대로). 그래서 위 예처럼 목록을 주면 목록에 적지 않은 기본 종류(edited_message, callback_query)는 더는 오지 않습니다 — 봇이 쓰는 종류를 모두 적으세요. 청하기 전에 일어난 통화의 알림은 쌓이지 않으므로 나중에 청해도 오지 않습니다.
  • direction: 봇에게 걸려 온 전화는 "incoming", 봇이 건 전화는 "outgoing" 입니다(봇이 거는 전화의 업데이트). 예전처럼 이 필드가 없으면 받는 전화로 읽어도 맞습니다.
  • 발신자(from)는 ID·이름·앱 언어만 옵니다. 사용자명과 전화번호는 오지 않습니다(확인 창이 그렇게 약속합니다). 지워진 계정은 first_name: "Deleted Account".
  • 웹훅이면 다른 업데이트처럼 순서대로 POST 로 옵니다. getUpdates 의 대기열은 봇마다 최근 1,000개까지 메모리에 있고 서버를 다시 켜면 사라집니다.
  • 정지된 봇, 지워진 봇에게는 아무것도 가지 않습니다.
event언제더 붙는 필드
started발신자의 앱이 통화를 확인하고(키 교환 끝) 서버가 게이트웨이에 통화가 켜졌다고 알렸을 때. 서버는 소리를 보지 않으므로 서버가 아는 "켜짐"입니다. 애플 워치의 통화는 키 교환이 없으므로 게이트웨이가 워치의 소리가 연결됐다고 알렸을 때입니다max_duration
ended켜졌던 통화가 끝났을 때. 대화 기록을 기다리면 최대 10초 늦게 옵니다. 대화 남기기가 켜진 서버에서는 원음 모드가 아닌 모든 통화가 발신자의 선택과 상관없이 최대 10초 늦게 옵니다 — 봇 주인이 발신자의 선택을 알 수 없게 하기 위해서입니다start_date, duration(서버 시계로 켜져 있던 초), max_duration, end_reason, (대화 기록이 켜졌다면) transcript
missed켜지기 전에 끝난 통화(울리다 끊김 등)end_reason
refused발신자는 모든 확인을 통과했는데 봇 쪽 사정(동시 통화, 하루 시간, 게이트웨이) 때문에 받지 못했을 때만(그 밖의 거절은 봇 주인에게 알리지 않음)refused_reason: busy(동시 통화가 가득) · budget(봇·주인·서버의 하루 시간이 다 됨) · unavailable(받을 게이트웨이가 가득)

end_reason: caller(발신자가 끊음) · bot(봇이 끊음, endVoiceCall) · max_length(최대 길이) · failed(연결 실패) · operator(운영자·차단 스위치) · suspended(봇 정지·삭제).

게이트웨이에 넘겨진 통화마다 발신자와 봇의 대화방에 통화 기록(통화 시간 또는 "취소된 통화")이 남습니다(받기 전에 거절된 통화는 남지 않음). 봇 쪽 사본은 message 업데이트로 오지 않습니다 — 봇의 기록은 voice_call 입니다. 발신자가 대화 남기기를 고르면 대화 글이 발신자의 대화방에만 통화 기록 바로 아래에 남고, 봇 쪽에는 아무것도 남지 않습니다. 대화 남기기가 켜진 서버에서는 통화 기록 뒤의 메시지 번호 30개를 미리 잡아 두므로, 통화 기록 다음 메시지의 번호는 기록보다 31 이상 큽니다(남기지 않은 통화도 같음).

통화 중에

끊기 — endVoiceCall

curl -s "https://pabal.me/bot$BOT_TOKEN/endVoiceCall" -d voice_call_id=4611686018427387905
  • farewell(Boolean, 기본 true): 켜진 통화면 모델이 짧게 작별 인사를 하고 끊습니다. 10초 안에 끊기지 않으면 서버가 끝냅니다. false 면 바로 끊습니다. 원음 모드 통화는 늘 바로 끊습니다.
  • 끝난 통화의 voice_call.end_reason 은 bot 입니다. 이 봇의 살아 있는 통화가 아니면 400 VOICE_CALL_NOT_FOUND.

모델에게 메모 주기 — sendVoiceCallContext

통화 중에 모델이 알아야 할 것을 알려 줍니다. 모델은 이것을 발신자의 말이 아니라 봇 주인의 글로 읽고, 파발 규칙이 여전히 먼저입니다.

curl -s "https://pabal.me/bot$BOT_TOKEN/sendVoiceCallContext" -d voice_call_id=4611686018427387905 \
  --data-urlencode "text=이 발신자는 방금 A-1042 주문을 취소했어요. 물으면 그렇게 알려 주세요." -d respond=false
  • text: 2,000자(6,144바이트)까지, 내용 규칙 검사. respond=true 면 모델이 곧바로 반응하고, 기본 false 면 다음 차례에 씁니다.
  • 메모도 모델(관리형 통화면 Google)로 가고, 모델이 발신자에게 말할 수 있습니다 — 발신자에게 알려도 되는 것만 담으세요.
  • 연결되기 전이면 400 VOICE_CALL_NOT_ACTIVE, 원음 통화나 메모를 받지 않는 게이트웨이면 400 VOICE_CONTEXT_NOT_SUPPORTED, 통화마다 1분에 10개를 넘으면 429(retry_after 60).

사용량 — getVoiceCallUsage

curl -s "https://pabal.me/bot$BOT_TOKEN/getVoiceCallUsage?period=today"
{"ok": true, "result": {"period": "today", "from_date": 1790348400, "to_date": 1790434799,
 "calls": 3, "seconds": 412, "missed": 1,
 "refused": {"busy": 0, "budget": 0, "unavailable": 0},
 "platform_seconds": 412, "own_key_seconds": 0,
 "remaining_seconds_today": {"bot": 3188, "owner": 6788}}}
  • period: today(기본) 또는 month. month 이면 month=2026-09 처럼 달을 고를 수 있습니다(미래 달은 VOICE_USAGE_PERIOD_INVALID).
  • 하루와 달의 경계는 서버의 시간대(기본 Asia/Seoul)입니다. remaining_seconds_today 는 하루 한도에서 끝난 통화와 지금 진행 중인 통화가 잡아 둔 시간을 뺀 값입니다.
  • 통화마다의 사용 기록(발신자는 적지 않음)은 그 달이 끝난 뒤 180일 보관하고, 달마다 합계는 봇이 있는 동안 남습니다.

도구 — 통화 중에 봇의 코드를 부르기

발신자가 "주문 A-1042 어디쯤 왔어요?" 라고 물으면, 모델이 봇의 check_order 도구를 부르고, 서버가 그 질의를 봇에게 건네고, 봇의 답을 모델이 말로 전합니다. 도구는 봇 주인이 정의하고, 봇의 코드가 답합니다.

모델 · 음성 게이트웨이 파발 서버 봇의 코드 ① check_order {"order_id": "A-1042"} 인자 검사 · 선언 안 된 키 뺌 카드·주민번호면 여기서 멈춤 ② voice_call_tool_query · budget_ms 조용하면 "잠시만요, 확인해 볼게요" 5초 넘으면 "조금만 더 기다려 주세요" ③ answerVoiceCallToolQuery(result) 답 검사 · 비밀·카드번호 거절 16,384바이트까지 ④ tool_result (데이터로만) 모델이 결과를 말로 전함 늦으면: "도구가 제시간에 답하지 않았습니다"
도구 질의 하나: 모델 → 서버 → 봇 → 서버 → 모델

다이어그램 설명

  • 세 줄기: 왼쪽이 모델과 음성 게이트웨이, 가운데가 파발 서버, 오른쪽이 봇 주인의 코드입니다. 세로 점선이 시간입니다. 모델은 봇과 직접 말하지 않고, 모든 질의와 답이 서버를 지납니다.
  • ①의 회색 칸 — 서버가 먼저 인자를 검사합니다: 도구의 parameters 에 맞는지, 크기와 깊이, 선언하지 않은 키(빼고 넘김), 카드 번호나 주민등록번호처럼 보이는 값(봇에게 가지 않고 모델이 거절을 들음).
  • ②에서 봇이 받는 것은 질의 id, 통화 id, 발신자, 도구 이름, 인자(JSON 문자열), 그리고 budget_ms — 답이 서버에 닿아야 할 때까지 남은 밀리초입니다. 기다리는 동안 게이트웨이가 침묵을 메웁니다(왼쪽 칸).
  • ③의 답도 검사합니다: 비밀처럼 보이는 것, 규칙을 어기게 하는 말, 카드·주민등록번호는 거절되고 모델은 오류를 듣습니다. 첫 답이 마지막이라 거절된 답도 질의를 닫습니다.
  • ④ 파란 실선이 정상, 빨간 점선이 마감을 넘긴 경우입니다. 모델은 도구의 결과를 지시가 아니라 데이터로만 받고, 실패면 파발의 짧은 문구를 듣고 발신자에게 사정을 말합니다.

도구 정하기

curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -H 'Content-Type: application/json' -d '{
  "instructions": "주문도우미 가게의 전화 상담원이에요. 존댓말로 짧게 답하세요. 발신자가 주문 번호를 말하면 check_order 도구로 배송 상태를 찾아 알려 주세요.",
  "tools": [{
    "name": "check_order",
    "description": "주문 번호로 배송 상태를 찾는다. 발신자가 주문 번호를 말하면 부른다.",
    "parameters": {"type": "object",
                   "properties": {"order_id": {"type": "string", "description": "주문 번호, 예: A-1042"}},
                   "required": ["order_id"]},
    "timeout_ms": 5000
  }]
}'
항목규칙
키name, description, parameters(선택), timeout_ms(선택) 넷만
개수·크기봇의 도구와 고정된 MCP 도구를 합쳐 16개, 정의(name·description·parameters)의 UTF-8 합 8,192바이트. 자기 키 봇도 같습니다
name[A-Za-z_][A-Za-z0-9_-]{0,63}. __ 를 넣을 수 없고(MCP 도구 이름에 씀), pabal 로 시작할 수 없으며, 대소문자를 무시하고 겹칠 수 없습니다
description필수, 1,024바이트까지, 내용 규칙 검사. 모델이 언제 부를지 이것으로 정하므로 구체적으로
parameters맨 위는 {"type": "object"}. 빼면 "인자 없음"
timeout_ms1,000~15,000, 기본 5,000. 모델이 답을 기다릴 최대 시간

parameters 는 두 엔진이 모두 받는 JSON Schema 의 일부만 씁니다.

  • 키워드는 type, description, enum, properties, required, items, minimum, maximum, minItems, maxItems 열 개뿐. type 은 하나(string·number·integer·boolean·array·object).
  • properties·required 는 object 에, items·minItems·maxItems 는 array 에(items 필수), minimum·maximum 은 수에, enum 은 string 에만(1~32개, 각 64바이트).
  • 속성 이름은 [A-Za-z_][A-Za-z0-9_]{0,63}, 한 도구에 속성 24개, object 3단·object 와 array 합쳐 5단까지. 매개변수 설명은 512바이트.
  • 비밀을 묻는 매개변수는 거절합니다: 이름에 pin·cvv·password·otp·ssn·rrn·cardnumber 같은 낱말이 있거나, 설명이 카드 번호·비밀번호·인증 번호·주민등록번호를 말하면 asks_secrets.
  • 오류는 첫 문제 하나를 경로와 함께 알립니다: VOICE_TOOL_INVALID: tools[0].parameters.properties.tags: items_missing, VOICE_TOOL_INVALID: tools[0].name: reserved, VOICE_CONTENT_REFUSED: tools[0].description: impersonation.
  • 도구는 Bot API 로만 정합니다. @BotFather 의 음성 통화 화면은 도구 이름을 보여 주기만 합니다.
  • 허용한 봇만 받는 서버에서는 도구를 더하거나 바꾸면 운영자가 다시 허용할 때까지 전화를 받지 않습니다(awaiting_review, 켜기 4단계).

도구 질의 받기

{
  "update_id": 813,
  "voice_call_tool_query": {
    "id": "8190448532776044545",
    "voice_call_id": "4611686018427387905",
    "from": {"id": 100002, "is_bot": false, "first_name": "민지", "language_code": "ko"},
    "name": "check_order",
    "arguments": "{\"order_id\":\"A-1042\"}",
    "budget_ms": 4700
  }
}
  • arguments 는 JSON 객체의 텍스트입니다(4,096바이트·깊이 8 이하, 서버가 이미 검사). 그래도 발신자의 말로 모델이 만든 값이니 봇이 다시 확인하세요. 주문 번호만 알면 남의 주문을 들을 수 있으면 안 됩니다 — from.id(파발이 확인한 발신자)의 것만 알려 주세요.
  • budget_ms 는 봇이 받은 때부터 답이 서버에 닿아야 할 때까지의 밀리초입니다. 도구의 timeout_ms 에서 모델까지 돌아가는 길(200 ms)과 봇까지 오는 길(100 ms)을 뺀 값이라 보통 timeout_ms 보다 약 300 ms 적습니다. 서로의 시계는 쓰지 않습니다.

질의가 봇에 닿는 길은 이 순서로 정해집니다(getVoiceCallSettings.tool_delivery).

  1. 웹훅이 있으면 웹훅으로. 웹훅의 allowed_updates 에 voice_call_tool_query 가 있어야 하고, 없으면 다른 길로 가지 않고 닿지 않습니다(tool_delivery_problem: "not_in_allowed_updates"). 질의는 따로 보내는 POST라 순서 대기열에 막히지 않고, 다시 보내지 않습니다.
  2. 웹훅이 없고 최근 15초 안에 getUpdates 를 불렀으면, 다음 getUpdates 의 맨 앞에. 역시 allowed_updates 에 있어야 합니다. 한 봇에 기다리는 질의는 8개까지.
  3. 그 밖에 MTProto 로 붙어 있는 봇이면 updateBotWebhookJSONQuery 로(allowed_updates 와 상관없이).
  4. 어느 것도 아니면 닿지 않고(tool_delivery_problem: "not_listening"), 모델은 "도구에 연결할 수 없었습니다" 를 듣습니다.

답하기 — answerVoiceCallToolQuery

파라미터설명
tool_query_id업데이트의 voice_call_tool_query.id
result결과. 글, 또는 JSON 본문이면 객체·배열(글로 보내도 JSON 객체·배열로 읽히면 JSON 으로 다룸). UTF-8 16,384바이트까지(운영자가 더 낮출 수 있음)
error봇 자신의 실패 문구, 512바이트까지. 모델은 이 말을 발신자에게 전합니다
  • result 와 error 중 정확히 하나(둘 다 없으면 TOOL_RESULT_EMPTY, 둘 다면 TOOL_RESULT_INVALID). 돌려주는 것은 True.
  • 도구의 답은 모델(관리형 통화면 Google)로 가고, 모델이 그 내용을 발신자에게 말합니다 — 발신자에게 알려도 되는 것만 담으세요.
  • 첫 답이 마지막입니다. 늦거나 끝난 질의에는 400 TOOL_QUERY_EXPIRED: <왜> — answered(이미 답함), timeout, cancelled(발신자가 끼어들어 모델이 거둠), call_ended, unreachable. 모르는 id 나 10분 지난 id 는 TOOL_QUERY_ID_INVALID.
  • 답도 검사합니다: 보이지 않는 글자는 빼고, 비밀처럼 보이는 것(looks_like_secret), 규칙을 어기게 하는 말(overrides_rules·hides_ai·claims_human·asks_secrets), 카드·주민등록번호(sensitive_data)는 TOOL_RESULT_REFUSED: <코드>. 가게의 주소·전화번호·URL 같은 봇 주인의 데이터는 통과합니다. 크기를 넘으면 TOOL_RESULT_TOO_LONG·TOOL_ERROR_TOO_LONG.
  • 웹훅이면 응답 본문으로 답할 수 있습니다 — 도구 질의 POST 의 200 응답에 이것을 담으면 됩니다(다른 메서드는 실행하지 않음). 답하기 전에 X-Telegram-Bot-Api-Secret-Token 헤더를 꼭 확인하세요. 확인하지 않고 고객의 데이터를 응답에 담으면, 그 주소로 가짜 질의를 보내는 누구나 그 답을 받아 갑니다.
{"method": "answerVoiceCallToolQuery", "tool_query_id": "8190448532776044545",
 "result": {"order_id": "A-1042", "status": "배송 중", "eta": "내일 오후 도착"}}

예제 — python-telegram-bot

위의 check_order 도구에 답하는 봇입니다. getUpdates 로 받고, 질의를 다른 업데이트와 동시에 처리합니다(concurrent_updates) — 앞의 업데이트를 처리하느라 늦으면 budget_ms 가 지나 버립니다. 주문은 주문한 사람의 파발 사용자 ID 로 찾고, 질의의 from.id 와 맞을 때만 알려 줍니다.

# order_voice_bot.py — pip install "python-telegram-bot==22.*"
# 실행: read -rs BOT_TOKEN && export BOT_TOKEN
#       CUSTOMER_ID=<주문한 사람의 파발 사용자 ID> python3 order_voice_bot.py
# 시험할 때는 CUSTOMER_ID 에 내 ID 를 — 봇에게 메시지를 보낸 뒤 getUpdates 의 message.from.id 입니다
import json
import os
import re

from telegram import Update
from telegram.error import TelegramError
from telegram.ext import Application, ContextTypes, TypeHandler

SERVER = "https://pabal.me"
CUSTOMER = int(os.environ.get("CUSTOMER_ID", "0"))
ORDERS = {                                    # 예제의 작은 표: 실제로는 가게의 주문 시스템
    (CUSTOMER, "A-1042"): {"status": "배송 중", "eta": "내일 오후 도착"},
}


def check_order(arguments, caller_id):
    """answerVoiceCallToolQuery 에 그대로 넘길 {"result": …} 또는 {"error": …}"""
    order_id = str(json.loads(arguments).get("order_id", "")).strip().upper()
    if not re.fullmatch(r"[A-Z]-\d{4}", order_id):     # 인자는 모델이 만든 것 — 다시 확인
        return {"error": "주문 번호를 알아듣지 못했어요. 예: A-1042"}
    found = ORDERS.get((caller_id, order_id))          # 전화를 건 사람의 주문만
    if found is None:                                  # 남의 주문도 없는 주문과 같은 말로
        return {"error": f"{order_id} 주문을 찾지 못했어요."}
    return {"result": json.dumps({"order_id": order_id, **found}, ensure_ascii=False)}


async def on_update(update: Update, context: ContextTypes.DEFAULT_TYPE):
    q = update.api_kwargs.get("voice_call_tool_query")  # PTB 가 모르는 종류는 api_kwargs 에
    if q is None:
        return
    if q["name"] == "check_order":
        answer = check_order(q["arguments"], q["from"]["id"])
    else:
        answer = {"error": "모르는 도구예요."}
    try:
        await context.bot.do_api_request("answerVoiceCallToolQuery",
                                         api_kwargs={"tool_query_id": q["id"], **answer})
    except TelegramError as e:                         # TOOL_QUERY_EXPIRED: 늦었거나 통화가 끝남
        print("답이 받아들여지지 않았어요:", e, flush=True)


app = (Application.builder().token(os.environ["BOT_TOKEN"])
       .base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot")
       .concurrent_updates(True).build())
app.add_handler(TypeHandler(Update, on_update))
app.run_polling(allowed_updates=["message", "voice_call", "voice_call_tool_query"], poll_interval=0, timeout=10)

aiogram 은 모르는 업데이트 종류를 건너뛰므로, 디스패처의 바깥 미들웨어(dp.update.outer_middleware)에서 update.model_extra 의 voice_call_tool_query 를 먼저 받아야 합니다. 파발 저장소에 예제(scripts/bots/voice_tools_aiogram.py)가 있으니 필요하면 서버 운영자에게 요청하세요.

모델과 발신자가 듣는 것

  • 성공: 결과가 모델의 함수 응답이 됩니다(JSON 이면 JSON 값으로). 결과 속 글은 데이터로만 읽히고 지시가 되지 않습니다.
  • 실패: 모델은 통화 언어로 파발의 짧은 문구를 듣고 발신자에게 사정을 말합니다 — 마감 초과 "도구가 제시간에 답하지 않았습니다", 닿지 않음 "도구에 연결할 수 없었습니다", 카드 번호 같은 인자 "파발은 카드 번호나 주민등록번호 같은 정보를 전달하지 않습니다", 봇의 error 는 그 문구 그대로. 틀린 인자에는 어디가 틀렸는지 힌트가 가서 모델이 다시 부를 수 있습니다.
  • 기다리는 동안 게이트웨이가 침묵을 메웁니다: 1.5초 조용하면 "잠시만요, 확인해 볼게요" 를 한 번(최근 응답이 느린 도구면 곧바로), 5초가 지나면 "조금만 더 기다려 주세요" 를 한 번. 이 두 말은 발신자의 언어가 아니라 봇의 언어로 나오고, 그 언어로 검토를 마친 녹음이 없으면 영어로 나옵니다(지금 검토를 마친 것은 한국어와 영어).
  • 발신자가 말을 끊고 들어오면(끼어들기) 게이트웨이가 열린 질의를 거둘 수 있고, 그 뒤의 답은 TOOL_QUERY_EXPIRED: cancelled 입니다.

도구 한도

동시에 열린 질의는 통화마다 2개, 봇마다 8개. 부르는 횟수는 통화마다 1분에 30번, 봇마다 1분에 120번. 넘으면 봇에게 가지 않고 모델이 곧바로 too_many_open·rate_limited 를 듣습니다.

봇 주인 약관

대화 기록, 자기 모델 키, 원음 모드, MCP 서버, 봇이 거는 전화는 봇 주인 약관(판 4)에 동의해야 켤 수 있습니다. 동의는 @BotFather 의 음성 통화 화면(대화 기록·자기 키·원음 모드·MCP 서버·봇이 거는 전화 중 어느 화면에서든)에서 사람이 합니다 — 토큰으로는 할 수 없습니다. 이전 판에 동의했어도 다시 묻습니다. 서버의 이용약관에 봇 주인 절이 게시되기 전에는 동의 버튼이 나오지 않습니다(그 전에 부르면 403 VOICE_OWNER_TERMS_NOT_ACCEPTED).

BotFather봇 주인 약관을 확인해 주세요. 대화 기록, 자기 모델 키, 원음 모드, MCP 서버, 봇이 거는 전화에 필요해요. 1. 대화 기록을 켜면, 통화가 끝날 때 AI가 적은 대화 글을 내가 받아요. 받은 글은 내가 처리하고 지켜요. 2. 대화 기록이 켜진 봇에 전화하면, 통화가 연결될 때 안내 음성이 대화 글이 봇 주인에게 간다고 먼저 알려요. 3. 받은 대화 글은 필요한 만큼만 두고, 발신자가 지워 달라고 하면 지워요. 모델 학습에 쓰지 않아요. 4. 자기 모델 키를 쓰면, 통화 소리와 글이 내 키의 제공자에게 내 계약으로 가요. 그 처리의 책임은 나에게 있고, 내 개인정보 안내에 그렇게 적어요. 5. 원음 모드에서는 전화 소리가 파발의 AI 모델이 아니라 내 서버로 가요. 그 소리를 받아 처리하는 건 나예요. 전화를 건 사람의 소리와 말한 내용을 법이 허락하는 범위에서만 쓰고, 필요 없으면 지워요. 6. 내 서버가 어디에 있는지, 무엇을 하는지 전화를 건 사람이 물으면 알려요. 봇 소개에 적어 두면 좋아요. 7. 봇은 언제나 AI 라고 밝혀요. 대답은 AI 가 만들어요. 사람이 직접 대답하거나, 사람의 녹음을 AI 대답처럼 틀지 않아요. 원음 모드에서는 파발이 통화 맨 앞에 "AI 음성 봇입니다. 통화 소리는 봇 개발자의 서버로 갑니다." 안내를 틀어요. 8. 카드 번호·주민등록번호·비밀번호·인증 번호를 묻지 않아요. 음성 봇 규칙도 그대로 지켜요. 9. 파발은 대화 글을 저장하지 않아요. 전해지면, 늦어도 24시간 뒤에는 서버에서 지워져요. 키, 원음 모드와 MCP 서버의 비밀은 암호화해 두고 어디에도 기록하지 않아요. 10. MCP 서버를 등록하면, 통화 중 모델이 그 서버의 도구를 부를 때 발신자가 말한 값이 그 서버로 가요. 그 서버와 그 서버가 넘기는 곳의 처리 책임은 나에게 있고, 받는 곳은 사실대로 신고해요. 11. 약관을 어기면 운영자가 음성 통화나 봇을 멈출 수 있어요. 봇이 거는 전화를 켜려면 아래도 지켜요. 12. 봇이 거는 전화는 선언하고 운영자가 허락한 목적(알림·깨우기·배달·예약)에만 써요. 광고·판매 권유·홍보를 하지 않아요. 배달·예약 안내 안에서도 마찬가지예요. 광고성 정보를 보내면 정보통신망법 제50조를 어긴 것이고 그 책임은 나에게 있어요. 파발은 그런 발신을 곧바로 멈추고 발신을 해지해요. 진료·채용·대출처럼 사람의 권리나 의무에 관한 판단을 이 전화로 알리지 않아요. 13. 전화를 건 봇은 첫 말에서 봇 이름과 AI 라는 것을 밝히고, 사람·기관·파발 직원을 사칭하지 않아요. 14. context 에 건강·금융(금액·계좌)·주소 같은 민감한 정보를 넣지 않고, 비밀번호·인증 번호·카드 번호를 묻거나 권하지 않아요. 15. 만 19세 미만인 사람에게 전화하려 하지 않아요. 16. 조용한 시간·쉬는 중·하루 한도를 피하려 하지 않아요(여러 봇·계정으로 나눠 걸기 포함). 사람이 그만 받겠다고 하면 다시 걸지 않아요. 17. 내 개인정보 처리방침을 두고, 통화 내용이 AI 제공자에게 간다는 것을 알려요. 봇이 거는 전화는 종단 간 암호화가 아니에요. 18. 사람에게 보이는 허락 창에 봇 주인인 내 이름이 나와요. 19. 어기면 그 봇의 발신이 멈추고, 음성 기능 전체가 멈출 수 있어요. 20. 발신으로 받는 사람 정보(파발 id·허락했는지·받았는지·통화 시각과 길이·누가 끊었는지)는 선언한 목적에만 쓰고, 남에게 주지 않고, 허락이 꺼지거나 그 사람이 지워 달라고 하면 지워요. 받은 시각으로 사람의 일과나 있는 곳을 짐작하지 않아요. 21. 허락을 서비스의 조건으로 걸거나 속여서 받지 않아요(허락 링크 메시지 포함). 22. 전화마다 적는 목적은 그 전화의 실제 목적이에요. 깨우기 창을 다른 용도로 쓰지 않아요(서버는 목적을 봇이 적은 대로 믿어요). 23. 봇 토큰을 지키고, 샌 것을 알면 곧바로 @BotFather 에서 재발급해요. 토큰이 곧 사람에게 전화할 권한이에요. 24. 봇이 건 전화에는 대화 기록(1항)이 오지 않아요. 봇에게 가는 것은 20항의 것이에요.
약관에 동의하기

12–24항은 봇이 거는 전화를 켤 때 지킬 것입니다: 선언하고 운영자가 허락한 목적에만 쓰고 광고를 하지 않기, 첫 말에서 봇 이름과 AI 라는 것을 밝히기, context 에 민감한 정보를 넣지 않기, 만 19세 미만에게 걸지 않기, 조용한 시간·쉬는 중·한도를 피하지 않기, 받는 사람의 정보를 목적에만 쓰고 허락이 꺼지면 지우기, 토큰을 지키기. 봇이 건 전화에는 대화 기록이 오지 않습니다(24항). 약관 글은 법률 검토를 마친 뒤 바뀔 수 있습니다.

MCP 서버

MCP 서버는 기본으로 꺼져 있고, pabal.me 에서도 꺼져 있습니다

운영자가 MCP 부품(pabal-mcp, 서명기, 송신 프록시)을 갖추고, 개인정보처리방침의 bot-calls-mcp 절과 이용약관의 봇 주인 절을 게시해야 켜집니다(MCP 요청이 나갈 전용 송신 주소를 따로 두는 것은 운영 조건이고, 코드가 확인하지는 않습니다). 켜진 뒤에도 앱이 확인 창에 봇의 MCP 받는 곳을 보여 주기 전에는 등록만 되고 어떤 통화도 MCP 도구를 싣지 않습니다(mcp_unavailable_reason: "sheet"). MCP 가 꺼진 서버에서는 setVoiceCallMcpServer 가 403 VOICE_NOT_AVAILABLE: MCP tools are not available on this server 이고, @BotFather 에 "MCP 서버" 버튼이 없습니다.

봇 주인이 운영하는(또는 계약한) MCP 서버를 등록하면, 그 서버의 도구를 모델이 통화 중에 부를 수 있습니다. 부르는 것은 봇의 코드가 아니라 파발입니다 — 파발의 MCP 클라이언트가 파발의 송신 프록시를 거쳐 그 서버에 요청하고, 기한·크기·검사를 지킵니다. 그래서 발신자가 말한 값이 그 서버로 갑니다(봇 주인 약관 10번).

MCP 서버가 갖출 것

  • Streamable HTTP, POST(프로토콜 판 2024-11-05 이상). https:// 공인 인증서, 리다이렉트 없음, 포트는 서버가 허용하는 것만(pabal.me 는 443).
  • 자격 증명을 쓰는 서버는 자격 증명 없이는 정직하게 401 로 거절하고, 틀린 자격 증명을 받아들이지 않아야 합니다. 한 자격 증명은 한 봇만 쓸 수 있습니다.
  • 운영자 문서: 서버 운영자는 https://<호스트>/.well-known/pabal-voice-mcp 에 JSON 문서(32KB 이하, 리다이렉트 없음)를 둡니다 — 운영 주체·나라·연락처, 목적, 데이터 종류와 위치, 받는 곳과 재위탁, 보관 기간. 발신자의 확인 창에 보일 받는 곳 정보이고, 없으면 봇 주인이 등록할 때 신고합니다(아래). 자격 증명 없이 누구나 부를 수 있는 공개 서버(auth.type: "none")는 이 문서의 끝점 항목에 "accepts": "any" 를 적어 파발의 호출을 받겠다고 밝혀야 합니다.
  • 파발이 붙이는 것: tools/call 의 _meta 에 남은 기한 me.pabal/budget_ms(밀리초)와 결과 크기 한도 me.pabal/result_budget_bytes 가 붙고, 파발이 서명한 단언(me.pabal/assertion, JWT)이 붙을 수 있습니다. 단언의 발급자·sub(bot:<봇 id>)·검증 키(JWKS)는 getVoiceCallMcpBinding 이 알려 줍니다.
  • tools/list 는 2MB·20쪽 이하. 사람이 거쳐야 하는 단계(브라우저 로그인 등)가 없어야 합니다.
  • 도구마다 _meta["me.pabal/voice"] 로 분류를 줍니다 — data(none·caller·third_party), content(system·third_party_text), effect(none·write·notify·financial), max_ms(15,000 이하), max_result_bytes(65,536 이하), 선택 p95_ms. 서버가 주지 않으면 봇 주인이 등록할 때 tools 로 덧씌웁니다.
  • 규격 1.0 은 L1 만 줍니다: data: "none" 이고 effect: "none" 인 도구(공개된 정보를 읽기만 하는 도구)만 통화에 고정됩니다. 발신자의 데이터를 다루거나(L2) 무언가를 실행하는(L3) 도구는 기록만 하고 쓰지 않습니다.
  • 등록할 때 파발이 적합성 시험을 돌립니다(주소·TLS·판·401·도구 목록·도구 구조·자격 증명 범위·사람 단계). 떨어지면 MCP_CONFORMANCE_FAILED: CT-xx 로 거절됩니다.

등록하기

비밀은 요청 본문으로만 보냅니다(쿼리 문자열이면 400). 명령줄에 비밀이 남지 않게 파일에 두고 보내세요.

umask 077
cat > mcp-shop.json <<'EOF'
{"label": "shop",
 "url": "https://mcp.example.com/mcp",
 "auth": {"type": "bearer", "token": "(MCP 서버가 준 토큰)"}}
EOF
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallMcpServer" -H 'Content-Type: application/json' -d @mcp-shop.json
  • label: [a-z][a-z0-9]{0,11}, pabal 로 시작할 수 없음. 봇마다 서버 2개. 모델이 보는 도구 이름은 label__도구이름 입니다.
  • auth.type: none, bearer(token), headers(헤더 3개까지), client_credentials(token_url, client_id, client_secret, 또는 private_key_jwt: true — 먼저 createVoiceCallMcpClientKey). authorization_code 는 아직 없습니다. none(공개 서버)은 그 서버 운영자의 공개 문서가 파발의 호출을 받겠다고 밝혀야 등록됩니다(아니면 MCP_OPT_IN_REQUIRED).
  • 받는 곳이 바뀌는 변경(새 서버, 다른 주소·토큰 호스트, 새 신고, 더 높은 등급)은 요청의 답이 400 MCP_CHANGE_NEEDS_CONFIRMATION 이고, @BotFather 에 뜨는 확인 버튼을 봇 주인이 5분 안에 눌러야 적용됩니다. 토큰이 새어도 발신자의 말이 몰래 다른 곳으로 가지 않게 하려는 것입니다. 비밀만 바꾸는 회전은 곧바로 됩니다.
  • 서버 운영자의 공개 문서가 없으면 봇 주인이 declaration 으로 받는 곳(운영 주체·나라·연락처·목적·데이터 종류·위치·재위탁·보관 기간)을 사실대로 신고합니다. 발신자의 확인 창에 이것이 보이고, 바뀌면 발신자에게 다시 묻습니다.
  • 등록·검사는 봇마다 한 시간 10번, 주인마다 20번. 다른 메서드: refreshVoiceCallMcpServer(다시 검사), deleteVoiceCallMcpServer(언제나 됨), getVoiceCallMcpBinding(내 서버가 파발의 호출을 알아보는 데 쓸 발급자·sub·JWKS), createVoiceCallMcpClientKey.
  • 등록한 서버와 도구의 상태는 getVoiceCallSettings.mcp_servers 에 나옵니다(경로·쿼리·비밀은 나오지 않음). 규격 전문(Pabal Voice MCP Profile 1.0)은 파발 저장소의 docs/app/voice-bot-mcp-profile.html 에 있으니 필요하면 서버 운영자에게 요청하세요.

자기 모델 키

자기 키는 아직 어떤 운영 서버에서도 쓸 수 없습니다

자기 키로 받는 통화는 발신자가 그 키의 제공자로 소리가 간다는 확인 창(2판)을 봐야 하는데, 그 창을 보여 주는 앱이 아직 없습니다. 그래서 시험 서버가 아닌 서버는 운영자가 앱이 준비됐다고 켜기 전까지 키를 받지도 않습니다(403 VOICE_OWN_KEY_NOT_AVAILABLE). 서버에 마스터 키도 있어야 합니다.

봇의 통화를 봇 주인 자신의 모델 계정으로 돌립니다. 요금은 주인의 제공자 계정에 나가고, 자기 키 통화는 플랫폼의 봇·주인·서버 하루 한도를 쓰지 않습니다(발신자의 한도는 그대로). 봇 주인 약관이 먼저입니다.

curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallApiKey" -d provider=google \
  --data-urlencode "api_key@vertex-sa.json"
  • provider: google 은 Vertex AI 서비스 계정 JSON 키(필요한 필드만 보관), openai 는 sk- 로 시작하는 API 키. 그 밖의 값은 VOICE_PROVIDER_INVALID. 키는 요청 본문으로만(쿼리면 400) — 위처럼 파일에서 읽어 보내면 명령줄에 남지 않습니다. @BotFather 대화에 붙여 넣지 마세요.
  • 서버는 키를 받기 전에 게이트웨이의 시험 세션으로 실제로 열리는지 봅니다(최대 12초). 통과하면 보관하고 True. 제공자가 거절하면 API_KEY_INVALID, 로그인은 되지만 실시간 API 권한이 없으면 API_KEY_NO_REALTIME_ACCESS, 시험할 게이트웨이가 없거나 바쁘면 503 API_KEY_CHECK_UNAVAILABLE: …: the key was not kept.
  • 시험은 봇마다 한 시간 5번, 주인마다(모든 봇 합쳐) 10번. 게이트웨이에 닿은 시험만 셉니다.
  • 키는 어디에도 나오지 않습니다 — 답·오류·로그·화면 모두. getVoiceCallSettings.api_key 에는 제공자·정한 날·지문(8자리)·쓸 수 있는지만 있습니다.
  • 정해 둔 키를 쓸 수 없으면(약관이 바뀜, 서버가 키를 열 수 없음 등) 봇은 전화를 받지 않습니다 — 플랫폼 키로 몰래 바꾸지 않습니다. deleteVoiceCallApiKey(언제나 됨)로 키를 지우면 플랫폼 키로 받고, 그 키로 진행 중이던 통화는 끝납니다.
  • 자기 키 통화에는 파발의 하루 한도가 없습니다 — 한 통화 길이, 동시 통화, 발신자의 한도만 걸립니다. 요금이 내 계정에 나가니 제공자 콘솔에 예산 알림을 걸어 두세요.
  • OpenAI 키는 게이트웨이 운영자가 따로 켜야 쓰입니다(게이트웨이 기본은 Google 키만). 켠 게이트웨이가 없으면 OpenAI 키의 시험은 503 … no_gateway 입니다. OpenAI 키 경로는 실제 OpenAI 로는 아직 시험하지 않았고(가짜 서버로만), OpenAI 키로 받는 통화는 봇의 목소리 설정과 상관없이 게이트웨이가 정한 OpenAI 목소리(기본 marin)를 씁니다. Google 키는 게이트웨이의 Vertex 지역(us-central1)에서 쓰입니다.
  • 원음 모드에서는 키를 보관만 하고 쓰지 않습니다.

대화 기록

대화 기록은 운영자가 허용한 서버에서만 옵니다

운영자가 법률 검토를 마치고 관리 화면에서 허용해야 합니다(기본 꺼짐). 허용되지 않은 서버에서 스위치를 켜 두면 transcript_available: false, transcript_unavailable_reason: "not_allowed" 이고 대화 글은 오지 않습니다.

켜면 AI 가 적은 통화의 대화 글을 봇 주인이 받습니다. 봇 주인 약관에 동의한 뒤 스위치를 켭니다.

대화 기록과 대화 남기기는 다릅니다

대화 기록(이 절)은 봇 주인이 켜고 봇 주인이 받으며, 발신자는 연결될 때 안내 음성을 듣습니다. 대화 남기기는 전화를 거는 사람이 확인 창의 체크 상자로 고르고, 통화가 끝난 뒤 그 사람 자신의 대화방에만 글로 남습니다 — 봇과 봇 주인에게는 가지 않고, 안내 음성도 없습니다. 한 통화가 둘 다 가질 수 있습니다. 대화 남기기에는 봇 주인이 켤 스위치가 없고, 서버의 개인정보처리방침에 bot-calls-keep-text 절이 있으면 켜집니다(준비 중 — 앱 사용하기 — 음성 봇).

curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -d transcript=true
  • 봇이 건 전화에는 대화 기록이 오지 않습니다(봇이 거는 전화) — 스위치를 켜 두어도 그렇습니다.
  • 대화 기록이 켜진 봇에 전화하면 발신자는 AI 안내 바로 뒤에 "이 통화의 대화 내용은 글로 적혀 봇 주인에게 전해집니다." 를 듣습니다. 안내가 끝난 뒤의 말만 적습니다. 원음 모드 통화에는 대화 기록이 없습니다.
  • 끝난 통화의 voice_call(event: "ended")에 transcript 가 담겨 옵니다. 이것이 유일한 완전본이므로 allowed_updates 에 voice_call 을 넣어 두세요. 통화 중 한 마디씩 받고 싶으면 voice_call_transcript 도 넣습니다(HTTP Bot API 로만, MTProto 로는 오지 않음).
  • 카드 번호나 주민등록번호가 든 마디는 모든 숫자를 * 로 바꿔 전합니다. 두 마디에 걸쳐 말하거나 말로 읽은 숫자는 잡지 못하니, 받은 글도 조심해서 다루세요.
  • 한 마디 4,096바이트, 한 통화 256KiB·4,000마디까지. 넘으면 나머지는 버리고 truncated: true.
  • 파발은 대화 글을 저장하지 않습니다: 통화 동안 메모리에 있다가 봇에게 한 번 건네고, 가져가지 않은 것은 24시간 뒤 지웁니다. 서버를 다시 켜면 사라집니다. 받은 뒤의 보관과 처리는 봇 주인의 책임입니다.
"transcript": {
  "turns": [
    {"role": "bot", "text": "주문도우미입니다. 무엇을 도와드릴까요?", "offset_ms": 5200},
    {"role": "caller", "text": "주문 A-1042 어디쯤 왔어요?", "offset_ms": 9800}
  ],
  "truncated": false
}
{"update_id": 21, "voice_call_transcript": {"voice_call_id": "4611686018427387905", "seq": 2,
 "role": "caller", "text": "주문 A-1042 어디쯤 왔어요?", "is_final": true, "offset_ms": 9800, "date": 1790380813}}

role 은 caller(발신자) 또는 bot(모델), offset_ms 는 통화 소리가 시작된 때부터의 밀리초입니다.

원음 모드

원음 모드는 아직 쓸 수 없습니다

발신자의 앱이 "통화 소리가 봇 개발자의 서버로 간다"는 원음 확인 창을 보여 줘야 원음 봇에 전화할 수 있는데, 그 창이 든 앱 빌드가 아직 없습니다. 서버에도 마스터 키, 개인정보처리방침의 bot-calls-stream 절, 봇 주인 절, 원음을 받는 게이트웨이가 있어야 합니다. 없으면 mode=stream 은 403 VOICE_STREAM_NOT_AVAILABLE 입니다.

원음 모드에서는 전화의 소리가 파발의 AI 모델이 아니라 봇 주인의 서버로 갑니다. 내 서버가 소리를 듣고, 대답을 소리로 돌려보냅니다 — 어떤 AI 를 쓰든 내가 정합니다(다른 제공자의 실시간 모델, 내 음성 인식·합성 조합). 파발은 여전히 AI 안내 음성을 맨 앞에 틀고, 발신자의 확인·한도·통화 길이·동시 통화 한도를 그대로 지킵니다. 지시문이 필요 없고, 도구·자기 키·대화 기록은 보관만 하고 쓰지 않습니다(stream_ignores).

발신자 파발 (서버 · 게이트웨이) 봇 주인의 wss 서버 ① 원음 확인 창 · 전화 ② 업그레이드 + 서명 · pabal.voice.v1 ③ start {call_id, bot_id, format} ④ ready (3초 안) AI 안내 음성 · 이 동안 발신자 소리는 봇 서버로 가지 않음 ⑤ live 소리 (중계) ⑥ PCM16 24 kHz · 20 ms 조각 ⑦ clear · mark · hangup ⑧ stop {reason} → 닫기 1000
원음 모드: 모델 자리에 봇 주인의 서버

다이어그램 설명

  • 세 줄기: 왼쪽이 발신자, 가운데가 파발(서버와 음성 게이트웨이), 오른쪽이 봇 주인의 서버입니다. 봇 서버는 앱과도, 파발 서버와도 직접 말하지 않고 게이트웨이하고만 WebSocket 으로 이야기합니다.
  • ②~④는 전화가 울리는 동안 일어납니다: 게이트웨이가 확인한 공인 주소로 TLS 접속해 서명 헤더와 함께 업그레이드하고 start 를 보냅니다. 봇 서버가 3초 안에 ready 를 보내야 전화를 받고, 아니면 발신자는 통화 중 신호를 듣습니다.
  • 회색 칸이 관문입니다: AI 안내 음성("AI 음성 봇입니다. 통화 소리는 봇 개발자의 서버로 갑니다.")이 다 나오기 전에는 발신자 소리가 봇 서버로 가지 않고, 봇이 먼저 보낸 소리는 재생되지 않습니다. 끝나는 순간 ⑤ live 가 갑니다.
  • ⑥ 소리는 두 방향 모두 PCM16·24 kHz·모노이고, 발신자 쪽은 언제나 20 ms(960바이트) 조각입니다. 파란 화살표는 파발이 보내는 것, 회색은 봇 서버가 보내는 것입니다.
  • ⑦과 ⑧: 봇은 끼어들기(clear), 재생 위치 알림(mark), 끊기(hangup)를 보낼 수 있고, 통화가 끝나면 파발이 이유를 담은 stop 을 보내고 1000 으로 닫습니다.

설정과 사전 시험

먼저 아래 예제 서버를 공인 주소(인증서가 맞는 이름)에 띄워 두세요. 사전 시험은 설정하는 순간 그 서버에 접속하고, 닿지 못한 실패도 한 시간 5번 한도에 셉니다.

봇 주인 약관에 동의한 뒤(@BotFather → 음성 통화 → 원음 모드 → 봇 주인 약관에 동의), 서버 주소와 비밀을 알립니다. 비밀은 32~128자의 출력 가능한 ASCII(공백 없음)이고, 새 주소에는 늘 새 비밀과 함께 보냅니다.

umask 077
openssl rand -hex 32 | tr -d '\n' > stream-secret.txt      # 끝에 줄바꿈이 없어야 합니다
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -d mode=stream \
  -d stream_url=wss://bot.example.com:8443/voice --data-urlencode "stream_secret@stream-secret.txt"
  1. 주소 형식: wss:// 만, 사용자 이름·#·공백 없이, ASCII 만(다른 글자는 퍼센트 인코딩), 2,048자 이하. 호스트는 점이 있는 이름이거나 공인 IP(localhost 는 안 됨), 포트는 443 또는 1024~65535. 아니면 400 STREAM_URL_INVALID: ….
  2. 사전 시험 한도는 요청하는 순간 셉니다: 봇마다 한 시간 5번, 주인마다 10번, 동시에 봇마다 1개·주인마다 2개. 넘으면 429. 게이트웨이가 바빠 아무 데도 접속하지 않은 사전 시험은 세지 않습니다.
  3. 주소 확인: 이름이 사설·예약 주소나 파발 자신의 주소를 하나라도 가리키면 400 STREAM_URL_INVALID: the host must resolve only to public addresses.
  4. 사전 시험: 게이트웨이가 한 번 접속해 봅니다(start 에 "probe": true). ready 가 오면 켜지고, 비밀은 서버의 마스터 키 아래 암호화해 둡니다. 실패하면 400 STREAM_PROBE_FAILED: <코드> — unreachable(이름·연결·시간·TLS 중 하나), handshake_failed(101 이 아닌 답 — 대개 서명을 거절함), redirect, no_subprotocol, no_ready, bad_ready, address_refused, busy, url 등 — 그 밖의 코드는 게이트웨이가 준 그대로 옵니다. 서버는 12초까지 기다립니다.
  5. Bot API(토큰)로 원음 모드가 켜지거나 주소가 바뀌면 @BotFather 가 봇 주인에게 "원음 주소가 … 로 바뀌었어요 (비밀 …)" 와 관리형으로 돌아가기 버튼을, 관리형으로 돌아가면 그 사실과 음성 통화 버튼을 보냅니다. 토큰이 새어 소리가 몰래 다른 서버로 가는 것을 막으려는 것입니다.
  • stream_secret 을 쿼리 문자열에 넣으면 다른 무엇보다 먼저 400 입니다. 웹훅 응답으로는 원음 모드를 설정할 수 없습니다.
  • 관리형으로 돌아가려면 mode=managed(주소와 비밀을 지움). 켜진 채 지시문이 비어 있으면 VOICE_INSTRUCTIONS_REQUIRED 이니 지시문을 함께 보내세요.
  • 운영자가 허용한 봇만 받는 서버에서는 운영자가 원음 주소를 확인해 다시 허용해야 전화를 받습니다.
  • getVoiceCallSettings 에는 stream_url 과 비밀의 지문만 나옵니다. 원음 통화에는 sendVoiceCallContext 가 안 되고(VOICE_CONTEXT_NOT_SUPPORTED), endVoiceCall 은 작별 없이 곧바로 끊습니다.

규격

  • TLS 1.2 이상, 인증서는 URL 의 이름에 맞는 공인 인증서. 게이트웨이는 확인한 IP 로 붙되 SNI 와 인증서 검사는 이름으로 합니다. 리다이렉트(3xx)는 따르지 않습니다.
  • 서브프로토콜 pabal.voice.v1 을 골라야 합니다(Sec-WebSocket-Protocol). 압축 없음. 한 메시지 최대 65,536바이트, 글 메시지 최대 4,096바이트(UTF-8).
  • 게이트웨이는 15초마다 ping 을 보냅니다. 15초 안에 pong 이 없으면 끊습니다(대부분의 라이브러리가 알아서 답합니다).
  • 글 메시지는 JSON 이벤트, 바이너리 메시지는 소리입니다. 소리 형식은 하나: PCM16 little-endian, 모노, 24,000 Hz. call_id·bot_id 는 10진수 문자열입니다.
방향메시지언제 · 뜻
파발 → 봇{"event":"start","protocol":1,"call_id":"…","bot_id":"…","probe":false,"caller":{"lang":"ko"},"format":{"encoding":"pcm_s16le","sample_rate":24000,"channels":1,"frame_ms":20},"max_seconds":300}업그레이드 직후 한 번. 발신자의 이름·번호·ID 는 없습니다(caller.lang 뿐 — 사람 정보는 voice_call 업데이트로)
파발 → 봇{"event":"live"}AI 안내가 끝났음. 이때부터 소리가 오갑니다
파발 → 봇바이너리발신자 소리, 언제나 960바이트(20 ms). live 뒤에만
파발 → 봇{"event":"mark","name":"…"}봇이 보낸 그 mark 앞의 소리가 다 재생됨
파발 → 봇{"event":"cleared","played_ms":N,"dropped_ms":N,"marks":["…"]}clear 의 답
파발 → 봇{"event":"buffer_full","dropped_ms":N}봇 소리가 30초 넘게 쌓여 넘친 것을 버림
파발 → 봇{"event":"stop","reason":"…","played_ms":N,"duration_ms":N}통화가 끝남. 곧 1000 으로 닫습니다
봇 → 파발{"event":"ready"}start 뒤 3초 안, 맨 먼저
봇 → 파발바이너리봇 소리: 짝수 바이트, 2~65,536바이트. live 전의 것은 버려집니다
봇 → 파발{"event":"clear"}끼어들기: 줄 서 있는 봇 소리를 지금 멈춤
봇 → 파발{"event":"mark","name":"[A-Za-z0-9_.:-]{1,64}"}"여기까지 재생되면 알려 줘". 통화마다 256개까지 대기
봇 → 파발{"event":"hangup"}통화를 끝냄. 줄 선 소리를 최대 5초 먼저 틀고 끊습니다
  • 모르는 event 는 무시됩니다. JSON 이 아닌 글, 4KiB 가 넘는 글, 홀수 바이트나 64KiB 가 넘는 소리는 규격 위반으로 통화를 끝냅니다(발신자는 사과 안내를 듣습니다).
  • 보내는 양의 상한: 글 이벤트는 평균 초당 50개(순간 100개), 소리는 평균 실시간의 4배(순간 60초 분량). 봇 소켓이 읽지 않아 발신자 소리가 30초(1,500개) 넘게 쌓여도 통화를 끝냅니다.

서명

게이트웨이는 업그레이드 요청에 이 헤더를 붙입니다.

X-Pabal-Voice-Signature: t=<유닉스 초>,nonce=<16바이트 hex>,call=<call_id>,v1=<hex>
v1 = HMAC-SHA256(stream_secret, "pabal-voice-stream/1\n<t>\n<nonce>\n<call>\n<host>\n<path?query>")
  • host 는 내가 파발에 알린 URL 의 호스트(소문자, 포트 없이)입니다. 요청의 Host 헤더로 계산하면 안 됩니다 — 그 값은 보내는 쪽이 쓰는 것이라 호스트에 묶는 효과가 없습니다. path?query 는 URL 의 경로와 쿼리 그대로(없으면 /). 사전 시험에서는 call 이 사전 시험 번호입니다.
  • 봇 서버는 반드시 확인합니다: |지금 − t| ≤ 300초, 같은 nonce 는 한 번만, HMAC 은 시간이 일정한 함수로 바이트끼리 비교. 틀리면 401 처럼 101 이 아닌 답을 줍니다.
  • 그리고 start.call_id 가 서명의 call 과 같은지 봅니다. 다르면 1008 로 닫습니다 — 서명된 업그레이드를 다른 통화에 다시 쓰지 못하게. 한 봇만 섬기는 서버라면 start.bot_id 가 내 봇인지도 봅니다.
  • 시험용 값: 비밀 example-stream-secret-of-the-bot-developer-0001, t=1790000000, nonce 00112233445566778899aabbccddeeff, call 4611686018427387905, host bot.example.com, 경로 /voice?bot=100042 → v1=807517ff96ac75223bde35b48c775f49a2eda2fb4832a02c0ea832b914d70d0c.

소리 · 끼어들기 · mark · 끝내기

  • 봇 → 발신자 버퍼: 게이트웨이가 재생 속도로 내보내므로 한꺼번에 보내도 되지만 30초 넘게 쌓이면 넘친 것은 버리고 buffer_full 을 보냅니다. 봇 소리가 300 ms 끊기면 한 "덩어리"가 끝난 것으로 봅니다(mark·clear 의 단위).
  • clear: 아직 안 나간 봇 소리를 모두 버리고 다음 10 ms 부터 조용해집니다(측정 50 ms 미만). 답 cleared 의 played_ms 는 이 통화에서 지금까지 실제로 재생된 봇 소리의 합, dropped_ms 는 이번에 버린 양, marks 는 버려진 덩어리에 걸린 mark 이름들입니다 — 모델에게 "어디서 끊겼나"를 알릴 때 씁니다.
  • mark: 그 앞의 소리가 재생되면 같은 이름으로 돌아옵니다. mark 앞의 소리는 480바이트(10 ms)의 배수로 끝내는 것이 좋습니다(남는 조각은 무음으로 채워집니다).
  • 끝내기: 봇은 hangup 을 보내거나 1000·1001 로 깨끗이 닫습니다. 그 밖의 닫힘이나 TCP 끊김은 규격 위반과 같이 끝납니다(발신자에게 사과 안내). 파발이 끝낼 때의 stop.reason 은 짧은 코드입니다 — hangup(발신자가 끊음), max_length, bot_ended, disconnect, drain, kill_switch, operator, link_lost, media_failed … 모르는 값은 "끝났다"로 다루세요.
  • 사전 시험의 start 는 "probe": true, 빈 caller, max_seconds: 0 입니다. ready 를 보내면 {"event":"stop","reason":"probe"} 가 오고 닫힙니다. 사전 시험에서 모델 세션처럼 비용이 드는 것을 열지 마세요.

예제 — 가장 작은 원음 서버

AI 없이 규격 전체를 확인하는 서버입니다: live 에 알림음을 틀고, 그 뒤로 발신자의 소리를 그대로 돌려줍니다(에코). 서명·시각·nonce·통화·봇을 모두 확인합니다. 먼저 이것으로 통화가 끝까지 되는 것을 본 뒤, 에코 자리에 내 AI 를 넣으세요. 파발 저장소에는 scripts/bots/stream_echo_bot.py(끼어들기 시연 포함)와 scripts/bots/stream_openai_relay.mjs(내 OpenAI 키로 Realtime 에 중계, 실행해 보지 않은 예제)도 있으니 필요하면 서버 운영자에게 요청하세요.

# stream_bot.py — pip install "websockets>=13"
# 실행: STREAM_SECRET_FILE=stream-secret.txt TLS_CERT=fullchain.pem TLS_KEY=privkey.pem \
#       PUBLIC_HOST=bot.example.com BOT_ID=<봇 ID — 토큰의 : 앞 숫자> python3 stream_bot.py
import asyncio
import hashlib
import hmac
import json
import math
import os
import ssl
import struct
import time

from websockets.asyncio.server import serve

SECRET = open(os.environ["STREAM_SECRET_FILE"], "rb").read().strip()
PUBLIC_HOST = os.environ["PUBLIC_HOST"].lower()   # 파발에 알린 stream_url 의 호스트 (Host 헤더가 아님)
BOT_ID = os.environ.get("BOT_ID", "")             # 비우면 어느 봇의 통화든 받음
PATH = os.environ.get("STREAM_PATH", "/voice")
PORT = int(os.environ.get("PORT", "8443"))
CHIME = b"".join(struct.pack("<h", int(8000 * math.sin(2 * math.pi * 880 * i / 24000)))
                 for i in range(24000 * 6 // 10))  # 880 Hz 0.6초, PCM16 24 kHz
seen = {}                                         # 한 번 쓴 nonce


def signed_call(header, target):
    """X-Pabal-Voice-Signature 를 확인하고 서명된 call id 를 돌려준다. 틀리면 None."""
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, nonce, call, v1 = int(parts["t"]), parts["nonce"], parts["call"], parts["v1"]
    except (KeyError, ValueError):
        return None
    now = time.time()
    if abs(now - t) > 300 or nonce in seen:
        return None
    text = f"pabal-voice-stream/1\n{t}\n{nonce}\n{call}\n{PUBLIC_HOST}\n{target}"
    expected = hmac.new(SECRET, text.encode("utf-8", "surrogateescape"), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), v1.encode("utf-8", "surrogateescape")):  # 바이트끼리
        return None
    for old in [n for n, at in seen.items() if at < now - 600]:
        del seen[old]
    seen[nonce] = now
    return call


def check(connection, request):
    if request.path.split("?", 1)[0] != PATH:
        return connection.respond(404, "not here\n")
    call = signed_call(request.headers.get("X-Pabal-Voice-Signature", ""), request.path)
    if call is None:
        return connection.respond(401, "bad signature\n")
    connection.signed_call = call                 # start 가 이 통화를 말해야 한다
    return None


async def handle(ws):
    if ws.subprotocol != "pabal.voice.v1":
        await ws.close(1002, "pabal.voice.v1 only")
        return
    start = json.loads(await asyncio.wait_for(ws.recv(), 5))
    if (start.get("event") != "start" or start.get("call_id") != ws.signed_call
            or (BOT_ID and start.get("bot_id") != BOT_ID)):
        await ws.close(1008, "not this call")
        return
    await ws.send(json.dumps({"event": "ready"}))    # 3초 안에, 맨 먼저
    live = False
    async for message in ws:
        if isinstance(message, bytes):
            if live:
                await ws.send(message)            # 발신자의 20 ms 를 그대로 돌려줌 (에코)
            continue
        event = json.loads(message)
        if event.get("event") == "live" and not start.get("probe"):   # AI 안내가 끝났다
            live = True
            await ws.send(CHIME)
            await ws.send(json.dumps({"event": "mark", "name": "chime"}))
        elif event.get("event") == "stop":
            print("끝:", start["call_id"], event.get("reason"), flush=True)
            break


async def main():
    tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    tls.minimum_version = ssl.TLSVersion.TLSv1_2
    tls.load_cert_chain(os.environ["TLS_CERT"], os.environ["TLS_KEY"])
    async with serve(handle, "0.0.0.0", PORT, ssl=tls, subprotocols=["pabal.voice.v1"],
                     process_request=check, max_size=65536, compression=None):
        print(f"듣는 중: wss://{PUBLIC_HOST}:{PORT}{PATH}", flush=True)
        await asyncio.get_running_loop().create_future()


asyncio.run(main())

발신자가 보는 것

  • 통화 버튼: 음성 봇의 대화방 머리와 프로필에 전화 버튼이 생깁니다. 서버가 그 사람·그 기기에 대해 통화를 받을 수 있다고 판단할 때만 보입니다(공개 전에는 시험자에게만). 영상 통화는 없습니다.
  • 확인 창(마이크 권한보다 먼저): 제목 "이 통화는 AI가 받습니다", 봇은 사람이 아니며 목소리도 AI 가 만든 것, 종단 간 암호화가 아님, 소리를 받는 곳(Google, Vertex AI 의 Gemini Live API)과 나라·지역(미국, 아이오와), 보내는 것(통화 중의 목소리, 대화 글과 AI 의 대답, 봇이 통화 중에 넣은 메모와 봇 기능의 결과), 보관(파발은 소리를 저장하지 않고 통화 기록만 180일, Google 은 대답을 만든 뒤 보관하지 않지만 자동 검사에서 남용이 의심된 내용은 최대 90일 기록될 수 있음), 봇 주인이 받는 것(이름·계정 번호·언어, 통화 시각·길이, 도구에 쓰인 값 — 전화번호와 소리는 아님), 만 19세 이상. 버튼은 "만 19세 이상입니다 · 통화" 와 취소. 나이는 본인 확인이 아니라 스스로 확인하는 것입니다. 통화 버튼이 주 버튼(채운 강조색)이고 취소가 보조 버튼입니다. 대화 남기기가 켜진 서버에서는 체크 상자 "이 통화의 대화를 내 대화방에 글로 남기기"(기본 꺼짐)가 설명 뒤에 붙습니다.
  • 얼마나 자주: 데스크톱·안드로이드는 그 봇에 처음 걸 때(받는 곳이나 창의 판이 바뀌면 다시), 아이폰과 애플 워치는 매번 묻습니다. 워치는 매번 창 전체를 보이고, 대화 남기기 스위치의 처음 값은 그 봇에 건 마지막 통화의 선택입니다. 앱은 통화마다 동의를 서버에 보내고, 서버는 같은 기기·같은 봇·2분 안의 동의 하나를 그 통화에 한 번만 씁니다.
  • 연결되면: AI 안내 음성 "AI 음성 봇과 연결되었습니다."(원음 봇이면 "AI 음성 봇입니다. 통화 소리는 봇 개발자의 서버로 갑니다.") → (대화 기록 봇이면) 대화 기록 안내 → 봇의 인사말. 안내는 봇 주인이 끌 수 없고, 안내가 끝나기 전의 발신자 말은 모델로 가지 않습니다. 대화 남기기에는 안내 음성이 없습니다 — 발신자가 화면에서 직접 골랐기 때문입니다.
  • 안내의 언어: 안내 음성은 봇의 language 가 아니라 발신자가 읽은 확인 창의 언어로 나옵니다. 한 통화의 안내는 모두 한 언어입니다 — AI 안내, 대화 기록 안내, 연결이 끊길 때의 사과(원음 봇이면 원음 안내와 사과). 그중 하나라도 그 언어로 검토를 마친 녹음이 없으면 모두 영어로 나옵니다. 지금 검토를 마친 언어는 한국어와 영어뿐입니다. 나머지 8개 언어(일본어·중국어·프랑스어·스페인어·인도네시아어·베트남어·크메르어·아랍어)의 확인 창과 안내는 법률 검토와 원어민 확인을 기다리는 초안이라, 그 언어의 앱을 쓰는 발신자도 지금은 영어 확인 창을 보고 영어 안내를 듣습니다. 크메르어 녹음과 일본어의 "조금만 더 기다려 주세요" 는 아직 사람이 들어 보고 고르기를 기다립니다. 통화의 첫 안내와 대화 기록 안내 앞에는 언제나 300ms 이상의 무음이 있습니다 — 전화 경로가 첫 낱말을 삼키지 않도록.
  • 통화 화면: 이름 아래 "@봇 · AI · 봇", "AI가 만든 목소리 · 통화 소리는 Google이 처리합니다". 이모지 키와 암호화 문구가 없습니다.
  • 연결에 문제가 생기면 "죄송합니다. 연결에 문제가 생겨 통화를 마칩니다." 를 듣고 끊깁니다. 거절은 모두 통화 중 신호로 끝납니다.

앱 사용자를 위한 설명은 앱 사용하기 — 음성 봇에 전화하기에 있습니다.

봇이 거는 전화

pabal.me 에서는 꺼져 있습니다 — 법률 검토 대기

서버에는 만들어져 있지만, 개인정보처리방침의 bot-calls-outbound 절과 이용약관의 voice-bot-owners-outbound 절이 법률 검토를 마치고 게시되기 전에는 어느 봇도 사람에게 전화를 걸 수 없습니다. 허락 창이 든 앱 빌드도 아직 나오지 않았습니다(지금 상태). 아래는 이 기능이 켜진 서버에서의 동작입니다.

음성 봇은 사람에게 먼저 전화를 걸 수도 있습니다. 예약을 확인하고, 정해 둔 때에 깨우고, 배달을 알리는 전화입니다. 봇 주인의 코드가 startVoiceCall 로 요청하면 서버가 판정해 사람의 기기를 울리고, 사람이 받으면 파발의 안내 음성 뒤에 AI 음성 모델이 봇의 지시문대로 말합니다. 어떻게 끝났는지는 voice_call 업데이트로 옵니다.

  • 허락한 사람에게만 겁니다. 사람이 파발 앱에서 그 봇에게 전화를 허락해야 하고(받는 사람의 허락), 허락은 언제든 끌 수 있습니다.
  • 광고 전화가 아닙니다. 목적은 알림·깨우기·배달·예약 넷이고, 광고·판매 권유·홍보는 할 수 없습니다(봇 주인 약관 12–24항).
  • 봇이 거는 전화는 종단 간 암호화가 아닙니다 — 소리가 파발 서버와 AI 제공자를 거칩니다. 사람이 받으면 통화 중에 소리가 Google 로 갑니다 — Vertex AI 의 Gemini Live API, 미국 아이오와(Google Cloud us-central1). 파발은 소리를 저장하지 않습니다.
  • 봇 주인에게 대화 글은 가지 않습니다. 봇이 건 전화에는 대화 기록이 없습니다. 봇 주인이 받는 것은 받는 사람이 보는 것에 적은 다섯 가지입니다.

흐름

봇의 코드 파발 서버 게이트웨이 · 모델 사람의 앱 ① startVoiceCall 판정: 봇 · 허락 · 한도 조용한 시간 · 쉬는 중 ② 200 {voice_call_id} ③ dial ④ 벨: 이름 (AI) ⑤ 받음 ⑥ 파발 안내 음성 ⑦ 봇: 이름 · AI · 목적 통화 — 소리는 게이트웨이와 AI 모델을 거침 ⑧ voice_call: started · ended 받지 않음 · 울리지 않음: not_answered
봇이 거는 전화 하나: 요청 → 판정 → 벨 → 받음 → 안내 → 첫 말 → 끝 알림

다이어그램 설명

  • 네 줄기: 왼쪽부터 봇 주인의 코드, 파발 서버, 음성 게이트웨이와 AI 모델, 받는 사람의 앱입니다. 세로 점선이 시간이고, 파란 실선이 일이 진행되는 길, 회색 선이 봇에게 가는 답입니다.
  • ①의 회색 칸 — 판정은 세 번입니다: 답하기 전에 봇 쪽(발신을 켰는지, 운영자 검토, 약관, 한도)과 허락이 있는지를, 200 뒤에 사람 쪽(조용한 시간, 쉬는 중, 사람의 하루 한도와 간격, 울릴 기기)을, 울리기 바로 전에 한 번 더 봅니다.
  • ② 200 은 "받아들였다"일 뿐입니다. 울렸는지, 받았는지는 ⑧의 업데이트로만 알 수 있습니다.
  • ④–⑦ 사람 쪽: 벨에는 봇 이름 뒤에 "(AI)" 가 붙고, 받으면 파발의 안내 음성(⑥)이 봇보다 먼저 나옵니다. 봇은 첫 말(⑦)에서 이름, AI 라는 것, 전화의 목적을 밝힙니다. 초록 칸의 동안 소리는 게이트웨이와 AI 모델을 거칩니다 — 종단 간 암호화가 아닙니다.
  • 빨간 점선이 핵심입니다: 사람 쪽 판정에 걸려 울리지 않은 요청과, 울렸는데 받지 않은 요청이 같은 때에 같은 not_answered 로 끝납니다. 봇은 사람이 그때 자고 있었는지, 통화 중이었는지 알 수 없습니다.

켜기 — @BotFather

봇이 거는 전화는 봇 주인이 @BotFather 에서만 켭니다. 토큰으로는 켤 수 없습니다(setVoiceCallSettings 에 발신 칸을 보내면 400 VOICE_FIELD_NOT_AVAILABLE). 먼저 음성 통화가 켜져 있어야 합니다. /mybots → 봇 → 음성 통화 → 봇이 거는 전화 → 켜기.

켜기
BotFather@order_helper_bot 이 사람에게 전화를 걸 수 있게 할까요? • 이 봇에게 전화를 허락한 사람에게만 걸어요. 허락은 그 사람이 파발 앱에서 하고, 언제든 끌 수 있어요. • 그 사람의 조용한 시간과 쉬는 중에는 울리지 않아요. • 하루 상한(지금 50번)과 서버의 한도가 있어요. • 운영자가 이 봇의 이름·사진·지시문·목소리·목적을 검토하고 허락해야 걸려요. 무엇이든 바뀌면 다시 검토해요. • 봇이 거는 전화는 종단 간 암호화가 아니에요. 통화 소리는 파발 서버와 AI 제공자를 거쳐요.
켜기
BotFather봇이 거는 전화를 켰어요. 운영자가 이 봇을 검토하고 허락하면 Bot API 의 startVoiceCall 로 걸 수 있어요. 봇이 거는 전화 · @order_helper_bot 상태: 켜짐 운영자 허락: 대기 목적: 배달 하루 상한: 50번 오늘: 요청 0 · 받음 0 · 안 받음 0 이 봇에게 전화를 허락한 사람에게만, 그 사람의 조용한 시간과 쉬는 중을 피해 걸어요. 봇이 거는 전화는 종단 간 암호화가 아니에요 — 통화 소리는 파발 서버와 AI 제공자를 거쳐요.
  • 약관 4판: 아직 동의하지 않았으면 켜기 전에 봇 주인 약관이 나옵니다. 12–24항이 봇이 거는 전화의 약속입니다.
  • 목적: reminder(알림) · wake_up(깨우기) · delivery(배달) · appointment(예약) 가운데 하나 이상을 골라야 켜집니다. 요청마다 이 가운데 하나를 적고, 고르지 않은 목적은 400 입니다. 목적을 바꾸면 허락한 사람에게 다시 묻고 운영자가 다시 검토합니다.
  • 하루 상한: 이 봇이 하루에 보낼 수 있는 요청 수. 10 · 20 · 50번 가운데 고릅니다(기본 50).
  • 운영자 검토: 운영자가 관리 화면에서 봇의 이름·@사용자명·사진·지시문·인사말·도구·목소리·언어·모드·목적·주인 이름을 보고 허락해야 걸 수 있습니다. 그중 하나라도 바뀌면 다시 허락받을 때까지 걸리지 않습니다(403 VOICE_OUTBOUND_OFF: review).
  • 토큰: 토큰으로 음성 설정을 바꾸면(음성 끄기는 빼고) 발신이 24시간 멈춥니다(frozen). 내가 바꾼 것이면 @BotFather 의 내가 바꿨어요(vdk·vdK)로 풉니다. 토큰을 다시 발급해도 24시간 멈추고, 진행 중인 발신이 끝나며, 발신이 켜진 봇의 웹훅이 지워집니다.
  • 봇 언어는 한국어·영어(ko-KR·en-US)만 걸 수 있습니다. 원음 모드·자기 키·도구·MCP 서버를 쓰는 봇은 걸 수 없습니다(mode).

받는 사람의 허락

봇은 허락을 직접 받을 수 없습니다. 사람이 파발 앱에서 그 봇의 허락 창을 열고 "만 19세 이상입니다 · 허락" 을 눌러야 합니다. 창은 이 전화를 AI 가 건다는 것, 목적, 봇 주인의 이름, 소리가 가는 곳, 조용한 시간을 보여 줍니다(앱 사용하기 — 봇이 나에게 전화하도록 허락하기). 창을 여는 곳은 셋입니다.

  • 봇 메뉴: 봇 대화방이나 프로필의 "전화 허락…".
  • 1:1 대화 안의 링크: 봇이 그 사람과의 1:1 대화에 https://pabal.me/<봇 사용자명>?callme=1 을 본문 링크나 인라인 URL 단추로 보냅니다. 그 봇이 그 대화에서 보낸 메시지일 때만 창이 열립니다 — 다른 대화로 전달된 링크, 미리보기, 웹에서는 대화방만 열립니다. 링크로 창을 여는 것은 사람마다 하루 한 번(모든 봇 합쳐)이고, 봇과 대화한 적이 없으면 열리지 않습니다.
  • 설정 목록 "전화를 허락한 봇": 허락한 봇을 보고, 끄고, 다시 허락합니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" -d chat_id=$USER_ID \
  --data-urlencode "text=주문한 물건이 도착하기 전에 전화로 알려 드릴까요?" \
  --data-urlencode 'reply_markup={"inline_keyboard": [[{"text": "전화 허락", "url": "https://pabal.me/order_helper_bot?callme=1"}]]}'
  • 자체 호스팅 서버: 앱은 pabal.me 의 링크만 압니다. 다른 서버라면 앱 빌드의 링크 규칙에 그 서버의 호스트를 넣어야 링크로 창이 열립니다(메뉴와 설정 목록은 그대로 됩니다).
  • 지금의 범위: +82 번호의 계정, 기기 시간대가 한국(서울), 로그인한 지 24시간이 지난 기기에서만 허락을 켤 수 있습니다(나라는 운영자가 정합니다). 만 19세 미만이라고 답하면 1년 동안 봇과 통화할 수 없습니다.
  • 봇 이름·주인·목적이 바뀌거나 허락한 지 1년이 지나면 다시 묻습니다. 그동안은 걸 수 없습니다.

걸기 — startVoiceCall

curl -s "https://pabal.me/bot$BOT_TOKEN/startVoiceCall" -d user_id=$USER_ID -d purpose=delivery \
  --data-urlencode "context=주문 A-1042 가 오늘 오후 3시쯤 도착해요. 그때 받으실 수 있는지 여쭤봐 주세요." \
  -d max_duration=180 -d ring_timeout=30
{"ok": true, "result": {"voice_call_id": "8812345678901234"}}
파라미터뜻
user_id걸 사람의 id. 그 사람이 이 봇에게 허락했어야 합니다
purpose이 봇이 고른 목적 가운데 하나: reminder · wake_up · delivery · appointment
context모델에게만 주는 이 전화의 안내, 1–1,000자·3,072바이트. 내용 규칙(사칭, AI 숨기기, 비밀 요구, 광고)을 검사합니다. 모델은 첫 말(이름·AI·목적) 뒤, 사람이 대답한 다음에 이것을 씁니다
max_duration받은 뒤 최대 길이(초), 30 ~ 300 과 봇의 상한 가운데 작은 값. 기본 180
ring_timeout울리는 시간(초), 30 ~ 45. 기본 40
답뜻할 일
200 {"voice_call_id": "…"}받아들임. 울렸는지·받았는지는 업데이트로—
400 Bad Request: <무엇>매개변수가 틀림, 고르지 않은 목적(purpose is not declared), 봇 언어가 한국어·영어가 아님고칩니다
400 VOICE_CONTEXT_REFUSED: <코드>context 가 내용 규칙에 걸림고칩니다
403 VOICE_OUTBOUND_OFF: <까닭>봇 쪽: owner_off(켜지 않음) · terms(약관 4판) · review(운영자 검토 대기) · suspended(자동 정지) · frozen(토큰 멈춤) · owner_name(주인 이름이 사칭 검사에 걸림) · mode, 또는 음성 설정(unavailable_reason)의 봇 쪽 까닭 bot_voice_off · not_allowed · suspended · name_refused · text_refused · awaiting_review · stream_unavailable · own_key_unavailable · no_gateway · no_gateway_with_tools · no_gateway_with_language · no_own_key_gateway. 서버 쪽 까닭 voice_off · kill_switch · no_relay · outbound_off 는 403 이 아니라 503 으로 옵니다@BotFather · 운영자
403 USER_UNAVAILABLE그 사람에게 걸 수 없음: 허락이 없음·꺼짐·다시 물음, 차단, 사람이 아님, 범위 밖, 만 19세 미만, 시험자가 아님. 몸은 까닭과 상관없이 늘 같습니다허락을 부탁합니다
429 retry after N봇·주인 쪽 한도: 부르는 속도, 봇·주인의 하루 요청, 봇의 동시 통화, 주인의 발신 분, 봇·주인의 하루 통화 시간(받는 전화와 함께 씀)N 초 뒤
503 VOICE_UNAVAILABLE서버 쪽: 발신 스위치가 꺼짐, 서버의 음성이 꺼짐(개인정보 처리방침에 봇 통화 절이 없을 때 포함), 차단 스위치, 통화 중계가 없음, 서버의 한도와 발신 몫, 걸 수 있는 게이트웨이가 없거나 모두 참, 요청을 기록하지 못함나중에
{"ok": false, "error_code": 403, "description": "Forbidden: USER_UNAVAILABLE"}

사람 쪽 판정은 200 뒤에 합니다. 허락이 있어도 조용한 시간, 쉬는 중, 그 사람과의 하루 한도, 사람의 하루 한도와 간격, 울릴 기기가 없음, 다른 통화 중이면 벨 없이 요청한 때부터 ring_timeout + 12초쯤 뒤에 not_answered 가 옵니다. 울렸는데 받지 않은 것과 구별되지 않습니다. 허락이 있어도 봇 쪽 판정(403·429·503)은 그대로입니다.

설정과 사용량

"outbound": {
  "enabled": true,
  "purposes": ["delivery"],
  "daily_cap": 50,
  "reviewed": true,
  "frozen_until": 0,
  "suspended": false,
  "available": true
}
"outbound": {
  "today": {"requests": 4, "answered": 2, "not_answered": 2, "minutes": 3},
  "limits": {"daily": 50, "owner_daily": 100, "owner_minutes": 30}
}
  • getVoiceCallSettings 의 outbound 는 늘 있습니다: @BotFather 에서 정한 것(enabled·purposes·daily_cap), 운영자 검토(reviewed), 토큰 멈춤이 풀리는 때(frozen_until, 멈추지 않았으면 0), 자동 정지(suspended), 지금 걸 수 있는지(available). available 이 false 일 때만 unavailable_reason 이 붙습니다(봇 쪽이면 403 VOICE_OUTBOUND_OFF 의 까닭과 같은 값, 서버 쪽이면 voice_off · kill_switch · no_relay · outbound_off — 이 넷은 503 VOICE_UNAVAILABLE 에 대응합니다).
  • getVoiceCallUsage 의 outbound 는 발신을 한 번이라도 켰던 봇에만 있습니다: 오늘의 요청·받음·안 받음·분과, 이 봇에 걸리는 하루 한도. 울린 수는 싣지 않습니다. calls·seconds·missed·refused 에는 봇이 건 전화가 들어가지 않습니다. 하지만 봇이 건 전화는 받는 전화와 같은 봇·주인의 하루 통화 시간을 씁니다: 받은 통화의 초가 청구되어 remaining_seconds_today 의 bot·owner 가 줄고, 울리는 동안에도 예약만큼 줄었다가 받지 않으면 돌아옵니다.
  • setVoiceCallSettings 로는 바꿀 수 없습니다(400 VOICE_FIELD_NOT_AVAILABLE).

업데이트

받는 전화와 같은 voice_call 업데이트로 옵니다(allowed_updates 에 voice_call). direction 이 "outgoing" 이고, 사람은 to 의 id 하나로만 나옵니다(이름·사용자명·전화번호는 없음).

{
  "update_id": 83,
  "voice_call": {
    "id": "8812345678901234",
    "direction": "outgoing",
    "event": "not_answered",
    "to": {"id": 100005},
    "purpose": "delivery",
    "date": 1790000123
  }
}
{
  "update_id": 81,
  "voice_call": {
    "id": "8812345678901234",
    "direction": "outgoing",
    "event": "started",
    "to": {"id": 100005},
    "purpose": "delivery",
    "date": 1790000130,
    "max_duration": 180
  }
}
{
  "update_id": 82,
  "voice_call": {
    "id": "8812345678901234",
    "direction": "outgoing",
    "event": "ended",
    "to": {"id": 100005},
    "purpose": "delivery",
    "date": 1790000250,
    "start_date": 1790000130,
    "duration": 120,
    "max_duration": 180,
    "end_reason": "callee"
  }
}
{
  "update_id": 84,
  "voice_call": {
    "id": "8812345678901234",
    "direction": "outgoing",
    "event": "failed",
    "to": {"id": 100005},
    "purpose": "delivery",
    "date": 1790000110
  }
}
event언제더 붙는 필드
started사람이 받아 연결됐을 때. 애플 워치로 받은 통화는 워치의 소리가 붙었을 때(붙지 못하면 0초 ended, failed)max_duration
ended받은 통화가 끝났을 때. 서버 설정과 상관없이 게이트웨이의 사용량을 기다리느라 최대 10초 늦게 옵니다start_date, duration, max_duration, end_reason
not_answered받지 않은 요청 — 울렸는데 받지 않음, 거절, 사람 쪽 판정에 걸려 울리지 않음을 가리지 않습니다. 요청 뒤 ring_timeout + 12초쯤없음
failed요청을 맡은 게이트웨이와의 연결이 받기 전에 끊김없음

end_reason: callee(받은 사람이 끊음) · bot(봇이 끊음, 토큰 재발급) · max_length(최대 길이) · failed(받은 뒤 연결 실패) · operator(운영자·차단 스위치) · suspended(봇·주인 정지). 봇이 건 전화에는 missed·refused·ringing 이 없고 — 몇 초 만에 받았는지로 사람이 폰 앞에 있었는지 드러나지 않게 — transcript 도 없습니다(설계상 꺼짐).

통화 중

  • endVoiceCall: 알리기 전이면 울리는 중이든 울리기 전이든 요청을 거둡니다(true, 그 뒤 not_answered). 받은 통화는 끝냅니다(end_reason bot). 요청 뒤 15초 안에 거두면 그 사람과의 쌍이 1시간 쉽니다.
  • sendVoiceCallContext: 사람이 받은 뒤에만 됩니다(그 전에는 VOICE_CALL_NOT_FOUND). 통화당 3번, 1,000자·3,072바이트, context 와 같은 내용 규칙. 게이트웨이가 메모를 받지 않으면 VOICE_CONTEXT_NOT_SUPPORTED.

받는 사람이 보는 것과 듣는 것

  • 벨: 봇 이름 뒤에 "(AI)" 가 붙고, "AI 음성 봇 · 파발은 인증번호를 묻지 않습니다" 가 보입니다.
  • 받으면 안내 음성이 봇보다 먼저 나옵니다: "파발 AI 음성 전화입니다. 비밀번호·인증번호·송금은 절대 묻지 않습니다. 끄려면 봇 대화방에서 전화 허락을 끄세요." — 허락 창을 읽은 언어(한국어·영어)로 나오고, 다른 언어의 앱이면 영어입니다. 봇 주인이 끌 수 없습니다.
  • 봇의 첫 말: 봇 이름, AI 라는 것, 전화의 목적. context 는 그 뒤에 씁니다.
  • 끄는 길: 메뉴의 "전화 허락 끄기", 봇 차단, 신고는 언제나 끕니다. 통화 중에 "다시는 전화하지 마세요"처럼 분명히 말하면 그 자리에서 꺼집니다(그냥 "그만"은 모델의 판단에 달려 있습니다).
  • 쉬는 중: 거절하거나, 울렸는데 받지 않거나, 받고 10초 안에 끊으면 그 봇은 24시간 그 사람에게 걸지 못합니다.
  • 놓친 전화 기록: 기기가 실제로 울렸고, 봇을 차단하지 않았고, 그 봇 대화방에 읽지 않은 메시지가 없을 때 "놓친 AI 봇 전화"(읽음, 알림 없음)로 남습니다. 읽지 않은 메시지가 있거나 조용한 시간·쉬는 중이라 울리지 않았으면 기록이 없습니다. 봇이 건 모든 요청은 신고 증거(30일)에는 빠짐없이 남습니다.
  • 봇 주인에게 가는 것(허락 창과 같은 순서):
    1. 파발 id
    2. 허락이 켜져 있는지(전화 요청의 답으로 알 수 있음)
    3. 받았는지
    4. 통화 시작 시각과 길이
    5. 누가 끊었는지
    안 받은 것과 거절한 것은 구별되지 않습니다. 대화 내용은 가지 않습니다.
  • 함께 쓰는 기기: 로그인된 기기 어디서나 받을 수 있고, 받은 사람이 누구든 봇은 목적과 안내 내용을 말합니다. 여럿이 쓰는 컴퓨터라면 로그아웃하라고 알려 주세요.
  • 잠금 화면: 안드로이드 14 이상에서는 사용자가 전체 화면 알림을 허용하지 않으면 잠금 화면에 전화 화면 대신 알림으로 옵니다. 아이폰과 애플 워치는 전화처럼 울립니다.
  • 대화 남기기는 사람이 허락 창에서 고르면 사람의 대화방에만 남습니다. 파발 워치 앱에서 워치로 받기를 켜면 애플 워치도 함께 울립니다.
  • 봇이 거는 전화는 종단 간 암호화가 아닙니다 — 소리가 파발 서버와 AI 제공자를 거칩니다.

한도 · 조용한 시간 · 쉬는 중 · 자동 정지

한도기본운영 서버에서
한 봇이 한 사람에게(하루)3통운영자가 줄일 수만 있음
한 사람에게(하루, 모든 봇)6통줄일 수만 있음
한 사람에게 거는 간격10분늘릴 수만 있음
봇 하나의 요청(하루)50통주인이 @BotFather 에서 10·20·50 가운데 고름
봇 주인 한 명(하루, 모든 봇)100통 · 30분통수는 줄일 수만 있음
봇이 다음 요청을 보낼 수 있을 때까지10초늘릴 수만 있음
서버의 발신 분서버 하루 분의 30%줄일 수만 있음
조용한 시간21:00–08:00운영자와 사람이 넓힐 수만 있음. 기기 시간대 기준
깨우기 창(wake_up 만)최대 120분사람이 조용한 시간 안에서 고름
벨(ring_timeout)30–45초요청에 없으면 40초
쉬는 중 — 거절, 울렸는데 받지 않음, 받고 10초 안에 끊음24시간늘릴 수만 있음
쉬는 중 — 봇이 15초 안에 거둠1시간늘릴 수만 있음
  • "하루"는 서버의 시간대(TELEGRAM_VOICE_TIME_ZONE)의 하루입니다. 통화 길이는 300초를 넘지 않습니다.
  • 봇이 거는 전화도 받는 전화와 같은 하루 통화 시간(봇 60분 · 주인 120분)을 함께 씁니다. 다 쓰면 startVoiceCall 은 429 를 받고, 그 봇에 걸려 오는 전화도 refused(budget)로 거절됩니다.
  • 쉬는 중이 풀리는 때: 시간이 지남, 사람이 그 봇에게 전화를 걺, 사람이 "지금 받을게요" 를 누름, 다시 허락. 봇의 메시지나 버튼으로는 풀리지 않습니다.
  • 자동 정지는 그 봇의 발신만 멈춥니다(403 VOICE_OUTBOUND_OFF: suspended). 운영자가 확인하고 풀 때까지 이어집니다. 받은 비율이 최근 20통에서 20% 미만, 7일 안에 받은 사람의 신고 3건, 허락을 끈 사람 30% 이상(10명 이상일 때), 통화 중 규칙에 걸림 2건, 15초 안에 거두기 5건, 받은 사람은 2명 이하인데 받은 분은 60분 이상.

한도

한도기본누가 정하나
한 통화 최대 길이봇 300초 (정할 수 있는 범위 60 ~ 운영자·서버 상한, 기본 600)봇 주인이 운영자·서버 상한 안에서. @BotFather 버튼은 1·3·5·10분
봇의 동시 통화2 (1 ~ 운영자·서버 상한, 기본 2)봇 주인이 운영자·서버 상한 안에서
봇 하나의 하루 통화 시간60분운영자
봇 주인 한 명(모든 봇 합쳐)의 하루120분운영자
서버 전체의 하루300분운영자
발신자 한 명하루 60분, 한 시간에 20통운영자
가장 짧게 받을 통화60초 — 남은 시간이 이보다 적으면 받지 않음운영자
새 계정가입 7일이 안 된 사람은 걸 수 없음 — 시험자, 그리고 봇이 하루 이상 전에 그 사람에게 메시지를 보낸 적이 있는 사람은 예외운영자
설정 바꾸기봇마다 한 시간 30번—
통화 중 메모통화마다 1분에 10개—
도구16개·8KB, 열린 질의 통화마다 2·봇마다 8, 1분에 통화마다 30·봇마다 120—
자기 키 시험 · 원음 사전 시험봇마다 한 시간 5번, 주인마다 10번—
MCP 등록·검사봇마다 한 시간 10번, 주인마다 20번, 봇마다 서버 2개—

애플 워치로는 도구·MCP 서버·자기 키·원음 모드·봇 주인의 대화 기록을 쓰는 봇에 걸 수 없습니다. 이 거절은 발신자(워치 앱의 403 WATCH_NOT_YET)에게만 가고, 봇에게는 refused 업데이트가 가지 않습니다.

한 통화의 길이는 발신자·봇·주인·서버의 오늘 남은 시간과 통화 상한 가운데 가장 작은 것입니다. 받을 때 그만큼을 네 한도 모두에 잡아 두고, 끝나면 서버 시계로 실제 초만 청구합니다. 자기 키 통화는 봇·주인·서버 한도를 쓰지 않습니다(발신자 한도는 그대로).

오류

오류는 다른 메서드와 같은 모양입니다: {"ok": false, "error_code": 400, "description": "Bad Request: VOICE_…"}. 403 은 Forbidden: <코드>: <설명>(USER_UNAVAILABLE 만은 설명 없이 늘 Forbidden: USER_UNAVAILABLE), 429 는 parameters.retry_after 가 붙습니다.

코드뜻
403 VOICE_RULES_NOT_ACCEPTED@BotFather 에서 음성 봇 규칙에 먼저 동의해야 함
403 VOICE_NOT_AVAILABLE이 서버에서 이 봇은 음성을 쓸 수 없음(음성 꺼짐·운영자가 허용하지 않음) — 설정을 바꿀 수 없고 끄기와 지우기만 됨
403 VOICE_SUSPENDED운영자가 이 봇의 음성을 멈춤. 끄기만 됨
403 VOICE_OWNER_TERMS_NOT_ACCEPTED봇 주인 약관(판 4)에 먼저 동의해야 함
403 VOICE_OWN_KEY_NOT_AVAILABLE이 서버는 자기 키를 받지 않음
403 VOICE_STREAM_NOT_AVAILABLE이 서버에서 지금 원음 모드를 쓸 수 없음
403 VOICE_OUTBOUND_OFF: <까닭>이 봇은 지금 사람에게 걸 수 없음: owner_off · terms · review · suspended · frozen · owner_name · mode, 또는 음성 설정의 봇 쪽 까닭(걸기). voice_off · kill_switch · no_relay · outbound_off 는 503
403 USER_UNAVAILABLE그 사람에게 걸 수 없음 — 허락이 없는 경우를 포함해 까닭과 상관없이 늘 같은 몸
503 VOICE_UNAVAILABLE서버 쪽 사정으로 지금 사람에게 걸 수 없음(발신 스위치, 서버의 음성 꺼짐, 차단 스위치, 통화 중계 없음, 서버 한도, 게이트웨이, 요청 기록 실패) — 나중에 다시
Bad Request: purpose is not declared, Bad Request: the bot's voice language must be ko or en for outbound callsstartVoiceCall: 고르지 않은 목적, 한국어·영어가 아닌 봇 언어
VOICE_INSTRUCTIONS_REQUIRED, VOICE_INSTRUCTIONS_TOO_LONG, VOICE_GREETING_TOO_LONG지시문이 필요함 · 지시문 8,192바이트 · 인사말 512바이트를 넘음
VOICE_INVALID, VOICE_LANGUAGE_INVALID, VOICE_LIMIT_INVALID, VOICE_MODE_INVALID없는 목소리 · 언어 · 범위 밖의 한도 · managed/stream 이 아닌 모드
VOICE_CONTENT_REFUSED: <칸>: <코드>, VOICE_CONTEXT_REFUSED: <코드>내용 규칙(설정, startVoiceCall 의 context)
VOICE_TOOL_INVALID: <경로>: <이유>도구 정의(도구 정하기)
VOICE_FIELD_NOT_AVAILABLE: …받지 않는 칸(model, api_key), 관리형 봇에 원음 칸, 봇이 거는 전화의 칸(outbound·purposes·daily_cap — @BotFather 에서만)
VOICE_CALL_NOT_FOUND, VOICE_CALL_NOT_ACTIVE, VOICE_CONTEXT_NOT_SUPPORTED, VOICE_CONTEXT_TOO_LONG통화 중 메서드(통화 중에)
VOICE_USAGE_PERIOD_INVALIDgetVoiceCallUsage 의 기간
TOOL_QUERY_ID_INVALID, TOOL_QUERY_EXPIRED: …, TOOL_RESULT_…, TOOL_ERROR_TOO_LONG도구의 답(답하기)
API_KEY_INVALID, API_KEY_NO_REALTIME_ACCESS, 503 API_KEY_CHECK_UNAVAILABLE, VOICE_PROVIDER_INVALID자기 키(자기 모델 키)
STREAM_URL_INVALID, STREAM_SECRET_REQUIRED, STREAM_SECRET_INVALID, STREAM_PROBE_FAILED: …원음 모드(설정과 사전 시험)
MCP_…, MCP_OPT_IN_REQUIREDMCP 서버(등록하기). 503 VOICE_NOT_AVAILABLE: mcp: … 는 잠시 뒤 다시, 409 MCP_CHECK_IN_PROGRESS 는 검사가 끝난 뒤 다시
429 Too Many Requests: retry after N한 시간 한도(설정·키 시험·사전 시험·MCP), 메모 한도, 봇이 거는 전화의 한도(한도)

MTProto 로 붙는 봇

  • MTProto 로만 로그인한 봇은 음성 메서드를 bots.sendCustomRequest 로 부릅니다(메서드 이름과 같은 JSON). 되는 것: setVoiceCallSettings, getVoiceCallSettings, answerVoiceCallToolQuery, endVoiceCall, sendVoiceCallContext, getVoiceCallUsage, startVoiceCall, MCP 메서드 다섯. 그 밖은 METHOD_INVALID. startVoiceCall 의 답은 {"voice_call_id": "…"} 이고, 거절은 RPC 오류(USER_UNAVAILABLE, VOICE_OUTBOUND_OFF: review, FLOOD_WAIT_n, VOICE_UNAVAILABLE)로 옵니다.
  • 웹훅 응답으로는 부를 수 없는 메서드: setVoiceCallApiKey, startVoiceCall, MCP 등록·키 메서드. 웹훅 POST 의 응답 본문에 넣어도 실행하지 않습니다 — 사람이 보낸 메시지 하나가 웹훅 응답을 거쳐 곧바로 전화가 되지 않게 하기 위해서입니다. 따로 HTTP 요청으로 부르세요.
  • 자기 키는 HTTPS 로만: setVoiceCallApiKey·deleteVoiceCallApiKey 는 sendCustomRequest 로 부를 수 없습니다. 키를 지우려면 HTTP Bot API 나 @BotFather 를 쓰세요.
  • 웹훅이 없고 최근 15초 안에 getUpdates 를 부르지 않은 봇에게 voice_call(봇이 건 전화의 것도)은 updateBotWebhookJSON 으로 한 번 밀립니다(allowed_updates 와 상관없이, 다시 보내지 않음). 도구 질의는 updateBotWebhookJSONQuery 로 오고 bots.answerWebhookJSONQuery 로 {"result": …} 나 {"error": "…"} 를 답합니다. voice_call_transcript 는 MTProto 로 오지 않습니다(끝난 업데이트의 transcript 는 옵니다).
  • RPC 오류는 429 가 FLOOD_WAIT_n, 나머지는 대문자 코드 그대로입니다.