mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
1719 lines
73 KiB
Python
1719 lines
73 KiB
Python
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
|