326 lines
13 KiB
Python
326 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Montana Protocol — Real Phone Number Binding
|
||
Привязка реальных телефонных номеров к Montana адресам.
|
||
|
||
Модель:
|
||
1. Пользователь вводит свой реальный номер (+7-921-123-4567)
|
||
2. Montana отправляет SMS с кодом верификации
|
||
3. Пользователь вводит код
|
||
4. Номер привязывается к Montana адресу
|
||
5. Теперь номер = кошелек (может звонить за 1 Ɉ/сек)
|
||
"""
|
||
|
||
import json
|
||
import threading
|
||
import fcntl
|
||
import time
|
||
import random
|
||
import hashlib
|
||
from pathlib import Path
|
||
from typing import Dict, Optional, List
|
||
from datetime import datetime, timedelta
|
||
import logging
|
||
|
||
log = logging.getLogger("montana_real_phone")
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# REAL PHONE NUMBER BINDING SERVICE
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class RealPhoneService:
|
||
"""
|
||
Сервис привязки реальных телефонных номеров к Montana адресам.
|
||
|
||
Workflow:
|
||
1. request_verification(phone, montana_address) → отправляет SMS с кодом
|
||
2. verify_code(phone, code) → проверяет код и привязывает номер
|
||
3. lookup(phone) → возвращает Montana адрес владельца
|
||
"""
|
||
|
||
def __init__(self, data_dir: Path):
|
||
self.data_dir = data_dir
|
||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||
self.bindings_file = self.data_dir / "real_phone_bindings.json"
|
||
self.pending_file = self.data_dir / "real_phone_pending.json"
|
||
self._lock = threading.Lock()
|
||
self._ensure_files()
|
||
|
||
def _ensure_files(self):
|
||
"""Создать файлы если не существуют"""
|
||
if not self.bindings_file.exists():
|
||
self._save_bindings({})
|
||
if not self.pending_file.exists():
|
||
self._save_pending({})
|
||
|
||
def _load_bindings(self) -> Dict[str, Dict]:
|
||
"""Загрузить привязанные номера"""
|
||
with open(self.bindings_file, 'r') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||
try:
|
||
data = json.load(f)
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
return data
|
||
|
||
def _save_bindings(self, bindings: Dict[str, Dict]):
|
||
"""Сохранить привязанные номера"""
|
||
with open(self.bindings_file, 'w') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
json.dump(bindings, f, indent=2)
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
|
||
def _load_pending(self) -> Dict[str, Dict]:
|
||
"""Загрузить pending верификации"""
|
||
with open(self.pending_file, 'r') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||
try:
|
||
data = json.load(f)
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
return data
|
||
|
||
def _save_pending(self, pending: Dict[str, Dict]):
|
||
"""Сохранить pending верификации"""
|
||
with open(self.pending_file, 'w') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
json.dump(pending, f, indent=2)
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
|
||
def _normalize_phone(self, phone: str) -> str:
|
||
"""
|
||
Нормализовать телефонный номер.
|
||
|
||
Examples:
|
||
+7-921-123-4567 → +79211234567
|
||
8 (921) 123-45-67 → +79211234567
|
||
+1 (555) 123-4567 → +15551234567
|
||
"""
|
||
# Удалить все кроме цифр и +
|
||
normalized = ''.join(c for c in phone if c.isdigit() or c == '+')
|
||
|
||
# Если начинается с 8 (Россия) → +7
|
||
if normalized.startswith('8') and len(normalized) == 11:
|
||
normalized = '+7' + normalized[1:]
|
||
|
||
# Добавить + если нет
|
||
if not normalized.startswith('+'):
|
||
# Определить страну по длине (упрощенно)
|
||
if len(normalized) == 10: # USA
|
||
normalized = '+1' + normalized
|
||
elif len(normalized) == 11: # Russia
|
||
normalized = '+' + normalized
|
||
|
||
return normalized
|
||
|
||
def _generate_code(self) -> str:
|
||
"""Сгенерировать 6-значный код верификации"""
|
||
return f"{random.randint(100000, 999999)}"
|
||
|
||
def _send_sms(self, phone: str, code: str) -> bool:
|
||
"""
|
||
Отправить SMS с кодом верификации.
|
||
|
||
В production это интегрируется с SMS провайдером (Twilio, etc.)
|
||
Сейчас просто логируем код.
|
||
"""
|
||
# TODO: Интеграция с SMS API (Twilio, MessageBird, etc.)
|
||
log.info(f"SMS → {phone}: Your Montana verification code: {code}")
|
||
|
||
# В dev режиме просто возвращаем True
|
||
# В production проверяем результат SMS API
|
||
return True
|
||
|
||
def request_verification(
|
||
self,
|
||
phone: str,
|
||
montana_address: str
|
||
) -> Dict:
|
||
"""
|
||
Запросить верификацию номера.
|
||
|
||
Args:
|
||
phone: Телефонный номер (любой формат)
|
||
montana_address: Montana адрес для привязки
|
||
|
||
Returns:
|
||
Dict с результатом запроса
|
||
"""
|
||
phone_normalized = self._normalize_phone(phone)
|
||
|
||
with self._lock:
|
||
bindings = self._load_bindings()
|
||
pending = self._load_pending()
|
||
|
||
# Проверить что номер не привязан к другому адресу
|
||
if phone_normalized in bindings:
|
||
existing_owner = bindings[phone_normalized]['montana_address']
|
||
if existing_owner != montana_address:
|
||
raise ValueError(
|
||
f"Phone number already bound to different address: {existing_owner[:10]}..."
|
||
)
|
||
return {
|
||
"status": "already_verified",
|
||
"phone": phone_normalized,
|
||
"montana_address": montana_address
|
||
}
|
||
|
||
# Сгенерировать код
|
||
code = self._generate_code()
|
||
|
||
# Сохранить в pending (expires in 10 minutes)
|
||
expires = (datetime.utcnow() + timedelta(minutes=10)).isoformat() + "Z"
|
||
pending[phone_normalized] = {
|
||
"code": code,
|
||
"montana_address": montana_address,
|
||
"expires": expires,
|
||
"created": datetime.utcnow().isoformat() + "Z",
|
||
"attempts": 0
|
||
}
|
||
self._save_pending(pending)
|
||
|
||
# Отправить SMS
|
||
sms_sent = self._send_sms(phone_normalized, code)
|
||
|
||
if not sms_sent:
|
||
raise Exception("Failed to send SMS")
|
||
|
||
log.info(f"Verification requested: {phone_normalized} → {montana_address[:10]}...")
|
||
|
||
return {
|
||
"status": "code_sent",
|
||
"phone": phone_normalized,
|
||
"montana_address": montana_address,
|
||
"expires": expires,
|
||
"dev_code": code if __debug__ else None # Only in debug mode
|
||
}
|
||
|
||
def verify_code(
|
||
self,
|
||
phone: str,
|
||
code: str
|
||
) -> Dict:
|
||
"""
|
||
Проверить код верификации и привязать номер.
|
||
|
||
Args:
|
||
phone: Телефонный номер
|
||
code: 6-значный код из SMS
|
||
|
||
Returns:
|
||
Dict с результатом верификации
|
||
"""
|
||
phone_normalized = self._normalize_phone(phone)
|
||
|
||
with self._lock:
|
||
pending = self._load_pending()
|
||
|
||
if phone_normalized not in pending:
|
||
raise ValueError("No pending verification for this phone number")
|
||
|
||
verification = pending[phone_normalized]
|
||
|
||
# Проверить expiration
|
||
expires = datetime.fromisoformat(verification['expires'].replace('Z', '+00:00'))
|
||
if datetime.utcnow() > expires.replace(tzinfo=None):
|
||
del pending[phone_normalized]
|
||
self._save_pending(pending)
|
||
raise ValueError("Verification code expired")
|
||
|
||
# Проверить код
|
||
if verification['code'] != code:
|
||
verification['attempts'] += 1
|
||
if verification['attempts'] >= 3:
|
||
del pending[phone_normalized]
|
||
self._save_pending(pending)
|
||
raise ValueError("Too many failed attempts")
|
||
else:
|
||
self._save_pending(pending)
|
||
raise ValueError(f"Invalid code ({verification['attempts']}/3 attempts)")
|
||
|
||
# Код правильный — привязать номер
|
||
montana_address = verification['montana_address']
|
||
|
||
bindings = self._load_bindings()
|
||
bindings[phone_normalized] = {
|
||
"montana_address": montana_address,
|
||
"verified": datetime.utcnow().isoformat() + "Z",
|
||
"phone": phone_normalized,
|
||
"audio_seconds_used": 0,
|
||
"video_seconds_used": 0
|
||
}
|
||
self._save_bindings(bindings)
|
||
|
||
# Удалить из pending
|
||
del pending[phone_normalized]
|
||
self._save_pending(pending)
|
||
|
||
log.info(f"Phone verified: {phone_normalized} → {montana_address[:10]}...")
|
||
|
||
return {
|
||
"status": "verified",
|
||
"phone": phone_normalized,
|
||
"montana_address": montana_address,
|
||
"verified": bindings[phone_normalized]['verified']
|
||
}
|
||
|
||
def lookup(self, phone: str) -> Optional[Dict]:
|
||
"""
|
||
Найти Montana адрес по телефонному номеру.
|
||
|
||
Args:
|
||
phone: Телефонный номер
|
||
|
||
Returns:
|
||
Dict с информацией о привязке или None
|
||
"""
|
||
phone_normalized = self._normalize_phone(phone)
|
||
|
||
with self._lock:
|
||
bindings = self._load_bindings()
|
||
if phone_normalized in bindings:
|
||
return bindings[phone_normalized].copy()
|
||
return None
|
||
|
||
def is_verified(self, phone: str) -> bool:
|
||
"""Проверить привязан ли номер"""
|
||
return self.lookup(phone) is not None
|
||
|
||
def get_by_address(self, montana_address: str) -> List[str]:
|
||
"""
|
||
Найти все телефонные номера привязанные к Montana адресу.
|
||
|
||
Args:
|
||
montana_address: Montana адрес
|
||
|
||
Returns:
|
||
Список телефонных номеров
|
||
"""
|
||
with self._lock:
|
||
bindings = self._load_bindings()
|
||
phones = []
|
||
for phone, info in bindings.items():
|
||
if info['montana_address'] == montana_address:
|
||
phones.append(phone)
|
||
return phones
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# GLOBAL INSTANCE
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
_real_phone_service: Optional[RealPhoneService] = None
|
||
|
||
def get_real_phone_service(data_dir: Path = None) -> RealPhoneService:
|
||
"""Получить глобальный экземпляр RealPhoneService (singleton)"""
|
||
global _real_phone_service
|
||
if _real_phone_service is None:
|
||
if data_dir is None:
|
||
data_dir = Path(__file__).parent / "data"
|
||
_real_phone_service = RealPhoneService(data_dir)
|
||
return _real_phone_service
|