Files
B.Tech-Project-III/negot8/backend/telegram-bots/bot.py
2026-04-05 00:43:23 +05:30

1719 lines
73 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
from telegram import Update, BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application, CommandHandler, MessageHandler, CallbackQueryHandler,
filters, ContextTypes, ConversationHandler
)
from agents.personal_agent import PersonalAgent
from agents.matching_agent import score_applicant as ai_score_applicant
from voice.elevenlabs_tts import generate_voice_summary, build_voice_text
from tools.upi_generator import UPIGeneratorTool
from features.base_feature import get_feature
import database as db
from config import TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B, VOICE_ID_AGENT_A, VOICE_ID_AGENT_B
# ─── Socket.IO emitters (optional — only active when api.py is co-running) ───
try:
from api import emit_round_update, emit_negotiation_started, emit_negotiation_resolved
_sio_available = True
except ImportError:
_sio_available = False
async def emit_round_update(*args, **kwargs): pass
async def emit_negotiation_started(*args, **kwargs): pass
async def emit_negotiation_resolved(*args, **kwargs): pass
_upi_tool = UPIGeneratorTool()
personal_agent = PersonalAgent()
# ─── Conversation states ───
AWAITING_PREFERENCES = 1 # User A describing their side
AWAITING_COUNTERPARTY_PREFS = 2 # User B describing their side
AWAITING_CONTRACT_DESCRIPTION = 3 # Poster describing an open contract
AWAITING_CONTRACT_APPLICATION = 4 # Seeker submitting their application
# ─── Shared in-memory store for pending coordination requests ───
# Key: counterparty username (lowercase), Value: coordination metadata
pending_coordinations = {}
# ─── Registry of running bot applications (for cross-bot messaging) ───
# Key: bot token, Value: Application instance
bot_apps: dict = {}
def register_bot(token: str, app: "Application") -> None:
"""Register a bot application so other bots can use it to send messages."""
bot_apps[token] = app
async def send_to_user(user_id: int, text: str, parse_mode: str = "Markdown",
reply_markup=None) -> bool:
"""
Send a message to any user via whichever registered bot can reach them.
Returns True if sent, False if no bot could reach the user.
"""
for app in bot_apps.values():
try:
await app.bot.send_message(
chat_id=user_id, text=text, parse_mode=parse_mode,
reply_markup=reply_markup
)
return True
except Exception:
continue
return False
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
await db.create_user(user.id, user.username, user.first_name)
await update.message.reply_text(
f"🤖 *Welcome to negoT8, {user.first_name}!*\n\n"
"I'm your personal AI agent. When you need to coordinate with someone, "
"I'll talk to their agent and we'll figure it out together.\n\n"
"🎭 Set your agent's style: /personality\n"
"📅 Connect your calendar: /connectcalendar _(optional, for auto-availability)_\n"
"🤝 Start coordinating: `/coordinate @friend`\n\n"
"Then just tell me what you need in plain English.",
parse_mode="Markdown"
)
async def connect_calendar_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Send the Google OAuth URL so the user can connect their calendar."""
from tools.google_calendar import GoogleCalendarTool
user = update.effective_user
cal = GoogleCalendarTool()
# Check if already connected
if await cal.is_connected(user.id):
await update.message.reply_text(
"📅 *Google Calendar already connected!*\n\n"
"Your agent automatically checks your calendar for free slots "
"whenever you schedule a meeting without specifying times.\n\n"
"To disconnect, revoke access at myaccount.google.com → Security → Third-party apps.",
parse_mode="Markdown",
)
return
try:
url = await cal.get_oauth_url(user.id)
except Exception as e:
await update.message.reply_text(
f"⚠️ Could not generate calendar link: {e}\n"
"Make sure GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set in .env."
)
return
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("🔗 Connect Google Calendar", url=url)]
])
await update.message.reply_text(
"📅 *Connect Your Google Calendar*\n\n"
"Tap below to grant *read-only* access to your calendar.\n"
"Your agent will use it to automatically find your free slots "
"when scheduling meetings — no need to type out your availability.\n\n"
"🔒 _Read-only. Revoke anytime from your Google account._",
reply_markup=keyboard,
parse_mode="Markdown",
)
async def personality_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Let user choose their agent's negotiation personality."""
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("😤 Aggressive Haggler", callback_data="personality_aggressive")],
[InlineKeyboardButton("🤝 People Pleaser", callback_data="personality_people_pleaser")],
[InlineKeyboardButton("📊 Data-Driven Analyst", callback_data="personality_analytical")],
[InlineKeyboardButton("💚 Empathetic Mediator", callback_data="personality_empathetic")],
[InlineKeyboardButton("⚖️ Balanced (Default)", callback_data="personality_balanced")],
])
await update.message.reply_text(
"🎭 *Choose your agent's negotiation personality:*\n\n"
"This changes HOW your agent negotiates — not what it negotiates for.\n\n"
"😤 *Aggressive* — Pushes hard, concedes slowly\n"
"🤝 *People Pleaser* — Concedes fast, preserves relationship\n"
"📊 *Analytical* — Cites data, logical arguments\n"
"💚 *Empathetic* — Creative win-wins, understands both sides\n"
"⚖️ *Balanced* — Middle-ground approach",
reply_markup=keyboard,
parse_mode="Markdown"
)
async def personality_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle personality selection."""
query = update.callback_query
await query.answer()
personality = query.data.replace("personality_", "")
user_id = query.from_user.id
await db.update_user_personality(user_id, personality)
labels = {
"aggressive": "😤 Aggressive Haggler",
"people_pleaser": "🤝 People Pleaser",
"analytical": "📊 Data-Driven Analyst",
"empathetic": "💚 Empathetic Mediator",
"balanced": "⚖️ Balanced"
}
await query.edit_message_text(
f"✅ Agent personality set to: {labels[personality]}\n\n"
"Your agent will use this style in all future negotiations.\n"
"Change anytime with /personality"
)
async def coordinate_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not context.args:
await update.message.reply_text(
"🤝 *Who do you want to coordinate with?*\n\n"
"Usage: `/coordinate @username`\n\n"
"Then describe what you need. Examples:\n"
"• Find time for coffee next week\n"
"• Split our trip expenses (include your UPI ID!)\n"
"• Negotiate project scope and budget\n"
"• Plan a weekend trip\n"
"• Decide where to eat tonight\n"
"• ...literally anything that needs agreement!",
parse_mode="Markdown"
)
return ConversationHandler.END
counterparty = context.args[0].replace("@", "")
context.user_data["counterparty"] = counterparty
await update.message.reply_text(
f"🤖 Got it! I'll coordinate with *@{counterparty}*'s agent.\n\n"
"Now tell me what you need — describe it naturally.\n"
"Include any specific numbers, dates, or constraints.\n"
"💡 For expenses: mention your UPI ID for auto-payment links!",
parse_mode="Markdown"
)
return AWAITING_PREFERENCES
async def receive_preferences(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_message = update.message.text
user = update.effective_user
counterparty = context.user_data.get("counterparty", "unknown")
await update.message.chat.send_action("typing")
preferences = await personal_agent.extract_preferences(user_message, user.id)
if "error" in preferences:
await update.message.reply_text(
"⚠️ I had trouble understanding that. Could you rephrase?\n"
"Try to include specific details like dates, amounts, or preferences."
)
return AWAITING_PREFERENCES
feature_type = preferences.get("feature_type", "generic")
# Get user's personality
user_data = await db.get_user(user.id)
personality = dict(user_data).get("personality", "balanced") if user_data else "balanced"
negotiation_id = await db.create_negotiation(feature_type, user.id)
await db.add_participant(negotiation_id, user.id, preferences, personality_used=personality)
pending_coordinations[negotiation_id] = {
"negotiation_id": negotiation_id,
"counterparty_username": counterparty.lower(),
"initiator_id": user.id,
"initiator_name": user.first_name,
"feature_type": feature_type,
"preferences_a": preferences,
"personality_a": personality,
"status": "pending"
}
feature_labels = {
"scheduling": "📅 Meeting Scheduling", "expenses": "💰 Expense Splitting",
"freelance": "💼 Project Negotiation", "roommate": "🏠 Roommate Decision",
"trip": "✈️ Trip Planning", "marketplace": "🛒 Buy/Sell Deal",
"collaborative": "🍕 Joint Decision", "conflict": "⚖️ Conflict Resolution",
"generic": "🤝 Coordination"
}
personality_emoji = {"aggressive": "😤", "people_pleaser": "🤝", "analytical": "📊", "empathetic": "💚", "balanced": "⚖️"}
await update.message.reply_text(
f"✅ *Preferences captured!*\n\n"
f"📋 Type: {feature_labels.get(feature_type, '🤝 Coordination')}\n"
f"🤝 With: @{counterparty}\n"
f"🎭 Agent style: {personality_emoji.get(personality, '⚖️')} {personality.replace('_', ' ').title()}\n"
f"🆔 Negotiation: `{negotiation_id}`\n\n"
f"I've notified @{counterparty}'s agent.\n"
f"Once they share their side, our agents will negotiate automatically.\n\n"
f"_I'll send you real-time updates + a voice summary at the end!_ 🤖↔️🤖🔊",
parse_mode="Markdown"
)
return ConversationHandler.END
# ─────────────────────────────────────────────────────────────
# /pending — Counterparty (User B) checks for incoming requests
# /accept_<neg_id> callback — User B accepts and provides prefs
# ─────────────────────────────────────────────────────────────
async def pending_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Show User B all pending coordination requests directed at their username.
Works by looking up `pending_coordinations` by the user's Telegram username.
"""
user = update.effective_user
username = (user.username or "").lower()
if not username:
await update.message.reply_text(
"⚠️ You need a Telegram username for negoT8 to work.\n"
"Set one in Telegram Settings → Edit Profile → Username."
)
return
# Collect all pending requests aimed at this user
matching = {
neg_id: data
for neg_id, data in pending_coordinations.items()
if data.get("counterparty_username", "").lower() == username
and data.get("status", "pending") == "pending"
}
if not matching:
await update.message.reply_text(
"📭 *No pending coordination requests.*\n\n"
"When someone does `/coordinate @you`, it'll appear here.\n"
"Use /personality to set your style before accepting!",
parse_mode="Markdown"
)
return
feature_labels = {
"scheduling": "📅 Meeting Scheduling", "expenses": "💰 Expense Splitting",
"freelance": "💼 Project Negotiation", "roommate": "🏠 Roommate Decision",
"trip": "✈️ Trip Planning", "marketplace": "🛒 Buy/Sell Deal",
"collaborative": "🍕 Joint Decision", "conflict": "⚖️ Conflict Resolution",
"generic": "🤝 Coordination"
}
for neg_id, data in matching.items():
initiator = data.get("initiator_name", "Someone")
feature = feature_labels.get(data.get("feature_type", "generic"), "🤝 Coordination")
# Quick summary of what initiator wants
prefs = data.get("preferences_a", {})
goal = prefs.get("goal", "coordinate with you")
keyboard = InlineKeyboardMarkup([[
InlineKeyboardButton("✅ Accept & Share My Side", callback_data=f"accept_{neg_id}"),
InlineKeyboardButton("❌ Decline", callback_data=f"decline_{neg_id}"),
]])
await update.message.reply_text(
f"📬 *Incoming request from {initiator}*\n\n"
f"📋 Type: {feature}\n"
f"🎯 They want to: _{goal}_\n"
f"🆔 ID: `{neg_id}`\n\n"
"Tap *Accept* to share your preferences and let agents negotiate!",
reply_markup=keyboard,
parse_mode="Markdown"
)
async def accept_pending_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Inline-button handler: User B taps Accept → prompt for their preferences.
Stores neg_id in user_data so the next text message is routed correctly.
"""
query = update.callback_query
await query.answer()
action, neg_id = query.data.split("_", 1)
if action == "decline":
# Get initiator_id BEFORE popping
data = pending_coordinations.get(neg_id, {})
initiator_id = data.get("initiator_id")
pending_coordinations.pop(neg_id, None)
await query.edit_message_text(
"❌ Request declined. The other party will be notified."
)
if initiator_id:
await send_to_user(
initiator_id,
"❌ @{} declined the coordination request.".format(
(query.from_user.username or query.from_user.first_name)
)
)
return
# Accept path
data = pending_coordinations.get(neg_id)
if not data:
await query.edit_message_text("⚠️ This request has expired or already been handled.")
return
# Mark as accepted so it doesn't show again in /pending
pending_coordinations[neg_id]["status"] = "accepted"
context.user_data["pending_neg_id"] = neg_id
initiator = data.get("initiator_name", "the other person")
feature_type = data.get("feature_type", "generic")
feature_labels = {
"scheduling": "meeting scheduling", "expenses": "expense splitting",
"freelance": "project negotiation", "roommate": "roommate decision",
"trip": "trip planning", "marketplace": "buy/sell deal",
"collaborative": "joint decision", "conflict": "conflict resolution",
"generic": "coordination"
}
feature_label = feature_labels.get(feature_type, "coordination")
await query.edit_message_text(
f"✅ *Accepted!*\n\n"
f"Now tell me YOUR side for the {feature_label} with *{initiator}*.\n\n"
"Describe your preferences, constraints, and goals in plain English.\n"
"💡 Include specific numbers, dates, or your UPI ID if it's an expense split.",
parse_mode="Markdown"
)
# Signal the ConversationHandler to move into the counterparty-prefs collection state
context.user_data["awaiting_counterparty_prefs"] = True
return AWAITING_COUNTERPARTY_PREFS
async def receive_counterparty_preferences(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
User B's free-text preferences after accepting.
1. Extract structured preferences via PersonalAgent
2. Persist both participants in DB
3. Run negotiation engine with live Telegram callbacks
"""
if not context.user_data.get("awaiting_counterparty_prefs"):
return # Not in counterparty flow
user = update.effective_user
user_message = update.message.text
neg_id = context.user_data.get("pending_neg_id")
if not neg_id or neg_id not in pending_coordinations:
await update.message.reply_text(
"⚠️ Couldn't find the negotiation. It may have expired. Ask the other person to resend."
)
context.user_data.pop("awaiting_counterparty_prefs", None)
return
await update.message.chat.send_action("typing")
data = pending_coordinations[neg_id]
feature_type = data.get("feature_type", "generic")
# ── Extract User B's preferences ──
preferences_b = await personal_agent.extract_preferences(user_message, user.id)
if "error" in preferences_b:
await update.message.reply_text(
"⚠️ I had trouble understanding that. Please rephrase and try again.\n"
"Tip: include specific details like dates, amounts, or preferences."
)
return # Stay in counterparty pref state
# ── Ensure User B is registered ──
await db.create_user(user.id, user.username, user.first_name)
# ── Get User B's personality ──
user_b_data = await db.get_user(user.id)
personality_b = dict(user_b_data).get("personality", "balanced") if user_b_data else "balanced"
# ── Persist User B as participant ──
await db.add_participant(
neg_id, user.id, preferences_b, personality_used=personality_b
)
# Mark negotiation as active
pending_coordinations[neg_id]["status"] = "running"
pending_coordinations[neg_id]["user_b_id"] = user.id
personality_emoji = {
"aggressive": "😤", "people_pleaser": "🤝",
"analytical": "📊", "empathetic": "💚", "balanced": "⚖️"
}
await update.message.reply_text(
f"🤖 *Got it! Starting negotiation…*\n\n"
f"🎭 Your agent style: {personality_emoji.get(personality_b, '⚖️')} "
f"{personality_b.replace('_', ' ').title()}\n\n"
f"🤖↔️🤖 *Agents are negotiating — stand by for updates!*",
parse_mode="Markdown"
)
# ── Clear conversation state ──
context.user_data.pop("awaiting_counterparty_prefs", None)
context.user_data.pop("pending_neg_id", None)
# ── Emit negotiation_started to dashboard ──
try:
participants_info = [
{"user_id": initiator_id, "personality": personality_a},
{"user_id": user.id, "personality": personality_b},
]
await emit_negotiation_started(neg_id, feature_type, participants_info)
except Exception as e:
print(f"[Socket.IO] emit_negotiation_started failed (non-critical): {e}")
# ── Run negotiation with live Telegram callbacks ──
initiator_id = data["initiator_id"]
# Shallow-copy preferences so we don't mutate the stored dict in pending_coordinations
preferences_a = dict(data["preferences_a"])
preferences_b = dict(preferences_b)
# Inject real names so the NegotiatorAgent refers to users by name in reasoning
preferences_a["human_name"] = data.get("initiator_name") or "User A"
preferences_b["human_name"] = (user.first_name or user.username or "User B")
personality_a = data.get("personality_a", "balanced")
asyncio.create_task(
run_negotiation_with_telegram_updates(
negotiation_id=neg_id,
preferences_a=preferences_a,
preferences_b=preferences_b,
user_a_id=initiator_id,
user_b_id=user.id,
feature_type=feature_type,
personality_a=personality_a,
personality_b=personality_b,
)
)
return ConversationHandler.END
async def run_negotiation_with_telegram_updates(
negotiation_id: str,
preferences_a: dict,
preferences_b: dict,
user_a_id: int,
user_b_id: int,
feature_type: str,
personality_a: str = "balanced",
personality_b: str = "balanced",
):
"""
Runs the negotiation engine and pushes real-time round updates + resolution
to both users via Telegram messages.
"""
from agents.negotiation import run_negotiation
# ── Pre-fetch feature-specific context (Tavily, Calculator, etc.) ──
feature = get_feature(feature_type)
try:
await send_to_user(user_a_id, "🔍 _Gathering real-world data for your negotiation…_")
feature_context = await feature.get_context(
preferences_a, preferences_b,
user_a_id=user_a_id, user_b_id=user_b_id,
)
except Exception as e:
print(f"Feature context fetch failed (non-fatal): {e}")
feature_context = ""
personality_emoji = {
"aggressive": "😤", "people_pleaser": "🤝",
"analytical": "📊", "empathetic": "💚", "balanced": "⚖️"
}
action_labels = {
"propose": "📤 Proposed",
"counter": "🔄 Counter-proposed",
"accept": "✅ Accepted",
"escalate": "⚠️ Escalated to humans",
}
async def on_round(data: dict):
round_num = data["round_number"]
action = data.get("action", "counter")
reasoning = data.get("reasoning", "")
sat_a = data.get("satisfaction_a", 0)
sat_b = data.get("satisfaction_b", 0)
label = action_labels.get(action, action)
name_a = preferences_a.get("human_name") or "Party A"
name_b = preferences_b.get("human_name") or "Party B"
# Telegram hard limit is 4096 chars — truncate reasoning there with a note
max_reasoning = 3800
if len(reasoning) > max_reasoning:
reasoning = reasoning[:max_reasoning] + "… _(truncated)_"
msg = (
f"🤖 *Round {round_num}* — {label}\n\n"
f"_{reasoning}_\n\n"
f"📊 *{name_a}:* {sat_a:.0f}% | *{name_b}:* {sat_b:.0f}%"
)
await send_to_user(user_a_id, msg)
await send_to_user(user_b_id, msg)
# ── Emit to dashboard via Socket.IO ──
try:
await emit_round_update(negotiation_id, data)
except Exception as e:
print(f"[Socket.IO] emit_round_update failed (non-critical): {e}")
async def on_resolution(data: dict):
import json
status = data.get("status", "resolved")
rounds_taken = data.get("rounds_taken", "?")
summary = data.get("summary", "Agreement reached")
final = data.get("final_proposal", {})
sat_timeline = data.get("satisfaction_timeline", [])
final_sat_a = sat_timeline[-1]["score_a"] if sat_timeline else 0
final_sat_b = sat_timeline[-1]["score_b"] if sat_timeline else 0
# ── Use feature-specific formatted resolution message ──
try:
details_text = feature.format_resolution(data, preferences_a, preferences_b)
except Exception as e:
print(f"Feature format_resolution failed (non-fatal): {e}")
if status == "resolved":
details_text = f"✅ *Agreement Reached!*\n\n_{summary}_"
else:
details_text = f"⚠️ *Escalated — Human decision needed*\n\n_{summary}_"
# ── UPI button (for payment-related features) ──
upi_markup = None
if feature_type in ("expenses", "freelance", "marketplace", "roommate"):
details = final.get("details", {})
settlement = details.get("settlement", {})
# Resolve UPI ID: cast a wide net over every key the LLM might use
raw_details_a = preferences_a.get("raw_details", {})
raw_details_b = preferences_b.get("raw_details", {})
upi_id = (
raw_details_a.get("upi_id") or raw_details_a.get("upi") or raw_details_a.get("upi_address")
or raw_details_b.get("upi_id") or raw_details_b.get("upi") or raw_details_b.get("upi_address")
or details.get("upi_id") or details.get("upi") or details.get("upiid")
or details.get("payee_upi") or details.get("receiver_upi") or details.get("pay_to")
or settlement.get("upi_id") or settlement.get("upi") or settlement.get("payee_upi")
or final.get("upi_id") or final.get("upi")
)
# Resolve amount: settlement > details > final proposal top-level
raw_amount = (
settlement.get("amount") or settlement.get("total")
or details.get("amount") or details.get("price") or details.get("total_amount")
or details.get("split_amount") or details.get("owed_amount")
or final.get("amount") or final.get("price")
)
if upi_id and raw_amount:
try:
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
pay_amount = float(raw_amount)
payee_name = (
settlement.get("payee_name")
or details.get("seller") or details.get("payee_name")
or "User"
)
upi_result = await _upi_tool.execute(
payee_upi=upi_id,
payee_name=payee_name,
amount=pay_amount,
note=f"negoT8: {feature_type} settlement"
)
upi_link = upi_result.get("upi_link", "")
if upi_link:
details_text += f"\n\n💳 *Tap to pay:* ₹{pay_amount:,.0f}\n🔗 UPI: `{upi_id}`"
upi_markup = InlineKeyboardMarkup([[
InlineKeyboardButton(
f"💳 Pay ₹{pay_amount:,.0f}",
url=upi_link
)
]])
except Exception as e:
print(f"UPI link generation failed: {e}")
name_a = preferences_a.get("human_name") or "Party A"
name_b = preferences_b.get("human_name") or "Party B"
msg = (
f"{details_text}\n\n"
f"📊 *{name_a}:* {final_sat_a:.0f}% | *{name_b}:* {final_sat_b:.0f}%"
)
await send_to_user(user_a_id, msg, reply_markup=upi_markup)
await send_to_user(user_b_id, msg, reply_markup=upi_markup)
# ── Voice summary (different voices for A and B) ──
try:
voice_context = {
"rounds": rounds_taken, "summary": summary,
**final.get("details", {}),
**{k: v for k, v in final.items() if k not in ("details", "summary")}
}
voice_text = build_voice_text(feature_type, voice_context)
voice_path_a = await generate_voice_summary(
voice_text, negotiation_id, VOICE_ID_AGENT_A
)
if voice_path_a:
for app in bot_apps.values():
try:
with open(voice_path_a, "rb") as f:
await app.bot.send_voice(
chat_id=user_a_id, voice=f,
caption="🎙 Voice summary from your agent"
)
break
except Exception:
continue
voice_path_b = await generate_voice_summary(
voice_text, f"{negotiation_id}_b", VOICE_ID_AGENT_B
)
if voice_path_b:
for app in bot_apps.values():
try:
with open(voice_path_b, "rb") as f:
await app.bot.send_voice(
chat_id=user_b_id, voice=f,
caption="🎙 Voice summary from your agent"
)
break
except Exception:
continue
except Exception as e:
print(f"Voice summary failed (non-fatal): {e}")
# ── Store analytics ──
try:
rounds_db = await db.get_rounds(negotiation_id)
concession_log = []
for r in rounds_db:
concessions = json.loads(r["concessions_made"]) if r["concessions_made"] else []
for c in concessions:
party = "A" if r["proposer_id"] == user_a_id else "B"
concession_log.append({"round": r["round_number"], "by": party, "gave_up": c})
fairness = 100 - abs(final_sat_a - final_sat_b)
await db.store_analytics({
"negotiation_id": negotiation_id,
"satisfaction_timeline": json.dumps(sat_timeline),
"concession_log": json.dumps(concession_log),
"fairness_score": fairness,
"total_concessions_a": sum(1 for c in concession_log if c["by"] == "A"),
"total_concessions_b": sum(1 for c in concession_log if c["by"] == "B"),
})
except Exception as e:
print(f"Analytics storage failed (non-fatal): {e}")
# ── Register on Polygon Amoy + send blockchain badge ──
if status == "resolved":
proof = None
try:
from blockchain_web3.blockchain import register_agreement_on_chain
print(f"[Blockchain] Registering {negotiation_id} on-chain...")
proof = await register_agreement_on_chain(
negotiation_id = negotiation_id,
feature_type = feature_type,
summary = final.get("summary", summary),
resolution_data = data,
)
print(f"[Blockchain] Result: success={proof.get('success')} mock={proof.get('mock')} tx={proof.get('tx_hash','')[:20]}")
except Exception as e:
print(f"[Blockchain] register_agreement_on_chain FAILED: {e}")
import traceback; traceback.print_exc()
# Always store to DB (even mock proofs) so /proof always works
if proof:
try:
await db.store_blockchain_proof(
negotiation_id = negotiation_id,
tx_hash = proof["tx_hash"],
block_number = proof.get("block_number", 0),
agreement_hash = proof["agreement_hash"],
explorer_url = proof.get("explorer_url", ""),
gas_used = proof.get("gas_used", 0),
)
print(f"[Blockchain] DB proof stored for {negotiation_id}")
except Exception as e:
print(f"[Blockchain] store_blockchain_proof FAILED: {e}")
import traceback; traceback.print_exc()
# Build the badge message
if proof and proof.get("success") and not proof.get("mock"):
explorer_url = proof["explorer_url"]
badge = (
f"🔗 *Agreement Verified on Blockchain*\n\n"
f"Permanently recorded on *Polygon Amoy*\.\n\n"
f"🏷 Negotiation: `{negotiation_id}`\n"
f"⛓ Block: `{proof['block_number']}`\n"
f"🔐 Hash: `{str(proof['agreement_hash'])[:22]}\.\.\.`\n\n"
f"[🔍 View Proof on PolygonScan]({explorer_url})\n\n"
f"_Use /proof {negotiation_id} anytime to retrieve this\._"
)
elif proof and proof.get("mock"):
badge = (
f"🔗 *Agreement Recorded*\n\n"
f"🏷 Negotiation: `{negotiation_id}`\n"
f"🔐 Hash: `{str(proof.get('agreement_hash',''))[:22]}\.\.\.`\n\n"
f"_Blockchain proof stored\. Use /proof {negotiation_id} to view\._"
)
else:
badge = (
f"🔗 *Agreement Saved*\n\n"
f"🏷 Negotiation ID: `{negotiation_id}`\n\n"
f"_Use /proof {negotiation_id} to check blockchain status\._"
)
await send_to_user(user_a_id, badge)
await send_to_user(user_b_id, badge)
# ── Generate & send Deal Agreement PDF (freelance / marketplace) ──
if feature_type in ("freelance", "marketplace"):
try:
from tools.pdf_generator import generate_deal_pdf
user_a_row = await db.get_user(user_a_id)
user_b_row = await db.get_user(user_b_id)
user_a_info = {
"id": user_a_id,
"name": user_a_row["display_name"] if user_a_row else "",
"username": user_a_row["username"] if user_a_row else "",
}
user_b_info = {
"id": user_b_id,
"name": user_b_row["display_name"] if user_b_row else "",
"username": user_b_row["username"] if user_b_row else "",
}
pdf_path = await generate_deal_pdf(
negotiation_id = negotiation_id,
feature_type = feature_type,
final_proposal = final,
user_a = user_a_info,
user_b = user_b_info,
rounds_taken = rounds_taken,
sat_a = final_sat_a,
sat_b = final_sat_b,
preferences_a = preferences_a,
preferences_b = preferences_b,
blockchain_proof= proof,
)
caption = (
f"📄 *Deal Agreement — {negotiation_id[:8].upper()}*\n"
f"Your official deal document. Keep this for your records."
)
for uid in (user_a_id, user_b_id):
for app in bot_apps.values():
try:
with open(pdf_path, "rb") as f:
await app.bot.send_document(
chat_id = uid,
document = f,
filename = f"negoT8_Deal_{negotiation_id[:8].upper()}.pdf",
caption = caption,
parse_mode = "Markdown",
)
break
except Exception:
continue
# Clean up temp file
try:
os.remove(pdf_path)
except OSError:
pass
except Exception as e:
print(f"[PDF] Deal agreement generation failed (non-fatal): {e}")
import traceback; traceback.print_exc()
# ── Emit final resolution to dashboard via Socket.IO ──
try:
await emit_negotiation_resolved(negotiation_id, data)
except Exception as e:
print(f"[Socket.IO] emit_negotiation_resolved failed (non-critical): {e}")
try:
if feature_type == "trip":
# Trip planning: use group negotiation (supports 2+ people)
from features.trip import run_group_negotiation
await run_group_negotiation(
negotiation_id=negotiation_id,
all_preferences=[preferences_a, preferences_b],
all_user_ids=[user_a_id, user_b_id],
feature_type=feature_type,
personalities=[personality_a, personality_b],
on_round_update=on_round,
on_resolution=on_resolution,
)
else:
await run_negotiation(
negotiation_id=negotiation_id,
preferences_a=preferences_a,
preferences_b=preferences_b,
user_a_id=user_a_id,
user_b_id=user_b_id,
feature_type=feature_type,
personality_a=personality_a,
personality_b=personality_b,
on_round_update=on_round,
on_resolution=on_resolution,
feature_context=feature_context,
)
# Clean up pending_coordinations entry
pending_coordinations.pop(negotiation_id, None)
except Exception as e:
err_msg = f"❌ Negotiation error: {e}\nPlease try again."
await send_to_user(user_a_id, err_msg)
await send_to_user(user_b_id, err_msg)
# ─────────────────────────────────────────────────────────────────────────────
# OPEN CONTRACTS — Public Opportunity Marketplace
# /opencontract — Poster creates a public opportunity
# /open_opportunities — Seeker browses & applies
# /mycontracts — Poster reviews applicants + triggers negotiation
# ─────────────────────────────────────────────────────────────────────────────
_CONTRACT_TYPE_LABELS = {
"freelance": "💼 Freelance Project",
"scheduling": "📅 Job Interview / Meeting",
"marketplace": "🛒 Buy / Sell",
"generic": "🤝 General Opportunity",
"expenses": "💰 Shared Expense",
"collaborative": "🍕 Collaborative Decision",
"conflict": "⚖️ Conflict Resolution",
"roommate": "🏠 Roommate / Living",
"trip": "✈️ Trip",
}
async def opencontract_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Poster kicks off a new open contract listing."""
await update.message.reply_text(
"📋 *Post an Open Contract*\n\n"
"Describe your opportunity in plain English. Include all relevant details:\n\n"
"💼 *Freelance project:* tech stack, deliverables, budget, timeline\n"
"📅 *Job / Interview:* role, company, skills needed, interview format\n"
"🛒 *Buy / Sell:* item description, condition, asking price, location\n"
"🤝 *Other:* anything two people need to agree on publicly\n\n"
"The more detail you give, the better your agent can match you!",
parse_mode="Markdown",
)
return AWAITING_CONTRACT_DESCRIPTION
async def receive_contract_description(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Extract structured requirements from the poster's description and store the contract."""
user = update.effective_user
user_message = update.message.text
await update.message.chat.send_action("typing")
# Always use the real PersonalAgent — mock mode is for the negotiation engine, not user input
requirements = await personal_agent.extract_preferences(user_message, user.id)
if "error" in requirements:
await update.message.reply_text(
"⚠️ I had trouble understanding that. Please add more detail and try again."
)
return AWAITING_CONTRACT_DESCRIPTION
feature_type = requirements.get("feature_type", "generic")
goal = requirements.get("goal", "Open opportunity")
# Build a short title from the goal
title = goal[:80] if len(goal) <= 80 else goal[:77] + "..."
await db.create_user(user.id, user.username, user.first_name)
contract_id = await db.create_open_contract(
poster_id = user.id,
contract_type = feature_type,
title = title,
description = user_message,
requirements = requirements,
)
type_label = _CONTRACT_TYPE_LABELS.get(feature_type, "🤝 General")
await update.message.reply_text(
f"✅ *Contract Posted!*\n\n"
f"📋 Type: {type_label}\n"
f"🎯 Goal: _{goal}_\n"
f"🆔 Contract ID: `{contract_id}`\n\n"
f"Anyone can now apply via */open_opportunities*.\n"
f"Use */mycontracts* to review applicants and pick the best match.\n\n"
f"_Your agent will score every applicant automatically before you decide!_ 🤖",
parse_mode="Markdown",
)
return ConversationHandler.END
# ─── /open_opportunities — Seeker browses open contracts ─────────────────────
# How many contracts to show per page
_PAGE_SIZE = 6
async def open_opportunities_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show a paginated list of open contracts."""
await _show_opportunities_page(update, context, page=0)
async def _show_opportunities_page(update, context, page: int = 0):
contracts = await db.get_open_contracts(status="open")
if not contracts:
text = (
"📭 *No open contracts right now.*\n\n"
"Be the first to post one with /opencontract!"
)
if update.callback_query:
await update.callback_query.edit_message_text(text, parse_mode="Markdown")
else:
await update.message.reply_text(text, parse_mode="Markdown")
return
total = len(contracts)
total_pages = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE)
page = max(0, min(page, total_pages - 1))
start = page * _PAGE_SIZE
page_contracts = contracts[start : start + _PAGE_SIZE]
buttons = []
for c in page_contracts:
type_icon = _CONTRACT_TYPE_LABELS.get(c["contract_type"], "🤝").split()[0]
apps = c.get("application_count", 0)
poster = c.get("poster_username") or c.get("poster_name") or "Anonymous"
label = f"{type_icon} {c['title'][:35]} | {apps} app(s) | @{poster}"
buttons.append([InlineKeyboardButton(label, callback_data=f"contract_select_{c['id']}")])
# Pagination row
nav = []
if page > 0:
nav.append(InlineKeyboardButton("⬅️ Prev", callback_data=f"opps_page_{page - 1}"))
if page < total_pages - 1:
nav.append(InlineKeyboardButton("Next ➡️", callback_data=f"opps_page_{page + 1}"))
if nav:
buttons.append(nav)
keyboard = InlineKeyboardMarkup(buttons)
text = (
f"🏪 *Open Contracts* ({total} available — page {page + 1}/{total_pages})\n\n"
"Tap any contract to see details and apply."
)
if update.callback_query:
await update.callback_query.edit_message_text(
text, reply_markup=keyboard, parse_mode="Markdown"
)
else:
await update.message.reply_text(
text, reply_markup=keyboard, parse_mode="Markdown"
)
async def opportunities_page_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle pagination buttons."""
query = update.callback_query
await query.answer()
page = int(query.data.split("_")[-1])
await _show_opportunities_page(update, context, page=page)
async def contract_selected_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show full contract details and an Apply button."""
query = update.callback_query
await query.answer()
try:
contract_id = query.data.replace("contract_select_", "")
contract = await db.get_open_contract(contract_id)
if not contract:
await query.edit_message_text("⚠️ Contract not found or no longer available.")
return
reqs = contract.get("requirements") or {}
goal = reqs.get("goal", contract["title"])
constraints = reqs.get("constraints", [])
poster = contract.get("poster_username") or contract.get("poster_name") or "Anonymous"
type_label = _CONTRACT_TYPE_LABELS.get(contract["contract_type"], "🤝 General")
apps = await db.get_applications(contract_id)
# Build constraint summary (plain text — no Markdown, user content could break it)
constraint_lines = [
f"{c.get('description', c.get('value', ''))}"
for c in constraints[:4] if c.get("hard")
]
constraints_text = "\n".join(constraint_lines) if constraint_lines else " • (none specified)"
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("🚀 Apply to This Contract", callback_data=f"contract_apply_{contract_id}")],
[InlineKeyboardButton("⬅️ Back to List", callback_data="opps_page_0")],
])
await query.edit_message_text(
f"📋 {contract['title']}\n"
f"{'' * 30}\n"
f"📌 Type: {type_label}\n"
f"👤 Posted by: @{poster}\n"
f"📬 Applications: {len(apps)}\n\n"
f"🎯 What they need:\n{goal}\n\n"
f"⛔ Hard requirements:\n{constraints_text}\n\n"
f"🆔 Contract ID: {contract_id}\n\n"
f"Tap Apply to submit your preferences and get a match score!",
reply_markup=keyboard,
)
except Exception as e:
print(f"[contract_selected_callback] error: {e}")
await query.edit_message_text(f"⚠️ Error loading contract: {e}")
async def contract_apply_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""User taps Apply — prompt them for their preferences."""
query = update.callback_query
await query.answer()
try:
contract_id = query.data.replace("contract_apply_", "")
contract = await db.get_open_contract(contract_id)
if not contract:
await query.edit_message_text("⚠️ Contract not found or no longer available.")
return
# Check if they're the poster
if contract["poster_id"] == query.from_user.id:
await query.answer("⚠️ You can't apply to your own contract!", show_alert=True)
return
# Check for duplicate application
existing_apps = await db.get_applications(contract_id)
if any(a["applicant_id"] == query.from_user.id for a in existing_apps):
await query.answer("You've already applied to this contract.", show_alert=True)
return
context.user_data["applying_contract_id"] = contract_id
context.user_data["applying_contract_type"] = contract["contract_type"]
context.user_data["awaiting_contract_application"] = True
hints = {
"freelance": "your skills, hourly rate or project rate, availability, and relevant experience",
"scheduling": "your availability windows, timezone, and preferred meeting format",
"marketplace": "your offer price, pickup preference, and any conditions",
"generic": "your goals, constraints, and what you bring to the table",
}
hint = hints.get(contract["contract_type"], hints["generic"])
await query.edit_message_text(
f"📝 Applying to: {contract['title'][:60]}\n"
f"{'' * 30}\n\n"
f"Tell me your side in plain English.\n"
f"Include: {hint}\n\n"
f"Your agent will score your match automatically.",
)
return AWAITING_CONTRACT_APPLICATION
except Exception as e:
print(f"[contract_apply_callback] error: {e}")
await query.edit_message_text(f"⚠️ Error: {e}")
async def receive_contract_application(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Receive applicant's preferences + store + fire async scoring."""
if not context.user_data.get("awaiting_contract_application"):
return
user = update.effective_user
user_message = update.message.text
contract_id = context.user_data.get("applying_contract_id")
contract_type = context.user_data.get("applying_contract_type", "generic")
if not contract_id:
await update.message.reply_text("⚠️ Session expired. Try /open_opportunities again.")
context.user_data.pop("awaiting_contract_application", None)
return
contract = await db.get_open_contract(contract_id)
if not contract or contract["status"] != "open":
await update.message.reply_text("⚠️ This contract is no longer open.")
context.user_data.pop("awaiting_contract_application", None)
return
await update.message.chat.send_action("typing")
# Always use the real PersonalAgent for contract applications
preferences = await personal_agent.extract_preferences(user_message, user.id)
if "error" in preferences:
await update.message.reply_text(
"⚠️ Couldn't parse your preferences. Please try again with more detail."
)
return AWAITING_CONTRACT_APPLICATION
await db.create_user(user.id, user.username, user.first_name)
app_id = await db.add_application(contract_id, user.id, preferences)
# Confirm immediately, then score in background
await update.message.reply_text(
f"✅ *Application submitted!*\n\n"
f"📋 Contract: `{contract_id}`\n"
f"🤖 Your agent is computing your match score...\n\n"
f"_The poster will see ranked applicants in /mycontracts._\n"
f"_If selected, you'll receive a Telegram message to start negotiation._",
parse_mode="Markdown",
)
# Fire background scoring
asyncio.create_task(
_score_and_notify(
app_id = app_id,
contract_id = contract_id,
contract_type = contract_type,
requirements = contract.get("requirements") or {},
preferences = preferences,
applicant_id = user.id,
)
)
context.user_data.pop("awaiting_contract_application", None)
context.user_data.pop("applying_contract_id", None)
context.user_data.pop("applying_contract_type", None)
return ConversationHandler.END
async def _score_and_notify(
app_id: int,
contract_id: str,
contract_type: str,
requirements: dict,
preferences: dict,
applicant_id: int,
):
"""Background task: AI-score the application, then notify the applicant."""
try:
result = await ai_score_applicant(requirements, preferences, contract_type)
score = result.get("match_score", 0)
reasoning = result.get("match_reasoning", "")
await db.update_application_match_score(app_id, score, reasoning)
# Score badge
if score >= 80:
badge = "🟢 Strong match"
elif score >= 55:
badge = "🟡 Moderate match"
else:
badge = "🔴 Weak match"
alignments = result.get("key_alignments", [])
gaps = result.get("key_gaps", [])
align_text = "\n".join(f"{a}" for a in alignments[:3]) or " (none)"
gap_text = "\n".join(f" ⚠️ {g}" for g in gaps[:3]) or " (none)"
await send_to_user(
applicant_id,
f"📊 Match Score: {score}/100 {badge}\n\n"
f"{reasoning}\n\n"
f"Alignments:\n{align_text}\n\n"
f"Gaps:\n{gap_text}\n\n"
f"Contract: {contract_id}\n"
f"If the poster selects you, your agents will negotiate automatically!",
parse_mode=None,
)
except Exception as e:
print(f"[_score_and_notify] failed for app {app_id}: {e}")
# ─── /mycontracts — Poster reviews + picks applicant ─────────────────────────
async def mycontracts_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show the poster all their contracts with application counts."""
user = update.effective_user
contracts = await db.get_contracts_by_poster(user.id)
if not contracts:
await update.message.reply_text(
"📭 *You have no posted contracts.*\n\n"
"Post one with /opencontract!",
parse_mode="Markdown",
)
return
status_icons = {
"open": "🟢", "negotiating": "🔵", "resolved": "", "closed": ""
}
buttons = []
for c in contracts:
icon = status_icons.get(c["status"], "")
apps = c.get("application_count", 0)
best = c.get("best_score") or 0
label = f"{icon} {c['title'][:30]} | {apps} apps | top {int(best)}/100"
buttons.append([InlineKeyboardButton(label, callback_data=f"mycontract_{c['id']}")])
await update.message.reply_text(
f"📋 *Your Contracts* ({len(contracts)} total)\n\n"
"Tap a contract to review applicants and start negotiation.",
reply_markup=InlineKeyboardMarkup(buttons),
parse_mode="Markdown",
)
async def mycontract_detail_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show ranked applicants for a specific contract with action buttons."""
query = update.callback_query
await query.answer()
try:
contract_id = query.data.replace("mycontract_", "")
contract = await db.get_open_contract(contract_id)
if not contract or contract["poster_id"] != query.from_user.id:
await query.edit_message_text("⚠️ Contract not found or you don't own it.")
return
apps = await db.get_applications(contract_id) # already sorted by match_score DESC
if not apps:
await query.edit_message_text(
f"📋 {contract['title'][:60]}\n"
f"{'' * 30}\n\n"
f"📭 No applications yet.\n"
f"Contract ID: {contract_id} | Status: {contract['status']}\n"
f"Share it so others can apply via /open_opportunities.",
)
return
# Build applicant summary lines
lines = []
for i, a in enumerate(apps[:8], 1):
name = a.get("username") or a.get("display_name") or f"User {a['applicant_id']}"
score = int(a.get("match_score") or 0)
lines.append(f"{i}. @{name}{score}/100")
applicants_text = "\n".join(lines)
buttons = [
[InlineKeyboardButton("🤖 Auto-pick Best Match", callback_data=f"autopick_{contract_id}")],
[InlineKeyboardButton("📋 Select Manually", callback_data=f"manualselect_{contract_id}")],
[InlineKeyboardButton("⬅️ Back", callback_data="mycontracts_back")],
]
if contract["status"] != "open":
buttons = [[InlineKeyboardButton("⬅️ Back", callback_data="mycontracts_back")]]
await query.edit_message_text(
f"📋 {contract['title'][:60]}\n"
f"{'' * 30}\n\n"
f"📬 {len(apps)} Applicant(s) — ranked by AI match score:\n\n"
f"{applicants_text}\n\n"
f"How do you want to select who to negotiate with?",
reply_markup=InlineKeyboardMarkup(buttons),
)
except Exception as e:
print(f"[mycontract_detail_callback] error: {e}")
await query.edit_message_text(f"⚠️ Error loading contract details: {e}")
async def mycontracts_back_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Navigate back to mycontracts list."""
query = update.callback_query
await query.answer()
await query.message.delete()
await mycontracts_command(update, context)
async def autopick_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Auto-select the highest-scored applicant and start negotiation."""
query = update.callback_query
await query.answer()
contract_id = query.data.replace("autopick_", "")
contract = await db.get_open_contract(contract_id)
if not contract or contract["poster_id"] != query.from_user.id:
await query.edit_message_text("⚠️ Contract not found or you don't own it.")
return
apps = await db.get_applications(contract_id)
if not apps:
await query.edit_message_text("⚠️ No applicants yet.")
return
best = apps[0] # already sorted by match_score DESC
name = best.get("username") or best.get("display_name") or f"User {best['applicant_id']}"
await query.edit_message_text(
f"🤖 Auto-picked best match: @{name} ({int(best.get('match_score', 0))}/100)\n\n"
f"Starting negotiation..."
)
await _start_open_contract_negotiation(contract_id, best["applicant_id"], query.from_user.id)
async def manual_select_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show individual applicants as selectable buttons."""
query = update.callback_query
await query.answer()
contract_id = query.data.replace("manualselect_", "")
apps = await db.get_applications(contract_id)
if not apps:
await query.edit_message_text("⚠️ No applicants yet.")
return
buttons = []
for a in apps[:8]:
name = a.get("username") or a.get("display_name") or f"User {a['applicant_id']}"
score = int(a.get("match_score") or 0)
reasoning = (a.get("match_reasoning") or "")[:60]
label = f"@{name}{score}/100 — {reasoning}"
buttons.append([InlineKeyboardButton(
label[:64], # Telegram button label max 64 chars
callback_data=f"selectapp_{contract_id}_{a['applicant_id']}"
)])
buttons.append([InlineKeyboardButton("⬅️ Back", callback_data=f"mycontract_{contract_id}")])
await query.edit_message_text(
"📋 *Select an Applicant to Negotiate With:*\n\n"
"(Ranked by AI match score — highest first)",
reply_markup=InlineKeyboardMarkup(buttons),
parse_mode="Markdown",
)
async def select_applicant_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Poster manually selects a specific applicant."""
query = update.callback_query
await query.answer()
_, contract_id, applicant_id_str = query.data.split("_", 2)
applicant_id = int(applicant_id_str)
contract = await db.get_open_contract(contract_id)
if not contract or contract["poster_id"] != query.from_user.id:
await query.edit_message_text("⚠️ Contract not found or you don't own it.")
return
await query.edit_message_text(
f"✅ *Selected applicant {applicant_id}*\n\n_Starting negotiation..._",
parse_mode="Markdown",
)
await _start_open_contract_negotiation(contract_id, applicant_id, query.from_user.id)
async def _start_open_contract_negotiation(
contract_id: str,
applicant_id: int,
poster_id: int,
):
"""
Load preferences from DB, create a negotiation, and kick off the engine.
Poster = user_a (made the contract = their requirements).
Applicant = user_b (their application preferences).
"""
contract = await db.get_open_contract(contract_id)
if not contract:
await send_to_user(poster_id, f"⚠️ Contract `{contract_id}` not found.")
return
# Load preferences
preferences_a = contract.get("requirements") or {}
apps = await db.get_applications(contract_id)
app = next((a for a in apps if a["applicant_id"] == applicant_id), None)
if not app:
await send_to_user(poster_id, "⚠️ Could not find that applicant's data.")
return
preferences_b = app.get("preferences") or {}
feature_type = contract.get("contract_type", "generic")
# Get personalities
poster_row = await db.get_user(poster_id)
applicant_row = await db.get_user(applicant_id)
personality_a = dict(poster_row).get("personality", "balanced") if poster_row else "balanced"
personality_b = dict(applicant_row).get("personality", "balanced") if applicant_row else "balanced"
# Inject human names
preferences_a["human_name"] = (
(dict(poster_row).get("display_name") or dict(poster_row).get("username") or "Poster")
if poster_row else "Poster"
)
preferences_b["human_name"] = (
(dict(applicant_row).get("display_name") or dict(applicant_row).get("username") or "Applicant")
if applicant_row else "Applicant"
)
# Create a standard negotiation in DB
neg_id = await db.create_negotiation(feature_type, poster_id)
await db.add_participant(neg_id, poster_id, preferences_a, personality_used=personality_a)
await db.add_participant(neg_id, applicant_id, preferences_b, personality_used=personality_b)
# Link contract → negotiation
await db.claim_contract(contract_id, applicant_id, neg_id)
applicant_name = preferences_b["human_name"]
poster_name = preferences_a["human_name"]
# Notify both parties that negotiation is starting
await send_to_user(
poster_id,
f"🤖↔️🤖 *Negotiation starting!*\n\n"
f"Your agent is negotiating with *{applicant_name}* on contract `{contract_id}`.\n"
f"Negotiation ID: `{neg_id}`\n\n"
f"_Stand by for real-time updates!_",
)
await send_to_user(
applicant_id,
f"🎉 *You've been selected!*\n\n"
f"*{poster_name}* picked your application on contract `{contract_id}`.\n"
f"Your agent is starting negotiation now...\n"
f"Negotiation ID: `{neg_id}`\n\n"
f"_Stand by for real-time updates!_ 🤖↔️🤖",
)
# Run negotiation using the existing engine
asyncio.create_task(
run_negotiation_with_telegram_updates(
negotiation_id = neg_id,
preferences_a = preferences_a,
preferences_b = preferences_b,
user_a_id = poster_id,
user_b_id = applicant_id,
feature_type = feature_type,
personality_a = personality_a,
personality_b = personality_b,
)
)
# Mark contract as resolved after negotiation task is queued
# (actual status update to 'resolved' happens via close_contract call in on_resolution)
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"🤖 *negoT8 — All Commands*\n"
"━━━━━━━━━━━━━━━━━━━━━━\n\n"
"⚙️ *Setup*\n"
"/start — Register & see a welcome message\n"
"/personality — Choose your agent's negotiation style\n"
" _(Aggressive · People Pleaser · Analytical · Empathetic · Balanced)_\n"
"/connectcalendar — Link your Google Calendar so your agent knows your free slots automatically\n\n"
"🤝 *Negotiation*\n"
"/coordinate @username — Kick off a negotiation with someone\n"
" _Then describe what you need in plain English_\n"
"/pending — See & accept incoming coordination requests from others\n\n"
"📋 *Marketplace (Open Contracts)*\n"
"/opencontract — Post a public job, project, or buy-sell opportunity\n"
"/open\\_opportunities — Browse all open contracts and apply to one\n"
"/mycontracts — View contracts you've posted, see applicants, and pick the best one\n\n"
"🔗 *Blockchain & Proof*\n"
"/proof — Show blockchain proof for your latest resolved negotiation\n"
"/proof <id> — Show proof for a specific negotiation ID\n"
" _Every resolved deal is permanently recorded on Polygon Amoy_\n\n"
" *Other*\n"
"/help — Show this command reference\n\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"💡 *Quick start:*\n"
"1. /personality → pick your style\n"
"2. `/coordinate @friend` → describe what you need\n"
"3. Your agents negotiate — you get a resolution + payment link + proof ✅\n\n"
"_Works for: scheduling · expenses · trips · buying/selling · roommates · conflicts · and more!_",
parse_mode="Markdown"
)
# ─── /proof command — Retrieve blockchain proof for a negotiation ─────────────
async def proof_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/proof [negotiation_id]
Shows the blockchain proof for a negotiation.
If no ID given, uses the user's most recent resolved negotiation.
"""
user = update.effective_user
if context.args:
neg_id = context.args[0].strip()
else:
neg_id = await db.get_latest_negotiation_for_user(user.id)
if not neg_id:
await update.message.reply_text(
"❓ *No negotiation found.*\n\n"
"Usage: /proof <negotiation\\_id>\n"
"The ID is shown after every negotiation resolves.\n\n"
"Example: /proof aa271ee7",
parse_mode="Markdown"
)
return
await update.message.chat.send_action("typing")
proof = await db.get_blockchain_proof(neg_id)
if not proof:
# Check if the negotiation itself exists (wrong ID vs not-yet-resolved)
neg = await db.get_negotiation(neg_id)
if not neg:
await update.message.reply_text(
f"❌ *Negotiation* `{neg_id}` *not found.*\n\n"
"Double-check the ID from your negotiation summary.",
parse_mode="Markdown"
)
else:
status = dict(neg).get("status", "unknown")
await update.message.reply_text(
f"⏳ *No blockchain proof yet* for `{neg_id}`\n\n"
f"Negotiation status: *{status}*\n\n"
"The proof is registered automatically when the negotiation resolves.\n"
"If it just finished, wait a few seconds and try again.",
parse_mode="Markdown"
)
return
tx = proof.get("tx_hash", "")
blk = proof.get("block_number", 0)
ahash = str(proof.get("agreement_hash", ""))
url = proof.get("explorer_url", "")
gas = proof.get("gas_used", 0)
is_mock = "MOCK" in tx.upper() or "FAILED" in tx.upper() or blk == 0
# Truncate for display
short_tx = (tx[:12] + "..." + tx[-8:]) if len(tx) > 22 else tx
short_hash = (ahash[:18] + "...") if len(ahash) > 20 else ahash
if is_mock:
# Mock/fallback proof — still show it, but clearly labelled
await update.message.reply_text(
f"🔗 *Agreement Proof* — `{neg_id}`\n\n"
f"🌐 Network: Polygon Amoy\n"
f"🔐 Agreement Hash: `{short_hash}`\n\n"
f"⚠️ _On-chain TX pending or unavailable._\n"
f"The agreement data is stored securely.\n\n"
f"_Hash uniquely identifies your agreement content._",
parse_mode="Markdown"
)
return
# Real on-chain proof
keyboard = None
if url and url.startswith("http"):
keyboard = InlineKeyboardMarkup([[
InlineKeyboardButton("🔍 View on PolygonScan", url=url)
]])
await update.message.reply_text(
f"🔗 *Blockchain Proof of Agreement*\n\n"
f"🏷 Negotiation: `{neg_id}`\n"
f"🌐 Network: *Polygon Amoy*\n"
f"⛓ Block: `{blk}`\n"
f"📜 TX: `{short_tx}`\n"
f"🔐 Hash: `{short_hash}`\n"
f"⛽ Gas Used: `{gas:,}`\n\n"
f"This agreement is *permanently and immutably* recorded on "
f"the Polygon blockchain — neither party can alter or delete it.",
reply_markup=keyboard,
parse_mode="Markdown"
)
async def _set_bot_commands(app: Application) -> None:
"""Register all commands in Telegram's bot-menu (the '/' popup)."""
await app.bot.set_my_commands([
BotCommand("start", "Register & see welcome message"),
BotCommand("help", "Show all available commands"),
BotCommand("personality", "Choose your agent's negotiation style"),
BotCommand("connectcalendar", "Link your Google Calendar"),
BotCommand("coordinate", "Start a negotiation with @username"),
BotCommand("pending", "View & accept incoming coordination requests"),
BotCommand("opencontract", "Post a public job / buy-sell opportunity"),
BotCommand("open_opportunities", "Browse & apply to open contracts"),
BotCommand("mycontracts", "View your contracts & pick the best applicant"),
BotCommand("proof", "Get blockchain proof for a negotiation"),
])
def create_bot(token: str) -> Application:
"""Create a bot application with all handlers."""
app = Application.builder().token(token).post_init(_set_bot_commands).build()
# Register this app so send_to_user() can reach users on either bot
register_bot(token, app)
# ── Initiator flow (User A): /coordinate → preferences ──
initiator_conv = ConversationHandler(
entry_points=[CommandHandler("coordinate", coordinate_command)],
states={
AWAITING_PREFERENCES: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_preferences)
]
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)],
name="initiator_conv",
per_user=True,
)
# ── Counterparty flow (User B): Accept inline button → preferences ──
# per_message=False (default) + per_chat=True so the ConversationHandler tracks
# state per-user across any chat, which is correct for CallbackQueryHandler
# entry_points (suppresses the PTBUserWarning about per_message).
counterparty_conv = ConversationHandler(
entry_points=[
CallbackQueryHandler(accept_pending_callback, pattern=r"^accept_"),
CallbackQueryHandler(accept_pending_callback, pattern=r"^decline_"),
],
states={
# After accepting, User B's next plain-text message is their preferences
AWAITING_COUNTERPARTY_PREFS: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_counterparty_preferences)
]
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)],
name="counterparty_conv",
per_user=True,
per_chat=False,
per_message=False,
)
app.add_handler(CommandHandler("start", start_command))
app.add_handler(CommandHandler("personality", personality_command))
app.add_handler(CommandHandler("pending", pending_command))
app.add_handler(CommandHandler("connectcalendar", connect_calendar_command))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(CommandHandler("proof", proof_command))
# Open contracts marketplace — simple command handlers (no conversation needed)
app.add_handler(CommandHandler("open_opportunities", open_opportunities_command))
app.add_handler(CommandHandler("mycontracts", mycontracts_command))
app.add_handler(CallbackQueryHandler(personality_callback, pattern=r"^personality_"))
app.add_handler(CallbackQueryHandler(opportunities_page_callback, pattern=r"^opps_page_"))
app.add_handler(CallbackQueryHandler(contract_selected_callback, pattern=r"^contract_select_"))
app.add_handler(CallbackQueryHandler(mycontract_detail_callback, pattern=r"^mycontract_[0-9a-f]"))
app.add_handler(CallbackQueryHandler(mycontracts_back_callback, pattern=r"^mycontracts_back$"))
app.add_handler(CallbackQueryHandler(autopick_callback, pattern=r"^autopick_"))
app.add_handler(CallbackQueryHandler(manual_select_callback, pattern=r"^manualselect_"))
app.add_handler(CallbackQueryHandler(select_applicant_callback, pattern=r"^selectapp_"))
app.add_handler(initiator_conv)
app.add_handler(counterparty_conv)
# ── Open Contract poster ConversationHandler ──
opencontract_conv = ConversationHandler(
entry_points=[CommandHandler("opencontract", opencontract_command)],
states={
AWAITING_CONTRACT_DESCRIPTION: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_contract_description)
]
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)],
name="opencontract_conv",
per_user=True,
)
# ── Contract application ConversationHandler (seeker) ──
contract_apply_conv = ConversationHandler(
entry_points=[CallbackQueryHandler(contract_apply_callback, pattern=r"^contract_apply_")],
states={
AWAITING_CONTRACT_APPLICATION: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_contract_application)
]
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)],
name="contract_apply_conv",
per_user=True,
per_chat=False,
per_message=False,
)
app.add_handler(opencontract_conv)
app.add_handler(contract_apply_conv)
# Fallback: catch User B's preference text when NOT inside a ConversationHandler
# (e.g., they tapped Accept but the conv state was lost after a bot restart)
app.add_handler(
MessageHandler(
filters.TEXT & ~filters.COMMAND,
receive_counterparty_preferences
)
)
return app