init
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# my.telegram.org -> API development tools
|
||||||
|
API_ID=1234567
|
||||||
|
API_HASH=your_api_hash_here
|
||||||
|
|
||||||
|
# Номер телефона аккаунта, который будет ЗВОНИТЬ (с кодом страны, без пробелов)
|
||||||
|
PHONE_NUMBER=+79991234567
|
||||||
|
|
||||||
|
# Имя session-файла (без расширения), будет лежать в /data/sessions
|
||||||
|
SESSION_NAME=caller
|
||||||
|
|
||||||
|
# Кому звонить по умолчанию: @username или numeric user id — реальный
|
||||||
|
# 1-на-1 звонок (p2p, с гудком и ожиданием ответа).
|
||||||
|
CALL_TARGET=@monster1025
|
||||||
|
|
||||||
|
# Сколько секунд ждать ответа (гудка), прежде чем считать, что не дозвонились
|
||||||
|
CALL_RING_TIMEOUT=45
|
||||||
|
|
||||||
|
# Язык TTS-озвучки (gTTS): ru, en, ...
|
||||||
|
TTS_LANG=ru
|
||||||
|
|
||||||
|
# Опциональный bearer-токен для защиты REST-ручки. Если пусто — без авторизации.
|
||||||
|
API_TOKEN=change-me
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
sessions/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# ffmpeg нужен pytgcalls для декодирования/кодирования аудио потока
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
# Сюда монтируется volume с session-файлом (см. docker-compose.yml)
|
||||||
|
RUN mkdir -p /data/sessions
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Telegram Call Service
|
||||||
|
|
||||||
|
REST-сервис: `POST /call` → делает настоящий **1-на-1 звонок** (p2p call,
|
||||||
|
такой же, как обычный звонок в приложении Telegram — с гудком и ожиданием
|
||||||
|
ответа) указанному пользователю под вашим аккаунтом и озвучивает переданный
|
||||||
|
текст через TTS сразу после того, как вызываемый принял звонок.
|
||||||
|
|
||||||
|
## Как это устроено
|
||||||
|
|
||||||
|
Telegram Bot API **не умеет** звонить — это принципиальное ограничение платформы,
|
||||||
|
никакой код это не обойдёт. Настоящий голосовой звонок доступен только через
|
||||||
|
MTProto-аккаунт обычного пользователя (Pyrogram-форк `pyrofork` + `py-tgcalls`,
|
||||||
|
низкоуровневый биндинг `ntgcalls`).
|
||||||
|
|
||||||
|
Библиотека `py-tgcalls` делает это через `phone.requestCall` / `phone.acceptCall`
|
||||||
|
с DH key exchange — ровно тот же путь, которым идёт обычный звонок из
|
||||||
|
приложения. `play()` блокирует выполнение, пока собеседник не ответит (или не
|
||||||
|
истечёт `CALL_RING_TIMEOUT`), бросает `TimedOutAnswer` / `CallDeclined` /
|
||||||
|
`CallBusy`, если не дозвонились.
|
||||||
|
|
||||||
|
## ⚠️ Важно понимать риски
|
||||||
|
|
||||||
|
- Это автоматизация **личного аккаунта**, а не бота. Telegram может ограничить
|
||||||
|
или заблокировать аккаунт за автоматизированные действия, особенно при
|
||||||
|
частом/массовом использовании. Используйте выделенный номер, не основной.
|
||||||
|
- API_ID/API_HASH и session-файл дают полный доступ к аккаунту — храните
|
||||||
|
`.env` и `sessions/` так же бережно, как пароль.
|
||||||
|
- Не используйте это для звонков посторонним людям без их согласия.
|
||||||
|
- Звонок реально дозвонится только если у вызываемого аккаунта в
|
||||||
|
**Settings → Privacy and Security → Calls** разрешены звонки от вашего
|
||||||
|
аккаунта (например, "Everybody", или вы у него в контактах). Иначе Telegram
|
||||||
|
тихо отклонит попытку на уровне privacy — это ограничение платформы, не бага
|
||||||
|
сервиса.
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
### 1. Получить API_ID / API_HASH
|
||||||
|
|
||||||
|
https://my.telegram.org → API development tools → создать приложение.
|
||||||
|
|
||||||
|
### 2. Настроить .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# заполнить API_ID, API_HASH, PHONE_NUMBER, CALL_TARGET, API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
`CALL_TARGET` — кому звонить по умолчанию: `@username` вызываемого аккаунта
|
||||||
|
(или его numeric user id). Убедитесь, что у вызывающего аккаунта этот
|
||||||
|
пользователь виден (например, есть в контактах) — иначе `resolve_peer` не
|
||||||
|
сможет найти адресата по username при первом обращении.
|
||||||
|
|
||||||
|
### 3. Один раз залогиниться (интерактивно, вне обычного запуска)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm callsvc python scripts/login.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Введите код из Telegram (и пароль 2FA, если включён). Session-файл сохранится
|
||||||
|
в `./sessions/` на хосте и будет переиспользоваться при обычном запуске.
|
||||||
|
|
||||||
|
### 4. Запуск сервиса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Проверка живости.
|
||||||
|
|
||||||
|
### `POST /call`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/call \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN" \
|
||||||
|
-d '{"text": "Внимание! Сработал алерт на проде."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля тела запроса:
|
||||||
|
|
||||||
|
| поле | обязательное | описание |
|
||||||
|
|--------|--------------|---------------------------------------------------------------------------|
|
||||||
|
| text | да | текст, который будет озвучен (TTS, до 2000 символов) |
|
||||||
|
| target | нет | `@username` или numeric user id вызываемого. Если не задан — берётся `CALL_TARGET` из `.env` |
|
||||||
|
|
||||||
|
Если `API_TOKEN` в `.env` не задан — заголовок `Authorization` не требуется.
|
||||||
|
|
||||||
|
Ответ (после того, как собеседник принял звонок и TTS проигрался):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status": "called", "target": "@monster1025", "duration": 4.2}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- `409` — не дозвонились: не ответили за `CALL_RING_TIMEOUT` секунд, отклонили,
|
||||||
|
заняты, звонок сброшен, либо звонок на эту цель уже идёт, либо `target`
|
||||||
|
резолвится в группу/канал (сервис звонит только пользователям).
|
||||||
|
- `401` — неверный/отсутствующий Bearer-токен (если `API_TOKEN` задан).
|
||||||
|
- `500` — прочие ошибки (см. логи `docker compose logs -f`).
|
||||||
|
|
||||||
|
## Ограничения текущей версии
|
||||||
|
|
||||||
|
- Один одновременный звонок на цель (защищено локом), параллельные запросы
|
||||||
|
на разные `target` обрабатываются независимо.
|
||||||
|
- Озвучка через gTTS требует исходящего доступа в интернет из контейнера.
|
||||||
|
- `POST /call` синхронно ждёт ответа на звонок (до `CALL_RING_TIMEOUT` сек) —
|
||||||
|
учитывайте это в таймауте клиента, который дёргает ручку.
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pyrogram import Client
|
||||||
|
from pytgcalls import PyTgCalls, filters as pfl
|
||||||
|
from pytgcalls.exceptions import CallBusy, CallDeclined, CallDiscarded, TimedOutAnswer
|
||||||
|
from pytgcalls.types import CallConfig, MediaStream, StreamEnded
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.tts import synthesize
|
||||||
|
|
||||||
|
logger = logging.getLogger("callsvc.calling")
|
||||||
|
|
||||||
|
pyro_client = Client(
|
||||||
|
settings.session_name,
|
||||||
|
api_id=settings.api_id,
|
||||||
|
api_hash=settings.api_hash,
|
||||||
|
phone_number=settings.phone_number,
|
||||||
|
workdir=settings.sessions_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_py = PyTgCalls(pyro_client)
|
||||||
|
|
||||||
|
# per-цель "stream finished" события, заполняются on_stream_end хендлером
|
||||||
|
_stream_finished: dict[int, asyncio.Event] = {}
|
||||||
|
# один вызов на цель одновременно, чтобы не пересекались звонки
|
||||||
|
_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_for(key: str) -> asyncio.Lock:
|
||||||
|
if key not in _locks:
|
||||||
|
_locks[key] = asyncio.Lock()
|
||||||
|
return _locks[key]
|
||||||
|
|
||||||
|
|
||||||
|
@call_py.on_update(pfl.stream_end)
|
||||||
|
async def _on_stream_end(_: PyTgCalls, update: StreamEnded):
|
||||||
|
event = _stream_finished.get(update.chat_id)
|
||||||
|
if event is not None:
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def start():
|
||||||
|
await pyro_client.start()
|
||||||
|
await call_py.start()
|
||||||
|
logger.info("Telegram user client + PyTgCalls started")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop():
|
||||||
|
# PyTgCalls не имеет отдельного stop() — он поднимает/использует тот же
|
||||||
|
# pyrogram-клиент, поэтому останавливаем только клиент.
|
||||||
|
await pyro_client.stop()
|
||||||
|
logger.info("Telegram user client + PyTgCalls stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def make_call(text: str, target: int | str | None = None) -> dict:
|
||||||
|
"""Звонит пользователю настоящим p2p-звонком (с гудком и ожиданием ответа)
|
||||||
|
и озвучивает текст (TTS) сразу после того, как собеседник ответил.
|
||||||
|
"""
|
||||||
|
target = target if target is not None else settings.call_target
|
||||||
|
lock = _lock_for(str(target))
|
||||||
|
|
||||||
|
if lock.locked():
|
||||||
|
raise RuntimeError(f"Call to {target} is already in progress")
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
audio_path, duration = synthesize(text)
|
||||||
|
resolved_id = await call_py.resolve_chat_id(target)
|
||||||
|
if resolved_id <= 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{target} resolves to a group/channel id ({resolved_id}). "
|
||||||
|
"This service only makes direct p2p calls to users."
|
||||||
|
)
|
||||||
|
event = asyncio.Event()
|
||||||
|
_stream_finished[resolved_id] = event
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = CallConfig(timeout=settings.call_ring_timeout)
|
||||||
|
try:
|
||||||
|
logger.info("Calling %s (resolved id %s)...", target, resolved_id)
|
||||||
|
await call_py.play(resolved_id, MediaStream(audio_path), config=config)
|
||||||
|
except TimedOutAnswer as exc:
|
||||||
|
raise RuntimeError(f"{target} did not answer the call in time") from exc
|
||||||
|
except CallDeclined as exc:
|
||||||
|
raise RuntimeError(f"{target} declined the call") from exc
|
||||||
|
except CallBusy as exc:
|
||||||
|
raise RuntimeError(f"{target} is busy") from exc
|
||||||
|
except CallDiscarded as exc:
|
||||||
|
raise RuntimeError(f"Call to {target} was discarded") from exc
|
||||||
|
|
||||||
|
logger.info("Call answered, playing TTS (%.1fs) to %s", duration, target)
|
||||||
|
|
||||||
|
# ждём сигнал о завершении стрима, но не дольше duration + запас
|
||||||
|
timeout = duration + 10
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(event.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Stream end event not received for %s within %.1fs, hanging up anyway",
|
||||||
|
target,
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
await call_py.leave_call(resolved_id)
|
||||||
|
return {"target": target, "duration": duration}
|
||||||
|
finally:
|
||||||
|
_stream_finished.pop(resolved_id, None)
|
||||||
|
if os.path.exists(audio_path):
|
||||||
|
os.remove(audio_path)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
def _require_int(name: str) -> int:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if not val:
|
||||||
|
raise RuntimeError(f"Env var {name} is required")
|
||||||
|
return int(val)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_str(name: str) -> str:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if not val:
|
||||||
|
raise RuntimeError(f"Env var {name} is required")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
api_id: int
|
||||||
|
api_hash: str
|
||||||
|
phone_number: str
|
||||||
|
session_name: str
|
||||||
|
sessions_dir: str
|
||||||
|
# Цель звонка по умолчанию: @username или numeric user id — прямой p2p звонок.
|
||||||
|
call_target: str
|
||||||
|
call_ring_timeout: int
|
||||||
|
tts_lang: str
|
||||||
|
api_token: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> Settings:
|
||||||
|
return Settings(
|
||||||
|
api_id=_require_int("API_ID"),
|
||||||
|
api_hash=_require_str("API_HASH"),
|
||||||
|
phone_number=_require_str("PHONE_NUMBER"),
|
||||||
|
session_name=os.environ.get("SESSION_NAME", "caller"),
|
||||||
|
sessions_dir=os.environ.get("SESSIONS_DIR", "/data/sessions"),
|
||||||
|
call_target=_require_str("CALL_TARGET"),
|
||||||
|
call_ring_timeout=int(os.environ.get("CALL_RING_TIMEOUT", "45")),
|
||||||
|
tts_lang=os.environ.get("TTS_LANG", "ru"),
|
||||||
|
api_token=os.environ.get("API_TOKEN") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Header
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import calling
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("callsvc.main")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
await calling.start()
|
||||||
|
yield
|
||||||
|
await calling.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Telegram Call Service", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
class CallRequest(BaseModel):
|
||||||
|
text: str = Field(..., min_length=1, max_length=2000, description="Текст для озвучки TTS")
|
||||||
|
target: int | str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Кому звонить: @username или numeric user id. "
|
||||||
|
"Если не задан — берётся CALL_TARGET из .env"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_auth(authorization: str | None = Header(default=None)):
|
||||||
|
if not settings.api_token:
|
||||||
|
return
|
||||||
|
expected = f"Bearer {settings.api_token}"
|
||||||
|
if authorization != expected:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing bearer token")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/call", dependencies=[Depends(check_auth)])
|
||||||
|
async def call(req: CallRequest):
|
||||||
|
try:
|
||||||
|
result = await calling.make_call(req.text, req.target)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Call failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "called", **result}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("callsvc.tts")
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(text: str) -> tuple[str, float]:
|
||||||
|
"""Генерирует mp3 из текста через gTTS.
|
||||||
|
|
||||||
|
Возвращает (путь_к_файлу, длительность_в_секундах).
|
||||||
|
Вызывающий код отвечает за удаление файла после использования.
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
raise ValueError("text must not be empty")
|
||||||
|
|
||||||
|
path = os.path.join(tempfile.gettempdir(), f"call-{uuid.uuid4().hex}.mp3")
|
||||||
|
gTTS(text=text, lang=settings.tts_lang).save(path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
duration = MP3(path).info.length
|
||||||
|
except Exception: # noqa: BLE001 - на всякий случай не роняем звонок из-за метаданных
|
||||||
|
logger.warning("Could not read mp3 duration for %s, defaulting to 30s", path)
|
||||||
|
duration = 30.0
|
||||||
|
|
||||||
|
return path, duration
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
callsvc:
|
||||||
|
build: .
|
||||||
|
container_name: telegram-call-service
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8200:8000"
|
||||||
|
volumes:
|
||||||
|
- ./sessions:/data/sessions
|
||||||
|
# для первого логина: docker compose run --rm callsvc python scripts/login.py
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pyrofork==2.3.69
|
||||||
|
tgcrypto-pyrofork==1.2.6
|
||||||
|
py-tgcalls[pyrogram]==2.3.3
|
||||||
|
gTTS==2.5.3
|
||||||
|
mutagen==1.47.0
|
||||||
|
pydantic==2.9.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Одноразовый интерактивный логин user-аккаунта Telegram.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
docker compose run --rm callsvc python scripts/login.py
|
||||||
|
|
||||||
|
Введёт код подтверждения (и пароль 2FA, если включён) и сохранит
|
||||||
|
session-файл в /data/sessions/<SESSION_NAME>.session — том смонтирован
|
||||||
|
в docker-compose.yml, так что сессия переживёт пересоздание контейнера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyrogram import Client
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
client = Client(
|
||||||
|
settings.session_name,
|
||||||
|
api_id=settings.api_id,
|
||||||
|
api_hash=settings.api_hash,
|
||||||
|
phone_number=settings.phone_number,
|
||||||
|
workdir=settings.sessions_dir,
|
||||||
|
)
|
||||||
|
with client:
|
||||||
|
me = client.get_me()
|
||||||
|
print(f"Logged in as {me.first_name} (id={me.id}). Session saved to {settings.sessions_dir}.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user