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