903 lines
42 KiB
Python
903 lines
42 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
КОНТРАКТЫ MONTANA — Смарт-контракты времени
|
||
============================================
|
||
|
||
Юнона = арбитр, свидетель, валидатор сделок.
|
||
Группа = контрактная комната (до 12 свидетелей).
|
||
|
||
Механика создания:
|
||
1. Диалог создания (Юнона ведёт)
|
||
2. Голосование участников (>50% за)
|
||
3. Юнона проверяет условия (ПОСЛЕДНЕЕ СЛОВО)
|
||
4. Escrow (заморозка средств)
|
||
5. Сторона Б принимает
|
||
|
||
Механика завершения (Bitcoin Pizza Style):
|
||
1. Сторона заявляет о выполнении (/contract done)
|
||
2. Стороны предоставляют доказательства (фото/видео/документы)
|
||
3. ВСЕ участники группы голосуют за валидность
|
||
4. Юнона = 2 голоса, свидетели = 1 голос
|
||
5. При кворуме + одобрении Юноны → COMPLETED
|
||
|
||
Статусы: draft → pending → accepted → completion_voting → completed
|
||
"""
|
||
|
||
import json
|
||
import secrets
|
||
from datetime import datetime, timezone
|
||
from typing import Optional, Dict, List, Tuple, Any
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
|
||
|
||
class ContractStatus(Enum):
|
||
"""Статусы контракта"""
|
||
DRAFT = "draft" # Черновик — ещё собирает голоса
|
||
PENDING = "pending" # Ожидает подтверждения Стороны Б
|
||
ACCEPTED = "accepted" # Принят, условия исполняются
|
||
COMPLETION_VOTING = "completion_voting" # Голосование за завершение
|
||
COMPLETED = "completed" # Исполнен, средства переведены
|
||
CANCELLED = "cancelled" # Отменён, средства разморожены
|
||
REJECTED = "rejected" # Отклонён большинством или Юноной
|
||
|
||
|
||
@dataclass
|
||
class ContractParty:
|
||
"""Сторона контракта"""
|
||
user_id: str
|
||
username: Optional[str] = None
|
||
name: Optional[str] = None # Имя из адресной книги
|
||
|
||
def display_name(self) -> str:
|
||
if self.username:
|
||
return f"@{self.username}"
|
||
if self.name:
|
||
return self.name
|
||
return str(self.user_id)
|
||
|
||
|
||
# Константы голосования
|
||
JUNONA_VOTE_WEIGHT = 2 # Юнона = 2 голоса
|
||
WITNESS_VOTE_WEIGHT = 1 # Свидетель = 1 голос
|
||
JUNONA_USER_ID = "JUNONA" # Специальный ID для голосов Юноны
|
||
|
||
|
||
@dataclass
|
||
class Contract:
|
||
"""Контракт Montana
|
||
|
||
Механика голосования (создание):
|
||
1. Создаётся в статусе DRAFT
|
||
2. Участники голосуют (approve/reject)
|
||
3. Когда большинство (>50%) одобрили — Юнона проверяет
|
||
4. Юнона имеет ПОСЛЕДНЕЕ СЛОВО (может отклонить даже с кворумом)
|
||
5. После одобрения Юноной → PENDING (ждёт Сторону Б)
|
||
|
||
Механика голосования (завершение):
|
||
1. Сторона А заявляет о выполнении → COMPLETION_VOTING
|
||
2. Стороны предоставляют доказательства (фото/видео/документы)
|
||
3. Все участники голосуют за валидность исполнения
|
||
4. Юнона = 2 голоса, свидетели = 1 голос
|
||
5. При достижении большинства + одобрении Юноны → COMPLETED
|
||
"""
|
||
contract_id: str
|
||
creator: ContractParty # Сторона А (отправитель)
|
||
target: ContractParty # Сторона Б (получатель)
|
||
amount: int # Сумма в Ɉ
|
||
description: str # Условие контракта
|
||
status: ContractStatus = ContractStatus.DRAFT
|
||
chat_id: Optional[str] = None # ID группы (если групповой)
|
||
witnesses: List[str] = field(default_factory=list) # ID участников комнаты
|
||
votes_approve: List[str] = field(default_factory=list) # ID тех кто одобрил создание
|
||
votes_reject: List[str] = field(default_factory=list) # ID тех кто отклонил создание
|
||
junona_approved: Optional[bool] = None # Последнее слово Юноны (создание)
|
||
junona_reason: Optional[str] = None # Причина решения Юноны
|
||
# Голосование за завершение
|
||
completion_votes_approve: List[str] = field(default_factory=list) # За исполнение
|
||
completion_votes_reject: List[str] = field(default_factory=list) # Против исполнения
|
||
junona_completion_approved: Optional[bool] = None # Юнона подтвердила завершение
|
||
junona_completion_reason: Optional[str] = None # Причина решения по завершению
|
||
created_at: Optional[str] = None
|
||
accepted_at: Optional[str] = None
|
||
completed_at: Optional[str] = None
|
||
cancelled_at: Optional[str] = None
|
||
escrow_tx_id: Optional[str] = None
|
||
|
||
def to_dict(self) -> Dict:
|
||
return {
|
||
"contract_id": self.contract_id,
|
||
"creator_id": self.creator.user_id,
|
||
"creator_username": self.creator.username,
|
||
"target_id": self.target.user_id,
|
||
"target_username": self.target.username,
|
||
"amount": self.amount,
|
||
"description": self.description,
|
||
"status": self.status.value,
|
||
"chat_id": self.chat_id,
|
||
"witnesses": self.witnesses,
|
||
"votes_approve": self.votes_approve,
|
||
"votes_reject": self.votes_reject,
|
||
"junona_approved": self.junona_approved,
|
||
"junona_reason": self.junona_reason,
|
||
# Голосование за завершение
|
||
"completion_votes_approve": self.completion_votes_approve,
|
||
"completion_votes_reject": self.completion_votes_reject,
|
||
"junona_completion_approved": self.junona_completion_approved,
|
||
"junona_completion_reason": self.junona_completion_reason,
|
||
"created_at": self.created_at,
|
||
"accepted_at": self.accepted_at,
|
||
"completed_at": self.completed_at,
|
||
"cancelled_at": self.cancelled_at,
|
||
"escrow_tx_id": self.escrow_tx_id
|
||
}
|
||
|
||
# ───────────────────────────────────────────────────────────────
|
||
# КОНСЕНСУС: Большинство + Юнона
|
||
# ───────────────────────────────────────────────────────────────
|
||
|
||
def vote(self, user_id: str, approve: bool) -> Tuple[bool, str]:
|
||
"""Участник голосует за/против контракта
|
||
|
||
Returns:
|
||
(success, message)
|
||
"""
|
||
# Можно голосовать только в статусе DRAFT
|
||
if self.status != ContractStatus.DRAFT:
|
||
return False, "Голосование закрыто"
|
||
|
||
# Только участники комнаты могут голосовать
|
||
if user_id not in self.witnesses:
|
||
return False, "Только участники комнаты могут голосовать"
|
||
|
||
# Убираем из предыдущего списка если передумал
|
||
if user_id in self.votes_approve:
|
||
self.votes_approve.remove(user_id)
|
||
if user_id in self.votes_reject:
|
||
self.votes_reject.remove(user_id)
|
||
|
||
# Добавляем голос
|
||
if approve:
|
||
self.votes_approve.append(user_id)
|
||
else:
|
||
self.votes_reject.append(user_id)
|
||
|
||
return True, "Голос принят"
|
||
|
||
def get_quorum_status(self) -> Dict:
|
||
"""Статус кворума
|
||
|
||
Returns:
|
||
{
|
||
"total": количество участников,
|
||
"voted": проголосовало,
|
||
"approve": за,
|
||
"reject": против,
|
||
"has_quorum": достигнут ли кворум (>50% за),
|
||
"ready_for_junona": можно ли отдать на решение Юноне
|
||
}
|
||
"""
|
||
total = len(self.witnesses) if self.witnesses else 1
|
||
approve = len(self.votes_approve)
|
||
reject = len(self.votes_reject)
|
||
voted = approve + reject
|
||
|
||
# Кворум = большинство (>50%) проголосовало ЗА
|
||
has_quorum = approve > total / 2
|
||
|
||
return {
|
||
"total": total,
|
||
"voted": voted,
|
||
"approve": approve,
|
||
"reject": reject,
|
||
"has_quorum": has_quorum,
|
||
"ready_for_junona": has_quorum # Юнона решает когда есть кворум
|
||
}
|
||
|
||
def junona_decision(self, approve: bool, reason: str = None) -> Tuple[bool, str]:
|
||
"""Юнона принимает финальное решение
|
||
|
||
ПОСЛЕДНЕЕ СЛОВО: Юнона может отклонить даже с кворумом
|
||
если условия невыполнимы или опасны.
|
||
|
||
Returns:
|
||
(success, message)
|
||
"""
|
||
if self.status != ContractStatus.DRAFT:
|
||
return False, "Контракт не в статусе ожидания"
|
||
|
||
quorum = self.get_quorum_status()
|
||
if not quorum["ready_for_junona"]:
|
||
return False, f"Нет кворума: {quorum['approve']}/{quorum['total']} (нужно >{quorum['total']//2})"
|
||
|
||
self.junona_approved = approve
|
||
self.junona_reason = reason
|
||
|
||
if approve:
|
||
self.status = ContractStatus.PENDING
|
||
return True, "Контракт одобрен. Ожидает подтверждения Стороны Б"
|
||
else:
|
||
self.status = ContractStatus.REJECTED
|
||
return True, f"Контракт отклонён Юноной: {reason or 'условия невыполнимы'}"
|
||
|
||
# ───────────────────────────────────────────────────────────────
|
||
# ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ (Bitcoin Pizza Style)
|
||
# ───────────────────────────────────────────────────────────────
|
||
|
||
def start_completion_voting(self) -> Tuple[bool, str]:
|
||
"""Запустить голосование за завершение
|
||
|
||
Вызывается когда сторона заявляет о выполнении условий.
|
||
Переводит контракт в статус COMPLETION_VOTING.
|
||
|
||
Returns:
|
||
(success, message)
|
||
"""
|
||
if self.status != ContractStatus.ACCEPTED:
|
||
return False, f"Голосование можно начать только для принятого контракта (сейчас: {self.status.value})"
|
||
|
||
self.status = ContractStatus.COMPLETION_VOTING
|
||
self.completion_votes_approve = []
|
||
self.completion_votes_reject = []
|
||
self.junona_completion_approved = None
|
||
self.junona_completion_reason = None
|
||
|
||
return True, "Голосование за завершение началось. Свидетели, проверьте доказательства!"
|
||
|
||
def vote_completion(self, user_id: str, approve: bool, is_junona: bool = False) -> Tuple[bool, str]:
|
||
"""Голосование за завершение контракта
|
||
|
||
ВЕСА ГОЛОСОВ:
|
||
- Юнона = 2 голоса (JUNONA_VOTE_WEIGHT)
|
||
- Свидетель = 1 голос (WITNESS_VOTE_WEIGHT)
|
||
|
||
Args:
|
||
user_id: ID голосующего
|
||
approve: True = за завершение, False = против
|
||
is_junona: True если голосует Юнона
|
||
|
||
Returns:
|
||
(success, message)
|
||
"""
|
||
if self.status != ContractStatus.COMPLETION_VOTING:
|
||
return False, "Голосование за завершение не активно"
|
||
|
||
# Юнона голосует отдельно
|
||
if is_junona:
|
||
if self.junona_completion_approved is not None:
|
||
return False, "Юнона уже проголосовала"
|
||
self.junona_completion_approved = approve
|
||
weight = JUNONA_VOTE_WEIGHT
|
||
voter = "Юнона"
|
||
else:
|
||
# Только участники комнаты могут голосовать
|
||
if user_id not in self.witnesses:
|
||
return False, "Только участники комнаты могут голосовать"
|
||
|
||
# Убираем из предыдущего списка если передумал
|
||
if user_id in self.completion_votes_approve:
|
||
self.completion_votes_approve.remove(user_id)
|
||
if user_id in self.completion_votes_reject:
|
||
self.completion_votes_reject.remove(user_id)
|
||
|
||
weight = WITNESS_VOTE_WEIGHT
|
||
voter = user_id
|
||
|
||
# Добавляем голос
|
||
if approve:
|
||
if not is_junona:
|
||
self.completion_votes_approve.append(user_id)
|
||
else:
|
||
if not is_junona:
|
||
self.completion_votes_reject.append(user_id)
|
||
|
||
return True, f"Голос принят ({voter}, вес: {weight})"
|
||
|
||
def get_completion_quorum_status(self) -> Dict:
|
||
"""Статус кворума для завершения
|
||
|
||
ВЕСА:
|
||
- Юнона = 2 голоса
|
||
- Свидетель = 1 голос
|
||
|
||
Returns:
|
||
{
|
||
"total_weight": общий вес всех голосов,
|
||
"approve_weight": вес за исполнение,
|
||
"reject_weight": вес против,
|
||
"has_quorum": достигнут ли кворум (>50% за),
|
||
"junona_voted": проголосовала ли Юнона,
|
||
"junona_approved": одобрила ли Юнона,
|
||
"ready_to_complete": можно ли завершить
|
||
}
|
||
"""
|
||
# Считаем веса
|
||
witness_approve = len(self.completion_votes_approve) * WITNESS_VOTE_WEIGHT
|
||
witness_reject = len(self.completion_votes_reject) * WITNESS_VOTE_WEIGHT
|
||
|
||
# Юнона
|
||
junona_weight = 0
|
||
junona_voted = self.junona_completion_approved is not None
|
||
if junona_voted:
|
||
if self.junona_completion_approved:
|
||
junona_weight = JUNONA_VOTE_WEIGHT
|
||
else:
|
||
junona_weight = -JUNONA_VOTE_WEIGHT # Против
|
||
|
||
# Общий вес
|
||
total_witnesses = len(self.witnesses) if self.witnesses else 1
|
||
total_weight = total_witnesses * WITNESS_VOTE_WEIGHT + JUNONA_VOTE_WEIGHT
|
||
|
||
approve_weight = witness_approve + (JUNONA_VOTE_WEIGHT if self.junona_completion_approved else 0)
|
||
reject_weight = witness_reject + (JUNONA_VOTE_WEIGHT if self.junona_completion_approved is False else 0)
|
||
|
||
# Кворум = большинство (>50%) за
|
||
has_quorum = approve_weight > total_weight / 2
|
||
|
||
# Готово к завершению: кворум + Юнона одобрила
|
||
ready_to_complete = has_quorum and self.junona_completion_approved is True
|
||
|
||
return {
|
||
"total_weight": total_weight,
|
||
"approve_weight": approve_weight,
|
||
"reject_weight": reject_weight,
|
||
"witnesses_voted": len(self.completion_votes_approve) + len(self.completion_votes_reject),
|
||
"total_witnesses": total_witnesses,
|
||
"has_quorum": has_quorum,
|
||
"junona_voted": junona_voted,
|
||
"junona_approved": self.junona_completion_approved,
|
||
"ready_to_complete": ready_to_complete
|
||
}
|
||
|
||
def junona_completion_decision(self, approve: bool, reason: str = None) -> Tuple[bool, str]:
|
||
"""Юнона принимает финальное решение по завершению
|
||
|
||
ПОСЛЕДНЕЕ СЛОВО: Даже при кворуме свидетелей, Юнона может отклонить.
|
||
|
||
Returns:
|
||
(success, message)
|
||
"""
|
||
if self.status != ContractStatus.COMPLETION_VOTING:
|
||
return False, "Контракт не в статусе голосования за завершение"
|
||
|
||
self.junona_completion_approved = approve
|
||
self.junona_completion_reason = reason
|
||
|
||
quorum = self.get_completion_quorum_status()
|
||
|
||
if approve and quorum["has_quorum"]:
|
||
self.status = ContractStatus.COMPLETED
|
||
return True, "Контракт исполнен! Свидетели подтвердили, Юнона одобрила."
|
||
elif not approve:
|
||
return True, f"Юнона не подтвердила завершение: {reason or 'требуются дополнительные доказательства'}"
|
||
else:
|
||
return True, f"Юнона одобрила, но нет кворума свидетелей ({quorum['approve_weight']}/{quorum['total_weight']})"
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: Dict) -> "Contract":
|
||
# Парсим JSON поля
|
||
def parse_json_list(val):
|
||
if not val:
|
||
return []
|
||
if isinstance(val, list):
|
||
return val
|
||
try:
|
||
return json.loads(val)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return []
|
||
|
||
return cls(
|
||
contract_id=data["contract_id"],
|
||
creator=ContractParty(
|
||
user_id=data["creator_id"],
|
||
username=data.get("creator_username")
|
||
),
|
||
target=ContractParty(
|
||
user_id=data["target_id"],
|
||
username=data.get("target_username")
|
||
),
|
||
amount=data["amount"],
|
||
description=data.get("description", ""),
|
||
status=ContractStatus(data.get("status", "draft")),
|
||
chat_id=data.get("chat_id"),
|
||
witnesses=parse_json_list(data.get("witnesses")),
|
||
votes_approve=parse_json_list(data.get("votes_approve")),
|
||
votes_reject=parse_json_list(data.get("votes_reject")),
|
||
junona_approved=data.get("junona_approved"),
|
||
junona_reason=data.get("junona_reason"),
|
||
# Голосование за завершение
|
||
completion_votes_approve=parse_json_list(data.get("completion_votes_approve")),
|
||
completion_votes_reject=parse_json_list(data.get("completion_votes_reject")),
|
||
junona_completion_approved=data.get("junona_completion_approved"),
|
||
junona_completion_reason=data.get("junona_completion_reason"),
|
||
created_at=data.get("created_at"),
|
||
accepted_at=data.get("accepted_at"),
|
||
completed_at=data.get("completed_at"),
|
||
cancelled_at=data.get("cancelled_at"),
|
||
escrow_tx_id=data.get("escrow_tx_id")
|
||
)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ГЕНЕРАЦИЯ ID
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def generate_contract_id(prefix: str = "CONTRACT") -> str:
|
||
"""
|
||
Генерирует уникальный ID контракта
|
||
|
||
Формат: PREFIX-XXXX (например PIZZA-A7F3)
|
||
"""
|
||
suffix = secrets.token_hex(2).upper()
|
||
return f"{prefix}-{suffix}"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ФОРМАТИРОВАНИЕ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def format_contract_card(contract: Contract, lang: str = "ru") -> str:
|
||
"""
|
||
Форматирует карточку контракта для отображения в чате
|
||
|
||
Args:
|
||
contract: Объект контракта
|
||
lang: Язык (ru/en/zh)
|
||
"""
|
||
status_emoji = {
|
||
ContractStatus.DRAFT: "📝",
|
||
ContractStatus.PENDING: "⏳",
|
||
ContractStatus.ACCEPTED: "✅",
|
||
ContractStatus.COMPLETION_VOTING: "🗳️",
|
||
ContractStatus.COMPLETED: "🎉",
|
||
ContractStatus.CANCELLED: "❌",
|
||
ContractStatus.REJECTED: "🚫"
|
||
}
|
||
|
||
status_text_map = {
|
||
"ru": {
|
||
ContractStatus.DRAFT: "ГОЛОСОВАНИЕ",
|
||
ContractStatus.PENDING: "ОЖИДАЕТ ПОДТВЕРЖДЕНИЯ",
|
||
ContractStatus.ACCEPTED: "ПРИНЯТ",
|
||
ContractStatus.COMPLETION_VOTING: "ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ",
|
||
ContractStatus.COMPLETED: "ИСПОЛНЕН",
|
||
ContractStatus.CANCELLED: "ОТМЕНЁН",
|
||
ContractStatus.REJECTED: "ОТКЛОНЁН"
|
||
},
|
||
"en": {
|
||
ContractStatus.DRAFT: "VOTING",
|
||
ContractStatus.PENDING: "PENDING CONFIRMATION",
|
||
ContractStatus.ACCEPTED: "ACCEPTED",
|
||
ContractStatus.COMPLETION_VOTING: "COMPLETION VOTING",
|
||
ContractStatus.COMPLETED: "COMPLETED",
|
||
ContractStatus.CANCELLED: "CANCELLED",
|
||
ContractStatus.REJECTED: "REJECTED"
|
||
}
|
||
}
|
||
status_text = status_text_map.get(lang, status_text_map["ru"]).get(
|
||
contract.status, contract.status.value.upper()
|
||
)
|
||
|
||
# Блок участников и голосования (создание)
|
||
participants_str = ""
|
||
if contract.witnesses:
|
||
total = len(contract.witnesses)
|
||
approve = len(contract.votes_approve)
|
||
reject = len(contract.votes_reject)
|
||
participants_str = f"\n║ Участники: {total} чел."
|
||
|
||
# Голосование за создание (если в DRAFT)
|
||
if contract.status == ContractStatus.DRAFT:
|
||
participants_str += f"\n║ Голоса: ✅ {approve} / ❌ {reject}"
|
||
quorum = contract.get_quorum_status()
|
||
if quorum["has_quorum"]:
|
||
participants_str += " (КВОРУМ ✓)"
|
||
else:
|
||
need = total // 2 + 1
|
||
participants_str += f" (нужно {need})"
|
||
|
||
# Блок голосования за завершение (если COMPLETION_VOTING)
|
||
completion_str = ""
|
||
if contract.status == ContractStatus.COMPLETION_VOTING:
|
||
quorum = contract.get_completion_quorum_status()
|
||
completion_str = f"\n║ ─── ГОЛОСОВАНИЕ ЗА ЗАВЕРШЕНИЕ ───"
|
||
completion_str += f"\n║ Голоса: ✅ {quorum['approve_weight']} / ❌ {quorum['reject_weight']} (всего: {quorum['total_weight']})"
|
||
completion_str += f"\n║ Свидетелей: {quorum['witnesses_voted']}/{quorum['total_witnesses']}"
|
||
if quorum['junona_voted']:
|
||
junona_vote = "✅" if quorum['junona_approved'] else "❌"
|
||
completion_str += f"\n║ Юнона (2 голоса): {junona_vote}"
|
||
else:
|
||
completion_str += f"\n║ Юнона: ожидает..."
|
||
if quorum['ready_to_complete']:
|
||
completion_str += f"\n║ 🎯 ГОТОВО К ЗАВЕРШЕНИЮ!"
|
||
|
||
# Решение Юноны (создание)
|
||
junona_str = ""
|
||
if contract.junona_approved is not None and contract.status not in [ContractStatus.COMPLETION_VOTING, ContractStatus.COMPLETED]:
|
||
if contract.junona_approved:
|
||
junona_str = "\n║ 🤖 Юнона: ОДОБРЕНО"
|
||
else:
|
||
reason = contract.junona_reason or "условия невыполнимы"
|
||
junona_str = f"\n║ 🤖 Юнона: ОТКЛОНЕНО ({reason[:30]})"
|
||
|
||
card = f"""
|
||
╔═══════════════════════════════════════════════════╗
|
||
║ КОНТРАКТ MONTANA #{contract.contract_id}
|
||
╠═══════════════════════════════════════════════════╣
|
||
║ Сторона А: {contract.creator.display_name()}
|
||
║ Сторона Б: {contract.target.display_name()}
|
||
║ Сумма: {contract.amount:,} Ɉ
|
||
║ Условие: {contract.description[:40]}{'...' if len(contract.description) > 40 else ''}
|
||
╠═══════════════════════════════════════════════════╣{participants_str}{completion_str}{junona_str}
|
||
║ Статус: {status_emoji.get(contract.status, '❓')} {status_text}
|
||
╚═══════════════════════════════════════════════════╝
|
||
"""
|
||
return card.strip()
|
||
|
||
|
||
def format_contract_mini(contract: Contract) -> str:
|
||
"""Короткий формат для списков"""
|
||
status_emoji = {
|
||
ContractStatus.DRAFT: "📝",
|
||
ContractStatus.PENDING: "⏳",
|
||
ContractStatus.ACCEPTED: "✅",
|
||
ContractStatus.COMPLETION_VOTING: "🗳️",
|
||
ContractStatus.COMPLETED: "🎉",
|
||
ContractStatus.CANCELLED: "❌",
|
||
ContractStatus.REJECTED: "🚫"
|
||
}
|
||
return f"{status_emoji.get(contract.status, '❓')} #{contract.contract_id}: {contract.amount:,} Ɉ → {contract.target.display_name()}"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ВАЛИДАЦИЯ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@dataclass
|
||
class ValidationResult:
|
||
"""Результат валидации"""
|
||
valid: bool
|
||
error: Optional[str] = None
|
||
error_code: Optional[str] = None
|
||
|
||
|
||
def validate_contract_creation(
|
||
creator_id: str,
|
||
target_id: str,
|
||
amount: int,
|
||
creator_balance: int,
|
||
description: str = None
|
||
) -> ValidationResult:
|
||
"""
|
||
Валидирует создание контракта
|
||
|
||
Проверяет:
|
||
- Получатель найден
|
||
- Сумма > 0
|
||
- Баланс >= суммы
|
||
- Описание не пустое
|
||
"""
|
||
# Проверка получателя
|
||
if not target_id:
|
||
return ValidationResult(
|
||
valid=False,
|
||
error="Получатель не указан",
|
||
error_code="NO_TARGET"
|
||
)
|
||
|
||
# Нельзя отправлять себе
|
||
if str(creator_id) == str(target_id):
|
||
return ValidationResult(
|
||
valid=False,
|
||
error="Нельзя создать контракт с самим собой",
|
||
error_code="SELF_CONTRACT"
|
||
)
|
||
|
||
# Проверка суммы
|
||
if amount <= 0:
|
||
return ValidationResult(
|
||
valid=False,
|
||
error="Сумма должна быть больше 0",
|
||
error_code="INVALID_AMOUNT"
|
||
)
|
||
|
||
# Проверка баланса
|
||
if creator_balance < amount:
|
||
return ValidationResult(
|
||
valid=False,
|
||
error=f"Недостаточно средств: {creator_balance} Ɉ < {amount} Ɉ",
|
||
error_code="INSUFFICIENT_BALANCE"
|
||
)
|
||
|
||
# Проверка описания
|
||
if not description or len(description.strip()) < 3:
|
||
return ValidationResult(
|
||
valid=False,
|
||
error="Укажите условие контракта (минимум 3 символа)",
|
||
error_code="NO_DESCRIPTION"
|
||
)
|
||
|
||
return ValidationResult(valid=True)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ЮНОНА — РЕГУЛЯТОР ДОГОВОРЁННОСТЕЙ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@dataclass
|
||
class ExecutabilityCheck:
|
||
"""Результат проверки исполнимости"""
|
||
executable: bool
|
||
confidence: float # 0.0 - 1.0
|
||
concerns: List[str] = field(default_factory=list)
|
||
suggestions: List[str] = field(default_factory=list)
|
||
|
||
|
||
def check_executability(description: str) -> ExecutabilityCheck:
|
||
"""
|
||
Юнона проверяет исполнимость условия контракта
|
||
|
||
Проверяет:
|
||
- Конкретность (не абстрактно ли условие?)
|
||
- Верифицируемость (как понять что выполнено?)
|
||
- Временные рамки (когда должно быть выполнено?)
|
||
- Реалистичность (возможно ли это физически?)
|
||
|
||
Возвращает оценку и рекомендации
|
||
"""
|
||
concerns = []
|
||
suggestions = []
|
||
confidence = 1.0
|
||
|
||
desc_lower = description.lower()
|
||
|
||
# Слишком короткое описание
|
||
if len(description) < 10:
|
||
confidence -= 0.3
|
||
concerns.append("Условие слишком краткое")
|
||
suggestions.append("Опиши подробнее что именно должно быть сделано")
|
||
|
||
# Абстрактные формулировки
|
||
abstract_words = ["помощь", "поддержка", "содействие", "что-то", "как-нибудь"]
|
||
for word in abstract_words:
|
||
if word in desc_lower:
|
||
confidence -= 0.2
|
||
concerns.append(f"Слово '{word}' слишком абстрактно")
|
||
suggestions.append("Укажи конкретный результат")
|
||
|
||
# Нет временных рамок
|
||
time_words = ["сегодня", "завтра", "до", "через", "в течение", "час", "день", "неделя"]
|
||
has_time = any(word in desc_lower for word in time_words)
|
||
if not has_time:
|
||
confidence -= 0.1
|
||
concerns.append("Не указаны сроки")
|
||
suggestions.append("Добавь когда должно быть выполнено")
|
||
|
||
# Как верифицировать?
|
||
verify_words = ["фото", "скриншот", "чек", "видео", "отчёт", "доказательство"]
|
||
has_verification = any(word in desc_lower for word in verify_words)
|
||
if not has_verification and confidence > 0.5:
|
||
suggestions.append("Как я пойму что условие выполнено? Добавь способ подтверждения")
|
||
|
||
# Ограничиваем confidence
|
||
confidence = max(0.1, min(1.0, confidence))
|
||
|
||
return ExecutabilityCheck(
|
||
executable=confidence >= 0.5,
|
||
confidence=confidence,
|
||
concerns=concerns,
|
||
suggestions=suggestions
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class GroupContractRoom:
|
||
"""
|
||
Контрактная комната (группа)
|
||
|
||
Юнона управляет всеми договорённостями в группе.
|
||
До 12 участников могут быть сторонами контрактов.
|
||
Все участники = потенциальные стороны контракта.
|
||
Большинство должно одобрить + Юнона принимает финальное решение.
|
||
"""
|
||
chat_id: str
|
||
participants: List[str] = field(default_factory=list) # До 12 участников
|
||
active_contracts: List[str] = field(default_factory=list) # ID активных контрактов
|
||
total_volume: int = 0 # Общий объём сделок в Ɉ
|
||
_verified_members: List[str] = field(default_factory=list) # Верифицированные через Telegram API
|
||
|
||
MAX_PARTICIPANTS = 12
|
||
|
||
def add_participant(self, user_id: str, verified: bool = False) -> bool:
|
||
"""Добавить участника
|
||
|
||
Args:
|
||
user_id: ID пользователя
|
||
verified: True если подтверждён через Telegram API (реальный участник группы)
|
||
"""
|
||
if len(self.participants) >= self.MAX_PARTICIPANTS:
|
||
return False
|
||
if user_id not in self.participants:
|
||
self.participants.append(user_id)
|
||
if verified:
|
||
self._verified_members.append(user_id)
|
||
return True
|
||
|
||
def is_verified_member(self, user_id: str) -> bool:
|
||
"""Проверяет верифицирован ли участник (через Telegram API)"""
|
||
return user_id in self._verified_members
|
||
|
||
def validate_witnesses(self, witness_ids: List[str]) -> Tuple[bool, str, List[str]]:
|
||
"""
|
||
Валидирует список свидетелей
|
||
|
||
DISNEY-SAFE: Нельзя указать произвольные ID как свидетелей.
|
||
Только верифицированные участники группы.
|
||
|
||
Returns:
|
||
(valid, error_message, valid_witnesses)
|
||
"""
|
||
valid_witnesses = []
|
||
invalid = []
|
||
|
||
for wid in witness_ids:
|
||
if wid in self.participants:
|
||
valid_witnesses.append(wid)
|
||
else:
|
||
invalid.append(wid)
|
||
|
||
if invalid:
|
||
return False, f"Участники {invalid} не в группе", valid_witnesses
|
||
|
||
return True, "OK", valid_witnesses
|
||
|
||
def can_create_contract(self, creator_id: str, target_id: str) -> Tuple[bool, str]:
|
||
"""Проверяет можно ли создать контракт между участниками"""
|
||
if creator_id not in self.participants:
|
||
return False, f"Пользователь {creator_id} не участник комнаты"
|
||
if target_id not in self.participants:
|
||
return False, f"Пользователь {target_id} не участник комнаты"
|
||
return True, "OK"
|
||
|
||
def get_witnesses(self, exclude: List[str] = None) -> List[str]:
|
||
"""Возвращает всех участников кроме исключённых (для голосования)"""
|
||
exclude = exclude or []
|
||
return [p for p in self.participants if p not in exclude]
|
||
|
||
def get_quorum_threshold(self) -> int:
|
||
"""Порог кворума: >50% участников должны одобрить"""
|
||
return len(self.participants) // 2 + 1
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ДИАЛОГ КОНТРАКТА
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class ContractDialogState(Enum):
|
||
"""Состояния диалога создания контракта"""
|
||
IDLE = "idle"
|
||
WAITING_TARGET = "waiting_target"
|
||
WAITING_AMOUNT = "waiting_amount"
|
||
WAITING_DESCRIPTION = "waiting_description"
|
||
WAITING_CONFIRMATION = "waiting_confirmation"
|
||
|
||
|
||
@dataclass
|
||
class ContractDialog:
|
||
"""Диалог создания контракта"""
|
||
creator_id: str
|
||
chat_id: Optional[str] = None
|
||
state: ContractDialogState = ContractDialogState.IDLE
|
||
target_id: Optional[str] = None
|
||
target_username: Optional[str] = None
|
||
amount: Optional[int] = None
|
||
description: Optional[str] = None
|
||
witnesses: List[str] = field(default_factory=list)
|
||
|
||
def next_prompt(self, lang: str = "ru") -> str:
|
||
"""Возвращает следующий вопрос для диалога"""
|
||
prompts = {
|
||
ContractDialogState.WAITING_TARGET: "Кто будет второй стороной? Укажи @username, ID или имя из контактов",
|
||
ContractDialogState.WAITING_AMOUNT: "Какую сумму хочешь заморозить в контракте? (в Ɉ)",
|
||
ContractDialogState.WAITING_DESCRIPTION: "Что получишь взамен? Опиши условие контракта",
|
||
ContractDialogState.WAITING_CONFIRMATION: "Проверь контракт. Всё верно?"
|
||
}
|
||
return prompts.get(self.state, "")
|
||
|
||
def is_complete(self) -> bool:
|
||
"""Проверяет готовность к созданию контракта"""
|
||
return all([
|
||
self.target_id,
|
||
self.amount and self.amount > 0,
|
||
self.description
|
||
])
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# РЕЗОЛВЕР ПОЛУЧАТЕЛЯ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def resolve_target(
|
||
input_str: str,
|
||
contacts: List[Dict] = None,
|
||
reply_user_id: str = None
|
||
) -> Tuple[Optional[str], Optional[str], str]:
|
||
"""
|
||
Резолвит получателя из разных форматов
|
||
|
||
Args:
|
||
input_str: Ввод пользователя (@username, ID, имя)
|
||
contacts: Адресная книга пользователя
|
||
reply_user_id: ID из reply на сообщение
|
||
|
||
Returns:
|
||
(user_id, username, method) или (None, None, error)
|
||
"""
|
||
input_str = input_str.strip()
|
||
|
||
# 1. Reply на сообщение
|
||
if reply_user_id:
|
||
return reply_user_id, None, "reply"
|
||
|
||
# 2. @username
|
||
if input_str.startswith("@"):
|
||
username = input_str[1:]
|
||
# В реальности нужно резолвить через Telegram API
|
||
return None, username, "username"
|
||
|
||
# 3. Числовой ID
|
||
if input_str.isdigit():
|
||
return input_str, None, "id"
|
||
|
||
# 4. Поиск в адресной книге
|
||
if contacts:
|
||
for contact in contacts:
|
||
if contact.get("name", "").lower() == input_str.lower():
|
||
return contact["target_id"], contact.get("target_username"), "contacts"
|
||
|
||
return None, None, f"Не удалось найти '{input_str}'. Укажи @username или ID"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# УТИЛИТЫ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def now_iso() -> str:
|
||
"""Текущее время в ISO формате"""
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def parse_amount(text: str) -> Optional[int]:
|
||
"""Парсит сумму из текста"""
|
||
# Убираем пробелы, запятые, символ валюты
|
||
cleaned = text.replace(" ", "").replace(",", "").replace("Ɉ", "").strip()
|
||
try:
|
||
amount = int(cleaned)
|
||
return amount if amount > 0 else None
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
if __name__ == "__main__":
|
||
# Пример создания контракта
|
||
contract = Contract(
|
||
contract_id=generate_contract_id("PIZZA"),
|
||
creator=ContractParty(user_id="8552053404", username="financier"),
|
||
target=ContractParty(user_id="987654321", username="pizza_place"),
|
||
amount=10000,
|
||
description="Большая пицца пепперони за консультацию",
|
||
chat_id="-1001234567890",
|
||
witnesses=["111", "222", "333"],
|
||
created_at=now_iso()
|
||
)
|
||
|
||
print(format_contract_card(contract))
|
||
print()
|
||
print("Mini:", format_contract_mini(contract))
|
||
print()
|
||
print("Dict:", json.dumps(contract.to_dict(), indent=2, ensure_ascii=False))
|