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_ 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 — 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 \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